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.
- package/dist/.env.example +33 -0
- package/dist/build-stamp.json +13 -0
- package/dist/locales/en.json +186 -20
- package/dist/locales/fi.json +186 -20
- package/dist/public/components/VoiceRecorder.js +116 -0
- package/dist/public/connection-done.html +53 -0
- package/dist/public/css/components/voice-recorder.css +66 -0
- package/dist/public/css/views/inbox.css +41 -0
- package/dist/public/css/views/profile.css +171 -95
- package/dist/public/css/views/transparency.css +5 -0
- package/dist/public/js/services/audio-recorder.js +171 -0
- package/dist/public/js/services/messages.js +21 -2
- package/dist/public/spa.html +3 -0
- package/dist/public/views/landing.js +4 -0
- package/dist/public/views/profile/access-tab/connections.js +264 -0
- package/dist/public/views/profile/access-tab.js +2 -0
- package/dist/public/views/profile/ai-transparency-card.js +22 -17
- package/dist/public/views/profile/inbox-tab/components.js +60 -10
- package/dist/public/views/profile/inbox-tab/helpers.js +33 -0
- package/dist/public/views/profile/inbox-tab/panels.js +6 -1
- package/dist/public/views/profile/inbox-tab/use-voice.js +50 -0
- package/dist/public/views/profile/inbox-tab/voice-parts.js +111 -0
- package/dist/public/views/profile/inbox-tab.js +18 -24
- package/dist/public/views/profile/openrouter/budget-panel.js +224 -0
- package/dist/public/views/profile/openrouter/model-picker.js +150 -0
- package/dist/public/views/profile/openrouter/pricing.js +139 -0
- package/dist/public/views/profile/openrouter-settings.js +425 -488
- package/dist/public/views/transparency.js +50 -0
- package/dist/scripts/build-sdk-libs.d.ts.map +1 -1
- package/dist/scripts/build-sdk-libs.js +1 -0
- package/dist/scripts/build-sdk-libs.js.map +1 -1
- package/dist/scripts/check-ai-disclosure.d.ts.map +1 -1
- package/dist/scripts/check-ai-disclosure.js +182 -0
- package/dist/scripts/check-ai-disclosure.js.map +1 -1
- package/dist/src/cli/connect/ai-provenance-carry.d.ts +247 -0
- package/dist/src/cli/connect/ai-provenance-carry.d.ts.map +1 -0
- package/dist/src/cli/connect/ai-provenance-carry.js +334 -0
- package/dist/src/cli/connect/ai-provenance-carry.js.map +1 -0
- package/dist/src/cli/connect/api-client.d.ts +11 -0
- package/dist/src/cli/connect/api-client.d.ts.map +1 -1
- package/dist/src/cli/connect/api-client.js +2 -0
- package/dist/src/cli/connect/api-client.js.map +1 -1
- package/dist/src/cli/connect/mcp/local-server.d.ts.map +1 -1
- package/dist/src/cli/connect/mcp/local-server.js +11 -0
- package/dist/src/cli/connect/mcp/local-server.js.map +1 -1
- package/dist/src/cli/connect/mcp/tools/agent-messages.d.ts +2 -0
- package/dist/src/cli/connect/mcp/tools/agent-messages.d.ts.map +1 -1
- package/dist/src/cli/connect/mcp/tools/agent-messages.js +5 -2
- package/dist/src/cli/connect/mcp/tools/agent-messages.js.map +1 -1
- package/dist/src/cli/connect/mcp/tools/agent-tasks.d.ts +2 -0
- package/dist/src/cli/connect/mcp/tools/agent-tasks.d.ts.map +1 -1
- package/dist/src/cli/connect/mcp/tools/agent-tasks.js +5 -2
- package/dist/src/cli/connect/mcp/tools/agent-tasks.js.map +1 -1
- package/dist/src/cli/connect/mcp/tools/apps.d.ts +3 -0
- package/dist/src/cli/connect/mcp/tools/apps.d.ts.map +1 -1
- package/dist/src/cli/connect/mcp/tools/apps.js +13 -5
- package/dist/src/cli/connect/mcp/tools/apps.js.map +1 -1
- package/dist/src/cli/connect/mcp/tools/boards.d.ts +3 -0
- package/dist/src/cli/connect/mcp/tools/boards.d.ts.map +1 -1
- package/dist/src/cli/connect/mcp/tools/boards.js +5 -2
- package/dist/src/cli/connect/mcp/tools/boards.js.map +1 -1
- package/dist/src/cli/connect/mcp/tools/core.d.ts +5 -0
- package/dist/src/cli/connect/mcp/tools/core.d.ts.map +1 -1
- package/dist/src/cli/connect/mcp/tools/core.js +25 -5
- package/dist/src/cli/connect/mcp/tools/core.js.map +1 -1
- package/dist/src/cli/connect/mcp/tools/dm-messages.d.ts +2 -0
- package/dist/src/cli/connect/mcp/tools/dm-messages.d.ts.map +1 -1
- package/dist/src/cli/connect/mcp/tools/dm-messages.js +11 -6
- package/dist/src/cli/connect/mcp/tools/dm-messages.js.map +1 -1
- package/dist/src/cli/connect/mcp/tools/exchange.d.ts +2 -0
- package/dist/src/cli/connect/mcp/tools/exchange.d.ts.map +1 -1
- package/dist/src/cli/connect/mcp/tools/exchange.js +8 -2
- package/dist/src/cli/connect/mcp/tools/exchange.js.map +1 -1
- package/dist/src/cli/connect/mcp/tools/knowledge.d.ts +3 -0
- package/dist/src/cli/connect/mcp/tools/knowledge.d.ts.map +1 -1
- package/dist/src/cli/connect/mcp/tools/knowledge.js +7 -3
- package/dist/src/cli/connect/mcp/tools/knowledge.js.map +1 -1
- package/dist/src/cli/connect/mcp/tools/organisms.d.ts +2 -0
- package/dist/src/cli/connect/mcp/tools/organisms.d.ts.map +1 -1
- package/dist/src/cli/connect/mcp/tools/organisms.js +7 -2
- package/dist/src/cli/connect/mcp/tools/organisms.js.map +1 -1
- package/dist/src/cli/connect/mcp/tools/workspaces.d.ts +3 -0
- package/dist/src/cli/connect/mcp/tools/workspaces.d.ts.map +1 -1
- package/dist/src/cli/connect/mcp/tools/workspaces.js +8 -2
- package/dist/src/cli/connect/mcp/tools/workspaces.js.map +1 -1
- package/dist/src/cli/connect/tool-call-defs-core.d.ts +3 -0
- package/dist/src/cli/connect/tool-call-defs-core.d.ts.map +1 -1
- package/dist/src/cli/connect/tool-call-defs-core.js +7 -0
- package/dist/src/cli/connect/tool-call-defs-core.js.map +1 -1
- package/dist/src/cli/connect/tool-call.d.ts +3 -0
- package/dist/src/cli/connect/tool-call.d.ts.map +1 -1
- package/dist/src/cli/connect/tool-call.js +11 -1
- package/dist/src/cli/connect/tool-call.js.map +1 -1
- package/dist/src/config-types.d.ts +14 -0
- package/dist/src/config-types.d.ts.map +1 -1
- package/dist/src/config.d.ts.map +1 -1
- package/dist/src/config.js +10 -0
- package/dist/src/config.js.map +1 -1
- package/dist/src/data/public-pages.d.ts +12 -0
- package/dist/src/data/public-pages.d.ts.map +1 -1
- package/dist/src/data/public-pages.js +12 -0
- package/dist/src/data/public-pages.js.map +1 -1
- package/dist/src/generated/api-types.d.ts +969 -85
- package/dist/src/generated/api-types.d.ts.map +1 -1
- package/dist/src/mcp/ai-provenance-input.d.ts +45 -2
- package/dist/src/mcp/ai-provenance-input.d.ts.map +1 -1
- package/dist/src/mcp/ai-provenance-input.js +48 -2
- package/dist/src/mcp/ai-provenance-input.js.map +1 -1
- package/dist/src/mcp/apps.d.ts +7 -0
- package/dist/src/mcp/apps.d.ts.map +1 -1
- package/dist/src/mcp/apps.js +20 -2
- package/dist/src/mcp/apps.js.map +1 -1
- package/dist/src/models/ai-provenance-schemas.d.ts +7 -1
- package/dist/src/models/ai-provenance-schemas.d.ts.map +1 -1
- package/dist/src/models/ai-provenance-schemas.js +11 -1
- package/dist/src/models/ai-provenance-schemas.js.map +1 -1
- package/dist/src/models/connection-schemas.d.ts +203 -0
- package/dist/src/models/connection-schemas.d.ts.map +1 -0
- package/dist/src/models/connection-schemas.js +33 -0
- package/dist/src/models/connection-schemas.js.map +1 -0
- package/dist/src/models/message-schemas.d.ts +40 -0
- package/dist/src/models/message-schemas.d.ts.map +1 -1
- package/dist/src/models/message-schemas.js +23 -0
- package/dist/src/models/message-schemas.js.map +1 -1
- package/dist/src/routes/ai-provenance.d.ts +6 -0
- package/dist/src/routes/ai-provenance.d.ts.map +1 -1
- package/dist/src/routes/ai-provenance.js +34 -1
- package/dist/src/routes/ai-provenance.js.map +1 -1
- package/dist/src/routes/ai-transparency.d.ts +6 -1
- package/dist/src/routes/ai-transparency.d.ts.map +1 -1
- package/dist/src/routes/ai-transparency.js +17 -3
- package/dist/src/routes/ai-transparency.js.map +1 -1
- package/dist/src/routes/ai.d.ts +5 -0
- package/dist/src/routes/ai.d.ts.map +1 -1
- package/dist/src/routes/ai.js +83 -0
- package/dist/src/routes/ai.js.map +1 -1
- package/dist/src/routes/app-grants.d.ts.map +1 -1
- package/dist/src/routes/app-grants.js +4 -0
- package/dist/src/routes/app-grants.js.map +1 -1
- package/dist/src/routes/apps/drafts.d.ts +3 -0
- package/dist/src/routes/apps/drafts.d.ts.map +1 -1
- package/dist/src/routes/apps/drafts.js +13 -0
- package/dist/src/routes/apps/drafts.js.map +1 -1
- package/dist/src/routes/apps/publish.d.ts +5 -0
- package/dist/src/routes/apps/publish.d.ts.map +1 -1
- package/dist/src/routes/apps/publish.js +21 -5
- package/dist/src/routes/apps/publish.js.map +1 -1
- package/dist/src/routes/connections.d.ts +35 -0
- package/dist/src/routes/connections.d.ts.map +1 -0
- package/dist/src/routes/connections.js +406 -0
- package/dist/src/routes/connections.js.map +1 -0
- package/dist/src/routes/libs.js +1 -1
- package/dist/src/routes/libs.js.map +1 -1
- package/dist/src/routes/messages.d.ts +5 -0
- package/dist/src/routes/messages.d.ts.map +1 -1
- package/dist/src/routes/messages.js +92 -0
- package/dist/src/routes/messages.js.map +1 -1
- package/dist/src/routes/openrouter.d.ts +6 -1
- package/dist/src/routes/openrouter.d.ts.map +1 -1
- package/dist/src/routes/openrouter.js +37 -7
- package/dist/src/routes/openrouter.js.map +1 -1
- package/dist/src/routes/upload.d.ts +5 -0
- package/dist/src/routes/upload.d.ts.map +1 -1
- package/dist/src/routes/upload.js +32 -0
- package/dist/src/routes/upload.js.map +1 -1
- package/dist/src/server-bootstrap/routes-loader.d.ts.map +1 -1
- package/dist/src/server-bootstrap/routes-loader.js +2 -0
- package/dist/src/server-bootstrap/routes-loader.js.map +1 -1
- package/dist/src/services/ai-completion.d.ts +48 -0
- package/dist/src/services/ai-completion.d.ts.map +1 -1
- package/dist/src/services/ai-completion.js +112 -72
- package/dist/src/services/ai-completion.js.map +1 -1
- package/dist/src/services/ai-disclosure.d.ts +6 -0
- package/dist/src/services/ai-disclosure.d.ts.map +1 -1
- package/dist/src/services/ai-disclosure.js +20 -2
- package/dist/src/services/ai-disclosure.js.map +1 -1
- package/dist/src/services/ai-provenance-page.d.ts +59 -0
- package/dist/src/services/ai-provenance-page.d.ts.map +1 -0
- package/dist/src/services/ai-provenance-page.js +185 -0
- package/dist/src/services/ai-provenance-page.js.map +1 -0
- package/dist/src/services/ai-provenance.d.ts +6 -0
- package/dist/src/services/ai-provenance.d.ts.map +1 -1
- package/dist/src/services/ai-provenance.js +5 -0
- package/dist/src/services/ai-provenance.js.map +1 -1
- package/dist/src/services/ai-transcription.d.ts +66 -0
- package/dist/src/services/ai-transcription.d.ts.map +1 -0
- package/dist/src/services/ai-transcription.js +90 -0
- package/dist/src/services/ai-transcription.js.map +1 -0
- package/dist/src/services/app-publish.d.ts.map +1 -1
- package/dist/src/services/app-publish.js +16 -1
- package/dist/src/services/app-publish.js.map +1 -1
- package/dist/src/services/build-app-prompt.d.ts.map +1 -1
- package/dist/src/services/build-app-prompt.js +2 -1
- package/dist/src/services/build-app-prompt.js.map +1 -1
- package/dist/src/services/connections/attach.d.ts +62 -0
- package/dist/src/services/connections/attach.d.ts.map +1 -0
- package/dist/src/services/connections/attach.js +168 -0
- package/dist/src/services/connections/attach.js.map +1 -0
- package/dist/src/services/connections/credential.d.ts +39 -0
- package/dist/src/services/connections/credential.d.ts.map +1 -0
- package/dist/src/services/connections/credential.js +66 -0
- package/dist/src/services/connections/credential.js.map +1 -0
- package/dist/src/services/connections/instance.d.ts +65 -0
- package/dist/src/services/connections/instance.d.ts.map +1 -0
- package/dist/src/services/connections/instance.js +144 -0
- package/dist/src/services/connections/instance.js.map +1 -0
- package/dist/src/services/connections/oauth.d.ts +77 -0
- package/dist/src/services/connections/oauth.d.ts.map +1 -0
- package/dist/src/services/connections/oauth.js +290 -0
- package/dist/src/services/connections/oauth.js.map +1 -0
- package/dist/src/services/connections/providers.d.ts +118 -0
- package/dist/src/services/connections/providers.d.ts.map +1 -0
- package/dist/src/services/connections/providers.js +260 -0
- package/dist/src/services/connections/providers.js.map +1 -0
- package/dist/src/services/connections/publish-gate.d.ts +103 -0
- package/dist/src/services/connections/publish-gate.d.ts.map +1 -0
- package/dist/src/services/connections/publish-gate.js +0 -0
- package/dist/src/services/connections/publish-gate.js.map +1 -0
- package/dist/src/services/connections/publish.d.ts +60 -0
- package/dist/src/services/connections/publish.d.ts.map +1 -0
- package/dist/src/services/connections/publish.js +340 -0
- package/dist/src/services/connections/publish.js.map +1 -0
- package/dist/src/services/connections/refresh.d.ts +55 -0
- package/dist/src/services/connections/refresh.d.ts.map +1 -0
- package/dist/src/services/connections/refresh.js +307 -0
- package/dist/src/services/connections/refresh.js.map +1 -0
- package/dist/src/services/markdown-negotiation.d.ts +17 -0
- package/dist/src/services/markdown-negotiation.d.ts.map +1 -1
- package/dist/src/services/markdown-negotiation.js +15 -0
- package/dist/src/services/markdown-negotiation.js.map +1 -1
- package/dist/src/services/message-send.d.ts.map +1 -1
- package/dist/src/services/message-send.js +16 -2
- package/dist/src/services/message-send.js.map +1 -1
- package/dist/src/services/openrouter.d.ts +67 -1
- package/dist/src/services/openrouter.d.ts.map +1 -1
- package/dist/src/services/openrouter.js +102 -11
- package/dist/src/services/openrouter.js.map +1 -1
- package/dist/src/services/upload-token.d.ts +5 -1
- package/dist/src/services/upload-token.d.ts.map +1 -1
- package/dist/src/services/upload-token.js +13 -1
- package/dist/src/services/upload-token.js.map +1 -1
- package/dist/src/services/workflow/engine-util.d.ts +18 -0
- package/dist/src/services/workflow/engine-util.d.ts.map +1 -1
- package/dist/src/services/workflow/engine-util.js +27 -0
- package/dist/src/services/workflow/engine-util.js.map +1 -1
- package/dist/src/services/workflow/engine.d.ts.map +1 -1
- package/dist/src/services/workflow/engine.js +4 -2
- package/dist/src/services/workflow/engine.js.map +1 -1
- package/dist/src/storage/interface.d.ts +2 -1
- package/dist/src/storage/interface.d.ts.map +1 -1
- package/dist/src/storage/providers/postgres-kysely/db-types.d.ts +65 -0
- package/dist/src/storage/providers/postgres-kysely/db-types.d.ts.map +1 -1
- package/dist/src/storage/providers/postgres-kysely/index.d.ts.map +1 -1
- package/dist/src/storage/providers/postgres-kysely/index.js +2 -1
- package/dist/src/storage/providers/postgres-kysely/index.js.map +1 -1
- package/dist/src/storage/providers/postgres-kysely/methods/connections.d.ts +44 -0
- package/dist/src/storage/providers/postgres-kysely/methods/connections.d.ts.map +1 -0
- package/dist/src/storage/providers/postgres-kysely/methods/connections.js +308 -0
- package/dist/src/storage/providers/postgres-kysely/methods/connections.js.map +1 -0
- package/dist/src/storage/providers/postgres-kysely/methods/node-infra.d.ts.map +1 -1
- package/dist/src/storage/providers/postgres-kysely/methods/node-infra.js +4 -2
- package/dist/src/storage/providers/postgres-kysely/methods/node-infra.js.map +1 -1
- package/dist/src/storage/providers/postgres-kysely/migrations/0021_connections.sql +116 -0
- package/dist/src/storage/providers/postgres-kysely/migrations/0022_provider_clients.sql +36 -0
- package/dist/src/storage/providers/postgres-kysely/migrations/0023_nonce_payload.sql +13 -0
- package/dist/src/storage/providers/sqlite/index.d.ts.map +1 -1
- package/dist/src/storage/providers/sqlite/index.js +2 -1
- package/dist/src/storage/providers/sqlite/index.js.map +1 -1
- package/dist/src/storage/providers/sqlite/methods/community.d.ts.map +1 -1
- package/dist/src/storage/providers/sqlite/methods/community.js +2 -1
- package/dist/src/storage/providers/sqlite/methods/community.js.map +1 -1
- package/dist/src/storage/providers/sqlite/methods/connections.d.ts +60 -0
- package/dist/src/storage/providers/sqlite/methods/connections.d.ts.map +1 -0
- package/dist/src/storage/providers/sqlite/methods/connections.js +287 -0
- package/dist/src/storage/providers/sqlite/methods/connections.js.map +1 -0
- package/dist/src/storage/providers/sqlite/schema-tables-1.d.ts.map +1 -1
- package/dist/src/storage/providers/sqlite/schema-tables-1.js +4 -0
- package/dist/src/storage/providers/sqlite/schema-tables-1.js.map +1 -1
- package/dist/src/storage/providers/sqlite/schema-tables-3.d.ts.map +1 -1
- package/dist/src/storage/providers/sqlite/schema-tables-3.js +106 -0
- package/dist/src/storage/providers/sqlite/schema-tables-3.js.map +1 -1
- package/dist/src/storage/providers/sqlite/schema.d.ts.map +1 -1
- package/dist/src/storage/providers/sqlite/schema.js +2 -0
- package/dist/src/storage/providers/sqlite/schema.js.map +1 -1
- package/dist/src/storage/repositories/connection.repository.d.ts +124 -0
- package/dist/src/storage/repositories/connection.repository.d.ts.map +1 -0
- package/dist/src/storage/repositories/connection.repository.js +2 -0
- package/dist/src/storage/repositories/connection.repository.js.map +1 -0
- package/dist/src/storage/types/agents-messaging.d.ts +19 -0
- package/dist/src/storage/types/agents-messaging.d.ts.map +1 -1
- package/dist/src/storage/types/agents-messaging.js.map +1 -1
- package/dist/src/storage/types/auth.d.ts +8 -1
- package/dist/src/storage/types/auth.d.ts.map +1 -1
- package/dist/src/utils/build-stamp.d.ts +56 -0
- package/dist/src/utils/build-stamp.d.ts.map +1 -0
- package/dist/src/utils/build-stamp.js +142 -0
- package/dist/src/utils/build-stamp.js.map +1 -0
- package/dist/src/utils/messaging.d.ts +14 -0
- package/dist/src/utils/messaging.d.ts.map +1 -1
- package/dist/src/utils/messaging.js +0 -0
- package/dist/src/utils/messaging.js.map +1 -1
- package/dist/static/sdk-libs/connect/index.js +199 -0
- package/dist/static/sdk-libs/connect/notes.js +47 -0
- package/dist/static/sdk-libs/connect/panel.js +238 -0
- package/dist/static/sdk-libs/data/index.js +63 -11
- package/dist/static/sdk-libs/dist/aimeat-connect.js +357 -0
- package/dist/static/sdk-libs/dist/aimeat-data.js +40 -9
- package/package.json +2 -2
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file public/views/profile/inbox-tab/use-voice.js
|
|
3
|
+
* @description The Inbox tab's voice-message state: whether this owner can transcribe at all, how
|
|
4
|
+
* long the node lets a recording run, and the call that turns one voice attachment into text.
|
|
5
|
+
* Extracted from inbox-tab.js to keep it under the max-file-lines rule.
|
|
6
|
+
* @structure useVoiceMessages(loadThread, activeConvRef) → { canTranscribe, voiceMaxSeconds, transcribeVoice }
|
|
7
|
+
* @usage const { canTranscribe, voiceMaxSeconds, transcribeVoice } = useVoiceMessages(loadThread, activeConvRef);
|
|
8
|
+
* @version-history
|
|
9
|
+
* v1.0.0 — 2026-08-01 — Extracted from inbox-tab.js (voice messages).
|
|
10
|
+
*/
|
|
11
|
+
import { useState, useEffect, useCallback } from 'preact/hooks';
|
|
12
|
+
import { t } from '/js/i18n.js';
|
|
13
|
+
import { apiGet } from '/js/api.js';
|
|
14
|
+
import * as messages from '/js/services/messages.js';
|
|
15
|
+
import { swallowed } from '/js/swallowed.js';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @param {(conv: any, markRead?: boolean) => Promise<void>} loadThread
|
|
19
|
+
* @param {{ current: any }} activeConvRef the OPEN conversation, read at call time
|
|
20
|
+
*/
|
|
21
|
+
export function useVoiceMessages(loadThread, activeConvRef) {
|
|
22
|
+
// canTranscribe answers "has this owner chosen a transcription model", which decides whether a
|
|
23
|
+
// voice bubble offers a Transcribe button or explains why it cannot. Read once per mount: an owner
|
|
24
|
+
// does not change it mid-thread, and polling it would be a request per render for one boolean.
|
|
25
|
+
const [canTranscribe, setCanTranscribe] = useState(null);
|
|
26
|
+
// Served by the node so the recorder stops at the operator's number rather than one compiled in.
|
|
27
|
+
const [voiceMaxSeconds, setVoiceMaxSeconds] = useState(300);
|
|
28
|
+
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
apiGet('/v1/openrouter/settings')
|
|
31
|
+
.then((r) => {
|
|
32
|
+
setCanTranscribe(!!(r?.data?.sttModel));
|
|
33
|
+
const n = Number(r?.data?.limits?.voice_msg_max_seconds);
|
|
34
|
+
if (n > 0) setVoiceMaxSeconds(n);
|
|
35
|
+
})
|
|
36
|
+
.catch((err) => { swallowed('inbox-tab: stt settings', err); setCanTranscribe(false); });
|
|
37
|
+
}, []);
|
|
38
|
+
|
|
39
|
+
/** Transcribe one voice attachment on the caller's OWN copy, then reload the thread so the text
|
|
40
|
+
* appears where the audio is. Errors are rethrown so the bubble can show them in place. */
|
|
41
|
+
const transcribeVoice = useCallback(async (msgId, attId) => {
|
|
42
|
+
const r = await messages.transcribeAttachment(msgId, attId);
|
|
43
|
+
if (r?.ok === false) throw new Error(r?.error?.message || t('inbox.transcribeFailed'));
|
|
44
|
+
const conv = activeConvRef.current;
|
|
45
|
+
if (conv) await loadThread(conv);
|
|
46
|
+
return r?.data;
|
|
47
|
+
}, [loadThread, activeConvRef]);
|
|
48
|
+
|
|
49
|
+
return { canTranscribe, voiceMaxSeconds, transcribeVoice };
|
|
50
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file public/views/profile/inbox-tab/voice-parts.js
|
|
3
|
+
* @description The voice-message pieces of a message bubble: the in-place audio player and the
|
|
4
|
+
* transcript row under it. Extracted from components.js purely to keep that file under the
|
|
5
|
+
* max-file-lines rule; behaviour is unchanged.
|
|
6
|
+
*
|
|
7
|
+
* Two rules live here. Only one player runs at a time (and starting one silences the read-aloud
|
|
8
|
+
* engine, so a synthetic voice never talks over a human one). And transcription is never automatic:
|
|
9
|
+
* it spends the owner's own AI budget, so it is always a click, and the button says what it will do.
|
|
10
|
+
* @structure fmtClock · stopOtherAudio · AudioAttachment · TranscriptPanel
|
|
11
|
+
* @usage import { AudioAttachment } from './voice-parts.js';
|
|
12
|
+
* @version-history
|
|
13
|
+
* v1.0.0 — 2026-08-01 — Extracted from components.js (voice messages).
|
|
14
|
+
*/
|
|
15
|
+
import { h } from 'preact';
|
|
16
|
+
import { useState } from 'preact/hooks';
|
|
17
|
+
import htm from 'htm';
|
|
18
|
+
const html = htm.bind(h);
|
|
19
|
+
import { t } from '/js/i18n.js';
|
|
20
|
+
import { escHtml } from '/js/utils.js';
|
|
21
|
+
import { stop as stopSpeech } from '/js/services/speech-reader.js';
|
|
22
|
+
|
|
23
|
+
/** Seconds → "0:14". */
|
|
24
|
+
export function fmtClock(seconds) {
|
|
25
|
+
const s = Math.max(0, Math.round(Number(seconds) || 0));
|
|
26
|
+
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** One voice message at a time. Two playing at once is noise, and a screen reader talking over a
|
|
30
|
+
* recording is worse — so starting one stops every other player AND the read-aloud engine. */
|
|
31
|
+
export function stopOtherAudio(current) {
|
|
32
|
+
document.querySelectorAll('audio.inbox-audio').forEach((el) => {
|
|
33
|
+
const player = /** @type {HTMLAudioElement} */ (el);
|
|
34
|
+
if (player !== current && !player.paused) player.pause();
|
|
35
|
+
});
|
|
36
|
+
stopSpeech();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The text of a voice message, and the button that produces it.
|
|
41
|
+
*
|
|
42
|
+
* Three states, none of them hidden: a transcript that came WITH the message (the sender wrote it,
|
|
43
|
+
* so reading it costs nothing), a transcript this reader produced, or an offer to produce one. With
|
|
44
|
+
* no transcription model configured the row says so and points at the settings instead of quietly
|
|
45
|
+
* rendering nothing — an empty space reads as broken.
|
|
46
|
+
*/
|
|
47
|
+
export function TranscriptPanel({ att, msgId, onTranscribe, canTranscribe }) {
|
|
48
|
+
const [busy, setBusy] = useState(false);
|
|
49
|
+
const [error, setError] = useState(null);
|
|
50
|
+
const [open, setOpen] = useState(false);
|
|
51
|
+
const tr = att.transcript;
|
|
52
|
+
|
|
53
|
+
async function run() {
|
|
54
|
+
setBusy(true); setError(null);
|
|
55
|
+
try {
|
|
56
|
+
await onTranscribe(msgId, att.id);
|
|
57
|
+
setOpen(true);
|
|
58
|
+
} catch (e) {
|
|
59
|
+
// Shown in place, not as a toast: "no model configured" and "daily budget spent" are both
|
|
60
|
+
// things the reader can act on, and they belong next to the button that produced them.
|
|
61
|
+
setError(e?.message || t('inbox.transcribeFailed'));
|
|
62
|
+
}
|
|
63
|
+
setBusy(false);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (tr?.text) {
|
|
67
|
+
return html`
|
|
68
|
+
<div class="inbox-transcript">
|
|
69
|
+
<button class="inbox-transcript-toggle" onClick=${() => setOpen((v) => !v)} aria-expanded=${open}>
|
|
70
|
+
${open ? '▾' : '▸'} ${t('inbox.transcript')}
|
|
71
|
+
<span class="inbox-transcript-src">
|
|
72
|
+
${tr.by === 'sender' ? t('inbox.transcriptBySender') : t('inbox.transcriptByYou', { model: tr.model || '' })}
|
|
73
|
+
</span>
|
|
74
|
+
</button>
|
|
75
|
+
${open ? html`<div class="inbox-transcript-text">${escHtml(tr.text)}</div>` : null}
|
|
76
|
+
</div>`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (!onTranscribe) return null;
|
|
80
|
+
|
|
81
|
+
return html`
|
|
82
|
+
<div class="inbox-transcript">
|
|
83
|
+
${canTranscribe === false
|
|
84
|
+
? html`<span class="inbox-transcript-hint">${t('inbox.transcribeNoModel')}</span>`
|
|
85
|
+
: html`
|
|
86
|
+
<button class="inbox-transcript-btn" onClick=${run} disabled=${busy}>
|
|
87
|
+
${busy ? t('inbox.transcribing') : t('inbox.transcribe')}
|
|
88
|
+
</button>`}
|
|
89
|
+
${error ? html`<span class="inbox-transcript-err">${error}</span>` : null}
|
|
90
|
+
</div>`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* A voice message in a bubble: it plays here rather than opening in a browser tab, which is a
|
|
95
|
+
* download and not a conversation.
|
|
96
|
+
*
|
|
97
|
+
* preload="metadata" is deliberate — a thread with ten voice messages must not fetch all ten on
|
|
98
|
+
* open. The duration is all that is needed until someone presses play.
|
|
99
|
+
*/
|
|
100
|
+
export function AudioAttachment({ a, url, name, download, msgId, onTranscribe, canTranscribe }) {
|
|
101
|
+
return html`<div class="inbox-attach-audio">
|
|
102
|
+
<audio class="inbox-audio" controls preload="metadata" src=${url}
|
|
103
|
+
onPlay=${(e) => stopOtherAudio(e.currentTarget)}></audio>
|
|
104
|
+
<div class="inbox-attach-cap">
|
|
105
|
+
<span class="inbox-attach-name">${escHtml(name)}</span>
|
|
106
|
+
${a.durationSeconds ? html`<span class="inbox-audio-dur">${fmtClock(a.durationSeconds)}</span>` : null}
|
|
107
|
+
${download}
|
|
108
|
+
</div>
|
|
109
|
+
<${TranscriptPanel} att=${a} msgId=${msgId} onTranscribe=${onTranscribe} canTranscribe=${canTranscribe} />
|
|
110
|
+
</div>`;
|
|
111
|
+
}
|
|
@@ -14,6 +14,11 @@
|
|
|
14
14
|
* (./inbox-tab/use-thread-ux.js)
|
|
15
15
|
* @usage Lazy-loaded profile tab; registered in profile.js TABS as id `messages`.
|
|
16
16
|
* @version-history
|
|
17
|
+
* v1.27.0 -- 2026-08-01 -- Voice messages: 🎤 in the composer records a clip that travels the normal
|
|
18
|
+
* attachment path, an audio bubble plays in place, and a voice attachment can be turned into text
|
|
19
|
+
* with the owner's own key (useVoiceMessages → POST /v1/messages/:id/attachments/:attId/transcribe).
|
|
20
|
+
* Never automatic — transcription spends the owner's AI budget, so it is always a click. parkMessage
|
|
21
|
+
* and openTrackedRecord moved to helpers.js to stay under max-file-lines.
|
|
17
22
|
* v1.26.0 -- 2026-07-27 -- Failed sends toast the thrown REASON (helpers.sendFailure); the flat generic line hid every actionable attachment error.
|
|
18
23
|
* v1.25.0 -- 2026-07-21 -- Thread history is no longer capped at the newest 50: threads open showing
|
|
19
24
|
* their FULL history by default (getConversation(all)); a header "Show last 50 / all" toggle
|
|
@@ -130,17 +135,16 @@ import { escHtml } from '/js/utils.js';
|
|
|
130
135
|
import * as messages from '/js/services/messages.js';
|
|
131
136
|
import * as tracked from '/js/services/tracked-responses.js';
|
|
132
137
|
import * as agentsSvc from '/js/services/agents.js';
|
|
133
|
-
import { parkMessageToNotebook } from '/js/services/notebook.js';
|
|
134
|
-
import { firstLine } from './notebook-helpers.js';
|
|
135
138
|
import { apiGet } from '/js/api.js';
|
|
136
139
|
import { getSession } from '/js/services/auth.js';
|
|
137
140
|
import { TrackResponseModal } from './track-response-modal.js';
|
|
138
141
|
import { peerLabel } from '/js/services/messages-ai-prompts.js';
|
|
139
|
-
import { peerName, ownerKeyOf, isAgentPeer, buildAnswerSummary, resolveThreadAttachmentUrls, sendFailure } from './inbox-tab/helpers.js';
|
|
142
|
+
import { peerName, ownerKeyOf, isAgentPeer, buildAnswerSummary, resolveThreadAttachmentUrls, sendFailure, parkMessage, openTrackedRecord } from './inbox-tab/helpers.js';
|
|
140
143
|
import { Composer, PollBuilder, MarkdownViewer, ReplyWithAiPopover, ConversationToNotebookPopover } from './inbox-tab/components.js';
|
|
141
144
|
import { buildConversationReplyProps, buildMessageReplyProps, buildConversationNotebookProps } from './inbox-tab/ai-actions.js';
|
|
142
145
|
import { ListPanel, ThreadPanel, TrackedPanel, ResultsPanel } from './inbox-tab/panels.js';
|
|
143
146
|
import { useThreadAutoScroll, useMobileComposerKeyboard, useLinkPreviewToggle } from './inbox-tab/use-thread-ux.js';
|
|
147
|
+
import { useVoiceMessages } from './inbox-tab/use-voice.js';
|
|
144
148
|
import { ContactPicker } from '/components/ContactPicker.js';
|
|
145
149
|
import { swallowed } from '/js/swallowed.js';
|
|
146
150
|
|
|
@@ -148,6 +152,9 @@ export default function InboxTab({ showToast }) {
|
|
|
148
152
|
const [requests, setRequests] = useState([]);
|
|
149
153
|
const [conversations, setConversations] = useState([]);
|
|
150
154
|
const [activeConv, setActiveConv] = useState(null); // { conversationId, peerGhii }
|
|
155
|
+
// Mirrored into a ref so []-memoized callbacks (transcribeVoice) can reload the OPEN thread
|
|
156
|
+
// without taking activeConv as a dependency and being rebuilt on every thread switch.
|
|
157
|
+
const activeConvRef = useRef(null); activeConvRef.current = activeConv;
|
|
151
158
|
const [thread, setThread] = useState([]);
|
|
152
159
|
const [urlMap, setUrlMap] = useState({});
|
|
153
160
|
const { showLinkPreviews, toggleLinkPreviews } = useLinkPreviewToggle(); const [mdViewer, setMdViewer] = useState(null); // link-preview toggle (persisted) + markdown viewer state
|
|
@@ -266,12 +273,7 @@ export default function InboxTab({ showToast }) {
|
|
|
266
273
|
|
|
267
274
|
// Clicking 📓: copy the message straight into the notebook for later processing (no AI step) — keeps the
|
|
268
275
|
// source link + reply intent so it can be replied to or enriched/filed from the notebook later.
|
|
269
|
-
const onParkMsg =
|
|
270
|
-
try {
|
|
271
|
-
await parkMessageToNotebook(msg, { title: firstLine(msg.body) });
|
|
272
|
-
showToast?.(t('inbox.parkedToNotebook'));
|
|
273
|
-
} catch (e) { showToast?.(e?.message || t('inbox.trackFailed'), true); }
|
|
274
|
-
};
|
|
276
|
+
const onParkMsg = (msg) => parkMessage(msg, showToast);
|
|
275
277
|
|
|
276
278
|
const cancelTracked = async (tr) => {
|
|
277
279
|
let resp;
|
|
@@ -299,20 +301,8 @@ export default function InboxTab({ showToast }) {
|
|
|
299
301
|
setReplyingTrId(tr.id);
|
|
300
302
|
};
|
|
301
303
|
|
|
302
|
-
//
|
|
303
|
-
|
|
304
|
-
// (so the Organisms tab opens that exact workspace), then hard-navigate.
|
|
305
|
-
const openRecord = (tr) => {
|
|
306
|
-
const r = tr.references || {};
|
|
307
|
-
if (!r.organismId || !r.workspaceId) { showToast?.(t('inbox.trackNoRecord'), true); return; }
|
|
308
|
-
try {
|
|
309
|
-
sessionStorage.setItem('aimeat-profile-tab', 'organisms');
|
|
310
|
-
sessionStorage.setItem('aimeat.ws.openId', r.organismId);
|
|
311
|
-
sessionStorage.setItem('aimeat.ws.openWs', r.workspaceId);
|
|
312
|
-
// eslint-disable-next-line aimeat/no-silent-catch -- noop
|
|
313
|
-
} catch { /* noop */ }
|
|
314
|
-
window.location.assign('/v1/profile?tab=organisms');
|
|
315
|
-
};
|
|
304
|
+
// Jump to the workspace record this tracked response watches (straight to the bug it is about).
|
|
305
|
+
const openRecord = (tr) => openTrackedRecord(tr, showToast);
|
|
316
306
|
|
|
317
307
|
// Fetch the suggested-reply body for each awaiting contract in the open conversation (for the bubble).
|
|
318
308
|
const awaitingIds = awaitingForConv.map(t => t.id).join(',');
|
|
@@ -356,6 +346,9 @@ export default function InboxTab({ showToast }) {
|
|
|
356
346
|
}, []);
|
|
357
347
|
const toggleThreadAll = () => setThreadAll(v => { const nv = !v; threadAllRef.current = nv; if (activeConv) loadThread(activeConv); return nv; });
|
|
358
348
|
|
|
349
|
+
// Voice messages: can this owner transcribe, how long may a recording run, and the call that does it.
|
|
350
|
+
const { canTranscribe, voiceMaxSeconds, transcribeVoice } = useVoiceMessages(loadThread, activeConvRef);
|
|
351
|
+
|
|
359
352
|
// Mount: one composite call seeds all six sections (requests/conversations/important/tracked/agents/groups).
|
|
360
353
|
useEffect(() => { loadOverview(); }, [loadOverview]);
|
|
361
354
|
|
|
@@ -774,7 +767,8 @@ export default function InboxTab({ showToast }) {
|
|
|
774
767
|
onTrackMsg=${onTrackMsg} onParkMsg=${onParkMsg} openMessageAi=${openMessageAi} submitInteractiveAnswers=${submitInteractiveAnswers}
|
|
775
768
|
setMdViewer=${setMdViewer} openConversationAi=${openConversationAi} openConversationNotebook=${openConversationNotebook} insertCommand=${insertCommand} setCmdFill=${setCmdFill}
|
|
776
769
|
cancelTracked=${cancelTracked} openRecord=${openRecord} startSuggestedReply=${startSuggestedReply} doSend=${doSend} showLinkPreviews=${showLinkPreviews} toggleLinkPreviews=${toggleLinkPreviews}
|
|
777
|
-
threadAll=${threadAll} toggleThreadAll=${toggleThreadAll}
|
|
770
|
+
threadAll=${threadAll} toggleThreadAll=${toggleThreadAll}
|
|
771
|
+
onTranscribe=${transcribeVoice} canTranscribe=${canTranscribe} voiceMaxSeconds=${voiceMaxSeconds} />` : null}
|
|
778
772
|
|
|
779
773
|
${mode === 'tracked' ? html`<${TrackedPanel} activeTracked=${activeTracked} doneCount=${doneCount}
|
|
780
774
|
openRecord=${openRecord} openTracked=${openTracked} cancelTracked=${cancelTracked} />` : null}
|
|
@@ -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
|
+
}
|