workspaceRoutes.ts 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697
  1. import { workspaceDetail, streamWorkspace } from './workspaceSync.js'
  2. import { readServerIdentity } from './serverIdentity.js'
  3. import { randomUUID } from 'node:crypto'
  4. import Router from '@koa/router'
  5. import type Koa from 'koa'
  6. import { createReadStream, statSync } from 'node:fs'
  7. import { z } from 'zod'
  8. import { WorkspaceStore, agentInput, agentPatch, parse } from './workspaceStore.js'
  9. import { WorkspaceRuntime, sendInput } from './workspaceRuntime.js'
  10. import { WorkspaceNodes, type WorkspaceNode } from './workspaceGateway.js'
  11. import {
  12. WorkspaceAssets,
  13. libraryFile,
  14. publicFile,
  15. type StoredWorkspaceFile,
  16. } from './workspaceAssets.js'
  17. import { receiveGroupUploads, type UploadStore } from './uploads.js'
  18. import { HttpError } from './errors.js'
  19. import type { PushCoordinator } from './pushCoordinator.js'
  20. import type { LocalAuthStore } from './localAuth.js'
  21. import type {
  22. WorkspaceMessage,
  23. WorkspaceAgent,
  24. WorkspaceConversation,
  25. WorkspaceRun,
  26. WorkspaceInteraction,
  27. } from '../shared/workspace.js'
  28. const body = (ctx: Koa.Context): any => (ctx.request as Koa.Request & { body?: unknown }).body ?? {}
  29. const number = (v: unknown, fallback: number) =>
  30. typeof v === 'string' && /^\d+$/.test(v) ? Math.min(Number(v), Number.MAX_SAFE_INTEGER) : fallback
  31. export function workspaceRouter(
  32. store: WorkspaceStore,
  33. runtime: WorkspaceRuntime,
  34. nodes: WorkspaceNodes,
  35. assets: WorkspaceAssets,
  36. uploads: UploadStore,
  37. auth: LocalAuthStore,
  38. push: PushCoordinator,
  39. ): Router {
  40. const router = new Router(),
  41. owner = (ctx: Koa.Context) => auth.require(ctx).id
  42. const libraryId = (user: string, id: string) =>
  43. Number(
  44. store.db
  45. .prepare("SELECT rowid AS n FROM workspace_entities WHERE owner=? AND kind='file' AND id=?")
  46. .get(user, id)?.n ?? 0,
  47. )
  48. const fileRecord = (user: string, id: string): StoredWorkspaceFile => {
  49. if (/^\d+$/.test(id)) {
  50. const row = store.db
  51. .prepare("SELECT data FROM workspace_entities WHERE owner=? AND kind='file' AND rowid=?")
  52. .get(user, Number(id))
  53. if (row) {const file:StoredWorkspaceFile=JSON.parse(String(row.data));if(file.sourceNodeId&&file.profile)nodes.requireSource(user,{nodeId:file.sourceNodeId,profile:file.profile});return file}
  54. }
  55. const file=store.require<StoredWorkspaceFile>(user,'file',id)
  56. if(file.sourceNodeId&&file.profile)nodes.requireSource(user,{nodeId:file.sourceNodeId,profile:file.profile})
  57. return file
  58. }
  59. router.get('/api/app/capabilities', (ctx) => {
  60. owner(ctx)
  61. ctx.body = {
  62. protocolVersion: 1,
  63. serverKind: 'yaoyao-web',
  64. features: [
  65. 'agents',
  66. 'conversations',
  67. 'conversationTasks',
  68. 'editableGroups',
  69. 'agentTeamManagement',
  70. 'computerControl','sharedComputers','computerManagement',
  71. 'files',
  72. 'voice',
  73. 'context',
  74. ...(auth.require(ctx).role === 'admin' ? ['nodes', 'pairedNodes', 'remoteAgentReferences', 'editableNodeAddress'] : []),
  75. 'events', 'workspace-stream-v1',
  76. ],
  77. }
  78. })
  79. router.get('/api/app/agents/sources', async (ctx) => {
  80. ctx.body = await nodes.sources(owner(ctx))
  81. })
  82. router.get('/api/app/agents', async (ctx) => {
  83. const user=owner(ctx), agents=store.list<WorkspaceAgent>(user,'agent')
  84. for (const agent of agents) if (agent.remoteAgentId) void nodes.refreshRemoteAgent(user,agent).catch(()=>{})
  85. ctx.body = { agents: agents.map(agent=>store.agentSummary(agent)) }
  86. })
  87. router.get('/api/app/nodes/:id/agents', async ctx => {
  88. auth.requireAdmin(ctx)
  89. ctx.body = { agents: await nodes.remoteAgents(owner(ctx),ctx.params.id) }
  90. })
  91. router.post('/api/app/agents/remote', async ctx => {
  92. auth.requireAdmin(ctx)
  93. const input = parse(z.object({nodeId:z.string().uuid(),agentId:z.string().uuid()}).strict(),body(ctx))
  94. ctx.body = { agent: await nodes.importRemoteAgent(owner(ctx),input.nodeId,input.agentId) };ctx.status=201
  95. })
  96. router.post('/api/app/agents', async (ctx) => {
  97. const user = owner(ctx),
  98. input = parse(agentInput, body(ctx))
  99. const authorization = auth.pushAuthorizationVersion(user)
  100. nodes.requireSource(user, input)
  101. const sources = await nodes.sources(user)
  102. if (!sources.sources.some((s) => s.nodeId === input.nodeId && s.profile === input.profile))
  103. throw new HttpError(409, '基础机器人当前不可用', 'source_unavailable')
  104. nodes.requireSource(user, input)
  105. if (input.canManageTeam || input.execution==='computer') await runtime.teamTools.requireAvailable(user, input)
  106. if (auth.pushAuthorizationVersion(user) !== authorization) throw new HttpError(401,'账号授权已变化,请重新登录','session_revoked')
  107. ctx.body = { agent: store.agentSummary(store.createAgent(user, input)) }
  108. ctx.status = 201
  109. })
  110. router.patch('/api/app/agents/:id', async (ctx) => {
  111. const user = owner(ctx), agent = store.require<WorkspaceAgent>(user, 'agent', ctx.params.id), input = parse(agentPatch,body(ctx))
  112. const authorization = auth.pushAuthorizationVersion(user)
  113. const updated={...agent,...input}
  114. const sourceChanged=updated.nodeId!==agent.nodeId||updated.profile!==agent.profile
  115. nodes.requireSource(user,updated)
  116. if(sourceChanged){
  117. const sources=await nodes.sources(user)
  118. if(!sources.sources.some(s=>s.nodeId===updated.nodeId&&s.profile===updated.profile))throw new HttpError(409,'基础机器人当前不可用','source_unavailable')
  119. }
  120. if ((input.canManageTeam === true && agent.canManageTeam !== true) || input.execution==='computer' || (sourceChanged&&(updated.canManageTeam||updated.execution==='computer'))) await runtime.teamTools.requireAvailable(user, updated)
  121. if(sourceChanged&&runtime.cloud?.selected(user,updated))await runtime.cloud.requireAvailable(user,updated,nodes.target(user,updated.nodeId))
  122. if (auth.pushAuthorizationVersion(user) !== authorization) throw new HttpError(401,'账号授权已变化,请重新登录','session_revoked')
  123. nodes.requireSource(user, updated)
  124. ctx.body = { agent: store.agentSummary(store.atomic(() => {
  125. if (input.archived === true && !agent.temporaryGoalId) {
  126. const direct = store.list<WorkspaceConversation>(user, 'conversation').find(c => c.kind === 'direct' && c.memberIds[0] === agent.id)
  127. if (direct) store.changeConversationLifecycle(user, direct.id, 'archive')
  128. }
  129. nodes.requireSource(user,{...store.require<WorkspaceAgent>(user,'agent',ctx.params.id),...input})
  130. return store.updateAgent(user, ctx.params.id, input)
  131. })) }
  132. })
  133. router.delete('/api/app/agents/:id', (ctx) => {
  134. store.deleteAgent(owner(ctx), ctx.params.id)
  135. ctx.body = { ok: true }
  136. })
  137. router.delete('/api/app/conversations/:id', (ctx) => {
  138. store.deleteConversation(owner(ctx), ctx.params.id)
  139. ctx.body = { ok: true }
  140. })
  141. router.get('/api/app/conversations/:id/lifecycle', ctx => {
  142. ctx.body = store.conversationLifecycle(owner(ctx), ctx.params.id)
  143. })
  144. router.post('/api/app/conversations/:id/lifecycle', ctx => {
  145. const input = parse(z.object({ action: z.enum(['archive', 'restore', 'delete']), confirmationToken: z.string().regex(/^[a-f0-9]{64}$/).optional() }).strict(), body(ctx))
  146. if (input.action !== 'restore' && !input.confirmationToken) throw new HttpError(409, '请先确认聊天操作', 'lifecycle_confirmation_required')
  147. store.changeConversationLifecycle(owner(ctx), ctx.params.id, input.action, input.confirmationToken)
  148. runtime.wake()
  149. ctx.body = { ok: true }
  150. })
  151. router.get('/api/app/conversations', (ctx) => {
  152. const user = owner(ctx)
  153. ctx.body = {
  154. conversations: store
  155. .list<WorkspaceConversation>(user, 'conversation')
  156. .map(c => store.conversationSummary(user, c))
  157. .sort(
  158. (a, b) =>
  159. Number(b.pinned) - Number(a.pinned) ||
  160. (b.lastMessageAt ?? b.createdAt) - (a.lastMessageAt ?? a.createdAt) ||
  161. a.id.localeCompare(b.id),
  162. ),
  163. cursor: store.cursor(user),
  164. }
  165. })
  166. router.post('/api/app/conversations', (ctx) => {
  167. const user = owner(ctx),
  168. input = body(ctx)
  169. for (const id of Array.isArray(input.memberIds) ? input.memberIds : [])
  170. nodes.requireSource(user, store.require<WorkspaceAgent>(user, 'agent', id))
  171. const conversation = store.createGroup(user, input)
  172. try {
  173. push.setGroupSubscription(user, conversation.id, true, conversation.lastSeq)
  174. } catch {
  175. /* Optional delivery cannot undo a created chat. */
  176. }
  177. ctx.body = { conversation }
  178. ctx.status = 201
  179. })
  180. router.get('/api/app/conversations/:id/tasks', (ctx) => {
  181. const user = owner(ctx)
  182. store.resolveTask(user, ctx.params.id)
  183. ctx.body = { tasks: store.tasks(user, ctx.params.id) }
  184. })
  185. router.get('/api/app/conversations/:id/tasks/:taskId/plan', (ctx) => {
  186. const user = owner(ctx)
  187. store.requireTask(user, ctx.params.id, ctx.params.taskId)
  188. ctx.body = { goal: store.get(user, 'goal', ctx.params.taskId) ?? null,
  189. assignments: runtime.tasks.assignments(user, ctx.params.taskId) }
  190. })
  191. router.patch('/api/app/conversations/:id/tasks/:taskId/plan', (ctx) => {
  192. const user = owner(ctx)
  193. store.requireTask(user, ctx.params.id, ctx.params.taskId)
  194. ctx.body = { goal: runtime.tasks.updateCriteria(user, ctx.params.taskId, body(ctx)) }
  195. })
  196. router.post('/api/app/conversations/:id/tasks/:taskId/stop', async (ctx) => {
  197. const user=owner(ctx)
  198. store.requireTask(user,ctx.params.id,ctx.params.taskId)
  199. await runtime.stopTask(user,ctx.params.id,ctx.params.taskId)
  200. ctx.body={ok:true}
  201. })
  202. router.post('/api/app/conversations/:id/tasks/:taskId/resume', async (ctx) => {
  203. const user=owner(ctx), input=parse(z.object({requestId:z.string().uuid()}).strict(),body(ctx))
  204. const version=auth.pushAuthorizationVersion(user)
  205. store.requireTask(user,ctx.params.id,ctx.params.taskId)
  206. const goal=store.require<import('../shared/agentTasks.js').AgentGoal>(user,'goal',ctx.params.taskId)
  207. const agent=store.require<WorkspaceAgent>(user,'agent',goal.coordinatorId)
  208. await runtime.teamTools.requireAvailable(user,agent)
  209. if(auth.pushAuthorizationVersion(user)!==version)throw new HttpError(401,'账号授权已变化,请重新登录','session_revoked')
  210. ctx.body=store.command(user,input.requestId,{operation:'goal.resume-user',goalId:goal.id},()=>{
  211. const run=runtime.send(user,ctx.params.id,{requestId:randomUUID(),taskId:goal.id,content:'继续处理当前目标,请先核对已有工作,避免重复执行。'})
  212. const updated=runtime.tasks.resume(user,agent.id,{requestId:randomUUID(),goalId:goal.id},
  213. {conversationId:ctx.params.id,conversationTaskId:goal.id,runId:run.id,agentId:agent.id})
  214. return {goal:updated,run}
  215. })
  216. })
  217. router.post('/api/app/conversations/:id/tasks', (ctx) => {
  218. const task = store.createTask(owner(ctx), ctx.params.id, body(ctx))
  219. ctx.status = 201
  220. ctx.body = { task }
  221. })
  222. router.patch('/api/app/conversations/:id/tasks/:taskId', (ctx) => {
  223. ctx.body = { task: store.updateTask(owner(ctx), ctx.params.id, ctx.params.taskId, body(ctx)) }
  224. })
  225. router.put('/api/app/conversations/:id/tasks/:taskId/read', (ctx) => {
  226. const { seq, unreadVersion } = parse(z.object({ seq: z.number().int().nonnegative(), unreadVersion: z.number().int().nonnegative().optional() }).strict(), body(ctx))
  227. const user = owner(ctx)
  228. ctx.body = { task: store.markTaskRead(user, ctx.params.id, ctx.params.taskId, seq, unreadVersion),
  229. conversation: store.conversationSummary(user, store.require(user, 'conversation', ctx.params.id)) }
  230. })
  231. router.delete('/api/app/conversations/:id/tasks/:taskId', async (ctx) => {
  232. const user = owner(ctx)
  233. await runtime.stopTask(user, ctx.params.id, ctx.params.taskId)
  234. store.deleteTask(user, ctx.params.id, ctx.params.taskId)
  235. ctx.body = { ok: true }
  236. })
  237. router.get('/api/app/conversations/:id', (ctx) => {
  238. ctx.body = workspaceDetail(store, runtime, owner(ctx), ctx.params.id,
  239. typeof ctx.query.taskId === 'string' ? ctx.query.taskId : undefined,
  240. Math.max(1, Math.min(100, number(ctx.query.limit, 100))))
  241. })
  242. router.patch('/api/app/conversations/:id', async (ctx) => {
  243. const user = owner(ctx), input = body(ctx)
  244. for (const id of Array.isArray(input.memberIds) ? input.memberIds : [])
  245. nodes.requireSource(user, store.require<WorkspaceAgent>(user, 'agent', id))
  246. const conversation = store.updateConversation(user, ctx.params.id, input)
  247. if (conversation.archived) await runtime.stopConversation(user, conversation.id)
  248. runtime.wake()
  249. ctx.body = { conversation: store.require(user, 'conversation', conversation.id) }
  250. })
  251. router.post('/api/app/conversations/:id/agents/:agentId/stop', async (ctx) => {
  252. const user = owner(ctx),
  253. taskId = typeof ctx.query.taskId === 'string' ? ctx.query.taskId : undefined,
  254. task = store.resolveTask(user, ctx.params.id, taskId)
  255. await runtime.stopAgent(user, ctx.params.id, ctx.params.agentId, task?.id)
  256. ctx.body = { ok: true }
  257. })
  258. router.get('/api/app/conversations/:id/messages', (ctx) => {
  259. const user = owner(ctx),
  260. taskId = typeof ctx.query.taskId === 'string' ? ctx.query.taskId : undefined,
  261. task = store.resolveTask(user, ctx.params.id, taskId)
  262. ctx.body = {
  263. messages: store.messages(
  264. user,
  265. ctx.params.id,
  266. number(ctx.query.before, Number.MAX_SAFE_INTEGER),
  267. Math.min(200, number(ctx.query.limit, 100)),
  268. false,
  269. task?.id,
  270. ),
  271. cursor: store.cursor(user),
  272. }
  273. })
  274. router.post('/api/app/conversations/:id/messages', async (ctx) => {
  275. const user = owner(ctx)
  276. const input = parse(sendInput, body(ctx))
  277. if (input.mode === 'goal') {
  278. const version = auth.pushAuthorizationVersion(user)
  279. const conversation = store.require<WorkspaceConversation>(user, 'conversation', ctx.params.id)
  280. await runtime.teamTools.requireAvailable(user, store.require<WorkspaceAgent>(user, 'agent', conversation.administratorId))
  281. if (auth.pushAuthorizationVersion(user) !== version) throw new HttpError(401, '账号授权已变化,请重新登录', 'session_revoked')
  282. }
  283. ctx.body = { run: runtime.send(user, ctx.params.id, input) }
  284. ctx.status = 202
  285. })
  286. router.put('/api/app/conversations/:id/read', (ctx) => {
  287. const user = owner(ctx),
  288. c = store.require<WorkspaceConversation>(user, 'conversation', ctx.params.id)
  289. const { seq, unreadVersion } = parse(z.object({ seq: z.number().int().nonnegative(), unreadVersion: z.number().int().nonnegative().optional() }).strict(), body(ctx))
  290. if (c.kind === 'group') {
  291. const taskId = typeof ctx.query.taskId === 'string' ? ctx.query.taskId : undefined,
  292. task = store.resolveTask(user, c.id, taskId)!
  293. store.markTaskRead(user, c.id, task.id, seq, unreadVersion)
  294. }
  295. ctx.body = { conversation: store.markConversationRead(user, c.id, seq, unreadVersion) }
  296. })
  297. router.post('/api/app/runs/:id/stop', async (ctx) => {
  298. await runtime.stop(owner(ctx), ctx.params.id)
  299. ctx.body = { ok: true }
  300. })
  301. router.post('/api/app/runs/:id/reconcile', (ctx) => {
  302. const user = owner(ctx)
  303. store.require(user, 'run', ctx.params.id)
  304. void runtime.reconcile(user, ctx.params.id).catch(() => {})
  305. ctx.status = 202
  306. ctx.body = { ok: true }
  307. })
  308. router.post('/api/app/interactions/:id/respond', async (ctx) => {
  309. const b = parse(z.object({ answer: z.string().min(1).max(16_000) }).strict(), body(ctx))
  310. await runtime.respond(owner(ctx), ctx.params.id, b.answer)
  311. ctx.body = { ok: true }
  312. })
  313. router.get('/api/app/workspace/snapshot', ctx => {
  314. const user = owner(ctx)
  315. const conversations = store.list<WorkspaceConversation>(user, 'conversation').map(c => store.conversationSummary(user, c))
  316. const details = conversations.filter(c => !c.archived).map(c => workspaceDetail(store, runtime, user, c.id))
  317. ctx.set('Cache-Control', 'no-store')
  318. ctx.body = { agents: store.list<WorkspaceAgent>(user, 'agent').map(a => store.agentSummary(a)),
  319. conversations, details, cursor: store.cursor(user), serverIdentity: readServerIdentity(store) }
  320. })
  321. router.get('/api/app/events/stream', ctx => streamWorkspace(ctx, store, auth))
  322. router.get('/api/app/events', (ctx) => {
  323. const user = owner(ctx),
  324. after = number(ctx.query.after, 0),
  325. events = store.events(user, after)
  326. ctx.set('Cache-Control', 'no-store')
  327. ctx.body = { events, cursor: events.at(-1)?.seq ?? after, serverIdentity: readServerIdentity(store) }
  328. })
  329. router.get('/api/app/nodes', (ctx) => {
  330. auth.requireAdmin(ctx)
  331. ctx.body = {
  332. nodes: store
  333. .list<WorkspaceNode>(owner(ctx), 'node')
  334. .map(({ secret: _secret, ...node }) => node),
  335. }
  336. })
  337. router.post('/api/app/nodes', async (ctx) => {
  338. auth.requireAdmin(ctx)
  339. const input = parse(z.object({ qrPayload: z.string().min(1).max(8192), name: z.string().max(100).default(''), nodeId:z.string().uuid().optional() }).strict(), body(ctx))
  340. await nodes.pair(owner(ctx), input)
  341. ctx.status = 201; ctx.body = { ok: true }
  342. })
  343. router.patch('/api/app/nodes/:id', async (ctx) => {
  344. auth.requireAdmin(ctx)
  345. const input = parse(z.object({ name: z.string().min(1).max(100), url: z.string().url().max(2048) }).strict(), body(ctx))
  346. await nodes.update(owner(ctx), ctx.params.id, input)
  347. ctx.body = { ok: true }
  348. })
  349. router.delete('/api/app/nodes/:id', (ctx) => {
  350. auth.requireAdmin(ctx)
  351. nodes.remove(owner(ctx), ctx.params.id)
  352. ctx.body = { ok: true }
  353. })
  354. router.post('/api/app/uploads', async (ctx) => {
  355. const user = owner(ctx),
  356. refs = await receiveGroupUploads(ctx.req, uploads, user)
  357. const files = uploads
  358. .records(
  359. refs.map((r) => r.id),
  360. user,
  361. )
  362. .map((r) => ({
  363. id: r.id,
  364. name: r.name,
  365. mimeType: r.mimeType,
  366. size: r.size,
  367. path: r.path,
  368. sender: 'user' as const,
  369. createdAt: Date.now(),
  370. }))
  371. // A library entry owns its bytes even when the user cancels the draft.
  372. uploads.markReferenced(files.map(file => file.id), user)
  373. for (const f of files) store.put(user, 'file', f.id, f)
  374. ctx.status = 201
  375. ctx.body = { files: files.map(publicFile) }
  376. })
  377. router.get('/api/app/files', (ctx) => {
  378. const files = store
  379. .visibleFiles(owner(ctx))
  380. .filter(
  381. (f) =>
  382. (!ctx.query.search ||
  383. f.name.toLowerCase().includes(String(ctx.query.search).toLowerCase())) &&
  384. (!ctx.query.profile || f.profile === ctx.query.profile) &&
  385. (!ctx.query.session_id || f.conversationId === ctx.query.session_id) &&
  386. (!ctx.query.sender || f.sender === ctx.query.sender),
  387. )
  388. .sort((a, b) => b.createdAt - a.createdAt || a.id.localeCompare(b.id))
  389. .map((f) => libraryFile(publicFile(f), libraryId(owner(ctx), f.id), store.get<WorkspaceConversation>(owner(ctx),'conversation',f.conversationId ?? '')))
  390. .filter((f) => !ctx.query.kind || f.kind === ctx.query.kind)
  391. const start = number(ctx.query.cursor, 0),
  392. limit = Math.min(200, number(ctx.query.limit, 50))
  393. ctx.body = {
  394. items: files.slice(start, start + limit),
  395. total: files.length,
  396. nextCursor: start + limit < files.length ? String(start + limit) : null,
  397. }
  398. })
  399. router.get('/api/app/files/stats', (ctx) => {
  400. const files = store.visibleFiles(owner(ctx))
  401. ctx.body = { count: files.length, totalBytes: files.reduce((sum, f) => sum + f.size, 0) }
  402. })
  403. router.post('/api/app/message-files/query', (ctx) => {
  404. const ids = parse(
  405. z.object({ messageIds: z.array(z.union([z.string(), z.number()])).max(500) }),
  406. body(ctx),
  407. ).messageIds.map(String)
  408. const files = store.visibleFiles(owner(ctx))
  409. ctx.body = {
  410. messages: Object.fromEntries(
  411. ids.map((id) => [
  412. id,
  413. files
  414. .filter((f) => f.messageId === id || store.get<WorkspaceMessage>(owner(ctx),'message',id)?.attachments.some(attachment=>attachment.id===f.id))
  415. .map((f, ordinal) => ({
  416. itemId: libraryId(owner(ctx), f.id),
  417. messageId: id,
  418. ordinal,
  419. originalPath: `/api/app/files/${f.id}/download`,
  420. referencePath: `/api/app/files/${f.id}/download`,
  421. name: f.name,
  422. size: f.size,
  423. mimeType: f.mimeType,
  424. archiveStatus: 'ready',
  425. archivedAt: f.createdAt,
  426. availability: 'archived',
  427. })),
  428. ]),
  429. ),
  430. }
  431. })
  432. for (const action of ['download', 'preview'])
  433. router.get(`/api/app/files/:id/${action}`, (ctx) => {
  434. const file = fileRecord(owner(ctx), ctx.params.id),
  435. size = statSync(file.path).size
  436. const activeContent =
  437. /(?:html|svg|javascript|ecmascript|xml)/i.test(file.mimeType) ||
  438. /\.(?:html?|svg|js|mjs|xml|xhtml)$/i.test(file.name)
  439. ctx.type = activeContent ? 'application/octet-stream' : file.mimeType
  440. ctx.set(
  441. 'Content-Disposition',
  442. `${action === 'preview' && !activeContent ? 'inline' : 'attachment'}; filename*=UTF-8''${encodeURIComponent(file.name)}`,
  443. )
  444. ctx.set('Accept-Ranges', 'bytes')
  445. ctx.set('Cache-Control', 'private, no-store')
  446. if(size===0){ctx.body=Buffer.alloc(0);return}
  447. const range = ctx.get('range'),
  448. match = /^bytes=(\d*)-(\d*)$/.exec(range)
  449. let start = 0,
  450. end = size - 1
  451. if (range) {
  452. if (!match || (!match[1] && !match[2]))
  453. throw new HttpError(416, 'Range invalid', 'invalid_range')
  454. if (!match[1]) start = Math.max(0, size - Number(match[2]))
  455. else {
  456. start = Number(match[1])
  457. end = match[2] ? Math.min(size - 1, Number(match[2])) : size - 1
  458. }
  459. if (start > end || start >= size) {
  460. ctx.set('Content-Range', `bytes */${size}`)
  461. throw new HttpError(416, 'Range invalid', 'invalid_range')
  462. }
  463. ctx.status = 206
  464. ctx.set('Content-Range', `bytes ${start}-${end}/${size}`)
  465. }
  466. ctx.length = Math.max(0, end - start + 1)
  467. ctx.body = size ? createReadStream(file.path, { start, end }) : Buffer.alloc(0)
  468. })
  469. const contextKey = (ctx: Koa.Context) =>
  470. JSON.stringify([ctx.query.profile ?? ctx.get('x-hermes-profile') ?? 'default', ctx.params.id])
  471. router.get('/api/app/session-context/:id', (ctx) => {
  472. ctx.body = { snapshot: store.get(owner(ctx), 'native-context', contextKey(ctx)) ?? null }
  473. })
  474. router.put('/api/app/session-context/:id', (ctx) => {
  475. const user = owner(ctx),
  476. key = contextKey(ctx)
  477. const value = parse(
  478. z
  479. .object({
  480. usedTokens: z.number().int().nonnegative(),
  481. limitTokens: z.number().int().positive().nullable().optional(),
  482. percent: z.number().nonnegative().nullable().optional(),
  483. compressions: z.number().int().nonnegative().nullable().optional(),
  484. model: z.string().max(512).nullable().optional(),
  485. provider: z.string().max(256).nullable().optional(),
  486. observedAt: z.number().nonnegative(),
  487. })
  488. .strict(),
  489. body(ctx),
  490. )
  491. const previous = store.get<{ observedAt: number }>(user, 'native-context', key)
  492. const snapshot =
  493. previous && previous.observedAt > value.observedAt
  494. ? previous
  495. : { ...value, sessionId: ctx.params.id, updatedAt: Date.now() / 1000 }
  496. store.put(user, 'native-context', key, snapshot)
  497. ctx.body = { snapshot }
  498. })
  499. registerVoice(router, store, nodes, owner, auth)
  500. return router
  501. }
  502. function registerVoice(
  503. router: Router,
  504. store: WorkspaceStore,
  505. vault: WorkspaceNodes,
  506. owner: (ctx: Koa.Context) => string,
  507. auth: LocalAuthStore,
  508. ): void {
  509. const read = (key: string): any => {
  510. const saved = store.get<string>('_system', 'voice', key)
  511. return saved ? vault.open(saved) : {}
  512. }
  513. const save = (key: string, value: unknown) =>
  514. store.put('_system', 'voice', key, vault.seal(value))
  515. const settings = () => ({
  516. voices: [],
  517. currentVoiceId: '',
  518. apiKey: '',
  519. updatedAt: 0,
  520. ...read('duplex'),
  521. })
  522. const publicSettings = () => {
  523. const { apiKey, ...value } = settings()
  524. return { ...value, hasApiKey: Boolean(apiKey) }
  525. }
  526. router.get('/api/app/admin/duplex-voice', (ctx) => {
  527. auth.requireAdmin(ctx)
  528. ctx.body = publicSettings()
  529. })
  530. router.put('/api/app/admin/duplex-voice', (ctx) => {
  531. auth.requireAdmin(ctx)
  532. const b = parse(
  533. z
  534. .object({
  535. apiKey: z.string().max(4096).optional(),
  536. voices: z
  537. .array(z.object({ id: z.string().min(1).max(200), name: z.string().min(1).max(200) }))
  538. .max(100),
  539. currentVoiceId: z.string().max(200),
  540. })
  541. .strict(),
  542. body(ctx),
  543. )
  544. if (
  545. new Set(b.voices.map((v) => v.id)).size !== b.voices.length ||
  546. (b.currentVoiceId && !b.voices.some((v) => v.id === b.currentVoiceId))
  547. )
  548. throw new HttpError(400, '音色设置无效')
  549. save('duplex', { ...settings(), ...b, updatedAt: Date.now() })
  550. ctx.body = publicSettings()
  551. })
  552. router.get('/api/app/voice/runtime', (ctx) => {
  553. const user = owner(ctx),
  554. value = settings()
  555. ctx.body = {
  556. ...value,
  557. currentVoiceId: store.get(user, 'voice-selection', 'current') ?? value.currentVoiceId,
  558. }
  559. })
  560. router.put('/api/app/voice/current-voice', (ctx) => {
  561. const user = owner(ctx),
  562. value = settings(),
  563. id = parse(z.object({ currentVoiceId: z.string() }).strict(), body(ctx)).currentVoiceId
  564. if (!value.voices.some((v: any) => v.id === id)) throw new HttpError(400, '音色不存在')
  565. store.put(user, 'voice-selection', 'current', id)
  566. ctx.body = { voices: value.voices, currentVoiceId: id, updatedAt: Date.now() }
  567. })
  568. for (const kind of ['tts', 'stt']) {
  569. const list = () => {
  570. const data = read(kind)
  571. return {
  572. activeProvider: data.activeProvider ?? (kind === 'tts' ? 'edge' : 'browser'),
  573. settings: Object.entries(data.providers ?? {}).map(([provider, v]: [string, any]) => ({
  574. provider,
  575. settings: v.settings,
  576. secrets: Object.fromEntries(Object.keys(v.secrets ?? {}).map((k) => [k, '[stored]'])),
  577. })),
  578. }
  579. }
  580. router.get(`/api/app/${kind}/settings`, (ctx) => {
  581. owner(ctx)
  582. ctx.body = list()
  583. })
  584. router.put(`/api/app/${kind}/settings/active`, (ctx) => {
  585. auth.requireAdmin(ctx)
  586. const data = read(kind),
  587. provider = parse(
  588. z.object({ provider: z.string().min(1).max(100) }).strict(),
  589. body(ctx),
  590. ).provider
  591. if (!data.providers?.[provider] && !['edge', 'browser'].includes(provider))
  592. throw new HttpError(400, '请先配置服务商')
  593. data.activeProvider = provider
  594. save(kind, data)
  595. ctx.body = list()
  596. })
  597. router.put(`/api/app/${kind}/settings/:provider`, (ctx) => {
  598. auth.requireAdmin(ctx)
  599. const input = parse(
  600. z
  601. .object({
  602. settings: z.record(z.string(), z.unknown()).default({}),
  603. secrets: z.record(z.string(), z.string().max(4096)).default({}),
  604. activeProvider: z.string().optional(),
  605. })
  606. .strict(),
  607. body(ctx),
  608. )
  609. if (JSON.stringify(input.settings).length > 32_000) throw new HttpError(413, '设置过大')
  610. const data = read(kind)
  611. data.providers ??= {}
  612. const old = data.providers[ctx.params.provider] ?? { settings: {}, secrets: {} }
  613. data.providers[ctx.params.provider] = {
  614. settings: { ...old.settings, ...input.settings },
  615. secrets: {
  616. ...old.secrets,
  617. ...Object.fromEntries(Object.entries(input.secrets).filter(([, v]) => v !== '[stored]')),
  618. },
  619. }
  620. if (input.activeProvider) data.activeProvider = input.activeProvider
  621. save(kind, data)
  622. ctx.body = list()
  623. })
  624. router.delete(`/api/app/${kind}/settings/:provider`, (ctx) => {
  625. auth.requireAdmin(ctx)
  626. const data = read(kind)
  627. if (data.providers) delete data.providers[ctx.params.provider]
  628. if (data.activeProvider === ctx.params.provider)
  629. data.activeProvider = kind === 'tts' ? 'edge' : 'browser'
  630. save(kind, data)
  631. ctx.body = list()
  632. })
  633. router.delete(`/api/app/${kind}/settings/:provider/secret/:secret`, (ctx) => {
  634. auth.requireAdmin(ctx)
  635. const data = read(kind)
  636. if (data.providers?.[ctx.params.provider]?.secrets)
  637. delete data.providers[ctx.params.provider].secrets[ctx.params.secret]
  638. save(kind, data)
  639. ctx.body = list()
  640. })
  641. router.delete(`/api/app/${kind}/settings/:provider/base-url-preset`, (ctx) => {
  642. auth.requireAdmin(ctx)
  643. const data = read(kind),
  644. s = data.providers?.[ctx.params.provider]?.settings
  645. if (s) s.baseUrlPresets = (s.baseUrlPresets ?? []).filter((v: string) => v !== ctx.query.url)
  646. save(kind, data)
  647. ctx.body = list()
  648. })
  649. }
  650. router.get('/api/app/voice/providers-info', (ctx) => {
  651. owner(ctx)
  652. ctx.body = {
  653. tts: ['edge', 'openai', 'custom', 'mimo', 'doubao'],
  654. stt: ['browser', 'openai', 'custom', 'doubao'],
  655. }
  656. })
  657. router.post('/api/app/voice/probe', async (ctx) => {
  658. auth.requireAdmin(ctx)
  659. const b = parse(
  660. z
  661. .object({
  662. kind: z.enum(['tts', 'stt']),
  663. provider: z.string().optional(),
  664. compatibility: z.enum(['manual', 'openai-compatible']).default('openai-compatible'),
  665. baseUrl: z.string().url(),
  666. apiKey: z.string().max(4096).optional(),
  667. })
  668. .strict(),
  669. body(ctx),
  670. ),
  671. url = new URL(b.baseUrl)
  672. if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password)
  673. throw new HttpError(400, '服务地址无效')
  674. if (b.compatibility === 'manual') {
  675. ctx.body = { ok: true, models: [], normalizedBaseUrl: b.baseUrl, manualModelAllowed: true }
  676. return
  677. }
  678. url.pathname = `${url.pathname.replace(/\/$/, '')}/models`
  679. const response = await fetch(url, {
  680. headers: b.apiKey ? { Authorization: `Bearer ${b.apiKey}` } : {},
  681. signal: AbortSignal.timeout(10_000),
  682. redirect: 'error',
  683. })
  684. const value = (await response.json()) as any
  685. ctx.body = {
  686. ok: response.ok,
  687. models: Array.isArray(value.data)
  688. ? value.data.slice(0, 500).map((m: any) => String(m.id))
  689. : [],
  690. normalizedBaseUrl: b.baseUrl,
  691. manualModelAllowed: true,
  692. errorSummary: response.ok ? '' : `HTTP ${response.status}`,
  693. }
  694. })
  695. }