fake-hermes.mjs 39 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536
  1. import { createServer } from 'node:http'
  2. import { readFileSync } from 'node:fs'
  3. import WebSocket, { WebSocketServer } from 'ws'
  4. const port = Number(process.env.FAKE_HERMES_PORT || 19119)
  5. const localAuthorization = process.env.FAKE_HERMES_LOCAL_AUTH === '1'
  6. const localToken = 'fixture_native_loopback_session_token_123456'
  7. const epoch = '11111111-1111-4111-8111-111111111111'
  8. const roomId = '22222222-2222-4222-8222-222222222222'
  9. const agentId = '33333333-3333-4333-8333-333333333333'
  10. const secondAgentId = '34343434-3434-4434-8434-343434343434'
  11. const topicId = '77777777-7777-4777-8777-777777777777'
  12. const releaseTopicId = '88888888-8888-4888-8888-888888888888'
  13. const now = () => Date.now() / 1000
  14. let groupCursor = 0
  15. const groupClients = new Set()
  16. let groupConnectionCount = 0
  17. let groupAvailable = true
  18. const kanbanColumns = ['triage', 'todo', 'scheduled', 'ready', 'running', 'blocked', 'review', 'done']
  19. const kanbanBoards = [
  20. { slug: 'default', name: '产品研发', description: 'Web 与移动端功能', is_current: true, default_workspace_kind: 'scratch' },
  21. { slug: 'mobile-release', name: '移动端发布', description: 'iOS 与 Android 发布任务', is_current: false, default_workspace_kind: 'worktree' },
  22. ]
  23. let kanbanTaskSequence = 3
  24. let kanbanEventSequence = 3
  25. const kanbanTasks = [
  26. { id: 't_web001', board: 'default', title: '完成 Web 看板验收', body: '覆盖桌面与 390px 响应式布局', status: 'running', assignee: 'yaoyao', tenant: 'web', priority: 3, created_by: 'dashboard', created_at: now() - 1800, started_at: now() - 420, latest_summary: '正在执行浏览器验收', comment_count: 1, link_counts: { parents: 0, children: 1 }, progress: { done: 0, total: 1 } },
  27. { id: 't_ios001', board: 'default', title: '核对 iOS 双端口', body: '9119 与 15300 使用同一契约', status: 'ready', assignee: 'yaoer', tenant: 'mobile', priority: 2, created_by: 'dashboard', created_at: now() - 1200, comment_count: 0, link_counts: { parents: 1, children: 0 }, progress: null },
  28. { id: 't_done01', board: 'mobile-release', title: '准备发布说明', body: '整理已完成事项', status: 'done', assignee: 'yaoyao', tenant: 'release', priority: 1, created_by: 'dashboard', created_at: now() - 7200, completed_at: now() - 3600, latest_summary: '发布说明已完成', comment_count: 0, link_counts: { parents: 0, children: 0 }, progress: null },
  29. ]
  30. const kanbanComments = new Map([
  31. ['t_web001', [{ id: 1, task_id: 't_web001', author: 'admin', body: '请保留真实浏览器验收证据。', created_at: now() - 600 }]],
  32. ])
  33. const kanbanEvents = new Map([
  34. ['t_web001', [
  35. { id: 1, task_id: 't_web001', run_id: null, kind: 'created', payload: { status: 'ready', assignee: 'yaoyao' }, created_at: now() - 1800 },
  36. { id: 2, task_id: 't_web001', run_id: 1, kind: 'claimed', payload: { profile: 'yaoyao' }, created_at: now() - 420 },
  37. ]],
  38. ['t_ios001', [{ id: 3, task_id: 't_ios001', run_id: null, kind: 'created', payload: { status: 'ready', assignee: 'yaoer' }, created_at: now() - 1200 }]],
  39. ])
  40. const kanbanRuns = new Map([
  41. ['t_web001', [{ id: 1, task_id: 't_web001', profile: 'yaoyao', status: 'running', outcome: null, summary: '正在执行浏览器验收', metadata: { verification: ['playwright'] }, worker_pid: 12345, started_at: now() - 420, ended_at: null }]],
  42. ])
  43. function kanbanBoardSlug(url) {
  44. return url.searchParams.get('board') || 'default'
  45. }
  46. function kanbanBoardSnapshot(url) {
  47. const slug = kanbanBoardSlug(url)
  48. const includeArchived = url.searchParams.get('include_archived') === 'true'
  49. const scoped = kanbanTasks.filter(task => task.board === slug && (includeArchived || task.status !== 'archived'))
  50. const columns = [...kanbanColumns, ...(includeArchived ? ['archived'] : [])]
  51. .map(name => ({ name, tasks: scoped.filter(task => task.status === name) }))
  52. return {
  53. columns,
  54. tenants: [...new Set(scoped.map(task => task.tenant).filter(Boolean))].sort(),
  55. assignees: [...new Set(scoped.map(task => task.assignee).filter(Boolean))].sort(),
  56. latest_event_id: kanbanEventSequence,
  57. now: now(),
  58. }
  59. }
  60. function recordKanbanEvent(task, kind, payload = null, runId = null) {
  61. const event = { id: ++kanbanEventSequence, task_id: task.id, run_id: runId, kind, payload, created_at: now() }
  62. kanbanEvents.set(task.id, [...(kanbanEvents.get(task.id) || []), event])
  63. return event
  64. }
  65. function broadcastGroup(event, payload) {
  66. groupCursor += 1
  67. const frame = JSON.stringify({ type: 'group.event', epoch, cursor: groupCursor, roomId, event, payload })
  68. for (const client of groupClients) if (client.readyState === WebSocket.OPEN) client.send(frame)
  69. }
  70. function authenticated(request) {
  71. if (localAuthorization) return request.headers['x-hermes-session-token'] === localToken
  72. return String(request.headers.cookie || '').includes('fake_session=authenticated')
  73. }
  74. function json(response, status, value, headers = {}) {
  75. const body = Buffer.from(JSON.stringify(value))
  76. response.writeHead(status, {
  77. 'Content-Type': 'application/json',
  78. 'Content-Length': body.length,
  79. ...headers,
  80. })
  81. response.end(body)
  82. }
  83. async function body(request) {
  84. const chunks = []
  85. for await (const chunk of request) chunks.push(Buffer.from(chunk))
  86. if (!chunks.length) return {}
  87. return JSON.parse(Buffer.concat(chunks).toString('utf8'))
  88. }
  89. const profiles = [
  90. { name: 'yaoyao', description: '夭夭', is_default: true, model: 'gpt-5.6', provider: 'openai', gateway_running: true },
  91. { name: 'yaoer', description: '瑶儿', is_default: false, model: 'gpt-5.6', provider: 'openai', gateway_running: true },
  92. ]
  93. // This intentionally differs from local timestamp/pin ranking. Browser tests
  94. // verify that the WebUI preserves the ordering returned by 9119.
  95. const sessions = [
  96. // `source` is mutable display metadata, not ownership proof. This row is
  97. // intentionally absent from the Web user's registry and must stay history-only.
  98. { id: 'session-history-only', profile: 'yaoyao', source: 'web', title: 'Hermes 外部历史', preview: '只读历史记录', message_count: 1, tool_call_count: 0, started_at: now() - 1800, last_active_at: now() - 900, model: 'gpt-5.6', provider: 'openai' },
  99. { id: 'session-second', profile: 'yaoyao', source: 'web', title: '第二个会话', preview: '用于验证列表和切换', message_count: 2, tool_call_count: 0, started_at: now() - 7200, last_active_at: now() - 600, model: 'gpt-5.5', provider: 'openai' },
  100. { id: 'session-demo', profile: 'yaoyao', source: 'web', title: '夭夭 AI 验收会话', preview: '文件库与群聊已经就绪', message_count: 4, tool_call_count: 1, started_at: now() - 3600, last_active_at: now(), pinned: true, model: 'gpt-5.6', provider: 'openai' },
  101. { id: 'session-yaoer', profile: 'yaoer', source: 'web', title: '瑶儿专属会话', message_count: 1, tool_call_count: 0, started_at: now() - 3500, last_active_at: now() - 20, model: 'gpt-5.6', provider: 'openai' },
  102. { id: 'session-media', profile: 'yaoer', source: 'web', title: '瑶儿生成图片验收', message_count: 1, tool_call_count: 0, started_at: now() - 3400, last_active_at: now() - 30, model: 'gpt-5.6', provider: 'openai' },
  103. { id: 'session-user-media', profile: 'yaoer', source: 'web', title: '用户多图验收', message_count: 1, tool_call_count: 0, started_at: now() - 3300, last_active_at: now() - 40, model: 'gpt-5.6', provider: 'openai' },
  104. ...Array.from({ length: 101 }, (_, index) => ({ id: `session-page-${index + 1}`, profile: 'yaoyao', source: 'web', title: `分页会话 ${index + 1}`, message_count: 0, tool_call_count: 0, started_at: now() - 10_000 - index, last_active_at: now() - 10_000 - index })),
  105. ]
  106. const messages = [
  107. { id: 'message-user', role: 'user', content: '请检查今天生成的产物。MEDIA:/brand/AppIcon-1024.png', timestamp: now() - 180 },
  108. { id: 'message-user-file', role: 'user', content: '查看附件\n\n[用户附加文件:测试报告.docx]\n@file:`attachments/测试报告.docx`', timestamp: now() - 179 },
  109. { id: 'message-user-image', role: 'user', content: '[用户附加图片:测试图片.png]\n@image:/Users/samien/.hermes/images/upload_test.png\n[screenshot]', timestamp: now() - 178 },
  110. { id: 'message-thinking-tool', role: 'assistant', content: '', reasoning_content: '先检索文件,再归纳结果。', tool_calls: [{ id: 'tool-thinking', type: 'function', function: { name: 'file_search', arguments: '{"query":"产物"}' } }], timestamp: now() - 177 },
  111. { id: 'message-tool-call', role: 'assistant', content: '', tool_calls: [{ id: 'tool-1', type: 'function', function: { name: 'file_search', arguments: '{"query":"产物"}' } }], timestamp: now() - 176 },
  112. { id: 'message-tool', role: 'tool', tool_name: 'file_search', tool_call_id: 'tool-1', summary: '扫描产物目录', result: { path: '/tmp/demo-report.pdf', count: 1 }, timestamp: now() - 175 },
  113. { id: 'message-assistant', role: 'assistant', content: '已整理完成。下面是 **验收摘要**:\n\n## 验收摘要\n\n- 普通聊天已连接\n- 群聊 v4 已就绪\n- [打开报告](https://example.com/report)', reasoning_content: '先核对会话与文件索引。', timestamp: now() - 170 },
  114. { id: 'message-image', role: 'assistant', content: '预览图:![夭夭 Logo](/brand/AppIcon-1024.png)', timestamp: now() - 160 },
  115. { id: 'message-image-second', role: 'assistant', content: '第二张:![夭夭 Logo 2](/brand/AppIcon-1024.png?variant=2)', timestamp: now() - 155 },
  116. { id: 'message-legacy-media', role: 'assistant', content: '历史兼容:MEDIA:/brand/AppIcon-1024.png', timestamp: now() - 150 },
  117. { id: 'message-local-link', role: 'assistant', content: '[方案草稿.md](/Users/samien/Agents/方案草稿.md)', timestamp: now() - 145 },
  118. { id: 'message-compaction', role: 'user', content: '[CONTEXT COMPACTION — REFERENCE ONLY]\n\n## Historical Task Snapshot\n\n- 已完成普通聊天验收\n- 保留当前会话上下文', timestamp: now() - 143 },
  119. { id: 'message-delegation', role: 'user', content: '[ASYNC DELEGATION BATCH COMPLETE]\n后台子任务已经完成。', display_kind: 'async_delegation_complete', display_metadata: { task_count: 2, completed_count: 2, failed_count: 0, duration_seconds: 71 }, timestamp: now() - 140 },
  120. { id: 'message-background-process', role: 'user', content: '[IMPORTANT: Background process proc_6be40e6c3864 exited (exit code 143, SIGTERM).\nCommand: ./run_mac.sh\nOutput:\nmodel loaded', timestamp: now() - 135 },
  121. { id: 'message-system', role: 'user', content: '[System: The active model for this chat has changed to gpt-5.6-terra via provider openai.]', timestamp: now() - 130 },
  122. ]
  123. // These are Hermes-owned paths, deliberately absent from the Web server's
  124. // filesystem. Only the authenticated upstream file API can serve their bytes.
  125. const profileMediaPaths = [
  126. '/Users/samien/.hermes/profiles/yaoer/cache/images/openai_codex_gpt-image-2-high_20260902_194820_1d225f08.png',
  127. '/Users/samien/.hermes/profiles/yaoer/cache/images/国漫三视图.png',
  128. ]
  129. const userProfileMediaPaths = Array.from({ length: 9 }, (_, index) =>
  130. `/Users/samien/.hermes/profiles/yaoer/images/upload_20260815_233834_${index + 1}.png`)
  131. const userProfileMediaMessages = [{
  132. id: 'message-user-profile-media', role: 'user', timestamp: now() - 40,
  133. content: [
  134. '读取图片中的提示词',
  135. ...userProfileMediaPaths.map((_, index) => `[用户附加图片:照片-${index + 1}.png]`),
  136. ...userProfileMediaPaths.map(path => `@image:${path}`),
  137. ...userProfileMediaPaths.map(() => '[screenshot]'),
  138. ].join('\n'),
  139. }]
  140. const profileMediaPng = readFileSync(new URL('../../public/brand/AppIcon-1024.png', import.meta.url))
  141. const profileMediaMessages = [{
  142. id: 'message-profile-media', role: 'assistant', timestamp: now() - 30,
  143. content: `已经生成两张图片:\n\n![3D 半写实女性四视图](${profileMediaPaths[0]})\n\n![国漫三视图](${profileMediaPaths[1]})`,
  144. }]
  145. const groupAgent = { id: agentId, roomId, profile: 'yaoyao', displayName: '夭夭', description: '主 Agent', enabled: true, replyWithoutMention: true, isHost: true, model: 'gpt-5.6', provider: 'openai', reasoningEffort: 'high', fastMode: true, status: 'idle', createdAt: now() - 2000, updatedAt: now() }
  146. const secondGroupAgent = { id: secondAgentId, roomId, profile: 'yaoer', nodeId: 'remote-node', nodeLabel: '远程节点', displayName: '瑶儿', description: '评审 Agent', enabled: true, replyWithoutMention: false, isHost: false, model: 'gpt-5.6', provider: 'openai', reasoningEffort: 'medium', fastMode: false, status: 'idle', createdAt: now() - 1900, updatedAt: now() }
  147. const groupAgents = [groupAgent, secondGroupAgent]
  148. const groupMessage = { seq: 1, id: '44444444-4444-4444-8444-444444444444', roomId, topicId, senderKind: 'human', senderId: 'demo-user', senderName: '验收用户', content: '大家好,检查一下群聊输入框。', status: 'completed', createdAt: now() - 120, updatedAt: now() - 120 }
  149. const groupAssistantMessage = { seq: 2, id: '55555555-5555-4555-8555-555555555555', roomId, topicId, senderKind: 'agent', senderId: agentId, senderName: '夭夭', content: '历史兼容:MEDIA:/brand/AppIcon-1024.png', status: 'completed', createdAt: now() - 100, updatedAt: now() - 100 }
  150. const releaseTopicMessage = { seq: 3, id: '99999999-9999-4999-8999-999999999999', roomId, topicId: releaseTopicId, senderKind: 'human', senderId: 'demo-user', senderName: '验收用户', content: '请核对发布话题的独立历史。', status: 'completed', createdAt: now() - 500, updatedAt: now() - 500 }
  151. const groupMessages = [groupMessage, groupAssistantMessage, releaseTopicMessage]
  152. const groupTopics = [
  153. { id: topicId, roomId, title: '设计验收', preview: groupAssistantMessage.content, messageCount: 2, unreadCount: 1, latestMessageSeq: 2, createdAt: now() - 120, updatedAt: now() - 100 },
  154. { id: releaseTopicId, roomId, title: '发布检查', preview: releaseTopicMessage.content, messageCount: 1, unreadCount: 0, latestMessageSeq: 3, createdAt: now() - 500, updatedAt: now() - 500 },
  155. ]
  156. let nextGroupSeq = 4
  157. const avatarMembers = groupAgents.map(agent => ({ profile: agent.profile, nodeId: 'local', displayName: agent.displayName }))
  158. const room = { id: roomId, name: '设计与工程协作', avatar: '', avatarMembers, cwd: '/tmp', createdAt: now() - 2000, updatedAt: now(), archived: false, agentCount: groupAgents.length, maxReplyRounds: -1, lastMessage: groupAssistantMessage, unreadCount: 1, activeRunCount: 0 }
  159. const rpcRequests = []
  160. const previewPdf = Buffer.from('JVBERi0xLjQKMSAwIG9iago8PC9UeXBlL0NhdGFsb2cvUGFnZXMgMiAwIFI+PgplbmRvYmoKMiAwIG9iago8PC9UeXBlL1BhZ2VzL0tpZHNbMyAwIFJdL0NvdW50IDE+PgplbmRvYmoKMyAwIG9iago8PC9UeXBlL1BhZ2UvUGFyZW50IDIgMCBSL01lZGlhQm94WzAgMCAyMDAgMjAwXT4+CmVuZG9iagp4cmVmCjAgNAowMDAwMDAwMDAwIDY1NTM1IGYgCjAwMDAwMDAwMTAgMDAwMDAgbiAKMDAwMDAwMDA1MyAwMDAwMCBuIAowMDAwMDAwMTA1IDAwMDAwIG4gCnRyYWlsZXIKPDwvU2l6ZSA0L1Jvb3QgMSAwIFI+PgpzdGFydHhyZWYKMTY2CiUlRU9G', 'base64')
  161. const server = createServer(async (request, response) => {
  162. const url = new URL(request.url || '/', `http://${request.headers.host}`)
  163. if (url.pathname === '/__test/group-connections' && request.method === 'GET') return json(response, 200, { count: groupConnectionCount })
  164. if (url.pathname === '/__test/groups/availability' && request.method === 'POST') {
  165. groupAvailable = (await body(request)).available !== false
  166. return json(response, 200, { available: groupAvailable })
  167. }
  168. if (url.pathname === '/__test/groups/disconnect' && request.method === 'POST') {
  169. for (const client of groupClients) client.terminate()
  170. return json(response, 200, { ok: true })
  171. }
  172. if (url.pathname === '/__test/rpc-requests' && request.method === 'GET') return json(response, 200, { requests: rpcRequests })
  173. if (url.pathname === '/__test/rpc-requests/reset' && request.method === 'POST') {
  174. rpcRequests.length = 0
  175. return json(response, 200, { ok: true })
  176. }
  177. if (localAuthorization && url.pathname === '/') {
  178. response.writeHead(200, { 'Content-Type': 'text/html' })
  179. return response.end(`<script>window.__HERMES_SESSION_TOKEN__=${JSON.stringify(localToken)};</script>`)
  180. }
  181. if (url.pathname === '/api/status') return json(response, 200, { version: 'test', overall: 'ok', auth_required: !localAuthorization, auth_providers: localAuthorization ? [] : ['basic'], gateway_running: true, gateway_state: 'running' })
  182. if (localAuthorization && ['/api/auth/providers', '/api/auth/ws-ticket', '/auth/password-login'].includes(url.pathname)) return json(response, 403, { detail: 'No account or ticket in local mode' })
  183. if (url.pathname === '/api/auth/providers') return json(response, 200, { providers: [{ name: 'basic', display_name: '账号密码', supports_password: true }] })
  184. if (url.pathname === '/auth/password-login' && request.method === 'POST') {
  185. const input = await body(request)
  186. if (input.username !== 'test' || input.password !== 'test') return json(response, 401, { detail: 'Invalid credentials' })
  187. return json(response, 200, { ok: true, next: '' }, { 'Set-Cookie': 'fake_session=authenticated; Path=/; HttpOnly; SameSite=Lax' })
  188. }
  189. if (url.pathname === '/auth/logout' && request.method === 'POST') return json(response, 200, { ok: true }, { 'Set-Cookie': 'fake_session=; Path=/; Max-Age=0; HttpOnly; SameSite=Lax' })
  190. if (!authenticated(request)) return json(response, 401, { detail: 'Unauthorized' })
  191. if (url.pathname === '/api/config') return json(response, 200, { terminal: { cwd: '/tmp/hermes-fixture' } })
  192. if (url.pathname === '/api/fs/default-cwd') return json(response, 200, { cwd: '/tmp/hermes-fixture' })
  193. if (url.pathname === '/api/files') {
  194. const path = url.searchParams.get('path') || '/tmp/hermes-fixture'
  195. return json(response, 200, { path, entries: [...profileMediaPaths, ...userProfileMediaPaths, '/tmp/hello.png']
  196. .filter(file => file.slice(0, file.lastIndexOf('/')) === path)
  197. .map(file => ({ name: file.split('/').at(-1), path: file, is_directory: false })) })
  198. }
  199. if (url.pathname === '/api/files/download' && request.method === 'GET') {
  200. const path = url.searchParams.get('path')
  201. if (![...profileMediaPaths, ...userProfileMediaPaths, '/tmp/hello.png'].includes(path)) return json(response, 404, { detail: 'File not found' })
  202. response.writeHead(200, {
  203. 'Content-Type': 'image/png',
  204. 'Content-Length': profileMediaPng.length,
  205. 'Content-Disposition': `attachment; filename*=UTF-8''${encodeURIComponent(path.split('/').at(-1))}`,
  206. })
  207. return response.end(profileMediaPng)
  208. }
  209. if (url.pathname.startsWith('/api/plugins/yaoyao/v1/') && !groupAvailable) return json(response, 503, { detail: 'Group service temporarily unavailable' })
  210. if (url.pathname === '/api/auth/me') return json(response, 200, { user_id: 'demo-user', display_name: '验收用户', email: 'demo@example.invalid', provider: 'basic' })
  211. if (url.pathname === '/api/auth/ws-ticket' && request.method === 'POST') return json(response, 200, { ticket: 'fake-ticket' })
  212. if (url.pathname === '/api/profiles') return json(response, 200, { profiles })
  213. if (url.pathname === '/api/dashboard/plugins') return json(response, 200, [{ name: 'yaoyao', version: '1.7.3' }, { name: 'kanban', version: '1.0.0' }])
  214. if (url.pathname === '/api/plugins/yaoyao/profiles') return json(response, 200, { profiles: [
  215. { name: 'yaoyao', label: '夭夭', botName: '夭夭', agentName: '旧夭夭名称', isDefault: true },
  216. { name: 'yaoer', label: '瑶儿', botName: '瑶儿', agentName: '旧瑶儿名称', isDefault: false },
  217. ] })
  218. if (url.pathname === '/api/plugins/kanban/boards' && request.method === 'GET') {
  219. return json(response, 200, {
  220. boards: kanbanBoards.map(board => {
  221. const scoped = kanbanTasks.filter(task => task.board === board.slug)
  222. const counts = Object.fromEntries(kanbanColumns.map(status => [status, scoped.filter(task => task.status === status).length]))
  223. return { ...board, counts, total: scoped.filter(task => task.status !== 'archived').length }
  224. }),
  225. current: 'default',
  226. })
  227. }
  228. if (url.pathname === '/api/plugins/kanban/boards' && request.method === 'POST') {
  229. const input = await body(request)
  230. const slug = String(input.slug || '').trim().toLowerCase()
  231. if (!/^[a-z0-9][a-z0-9_-]{0,63}$/.test(slug)) return json(response, 400, { detail: 'invalid board slug' })
  232. let board = kanbanBoards.find(item => item.slug === slug)
  233. if (!board) {
  234. board = { slug, name: String(input.name || slug), description: String(input.description || ''), is_current: false, default_workspace_kind: 'scratch' }
  235. kanbanBoards.push(board)
  236. }
  237. return json(response, 200, { board, current: 'default' })
  238. }
  239. if (url.pathname === '/api/plugins/kanban/board' && request.method === 'GET') {
  240. return json(response, 200, kanbanBoardSnapshot(url))
  241. }
  242. if (url.pathname === '/api/plugins/kanban/profiles' && request.method === 'GET') {
  243. return json(response, 200, { profiles: profiles.map(profile => ({
  244. name: profile.name,
  245. is_default: profile.is_default,
  246. description: profile.description,
  247. description_auto: false,
  248. model: profile.model,
  249. provider: profile.provider,
  250. })) })
  251. }
  252. if (url.pathname === '/api/plugins/kanban/tasks' && request.method === 'POST') {
  253. const input = await body(request)
  254. if (!String(input.title || '').trim()) return json(response, 400, { detail: 'title is required' })
  255. const task = {
  256. id: `t_e2e${kanbanTaskSequence++}`,
  257. board: kanbanBoardSlug(url),
  258. title: String(input.title).trim(),
  259. body: input.body == null ? null : String(input.body),
  260. status: input.triage === true ? 'triage' : 'ready',
  261. assignee: input.assignee == null ? null : String(input.assignee),
  262. tenant: input.tenant == null ? null : String(input.tenant),
  263. priority: Number(input.priority) || 0,
  264. created_by: 'dashboard',
  265. created_at: now(),
  266. comment_count: 0,
  267. link_counts: { parents: Array.isArray(input.parents) ? input.parents.length : 0, children: 0 },
  268. progress: null,
  269. }
  270. kanbanTasks.push(task)
  271. recordKanbanEvent(task, 'created', { status: task.status, assignee: task.assignee })
  272. return json(response, 200, { task })
  273. }
  274. const kanbanCommentMatch = /^\/api\/plugins\/kanban\/tasks\/([^/]+)\/comments$/.exec(url.pathname)
  275. if (kanbanCommentMatch && request.method === 'POST') {
  276. const id = decodeURIComponent(kanbanCommentMatch[1])
  277. const task = kanbanTasks.find(item => item.id === id && item.board === kanbanBoardSlug(url))
  278. if (!task) return json(response, 404, { detail: 'task not found' })
  279. const input = await body(request)
  280. if (!String(input.body || '').trim()) return json(response, 400, { detail: 'body is required' })
  281. const comments = kanbanComments.get(id) || []
  282. comments.push({ id: comments.length + 1, task_id: id, author: String(input.author || 'dashboard'), body: String(input.body).trim(), created_at: now() })
  283. kanbanComments.set(id, comments)
  284. task.comment_count = comments.length
  285. recordKanbanEvent(task, 'commented', { author: input.author || 'dashboard' })
  286. return json(response, 200, { ok: true })
  287. }
  288. const kanbanTaskMatch = /^\/api\/plugins\/kanban\/tasks\/([^/]+)$/.exec(url.pathname)
  289. if (kanbanTaskMatch) {
  290. const id = decodeURIComponent(kanbanTaskMatch[1])
  291. const index = kanbanTasks.findIndex(item => item.id === id && item.board === kanbanBoardSlug(url))
  292. const task = kanbanTasks[index]
  293. if (!task) return json(response, 404, { detail: 'task not found' })
  294. if (request.method === 'PATCH') {
  295. const input = await body(request)
  296. const previousStatus = task.status
  297. for (const key of ['title', 'body', 'assignee', 'priority', 'result']) {
  298. if (Object.prototype.hasOwnProperty.call(input, key)) task[key] = input[key]
  299. }
  300. if (typeof input.status === 'string' && input.status) task.status = input.status
  301. if (task.status !== previousStatus) recordKanbanEvent(task, 'status', { status: task.status })
  302. else recordKanbanEvent(task, 'edited', null)
  303. return json(response, 200, { task })
  304. }
  305. if (request.method === 'DELETE') {
  306. kanbanTasks.splice(index, 1)
  307. kanbanComments.delete(id)
  308. kanbanEvents.delete(id)
  309. kanbanRuns.delete(id)
  310. return json(response, 200, { ok: true })
  311. }
  312. if (request.method === 'GET') {
  313. return json(response, 200, {
  314. task,
  315. comments: kanbanComments.get(id) || [],
  316. events: kanbanEvents.get(id) || [],
  317. attachments: [],
  318. links: { parents: id === 't_ios001' ? ['t_web001'] : [], children: id === 't_web001' ? ['t_ios001'] : [] },
  319. child_results: id === 't_web001' ? [{ id: 't_ios001', title: '核对 iOS 双端口', status: 'ready' }] : [],
  320. runs: kanbanRuns.get(id) || [],
  321. })
  322. }
  323. }
  324. if (url.pathname === '/api/plugins/kanban/dispatch' && request.method === 'POST') {
  325. const task = kanbanTasks.find(item => item.board === kanbanBoardSlug(url) && item.status === 'ready' && item.assignee)
  326. if (!task) return json(response, 200, { spawned: [] })
  327. task.status = 'running'
  328. task.started_at = now()
  329. const run = { id: kanbanRuns.size + 2, task_id: task.id, profile: task.assignee, status: 'running', outcome: null, summary: null, metadata: null, worker_pid: 12346, started_at: task.started_at, ended_at: null }
  330. kanbanRuns.set(task.id, [...(kanbanRuns.get(task.id) || []), run])
  331. recordKanbanEvent(task, 'claimed', { profile: task.assignee }, run.id)
  332. return json(response, 200, { spawned: [{ task_id: task.id, profile: task.assignee }] })
  333. }
  334. if (url.pathname === '/api/model/options') return json(response, 200, { model: 'gpt-5.6', provider: 'openai', providers: [{ slug: 'openai', name: 'OpenAI', models: ['gpt-5.6', 'gpt-5.5'] }] })
  335. if (url.pathname === '/api/profiles/sessions' || url.pathname === '/api/sessions') {
  336. const offset = Math.max(0, Number(url.searchParams.get('offset') || 0))
  337. const limit = Math.max(1, Number(url.searchParams.get('limit') || 100))
  338. const excluded = new Set((url.searchParams.get('exclude_sources') || '').split(',').filter(Boolean))
  339. const scoped = sessions.filter(item => (!url.searchParams.get('profile') || item.profile === url.searchParams.get('profile'))
  340. && (!url.searchParams.get('source') || item.source === url.searchParams.get('source')) && !excluded.has(item.source))
  341. return json(response, 200, { sessions: scoped.slice(offset, offset + limit), total: scoped.length, offset, limit })
  342. }
  343. if (/^\/api\/sessions\/[^/]+\/messages$/.test(url.pathname)) {
  344. const id = decodeURIComponent(url.pathname.split('/')[3])
  345. const scopedMessages = id === 'session-media' ? profileMediaMessages
  346. : id === 'session-user-media' ? userProfileMediaMessages
  347. : id === 'session-yaoer' ? [{ id: 'yaoer-history', role: 'assistant', content: '瑶儿历史消息', timestamp: now() - 10 }] : messages
  348. return json(response, 200, { messages: scopedMessages, pagination: { total: scopedMessages.length, returned: scopedMessages.length, limit: 150, hasMore: false } })
  349. }
  350. if (/^\/api\/sessions\/[^/]+$/.test(url.pathname)) {
  351. const id = decodeURIComponent(url.pathname.split('/').at(-1))
  352. const session = sessions.find(item => item.id === id && (!url.searchParams.get('profile') || item.profile === url.searchParams.get('profile')))
  353. if (session && request.method === 'PATCH') {
  354. const input = await body(request)
  355. if (typeof input.pinned === 'boolean') session.pinned = input.pinned
  356. if (typeof input.title === 'string' && input.title.trim()) session.title = input.title.trim()
  357. return json(response, 200, { ok: true, pinned: session.pinned, title: session.title })
  358. }
  359. return session ? json(response, 200, session) : json(response, 404, { detail: 'Not found' })
  360. }
  361. if (url.pathname === '/api/session-unread') return json(response, 200, { items: { 'session-demo': 0 } })
  362. if (url.pathname === '/api/plugins/yaoyao/v1/capabilities') return json(response, 200, { protocolVersion: 12, journalEpoch: epoch, latestCursor: groupCursor, limits: { maxAgentsPerRoom: 8, maxMessageBytes: 65536, maxMessagePageSize: 100, defaultMaxReplyRounds: 3, unlimitedReplyRoundsValue: -1, maxAgentDisplayNameLength: 100, maxRoomAvatarLength: 524288 }, features: ['roomAvatar'], eventTypes: ['message.upsert', 'topic.updated', 'room.activity', 'run.updated', 'agent.status', 'agent.updated'] })
  363. if (url.pathname === '/api/plugins/yaoyao/v1/topics/pinned') return json(response, 200, { items: groupTopics.filter(topic => topic.pinned), nextCursor: null })
  364. if (url.pathname === '/api/plugins/yaoyao/v1/rooms') return json(response, 200, { items: [room], nextCursor: null })
  365. if (url.pathname === `/api/plugins/yaoyao/v1/rooms/${roomId}`) {
  366. if (request.method === 'PATCH') {
  367. const input = await body(request)
  368. for (const key of ['name', 'cwd', 'instructions', 'avatar', 'maxReplyRounds', 'orchestrationMode']) {
  369. if (Object.prototype.hasOwnProperty.call(input, key)) room[key] = input[key]
  370. }
  371. room.updatedAt = now()
  372. }
  373. return json(response, 200, { ...room, agents: groupAgents, runs: [], pendingInteractions: [], latestCursor: groupCursor })
  374. }
  375. const agentPatchMatch = new RegExp(`^/api/plugins/yaoyao/v1/rooms/${roomId}/agents/([^/]+)$`).exec(url.pathname)
  376. if (agentPatchMatch && request.method === 'PATCH') {
  377. const target = groupAgents.find(agent => agent.id === decodeURIComponent(agentPatchMatch[1]))
  378. if (!target) return json(response, 404, { detail: 'Agent not found' })
  379. const input = await body(request)
  380. if (input.displayName === '所有人') return json(response, 409, { detail: '成员名称不能使用“所有人”' })
  381. const changed = []
  382. if (input.isHost === true) {
  383. for (const agent of groupAgents) {
  384. if (agent.id !== target.id && agent.isHost) {
  385. agent.isHost = false
  386. agent.updatedAt = now()
  387. changed.push(agent)
  388. }
  389. }
  390. }
  391. for (const key of ['displayName', 'description', 'enabled', 'replyWithoutMention', 'isHost', 'model', 'provider', 'reasoningEffort', 'fastMode']) {
  392. if (Object.prototype.hasOwnProperty.call(input, key)) target[key] = input[key]
  393. }
  394. target.updatedAt = now()
  395. changed.push(target)
  396. json(response, 200, target)
  397. setTimeout(() => {
  398. for (const agent of changed) broadcastGroup('agent.updated', agent)
  399. }, 10)
  400. return
  401. }
  402. if (url.pathname === `/api/plugins/yaoyao/v1/rooms/${roomId}/topics`) {
  403. return json(response, 200, { items: [...groupTopics].sort((a, b) => b.updatedAt - a.updatedAt), nextCursor: null })
  404. }
  405. const topicReadMatch = new RegExp(`^/api/plugins/yaoyao/v1/rooms/${roomId}/topics/([^/]+)/read$`).exec(url.pathname)
  406. if (topicReadMatch && request.method === 'PATCH') {
  407. const topic = groupTopics.find(item => item.id === decodeURIComponent(topicReadMatch[1]))
  408. if (!topic) return json(response, 404, { detail: 'Topic not found' })
  409. const input = await body(request)
  410. topic.unreadCount = 0
  411. topic.latestMessageSeq = Math.max(topic.latestMessageSeq, Number(input.throughSeq) || 0)
  412. room.unreadCount = groupTopics.reduce((count, item) => count + item.unreadCount, 0)
  413. return json(response, 200, { topic, room: { roomId, activeRunCount: room.activeRunCount, unreadCount: room.unreadCount, lastMessage: room.lastMessage } })
  414. }
  415. if (url.pathname === `/api/plugins/yaoyao/v1/rooms/${roomId}/messages` && request.method === 'POST') {
  416. const input = await body(request)
  417. if (typeof input.topicId !== 'string' || !input.topicId) return json(response, 400, { detail: 'topicId is required for group protocol v4' })
  418. const sentAt = now()
  419. const sent = { ...groupMessage, seq: nextGroupSeq++, id: input.clientMessageId, topicId: input.topicId, clientMessageId: input.clientMessageId, content: input.content, createdAt: sentAt, updatedAt: sentAt }
  420. groupMessages.push(sent)
  421. let topic = groupTopics.find(item => item.id === input.topicId)
  422. if (topic) {
  423. topic.preview = sent.content
  424. topic.messageCount = groupMessages.filter(message => message.topicId === input.topicId).length
  425. topic.latestMessageSeq = sent.seq
  426. topic.updatedAt = sentAt
  427. } else {
  428. topic = { id: input.topicId, roomId, title: String(input.content || '').trim().split('\n')[0].slice(0, 40) || '新话题', preview: sent.content, messageCount: 1, unreadCount: 0, latestMessageSeq: sent.seq, createdAt: sentAt, updatedAt: sentAt }
  429. groupTopics.push(topic)
  430. }
  431. room.lastMessage = sent
  432. room.updatedAt = sentAt
  433. const runId = '66666666-6666-4666-8666-666666666666'
  434. const run = { id: runId, roomId, topicId: input.topicId, agentId, triggerMessageId: sent.id, responseMessageId: '', rootMessageId: sent.id, depth: 0, status: 'queued', runtimeSessionId: null, error: '', replyMode: 'automatic', createdAt: sentAt, updatedAt: sentAt }
  435. json(response, 200, { message: sent, runs: [run] })
  436. setTimeout(() => broadcastGroup('message.upsert', sent), 20)
  437. setTimeout(() => broadcastGroup('topic.updated', topic), 30)
  438. setTimeout(() => broadcastGroup('run.updated', { ...run, status: 'queued', updatedAt: now() }), 40)
  439. setTimeout(() => broadcastGroup('agent.status', { roomId, agentId, status: 'queued', runId }), 40)
  440. setTimeout(() => broadcastGroup('run.updated', { ...run, status: 'running', runtimeSessionId: 'group-runtime-demo', updatedAt: now() }), 650)
  441. setTimeout(() => broadcastGroup('agent.status', { roomId, agentId, status: 'running', runId }), 650)
  442. setTimeout(() => broadcastGroup('run.updated', { ...run, status: 'completed', runtimeSessionId: 'group-runtime-demo', updatedAt: now() }), 1800)
  443. setTimeout(() => broadcastGroup('agent.status', { roomId, agentId, status: 'idle', runId: null }), 1800)
  444. setTimeout(() => broadcastGroup('room.activity', { roomId, activeRunCount: 0, unreadCount: room.unreadCount, lastMessage: room.lastMessage }), 1810)
  445. return
  446. }
  447. if (url.pathname === `/api/plugins/yaoyao/v1/rooms/${roomId}/messages`) {
  448. const selectedTopicId = url.searchParams.get('topicId')
  449. const items = selectedTopicId ? groupMessages.filter(message => message.topicId === selectedTopicId) : groupMessages
  450. return json(response, 200, { items })
  451. }
  452. if (url.pathname === '/api/plugins/yaoyao/files') return json(response, 200, { items: [{ id: 1, path: '/tmp/demo-report.pdf', name: 'demo-report.pdf', extension: 'pdf', mimeType: 'application/pdf', size: 204800, modifiedAt: now(), exists: true, origins: [{ profile: 'yaoyao', sessionId: 'session-demo', sessionTitle: '夭夭 AI 验收会话', messageId: 'message-assistant', authorKind: 'assistant', authorName: '夭夭', observedAt: now() }] }], nextCursor: null, total: 1 })
  453. if (url.pathname === '/api/plugins/yaoyao/1/download') {
  454. response.writeHead(200, { 'Content-Type': 'application/pdf', 'Content-Length': previewPdf.length })
  455. return response.end(previewPdf)
  456. }
  457. json(response, 404, { detail: `No fake route for ${request.method} ${url.pathname}` })
  458. })
  459. const sockets = new WebSocketServer({ noServer: true })
  460. server.on('upgrade', (request, socket, head) => {
  461. const url = new URL(request.url || '/', `http://${request.headers.host}`)
  462. if (localAuthorization ? url.searchParams.get('token') !== localToken || url.searchParams.has('ticket')
  463. : url.searchParams.get('ticket') !== 'fake-ticket') return socket.destroy()
  464. sockets.handleUpgrade(request, socket, head, client => {
  465. if (url.pathname.endsWith('/events')) {
  466. groupConnectionCount += 1
  467. groupClients.add(client)
  468. client.once('close', () => groupClients.delete(client))
  469. client.send(JSON.stringify({ type: 'group.ready', epoch, cursor: groupCursor, heartbeatSeconds: 20 }))
  470. return
  471. }
  472. client.send(JSON.stringify({ jsonrpc: '2.0', method: 'event', params: { type: 'gateway.ready', payload: { capabilities: ['safe_interrupt', 'session_reasoning_config'] } } }))
  473. client.on('message', raw => {
  474. const requestFrame = JSON.parse(raw.toString())
  475. rpcRequests.push(requestFrame)
  476. const respond = result => client.send(JSON.stringify({ jsonrpc: '2.0', id: requestFrame.id, result }))
  477. if (requestFrame.method === 'session.cwd.set') return respond({ cwd: requestFrame.params.cwd })
  478. if (requestFrame.method === 'session.resume') return respond({ session_id: 'runtime-demo', stored_session_id: requestFrame.params.session_id, fast: requestFrame.params.session_id === 'session-yaoer', messages: [] })
  479. if (requestFrame.method === 'session.create') return respond({ session_id: 'runtime-new', stored_session_id: 'session-new', fast: requestFrame.params.fast === true })
  480. if (requestFrame.method === 'session.usage') return respond({ context_used: 12500, context_max: 114688, total: 12500, input: 9000, output: 3500 })
  481. if (requestFrame.method === 'prompt.submit') {
  482. respond({ status: 'accepted' })
  483. const slowStream = requestFrame.params.text === '请开始思考'
  484. setTimeout(() => {
  485. const emit = (type, payload) => client.send(JSON.stringify({
  486. jsonrpc: '2.0', method: 'event',
  487. params: { type, session_id: requestFrame.params.session_id, profile: 'yaoyao', ...(payload ? { payload } : {}) },
  488. }))
  489. if (requestFrame.params.text === '验证工具和文件权限') {
  490. const tool_id = `file-test-${Date.now()}`
  491. emit('message.start', {})
  492. emit('tool.generating', { name: 'terminal' })
  493. emit('tool.start', { tool_id, name: 'terminal', args: { command: '创建测试图片' } })
  494. emit('tool.complete', { tool_id, name: 'terminal', result: { output: '', exit_code: 0 } })
  495. emit('message.complete', { text: '已完成。\n\nMEDIA: /tmp/hello.png', status: 'complete' })
  496. return
  497. }
  498. if (requestFrame.params.text === '验证流式分段') {
  499. emit('message.start')
  500. emit('message.delta', { text: '我先检查配置。' })
  501. emit('message.interim', { text: '我先检查配置。', already_streamed: true })
  502. emit('tool.start', { tool_id: 'fake-tool', name: 'terminal', arguments: { command: 'pwd' } })
  503. setTimeout(() => {
  504. emit('tool.complete', { tool_id: 'fake-tool', name: 'terminal', result: { output: '/tmp' } })
  505. emit('message.delta', { text: '配置检查完成。' })
  506. }, 800)
  507. setTimeout(() => emit('message.complete', { text: '配置检查完成。', status: 'complete' }), 2_500)
  508. return
  509. }
  510. client.send(JSON.stringify({ jsonrpc: '2.0', method: 'event', params: { type: 'message.start', session_id: requestFrame.params.session_id, profile: 'yaoyao' } }))
  511. client.send(JSON.stringify({ jsonrpc: '2.0', method: 'event', params: { type: 'message.delta', session_id: requestFrame.params.session_id, profile: 'yaoyao', payload: { text: '这是来自假 Gateway 的流式回复。' } } }))
  512. setTimeout(() => {
  513. client.send(JSON.stringify({ jsonrpc: '2.0', method: 'event', params: { type: 'message.complete', session_id: requestFrame.params.session_id, profile: 'yaoyao', payload: { text: '这是来自假 Gateway 的流式回复。', status: 'complete' } } }))
  514. }, slowStream ? 1_200 : 0)
  515. }, slowStream ? 3_500 : 1_200)
  516. return
  517. }
  518. respond({ ok: true })
  519. })
  520. })
  521. })
  522. server.listen(port, '127.0.0.1', () => process.stdout.write(`Fake Hermes listening on 127.0.0.1:${port}\n`))
  523. for (const signal of ['SIGTERM', 'SIGINT']) process.once(signal, () => server.close(() => process.exit(0)))