workspaceRoutes.ts 28 KB

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