waypoi 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (260) hide show
  1. package/.github/instructions/ui.instructions.md +42 -0
  2. package/.github/workflows/ci.yml +35 -0
  3. package/.github/workflows/publish.yml +71 -0
  4. package/.github/workflows/release.yml +48 -0
  5. package/.playwright-mcp/console-2026-04-04T01-41-10-746Z.log +2 -0
  6. package/.playwright-mcp/console-2026-04-04T01-41-28-799Z.log +3 -0
  7. package/.playwright-mcp/console-2026-04-05T02-26-51-909Z.log +76 -0
  8. package/.playwright-mcp/page-2026-04-04T01-41-10-816Z.yml +1 -0
  9. package/.playwright-mcp/page-2026-04-04T01-41-29-141Z.yml +77 -0
  10. package/.playwright-mcp/page-2026-04-04T01-41-42-633Z.yml +190 -0
  11. package/.playwright-mcp/page-2026-04-04T01-42-03-929Z.yml +262 -0
  12. package/.playwright-mcp/page-2026-04-04T02-12-54-813Z.yml +6 -0
  13. package/.playwright-mcp/page-2026-04-04T02-14-58-600Z.yml +190 -0
  14. package/.playwright-mcp/page-2026-04-04T02-15-03-923Z.yml +190 -0
  15. package/.playwright-mcp/page-2026-04-04T02-15-07-426Z.yml +190 -0
  16. package/.playwright-mcp/page-2026-04-04T02-15-25-729Z.yml +262 -0
  17. package/.playwright-mcp/page-2026-04-04T02-16-22-984Z.yml +262 -0
  18. package/.playwright-mcp/page-2026-04-04T02-17-00-599Z.yml +190 -0
  19. package/.playwright-mcp/page-2026-04-04T02-17-50-874Z.yml +190 -0
  20. package/.playwright-mcp/page-2026-04-05T02-26-55-570Z.yml +6 -0
  21. package/AGENTS.md +48 -0
  22. package/CHANGELOG.md +131 -0
  23. package/README.md +552 -0
  24. package/assets/agent-mode.png +0 -0
  25. package/assets/categorize.png +0 -0
  26. package/assets/dashboard.png +0 -0
  27. package/assets/endpoint-proxy.png +0 -0
  28. package/assets/icon.png +0 -0
  29. package/assets/mcp-generate-image.png +0 -0
  30. package/assets/mcp-understand-image.png +0 -0
  31. package/assets/peek-token-flow.png +0 -0
  32. package/assets/playground.png +0 -0
  33. package/assets/sankey.png +0 -0
  34. package/cli/index.ts +2805 -0
  35. package/cli/legacyRewrite.ts +108 -0
  36. package/cli/modelRef.ts +24 -0
  37. package/dist/cli/index.js +2536 -0
  38. package/dist/cli/legacyRewrite.js +92 -0
  39. package/dist/cli/modelRef.js +20 -0
  40. package/dist/src/benchmark/artifacts.js +131 -0
  41. package/dist/src/benchmark/capabilityClassifier.js +81 -0
  42. package/dist/src/benchmark/capabilityStore.js +144 -0
  43. package/dist/src/benchmark/config.js +238 -0
  44. package/dist/src/benchmark/gates.js +118 -0
  45. package/dist/src/benchmark/jobs.js +252 -0
  46. package/dist/src/benchmark/runner.js +1847 -0
  47. package/dist/src/benchmark/schema.js +353 -0
  48. package/dist/src/benchmark/suites.js +314 -0
  49. package/dist/src/benchmark/tinyQaDataset.js +422 -0
  50. package/dist/src/benchmark/types.js +25 -0
  51. package/dist/src/config.js +47 -0
  52. package/dist/src/index.js +178 -0
  53. package/dist/src/mcp/client.js +215 -0
  54. package/dist/src/mcp/discovery.js +226 -0
  55. package/dist/src/mcp/policy.js +65 -0
  56. package/dist/src/mcp/registry.js +129 -0
  57. package/dist/src/mcp/service.js +460 -0
  58. package/dist/src/middleware/auth.js +179 -0
  59. package/dist/src/middleware/requestCapture.js +192 -0
  60. package/dist/src/middleware/requestStats.js +118 -0
  61. package/dist/src/pools/builder.js +132 -0
  62. package/dist/src/pools/repository.js +69 -0
  63. package/dist/src/pools/scheduler.js +360 -0
  64. package/dist/src/pools/types.js +2 -0
  65. package/dist/src/protocols/adapters/dashscope.js +267 -0
  66. package/dist/src/protocols/adapters/inferenceV2.js +346 -0
  67. package/dist/src/protocols/adapters/openai.js +27 -0
  68. package/dist/src/protocols/registry.js +99 -0
  69. package/dist/src/protocols/types.js +2 -0
  70. package/dist/src/providers/health.js +153 -0
  71. package/dist/src/providers/importer.js +289 -0
  72. package/dist/src/providers/modelRegistry.js +313 -0
  73. package/dist/src/providers/repository.js +361 -0
  74. package/dist/src/providers/types.js +2 -0
  75. package/dist/src/routes/admin.js +531 -0
  76. package/dist/src/routes/audio.js +295 -0
  77. package/dist/src/routes/chat.js +240 -0
  78. package/dist/src/routes/embeddings.js +157 -0
  79. package/dist/src/routes/images.js +288 -0
  80. package/dist/src/routes/mcp.js +256 -0
  81. package/dist/src/routes/mcpService.js +100 -0
  82. package/dist/src/routes/models.js +48 -0
  83. package/dist/src/routes/responses.js +711 -0
  84. package/dist/src/routes/sessions.js +450 -0
  85. package/dist/src/routes/stats.js +270 -0
  86. package/dist/src/routes/ui.js +97 -0
  87. package/dist/src/routes/videos.js +107 -0
  88. package/dist/src/routing/router.js +338 -0
  89. package/dist/src/services/imageGeneration.js +280 -0
  90. package/dist/src/services/imageUnderstanding.js +352 -0
  91. package/dist/src/services/videoGeneration.js +79 -0
  92. package/dist/src/storage/captureRepository.js +1591 -0
  93. package/dist/src/storage/files.js +157 -0
  94. package/dist/src/storage/imageCache.js +346 -0
  95. package/dist/src/storage/repositories.js +388 -0
  96. package/dist/src/storage/sessionRepository.js +370 -0
  97. package/dist/src/storage/statsRepository.js +204 -0
  98. package/dist/src/transport/httpClient.js +126 -0
  99. package/dist/src/types.js +2 -0
  100. package/dist/src/utils/messageMedia.js +285 -0
  101. package/dist/src/utils/modelCapabilities.js +108 -0
  102. package/dist/src/utils/modelDiscovery.js +170 -0
  103. package/dist/src/version.js +5 -0
  104. package/dist/src/workers/captureRetention.js +25 -0
  105. package/dist/src/workers/configWatcher.js +91 -0
  106. package/dist/src/workers/healthChecker.js +21 -0
  107. package/dist/src/workers/statsRotation.js +41 -0
  108. package/docs/LLM/output_schema.md +312 -0
  109. package/docs/benchmark.md +208 -0
  110. package/docs/mcp-guidelines.md +125 -0
  111. package/docs/mcp-service.md +178 -0
  112. package/docs/opencode.md +86 -0
  113. package/docs/providers.md +79 -0
  114. package/examples/benchmark.config.yaml +28 -0
  115. package/examples/providers/alibaba-dashscope.yaml +88 -0
  116. package/examples/providers/alibaba-llm.yaml +64 -0
  117. package/examples/providers/alibaba-registry.yaml +7 -0
  118. package/examples/providers/inference-v2-ray.yaml +29 -0
  119. package/examples/scenarios/assets/omni-call-sample.wav +0 -0
  120. package/examples/scenarios/custom.jsonl +5 -0
  121. package/examples/scenarios/custom.yaml +40 -0
  122. package/model-form-v2.png +0 -0
  123. package/package.json +66 -0
  124. package/provider-form-v2.png +0 -0
  125. package/provider-form.png +0 -0
  126. package/scripts/manual-test.sh +11 -0
  127. package/scripts/version-from-git.js +23 -0
  128. package/src/benchmark/artifacts.ts +149 -0
  129. package/src/benchmark/capabilityClassifier.ts +99 -0
  130. package/src/benchmark/capabilityStore.ts +174 -0
  131. package/src/benchmark/config.ts +337 -0
  132. package/src/benchmark/gates.ts +164 -0
  133. package/src/benchmark/jobs.ts +312 -0
  134. package/src/benchmark/runner.ts +2519 -0
  135. package/src/benchmark/schema.ts +443 -0
  136. package/src/benchmark/suites.ts +323 -0
  137. package/src/benchmark/tinyQaDataset.ts +428 -0
  138. package/src/benchmark/types.ts +442 -0
  139. package/src/config.ts +44 -0
  140. package/src/index.ts +195 -0
  141. package/src/mcp/client.ts +305 -0
  142. package/src/mcp/discovery.ts +266 -0
  143. package/src/mcp/policy.ts +105 -0
  144. package/src/mcp/registry.ts +164 -0
  145. package/src/mcp/service.ts +611 -0
  146. package/src/middleware/auth.ts +251 -0
  147. package/src/middleware/requestCapture.ts +245 -0
  148. package/src/middleware/requestStats.ts +163 -0
  149. package/src/pools/builder.ts +159 -0
  150. package/src/pools/repository.ts +71 -0
  151. package/src/pools/scheduler.ts +425 -0
  152. package/src/pools/types.ts +117 -0
  153. package/src/protocols/adapters/dashscope.ts +335 -0
  154. package/src/protocols/adapters/inferenceV2.ts +428 -0
  155. package/src/protocols/adapters/openai.ts +32 -0
  156. package/src/protocols/registry.ts +117 -0
  157. package/src/protocols/types.ts +81 -0
  158. package/src/providers/health.ts +207 -0
  159. package/src/providers/importer.ts +402 -0
  160. package/src/providers/modelRegistry.ts +415 -0
  161. package/src/providers/repository.ts +439 -0
  162. package/src/providers/types.ts +113 -0
  163. package/src/routes/admin.ts +666 -0
  164. package/src/routes/audio.ts +372 -0
  165. package/src/routes/chat.ts +301 -0
  166. package/src/routes/embeddings.ts +197 -0
  167. package/src/routes/images.ts +356 -0
  168. package/src/routes/mcp.ts +320 -0
  169. package/src/routes/mcpService.ts +114 -0
  170. package/src/routes/models.ts +50 -0
  171. package/src/routes/responses.ts +872 -0
  172. package/src/routes/sessions.ts +558 -0
  173. package/src/routes/stats.ts +312 -0
  174. package/src/routes/ui.ts +96 -0
  175. package/src/routes/videos.ts +132 -0
  176. package/src/routing/router.ts +501 -0
  177. package/src/services/imageGeneration.ts +396 -0
  178. package/src/services/imageUnderstanding.ts +449 -0
  179. package/src/services/videoGeneration.ts +127 -0
  180. package/src/storage/captureRepository.ts +1835 -0
  181. package/src/storage/files.ts +178 -0
  182. package/src/storage/imageCache.ts +405 -0
  183. package/src/storage/repositories.ts +494 -0
  184. package/src/storage/sessionRepository.ts +419 -0
  185. package/src/storage/statsRepository.ts +238 -0
  186. package/src/transport/httpClient.ts +145 -0
  187. package/src/types.ts +322 -0
  188. package/src/utils/messageMedia.ts +293 -0
  189. package/src/utils/modelCapabilities.ts +161 -0
  190. package/src/utils/modelDiscovery.ts +203 -0
  191. package/src/workers/captureRetention.ts +25 -0
  192. package/src/workers/configWatcher.ts +115 -0
  193. package/src/workers/healthChecker.ts +22 -0
  194. package/src/workers/statsRotation.ts +49 -0
  195. package/tests/benchmarkAdminRoutes.test.ts +82 -0
  196. package/tests/benchmarkBasics.test.ts +116 -0
  197. package/tests/captureAdminRoutes.test.ts +420 -0
  198. package/tests/captureRepository.test.ts +797 -0
  199. package/tests/cliLegacyRewrite.test.ts +45 -0
  200. package/tests/imageGeneration.service.test.ts +107 -0
  201. package/tests/imageUnderstanding.service.test.ts +123 -0
  202. package/tests/mcpPolicy.test.ts +105 -0
  203. package/tests/mcpService.test.ts +1245 -0
  204. package/tests/modelRef.test.ts +23 -0
  205. package/tests/modelsRoutes.test.ts +154 -0
  206. package/tests/sessionMediaCache.test.ts +167 -0
  207. package/tests/statsRoutes.test.ts +323 -0
  208. package/tsconfig.json +15 -0
  209. package/ui/index.html +16 -0
  210. package/ui/package-lock.json +8521 -0
  211. package/ui/package.json +52 -0
  212. package/ui/postcss.config.js +6 -0
  213. package/ui/public/assets/apple-touch-icon.png +0 -0
  214. package/ui/public/assets/favicon-16.png +0 -0
  215. package/ui/public/assets/favicon-32.png +0 -0
  216. package/ui/public/assets/icon-192.png +0 -0
  217. package/ui/public/assets/icon-512.png +0 -0
  218. package/ui/src/App.tsx +27 -0
  219. package/ui/src/api/client.ts +1503 -0
  220. package/ui/src/components/EndpointUsageGuide.tsx +361 -0
  221. package/ui/src/components/Layout.tsx +124 -0
  222. package/ui/src/components/MessageContent.tsx +365 -0
  223. package/ui/src/components/ToolCallMessage.tsx +179 -0
  224. package/ui/src/components/ToolPicker.tsx +442 -0
  225. package/ui/src/components/messageContentParser.test.ts +41 -0
  226. package/ui/src/components/messageContentParser.ts +73 -0
  227. package/ui/src/components/thinkingPreview.test.ts +27 -0
  228. package/ui/src/components/thinkingPreview.ts +15 -0
  229. package/ui/src/components/toMermaidSankey.test.ts +78 -0
  230. package/ui/src/components/toMermaidSankey.ts +56 -0
  231. package/ui/src/components/ui/button.tsx +58 -0
  232. package/ui/src/components/ui/input.tsx +21 -0
  233. package/ui/src/components/ui/textarea.tsx +21 -0
  234. package/ui/src/lib/utils.ts +6 -0
  235. package/ui/src/main.tsx +9 -0
  236. package/ui/src/pages/AgentPlayground.tsx +2010 -0
  237. package/ui/src/pages/Benchmark.tsx +988 -0
  238. package/ui/src/pages/Dashboard.tsx +581 -0
  239. package/ui/src/pages/Peek.tsx +962 -0
  240. package/ui/src/pages/Settings.tsx +2013 -0
  241. package/ui/src/pages/agentPlaygroundPayload.test.ts +109 -0
  242. package/ui/src/pages/agentPlaygroundPayload.ts +97 -0
  243. package/ui/src/pages/agentThinkingContent.test.ts +50 -0
  244. package/ui/src/pages/agentThinkingContent.ts +57 -0
  245. package/ui/src/pages/dashboardTokenUsage.test.ts +66 -0
  246. package/ui/src/pages/dashboardTokenUsage.ts +36 -0
  247. package/ui/src/pages/imageUpload.test.ts +39 -0
  248. package/ui/src/pages/imageUpload.ts +71 -0
  249. package/ui/src/pages/peekFilters.test.ts +29 -0
  250. package/ui/src/pages/peekFilters.ts +13 -0
  251. package/ui/src/pages/peekMedia.test.ts +58 -0
  252. package/ui/src/pages/peekMedia.ts +148 -0
  253. package/ui/src/pages/sessionAutoTitle.test.ts +128 -0
  254. package/ui/src/pages/sessionAutoTitle.ts +106 -0
  255. package/ui/src/stores/settings.ts +58 -0
  256. package/ui/src/styles/globals.css +223 -0
  257. package/ui/src/vite-env.d.ts +8 -0
  258. package/ui/tailwind.config.js +106 -0
  259. package/ui/tsconfig.json +32 -0
  260. package/ui/vite.config.ts +37 -0
@@ -0,0 +1,558 @@
1
+ import { FastifyInstance, FastifyRequest, FastifyReply } from "fastify";
2
+ import {
3
+ listSessions,
4
+ getSession,
5
+ createSession,
6
+ updateSession,
7
+ deleteSession,
8
+ addMessage,
9
+ appendMessageContent,
10
+ } from "../storage/sessionRepository";
11
+ import { storeMedia, getMediaPath, getMediaEntry, getCacheStats, clearCache, ensureMediaCacheReady } from "../storage/imageCache";
12
+ import { resolveStoragePaths } from "../storage/files";
13
+ import { ChatMessage } from "../types";
14
+ import { promises as fs } from "fs";
15
+ import path from "path";
16
+ import { routeRequest } from "../routing/router";
17
+ import { pickBestModelByCapabilities } from "../storage/repositories";
18
+
19
+ /**
20
+ * Sessions Routes
21
+ *
22
+ * REST API for managing chat sessions in the playground.
23
+ *
24
+ * Endpoints:
25
+ * GET /admin/sessions - List all sessions
26
+ * POST /admin/sessions - Create a new session
27
+ * GET /admin/sessions/:id - Get session by ID
28
+ * PUT /admin/sessions/:id - Update session metadata
29
+ * DELETE /admin/sessions/:id - Delete a session
30
+ * POST /admin/sessions/:id/messages - Add a message to session
31
+ * PATCH /admin/sessions/:id/messages/:msgIndex - Append to message (streaming)
32
+ *
33
+ * GET /admin/images/:hash - Get cached image by hash
34
+ * POST /admin/images - Store image in cache
35
+ * GET /admin/images/stats - Get image cache stats
36
+ * DELETE /admin/images - Clear image cache
37
+ */
38
+
39
+ export async function registerSessionRoutes(app: FastifyInstance): Promise<void> {
40
+ const paths = resolveStoragePaths();
41
+ await ensureMediaCacheReady(paths);
42
+ app.log.info({ mediaRoot: path.join(paths.baseDir, "media") }, "Media cache initialized");
43
+
44
+ // ─────────────────────────────────────────────────────────────────────────────
45
+ // Session CRUD
46
+ // ─────────────────────────────────────────────────────────────────────────────
47
+
48
+ app.get("/admin/sessions", async (_req: FastifyRequest, reply: FastifyReply) => {
49
+ try {
50
+ const sessions = await listSessions(paths);
51
+ return reply.send({
52
+ object: "list",
53
+ data: sessions.map((s) => ({
54
+ id: s.id,
55
+ name: s.name,
56
+ model: s.model,
57
+ storageVersion: s.storageVersion,
58
+ titleStatus: s.titleStatus,
59
+ titleUpdatedAt: s.titleUpdatedAt,
60
+ messageCount: s.messages.length,
61
+ createdAt: s.createdAt,
62
+ updatedAt: s.updatedAt,
63
+ })),
64
+ });
65
+ } catch (error) {
66
+ app.log.error(error, "Failed to list sessions");
67
+ return reply.status(500).send({
68
+ error: { message: "Failed to list sessions", type: "internal_error" },
69
+ });
70
+ }
71
+ });
72
+
73
+ app.post(
74
+ "/admin/sessions",
75
+ async (
76
+ req: FastifyRequest<{ Body: { name?: string; model?: string } }>,
77
+ reply: FastifyReply
78
+ ) => {
79
+ try {
80
+ const { name, model } = req.body || {};
81
+ const session = await createSession(paths, { name, model });
82
+ return reply.status(201).send(toApiSession(session));
83
+ } catch (error) {
84
+ app.log.error(error, "Failed to create session");
85
+ return reply.status(500).send({
86
+ error: { message: "Failed to create session", type: "internal_error" },
87
+ });
88
+ }
89
+ }
90
+ );
91
+
92
+ app.post(
93
+ "/admin/sessions/:id/auto-title",
94
+ async (
95
+ req: FastifyRequest<{ Params: { id: string }; Body: { model?: string; seedText?: string } }>,
96
+ reply: FastifyReply
97
+ ) => {
98
+ try {
99
+ const session = await getSession(paths, req.params.id);
100
+ if (!session) {
101
+ return reply.status(404).send({
102
+ error: { message: "Session not found", type: "not_found" },
103
+ });
104
+ }
105
+
106
+ const seedText =
107
+ req.body?.seedText?.trim() || extractSeedTextFromSession(session.messages);
108
+ if (!seedText) {
109
+ return reply.status(400).send({
110
+ error: { message: "seedText is required for auto-title", type: "invalid_request" },
111
+ });
112
+ }
113
+
114
+ const model =
115
+ req.body?.model ||
116
+ session.model ||
117
+ (await pickBestModelByCapabilities(
118
+ paths,
119
+ { requiredInput: ["text"], requiredOutput: ["text"] },
120
+ "llm"
121
+ ));
122
+
123
+ let generated = false;
124
+ let title = fallbackTitleFromSeed(seedText);
125
+
126
+ if (model) {
127
+ try {
128
+ title = await generateTitleFromModel(paths, model, seedText);
129
+ generated = true;
130
+ } catch (error) {
131
+ app.log.warn(error, "Auto-title generation failed, using fallback title");
132
+ }
133
+ }
134
+
135
+ const updated = await updateSession(paths, req.params.id, {
136
+ name: title,
137
+ titleStatus: generated ? "generated" : "failed",
138
+ titleUpdatedAt: new Date(),
139
+ });
140
+ if (!updated) {
141
+ return reply.status(404).send({
142
+ error: { message: "Session not found", type: "not_found" },
143
+ });
144
+ }
145
+ return reply.send({
146
+ id: updated.id,
147
+ name: updated.name,
148
+ titleStatus: updated.titleStatus,
149
+ titleUpdatedAt: updated.titleUpdatedAt,
150
+ generated,
151
+ model,
152
+ });
153
+ } catch (error) {
154
+ app.log.error(error, "Failed to auto-title session");
155
+ return reply.status(500).send({
156
+ error: { message: "Failed to auto-title session", type: "internal_error" },
157
+ });
158
+ }
159
+ }
160
+ );
161
+
162
+ app.get(
163
+ "/admin/sessions/:id",
164
+ async (req: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => {
165
+ try {
166
+ const session = await getSession(paths, req.params.id);
167
+ if (!session) {
168
+ return reply.status(404).send({
169
+ error: { message: "Session not found", type: "not_found" },
170
+ });
171
+ }
172
+ return reply.send(toApiSession(session));
173
+ } catch (error) {
174
+ app.log.error(error, "Failed to get session");
175
+ return reply.status(500).send({
176
+ error: { message: "Failed to get session", type: "internal_error" },
177
+ });
178
+ }
179
+ }
180
+ );
181
+
182
+ app.put(
183
+ "/admin/sessions/:id",
184
+ async (
185
+ req: FastifyRequest<{ Params: { id: string }; Body: { name?: string; model?: string } }>,
186
+ reply: FastifyReply
187
+ ) => {
188
+ try {
189
+ const updates: Partial<{ name: string; model: string }> = {};
190
+ if (req.body?.name !== undefined) updates.name = req.body.name;
191
+ if (req.body?.model !== undefined) updates.model = req.body.model;
192
+
193
+ const session = await updateSession(paths, req.params.id, updates);
194
+ if (!session) {
195
+ return reply.status(404).send({
196
+ error: { message: "Session not found", type: "not_found" },
197
+ });
198
+ }
199
+ return reply.send(toApiSession(session));
200
+ } catch (error) {
201
+ app.log.error(error, "Failed to update session");
202
+ return reply.status(500).send({
203
+ error: { message: "Failed to update session", type: "internal_error" },
204
+ });
205
+ }
206
+ }
207
+ );
208
+
209
+ app.delete(
210
+ "/admin/sessions/:id",
211
+ async (req: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => {
212
+ try {
213
+ const deleted = await deleteSession(paths, req.params.id);
214
+ if (!deleted) {
215
+ return reply.status(404).send({
216
+ error: { message: "Session not found", type: "not_found" },
217
+ });
218
+ }
219
+ return reply.status(204).send();
220
+ } catch (error) {
221
+ app.log.error(error, "Failed to delete session");
222
+ return reply.status(500).send({
223
+ error: { message: "Failed to delete session", type: "internal_error" },
224
+ });
225
+ }
226
+ }
227
+ );
228
+
229
+ // ─────────────────────────────────────────────────────────────────────────────
230
+ // Message management
231
+ // ─────────────────────────────────────────────────────────────────────────────
232
+
233
+ app.post(
234
+ "/admin/sessions/:id/messages",
235
+ async (
236
+ req: FastifyRequest<{ Params: { id: string }; Body: IncomingChatMessage }>,
237
+ reply: FastifyReply
238
+ ) => {
239
+ try {
240
+ const message = await addMessage(paths, req.params.id, normalizeIncomingMessage(req.body));
241
+
242
+ if (!message) {
243
+ return reply.status(404).send({
244
+ error: { message: "Session not found", type: "not_found" },
245
+ });
246
+ }
247
+ return reply.status(201).send({
248
+ messageId: message.id,
249
+ createdAt: message.createdAt,
250
+ });
251
+ } catch (error) {
252
+ app.log.error(error, "Failed to add message");
253
+ return reply.status(500).send({
254
+ error: { message: "Failed to add message", type: "internal_error" },
255
+ });
256
+ }
257
+ }
258
+ );
259
+
260
+ app.patch(
261
+ "/admin/sessions/:id/messages/:messageId",
262
+ async (
263
+ req: FastifyRequest<{
264
+ Params: { id: string; messageId: string };
265
+ Body: { content: string };
266
+ }>,
267
+ reply: FastifyReply
268
+ ) => {
269
+ try {
270
+ const success = await appendMessageContent(
271
+ paths,
272
+ req.params.id,
273
+ req.params.messageId,
274
+ req.body?.content || ""
275
+ );
276
+ if (!success) {
277
+ return reply.status(404).send({
278
+ error: { message: "Session or message not found", type: "not_found" },
279
+ });
280
+ }
281
+ return reply.send({ success: true });
282
+ } catch (error) {
283
+ app.log.error(error, "Failed to append to message");
284
+ return reply.status(500).send({
285
+ error: { message: "Failed to append to message", type: "internal_error" },
286
+ });
287
+ }
288
+ }
289
+ );
290
+
291
+ // ─────────────────────────────────────────────────────────────────────────────
292
+ // Media cache (/admin/media) with image alias compatibility (/admin/images)
293
+ // ─────────────────────────────────────────────────────────────────────────────
294
+ registerMediaCacheRoutes("/admin/media");
295
+ registerMediaCacheRoutes("/admin/images");
296
+
297
+ function registerMediaCacheRoutes(prefix: "/admin/media" | "/admin/images"): void {
298
+ app.get(`${prefix}/stats`, async (_req: FastifyRequest, reply: FastifyReply) => {
299
+ try {
300
+ const stats = await getCacheStats(paths);
301
+ return reply.send(stats);
302
+ } catch (error) {
303
+ app.log.error(error, "Failed to get media cache stats");
304
+ return reply.status(500).send({
305
+ error: { message: "Failed to get cache stats", type: "internal_error" },
306
+ });
307
+ }
308
+ });
309
+
310
+ app.get(
311
+ `${prefix}/:hash`,
312
+ async (req: FastifyRequest<{ Params: { hash: string } }>, reply: FastifyReply) => {
313
+ try {
314
+ const filePath = await getMediaPath(paths, req.params.hash);
315
+ const entry = await getMediaEntry(paths, req.params.hash);
316
+ if (!filePath || !entry) {
317
+ return reply.status(404).send({
318
+ error: { message: "Media not found", type: "not_found" },
319
+ });
320
+ }
321
+
322
+ const buffer = await fs.readFile(filePath);
323
+ return reply
324
+ .header("Content-Type", entry.mimeType || guessMimeType(filePath))
325
+ .header("Cache-Control", "public, max-age=31536000, immutable")
326
+ .send(buffer);
327
+ } catch (error) {
328
+ app.log.error(error, "Failed to get media");
329
+ return reply.status(500).send({
330
+ error: { message: "Failed to get media", type: "internal_error" },
331
+ });
332
+ }
333
+ }
334
+ );
335
+
336
+ app.post(
337
+ `${prefix}`,
338
+ async (
339
+ req: FastifyRequest<{ Body: { data: string; model?: string; mimeType?: string } }>,
340
+ reply: FastifyReply
341
+ ) => {
342
+ try {
343
+ if (!req.body?.data) {
344
+ return reply.status(400).send({
345
+ error: { message: "Missing media data", type: "invalid_request" },
346
+ });
347
+ }
348
+
349
+ const result = await storeMedia(paths, req.body.data, {
350
+ model: req.body.model,
351
+ mimeType: req.body.mimeType,
352
+ });
353
+
354
+ return reply.status(201).send({
355
+ hash: result.hash,
356
+ mimeType: result.mimeType,
357
+ url: `${prefix}/${result.hash}`,
358
+ evicted: result.evicted,
359
+ });
360
+ } catch (error) {
361
+ app.log.error(error, "Failed to store media");
362
+ return reply.status(500).send({
363
+ error: { message: "Failed to store media", type: "internal_error" },
364
+ });
365
+ }
366
+ }
367
+ );
368
+
369
+ app.delete(`${prefix}`, async (_req: FastifyRequest, reply: FastifyReply) => {
370
+ try {
371
+ const deleted = await clearCache(paths);
372
+ return reply.send({ deleted });
373
+ } catch (error) {
374
+ app.log.error(error, "Failed to clear media cache");
375
+ return reply.status(500).send({
376
+ error: { message: "Failed to clear cache", type: "internal_error" },
377
+ });
378
+ }
379
+ });
380
+ }
381
+ }
382
+
383
+ type IncomingChatMessage = Partial<ChatMessage> & { timestamp?: string };
384
+
385
+ function normalizeIncomingMessage(body: IncomingChatMessage): Omit<ChatMessage, "id" | "createdAt"> {
386
+ return {
387
+ role: (body.role as ChatMessage["role"]) ?? "user",
388
+ content: body.content ?? "",
389
+ name: body.name,
390
+ tool_calls: body.tool_calls,
391
+ tool_call_id: body.tool_call_id,
392
+ images: body.images,
393
+ model: body.model,
394
+ };
395
+ }
396
+
397
+ function toApiSession(session: {
398
+ id: string;
399
+ name: string;
400
+ model?: string;
401
+ titleStatus?: "pending" | "generated" | "manual" | "failed";
402
+ titleUpdatedAt?: Date;
403
+ storageVersion: number;
404
+ messages: ChatMessage[];
405
+ createdAt: Date;
406
+ updatedAt: Date;
407
+ }) {
408
+ return {
409
+ ...session,
410
+ messages: session.messages.map((message) => ({
411
+ ...message,
412
+ timestamp: message.createdAt,
413
+ })),
414
+ };
415
+ }
416
+
417
+ function guessMimeType(filePath: string): string {
418
+ const ext = path.extname(filePath).slice(1).toLowerCase();
419
+ const mimeTypes: Record<string, string> = {
420
+ png: "image/png",
421
+ jpg: "image/jpeg",
422
+ jpeg: "image/jpeg",
423
+ gif: "image/gif",
424
+ webp: "image/webp",
425
+ wav: "audio/wav",
426
+ mp3: "audio/mpeg",
427
+ ogg: "audio/ogg",
428
+ webm: "audio/webm",
429
+ m4a: "audio/mp4",
430
+ };
431
+ return mimeTypes[ext] ?? "application/octet-stream";
432
+ }
433
+
434
+ function extractSeedTextFromSession(messages: ChatMessage[]): string {
435
+ const firstUserMessage = messages.find((message) => message.role === "user");
436
+ if (!firstUserMessage) {
437
+ return "";
438
+ }
439
+ return textFromContent(firstUserMessage.content);
440
+ }
441
+
442
+ function textFromContent(content: ChatMessage["content"]): string {
443
+ if (typeof content === "string") {
444
+ return content.trim();
445
+ }
446
+ if (!Array.isArray(content)) {
447
+ return "";
448
+ }
449
+ const parts: string[] = [];
450
+ for (const part of content) {
451
+ if (part.type === "text") {
452
+ parts.push(part.text);
453
+ }
454
+ }
455
+ return parts.join(" ").trim();
456
+ }
457
+
458
+ function fallbackTitleFromSeed(seed: string): string {
459
+ const cleaned = sanitizeTitle(seed);
460
+ if (!cleaned) {
461
+ return "New Session";
462
+ }
463
+ const words = cleaned.split(/\s+/).slice(0, 7).join(" ");
464
+ return words.length > 60 ? words.slice(0, 60).trim() : words;
465
+ }
466
+
467
+ async function generateTitleFromModel(paths: ReturnType<typeof resolveStoragePaths>, model: string, seedText: string): Promise<string> {
468
+ const prompt = [
469
+ "Generate a short title for this chat.",
470
+ "Rules: 3-7 words, sentence case, no quotes, no punctuation at end.",
471
+ "Return only the title text.",
472
+ `Chat seed: ${seedText}`,
473
+ ].join("\n");
474
+
475
+ const payload: Record<string, unknown> = {
476
+ model,
477
+ stream: false,
478
+ temperature: 0,
479
+ max_tokens: 32,
480
+ messages: [
481
+ {
482
+ role: "system",
483
+ content: "You write concise conversation titles.",
484
+ },
485
+ {
486
+ role: "user",
487
+ content: prompt,
488
+ },
489
+ ],
490
+ };
491
+
492
+ const outcome = await routeRequest(
493
+ paths,
494
+ model,
495
+ "/v1/chat/completions",
496
+ payload,
497
+ {},
498
+ AbortSignal.timeout(15_000),
499
+ {
500
+ requiredInput: ["text"],
501
+ requiredOutput: ["text"],
502
+ }
503
+ );
504
+
505
+ const payloadJson = await readJsonBody(outcome.attempt.response.body);
506
+ const text = extractAssistantText(payloadJson);
507
+ const sanitized = sanitizeTitle(text);
508
+ if (!sanitized) {
509
+ throw new Error("Model did not return a valid title");
510
+ }
511
+ return sanitized;
512
+ }
513
+
514
+ async function readJsonBody(stream: NodeJS.ReadableStream): Promise<unknown> {
515
+ const chunks: Buffer[] = [];
516
+ for await (const chunk of stream) {
517
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
518
+ }
519
+ const text = Buffer.concat(chunks).toString("utf8");
520
+ return JSON.parse(text);
521
+ }
522
+
523
+ function extractAssistantText(payload: unknown): string {
524
+ if (!payload || typeof payload !== "object") {
525
+ return "";
526
+ }
527
+ const choices = (payload as { choices?: Array<{ message?: { content?: unknown } }> }).choices;
528
+ if (!Array.isArray(choices) || choices.length === 0) {
529
+ return "";
530
+ }
531
+ const content = choices[0]?.message?.content;
532
+ if (typeof content === "string") {
533
+ return content;
534
+ }
535
+ if (!Array.isArray(content)) {
536
+ return "";
537
+ }
538
+ const parts: string[] = [];
539
+ for (const part of content) {
540
+ if (part && typeof part === "object" && typeof (part as { text?: unknown }).text === "string") {
541
+ parts.push((part as { text: string }).text);
542
+ }
543
+ }
544
+ return parts.join(" ");
545
+ }
546
+
547
+ function sanitizeTitle(input: string): string {
548
+ const normalized = input
549
+ .replace(/[\r\n]+/g, " ")
550
+ .replace(/["'`]+/g, "")
551
+ .replace(/\s+/g, " ")
552
+ .trim();
553
+ const trimmed = normalized.replace(/[.,;:!?]+$/g, "").trim();
554
+ if (!trimmed) {
555
+ return "";
556
+ }
557
+ return trimmed.length > 60 ? trimmed.slice(0, 60).trim() : trimmed;
558
+ }