aimeat 2.4.0 → 2.6.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 (308) hide show
  1. package/dist/.env.example +33 -0
  2. package/dist/build-stamp.json +13 -0
  3. package/dist/locales/en.json +186 -20
  4. package/dist/locales/fi.json +186 -20
  5. package/dist/public/components/VoiceRecorder.js +116 -0
  6. package/dist/public/connection-done.html +53 -0
  7. package/dist/public/css/components/voice-recorder.css +66 -0
  8. package/dist/public/css/views/inbox.css +41 -0
  9. package/dist/public/css/views/profile.css +171 -95
  10. package/dist/public/css/views/transparency.css +5 -0
  11. package/dist/public/js/services/audio-recorder.js +171 -0
  12. package/dist/public/js/services/messages.js +21 -2
  13. package/dist/public/spa.html +3 -0
  14. package/dist/public/views/landing.js +4 -0
  15. package/dist/public/views/profile/access-tab/connections.js +264 -0
  16. package/dist/public/views/profile/access-tab.js +2 -0
  17. package/dist/public/views/profile/ai-transparency-card.js +22 -17
  18. package/dist/public/views/profile/inbox-tab/components.js +60 -10
  19. package/dist/public/views/profile/inbox-tab/helpers.js +33 -0
  20. package/dist/public/views/profile/inbox-tab/panels.js +6 -1
  21. package/dist/public/views/profile/inbox-tab/use-voice.js +50 -0
  22. package/dist/public/views/profile/inbox-tab/voice-parts.js +111 -0
  23. package/dist/public/views/profile/inbox-tab.js +18 -24
  24. package/dist/public/views/profile/openrouter/budget-panel.js +224 -0
  25. package/dist/public/views/profile/openrouter/model-picker.js +150 -0
  26. package/dist/public/views/profile/openrouter/pricing.js +139 -0
  27. package/dist/public/views/profile/openrouter-settings.js +425 -488
  28. package/dist/public/views/transparency.js +50 -0
  29. package/dist/scripts/build-sdk-libs.d.ts.map +1 -1
  30. package/dist/scripts/build-sdk-libs.js +1 -0
  31. package/dist/scripts/build-sdk-libs.js.map +1 -1
  32. package/dist/scripts/check-ai-disclosure.d.ts.map +1 -1
  33. package/dist/scripts/check-ai-disclosure.js +182 -0
  34. package/dist/scripts/check-ai-disclosure.js.map +1 -1
  35. package/dist/src/cli/connect/ai-provenance-carry.d.ts +247 -0
  36. package/dist/src/cli/connect/ai-provenance-carry.d.ts.map +1 -0
  37. package/dist/src/cli/connect/ai-provenance-carry.js +334 -0
  38. package/dist/src/cli/connect/ai-provenance-carry.js.map +1 -0
  39. package/dist/src/cli/connect/api-client.d.ts +11 -0
  40. package/dist/src/cli/connect/api-client.d.ts.map +1 -1
  41. package/dist/src/cli/connect/api-client.js +2 -0
  42. package/dist/src/cli/connect/api-client.js.map +1 -1
  43. package/dist/src/cli/connect/mcp/local-server.d.ts.map +1 -1
  44. package/dist/src/cli/connect/mcp/local-server.js +11 -0
  45. package/dist/src/cli/connect/mcp/local-server.js.map +1 -1
  46. package/dist/src/cli/connect/mcp/tools/agent-messages.d.ts +2 -0
  47. package/dist/src/cli/connect/mcp/tools/agent-messages.d.ts.map +1 -1
  48. package/dist/src/cli/connect/mcp/tools/agent-messages.js +5 -2
  49. package/dist/src/cli/connect/mcp/tools/agent-messages.js.map +1 -1
  50. package/dist/src/cli/connect/mcp/tools/agent-tasks.d.ts +2 -0
  51. package/dist/src/cli/connect/mcp/tools/agent-tasks.d.ts.map +1 -1
  52. package/dist/src/cli/connect/mcp/tools/agent-tasks.js +5 -2
  53. package/dist/src/cli/connect/mcp/tools/agent-tasks.js.map +1 -1
  54. package/dist/src/cli/connect/mcp/tools/apps.d.ts +3 -0
  55. package/dist/src/cli/connect/mcp/tools/apps.d.ts.map +1 -1
  56. package/dist/src/cli/connect/mcp/tools/apps.js +13 -5
  57. package/dist/src/cli/connect/mcp/tools/apps.js.map +1 -1
  58. package/dist/src/cli/connect/mcp/tools/boards.d.ts +3 -0
  59. package/dist/src/cli/connect/mcp/tools/boards.d.ts.map +1 -1
  60. package/dist/src/cli/connect/mcp/tools/boards.js +5 -2
  61. package/dist/src/cli/connect/mcp/tools/boards.js.map +1 -1
  62. package/dist/src/cli/connect/mcp/tools/core.d.ts +5 -0
  63. package/dist/src/cli/connect/mcp/tools/core.d.ts.map +1 -1
  64. package/dist/src/cli/connect/mcp/tools/core.js +25 -5
  65. package/dist/src/cli/connect/mcp/tools/core.js.map +1 -1
  66. package/dist/src/cli/connect/mcp/tools/dm-messages.d.ts +2 -0
  67. package/dist/src/cli/connect/mcp/tools/dm-messages.d.ts.map +1 -1
  68. package/dist/src/cli/connect/mcp/tools/dm-messages.js +11 -6
  69. package/dist/src/cli/connect/mcp/tools/dm-messages.js.map +1 -1
  70. package/dist/src/cli/connect/mcp/tools/exchange.d.ts +2 -0
  71. package/dist/src/cli/connect/mcp/tools/exchange.d.ts.map +1 -1
  72. package/dist/src/cli/connect/mcp/tools/exchange.js +8 -2
  73. package/dist/src/cli/connect/mcp/tools/exchange.js.map +1 -1
  74. package/dist/src/cli/connect/mcp/tools/knowledge.d.ts +3 -0
  75. package/dist/src/cli/connect/mcp/tools/knowledge.d.ts.map +1 -1
  76. package/dist/src/cli/connect/mcp/tools/knowledge.js +7 -3
  77. package/dist/src/cli/connect/mcp/tools/knowledge.js.map +1 -1
  78. package/dist/src/cli/connect/mcp/tools/organisms.d.ts +2 -0
  79. package/dist/src/cli/connect/mcp/tools/organisms.d.ts.map +1 -1
  80. package/dist/src/cli/connect/mcp/tools/organisms.js +7 -2
  81. package/dist/src/cli/connect/mcp/tools/organisms.js.map +1 -1
  82. package/dist/src/cli/connect/mcp/tools/workspaces.d.ts +3 -0
  83. package/dist/src/cli/connect/mcp/tools/workspaces.d.ts.map +1 -1
  84. package/dist/src/cli/connect/mcp/tools/workspaces.js +8 -2
  85. package/dist/src/cli/connect/mcp/tools/workspaces.js.map +1 -1
  86. package/dist/src/cli/connect/tool-call-defs-core.d.ts +3 -0
  87. package/dist/src/cli/connect/tool-call-defs-core.d.ts.map +1 -1
  88. package/dist/src/cli/connect/tool-call-defs-core.js +7 -0
  89. package/dist/src/cli/connect/tool-call-defs-core.js.map +1 -1
  90. package/dist/src/cli/connect/tool-call.d.ts +3 -0
  91. package/dist/src/cli/connect/tool-call.d.ts.map +1 -1
  92. package/dist/src/cli/connect/tool-call.js +11 -1
  93. package/dist/src/cli/connect/tool-call.js.map +1 -1
  94. package/dist/src/config-types.d.ts +14 -0
  95. package/dist/src/config-types.d.ts.map +1 -1
  96. package/dist/src/config.d.ts.map +1 -1
  97. package/dist/src/config.js +10 -0
  98. package/dist/src/config.js.map +1 -1
  99. package/dist/src/data/public-pages.d.ts +12 -0
  100. package/dist/src/data/public-pages.d.ts.map +1 -1
  101. package/dist/src/data/public-pages.js +12 -0
  102. package/dist/src/data/public-pages.js.map +1 -1
  103. package/dist/src/generated/api-types.d.ts +969 -85
  104. package/dist/src/generated/api-types.d.ts.map +1 -1
  105. package/dist/src/mcp/ai-provenance-input.d.ts +45 -2
  106. package/dist/src/mcp/ai-provenance-input.d.ts.map +1 -1
  107. package/dist/src/mcp/ai-provenance-input.js +48 -2
  108. package/dist/src/mcp/ai-provenance-input.js.map +1 -1
  109. package/dist/src/mcp/apps.d.ts +7 -0
  110. package/dist/src/mcp/apps.d.ts.map +1 -1
  111. package/dist/src/mcp/apps.js +20 -2
  112. package/dist/src/mcp/apps.js.map +1 -1
  113. package/dist/src/models/ai-provenance-schemas.d.ts +7 -1
  114. package/dist/src/models/ai-provenance-schemas.d.ts.map +1 -1
  115. package/dist/src/models/ai-provenance-schemas.js +11 -1
  116. package/dist/src/models/ai-provenance-schemas.js.map +1 -1
  117. package/dist/src/models/connection-schemas.d.ts +203 -0
  118. package/dist/src/models/connection-schemas.d.ts.map +1 -0
  119. package/dist/src/models/connection-schemas.js +33 -0
  120. package/dist/src/models/connection-schemas.js.map +1 -0
  121. package/dist/src/models/message-schemas.d.ts +40 -0
  122. package/dist/src/models/message-schemas.d.ts.map +1 -1
  123. package/dist/src/models/message-schemas.js +23 -0
  124. package/dist/src/models/message-schemas.js.map +1 -1
  125. package/dist/src/routes/ai-provenance.d.ts +6 -0
  126. package/dist/src/routes/ai-provenance.d.ts.map +1 -1
  127. package/dist/src/routes/ai-provenance.js +34 -1
  128. package/dist/src/routes/ai-provenance.js.map +1 -1
  129. package/dist/src/routes/ai-transparency.d.ts +6 -1
  130. package/dist/src/routes/ai-transparency.d.ts.map +1 -1
  131. package/dist/src/routes/ai-transparency.js +17 -3
  132. package/dist/src/routes/ai-transparency.js.map +1 -1
  133. package/dist/src/routes/ai.d.ts +5 -0
  134. package/dist/src/routes/ai.d.ts.map +1 -1
  135. package/dist/src/routes/ai.js +83 -0
  136. package/dist/src/routes/ai.js.map +1 -1
  137. package/dist/src/routes/app-grants.d.ts.map +1 -1
  138. package/dist/src/routes/app-grants.js +4 -0
  139. package/dist/src/routes/app-grants.js.map +1 -1
  140. package/dist/src/routes/apps/drafts.d.ts +3 -0
  141. package/dist/src/routes/apps/drafts.d.ts.map +1 -1
  142. package/dist/src/routes/apps/drafts.js +13 -0
  143. package/dist/src/routes/apps/drafts.js.map +1 -1
  144. package/dist/src/routes/apps/publish.d.ts +5 -0
  145. package/dist/src/routes/apps/publish.d.ts.map +1 -1
  146. package/dist/src/routes/apps/publish.js +21 -5
  147. package/dist/src/routes/apps/publish.js.map +1 -1
  148. package/dist/src/routes/connections.d.ts +35 -0
  149. package/dist/src/routes/connections.d.ts.map +1 -0
  150. package/dist/src/routes/connections.js +406 -0
  151. package/dist/src/routes/connections.js.map +1 -0
  152. package/dist/src/routes/libs.js +1 -1
  153. package/dist/src/routes/libs.js.map +1 -1
  154. package/dist/src/routes/messages.d.ts +5 -0
  155. package/dist/src/routes/messages.d.ts.map +1 -1
  156. package/dist/src/routes/messages.js +92 -0
  157. package/dist/src/routes/messages.js.map +1 -1
  158. package/dist/src/routes/openrouter.d.ts +6 -1
  159. package/dist/src/routes/openrouter.d.ts.map +1 -1
  160. package/dist/src/routes/openrouter.js +37 -7
  161. package/dist/src/routes/openrouter.js.map +1 -1
  162. package/dist/src/routes/upload.d.ts +5 -0
  163. package/dist/src/routes/upload.d.ts.map +1 -1
  164. package/dist/src/routes/upload.js +32 -0
  165. package/dist/src/routes/upload.js.map +1 -1
  166. package/dist/src/server-bootstrap/routes-loader.d.ts.map +1 -1
  167. package/dist/src/server-bootstrap/routes-loader.js +2 -0
  168. package/dist/src/server-bootstrap/routes-loader.js.map +1 -1
  169. package/dist/src/services/ai-completion.d.ts +48 -0
  170. package/dist/src/services/ai-completion.d.ts.map +1 -1
  171. package/dist/src/services/ai-completion.js +112 -72
  172. package/dist/src/services/ai-completion.js.map +1 -1
  173. package/dist/src/services/ai-disclosure.d.ts +6 -0
  174. package/dist/src/services/ai-disclosure.d.ts.map +1 -1
  175. package/dist/src/services/ai-disclosure.js +20 -2
  176. package/dist/src/services/ai-disclosure.js.map +1 -1
  177. package/dist/src/services/ai-provenance-page.d.ts +59 -0
  178. package/dist/src/services/ai-provenance-page.d.ts.map +1 -0
  179. package/dist/src/services/ai-provenance-page.js +185 -0
  180. package/dist/src/services/ai-provenance-page.js.map +1 -0
  181. package/dist/src/services/ai-provenance.d.ts +6 -0
  182. package/dist/src/services/ai-provenance.d.ts.map +1 -1
  183. package/dist/src/services/ai-provenance.js +5 -0
  184. package/dist/src/services/ai-provenance.js.map +1 -1
  185. package/dist/src/services/ai-transcription.d.ts +66 -0
  186. package/dist/src/services/ai-transcription.d.ts.map +1 -0
  187. package/dist/src/services/ai-transcription.js +90 -0
  188. package/dist/src/services/ai-transcription.js.map +1 -0
  189. package/dist/src/services/app-publish.d.ts.map +1 -1
  190. package/dist/src/services/app-publish.js +16 -1
  191. package/dist/src/services/app-publish.js.map +1 -1
  192. package/dist/src/services/build-app-prompt.d.ts.map +1 -1
  193. package/dist/src/services/build-app-prompt.js +2 -1
  194. package/dist/src/services/build-app-prompt.js.map +1 -1
  195. package/dist/src/services/connections/attach.d.ts +62 -0
  196. package/dist/src/services/connections/attach.d.ts.map +1 -0
  197. package/dist/src/services/connections/attach.js +168 -0
  198. package/dist/src/services/connections/attach.js.map +1 -0
  199. package/dist/src/services/connections/credential.d.ts +39 -0
  200. package/dist/src/services/connections/credential.d.ts.map +1 -0
  201. package/dist/src/services/connections/credential.js +66 -0
  202. package/dist/src/services/connections/credential.js.map +1 -0
  203. package/dist/src/services/connections/instance.d.ts +65 -0
  204. package/dist/src/services/connections/instance.d.ts.map +1 -0
  205. package/dist/src/services/connections/instance.js +144 -0
  206. package/dist/src/services/connections/instance.js.map +1 -0
  207. package/dist/src/services/connections/oauth.d.ts +77 -0
  208. package/dist/src/services/connections/oauth.d.ts.map +1 -0
  209. package/dist/src/services/connections/oauth.js +290 -0
  210. package/dist/src/services/connections/oauth.js.map +1 -0
  211. package/dist/src/services/connections/providers.d.ts +118 -0
  212. package/dist/src/services/connections/providers.d.ts.map +1 -0
  213. package/dist/src/services/connections/providers.js +260 -0
  214. package/dist/src/services/connections/providers.js.map +1 -0
  215. package/dist/src/services/connections/publish-gate.d.ts +103 -0
  216. package/dist/src/services/connections/publish-gate.d.ts.map +1 -0
  217. package/dist/src/services/connections/publish-gate.js +0 -0
  218. package/dist/src/services/connections/publish-gate.js.map +1 -0
  219. package/dist/src/services/connections/publish.d.ts +60 -0
  220. package/dist/src/services/connections/publish.d.ts.map +1 -0
  221. package/dist/src/services/connections/publish.js +340 -0
  222. package/dist/src/services/connections/publish.js.map +1 -0
  223. package/dist/src/services/connections/refresh.d.ts +55 -0
  224. package/dist/src/services/connections/refresh.d.ts.map +1 -0
  225. package/dist/src/services/connections/refresh.js +307 -0
  226. package/dist/src/services/connections/refresh.js.map +1 -0
  227. package/dist/src/services/markdown-negotiation.d.ts +17 -0
  228. package/dist/src/services/markdown-negotiation.d.ts.map +1 -1
  229. package/dist/src/services/markdown-negotiation.js +15 -0
  230. package/dist/src/services/markdown-negotiation.js.map +1 -1
  231. package/dist/src/services/message-send.d.ts.map +1 -1
  232. package/dist/src/services/message-send.js +16 -2
  233. package/dist/src/services/message-send.js.map +1 -1
  234. package/dist/src/services/openrouter.d.ts +67 -1
  235. package/dist/src/services/openrouter.d.ts.map +1 -1
  236. package/dist/src/services/openrouter.js +102 -11
  237. package/dist/src/services/openrouter.js.map +1 -1
  238. package/dist/src/services/upload-token.d.ts +5 -1
  239. package/dist/src/services/upload-token.d.ts.map +1 -1
  240. package/dist/src/services/upload-token.js +13 -1
  241. package/dist/src/services/upload-token.js.map +1 -1
  242. package/dist/src/services/workflow/engine-util.d.ts +18 -0
  243. package/dist/src/services/workflow/engine-util.d.ts.map +1 -1
  244. package/dist/src/services/workflow/engine-util.js +27 -0
  245. package/dist/src/services/workflow/engine-util.js.map +1 -1
  246. package/dist/src/services/workflow/engine.d.ts.map +1 -1
  247. package/dist/src/services/workflow/engine.js +4 -2
  248. package/dist/src/services/workflow/engine.js.map +1 -1
  249. package/dist/src/storage/interface.d.ts +2 -1
  250. package/dist/src/storage/interface.d.ts.map +1 -1
  251. package/dist/src/storage/providers/postgres-kysely/db-types.d.ts +65 -0
  252. package/dist/src/storage/providers/postgres-kysely/db-types.d.ts.map +1 -1
  253. package/dist/src/storage/providers/postgres-kysely/index.d.ts.map +1 -1
  254. package/dist/src/storage/providers/postgres-kysely/index.js +2 -1
  255. package/dist/src/storage/providers/postgres-kysely/index.js.map +1 -1
  256. package/dist/src/storage/providers/postgres-kysely/methods/connections.d.ts +44 -0
  257. package/dist/src/storage/providers/postgres-kysely/methods/connections.d.ts.map +1 -0
  258. package/dist/src/storage/providers/postgres-kysely/methods/connections.js +308 -0
  259. package/dist/src/storage/providers/postgres-kysely/methods/connections.js.map +1 -0
  260. package/dist/src/storage/providers/postgres-kysely/methods/node-infra.d.ts.map +1 -1
  261. package/dist/src/storage/providers/postgres-kysely/methods/node-infra.js +4 -2
  262. package/dist/src/storage/providers/postgres-kysely/methods/node-infra.js.map +1 -1
  263. package/dist/src/storage/providers/postgres-kysely/migrations/0021_connections.sql +116 -0
  264. package/dist/src/storage/providers/postgres-kysely/migrations/0022_provider_clients.sql +36 -0
  265. package/dist/src/storage/providers/postgres-kysely/migrations/0023_nonce_payload.sql +13 -0
  266. package/dist/src/storage/providers/sqlite/index.d.ts.map +1 -1
  267. package/dist/src/storage/providers/sqlite/index.js +2 -1
  268. package/dist/src/storage/providers/sqlite/index.js.map +1 -1
  269. package/dist/src/storage/providers/sqlite/methods/community.d.ts.map +1 -1
  270. package/dist/src/storage/providers/sqlite/methods/community.js +2 -1
  271. package/dist/src/storage/providers/sqlite/methods/community.js.map +1 -1
  272. package/dist/src/storage/providers/sqlite/methods/connections.d.ts +60 -0
  273. package/dist/src/storage/providers/sqlite/methods/connections.d.ts.map +1 -0
  274. package/dist/src/storage/providers/sqlite/methods/connections.js +287 -0
  275. package/dist/src/storage/providers/sqlite/methods/connections.js.map +1 -0
  276. package/dist/src/storage/providers/sqlite/schema-tables-1.d.ts.map +1 -1
  277. package/dist/src/storage/providers/sqlite/schema-tables-1.js +4 -0
  278. package/dist/src/storage/providers/sqlite/schema-tables-1.js.map +1 -1
  279. package/dist/src/storage/providers/sqlite/schema-tables-3.d.ts.map +1 -1
  280. package/dist/src/storage/providers/sqlite/schema-tables-3.js +106 -0
  281. package/dist/src/storage/providers/sqlite/schema-tables-3.js.map +1 -1
  282. package/dist/src/storage/providers/sqlite/schema.d.ts.map +1 -1
  283. package/dist/src/storage/providers/sqlite/schema.js +2 -0
  284. package/dist/src/storage/providers/sqlite/schema.js.map +1 -1
  285. package/dist/src/storage/repositories/connection.repository.d.ts +124 -0
  286. package/dist/src/storage/repositories/connection.repository.d.ts.map +1 -0
  287. package/dist/src/storage/repositories/connection.repository.js +2 -0
  288. package/dist/src/storage/repositories/connection.repository.js.map +1 -0
  289. package/dist/src/storage/types/agents-messaging.d.ts +19 -0
  290. package/dist/src/storage/types/agents-messaging.d.ts.map +1 -1
  291. package/dist/src/storage/types/agents-messaging.js.map +1 -1
  292. package/dist/src/storage/types/auth.d.ts +8 -1
  293. package/dist/src/storage/types/auth.d.ts.map +1 -1
  294. package/dist/src/utils/build-stamp.d.ts +56 -0
  295. package/dist/src/utils/build-stamp.d.ts.map +1 -0
  296. package/dist/src/utils/build-stamp.js +142 -0
  297. package/dist/src/utils/build-stamp.js.map +1 -0
  298. package/dist/src/utils/messaging.d.ts +14 -0
  299. package/dist/src/utils/messaging.d.ts.map +1 -1
  300. package/dist/src/utils/messaging.js +0 -0
  301. package/dist/src/utils/messaging.js.map +1 -1
  302. package/dist/static/sdk-libs/connect/index.js +199 -0
  303. package/dist/static/sdk-libs/connect/notes.js +47 -0
  304. package/dist/static/sdk-libs/connect/panel.js +238 -0
  305. package/dist/static/sdk-libs/data/index.js +63 -11
  306. package/dist/static/sdk-libs/dist/aimeat-connect.js +357 -0
  307. package/dist/static/sdk-libs/dist/aimeat-data.js +40 -9
  308. package/package.json +2 -2
@@ -1,35 +1,34 @@
1
1
  /**
2
2
  * @file openrouter-settings.js
3
- * @description AI provider configuration panel — OpenRouter / LM Studio / custom
4
- * OpenAI-compatible provider, API key, model + vision model, sampling params, and the
5
- * AI-apps daily-budget / per-app spend panel. Standalone; used by the profile AI tab and
6
- * by any view that embeds AI-provider settings.
7
- * @structure
8
- * - OpenRouterSettings: collapsible panel for AI provider, API key, model, auto-retry, budget
9
- * @usage
10
- * import { OpenRouterSettings } from './openrouter-settings.js';
3
+ * @description AI provider configuration — OpenRouter / LM Studio / custom OpenAI-compatible
4
+ * endpoint, the API key, the five model roles (default, reasoning, execution, vision,
5
+ * speech-to-text), sampling parameters, and the AI spend panel.
6
+ *
7
+ * Four sections, each saving its OWN fields where the user is looking. One Save at the bottom of a
8
+ * single long form was the standing complaint: the key was typed at the top and the button that
9
+ * stored it sat ten fields and an entire budget panel below, so "did that save?" had no answer
10
+ * where it was asked. PUT /v1/openrouter/settings is a partial update, so per-section saving needs
11
+ * no server change.
12
+ * @structure OpenRouterSettings (container + state) · SttTestPanel · ParamsSection ·
13
+ * openrouter/model-picker.js · openrouter/pricing.js · openrouter/budget-panel.js
14
+ * @usage import { OpenRouterSettings } from './openrouter-settings.js';
11
15
  * @version-history
12
- * v2.1.0 — 2026-08-01 — TARGET-058 Phase 9 step 0. "Test" now distinguishes a spent daily budget
13
- * from a failed connection. Phase 8b routed the test through the completion chokepoint, which
14
- * correctly made it refusable at 402 but the panel headlined every failure "Connection failed",
15
- * so a person verifying their setup was told to doubt a key that was working.
16
+ * v3.0.0 — 2026-08-01 — Sectioned rework. Save-next-to-the-key; searchable model pickers showing
17
+ * price, context and a link to the model's page (a flat <select> of 336 unlabelled options was
18
+ * unusable); reasoning + execution pickers surfaced at last (the server has stored those two
19
+ * since v1.x with no UI to set them); speech-to-text model + language + a MEASURED test
20
+ * transcription, because an audio model's listed price carries no unit; the spend panel
21
+ * translated and moved to openrouter/budget-panel.js (it was hardcoded English). The
22
+ * onSettingsChange contract and the startOpen prop are unchanged for all three callers.
23
+ * v2.1.0 — 2026-08-01 — TARGET-058 Phase 9 step 0. "Test" distinguishes a spent daily budget from
24
+ * a failed connection; headlining a 402 as "Connection failed" sent people off to doubt a key
25
+ * that was working. (Behaviour carried into v3.)
16
26
  * v2.0.0 — 2026-07-18 — Split out of generator-settings.js (SettingsCollectionView removed with
17
- * the deleted Generator feature); dropped the generator.js project-settings import. OpenRouterSettings unchanged.
18
- * v1.5.0 — 2026-07-05 — Model dropdown now populates for custom OpenAI-compatible providers
19
- * (e.g. NVIDIA NIM https://integrate.api.nvidia.com/v1): reload models after every save (not
20
- * only when a new key is typed, so switching base URL refreshes the list), surface model-load
21
- * failures inline, and add a "Refresh models" button with a live count. Pairs with the
22
- * listModels() name→id fallback fix in services/openrouter.ts.
23
- * v1.4.1 — 2026-07-05 — Per-app limits table also lists apps active in the last 30 days (from the
24
- * history chart), not only apps that spent TODAY — so a cap can be set even when today's spend is $0.
25
- * v1.4.0 — 2026-07-05 — AI apps budget panel: add a per-app stacked "usage over time" chart (30 days,
26
- * cost⇄tokens toggle) fed by GET /v1/ai/usage/history, below the today bar + per-app table.
27
- * v1.3.0 — 2026-07-05 — AI apps budget panel: per-app daily caps are now VISIBLE + EDITABLE inline
28
- * (each app defaults to the whole daily budget; set a cap to throttle one app). Was API-only.
29
- * v1.2.0 — 2026-06-24 — Add a Vision model selector (a vision-capable model, e.g. qwen-2.5-VL) used
30
- * for image inputs — the Secretary's doc/image intake sends attached images to this model.
31
- * v1.1.0 — 2026-04-01 — Add temperature, top_p, max_tokens model parameter fields
32
- * v1.0.0 — 2026-03-22 — Extracted from generator-tab.js (was inline in v5.1.0+)
27
+ * the deleted Generator feature); dropped the generator.js project-settings import.
28
+ * v1.5.0 — 2026-07-05 — Model dropdown populates for custom OpenAI-compatible providers.
29
+ * v1.4.0 2026-07-05 AI apps budget panel: per-app stacked usage chart.
30
+ * v1.2.0 2026-06-24 Vision model selector.
31
+ * v1.0.0 2026-03-22 Extracted from generator-tab.js
33
32
  */
34
33
  import { h } from 'preact';
35
34
  import { useState, useEffect } from 'preact/hooks';
@@ -37,43 +36,70 @@ import htm from 'htm';
37
36
  const html = htm.bind(h);
38
37
  import { t } from '/js/i18n.js';
39
38
  import { apiGet, apiPut, apiPost, apiDelete } from '/js/api.js';
40
- import { UsageChart, colorForIndex } from '/components/UsageChart.js';
41
39
  import { useConfirm } from '/components/Modal.js';
40
+ import { VoiceRecorder } from '/components/VoiceRecorder.js';
42
41
  import { swallowed } from '/js/swallowed.js';
43
-
44
- /** Compact number (73306 "73.3k") for token axis/labels. */
45
- function fmtCompact(n) {
46
- const v = Number(n) || 0;
47
- if (v < 1000) return String(Math.round(v));
48
- if (v < 1_000_000) return (v / 1000).toFixed(v < 10_000 ? 1 : 0) + 'k';
49
- return (v / 1_000_000).toFixed(1) + 'M';
42
+ import { ModelPicker } from './openrouter/model-picker.js';
43
+ import { AiAppsBudgetPanel } from './openrouter/budget-panel.js';
44
+
45
+ /** Language hints offered for transcription. Auto-detect is first and is the right answer for
46
+ * mixed-language speech; a hint measurably helps when the language is known. */
47
+ const STT_LANGS = ['', 'fi', 'en', 'sv', 'de', 'fr', 'es', 'et'];
48
+
49
+ /** Read a File as bare base64 (no data: prefix) — the shape /v1/ai/transcribe takes inline. */
50
+ function fileToBase64(file) {
51
+ return new Promise((resolve, reject) => {
52
+ const fr = new FileReader();
53
+ fr.onerror = () => reject(new Error('read failed'));
54
+ fr.onload = () => {
55
+ const s = String(fr.result || '');
56
+ const comma = s.indexOf(',');
57
+ resolve(comma >= 0 ? s.slice(comma + 1) : s);
58
+ };
59
+ fr.readAsDataURL(file);
60
+ });
50
61
  }
51
62
 
52
- /* ── OpenRouter / LM Studio / Custom Settings ────────── */
53
-
54
63
  export function OpenRouterSettings({ onSettingsChange, startOpen = false }) {
55
64
  const { confirm, ConfirmUI } = useConfirm();
56
65
  const [collapsed, setCollapsed] = useState(!startOpen);
66
+ const [loaded, setLoaded] = useState(false);
67
+
68
+ // connection
57
69
  const [hasApiKey, setHasApiKey] = useState(false);
58
70
  const [apiKey, setApiKey] = useState('');
71
+ const [provider, setProvider] = useState('openrouter');
72
+ const [baseUrl, setBaseUrl] = useState('');
73
+ const [savingKey, setSavingKey] = useState(false);
74
+ const [testing, setTesting] = useState(false);
75
+ const [connMsg, setConnMsg] = useState(null);
76
+
77
+ // models
59
78
  const [model, setModel] = useState('');
79
+ const [reasoningModel, setReasoningModel] = useState('');
80
+ const [executionModel, setExecutionModel] = useState('');
60
81
  const [visionModel, setVisionModel] = useState('');
82
+ const [sttModel, setSttModel] = useState('');
83
+ const [sttLanguage, setSttLanguage] = useState('');
61
84
  const [models, setModels] = useState([]);
85
+ const [sttModels, setSttModels] = useState([]);
62
86
  const [modelsLoading, setModelsLoading] = useState(false);
63
87
  const [modelsError, setModelsError] = useState(null);
88
+ const [savingModels, setSavingModels] = useState(false);
89
+ const [modelsMsg, setModelsMsg] = useState(null);
90
+
91
+ // params
64
92
  const [autoRetry, setAutoRetry] = useState(false);
65
93
  const [maxRetries, setMaxRetries] = useState(3);
66
- const [provider, setProvider] = useState('openrouter');
67
- const [baseUrl, setBaseUrl] = useState('');
68
94
  const [temperature, setTemperature] = useState('');
69
95
  const [topP, setTopP] = useState('');
70
96
  const [maxTokens, setMaxTokens] = useState('');
71
- const [saving, setSaving] = useState(false);
72
- const [testing, setTesting] = useState(false);
73
- const [message, setMessage] = useState(null); // { text, error }
74
- const [loaded, setLoaded] = useState(false);
97
+ const [savingParams, setSavingParams] = useState(false);
98
+ const [paramsMsg, setParamsMsg] = useState(null);
75
99
 
76
- // eslint-disable-next-line react-hooks/exhaustive-deps -- Mount-only settings load; loadSettings (and the loadModels it calls) use only stable setters/t.
100
+ const isOpenRouter = provider === 'openrouter';
101
+
102
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only load; the loaders use stable setters
77
103
  useEffect(() => { loadSettings(); }, []);
78
104
 
79
105
  async function loadSettings() {
@@ -82,7 +108,11 @@ export function OpenRouterSettings({ onSettingsChange, startOpen = false }) {
82
108
  if (resp.ok !== false && resp.data) {
83
109
  setHasApiKey(!!resp.data.hasApiKey);
84
110
  setModel(resp.data.model || '');
111
+ setReasoningModel(resp.data.reasoningModel || '');
112
+ setExecutionModel(resp.data.executionModel || '');
85
113
  setVisionModel(resp.data.visionModel || '');
114
+ setSttModel(resp.data.sttModel || '');
115
+ setSttLanguage(resp.data.sttLanguage || '');
86
116
  setAutoRetry(!!resp.data.autoRetry);
87
117
  setMaxRetries(resp.data.maxRetries || 3);
88
118
  setProvider(resp.data.provider || 'openrouter');
@@ -99,528 +129,435 @@ export function OpenRouterSettings({ onSettingsChange, startOpen = false }) {
99
129
  // Notify parent whenever key settings change
100
130
  useEffect(() => {
101
131
  if (loaded && onSettingsChange) {
102
- onSettingsChange({ hasApiKey: hasApiKey, autoRetry, maxRetries, provider, baseUrl });
132
+ onSettingsChange({ hasApiKey, autoRetry, maxRetries, provider, baseUrl });
103
133
  }
104
- // eslint-disable-next-line react-hooks/exhaustive-deps -- Notify parent only when the listed settings change; onSettingsChange is a caller callback intentionally excluded to avoid re-firing on every parent re-render.
134
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- onSettingsChange is a caller callback, intentionally excluded so it does not re-fire on every parent render
105
135
  }, [loaded, hasApiKey, autoRetry, maxRetries, provider, baseUrl]);
106
136
 
137
+ /**
138
+ * Two catalogues, not one filtered twice: OpenRouter's default /models contains no transcription
139
+ * models at all, so the STT picker is empty unless its list is fetched with its own modality.
140
+ */
107
141
  async function loadModels() {
108
142
  setModelsLoading(true);
109
143
  setModelsError(null);
110
- try {
111
- const resp = await apiGet('/v1/openrouter/models');
112
- if (resp.ok !== false && Array.isArray(resp.data?.models)) {
113
- setModels(resp.data.models);
114
- if (resp.data.models.length === 0) setModelsError(t('profile.generator.openrouter.modelsEmpty'));
115
- } else {
116
- setModels([]);
117
- setModelsError(resp.error?.message || t('profile.generator.openrouter.modelsError'));
118
- }
119
- } catch (e) {
144
+ const [chat, stt] = await Promise.all([
145
+ apiGet('/v1/openrouter/models').catch(e => ({ ok: false, error: { message: e?.message } })),
146
+ apiGet('/v1/openrouter/models?modality=transcription').catch(e => {
147
+ // A provider with no transcription catalogue and a provider that failed to answer look the
148
+ // same from here, so the failure is logged rather than silently read as "none available".
149
+ swallowed('openrouter-settings: stt model list', e);
150
+ return { ok: false, data: null };
151
+ }),
152
+ ]);
153
+ if (chat && chat.ok !== false && Array.isArray(chat.data?.models)) {
154
+ setModels(chat.data.models);
155
+ if (chat.data.models.length === 0) setModelsError(t('profile.openrouter.modelsEmpty'));
156
+ } else {
120
157
  setModels([]);
121
- setModelsError(e?.message || t('profile.generator.openrouter.modelsError'));
158
+ setModelsError(chat?.error?.message || t('profile.openrouter.modelsError'));
122
159
  }
160
+ // A provider with no transcription catalogue is not an error — it simply has none to offer, and
161
+ // the STT section says so instead of showing an empty picker with no explanation.
162
+ setSttModels((stt && stt.ok !== false && Array.isArray(stt.data?.models)) ? stt.data.models : []);
123
163
  setModelsLoading(false);
124
164
  }
125
165
 
126
- function showMsg(text, error = false) {
127
- setMessage({ text, error });
128
- setTimeout(() => setMessage(null), 4000);
166
+ function flash(setter, text, error = false) {
167
+ setter({ text, error });
168
+ setTimeout(() => setter(null), 5000);
129
169
  }
130
170
 
131
- async function handleSave() {
132
- setSaving(true);
171
+ /** Save ONLY the connection fields, so the answer to "did my key save?" appears beside the key. */
172
+ async function saveKey() {
173
+ setSavingKey(true);
133
174
  try {
134
- const body = { model, visionModel: visionModel || null, autoRetry, maxRetries: parseInt(maxRetries) || 3, provider, baseUrl };
135
- body.temperature = temperature !== '' ? parseFloat(temperature) : null;
136
- body.top_p = topP !== '' ? parseFloat(topP) : null;
137
- body.max_tokens = maxTokens !== '' ? parseInt(maxTokens) : null;
175
+ const body = { provider, baseUrl };
138
176
  if (apiKey) body.apiKey = apiKey;
139
177
  const resp = await apiPut('/v1/openrouter/settings', body);
140
178
  if (resp.ok === false) {
141
- showMsg(resp.error?.message || t('profile.generator.openrouter.testFail'), true);
179
+ flash(setConnMsg, resp.error?.message || t('profile.openrouter.saveFailed'), true);
142
180
  } else {
143
- showMsg(t('profile.generator.openrouter.apiKeySaved'));
144
- setHasApiKey(true);
181
+ flash(setConnMsg, apiKey ? t('profile.openrouter.keySaved') : t('profile.openrouter.providerSaved'));
182
+ if (apiKey) setHasApiKey(true);
145
183
  setApiKey('');
146
- // Reload models after every save the list is fetched from the provider's
147
- // /v1/models using the just-saved base URL + key, so switching provider (e.g.
148
- // to NVIDIA's integrate.api.nvidia.com/v1) refreshes it even with no new key.
184
+ // Refetch after every save: the list comes from the just-saved base URL + key, so switching
185
+ // provider refreshes it even when no new key was typed.
149
186
  loadModels();
150
187
  }
151
188
  } catch (e) {
152
- showMsg(e.message, true);
189
+ flash(setConnMsg, e.message, true);
190
+ }
191
+ setSavingKey(false);
192
+ }
193
+
194
+ async function saveModels() {
195
+ setSavingModels(true);
196
+ try {
197
+ const resp = await apiPut('/v1/openrouter/settings', {
198
+ model,
199
+ reasoningModel,
200
+ executionModel,
201
+ visionModel: visionModel || null,
202
+ sttModel: sttModel || null,
203
+ sttLanguage: sttLanguage || null,
204
+ provider,
205
+ baseUrl,
206
+ });
207
+ if (resp.ok === false) flash(setModelsMsg, resp.error?.message || t('profile.openrouter.saveFailed'), true);
208
+ else flash(setModelsMsg, t('profile.openrouter.modelsSaved'));
209
+ } catch (e) {
210
+ flash(setModelsMsg, e.message, true);
153
211
  }
154
- setSaving(false);
212
+ setSavingModels(false);
213
+ }
214
+
215
+ async function saveParams() {
216
+ setSavingParams(true);
217
+ try {
218
+ const resp = await apiPut('/v1/openrouter/settings', {
219
+ autoRetry,
220
+ maxRetries: parseInt(maxRetries) || 3,
221
+ temperature: temperature !== '' ? parseFloat(temperature) : null,
222
+ top_p: topP !== '' ? parseFloat(topP) : null,
223
+ max_tokens: maxTokens !== '' ? parseInt(maxTokens) : null,
224
+ provider,
225
+ baseUrl,
226
+ });
227
+ if (resp.ok === false) flash(setParamsMsg, resp.error?.message || t('profile.openrouter.saveFailed'), true);
228
+ else flash(setParamsMsg, t('profile.openrouter.paramsSaved'));
229
+ } catch (e) {
230
+ flash(setParamsMsg, e.message, true);
231
+ }
232
+ setSavingParams(false);
155
233
  }
156
234
 
157
235
  async function handleTest() {
158
236
  setTesting(true);
237
+ const started = Date.now();
159
238
  try {
160
239
  const resp = await apiPost('/v1/openrouter/test');
161
240
  if (resp.ok === false) {
162
- // A connectivity test is a real, billable completion, so it is REFUSED once the day's budget
163
- // is spent and that refusal is not a connection failure. Headlining it "Connection failed"
164
- // sends someone off to check a key that was never the problem, which is exactly the wrong
165
- // conclusion to hand a person who is trying to verify their setup.
241
+ // A refused test is not necessarily a broken connection: the completion chokepoint also
242
+ // refuses when the daily budget is spent. Headlining that "Connection failed" sends someone
243
+ // off to check a key that was never the problem.
166
244
  const budgetSpent = resp.error?.code === 'QUOTA_EXHAUSTED' || resp.error?.code === 'APP_QUOTA_EXHAUSTED';
167
- const head = budgetSpent
168
- ? t('profile.generator.openrouter.testBudgetSpent')
169
- : t('profile.generator.openrouter.testFail');
170
- showMsg(head + (resp.error?.message ? ': ' + resp.error.message : ''), true);
245
+ const head = budgetSpent ? t('profile.openrouter.testBudgetSpent') : t('profile.openrouter.testFail');
246
+ // The provider's own sentence stays: it is the part that says what is actually wrong.
247
+ flash(setConnMsg, head + (resp.error?.message ? ': ' + resp.error.message : ''), true);
171
248
  } else {
172
- showMsg(t('profile.generator.openrouter.testSuccess'));
249
+ flash(setConnMsg, t('profile.openrouter.testOk', {
250
+ model: resp.data?.model || '', ms: Date.now() - started,
251
+ }));
173
252
  }
174
253
  } catch (e) {
175
- showMsg(t('profile.generator.openrouter.testFail') + ': ' + e.message, true);
254
+ flash(setConnMsg, `${t('profile.openrouter.testFail')}: ${e.message}`, true);
176
255
  }
177
256
  setTesting(false);
178
257
  }
179
258
 
180
259
  function handleDelete() {
181
- confirm(t('profile.generator.openrouter.deleteConfirm'), async () => {
260
+ confirm(t('profile.openrouter.deleteConfirm'), async () => {
182
261
  try {
183
262
  await apiDelete('/v1/openrouter/settings');
184
- setHasApiKey(false);
185
- setApiKey('');
186
- setModel('');
187
- setVisionModel('');
188
- setModels([]);
189
- setModelsError(null);
190
- setAutoRetry(false);
191
- setMaxRetries(3);
192
- setTemperature('');
193
- setTopP('');
194
- setMaxTokens('');
195
- showMsg(t('profile.generator.openrouter.delete'));
263
+ setHasApiKey(false); setApiKey('');
264
+ setModel(''); setReasoningModel(''); setExecutionModel('');
265
+ setVisionModel(''); setSttModel(''); setSttLanguage('');
266
+ setModels([]); setSttModels([]); setModelsError(null);
267
+ setAutoRetry(false); setMaxRetries(3);
268
+ setTemperature(''); setTopP(''); setMaxTokens('');
269
+ flash(setConnMsg, t('profile.openrouter.deleted'));
196
270
  } catch (e) {
197
- showMsg(e.message, true);
271
+ flash(setConnMsg, e.message, true);
198
272
  }
199
273
  }, { danger: true });
200
274
  }
201
275
 
202
276
  if (!loaded) return null;
203
277
 
278
+ const keyed = hasApiKey || !!apiKey;
279
+ const pickerProps = { models, isOpenRouter, disabled: !keyed };
280
+
204
281
  return html`
205
- <div class="pf-gen-or-wrapper">
206
- <button class="pf-gen-or-toggle" onClick=${() => setCollapsed(!collapsed)}>
207
- <span class="pf-gen-or-toggle-icon">${collapsed ? '\u25B6' : '\u25BC'}</span>
208
- <span>${t('profile.generator.openrouter.title')}</span>
209
- ${hasApiKey && html`<span class="pf-gen-or-status-dot"></span>`}
282
+ <div class="pf-or-wrapper">
283
+ <button class="pf-or-toggle" onClick=${() => setCollapsed(!collapsed)}>
284
+ <span class="pf-or-toggle-icon">${collapsed ? '' : ''}</span>
285
+ <span>${t('profile.openrouter.title')}</span>
286
+ <span class=${hasApiKey ? 'pf-or-status pf-or-status--on' : 'pf-or-status'}>
287
+ ${hasApiKey ? t('profile.openrouter.statusReady') : t('profile.openrouter.statusNoKey')}
288
+ </span>
210
289
  </button>
290
+
211
291
  ${!collapsed && html`
212
- <div class="pf-gen-or-panel">
213
- <!-- Provider selector -->
214
- <div class="pf-gen-or-field">
215
- <label class="pf-gen-or-label">${t('profile.generator.openrouter.provider')}</label>
216
- <div class="pf-gen-or-radio-group">
217
- ${['openrouter', 'lmstudio', 'custom'].map(p => html`
218
- <label class="pf-gen-or-radio-label">
219
- <input type="radio" name="ai-provider" value=${p}
220
- checked=${provider === p}
221
- onChange=${() => {
222
- setProvider(p);
223
- if (p === 'lmstudio') setBaseUrl('http://localhost:1234/v1');
224
- else if (p === 'openrouter') setBaseUrl('');
225
- }} />
226
- ${t('profile.generator.openrouter.provider_' + p)}
227
- </label>
228
- `)}
292
+ <div class="pf-or-panel">
293
+
294
+ <!-- ── 1. Connection ───────────────────────────────── -->
295
+ <section class="pf-or-section">
296
+ <h4 class="pf-or-section-title">${t('profile.openrouter.section.connection')}</h4>
297
+
298
+ <div class="pf-or-field">
299
+ <label class="pf-or-label">${t('profile.openrouter.provider')}</label>
300
+ <div class="pf-or-radio-group">
301
+ ${['openrouter', 'lmstudio', 'custom'].map(p => html`
302
+ <label class="pf-or-radio-label" key=${p}>
303
+ <input type="radio" name="ai-provider" value=${p} checked=${provider === p}
304
+ onChange=${() => {
305
+ setProvider(p);
306
+ if (p === 'lmstudio') setBaseUrl('http://localhost:1234/v1');
307
+ else if (p === 'openrouter') setBaseUrl('');
308
+ }} />
309
+ ${t('profile.openrouter.provider_' + p)}
310
+ </label>`)}
311
+ </div>
229
312
  </div>
230
- </div>
231
313
 
232
- <!-- Base URL (lmstudio / custom) -->
233
- ${provider !== 'openrouter' && html`
234
- <div class="pf-gen-or-field">
235
- <label class="pf-gen-or-label">${t('profile.generator.openrouter.baseUrl')}</label>
236
- <input
237
- type="url"
238
- class="pf-gen-or-input"
239
- value=${baseUrl}
240
- placeholder=${t('profile.generator.openrouter.baseUrl_hint')}
241
- onInput=${e => setBaseUrl(e.target.value)}
242
- />
314
+ ${!isOpenRouter && html`
315
+ <div class="pf-or-field">
316
+ <label class="pf-or-label">${t('profile.openrouter.baseUrl')}</label>
317
+ <input type="url" class="pf-or-input" value=${baseUrl}
318
+ placeholder=${t('profile.openrouter.baseUrl_hint')}
319
+ onInput=${e => setBaseUrl(e.target.value)} />
320
+ </div>`}
321
+
322
+ <div class="pf-or-field">
323
+ <label class="pf-or-label">${t('profile.openrouter.apiKey')}</label>
324
+ <div class="pf-or-key-row">
325
+ <input type="password" autocomplete="off" data-1p-ignore data-lpignore="true"
326
+ class="pf-or-input pf-or-key-input"
327
+ placeholder=${hasApiKey ? t('profile.openrouter.apiKeyMasked') : t('profile.openrouter.apiKeyPlaceholder')}
328
+ value=${apiKey} onInput=${e => setApiKey(e.target.value)} />
329
+ <button class="btn-primary btn-sm" onClick=${saveKey} disabled=${savingKey}>
330
+ ${savingKey ? '…' : t('profile.openrouter.saveKey')}
331
+ </button>
332
+ ${hasApiKey && html`
333
+ <button class="btn-outline btn-sm" onClick=${handleTest} disabled=${testing}>
334
+ ${testing ? html`<span class="spinner"></span>` : ''}${t('profile.openrouter.testConnection')}
335
+ </button>
336
+ <button class="btn-ghost btn-sm pf-or-delete" onClick=${handleDelete}>
337
+ ${t('profile.openrouter.delete')}
338
+ </button>`}
339
+ </div>
340
+ ${connMsg && html`<div class=${`pf-or-message ${connMsg.error ? 'pf-or-message-error' : 'pf-or-message-success'}`}>${connMsg.text}</div>`}
341
+ ${isOpenRouter && html`
342
+ <div class="pf-or-links">
343
+ <a href="https://openrouter.ai/keys" target="_blank" rel="noopener">${t('profile.openrouter.getKeyLink')} ↗</a>
344
+ <a href="https://openrouter.ai/credits" target="_blank" rel="noopener">${t('profile.openrouter.creditsLink')} ↗</a>
345
+ </div>`}
243
346
  </div>
244
- `}
245
-
246
- <!-- API Key -->
247
- <div class="pf-gen-or-field">
248
- <label class="pf-gen-or-label">${t('profile.generator.openrouter.apiKey')}</label>
249
- <input
250
- type="password"
251
- autocomplete="off" data-1p-ignore data-lpignore="true"
252
- class="pf-gen-or-input"
253
- placeholder=${hasApiKey ? t('profile.generator.openrouter.apiKeyMasked') : t('profile.generator.openrouter.apiKeyPlaceholder')}
254
- value=${apiKey}
255
- onInput=${e => setApiKey(e.target.value)}
256
- />
257
- </div>
258
-
259
- <!-- Model -->
260
- <div class="pf-gen-or-field">
261
- <label class="pf-gen-or-label">${t('profile.generator.openrouter.model')}</label>
262
- ${modelsLoading
263
- ? html`<span class="pf-gen-or-loading">${t('profile.loading')}</span>`
264
- : html`
265
- <select
266
- class="pf-gen-or-select"
267
- value=${model}
268
- onChange=${e => setModel(e.target.value)}
269
- disabled=${!hasApiKey && !apiKey}
270
- >
271
- <option value="">${t('profile.generator.openrouter.modelSelect')}</option>
272
- ${models.map(m => html`
273
- <option value=${m.id}>${m.name || m.id}</option>
274
- `)}
275
- </select>
276
- `
277
- }
278
- <div class="pf-gen-or-param-row pf-gen-or-model-row">
279
- <button type="button" class="btn-outline btn-sm"
280
- onClick=${loadModels} disabled=${modelsLoading || !hasApiKey}>
281
- ${t('profile.generator.openrouter.modelsRefresh')}${models.length ? ` (${models.length})` : ''}
347
+ </section>
348
+
349
+ <!-- ── 2. Models ───────────────────────────────────── -->
350
+ <section class="pf-or-section">
351
+ <h4 class="pf-or-section-title">${t('profile.openrouter.section.models')}</h4>
352
+ <div class="pf-or-models-head">
353
+ <button type="button" class="btn-outline btn-sm" onClick=${loadModels}
354
+ disabled=${modelsLoading || !keyed}>
355
+ ${t('profile.openrouter.modelsRefresh')}${models.length ? ` (${models.length})` : ''}
282
356
  </button>
283
- <span class=${modelsError ? 'pf-gen-or-param-hint pf-gen-or-model-error' : 'pf-gen-or-param-hint'}>
284
- ${modelsError || t('profile.generator.openrouter.modelsHint')}
357
+ <span class=${modelsError ? 'pf-or-hint pf-or-hint--error' : 'pf-or-hint'}>
358
+ ${modelsError || t('profile.openrouter.modelsHint')}
285
359
  </span>
286
360
  </div>
287
- </div>
288
-
289
- <!-- Vision model (image inputs — Secretary doc/image intake) -->
290
- <div class="pf-gen-or-field">
291
- <label class="pf-gen-or-label">${t('profile.generator.openrouter.visionModel')}</label>
292
- ${modelsLoading
293
- ? html`<span class="pf-gen-or-loading">${t('profile.loading')}</span>`
294
- : html`
295
- <select
296
- class="pf-gen-or-select"
297
- value=${visionModel}
298
- onChange=${e => setVisionModel(e.target.value)}
299
- disabled=${!hasApiKey && !apiKey}
300
- >
301
- <option value="">${t('profile.generator.openrouter.visionModelNone')}</option>
302
- ${models.map(m => html`
303
- <option value=${m.id}>${m.name || m.id}</option>
304
- `)}
305
- </select>
306
- `
307
- }
308
- <span class="pf-gen-or-param-hint">${t('profile.generator.openrouter.visionModel_hint')}</span>
309
- </div>
310
-
311
- <!-- Auto-retry -->
312
- <div class="pf-gen-or-field">
313
- <label class="pf-gen-or-checkbox">
314
- <input
315
- type="checkbox"
316
- checked=${autoRetry}
317
- onChange=${e => setAutoRetry(e.target.checked)}
318
- />
319
- ${t('profile.generator.openrouter.autoRetry')}
320
- </label>
321
- </div>
322
-
323
- <!-- Max retries (conditional) -->
324
- ${autoRetry && html`
325
- <div class="pf-gen-or-field">
326
- <label class="pf-gen-or-label">${t('profile.generator.openrouter.maxRetries')}</label>
327
- <input
328
- type="number"
329
- class="pf-gen-or-input pf-gen-or-input-sm"
330
- min="1"
331
- max="10"
332
- value=${maxRetries}
333
- onInput=${e => setMaxRetries(Math.min(10, Math.max(1, parseInt(e.target.value) || 1)))}
334
- />
335
- </div>
336
- `}
337
-
338
- <!-- Model Parameters -->
339
- <div class="pf-gen-or-field">
340
- <label class="pf-gen-or-label">${t('profile.generator.openrouter.temperature')}</label>
341
- <div class="pf-gen-or-param-row">
342
- <input
343
- type="number"
344
- class="pf-gen-or-input pf-gen-or-input-sm"
345
- min="0"
346
- max="2"
347
- step="0.1"
348
- placeholder="default"
349
- value=${temperature}
350
- onInput=${e => setTemperature(e.target.value)}
351
- />
352
- <span class="pf-gen-or-param-hint">${t('profile.generator.openrouter.temperature_hint')}</span>
353
- </div>
354
- </div>
355
-
356
- <div class="pf-gen-or-field">
357
- <label class="pf-gen-or-label">${t('profile.generator.openrouter.topP')}</label>
358
- <div class="pf-gen-or-param-row">
359
- <input
360
- type="number"
361
- class="pf-gen-or-input pf-gen-or-input-sm"
362
- min="0"
363
- max="1"
364
- step="0.05"
365
- placeholder="default"
366
- value=${topP}
367
- onInput=${e => setTopP(e.target.value)}
368
- />
369
- <span class="pf-gen-or-param-hint">${t('profile.generator.openrouter.topP_hint')}</span>
370
- </div>
371
- </div>
372
361
 
373
- <div class="pf-gen-or-field">
374
- <label class="pf-gen-or-label">${t('profile.generator.openrouter.maxTokens')}</label>
375
- <div class="pf-gen-or-param-row">
376
- <input
377
- type="number"
378
- class="pf-gen-or-input pf-gen-or-input-sm"
379
- min="256"
380
- max="128000"
381
- step="256"
382
- placeholder="default"
383
- value=${maxTokens}
384
- onInput=${e => setMaxTokens(e.target.value)}
385
- />
386
- <span class="pf-gen-or-param-hint">${t('profile.generator.openrouter.maxTokens_hint')}</span>
387
- </div>
388
- </div>
362
+ ${modelsLoading ? html`<div class="pf-or-loading">${t('profile.loading')}</div>` : html`
363
+ <div class="pf-or-field">
364
+ <label class="pf-or-label">${t('profile.openrouter.model.default')}</label>
365
+ <${ModelPicker} ...${pickerProps} value=${model} onChange=${setModel} modality="chat"
366
+ allowCustom=${!isOpenRouter} />
367
+ </div>
368
+
369
+ <details class="pf-or-roles">
370
+ <summary class="pf-or-roles-summary">${t('profile.openrouter.model.rolesSummary')}</summary>
371
+ <div class="pf-or-field">
372
+ <label class="pf-or-label">${t('profile.openrouter.model.reasoning')}</label>
373
+ <${ModelPicker} ...${pickerProps} value=${reasoningModel} onChange=${setReasoningModel}
374
+ modality="chat" allowNone=${true} allowCustom=${!isOpenRouter} />
375
+ <span class="pf-or-hint">${t('profile.openrouter.model.reasoning_hint')}</span>
376
+ </div>
377
+ <div class="pf-or-field">
378
+ <label class="pf-or-label">${t('profile.openrouter.model.execution')}</label>
379
+ <${ModelPicker} ...${pickerProps} value=${executionModel} onChange=${setExecutionModel}
380
+ modality="chat" allowNone=${true} allowCustom=${!isOpenRouter} />
381
+ <span class="pf-or-hint">${t('profile.openrouter.model.execution_hint')}</span>
382
+ </div>
383
+ </details>
384
+
385
+ <div class="pf-or-field">
386
+ <label class="pf-or-label">${t('profile.openrouter.model.vision')}</label>
387
+ <${ModelPicker} ...${pickerProps} value=${visionModel} onChange=${setVisionModel}
388
+ modality="vision" allowNone=${true} allowCustom=${!isOpenRouter}
389
+ noneLabel=${t('profile.openrouter.model.visionNone')} />
390
+ <span class="pf-or-hint">${t('profile.openrouter.model.vision_hint')}</span>
391
+ </div>
392
+
393
+ <div class="pf-or-field pf-or-stt">
394
+ <label class="pf-or-label">${t('profile.openrouter.model.stt')}</label>
395
+ ${sttModels.length === 0 && keyed
396
+ ? html`<div class="pf-or-hint">${t('profile.openrouter.stt.listEmpty')}</div>` : null}
397
+ <${ModelPicker} models=${sttModels} isOpenRouter=${isOpenRouter} disabled=${!keyed}
398
+ value=${sttModel} onChange=${setSttModel} modality="transcription" allowNone=${true}
399
+ allowCustom=${!isOpenRouter} noneLabel=${t('profile.openrouter.model.sttNone')} />
400
+ <span class="pf-or-hint">${t('profile.openrouter.model.stt_hint')}</span>
401
+
402
+ <div class="pf-or-inline-field">
403
+ <label class="pf-or-label pf-or-label--inline">${t('profile.openrouter.stt.language')}</label>
404
+ <select class="pf-or-select pf-or-select--sm" value=${sttLanguage}
405
+ onChange=${e => setSttLanguage(e.target.value)}>
406
+ ${STT_LANGS.map(code => html`
407
+ <option key=${code || 'auto'} value=${code}>
408
+ ${code ? t('profile.openrouter.stt.lang_' + code) : t('profile.openrouter.stt.languageAuto')}
409
+ </option>`)}
410
+ </select>
411
+ <span class="pf-or-hint">${t('profile.openrouter.stt.language_hint')}</span>
412
+ </div>
413
+
414
+ <${SttTestPanel} sttModel=${sttModel} sttLanguage=${sttLanguage} />
415
+ </div>
416
+ `}
389
417
 
390
- <!-- Message -->
391
- ${message && html`
392
- <div class="pf-gen-or-message ${message.error ? 'pf-gen-or-message-error' : 'pf-gen-or-message-success'}">
393
- ${message.text}
394
- </div>
395
- `}
396
-
397
- <!-- Apps AI budget — apps reach this key via /v1/ai/complete -->
398
- ${hasApiKey && html`<${AiAppsBudgetPanel} />`}
399
-
400
- <!-- Actions -->
401
- <div class="pf-gen-or-actions">
402
- <button class="btn-primary" onClick=${handleSave} disabled=${saving}>
403
- ${saving ? '...' : t('profile.generator.openrouter.save')}
404
- </button>
405
- ${hasApiKey && html`
406
- <button class="btn-outline" onClick=${handleTest} disabled=${testing}>
407
- ${testing ? html`<span class="spinner"></span>` : ''}
408
- ${t('profile.generator.openrouter.testConnection')}
418
+ <div class="pf-or-actions">
419
+ <button class="btn-primary" onClick=${saveModels} disabled=${savingModels}>
420
+ ${savingModels ? '' : t('profile.openrouter.saveModels')}
409
421
  </button>
410
- <button class="btn-outline btn-sm pf-gen-or-delete" onClick=${handleDelete}>
411
- ${t('profile.generator.openrouter.delete')}
412
- </button>
413
- `}
414
- </div>
415
- </div>
416
- `}
422
+ ${modelsMsg && html`<span class=${`pf-or-message ${modelsMsg.error ? 'pf-or-message-error' : 'pf-or-message-success'}`}>${modelsMsg.text}</span>`}
423
+ </div>
424
+ </section>
425
+
426
+ <!-- ── 3. Parameters ───────────────────────────────── -->
427
+ <${ParamsSection}
428
+ autoRetry=${autoRetry} setAutoRetry=${setAutoRetry}
429
+ maxRetries=${maxRetries} setMaxRetries=${setMaxRetries}
430
+ temperature=${temperature} setTemperature=${setTemperature}
431
+ topP=${topP} setTopP=${setTopP}
432
+ maxTokens=${maxTokens} setMaxTokens=${setMaxTokens}
433
+ saving=${savingParams} onSave=${saveParams} message=${paramsMsg} />
434
+
435
+ <!-- ── 4. Budget ───────────────────────────────────── -->
436
+ ${hasApiKey && html`
437
+ <section class="pf-or-section">
438
+ <h4 class="pf-or-section-title">${t('profile.openrouter.section.budget')}</h4>
439
+ <${AiAppsBudgetPanel} />
440
+ </section>`}
441
+ </div>`}
417
442
  <${ConfirmUI} />
418
- </div>
419
- `;
443
+ </div>`;
420
444
  }
421
445
 
422
446
  /**
423
- * Budget + spend panel for app-level AI use (/v1/ai/complete). Shows today's
424
- * total spend and per-app breakdown, lets the user set the daily USD cap.
447
+ * Record a few seconds and transcribe them for real.
425
448
  *
426
- * Numbers come from the server's tracked usage (token counts always exact,
427
- * cost is OpenRouter-reported when available, rough estimate otherwise).
428
- * Each app may spend up to the daily budget; a per-app cap (edited inline here)
429
- * throttles a single app below it.
449
+ * This answers a question the catalogue cannot: what a transcription actually costs. An audio model's
450
+ * listed price carries no unit the same Whisper Large V3 reports 0.0015 on one provider and 0.111
451
+ * on another, per minute against per hour so one measured call beats any figure derived from the
452
+ * model list. It also proves the whole chain end to end: microphone, upload, key, model.
430
453
  */
431
- function AiAppsBudgetPanel() {
432
- const [usage, setUsage] = useState(null);
433
- const [settings, setSettings] = useState(null);
434
- const [editing, setEditing] = useState(false);
435
- const [budgetInput, setBudgetInput] = useState('1');
436
- const [saving, setSaving] = useState(false);
437
- const [message, setMessage] = useState(null);
438
- const [caps, setCaps] = useState({}); // app → cap input string ('' = use the daily budget)
439
- const [savingCaps, setSavingCaps] = useState(false);
440
- const [capsMsg, setCapsMsg] = useState(null);
441
- const [history, setHistory] = useState(null); // GET /v1/ai/usage/history — per-day series + rollups
442
- const [metric, setMetric] = useState('cost'); // chart metric: 'cost' | 'tokens'
443
-
444
- useEffect(() => { reload(); }, []);
445
-
446
- async function reload() {
447
- const [u, s, hist] = await Promise.all([
448
- apiGet('/v1/ai/usage').catch(err => { swallowed('openrouter-settings: reload', err); return null; }),
449
- apiGet('/v1/ai/settings').catch(err => { swallowed('openrouter-settings: reload', err); return null; }),
450
- apiGet('/v1/ai/usage/history?days=30').catch(err => { swallowed('openrouter-settings: reload', err); return null; }),
451
- ]);
452
- if (hist && hist.ok !== false && hist.data) setHistory(hist.data);
453
- if (u && u.ok !== false && u.data) setUsage(u.data);
454
- if (s && s.ok !== false && s.data) {
455
- setSettings(s.data);
456
- setBudgetInput(String(s.data.daily_budget_usd ?? 1));
457
- const q = s.data.app_quotas || {};
458
- setCaps(Object.fromEntries(Object.entries(q).map(([app, v]) =>
459
- [app, (v && v.daily_usd != null) ? String(v.daily_usd) : ''])));
460
- }
461
- }
462
-
463
- async function saveCaps() {
464
- setSavingCaps(true); setCapsMsg(null);
465
- const app_quotas = {};
466
- for (const [app, val] of Object.entries(caps)) {
467
- const s = String(val).trim();
468
- if (s === '') continue; // blank = no override → app uses the daily budget
469
- const n = Number(s);
470
- if (!Number.isFinite(n) || n < 0 || n > 1000) {
471
- setCapsMsg({ text: `Cap for "${app}" must be a number 0–1000.`, error: true });
472
- setSavingCaps(false); return;
473
- }
474
- app_quotas[app] = { daily_usd: n };
475
- }
476
- try {
477
- const r = await apiPost('/v1/ai/settings', { app_quotas });
478
- if (r.ok === false) throw new Error(r.error?.message || 'Save failed');
479
- setCapsMsg({ text: 'Per-app limits saved.' });
480
- await reload();
481
- } catch (e) {
482
- setCapsMsg({ text: e.message || 'Save failed', error: true });
483
- }
484
- setSavingCaps(false);
485
- }
454
+ function SttTestPanel({ sttModel, sttLanguage }) {
455
+ const [busy, setBusy] = useState(false);
456
+ const [result, setResult] = useState(null);
457
+ const [error, setError] = useState(null);
486
458
 
487
- async function saveBudget() {
488
- setSaving(true); setMessage(null);
489
- const n = Number(budgetInput);
490
- if (!Number.isFinite(n) || n < 0 || n > 1000) {
491
- setMessage({ text: 'Budget must be a number between 0 and 1000.', error: true });
492
- setSaving(false); return;
493
- }
459
+ async function run(file) {
460
+ setBusy(true); setError(null); setResult(null);
494
461
  try {
495
- const r = await apiPost('/v1/ai/settings', { daily_budget_usd: n });
496
- if (r.ok === false) throw new Error(r.error?.message || 'Save failed');
497
- setMessage({ text: 'Budget saved.' });
498
- setEditing(false);
499
- await reload();
462
+ const audio_base64 = await fileToBase64(file);
463
+ const resp = await apiPost('/v1/ai/transcribe', {
464
+ audio_base64, mime: file.type, filename: file.name,
465
+ model: sttModel || undefined, language: sttLanguage || undefined, app_id: 'settings-test',
466
+ });
467
+ if (resp.ok === false) throw new Error(resp.error?.message || t('profile.openrouter.stt.testFailed'));
468
+ setResult(resp.data);
500
469
  } catch (e) {
501
- setMessage({ text: e.message || 'Save failed', error: true });
470
+ setError(e.message || t('profile.openrouter.stt.testFailed'));
502
471
  }
503
- setSaving(false);
472
+ setBusy(false);
504
473
  }
505
474
 
506
- if (!usage || !settings) return null;
507
- const budget = usage.daily_budget_usd;
508
- const spent = usage.spent_today_usd;
509
- const pct = budget > 0 ? Math.min(100, Math.round((spent / budget) * 100)) : 0;
510
- const perAppEntries = Object.entries(usage.per_app || {});
511
- // apps to offer a per-app cap for: spent today, OR active in the last 30 days (from the history
512
- // chart), OR already have a configured cap. Without the history apps the table is hidden whenever
513
- // today's spend is $0 — leaving nowhere to set a cap for an app that only ran on earlier days.
514
- const historyApps = (history && Array.isArray(history.apps)) ? history.apps : [];
515
- const appNames = Array.from(new Set([...perAppEntries.map(([a]) => a), ...historyApps, ...Object.keys(caps)]));
516
-
517
475
  return html`
518
- <div class="pf-gen-or-field pf-gen-spend-section">
519
- <label class="pf-gen-or-label">💸 AI apps daily budget</label>
520
- <div class="pf-gen-spend-desc">
521
- Apps that call <code>AIMEAT.ai.complete()</code> use this key. This is the TOTAL across all
522
- apps for the day; each app may spend up to this much unless you set a per-app limit below.
523
- When the budget is hit, further calls return <code>QUOTA_EXHAUSTED</code>.
476
+ <div class="pf-or-stt-test">
477
+ <div class="pf-or-stt-test-head">
478
+ <${VoiceRecorder} maxSeconds=${30} disabled=${busy || !sttModel}
479
+ label=${t('profile.openrouter.stt.test')} className="btn-outline btn-sm"
480
+ onRecorded=${(file) => run(file)} />
481
+ ${busy ? html`<span class="pf-or-hint">${t('profile.openrouter.stt.testing')}</span>` : null}
482
+ ${!sttModel ? html`<span class="pf-or-hint">${t('profile.openrouter.stt.testNeedsModel')}</span>` : null}
524
483
  </div>
525
-
526
- <div class="pf-gen-spend-bar-row">
527
- <div class="pf-gen-spend-bar">
528
- <div class="pf-gen-spend-bar-fill ${pct >= 90 ? 'crit' : pct >= 60 ? 'warn' : ''}" style="width:${pct}%"></div>
529
- <div class="pf-gen-spend-bar-label">
530
- $${spent.toFixed(4)} / $${budget.toFixed(2)} (${pct}%)
484
+ ${error ? html`<div class="pf-or-message pf-or-message-error">${error}</div>` : null}
485
+ ${result ? html`
486
+ <div class="pf-or-stt-result">
487
+ <div class="pf-or-stt-text">${result.text || t('profile.openrouter.stt.testSilent')}</div>
488
+ <div class="pf-or-stt-meta">
489
+ ${t('profile.openrouter.stt.measured', {
490
+ seconds: (Number(result.seconds) || 0).toFixed(1),
491
+ cost: (Number(result.usage?.cost_usd) || 0).toFixed(6),
492
+ })}
493
+ ${result.usage?.cost_exact === false
494
+ ? html` <span class="pf-or-hint">${t('profile.openrouter.stt.costNotReported')}</span>` : null}
531
495
  </div>
496
+ </div>` : null}
497
+ </div>`;
498
+ }
499
+
500
+ /** Sampling parameters. Collapsed by default: most owners never touch them, and four rarely-used
501
+ * numeric fields sitting open is part of what pushed the save button off the screen before. */
502
+ function ParamsSection({
503
+ autoRetry, setAutoRetry, maxRetries, setMaxRetries, temperature, setTemperature,
504
+ topP, setTopP, maxTokens, setMaxTokens, saving, onSave, message,
505
+ }) {
506
+ return html`
507
+ <section class="pf-or-section">
508
+ <details class="pf-or-params">
509
+ <summary class="pf-or-section-title pf-or-params-summary">${t('profile.openrouter.section.params')}</summary>
510
+
511
+ <div class="pf-or-field">
512
+ <label class="pf-or-checkbox">
513
+ <input type="checkbox" checked=${autoRetry} onChange=${e => setAutoRetry(e.target.checked)} />
514
+ ${t('profile.openrouter.autoRetry')}
515
+ </label>
532
516
  </div>
533
- ${editing ? html`
534
- <input type="number" min="0" max="1000" step="0.10" value=${budgetInput}
535
- onInput=${e => setBudgetInput(e.target.value)}
536
- class="pf-gen-spend-budget-input" />
537
- <button class="btn-primary btn-sm" onClick=${saveBudget} disabled=${saving}>
538
- ${saving ? '...' : 'Save'}
539
- </button>
540
- <button class="btn-outline btn-sm" onClick=${() => setEditing(false)}>Cancel</button>
541
- ` : html`
542
- <button class="btn-outline btn-sm" onClick=${() => setEditing(true)}>Change</button>
543
- `}
544
- </div>
545
517
 
546
- ${message && html`<div class="pf-gen-or-message pf-gen-spend-msg ${message.error ? 'pf-gen-or-message-error' : 'pf-gen-or-message-success'}">${message.text}</div>`}
518
+ ${autoRetry && html`
519
+ <div class="pf-or-field">
520
+ <label class="pf-or-label">${t('profile.openrouter.maxRetries')}</label>
521
+ <input type="number" class="pf-or-input pf-or-input-sm" min="1" max="10" value=${maxRetries}
522
+ onInput=${e => setMaxRetries(Math.min(10, Math.max(1, parseInt(e.target.value) || 1)))} />
523
+ </div>`}
524
+
525
+ <div class="pf-or-field">
526
+ <label class="pf-or-label">${t('profile.openrouter.temperature')}</label>
527
+ <div class="pf-or-param-row">
528
+ <input type="number" class="pf-or-input pf-or-input-sm" min="0" max="2" step="0.1"
529
+ placeholder=${t('profile.openrouter.paramDefault')} value=${temperature}
530
+ onInput=${e => setTemperature(e.target.value)} />
531
+ <span class="pf-or-hint">${t('profile.openrouter.temperature_hint')}</span>
532
+ </div>
533
+ </div>
547
534
 
548
- ${appNames.length > 0 && html`
549
- <details class="pf-gen-spend-details" open>
550
- <summary class="pf-gen-spend-summary">
551
- Per-app limits & spend (${appNames.length} app${appNames.length === 1 ? '' : 's'})
552
- </summary>
553
- <div class="pf-gen-spend-hint">
554
- Each app may spend up to the daily budget above. Set a cap to throttle one app below it;
555
- leave blank to use the full budget.
535
+ <div class="pf-or-field">
536
+ <label class="pf-or-label">${t('profile.openrouter.topP')}</label>
537
+ <div class="pf-or-param-row">
538
+ <input type="number" class="pf-or-input pf-or-input-sm" min="0" max="1" step="0.05"
539
+ placeholder=${t('profile.openrouter.paramDefault')} value=${topP}
540
+ onInput=${e => setTopP(e.target.value)} />
541
+ <span class="pf-or-hint">${t('profile.openrouter.topP_hint')}</span>
556
542
  </div>
557
- <table class="pf-gen-spend-table">
558
- <thead>
559
- <tr>
560
- <th>App</th>
561
- <th class="num">Spent today</th>
562
- <th class="num">Daily cap ($)</th>
563
- <th class="num">Calls</th>
564
- </tr>
565
- </thead>
566
- <tbody>
567
- ${appNames.map((app) => {
568
- const s = usage.per_app[app] || { cost_usd: 0, calls: 0 };
569
- return html`
570
- <tr key=${app}>
571
- <td>${app}</td>
572
- <td class="num">$${(s.cost_usd || 0).toFixed(4)}</td>
573
- <td class="num">
574
- <input type="number" min="0" max="1000" step="0.10"
575
- value=${caps[app] ?? ''} placeholder=${budget.toFixed(2)}
576
- onInput=${e => setCaps(c => ({ ...c, [app]: e.target.value }))}
577
- class="pf-gen-spend-cap-input" />
578
- </td>
579
- <td class="num">${s.calls || 0}</td>
580
- </tr>`;
581
- })}
582
- </tbody>
583
- </table>
584
- <div class="pf-gen-spend-actions">
585
- <button class="btn-primary btn-sm" onClick=${saveCaps} disabled=${savingCaps}>
586
- ${savingCaps ? '...' : 'Save per-app limits'}
587
- </button>
588
- ${capsMsg && html`<span class="pf-gen-or-message ${capsMsg.error ? 'pf-gen-or-message-error' : 'pf-gen-or-message-success'}">${capsMsg.text}</span>`}
543
+ </div>
544
+
545
+ <div class="pf-or-field">
546
+ <label class="pf-or-label">${t('profile.openrouter.maxTokens')}</label>
547
+ <div class="pf-or-param-row">
548
+ <input type="number" class="pf-or-input pf-or-input-sm" min="256" max="128000" step="256"
549
+ placeholder=${t('profile.openrouter.paramDefault')} value=${maxTokens}
550
+ onInput=${e => setMaxTokens(e.target.value)} />
551
+ <span class="pf-or-hint">${t('profile.openrouter.maxTokens_hint')}</span>
589
552
  </div>
590
- </details>
591
- `}
592
-
593
- ${history && Array.isArray(history.days) && history.days.length > 0 && (() => {
594
- const labels = history.days.map((d) => d.date.slice(5));
595
- const chartApps = history.apps || [];
596
- const isTokens = metric === 'tokens';
597
- const datasets = chartApps.map((app, i) => ({
598
- label: app,
599
- data: history.days.map((d) => {
600
- const m = (d.per_app && d.per_app[app]) || {};
601
- return (isTokens ? m.tokens : m.cost_usd) || 0;
602
- }),
603
- backgroundColor: colorForIndex(i),
604
- }));
605
- const yFormat = isTokens ? (v) => fmtCompact(v) : (v) => '$' + (Number(v) < 1 ? Number(v).toFixed(3) : Number(v).toFixed(2));
606
- return html`
607
- <div class="pf-gen-spend-chart-wrap">
608
- <div class="pf-gen-spend-chart-head">
609
- <span class="pf-gen-spend-chart-title">${t('profile.generator.aiUsageOverTime') || 'Usage over time (30 days)'}</span>
610
- <span class="pf-gen-spend-metric-toggle">
611
- <button class=${!isTokens ? 'btn-primary btn-sm' : 'btn-outline btn-sm'} onClick=${() => setMetric('cost')}>${t('profile.generator.aiMetricCost') || 'Cost'}</button>
612
- <button class=${isTokens ? 'btn-primary btn-sm' : 'btn-outline btn-sm'} onClick=${() => setMetric('tokens')}>${t('profile.generator.aiMetricTokens') || 'Tokens'}</button>
613
- </span>
614
- </div>
615
- <${UsageChart} stacked labels=${labels} datasets=${datasets} height=${220} yFormat=${yFormat} />
616
- </div>`;
617
- })()}
553
+ </div>
618
554
 
619
- <div class="pf-gen-spend-footnote">
620
- Cost is estimated when the provider doesn't report it (LM Studio, custom). Your
621
- OpenRouter dashboard has the authoritative bill.
622
- </div>
623
- </div>
624
- `;
555
+ <div class="pf-or-actions">
556
+ <button class="btn-primary btn-sm" onClick=${onSave} disabled=${saving}>
557
+ ${saving ? '…' : t('profile.openrouter.saveParams')}
558
+ </button>
559
+ ${message && html`<span class=${`pf-or-message ${message.error ? 'pf-or-message-error' : 'pf-or-message-success'}`}>${message.text}</span>`}
560
+ </div>
561
+ </details>
562
+ </section>`;
625
563
  }
626
-