aimeat 2.5.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 (256) hide show
  1. package/dist/.env.example +33 -0
  2. package/dist/build-stamp.json +13 -0
  3. package/dist/locales/en.json +166 -3
  4. package/dist/locales/fi.json +166 -3
  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/js/services/audio-recorder.js +171 -0
  11. package/dist/public/js/services/messages.js +21 -2
  12. package/dist/public/spa.html +3 -0
  13. package/dist/public/views/profile/access-tab/connections.js +264 -0
  14. package/dist/public/views/profile/access-tab.js +2 -0
  15. package/dist/public/views/profile/inbox-tab/components.js +60 -10
  16. package/dist/public/views/profile/inbox-tab/helpers.js +33 -0
  17. package/dist/public/views/profile/inbox-tab/panels.js +6 -1
  18. package/dist/public/views/profile/inbox-tab/use-voice.js +50 -0
  19. package/dist/public/views/profile/inbox-tab/voice-parts.js +111 -0
  20. package/dist/public/views/profile/inbox-tab.js +18 -24
  21. package/dist/public/views/profile/openrouter/budget-panel.js +224 -0
  22. package/dist/public/views/profile/openrouter/model-picker.js +150 -0
  23. package/dist/public/views/profile/openrouter/pricing.js +139 -0
  24. package/dist/public/views/profile/openrouter-settings.js +425 -488
  25. package/dist/scripts/build-sdk-libs.d.ts.map +1 -1
  26. package/dist/scripts/build-sdk-libs.js +1 -0
  27. package/dist/scripts/build-sdk-libs.js.map +1 -1
  28. package/dist/scripts/check-ai-disclosure.d.ts.map +1 -1
  29. package/dist/scripts/check-ai-disclosure.js +65 -0
  30. package/dist/scripts/check-ai-disclosure.js.map +1 -1
  31. package/dist/src/cli/connect/ai-provenance-carry.d.ts +54 -3
  32. package/dist/src/cli/connect/ai-provenance-carry.d.ts.map +1 -1
  33. package/dist/src/cli/connect/ai-provenance-carry.js +84 -12
  34. package/dist/src/cli/connect/ai-provenance-carry.js.map +1 -1
  35. package/dist/src/cli/connect/api-client.d.ts +11 -0
  36. package/dist/src/cli/connect/api-client.d.ts.map +1 -1
  37. package/dist/src/cli/connect/api-client.js +2 -0
  38. package/dist/src/cli/connect/api-client.js.map +1 -1
  39. package/dist/src/cli/connect/mcp/local-server.d.ts.map +1 -1
  40. package/dist/src/cli/connect/mcp/local-server.js +11 -0
  41. package/dist/src/cli/connect/mcp/local-server.js.map +1 -1
  42. package/dist/src/cli/connect/mcp/tools/apps.d.ts +1 -0
  43. package/dist/src/cli/connect/mcp/tools/apps.d.ts.map +1 -1
  44. package/dist/src/cli/connect/mcp/tools/apps.js +3 -2
  45. package/dist/src/cli/connect/mcp/tools/apps.js.map +1 -1
  46. package/dist/src/cli/connect/mcp/tools/core.d.ts +2 -0
  47. package/dist/src/cli/connect/mcp/tools/core.d.ts.map +1 -1
  48. package/dist/src/cli/connect/mcp/tools/core.js +5 -2
  49. package/dist/src/cli/connect/mcp/tools/core.js.map +1 -1
  50. package/dist/src/cli/connect/mcp/tools/knowledge.d.ts +1 -0
  51. package/dist/src/cli/connect/mcp/tools/knowledge.d.ts.map +1 -1
  52. package/dist/src/cli/connect/mcp/tools/knowledge.js +3 -2
  53. package/dist/src/cli/connect/mcp/tools/knowledge.js.map +1 -1
  54. package/dist/src/config-types.d.ts +14 -0
  55. package/dist/src/config-types.d.ts.map +1 -1
  56. package/dist/src/config.d.ts.map +1 -1
  57. package/dist/src/config.js +10 -0
  58. package/dist/src/config.js.map +1 -1
  59. package/dist/src/generated/api-types.d.ts +969 -4
  60. package/dist/src/generated/api-types.d.ts.map +1 -1
  61. package/dist/src/mcp/ai-provenance-input.d.ts +33 -0
  62. package/dist/src/mcp/ai-provenance-input.d.ts.map +1 -1
  63. package/dist/src/mcp/ai-provenance-input.js +36 -0
  64. package/dist/src/mcp/ai-provenance-input.js.map +1 -1
  65. package/dist/src/mcp/apps.d.ts +7 -0
  66. package/dist/src/mcp/apps.d.ts.map +1 -1
  67. package/dist/src/mcp/apps.js +20 -2
  68. package/dist/src/mcp/apps.js.map +1 -1
  69. package/dist/src/models/ai-provenance-schemas.d.ts +7 -1
  70. package/dist/src/models/ai-provenance-schemas.d.ts.map +1 -1
  71. package/dist/src/models/ai-provenance-schemas.js +11 -1
  72. package/dist/src/models/ai-provenance-schemas.js.map +1 -1
  73. package/dist/src/models/connection-schemas.d.ts +203 -0
  74. package/dist/src/models/connection-schemas.d.ts.map +1 -0
  75. package/dist/src/models/connection-schemas.js +33 -0
  76. package/dist/src/models/connection-schemas.js.map +1 -0
  77. package/dist/src/models/message-schemas.d.ts +40 -0
  78. package/dist/src/models/message-schemas.d.ts.map +1 -1
  79. package/dist/src/models/message-schemas.js +23 -0
  80. package/dist/src/models/message-schemas.js.map +1 -1
  81. package/dist/src/routes/ai-provenance.d.ts +6 -0
  82. package/dist/src/routes/ai-provenance.d.ts.map +1 -1
  83. package/dist/src/routes/ai-provenance.js +34 -1
  84. package/dist/src/routes/ai-provenance.js.map +1 -1
  85. package/dist/src/routes/ai.d.ts +5 -0
  86. package/dist/src/routes/ai.d.ts.map +1 -1
  87. package/dist/src/routes/ai.js +83 -0
  88. package/dist/src/routes/ai.js.map +1 -1
  89. package/dist/src/routes/app-grants.d.ts.map +1 -1
  90. package/dist/src/routes/app-grants.js +4 -0
  91. package/dist/src/routes/app-grants.js.map +1 -1
  92. package/dist/src/routes/apps/drafts.d.ts +3 -0
  93. package/dist/src/routes/apps/drafts.d.ts.map +1 -1
  94. package/dist/src/routes/apps/drafts.js +13 -0
  95. package/dist/src/routes/apps/drafts.js.map +1 -1
  96. package/dist/src/routes/apps/publish.d.ts +5 -0
  97. package/dist/src/routes/apps/publish.d.ts.map +1 -1
  98. package/dist/src/routes/apps/publish.js +21 -5
  99. package/dist/src/routes/apps/publish.js.map +1 -1
  100. package/dist/src/routes/connections.d.ts +35 -0
  101. package/dist/src/routes/connections.d.ts.map +1 -0
  102. package/dist/src/routes/connections.js +406 -0
  103. package/dist/src/routes/connections.js.map +1 -0
  104. package/dist/src/routes/libs.js +1 -1
  105. package/dist/src/routes/libs.js.map +1 -1
  106. package/dist/src/routes/messages.d.ts +5 -0
  107. package/dist/src/routes/messages.d.ts.map +1 -1
  108. package/dist/src/routes/messages.js +92 -0
  109. package/dist/src/routes/messages.js.map +1 -1
  110. package/dist/src/routes/openrouter.d.ts +6 -1
  111. package/dist/src/routes/openrouter.d.ts.map +1 -1
  112. package/dist/src/routes/openrouter.js +37 -7
  113. package/dist/src/routes/openrouter.js.map +1 -1
  114. package/dist/src/routes/upload.d.ts +5 -0
  115. package/dist/src/routes/upload.d.ts.map +1 -1
  116. package/dist/src/routes/upload.js +32 -0
  117. package/dist/src/routes/upload.js.map +1 -1
  118. package/dist/src/server-bootstrap/routes-loader.d.ts.map +1 -1
  119. package/dist/src/server-bootstrap/routes-loader.js +2 -0
  120. package/dist/src/server-bootstrap/routes-loader.js.map +1 -1
  121. package/dist/src/services/ai-completion.d.ts +48 -0
  122. package/dist/src/services/ai-completion.d.ts.map +1 -1
  123. package/dist/src/services/ai-completion.js +112 -72
  124. package/dist/src/services/ai-completion.js.map +1 -1
  125. package/dist/src/services/ai-disclosure.d.ts +6 -0
  126. package/dist/src/services/ai-disclosure.d.ts.map +1 -1
  127. package/dist/src/services/ai-disclosure.js +20 -2
  128. package/dist/src/services/ai-disclosure.js.map +1 -1
  129. package/dist/src/services/ai-provenance-page.d.ts +59 -0
  130. package/dist/src/services/ai-provenance-page.d.ts.map +1 -0
  131. package/dist/src/services/ai-provenance-page.js +185 -0
  132. package/dist/src/services/ai-provenance-page.js.map +1 -0
  133. package/dist/src/services/ai-provenance.d.ts +6 -0
  134. package/dist/src/services/ai-provenance.d.ts.map +1 -1
  135. package/dist/src/services/ai-provenance.js +5 -0
  136. package/dist/src/services/ai-provenance.js.map +1 -1
  137. package/dist/src/services/ai-transcription.d.ts +66 -0
  138. package/dist/src/services/ai-transcription.d.ts.map +1 -0
  139. package/dist/src/services/ai-transcription.js +90 -0
  140. package/dist/src/services/ai-transcription.js.map +1 -0
  141. package/dist/src/services/app-publish.d.ts.map +1 -1
  142. package/dist/src/services/app-publish.js +16 -1
  143. package/dist/src/services/app-publish.js.map +1 -1
  144. package/dist/src/services/build-app-prompt.d.ts.map +1 -1
  145. package/dist/src/services/build-app-prompt.js +2 -1
  146. package/dist/src/services/build-app-prompt.js.map +1 -1
  147. package/dist/src/services/connections/attach.d.ts +62 -0
  148. package/dist/src/services/connections/attach.d.ts.map +1 -0
  149. package/dist/src/services/connections/attach.js +168 -0
  150. package/dist/src/services/connections/attach.js.map +1 -0
  151. package/dist/src/services/connections/credential.d.ts +39 -0
  152. package/dist/src/services/connections/credential.d.ts.map +1 -0
  153. package/dist/src/services/connections/credential.js +66 -0
  154. package/dist/src/services/connections/credential.js.map +1 -0
  155. package/dist/src/services/connections/instance.d.ts +65 -0
  156. package/dist/src/services/connections/instance.d.ts.map +1 -0
  157. package/dist/src/services/connections/instance.js +144 -0
  158. package/dist/src/services/connections/instance.js.map +1 -0
  159. package/dist/src/services/connections/oauth.d.ts +77 -0
  160. package/dist/src/services/connections/oauth.d.ts.map +1 -0
  161. package/dist/src/services/connections/oauth.js +290 -0
  162. package/dist/src/services/connections/oauth.js.map +1 -0
  163. package/dist/src/services/connections/providers.d.ts +118 -0
  164. package/dist/src/services/connections/providers.d.ts.map +1 -0
  165. package/dist/src/services/connections/providers.js +260 -0
  166. package/dist/src/services/connections/providers.js.map +1 -0
  167. package/dist/src/services/connections/publish-gate.d.ts +103 -0
  168. package/dist/src/services/connections/publish-gate.d.ts.map +1 -0
  169. package/dist/src/services/connections/publish-gate.js +0 -0
  170. package/dist/src/services/connections/publish-gate.js.map +1 -0
  171. package/dist/src/services/connections/publish.d.ts +60 -0
  172. package/dist/src/services/connections/publish.d.ts.map +1 -0
  173. package/dist/src/services/connections/publish.js +340 -0
  174. package/dist/src/services/connections/publish.js.map +1 -0
  175. package/dist/src/services/connections/refresh.d.ts +55 -0
  176. package/dist/src/services/connections/refresh.d.ts.map +1 -0
  177. package/dist/src/services/connections/refresh.js +307 -0
  178. package/dist/src/services/connections/refresh.js.map +1 -0
  179. package/dist/src/services/message-send.d.ts.map +1 -1
  180. package/dist/src/services/message-send.js +16 -2
  181. package/dist/src/services/message-send.js.map +1 -1
  182. package/dist/src/services/openrouter.d.ts +67 -1
  183. package/dist/src/services/openrouter.d.ts.map +1 -1
  184. package/dist/src/services/openrouter.js +102 -11
  185. package/dist/src/services/openrouter.js.map +1 -1
  186. package/dist/src/services/upload-token.d.ts +5 -1
  187. package/dist/src/services/upload-token.d.ts.map +1 -1
  188. package/dist/src/services/upload-token.js +13 -1
  189. package/dist/src/services/upload-token.js.map +1 -1
  190. package/dist/src/services/workflow/engine-util.d.ts +18 -0
  191. package/dist/src/services/workflow/engine-util.d.ts.map +1 -1
  192. package/dist/src/services/workflow/engine-util.js +27 -0
  193. package/dist/src/services/workflow/engine-util.js.map +1 -1
  194. package/dist/src/services/workflow/engine.d.ts.map +1 -1
  195. package/dist/src/services/workflow/engine.js +4 -2
  196. package/dist/src/services/workflow/engine.js.map +1 -1
  197. package/dist/src/storage/interface.d.ts +2 -1
  198. package/dist/src/storage/interface.d.ts.map +1 -1
  199. package/dist/src/storage/providers/postgres-kysely/db-types.d.ts +65 -0
  200. package/dist/src/storage/providers/postgres-kysely/db-types.d.ts.map +1 -1
  201. package/dist/src/storage/providers/postgres-kysely/index.d.ts.map +1 -1
  202. package/dist/src/storage/providers/postgres-kysely/index.js +2 -1
  203. package/dist/src/storage/providers/postgres-kysely/index.js.map +1 -1
  204. package/dist/src/storage/providers/postgres-kysely/methods/connections.d.ts +44 -0
  205. package/dist/src/storage/providers/postgres-kysely/methods/connections.d.ts.map +1 -0
  206. package/dist/src/storage/providers/postgres-kysely/methods/connections.js +308 -0
  207. package/dist/src/storage/providers/postgres-kysely/methods/connections.js.map +1 -0
  208. package/dist/src/storage/providers/postgres-kysely/methods/node-infra.d.ts.map +1 -1
  209. package/dist/src/storage/providers/postgres-kysely/methods/node-infra.js +4 -2
  210. package/dist/src/storage/providers/postgres-kysely/methods/node-infra.js.map +1 -1
  211. package/dist/src/storage/providers/postgres-kysely/migrations/0021_connections.sql +116 -0
  212. package/dist/src/storage/providers/postgres-kysely/migrations/0022_provider_clients.sql +36 -0
  213. package/dist/src/storage/providers/postgres-kysely/migrations/0023_nonce_payload.sql +13 -0
  214. package/dist/src/storage/providers/sqlite/index.d.ts.map +1 -1
  215. package/dist/src/storage/providers/sqlite/index.js +2 -1
  216. package/dist/src/storage/providers/sqlite/index.js.map +1 -1
  217. package/dist/src/storage/providers/sqlite/methods/community.d.ts.map +1 -1
  218. package/dist/src/storage/providers/sqlite/methods/community.js +2 -1
  219. package/dist/src/storage/providers/sqlite/methods/community.js.map +1 -1
  220. package/dist/src/storage/providers/sqlite/methods/connections.d.ts +60 -0
  221. package/dist/src/storage/providers/sqlite/methods/connections.d.ts.map +1 -0
  222. package/dist/src/storage/providers/sqlite/methods/connections.js +287 -0
  223. package/dist/src/storage/providers/sqlite/methods/connections.js.map +1 -0
  224. package/dist/src/storage/providers/sqlite/schema-tables-1.d.ts.map +1 -1
  225. package/dist/src/storage/providers/sqlite/schema-tables-1.js +4 -0
  226. package/dist/src/storage/providers/sqlite/schema-tables-1.js.map +1 -1
  227. package/dist/src/storage/providers/sqlite/schema-tables-3.d.ts.map +1 -1
  228. package/dist/src/storage/providers/sqlite/schema-tables-3.js +106 -0
  229. package/dist/src/storage/providers/sqlite/schema-tables-3.js.map +1 -1
  230. package/dist/src/storage/providers/sqlite/schema.d.ts.map +1 -1
  231. package/dist/src/storage/providers/sqlite/schema.js +2 -0
  232. package/dist/src/storage/providers/sqlite/schema.js.map +1 -1
  233. package/dist/src/storage/repositories/connection.repository.d.ts +124 -0
  234. package/dist/src/storage/repositories/connection.repository.d.ts.map +1 -0
  235. package/dist/src/storage/repositories/connection.repository.js +2 -0
  236. package/dist/src/storage/repositories/connection.repository.js.map +1 -0
  237. package/dist/src/storage/types/agents-messaging.d.ts +19 -0
  238. package/dist/src/storage/types/agents-messaging.d.ts.map +1 -1
  239. package/dist/src/storage/types/agents-messaging.js.map +1 -1
  240. package/dist/src/storage/types/auth.d.ts +8 -1
  241. package/dist/src/storage/types/auth.d.ts.map +1 -1
  242. package/dist/src/utils/build-stamp.d.ts +56 -0
  243. package/dist/src/utils/build-stamp.d.ts.map +1 -0
  244. package/dist/src/utils/build-stamp.js +142 -0
  245. package/dist/src/utils/build-stamp.js.map +1 -0
  246. package/dist/src/utils/messaging.d.ts +14 -0
  247. package/dist/src/utils/messaging.d.ts.map +1 -1
  248. package/dist/src/utils/messaging.js +0 -0
  249. package/dist/src/utils/messaging.js.map +1 -1
  250. package/dist/static/sdk-libs/connect/index.js +199 -0
  251. package/dist/static/sdk-libs/connect/notes.js +47 -0
  252. package/dist/static/sdk-libs/connect/panel.js +238 -0
  253. package/dist/static/sdk-libs/data/index.js +63 -11
  254. package/dist/static/sdk-libs/dist/aimeat-connect.js +357 -0
  255. package/dist/static/sdk-libs/dist/aimeat-data.js +40 -9
  256. package/package.json +2 -2
@@ -0,0 +1,224 @@
1
+ /**
2
+ * @file budget-panel.js
3
+ * @description The AI spend panel: today's total against the daily budget, per-app caps, and a
4
+ * 30-day stacked chart. Moved out of openrouter-settings.js during the settings rework and
5
+ * translated — every string in it used to be hardcoded English, which made this the one part of
6
+ * the profile that stayed English in a Finnish session.
7
+ *
8
+ * Numbers come from the server's own ledger (tokens always exact; cost is provider-reported when
9
+ * available and a rough estimate otherwise). Transcription spends the SAME budget as text, which is
10
+ * why the chart has a seconds metric and the footnote says so — a budget eaten by voice messages
11
+ * should not be a mystery.
12
+ * @structure AiAppsBudgetPanel (default export of the section) · fmtCompact
13
+ * @usage <${AiAppsBudgetPanel} />
14
+ * @version-history
15
+ * v1.0.0 — 2026-08-01 — Extracted from openrouter-settings.js v2.0.0, translated, seconds metric added.
16
+ */
17
+ import { h } from 'preact';
18
+ import { useState, useEffect } from 'preact/hooks';
19
+ import htm from 'htm';
20
+ const html = htm.bind(h);
21
+ import { t } from '/js/i18n.js';
22
+ import { apiGet, apiPost } from '/js/api.js';
23
+ import { UsageChart, colorForIndex } from '/components/UsageChart.js';
24
+ import { swallowed } from '/js/swallowed.js';
25
+
26
+ /** Compact number (73306 → "73.3k") for token axis/labels. */
27
+ export function fmtCompact(n) {
28
+ const v = Number(n) || 0;
29
+ if (v < 1000) return String(Math.round(v));
30
+ if (v < 1_000_000) return (v / 1000).toFixed(v < 10_000 ? 1 : 0) + 'k';
31
+ return (v / 1_000_000).toFixed(1) + 'M';
32
+ }
33
+
34
+ /** Seconds → "1:23" / "12s", for the audio metric. */
35
+ function fmtSeconds(n) {
36
+ const v = Math.round(Number(n) || 0);
37
+ if (v < 60) return `${v}s`;
38
+ return `${Math.floor(v / 60)}:${String(v % 60).padStart(2, '0')}`;
39
+ }
40
+
41
+ export function AiAppsBudgetPanel() {
42
+ const [usage, setUsage] = useState(null);
43
+ const [settings, setSettings] = useState(null);
44
+ const [editing, setEditing] = useState(false);
45
+ const [budgetInput, setBudgetInput] = useState('1');
46
+ const [saving, setSaving] = useState(false);
47
+ const [message, setMessage] = useState(null);
48
+ const [caps, setCaps] = useState({}); // app → cap input string ('' = use the daily budget)
49
+ const [savingCaps, setSavingCaps] = useState(false);
50
+ const [capsMsg, setCapsMsg] = useState(null);
51
+ const [history, setHistory] = useState(null); // GET /v1/ai/usage/history — per-day series + rollups
52
+ const [metric, setMetric] = useState('cost'); // 'cost' | 'tokens' | 'seconds'
53
+
54
+ useEffect(() => { reload(); }, []);
55
+
56
+ async function reload() {
57
+ const [u, s, hist] = await Promise.all([
58
+ apiGet('/v1/ai/usage').catch(err => { swallowed('budget-panel: usage', err); return null; }),
59
+ apiGet('/v1/ai/settings').catch(err => { swallowed('budget-panel: settings', err); return null; }),
60
+ apiGet('/v1/ai/usage/history?days=30').catch(err => { swallowed('budget-panel: history', err); return null; }),
61
+ ]);
62
+ if (hist && hist.ok !== false && hist.data) setHistory(hist.data);
63
+ if (u && u.ok !== false && u.data) setUsage(u.data);
64
+ if (s && s.ok !== false && s.data) {
65
+ setSettings(s.data);
66
+ setBudgetInput(String(s.data.daily_budget_usd ?? 1));
67
+ const q = s.data.app_quotas || {};
68
+ setCaps(Object.fromEntries(Object.entries(q).map(([app, v]) =>
69
+ [app, (v && v.daily_usd != null) ? String(v.daily_usd) : ''])));
70
+ }
71
+ }
72
+
73
+ async function saveCaps() {
74
+ setSavingCaps(true); setCapsMsg(null);
75
+ const app_quotas = {};
76
+ for (const [app, val] of Object.entries(caps)) {
77
+ const s = String(val).trim();
78
+ if (s === '') continue; // blank = no override → app uses the daily budget
79
+ const n = Number(s);
80
+ if (!Number.isFinite(n) || n < 0 || n > 1000) {
81
+ setCapsMsg({ text: t('profile.openrouter.budget.capRange', { app }), error: true });
82
+ setSavingCaps(false); return;
83
+ }
84
+ app_quotas[app] = { daily_usd: n };
85
+ }
86
+ try {
87
+ const r = await apiPost('/v1/ai/settings', { app_quotas });
88
+ if (r.ok === false) throw new Error(r.error?.message || t('profile.openrouter.budget.saveFailed'));
89
+ setCapsMsg({ text: t('profile.openrouter.budget.capsSaved') });
90
+ await reload();
91
+ } catch (e) {
92
+ setCapsMsg({ text: e.message || t('profile.openrouter.budget.saveFailed'), error: true });
93
+ }
94
+ setSavingCaps(false);
95
+ }
96
+
97
+ async function saveBudget() {
98
+ setSaving(true); setMessage(null);
99
+ const n = Number(budgetInput);
100
+ if (!Number.isFinite(n) || n < 0 || n > 1000) {
101
+ setMessage({ text: t('profile.openrouter.budget.budgetRange'), error: true });
102
+ setSaving(false); return;
103
+ }
104
+ try {
105
+ const r = await apiPost('/v1/ai/settings', { daily_budget_usd: n });
106
+ if (r.ok === false) throw new Error(r.error?.message || t('profile.openrouter.budget.saveFailed'));
107
+ setMessage({ text: t('profile.openrouter.budget.budgetSaved') });
108
+ setEditing(false);
109
+ await reload();
110
+ } catch (e) {
111
+ setMessage({ text: e.message || t('profile.openrouter.budget.saveFailed'), error: true });
112
+ }
113
+ setSaving(false);
114
+ }
115
+
116
+ if (!usage || !settings) return null;
117
+ const budget = usage.daily_budget_usd;
118
+ const spent = usage.spent_today_usd;
119
+ const pct = budget > 0 ? Math.min(100, Math.round((spent / budget) * 100)) : 0;
120
+ const perAppEntries = Object.entries(usage.per_app || {});
121
+ // Apps to offer a cap for: spent today, OR active in the last 30 days, OR already capped. Without
122
+ // the history apps the table hides itself whenever today's spend is $0, leaving nowhere to set a
123
+ // cap for an app that only ran on earlier days.
124
+ const historyApps = (history && Array.isArray(history.apps)) ? history.apps : [];
125
+ const appNames = Array.from(new Set([...perAppEntries.map(([a]) => a), ...historyApps, ...Object.keys(caps)]));
126
+
127
+ return html`
128
+ <div class="pf-or-field pf-or-spend-section">
129
+ <label class="pf-or-label">${t('profile.openrouter.budget.title')}</label>
130
+ <div class="pf-or-spend-desc">${t('profile.openrouter.budget.desc')}</div>
131
+
132
+ <div class="pf-or-spend-bar-row">
133
+ <div class="pf-or-spend-bar">
134
+ <div class="pf-or-spend-bar-fill ${pct >= 90 ? 'crit' : pct >= 60 ? 'warn' : ''}" style="width:${pct}%"></div>
135
+ <div class="pf-or-spend-bar-label">$${spent.toFixed(4)} / $${budget.toFixed(2)} (${pct}%)</div>
136
+ </div>
137
+ ${editing ? html`
138
+ <input type="number" min="0" max="1000" step="0.10" value=${budgetInput}
139
+ onInput=${e => setBudgetInput(e.target.value)}
140
+ class="pf-or-spend-budget-input" />
141
+ <button class="btn-primary btn-sm" onClick=${saveBudget} disabled=${saving}>
142
+ ${saving ? '…' : t('profile.openrouter.save')}
143
+ </button>
144
+ <button class="btn-outline btn-sm" onClick=${() => setEditing(false)}>${t('profile.openrouter.cancel')}</button>
145
+ ` : html`
146
+ <button class="btn-outline btn-sm" onClick=${() => setEditing(true)}>${t('profile.openrouter.budget.change')}</button>
147
+ `}
148
+ </div>
149
+
150
+ ${message && html`<div class="pf-or-message pf-or-spend-msg ${message.error ? 'pf-or-message-error' : 'pf-or-message-success'}">${message.text}</div>`}
151
+
152
+ ${appNames.length > 0 && html`
153
+ <details class="pf-or-spend-details" open>
154
+ <summary class="pf-or-spend-summary">${t('profile.openrouter.budget.perApp', { n: appNames.length })}</summary>
155
+ <div class="pf-or-spend-hint">${t('profile.openrouter.budget.perAppHint')}</div>
156
+ <table class="pf-or-spend-table">
157
+ <thead>
158
+ <tr>
159
+ <th>${t('profile.openrouter.budget.colApp')}</th>
160
+ <th class="num">${t('profile.openrouter.budget.colSpent')}</th>
161
+ <th class="num">${t('profile.openrouter.budget.colCap')}</th>
162
+ <th class="num">${t('profile.openrouter.budget.colCalls')}</th>
163
+ </tr>
164
+ </thead>
165
+ <tbody>
166
+ ${appNames.map((app) => {
167
+ const s = usage.per_app[app] || { cost_usd: 0, calls: 0 };
168
+ return html`
169
+ <tr key=${app}>
170
+ <td>${app}</td>
171
+ <td class="num">$${(s.cost_usd || 0).toFixed(4)}</td>
172
+ <td class="num">
173
+ <input type="number" min="0" max="1000" step="0.10"
174
+ value=${caps[app] ?? ''} placeholder=${budget.toFixed(2)}
175
+ onInput=${e => setCaps(c => ({ ...c, [app]: e.target.value }))}
176
+ class="pf-or-spend-cap-input" />
177
+ </td>
178
+ <td class="num">${s.calls || 0}</td>
179
+ </tr>`;
180
+ })}
181
+ </tbody>
182
+ </table>
183
+ <div class="pf-or-spend-actions">
184
+ <button class="btn-primary btn-sm" onClick=${saveCaps} disabled=${savingCaps}>
185
+ ${savingCaps ? '…' : t('profile.openrouter.budget.saveCaps')}
186
+ </button>
187
+ ${capsMsg && html`<span class="pf-or-message ${capsMsg.error ? 'pf-or-message-error' : 'pf-or-message-success'}">${capsMsg.text}</span>`}
188
+ </div>
189
+ </details>
190
+ `}
191
+
192
+ ${history && Array.isArray(history.days) && history.days.length > 0 && (() => {
193
+ const labels = history.days.map((d) => d.date.slice(5));
194
+ const chartApps = history.apps || [];
195
+ const pick = (m) => (metric === 'tokens' ? m.tokens : metric === 'seconds' ? m.audio_seconds : m.cost_usd) || 0;
196
+ const datasets = chartApps.map((app, i) => ({
197
+ label: app,
198
+ data: history.days.map((d) => pick((d.per_app && d.per_app[app]) || {})),
199
+ backgroundColor: colorForIndex(i),
200
+ }));
201
+ const yFormat = metric === 'tokens' ? ((v) => fmtCompact(v))
202
+ : metric === 'seconds' ? ((v) => fmtSeconds(v))
203
+ : ((v) => '$' + (Number(v) < 1 ? Number(v).toFixed(3) : Number(v).toFixed(2)));
204
+ const btn = (key, label) => html`
205
+ <button class=${metric === key ? 'btn-primary btn-sm' : 'btn-outline btn-sm'}
206
+ onClick=${() => setMetric(key)}>${label}</button>`;
207
+ return html`
208
+ <div class="pf-or-spend-chart-wrap">
209
+ <div class="pf-or-spend-chart-head">
210
+ <span class="pf-or-spend-chart-title">${t('profile.openrouter.budget.chartTitle')}</span>
211
+ <span class="pf-or-spend-metric-toggle">
212
+ ${btn('cost', t('profile.openrouter.budget.metricCost'))}
213
+ ${btn('tokens', t('profile.openrouter.budget.metricTokens'))}
214
+ ${btn('seconds', t('profile.openrouter.budget.metricSeconds'))}
215
+ </span>
216
+ </div>
217
+ <${UsageChart} stacked labels=${labels} datasets=${datasets} height=${220} yFormat=${yFormat} />
218
+ </div>`;
219
+ })()}
220
+
221
+ <div class="pf-or-spend-footnote">${t('profile.openrouter.budget.footnote')}</div>
222
+ </div>
223
+ `;
224
+ }
@@ -0,0 +1,150 @@
1
+ /**
2
+ * @file model-picker.js
3
+ * @description One model selector, used for every model role (default, reasoning, execution, vision,
4
+ * speech-to-text). Replaces a flat <select> that rendered the provider's whole catalogue — 336
5
+ * options on OpenRouter, with no search, no prices and no way to find out what a model is.
6
+ *
7
+ * Native <select> is kept for short lists (a self-hosted provider with a handful of models): it is
8
+ * the better control when it fits on a screen. Above the threshold it becomes a searchable list with
9
+ * a recommended group, the price, the context length, and a link to the model's own page.
10
+ * @structure ModelPicker (the control) · ModelRow (one option) · SELECT_THRESHOLD
11
+ * @usage <${ModelPicker} value=${model} onChange=${setModel} models=${models} modality="chat" />
12
+ * @version-history
13
+ * v1.0.0 — 2026-08-01 — Initial version (OpenRouter settings rework).
14
+ */
15
+ import { h } from 'preact';
16
+ import { useState, useMemo, useRef, useEffect } from 'preact/hooks';
17
+ import htm from 'htm';
18
+ const html = htm.bind(h);
19
+ import { t } from '/js/i18n.js';
20
+ import {
21
+ chatPriceLabel, audioPriceLabel, contextLabel, modelPageUrl, rankModels, matchesQuery, acceptsImages,
22
+ } from './pricing.js';
23
+
24
+ /** Below this many models a plain <select> is the better control, so we keep it. */
25
+ const SELECT_THRESHOLD = 25;
26
+
27
+ function priceLabelFor(model, modality) {
28
+ return (modality === 'transcription' || modality === 'speech')
29
+ ? audioPriceLabel(model, t)
30
+ : chatPriceLabel(model, t);
31
+ }
32
+
33
+ function ModelRow({ model, modality, selected, onPick, showLink }) {
34
+ const price = priceLabelFor(model, modality);
35
+ const ctx = contextLabel(model);
36
+ return html`
37
+ <li class=${`pf-or-mrow${selected ? ' pf-or-mrow--on' : ''}`}>
38
+ <button type="button" class="pf-or-mpick" onClick=${() => onPick(model.id)} title=${model.id}>
39
+ <span class="pf-or-mname">${model.name || model.id}</span>
40
+ <span class="pf-or-mid">${model.id}</span>
41
+ <span class="pf-or-mmeta">
42
+ ${price ? html`<span class="pf-or-mprice">${price}</span>` : null}
43
+ ${ctx ? html`<span class="pf-or-mctx">${t('profile.openrouter.price.context', { n: ctx })}</span>` : null}
44
+ </span>
45
+ </button>
46
+ ${showLink ? html`
47
+ <a class="pf-or-mlink" href=${modelPageUrl(model.id)} target="_blank" rel="noopener"
48
+ title=${t('profile.openrouter.model.openPage')} aria-label=${t('profile.openrouter.model.openPage')}>↗</a>` : null}
49
+ </li>`;
50
+ }
51
+
52
+ /**
53
+ * @param {object} props
54
+ * @param {string} props.value currently selected model id ('' = none)
55
+ * @param {(id: string) => void} props.onChange
56
+ * @param {Array<object>} props.models models already fetched for this modality
57
+ * @param {string} [props.modality] 'chat' | 'vision' | 'transcription' | 'speech'
58
+ * @param {boolean} [props.allowNone] offer an explicit "not in use" option
59
+ * @param {boolean} [props.allowCustom] let the user type a model id the catalogue does not list
60
+ * @param {boolean} [props.isOpenRouter] show links to openrouter.ai
61
+ * @param {boolean} [props.disabled]
62
+ * @param {string} [props.noneLabel]
63
+ */
64
+ export function ModelPicker({
65
+ value, onChange, models, modality = 'chat', allowNone = false, allowCustom = false,
66
+ isOpenRouter = true, disabled = false, noneLabel,
67
+ }) {
68
+ const [query, setQuery] = useState('');
69
+ const [showAll, setShowAll] = useState(false);
70
+ const [custom, setCustom] = useState('');
71
+ const listRef = useRef(null);
72
+
73
+ // Vision is not its own catalogue — it is the chat catalogue filtered by what each model declares
74
+ // it accepts, which is the only reliable signal (180 of 336 read images, with no naming pattern).
75
+ const pool = useMemo(() => {
76
+ const all = Array.isArray(models) ? models : [];
77
+ return modality === 'vision' ? all.filter(acceptsImages) : all;
78
+ }, [models, modality]);
79
+ const recommended = useMemo(() => rankModels(pool, modality).slice(0, 8), [pool, modality]);
80
+ const filtered = useMemo(() => pool.filter((m) => matchesQuery(m, query)), [pool, query]);
81
+
82
+ // A selected model that is not in the fetched list (a custom provider's id, or a model that left
83
+ // the catalogue) must still be visible as the current value rather than silently reading as unset.
84
+ const selectedKnown = pool.some((m) => m.id === value);
85
+
86
+ useEffect(() => { if (!query) setShowAll(false); }, [query]);
87
+
88
+ // A native <select> is the better control for a short list — but only for CHAT models, where the
89
+ // label alone is enough to choose by. An audio model's price is the thing a person needs to see
90
+ // (its unit is not knowable from the catalogue, so the reported figure and the link to the model's
91
+ // page carry the whole story), and a <select> can show neither. The transcription list is short by
92
+ // nature — 13 models — so this branch would otherwise swallow exactly the case the rich row exists for.
93
+ const richOnly = modality === 'transcription' || modality === 'speech';
94
+ if (!richOnly && pool.length > 0 && pool.length <= SELECT_THRESHOLD && !allowCustom) {
95
+ return html`
96
+ <select class="pf-or-select" value=${value} disabled=${disabled}
97
+ onChange=${(e) => onChange(e.target.value)}>
98
+ ${allowNone ? html`<option value="">${noneLabel || t('profile.openrouter.model.none')}</option>` : null}
99
+ ${pool.map((m) => html`<option key=${m.id} value=${m.id}>${m.name || m.id}</option>`)}
100
+ </select>`;
101
+ }
102
+
103
+ const visible = query ? filtered : (showAll ? filtered : recommended);
104
+
105
+ return html`
106
+ <div class=${`pf-or-picker${disabled ? ' pf-or-picker--off' : ''}`}>
107
+ <div class="pf-or-picker-head">
108
+ <span class="pf-or-picked">
109
+ ${value
110
+ ? html`<code class="pf-or-picked-id">${value}</code>`
111
+ : html`<span class="pf-or-picked-none">${noneLabel || t('profile.openrouter.model.none')}</span>`}
112
+ ${value && !selectedKnown ? html`<span class="pf-or-picked-warn">${t('profile.openrouter.model.notInList')}</span>` : null}
113
+ </span>
114
+ ${value && allowNone ? html`
115
+ <button type="button" class="btn-ghost btn-sm" disabled=${disabled}
116
+ onClick=${() => onChange('')}>${t('profile.openrouter.model.clear')}</button>` : null}
117
+ </div>
118
+
119
+ <input type="search" class="pf-or-input pf-or-msearch" value=${query} disabled=${disabled}
120
+ placeholder=${t('profile.openrouter.model.searchPlaceholder', { n: pool.length })}
121
+ onInput=${(e) => setQuery(e.target.value)} />
122
+
123
+ ${visible.length === 0
124
+ ? html`<div class="pf-or-mempty">${t('profile.openrouter.model.noMatch')}</div>`
125
+ : html`
126
+ <ul class="pf-or-mlist" ref=${listRef}>
127
+ ${!query && !showAll ? html`<li class="pf-or-mgroup">${t('profile.openrouter.model.recommended')}</li>` : null}
128
+ ${visible.map((m) => html`
129
+ <${ModelRow} key=${m.id} model=${m} modality=${modality} selected=${m.id === value}
130
+ onPick=${onChange} showLink=${isOpenRouter} />`)}
131
+ </ul>`}
132
+
133
+ ${!query && !showAll && filtered.length > recommended.length ? html`
134
+ <button type="button" class="btn-ghost btn-sm pf-or-mall" onClick=${() => setShowAll(true)}>
135
+ ${t('profile.openrouter.model.showAll', { n: filtered.length })}
136
+ </button>` : null}
137
+
138
+ ${allowCustom ? html`
139
+ <div class="pf-or-mcustom">
140
+ <input type="text" class="pf-or-input" value=${custom} disabled=${disabled}
141
+ placeholder=${t('profile.openrouter.model.customIdPlaceholder')}
142
+ onInput=${(e) => setCustom(e.target.value)} />
143
+ <button type="button" class="btn-outline btn-sm" disabled=${disabled || !custom.trim()}
144
+ onClick=${() => { onChange(custom.trim()); setCustom(''); }}>
145
+ ${t('profile.openrouter.model.customIdUse')}
146
+ </button>
147
+ </div>
148
+ <span class="pf-or-hint">${t('profile.openrouter.model.customIdHint')}</span>` : null}
149
+ </div>`;
150
+ }
@@ -0,0 +1,139 @@
1
+ /**
2
+ * @file pricing.js
3
+ * @description Pure pricing + ranking helpers for the AI provider model pickers. No Preact, no DOM,
4
+ * no network — everything here is a function of the model list the provider returned, which is what
5
+ * makes it the one place the pricing rules can be read and checked.
6
+ *
7
+ * THE RULE THAT MATTERS: chat models are priced per token and can be shown as $/M. Audio models
8
+ * cannot. Measured 2026-08-01, `openai/whisper-large-v3` reports pricing.prompt 0.0015 on Together
9
+ * and 0.111 on Groq — per minute and per hour respectively, with nothing in the API distinguishing
10
+ * them. So audioPriceLabel() shows the reported number WITHOUT inventing a unit, and the settings
11
+ * page pairs it with a real measured cost from a test transcription. A derived "$/min" here would be
12
+ * confidently wrong for whole providers.
13
+ * @structure perMillion / fmtUsdPerMillion / chatPriceLabel / audioPriceLabel / isFree /
14
+ * acceptsImages / modelPageUrl / rankModels
15
+ * @usage import { chatPriceLabel, rankModels } from './pricing.js';
16
+ * @version-history
17
+ * v1.0.0 — 2026-08-01 — Extracted from the settings panel as part of the OpenRouter settings rework.
18
+ */
19
+
20
+ /** A per-token price as dollars per million tokens, or null when the provider gave no number. */
21
+ export function perMillion(v) {
22
+ const n = Number(v);
23
+ return Number.isFinite(n) ? n * 1e6 : null;
24
+ }
25
+
26
+ /** Format a per-million figure. Sub-dollar prices need three decimals to say anything at all. */
27
+ export function fmtUsdPerMillion(n) {
28
+ if (n === null || !Number.isFinite(n)) return null;
29
+ if (n === 0) return '0';
30
+ return n < 1 ? `$${n.toFixed(3)}` : `$${n.toFixed(2)}`;
31
+ }
32
+
33
+ /** true when the model costs nothing to prompt (17 of the 336 catalogue models at time of writing). */
34
+ export function isFree(model) {
35
+ return Number(model?.pricing?.prompt) === 0 && Number(model?.pricing?.completion || 0) === 0;
36
+ }
37
+
38
+ /** Whether the model reads images. Read from what it declares, never guessed from its name. */
39
+ export function acceptsImages(model) {
40
+ return Array.isArray(model?.input_modalities) && model.input_modalities.includes('image');
41
+ }
42
+
43
+ /** Whether the model reads audio directly in a chat turn (a different thing from an STT model). */
44
+ export function acceptsAudio(model) {
45
+ return Array.isArray(model?.input_modalities) && model.input_modalities.includes('audio');
46
+ }
47
+
48
+ /**
49
+ * "$5.00 / M in · $25.00 / M out" for a chat model, or null when the provider reported no pricing
50
+ * (LM Studio and self-hosted endpoints do not).
51
+ */
52
+ export function chatPriceLabel(model, t) {
53
+ const inUsd = fmtUsdPerMillion(perMillion(model?.pricing?.prompt));
54
+ if (inUsd === null) return null;
55
+ if (isFree(model)) return t('profile.openrouter.price.free');
56
+ const outUsd = fmtUsdPerMillion(perMillion(model?.pricing?.completion));
57
+ const inPart = t('profile.openrouter.price.perMillionIn', { usd: inUsd });
58
+ return outUsd === null ? inPart : `${inPart} · ${t('profile.openrouter.price.perMillionOut', { usd: outUsd })}`;
59
+ }
60
+
61
+ /**
62
+ * The reported price of an audio model, stated as a bare number.
63
+ *
64
+ * Deliberately unitless: see the file header. The caller shows this next to a link to the model page,
65
+ * where the unit is written out, and next to the measured cost of a real transcription.
66
+ */
67
+ export function audioPriceLabel(model, t) {
68
+ const raw = model?.pricing?.prompt;
69
+ const n = Number(raw);
70
+ if (!Number.isFinite(n)) return null;
71
+ if (n === 0) return t('profile.openrouter.price.free');
72
+ // Trailing zeros stripped: 0.00150 reads as a different number from 0.0015 to a human eye.
73
+ return t('profile.openrouter.price.audioReported', { value: String(Number(n.toPrecision(6))) });
74
+ }
75
+
76
+ /** The model's page on OpenRouter. Only meaningful for OpenRouter itself. */
77
+ export function modelPageUrl(modelId) {
78
+ return `https://openrouter.ai/${modelId}`;
79
+ }
80
+
81
+ /** Context length as "200 k", or null. */
82
+ export function contextLabel(model) {
83
+ const n = Number(model?.context_length);
84
+ if (!Number.isFinite(n) || n <= 0) return null;
85
+ return n >= 1000 ? `${Math.round(n / 1000)} k` : String(n);
86
+ }
87
+
88
+ /**
89
+ * The short "recommended" group shown above the full list.
90
+ *
91
+ * Derived from the live catalogue by rule, never a hardcoded list of slugs: the catalogue changes
92
+ * weekly, and a pinned list would rot silently into recommending models that no longer exist.
93
+ *
94
+ * - transcription: the whole list is small (13 models), so it is simply ordered by reported price.
95
+ * - vision: image-capable models, cheapest input first.
96
+ * - chat: the largest context in each of three price bands (free / under $1 per M / above), which
97
+ * surfaces one sensible default per budget rather than 336 undifferentiated rows.
98
+ */
99
+ export function rankModels(models, modality) {
100
+ const list = Array.isArray(models) ? models.slice() : [];
101
+ const priceOf = (m) => {
102
+ const n = Number(m?.pricing?.prompt);
103
+ return Number.isFinite(n) ? n : Number.POSITIVE_INFINITY;
104
+ };
105
+
106
+ if (modality === 'transcription' || modality === 'speech') {
107
+ return list.sort((a, b) => priceOf(a) - priceOf(b));
108
+ }
109
+
110
+ if (modality === 'vision') {
111
+ return list.filter(acceptsImages).sort((a, b) => priceOf(a) - priceOf(b));
112
+ }
113
+
114
+ const byContext = (a, b) => (Number(b.context_length) || 0) - (Number(a.context_length) || 0);
115
+ const bands = [
116
+ list.filter((m) => isFree(m)),
117
+ list.filter((m) => !isFree(m) && perMillion(m?.pricing?.prompt) !== null && perMillion(m.pricing.prompt) < 1),
118
+ list.filter((m) => perMillion(m?.pricing?.prompt) !== null && perMillion(m.pricing.prompt) >= 1),
119
+ ];
120
+ const picked = [];
121
+ const seen = new Set();
122
+ for (const band of bands) {
123
+ for (const m of band.sort(byContext).slice(0, 2)) {
124
+ if (!seen.has(m.id)) { seen.add(m.id); picked.push(m); }
125
+ }
126
+ }
127
+ // The free router is a genuine default (no vendor pinned) and is worth offering even when the
128
+ // banding above did not surface it.
129
+ const free = list.find((m) => m.id === 'openrouter/free');
130
+ if (free && !seen.has(free.id)) picked.unshift(free);
131
+ return picked;
132
+ }
133
+
134
+ /** Case-insensitive match over id and display name. */
135
+ export function matchesQuery(model, query) {
136
+ const q = String(query || '').trim().toLowerCase();
137
+ if (!q) return true;
138
+ return String(model.id).toLowerCase().includes(q) || String(model.name || '').toLowerCase().includes(q);
139
+ }