@bitbaum/chatkit 0.2.1
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/LICENSE +21 -0
- package/README.md +132 -0
- package/dist/attachments.d.ts +72 -0
- package/dist/attachments.d.ts.map +1 -0
- package/dist/attachments.js +64 -0
- package/dist/attachments.js.map +1 -0
- package/dist/composer.d.ts +34 -0
- package/dist/composer.d.ts.map +1 -0
- package/dist/composer.js +41 -0
- package/dist/composer.js.map +1 -0
- package/dist/dictation.d.ts +60 -0
- package/dist/dictation.d.ts.map +1 -0
- package/dist/dictation.js +93 -0
- package/dist/dictation.js.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -0
- package/dist/markdown.d.ts +64 -0
- package/dist/markdown.d.ts.map +1 -0
- package/dist/markdown.js +129 -0
- package/dist/markdown.js.map +1 -0
- package/dist/react/Composer.d.ts +85 -0
- package/dist/react/Composer.d.ts.map +1 -0
- package/dist/react/Composer.js +132 -0
- package/dist/react/Composer.js.map +1 -0
- package/dist/react/Markdown.d.ts +27 -0
- package/dist/react/Markdown.d.ts.map +1 -0
- package/dist/react/Markdown.js +62 -0
- package/dist/react/Markdown.js.map +1 -0
- package/dist/react/Thread.d.ts +90 -0
- package/dist/react/Thread.d.ts.map +1 -0
- package/dist/react/Thread.js +64 -0
- package/dist/react/Thread.js.map +1 -0
- package/dist/react/hooks.d.ts +40 -0
- package/dist/react/hooks.d.ts.map +1 -0
- package/dist/react/hooks.js +99 -0
- package/dist/react/hooks.js.map +1 -0
- package/dist/react/icons.d.ts +12 -0
- package/dist/react/icons.d.ts.map +1 -0
- package/dist/react/icons.js +21 -0
- package/dist/react/icons.js.map +1 -0
- package/dist/react/index.d.ts +21 -0
- package/dist/react/index.d.ts.map +1 -0
- package/dist/react/index.js +16 -0
- package/dist/react/index.js.map +1 -0
- package/dist/react/use-attachments.d.ts +26 -0
- package/dist/react/use-attachments.d.ts.map +1 -0
- package/dist/react/use-attachments.js +202 -0
- package/dist/react/use-attachments.js.map +1 -0
- package/dist/react/use-dictation.d.ts +49 -0
- package/dist/react/use-dictation.d.ts.map +1 -0
- package/dist/react/use-dictation.js +365 -0
- package/dist/react/use-dictation.js.map +1 -0
- package/dist/scroll.d.ts +13 -0
- package/dist/scroll.d.ts.map +1 -0
- package/dist/scroll.js +11 -0
- package/dist/scroll.js.map +1 -0
- package/package.json +83 -0
- package/styles.css +689 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Cato
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# chatkit
|
|
2
|
+
|
|
3
|
+
**One chat for every bitbaum product.** A composer with a microphone that never
|
|
4
|
+
goes dead, a thread that does not fight the reader, answers with Copy and Retry,
|
|
5
|
+
and LLM markdown — the fleet's chat standard as a package, so a fix made once
|
|
6
|
+
reaches every product.
|
|
7
|
+
|
|
8
|
+
Why it exists: on 2026-09-25 there were 13 chats in 10 repos, each written from
|
|
9
|
+
scratch, each missing something another had already fixed — most often the
|
|
10
|
+
microphone. The person using them found every gap by hand. This package is the
|
|
11
|
+
one place those fixes now live. (Model calls, streaming and transcription stay
|
|
12
|
+
in [`ai-kit`](https://github.com/bitbaum/ai-kit), server-side; chatkit is the
|
|
13
|
+
interface that calls them.)
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
pnpm add github:bitbaum/chatkit#v0.2.0
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
`dist/` is committed, so the install needs no build step and no `allowBuilds`
|
|
22
|
+
entry. (npm publishing is set up once the package has its first release there.)
|
|
23
|
+
|
|
24
|
+
## Use
|
|
25
|
+
|
|
26
|
+
```tsx
|
|
27
|
+
import { ChatStarters, ChatThread, Composer } from "@bitbaum/chatkit/react";
|
|
28
|
+
import "@bitbaum/chatkit/styles.css";
|
|
29
|
+
|
|
30
|
+
<ChatThread
|
|
31
|
+
messages={messages} // { id, role, content, speaker?, citations?, failed? }
|
|
32
|
+
live={streaming ? { text: partial } : null}
|
|
33
|
+
onStop={stop}
|
|
34
|
+
onRetry={retryLast}
|
|
35
|
+
empty={<ChatStarters starters={["What can you build for me?"]} onPick={send} />}
|
|
36
|
+
/>
|
|
37
|
+
<Composer
|
|
38
|
+
onSend={(text, attachments) => send(text, attachments)} // return false to keep the draft
|
|
39
|
+
placeholder="Ask about any project…"
|
|
40
|
+
sending={streaming}
|
|
41
|
+
onStop={stop}
|
|
42
|
+
voice={{ transcribeUrl: "/api/transcribe" }}
|
|
43
|
+
attach // only if your API accepts attachments
|
|
44
|
+
tools={<ModelPicker />} // app-specific controls go in slots
|
|
45
|
+
/>
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
### The microphone
|
|
49
|
+
|
|
50
|
+
`voice` is on by default. Give it a server leg so it works everywhere:
|
|
51
|
+
|
|
52
|
+
- `transcribeUrl`: an endpoint taking `multipart/form-data` `{ audio, locale }`
|
|
53
|
+
and answering `{ text }` — implement it with `ai-kit`'s `transcribe()`.
|
|
54
|
+
- or `transcribe: (audio, locale) => Promise<string>` for a custom call.
|
|
55
|
+
|
|
56
|
+
The browser's own recogniser is tried first (free, instant). Where it is
|
|
57
|
+
missing, **or accepts `start()` and then says nothing** — Chromium without
|
|
58
|
+
Google's speech service does exactly that — the same press records and the
|
|
59
|
+
server transcribes. The dead recogniser is remembered for 30 days so the next
|
|
60
|
+
press records immediately. Every failure is shown in words (blocked mic,
|
|
61
|
+
nothing heard, not available), never as a button that does nothing.
|
|
62
|
+
`prefer: "server"` skips the browser leg (one model everywhere, language
|
|
63
|
+
detected by the model). `rememberKey: null` stores nothing on the device.
|
|
64
|
+
|
|
65
|
+
### Screenshots
|
|
66
|
+
|
|
67
|
+
`attach` takes limits: `attach={{ maxImageEdge: 1024 }}` shrinks a picture in
|
|
68
|
+
the browser to 1024px on its longest edge and re-encodes it as JPEG before it
|
|
69
|
+
is staged, stepping quality down until it fits `maxImageBytes`. A phone
|
|
70
|
+
screenshot (~1290×2796, several MB) goes out at a few hundred KB: cheaper for
|
|
71
|
+
whoever pays for the vision model, and the full-resolution screenshot never
|
|
72
|
+
leaves the device. Off by default (`0`).
|
|
73
|
+
|
|
74
|
+
Every reason a file did not attach is a label — `labels={{ attachNotes: {
|
|
75
|
+
imageTooLarge: (name, mb) => … } }}` — so it reaches the reader in their
|
|
76
|
+
language.
|
|
77
|
+
|
|
78
|
+
### Look
|
|
79
|
+
|
|
80
|
+
Every colour, radius and face is a `--ck-*` variable. They default to
|
|
81
|
+
[`@bitbaum/design-tokens`](https://github.com/bitbaum/design-tokens), so
|
|
82
|
+
OrangeCat, Loki, Solon and bitbaum match with no work. Any other app sets
|
|
83
|
+
`--ck-*` on the chat's container and keeps its own look. What you cannot
|
|
84
|
+
change is the behaviour — that is the point.
|
|
85
|
+
|
|
86
|
+
### Language
|
|
87
|
+
|
|
88
|
+
Every visible word is a label: `labels={{ send: "Senden", dictation: {...} }}`
|
|
89
|
+
on `Composer`, `labels` on `ChatThread`.
|
|
90
|
+
|
|
91
|
+
## The standard (what this package guarantees)
|
|
92
|
+
|
|
93
|
+
1. A microphone in the composer, with the server fallback above.
|
|
94
|
+
2. 16px text in the input and the messages (below that iOS zooms the page).
|
|
95
|
+
3. A composer that belongs to the conversation: auto-growing textarea, Enter
|
|
96
|
+
sends, Shift+Enter breaks the line, IME composition respected, 44px targets,
|
|
97
|
+
usable above the phone keyboard (`useViewportHeight`).
|
|
98
|
+
4. Stop in the send slot while a turn is in flight.
|
|
99
|
+
5. A failed turn shows as a failure with Retry — never silence.
|
|
100
|
+
6. Scroll follows new tokens only while the reader is at the bottom, with a
|
|
101
|
+
jump-down button otherwise.
|
|
102
|
+
7. Markdown rendered, never as HTML; `javascript:` links stay text; Copy on
|
|
103
|
+
every answer.
|
|
104
|
+
8. Starters in the empty state; the speaker's name on every answer once more
|
|
105
|
+
than one agent speaks.
|
|
106
|
+
9. Nothing overflows at 320–390px.
|
|
107
|
+
|
|
108
|
+
`pnpm run verify` proves all of it: unit and render tests, then
|
|
109
|
+
`test/browser.mjs` drives Chromium through the demo at desktop and 390px, light
|
|
110
|
+
and dark, and speaks into a fake microphone past a silent recogniser.
|
|
111
|
+
|
|
112
|
+
## Improving it — here, never in your app
|
|
113
|
+
|
|
114
|
+
When a chat in any product has a bug or is missing something, **fix it in this
|
|
115
|
+
repo**, add the check that would have caught it, bump the version, and let the
|
|
116
|
+
products pick the release up. A local patch in one app is how the fleet got 13
|
|
117
|
+
different chats. If your app needs something truly its own, it goes in a slot
|
|
118
|
+
(`tools`, `header`, `footer`, `renderFooter`, `renderLink`) — ask for a new slot
|
|
119
|
+
here rather than forking the component.
|
|
120
|
+
|
|
121
|
+
Release: bump `version` in `package.json`, merge, then
|
|
122
|
+
`git tag v<version> origin/main && git push origin v<version>`.
|
|
123
|
+
|
|
124
|
+
## Develop
|
|
125
|
+
|
|
126
|
+
```sh
|
|
127
|
+
pnpm install
|
|
128
|
+
pnpm run demo # builds demo/dist; open demo/index.html
|
|
129
|
+
pnpm run verify # everything CI runs
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
MIT © Cato
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a composer can attach, and the wire shape an app's API receives.
|
|
3
|
+
*
|
|
4
|
+
* From loki `lib/loki/attachments.ts`. The limits are defaults, not law — an
|
|
5
|
+
* app passes its own to `useAttachments` when its model or API differs.
|
|
6
|
+
*/
|
|
7
|
+
export type TextAttachment = {
|
|
8
|
+
kind: "text";
|
|
9
|
+
name: string;
|
|
10
|
+
content: string;
|
|
11
|
+
};
|
|
12
|
+
export type ImageAttachment = {
|
|
13
|
+
kind: "image";
|
|
14
|
+
name: string;
|
|
15
|
+
mimeType: string;
|
|
16
|
+
dataBase64: string;
|
|
17
|
+
};
|
|
18
|
+
export type Attachment = TextAttachment | ImageAttachment;
|
|
19
|
+
/** Client-only preview URL — never sent to the API. */
|
|
20
|
+
export type StagedAttachment = Attachment & {
|
|
21
|
+
previewUrl?: string;
|
|
22
|
+
};
|
|
23
|
+
export type AttachmentLimits = {
|
|
24
|
+
/** Files per message. */
|
|
25
|
+
maxFiles: number;
|
|
26
|
+
/** Characters per text file (it is inlined into a prompt). */
|
|
27
|
+
maxTextChars: number;
|
|
28
|
+
/** Raw image bytes before base64 — checked AFTER any downscale. */
|
|
29
|
+
maxImageBytes: number;
|
|
30
|
+
/**
|
|
31
|
+
* Longest edge, in pixels, an image is shrunk to in the browser before it is
|
|
32
|
+
* staged. 0 = send the original. A phone screenshot is ~1290×2796 and several
|
|
33
|
+
* megabytes; vision models bill by tile and read a chat bubble just as well
|
|
34
|
+
* at 1024px, so shrinking first is cheaper for the reader AND means the full
|
|
35
|
+
* screenshot never leaves the device. Shrunk images are re-encoded as JPEG
|
|
36
|
+
* (a screenshot re-encoded as PNG is often larger), stepping quality down
|
|
37
|
+
* until they fit `maxImageBytes`. From heidi `downscale.ts`.
|
|
38
|
+
*/
|
|
39
|
+
maxImageEdge: number;
|
|
40
|
+
};
|
|
41
|
+
export declare const DEFAULT_ATTACHMENT_LIMITS: AttachmentLimits;
|
|
42
|
+
/** Why a file did not attach — every one a label, so an app can say it in its
|
|
43
|
+
* reader's language. */
|
|
44
|
+
export type AttachmentNotes = {
|
|
45
|
+
wrongType: (name: string) => string;
|
|
46
|
+
imageTooLarge: (name: string, maxMb: number) => string;
|
|
47
|
+
textTooLarge: (name: string, maxThousandChars: number) => string;
|
|
48
|
+
unreadable: (name: string) => string;
|
|
49
|
+
tooMany: (max: number) => string;
|
|
50
|
+
};
|
|
51
|
+
export declare const DEFAULT_ATTACHMENT_NOTES: AttachmentNotes;
|
|
52
|
+
/**
|
|
53
|
+
* The size an image is drawn at to fit `maxEdge` on its longest side. Never
|
|
54
|
+
* enlarges; 0 (or less) means "leave it". Pure, so it is tested without a DOM.
|
|
55
|
+
*/
|
|
56
|
+
export declare function fitWithin(width: number, height: number, maxEdge: number): {
|
|
57
|
+
width: number;
|
|
58
|
+
height: number;
|
|
59
|
+
scaled: boolean;
|
|
60
|
+
};
|
|
61
|
+
/** Bytes a base64 data URL decodes to — close enough to compare to a limit. */
|
|
62
|
+
export declare function dataUrlBytes(dataUrl: string): number;
|
|
63
|
+
export declare function isImageMime(mime: string): boolean;
|
|
64
|
+
/** `data:image/png;base64,AAAA` → `AAAA`: FileReader hands back a data URL,
|
|
65
|
+
* the wire carries raw base64. */
|
|
66
|
+
export declare function stripDataUrlBase64(dataUrl: string): string;
|
|
67
|
+
/** Identity for dedupe and removal: the same name and bytes are the same
|
|
68
|
+
* attachment however they arrived (picker, paste, drop). */
|
|
69
|
+
export declare function attachmentKey(a: Attachment): string;
|
|
70
|
+
/** Strip client-only fields: what the API validates. */
|
|
71
|
+
export declare function toWire(staged: StagedAttachment[]): Attachment[];
|
|
72
|
+
//# sourceMappingURL=attachments.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"attachments.d.ts","sourceRoot":"","sources":["../src/attachments.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,MAAM,MAAM,cAAc,GAAG;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,CAAC;AAC7E,MAAM,MAAM,eAAe,GAAG;IAC5B,IAAI,EAAE,OAAO,CAAC;IACd,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,MAAM,CAAC;CACpB,CAAC;AACF,MAAM,MAAM,UAAU,GAAG,cAAc,GAAG,eAAe,CAAC;AAE1D,uDAAuD;AACvD,MAAM,MAAM,gBAAgB,GAAG,UAAU,GAAG;IAAE,UAAU,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC;AAEpE,MAAM,MAAM,gBAAgB,GAAG;IAC7B,yBAAyB;IACzB,QAAQ,EAAE,MAAM,CAAC;IACjB,8DAA8D;IAC9D,YAAY,EAAE,MAAM,CAAC;IACrB,mEAAmE;IACnE,aAAa,EAAE,MAAM,CAAC;IACtB;;;;;;;;OAQG;IACH,YAAY,EAAE,MAAM,CAAC;CACtB,CAAC;AAEF,eAAO,MAAM,yBAAyB,EAAE,gBAKvC,CAAC;AAEF;yBACyB;AACzB,MAAM,MAAM,eAAe,GAAG;IAC5B,SAAS,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM,CAAC;IACpC,aAAa,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,KAAK,MAAM,CAAC;IACvD,YAAY,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,gBAAgB,EAAE,MAAM,KAAK,MAAM,CAAC;IACjE,UAAU,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM,CAAC;IACrC,OAAO,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,MAAM,CAAC;CAClC,CAAC;AAEF,eAAO,MAAM,wBAAwB,EAAE,eAMtC,CAAC;AAEF;;;GAGG;AACH,wBAAgB,SAAS,CACvB,KAAK,EAAE,MAAM,EACb,MAAM,EAAE,MAAM,EACd,OAAO,EAAE,MAAM,GACd;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,OAAO,CAAA;CAAE,CASpD;AAED,+EAA+E;AAC/E,wBAAgB,YAAY,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAEpD;AAID,wBAAgB,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAEjD;AAED;mCACmC;AACnC,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAG1D;AAED;6DAC6D;AAC7D,wBAAgB,aAAa,CAAC,CAAC,EAAE,UAAU,GAAG,MAAM,CAInD;AAED,wDAAwD;AACxD,wBAAgB,MAAM,CAAC,MAAM,EAAE,gBAAgB,EAAE,GAAG,UAAU,EAAE,CAM/D"}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a composer can attach, and the wire shape an app's API receives.
|
|
3
|
+
*
|
|
4
|
+
* From loki `lib/loki/attachments.ts`. The limits are defaults, not law — an
|
|
5
|
+
* app passes its own to `useAttachments` when its model or API differs.
|
|
6
|
+
*/
|
|
7
|
+
export const DEFAULT_ATTACHMENT_LIMITS = {
|
|
8
|
+
maxFiles: 5,
|
|
9
|
+
maxTextChars: 100_000,
|
|
10
|
+
maxImageBytes: 2_800_000,
|
|
11
|
+
maxImageEdge: 0,
|
|
12
|
+
};
|
|
13
|
+
export const DEFAULT_ATTACHMENT_NOTES = {
|
|
14
|
+
wrongType: (name) => `${name}: use PNG, JPEG, GIF or WebP.`,
|
|
15
|
+
imageTooLarge: (name, maxMb) => `${name} is too large (max ${maxMb} MB).`,
|
|
16
|
+
textTooLarge: (name, k) => `${name} is too large (max ${k}k characters).`,
|
|
17
|
+
unreadable: (name) => `Could not read ${name}.`,
|
|
18
|
+
tooMany: (max) => `Up to ${max} files.`,
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* The size an image is drawn at to fit `maxEdge` on its longest side. Never
|
|
22
|
+
* enlarges; 0 (or less) means "leave it". Pure, so it is tested without a DOM.
|
|
23
|
+
*/
|
|
24
|
+
export function fitWithin(width, height, maxEdge) {
|
|
25
|
+
const longest = Math.max(width, height);
|
|
26
|
+
if (maxEdge <= 0 || longest <= maxEdge)
|
|
27
|
+
return { width, height, scaled: false };
|
|
28
|
+
const scale = maxEdge / longest;
|
|
29
|
+
return {
|
|
30
|
+
width: Math.max(1, Math.round(width * scale)),
|
|
31
|
+
height: Math.max(1, Math.round(height * scale)),
|
|
32
|
+
scaled: true,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
/** Bytes a base64 data URL decodes to — close enough to compare to a limit. */
|
|
36
|
+
export function dataUrlBytes(dataUrl) {
|
|
37
|
+
return Math.floor(stripDataUrlBase64(dataUrl).length * 0.75);
|
|
38
|
+
}
|
|
39
|
+
const IMAGE_MIME = /^image\/(jpeg|jpg|png|gif|webp)$/i;
|
|
40
|
+
export function isImageMime(mime) {
|
|
41
|
+
return IMAGE_MIME.test(mime);
|
|
42
|
+
}
|
|
43
|
+
/** `data:image/png;base64,AAAA` → `AAAA`: FileReader hands back a data URL,
|
|
44
|
+
* the wire carries raw base64. */
|
|
45
|
+
export function stripDataUrlBase64(dataUrl) {
|
|
46
|
+
const i = dataUrl.indexOf(",");
|
|
47
|
+
return i >= 0 ? dataUrl.slice(i + 1) : dataUrl;
|
|
48
|
+
}
|
|
49
|
+
/** Identity for dedupe and removal: the same name and bytes are the same
|
|
50
|
+
* attachment however they arrived (picker, paste, drop). */
|
|
51
|
+
export function attachmentKey(a) {
|
|
52
|
+
return a.kind === "image"
|
|
53
|
+
? `image:${a.name}:${a.dataBase64.length}`
|
|
54
|
+
: `text:${a.name}:${a.content.length}`;
|
|
55
|
+
}
|
|
56
|
+
/** Strip client-only fields: what the API validates. */
|
|
57
|
+
export function toWire(staged) {
|
|
58
|
+
return staged.map((a) => {
|
|
59
|
+
const { previewUrl: _preview, ...rest } = a;
|
|
60
|
+
void _preview;
|
|
61
|
+
return rest;
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
//# sourceMappingURL=attachments.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"attachments.js","sourceRoot":"","sources":["../src/attachments.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAiCH,MAAM,CAAC,MAAM,yBAAyB,GAAqB;IACzD,QAAQ,EAAE,CAAC;IACX,YAAY,EAAE,OAAO;IACrB,aAAa,EAAE,SAAS;IACxB,YAAY,EAAE,CAAC;CAChB,CAAC;AAYF,MAAM,CAAC,MAAM,wBAAwB,GAAoB;IACvD,SAAS,EAAE,CAAC,IAAI,EAAE,EAAE,CAAC,GAAG,IAAI,+BAA+B;IAC3D,aAAa,EAAE,CAAC,IAAI,EAAE,KAAK,EAAE,EAAE,CAAC,GAAG,IAAI,sBAAsB,KAAK,OAAO;IACzE,YAAY,EAAE,CAAC,IAAI,EAAE,CAAC,EAAE,EAAE,CAAC,GAAG,IAAI,sBAAsB,CAAC,gBAAgB;IACzE,UAAU,EAAE,CAAC,IAAI,EAAE,EAAE,CAAC,kBAAkB,IAAI,GAAG;IAC/C,OAAO,EAAE,CAAC,GAAG,EAAE,EAAE,CAAC,SAAS,GAAG,SAAS;CACxC,CAAC;AAEF;;;GAGG;AACH,MAAM,UAAU,SAAS,CACvB,KAAa,EACb,MAAc,EACd,OAAe;IAEf,MAAM,OAAO,GAAG,IAAI,CAAC,GAAG,CAAC,KAAK,EAAE,MAAM,CAAC,CAAC;IACxC,IAAI,OAAO,IAAI,CAAC,IAAI,OAAO,IAAI,OAAO;QAAE,OAAO,EAAE,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,CAAC;IAChF,MAAM,KAAK,GAAG,OAAO,GAAG,OAAO,CAAC;IAChC,OAAO;QACL,KAAK,EAAE,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,IAAI,CAAC,KAAK,CAAC,KAAK,GAAG,KAAK,CAAC,CAAC;QAC7C,MAAM,EAAE,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,IAAI,CAAC,KAAK,CAAC,MAAM,GAAG,KAAK,CAAC,CAAC;QAC/C,MAAM,EAAE,IAAI;KACb,CAAC;AACJ,CAAC;AAED,+EAA+E;AAC/E,MAAM,UAAU,YAAY,CAAC,OAAe;IAC1C,OAAO,IAAI,CAAC,KAAK,CAAC,kBAAkB,CAAC,OAAO,CAAC,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;AAC/D,CAAC;AAED,MAAM,UAAU,GAAG,mCAAmC,CAAC;AAEvD,MAAM,UAAU,WAAW,CAAC,IAAY;IACtC,OAAO,UAAU,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;AAC/B,CAAC;AAED;mCACmC;AACnC,MAAM,UAAU,kBAAkB,CAAC,OAAe;IAChD,MAAM,CAAC,GAAG,OAAO,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC;IAC/B,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC;AACjD,CAAC;AAED;6DAC6D;AAC7D,MAAM,UAAU,aAAa,CAAC,CAAa;IACzC,OAAO,CAAC,CAAC,IAAI,KAAK,OAAO;QACvB,CAAC,CAAC,SAAS,CAAC,CAAC,IAAI,IAAI,CAAC,CAAC,UAAU,CAAC,MAAM,EAAE;QAC1C,CAAC,CAAC,QAAQ,CAAC,CAAC,IAAI,IAAI,CAAC,CAAC,OAAO,CAAC,MAAM,EAAE,CAAC;AAC3C,CAAC;AAED,wDAAwD;AACxD,MAAM,UAAU,MAAM,CAAC,MAA0B;IAC/C,OAAO,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE;QACtB,MAAM,EAAE,UAAU,EAAE,QAAQ,EAAE,GAAG,IAAI,EAAE,GAAG,CAAC,CAAC;QAC5C,KAAK,QAAQ,CAAC;QACd,OAAO,IAAkB,CAAC;IAC5B,CAAC,CAAC,CAAC;AACL,CAAC"}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The composer's decisions, kept out of the component so they can be tested
|
|
3
|
+
* without a DOM — and so every app that uses the one composer gets exactly the
|
|
4
|
+
* same answer to "may this send?" and "does the draft survive?".
|
|
5
|
+
*
|
|
6
|
+
* From loki `src/components/composer/composer-logic.ts`, where these were
|
|
7
|
+
* written after four composers in one app gave four different answers.
|
|
8
|
+
*/
|
|
9
|
+
/** A destination the same words can go to (e.g. Ask vs Inject). */
|
|
10
|
+
export type ComposerMode = {
|
|
11
|
+
id: string;
|
|
12
|
+
label: string;
|
|
13
|
+
hint?: string;
|
|
14
|
+
};
|
|
15
|
+
/** `false` keeps the draft (the send failed or was refused); anything else
|
|
16
|
+
* clears it. A failed send must be retryable from the same box, not retyped. */
|
|
17
|
+
export type ComposerSendResult = void | boolean;
|
|
18
|
+
export declare function composerCanSend({ text, attachmentCount, attachmentOnlyText, sending, disabled, blocked, }: {
|
|
19
|
+
text: string;
|
|
20
|
+
attachmentCount: number;
|
|
21
|
+
attachmentOnlyText?: string;
|
|
22
|
+
sending: boolean;
|
|
23
|
+
disabled: boolean;
|
|
24
|
+
blocked: boolean;
|
|
25
|
+
}): boolean;
|
|
26
|
+
/** The words actually sent: the draft, or the app's stand-in for an
|
|
27
|
+
* attachments-only send. */
|
|
28
|
+
export declare function composerOutgoingText(text: string, attachmentCount: number, attachmentOnlyText?: string): string;
|
|
29
|
+
export declare function shouldClearDraft(result: ComposerSendResult): boolean;
|
|
30
|
+
/** Dictated words join the draft with one space, never glued to the last word. */
|
|
31
|
+
export declare function appendTranscript(draft: string, said: string): string;
|
|
32
|
+
/** `75` → `1:15`. The recording timer. */
|
|
33
|
+
export declare function formatElapsed(seconds: number): string;
|
|
34
|
+
//# sourceMappingURL=composer.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"composer.d.ts","sourceRoot":"","sources":["../src/composer.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,mEAAmE;AACnE,MAAM,MAAM,YAAY,GAAG;IAAE,EAAE,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC;AAExE;iFACiF;AACjF,MAAM,MAAM,kBAAkB,GAAG,IAAI,GAAG,OAAO,CAAC;AAEhD,wBAAgB,eAAe,CAAC,EAC9B,IAAI,EACJ,eAAe,EACf,kBAAkB,EAClB,OAAO,EACP,QAAQ,EACR,OAAO,GACR,EAAE;IACD,IAAI,EAAE,MAAM,CAAC;IACb,eAAe,EAAE,MAAM,CAAC;IACxB,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,OAAO,EAAE,OAAO,CAAC;IACjB,QAAQ,EAAE,OAAO,CAAC;IAClB,OAAO,EAAE,OAAO,CAAC;CAClB,GAAG,OAAO,CAMV;AAED;6BAC6B;AAC7B,wBAAgB,oBAAoB,CAClC,IAAI,EAAE,MAAM,EACZ,eAAe,EAAE,MAAM,EACvB,kBAAkB,CAAC,EAAE,MAAM,GAC1B,MAAM,CAIR;AAED,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,kBAAkB,GAAG,OAAO,CAEpE;AAED,kFAAkF;AAClF,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,CAIpE;AAED,0CAA0C;AAC1C,wBAAgB,aAAa,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAGrD"}
|
package/dist/composer.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The composer's decisions, kept out of the component so they can be tested
|
|
3
|
+
* without a DOM — and so every app that uses the one composer gets exactly the
|
|
4
|
+
* same answer to "may this send?" and "does the draft survive?".
|
|
5
|
+
*
|
|
6
|
+
* From loki `src/components/composer/composer-logic.ts`, where these were
|
|
7
|
+
* written after four composers in one app gave four different answers.
|
|
8
|
+
*/
|
|
9
|
+
export function composerCanSend({ text, attachmentCount, attachmentOnlyText, sending, disabled, blocked, }) {
|
|
10
|
+
if (sending || disabled || blocked)
|
|
11
|
+
return false;
|
|
12
|
+
if (text.trim().length > 0)
|
|
13
|
+
return true;
|
|
14
|
+
// A screenshot with no words is still a complete message — but only where
|
|
15
|
+
// the app has said what that message means.
|
|
16
|
+
return attachmentCount > 0 && Boolean(attachmentOnlyText);
|
|
17
|
+
}
|
|
18
|
+
/** The words actually sent: the draft, or the app's stand-in for an
|
|
19
|
+
* attachments-only send. */
|
|
20
|
+
export function composerOutgoingText(text, attachmentCount, attachmentOnlyText) {
|
|
21
|
+
const trimmed = text.trim();
|
|
22
|
+
if (trimmed)
|
|
23
|
+
return trimmed;
|
|
24
|
+
return attachmentCount > 0 && attachmentOnlyText ? attachmentOnlyText : "";
|
|
25
|
+
}
|
|
26
|
+
export function shouldClearDraft(result) {
|
|
27
|
+
return result !== false;
|
|
28
|
+
}
|
|
29
|
+
/** Dictated words join the draft with one space, never glued to the last word. */
|
|
30
|
+
export function appendTranscript(draft, said) {
|
|
31
|
+
const t = said.trim();
|
|
32
|
+
if (!t)
|
|
33
|
+
return draft;
|
|
34
|
+
return draft.trim() ? `${draft.replace(/\s+$/, "")} ${t}` : t;
|
|
35
|
+
}
|
|
36
|
+
/** `75` → `1:15`. The recording timer. */
|
|
37
|
+
export function formatElapsed(seconds) {
|
|
38
|
+
const s = Math.max(0, Math.floor(seconds));
|
|
39
|
+
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
|
|
40
|
+
}
|
|
41
|
+
//# sourceMappingURL=composer.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"composer.js","sourceRoot":"","sources":["../src/composer.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AASH,MAAM,UAAU,eAAe,CAAC,EAC9B,IAAI,EACJ,eAAe,EACf,kBAAkB,EAClB,OAAO,EACP,QAAQ,EACR,OAAO,GAQR;IACC,IAAI,OAAO,IAAI,QAAQ,IAAI,OAAO;QAAE,OAAO,KAAK,CAAC;IACjD,IAAI,IAAI,CAAC,IAAI,EAAE,CAAC,MAAM,GAAG,CAAC;QAAE,OAAO,IAAI,CAAC;IACxC,0EAA0E;IAC1E,4CAA4C;IAC5C,OAAO,eAAe,GAAG,CAAC,IAAI,OAAO,CAAC,kBAAkB,CAAC,CAAC;AAC5D,CAAC;AAED;6BAC6B;AAC7B,MAAM,UAAU,oBAAoB,CAClC,IAAY,EACZ,eAAuB,EACvB,kBAA2B;IAE3B,MAAM,OAAO,GAAG,IAAI,CAAC,IAAI,EAAE,CAAC;IAC5B,IAAI,OAAO;QAAE,OAAO,OAAO,CAAC;IAC5B,OAAO,eAAe,GAAG,CAAC,IAAI,kBAAkB,CAAC,CAAC,CAAC,kBAAkB,CAAC,CAAC,CAAC,EAAE,CAAC;AAC7E,CAAC;AAED,MAAM,UAAU,gBAAgB,CAAC,MAA0B;IACzD,OAAO,MAAM,KAAK,KAAK,CAAC;AAC1B,CAAC;AAED,kFAAkF;AAClF,MAAM,UAAU,gBAAgB,CAAC,KAAa,EAAE,IAAY;IAC1D,MAAM,CAAC,GAAG,IAAI,CAAC,IAAI,EAAE,CAAC;IACtB,IAAI,CAAC,CAAC;QAAE,OAAO,KAAK,CAAC;IACrB,OAAO,KAAK,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,GAAG,KAAK,CAAC,OAAO,CAAC,MAAM,EAAE,EAAE,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC;AAChE,CAAC;AAED,0CAA0C;AAC1C,MAAM,UAAU,aAAa,CAAC,OAAe;IAC3C,MAAM,CAAC,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC;IAC3C,OAAO,GAAG,IAAI,CAAC,KAAK,CAAC,CAAC,GAAG,EAAE,CAAC,IAAI,MAAM,CAAC,CAAC,GAAG,EAAE,CAAC,CAAC,QAAQ,CAAC,CAAC,EAAE,GAAG,CAAC,EAAE,CAAC;AACpE,CAAC"}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The decisions behind the microphone, without the microphone.
|
|
3
|
+
*
|
|
4
|
+
* The shape comes from heidi (`app/[locale]/_components/use-dictation.ts`),
|
|
5
|
+
* which is the only chat in the fleet whose mic never goes dead:
|
|
6
|
+
*
|
|
7
|
+
* - The browser's recogniser is FAST (free, instant, nothing uploaded) but
|
|
8
|
+
* sometimes a lie: Chromium builds without Google's speech service accept
|
|
9
|
+
* `start()` and then never fire an event — measured on heidi's live site
|
|
10
|
+
* 2026-09-12, nine seconds, zero events. So the recogniser is never the only
|
|
11
|
+
* path. Missing or silent, the same button RECORDS and a server transcribes.
|
|
12
|
+
* - Every failure becomes one of three words the person can act on — the mic
|
|
13
|
+
* (permission, hardware), silence (nothing heard), unavailable (nothing here
|
|
14
|
+
* can do it) — never a button that does nothing and says nothing.
|
|
15
|
+
*
|
|
16
|
+
* Kept pure so "never forever" and "which failures the fallback rescues" are
|
|
17
|
+
* tested properties rather than behaviour buried in a timeout.
|
|
18
|
+
*/
|
|
19
|
+
/** Why a dictation produced no text, in terms the person can act on. */
|
|
20
|
+
export type DictationProblem = "mic" | "silence" | "unavailable";
|
|
21
|
+
/** Which path to try first. `browser` is free and instant; `server` is one
|
|
22
|
+
* model everywhere (and detects the language by itself). Either falls back to
|
|
23
|
+
* the other when it cannot work. */
|
|
24
|
+
export type DictationPreference = "browser" | "server";
|
|
25
|
+
/**
|
|
26
|
+
* Which recogniser failures the fallback can rescue: only "unavailable" — the
|
|
27
|
+
* recogniser cannot do this (no speech service, network refused, language
|
|
28
|
+
* unsupported) and recording locally does not care. "mic" is the person's
|
|
29
|
+
* hardware or a permission they denied; "silence" is whether they spoke.
|
|
30
|
+
* Recording again would tell them the same thing twice.
|
|
31
|
+
*/
|
|
32
|
+
export declare function fallbackCanRescue(problem: DictationProblem | null): boolean;
|
|
33
|
+
/** Web Speech `error` codes → the three words. Anything unknown is
|
|
34
|
+
* `unavailable`, never nothing: a control that fails silently looks broken. */
|
|
35
|
+
export declare function problemFor(error: string | undefined): DictationProblem | null;
|
|
36
|
+
/** getUserMedia / MediaRecorder failures → the same three words, so the UI
|
|
37
|
+
* never grows a second vocabulary for the same situations. */
|
|
38
|
+
export declare function problemForRecording(error: unknown): DictationProblem;
|
|
39
|
+
/** A working recogniser fires `start` well inside this once the mic is allowed. */
|
|
40
|
+
export declare const START_TIMEOUT_MS = 4000;
|
|
41
|
+
/**
|
|
42
|
+
* How long a pending permission may hold off the fallback. The wait is right —
|
|
43
|
+
* someone reading a permission dialog has not failed — but it must be BOUNDED:
|
|
44
|
+
* `permissions.query` says "prompt" both while a dialog is open and when none
|
|
45
|
+
* will ever appear (heidi, 2026-09-12: twenty-two seconds, nothing).
|
|
46
|
+
*/
|
|
47
|
+
export declare const PERMISSION_WAIT_MS = 10000;
|
|
48
|
+
export declare function mayKeepWaitingForPermission(elapsedMs: number, permissionPending: boolean): boolean;
|
|
49
|
+
/** A browser whose recogniser proved dead is remembered for this long, so the
|
|
50
|
+
* person does not rediscover it on every press — and not forever, because a
|
|
51
|
+
* browser can gain the capability. */
|
|
52
|
+
export declare const DEAD_RECOGNISER_TTL_MS: number;
|
|
53
|
+
export declare function deadRecogniserStillTrusted(rememberedAt: number | null, now?: number): boolean;
|
|
54
|
+
/** Recording is capped so a forgotten mic does not run for an hour. */
|
|
55
|
+
export declare const MAX_RECORDING_MS = 120000;
|
|
56
|
+
/** Candidate containers for MediaRecorder, best first. */
|
|
57
|
+
export declare const RECORDING_MIME_CANDIDATES: readonly ["audio/webm;codecs=opus", "audio/webm", "audio/mp4", "audio/ogg"];
|
|
58
|
+
/** The default words for each problem. Override per app (and per language). */
|
|
59
|
+
export declare const DEFAULT_DICTATION_MESSAGES: Record<DictationProblem, string>;
|
|
60
|
+
//# sourceMappingURL=dictation.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"dictation.d.ts","sourceRoot":"","sources":["../src/dictation.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAEH,wEAAwE;AACxE,MAAM,MAAM,gBAAgB,GAAG,KAAK,GAAG,SAAS,GAAG,aAAa,CAAC;AAEjE;;qCAEqC;AACrC,MAAM,MAAM,mBAAmB,GAAG,SAAS,GAAG,QAAQ,CAAC;AAEvD;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,gBAAgB,GAAG,IAAI,GAAG,OAAO,CAE3E;AAED;gFACgF;AAChF,wBAAgB,UAAU,CAAC,KAAK,EAAE,MAAM,GAAG,SAAS,GAAG,gBAAgB,GAAG,IAAI,CAa7E;AAED;+DAC+D;AAC/D,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,OAAO,GAAG,gBAAgB,CAKpE;AAED,mFAAmF;AACnF,eAAO,MAAM,gBAAgB,OAAO,CAAC;AAErC;;;;;GAKG;AACH,eAAO,MAAM,kBAAkB,QAAS,CAAC;AAEzC,wBAAgB,2BAA2B,CACzC,SAAS,EAAE,MAAM,EACjB,iBAAiB,EAAE,OAAO,GACzB,OAAO,CAET;AAED;;uCAEuC;AACvC,eAAO,MAAM,sBAAsB,QAA2B,CAAC;AAE/D,wBAAgB,0BAA0B,CAAC,YAAY,EAAE,MAAM,GAAG,IAAI,EAAE,GAAG,SAAa,GAAG,OAAO,CAKjG;AAED,uEAAuE;AACvE,eAAO,MAAM,gBAAgB,SAAU,CAAC;AAExC,0DAA0D;AAC1D,eAAO,MAAM,yBAAyB,6EAK5B,CAAC;AAEX,+EAA+E;AAC/E,eAAO,MAAM,0BAA0B,EAAE,MAAM,CAAC,gBAAgB,EAAE,MAAM,CAIvE,CAAC"}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The decisions behind the microphone, without the microphone.
|
|
3
|
+
*
|
|
4
|
+
* The shape comes from heidi (`app/[locale]/_components/use-dictation.ts`),
|
|
5
|
+
* which is the only chat in the fleet whose mic never goes dead:
|
|
6
|
+
*
|
|
7
|
+
* - The browser's recogniser is FAST (free, instant, nothing uploaded) but
|
|
8
|
+
* sometimes a lie: Chromium builds without Google's speech service accept
|
|
9
|
+
* `start()` and then never fire an event — measured on heidi's live site
|
|
10
|
+
* 2026-09-12, nine seconds, zero events. So the recogniser is never the only
|
|
11
|
+
* path. Missing or silent, the same button RECORDS and a server transcribes.
|
|
12
|
+
* - Every failure becomes one of three words the person can act on — the mic
|
|
13
|
+
* (permission, hardware), silence (nothing heard), unavailable (nothing here
|
|
14
|
+
* can do it) — never a button that does nothing and says nothing.
|
|
15
|
+
*
|
|
16
|
+
* Kept pure so "never forever" and "which failures the fallback rescues" are
|
|
17
|
+
* tested properties rather than behaviour buried in a timeout.
|
|
18
|
+
*/
|
|
19
|
+
/**
|
|
20
|
+
* Which recogniser failures the fallback can rescue: only "unavailable" — the
|
|
21
|
+
* recogniser cannot do this (no speech service, network refused, language
|
|
22
|
+
* unsupported) and recording locally does not care. "mic" is the person's
|
|
23
|
+
* hardware or a permission they denied; "silence" is whether they spoke.
|
|
24
|
+
* Recording again would tell them the same thing twice.
|
|
25
|
+
*/
|
|
26
|
+
export function fallbackCanRescue(problem) {
|
|
27
|
+
return problem === "unavailable";
|
|
28
|
+
}
|
|
29
|
+
/** Web Speech `error` codes → the three words. Anything unknown is
|
|
30
|
+
* `unavailable`, never nothing: a control that fails silently looks broken. */
|
|
31
|
+
export function problemFor(error) {
|
|
32
|
+
switch (error) {
|
|
33
|
+
case "aborted":
|
|
34
|
+
return null;
|
|
35
|
+
case "not-allowed":
|
|
36
|
+
case "service-not-allowed":
|
|
37
|
+
case "audio-capture":
|
|
38
|
+
return "mic";
|
|
39
|
+
case "no-speech":
|
|
40
|
+
return "silence";
|
|
41
|
+
default:
|
|
42
|
+
return "unavailable";
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/** getUserMedia / MediaRecorder failures → the same three words, so the UI
|
|
46
|
+
* never grows a second vocabulary for the same situations. */
|
|
47
|
+
export function problemForRecording(error) {
|
|
48
|
+
const name = error?.name ?? "";
|
|
49
|
+
if (name === "NotAllowedError" || name === "SecurityError")
|
|
50
|
+
return "mic";
|
|
51
|
+
if (name === "NotFoundError" || name === "NotReadableError")
|
|
52
|
+
return "mic";
|
|
53
|
+
return "unavailable";
|
|
54
|
+
}
|
|
55
|
+
/** A working recogniser fires `start` well inside this once the mic is allowed. */
|
|
56
|
+
export const START_TIMEOUT_MS = 4000;
|
|
57
|
+
/**
|
|
58
|
+
* How long a pending permission may hold off the fallback. The wait is right —
|
|
59
|
+
* someone reading a permission dialog has not failed — but it must be BOUNDED:
|
|
60
|
+
* `permissions.query` says "prompt" both while a dialog is open and when none
|
|
61
|
+
* will ever appear (heidi, 2026-09-12: twenty-two seconds, nothing).
|
|
62
|
+
*/
|
|
63
|
+
export const PERMISSION_WAIT_MS = 10_000;
|
|
64
|
+
export function mayKeepWaitingForPermission(elapsedMs, permissionPending) {
|
|
65
|
+
return permissionPending && elapsedMs < PERMISSION_WAIT_MS;
|
|
66
|
+
}
|
|
67
|
+
/** A browser whose recogniser proved dead is remembered for this long, so the
|
|
68
|
+
* person does not rediscover it on every press — and not forever, because a
|
|
69
|
+
* browser can gain the capability. */
|
|
70
|
+
export const DEAD_RECOGNISER_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
|
71
|
+
export function deadRecogniserStillTrusted(rememberedAt, now = Date.now()) {
|
|
72
|
+
if (rememberedAt === null || !Number.isFinite(rememberedAt))
|
|
73
|
+
return false;
|
|
74
|
+
const age = now - rememberedAt;
|
|
75
|
+
// A timestamp from the future is a clock change, not a verdict.
|
|
76
|
+
return age >= 0 && age < DEAD_RECOGNISER_TTL_MS;
|
|
77
|
+
}
|
|
78
|
+
/** Recording is capped so a forgotten mic does not run for an hour. */
|
|
79
|
+
export const MAX_RECORDING_MS = 120_000;
|
|
80
|
+
/** Candidate containers for MediaRecorder, best first. */
|
|
81
|
+
export const RECORDING_MIME_CANDIDATES = [
|
|
82
|
+
"audio/webm;codecs=opus",
|
|
83
|
+
"audio/webm",
|
|
84
|
+
"audio/mp4",
|
|
85
|
+
"audio/ogg",
|
|
86
|
+
];
|
|
87
|
+
/** The default words for each problem. Override per app (and per language). */
|
|
88
|
+
export const DEFAULT_DICTATION_MESSAGES = {
|
|
89
|
+
mic: "The microphone is blocked. Allow it for this site and try again.",
|
|
90
|
+
silence: "Nothing was heard. Try again, a little closer to the mic.",
|
|
91
|
+
unavailable: "Voice input is not available right now. Type instead, or try again later.",
|
|
92
|
+
};
|
|
93
|
+
//# sourceMappingURL=dictation.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"dictation.js","sourceRoot":"","sources":["../src/dictation.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAUH;;;;;;GAMG;AACH,MAAM,UAAU,iBAAiB,CAAC,OAAgC;IAChE,OAAO,OAAO,KAAK,aAAa,CAAC;AACnC,CAAC;AAED;gFACgF;AAChF,MAAM,UAAU,UAAU,CAAC,KAAyB;IAClD,QAAQ,KAAK,EAAE,CAAC;QACd,KAAK,SAAS;YACZ,OAAO,IAAI,CAAC;QACd,KAAK,aAAa,CAAC;QACnB,KAAK,qBAAqB,CAAC;QAC3B,KAAK,eAAe;YAClB,OAAO,KAAK,CAAC;QACf,KAAK,WAAW;YACd,OAAO,SAAS,CAAC;QACnB;YACE,OAAO,aAAa,CAAC;IACzB,CAAC;AACH,CAAC;AAED;+DAC+D;AAC/D,MAAM,UAAU,mBAAmB,CAAC,KAAc;IAChD,MAAM,IAAI,GAAI,KAAkC,EAAE,IAAI,IAAI,EAAE,CAAC;IAC7D,IAAI,IAAI,KAAK,iBAAiB,IAAI,IAAI,KAAK,eAAe;QAAE,OAAO,KAAK,CAAC;IACzE,IAAI,IAAI,KAAK,eAAe,IAAI,IAAI,KAAK,kBAAkB;QAAE,OAAO,KAAK,CAAC;IAC1E,OAAO,aAAa,CAAC;AACvB,CAAC;AAED,mFAAmF;AACnF,MAAM,CAAC,MAAM,gBAAgB,GAAG,IAAI,CAAC;AAErC;;;;;GAKG;AACH,MAAM,CAAC,MAAM,kBAAkB,GAAG,MAAM,CAAC;AAEzC,MAAM,UAAU,2BAA2B,CACzC,SAAiB,EACjB,iBAA0B;IAE1B,OAAO,iBAAiB,IAAI,SAAS,GAAG,kBAAkB,CAAC;AAC7D,CAAC;AAED;;uCAEuC;AACvC,MAAM,CAAC,MAAM,sBAAsB,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,IAAI,CAAC;AAE/D,MAAM,UAAU,0BAA0B,CAAC,YAA2B,EAAE,GAAG,GAAG,IAAI,CAAC,GAAG,EAAE;IACtF,IAAI,YAAY,KAAK,IAAI,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,YAAY,CAAC;QAAE,OAAO,KAAK,CAAC;IAC1E,MAAM,GAAG,GAAG,GAAG,GAAG,YAAY,CAAC;IAC/B,gEAAgE;IAChE,OAAO,GAAG,IAAI,CAAC,IAAI,GAAG,GAAG,sBAAsB,CAAC;AAClD,CAAC;AAED,uEAAuE;AACvE,MAAM,CAAC,MAAM,gBAAgB,GAAG,OAAO,CAAC;AAExC,0DAA0D;AAC1D,MAAM,CAAC,MAAM,yBAAyB,GAAG;IACvC,wBAAwB;IACxB,YAAY;IACZ,WAAW;IACX,WAAW;CACH,CAAC;AAEX,+EAA+E;AAC/E,MAAM,CAAC,MAAM,0BAA0B,GAAqC;IAC1E,GAAG,EAAE,kEAAkE;IACvE,OAAO,EAAE,2DAA2D;IACpE,WAAW,EAAE,2EAA2E;CACzF,CAAC"}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @bitbaum/chatkit — the framework-free half. Every decision a chat makes,
|
|
3
|
+
* testable without a DOM. The React components live in `@bitbaum/chatkit/react`,
|
|
4
|
+
* the look in `@bitbaum/chatkit/styles.css`.
|
|
5
|
+
*/
|
|
6
|
+
export * from "./composer.js";
|
|
7
|
+
export * from "./dictation.js";
|
|
8
|
+
export * from "./markdown.js";
|
|
9
|
+
export * from "./scroll.js";
|
|
10
|
+
export * from "./attachments.js";
|
|
11
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,cAAc,eAAe,CAAC;AAC9B,cAAc,gBAAgB,CAAC;AAC/B,cAAc,eAAe,CAAC;AAC9B,cAAc,aAAa,CAAC;AAC5B,cAAc,kBAAkB,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @bitbaum/chatkit — the framework-free half. Every decision a chat makes,
|
|
3
|
+
* testable without a DOM. The React components live in `@bitbaum/chatkit/react`,
|
|
4
|
+
* the look in `@bitbaum/chatkit/styles.css`.
|
|
5
|
+
*/
|
|
6
|
+
export * from "./composer.js";
|
|
7
|
+
export * from "./dictation.js";
|
|
8
|
+
export * from "./markdown.js";
|
|
9
|
+
export * from "./scroll.js";
|
|
10
|
+
export * from "./attachments.js";
|
|
11
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,cAAc,eAAe,CAAC;AAC9B,cAAc,gBAAgB,CAAC;AAC/B,cAAc,eAAe,CAAC;AAC9B,cAAc,aAAa,CAAC;AAC5B,cAAc,kBAAkB,CAAC"}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The markdown an LLM actually writes, parsed into blocks and inline spans —
|
|
3
|
+
* no dependency, no HTML string, nothing to sanitise because nothing is ever
|
|
4
|
+
* rendered as HTML.
|
|
5
|
+
*
|
|
6
|
+
* From loki's `MarkdownText`, the one renderer in the fleet that four repos
|
|
7
|
+
* did NOT have: those rendered model output with the literal `**` still in it.
|
|
8
|
+
* Supports what answers use — paragraphs, bullet and numbered lists, headings,
|
|
9
|
+
* **bold**, *italic*, `code`, fenced code, [links](url) and `[F1]` citation
|
|
10
|
+
* handles — and deliberately nothing else.
|
|
11
|
+
*/
|
|
12
|
+
export type MarkdownBlock = {
|
|
13
|
+
kind: "p";
|
|
14
|
+
text: string;
|
|
15
|
+
} | {
|
|
16
|
+
kind: "h";
|
|
17
|
+
text: string;
|
|
18
|
+
} | {
|
|
19
|
+
kind: "ul";
|
|
20
|
+
items: string[];
|
|
21
|
+
} | {
|
|
22
|
+
kind: "ol";
|
|
23
|
+
items: string[];
|
|
24
|
+
} | {
|
|
25
|
+
kind: "code";
|
|
26
|
+
lang: string;
|
|
27
|
+
code: string;
|
|
28
|
+
} | {
|
|
29
|
+
kind: "quote";
|
|
30
|
+
text: string;
|
|
31
|
+
};
|
|
32
|
+
export declare function parseBlocks(text: string): MarkdownBlock[];
|
|
33
|
+
export type InlineSpan = {
|
|
34
|
+
kind: "text";
|
|
35
|
+
text: string;
|
|
36
|
+
} | {
|
|
37
|
+
kind: "code";
|
|
38
|
+
text: string;
|
|
39
|
+
} | {
|
|
40
|
+
kind: "strong";
|
|
41
|
+
text: string;
|
|
42
|
+
children: InlineSpan[];
|
|
43
|
+
} | {
|
|
44
|
+
kind: "em";
|
|
45
|
+
text: string;
|
|
46
|
+
children: InlineSpan[];
|
|
47
|
+
} | {
|
|
48
|
+
kind: "link";
|
|
49
|
+
text: string;
|
|
50
|
+
href: string;
|
|
51
|
+
} | {
|
|
52
|
+
kind: "cite";
|
|
53
|
+
ids: string[];
|
|
54
|
+
};
|
|
55
|
+
export declare function parseInline(text: string): InlineSpan[];
|
|
56
|
+
/**
|
|
57
|
+
* Only http(s), mailto and same-site paths become links. A `javascript:` URL in
|
|
58
|
+
* model output is text, never a link — the answer is written by a model that
|
|
59
|
+
* may have read a hostile page.
|
|
60
|
+
*/
|
|
61
|
+
export declare function safeHref(href: string): string | null;
|
|
62
|
+
/** A same-site path navigates in place; anything else opens a new tab. */
|
|
63
|
+
export declare function isInternalHref(href: string): boolean;
|
|
64
|
+
//# sourceMappingURL=markdown.d.ts.map
|