workspaceRoutes.ts 27 KB

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