workspaceRoutes.ts 29 KB

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