@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.
Files changed (60) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +132 -0
  3. package/dist/attachments.d.ts +72 -0
  4. package/dist/attachments.d.ts.map +1 -0
  5. package/dist/attachments.js +64 -0
  6. package/dist/attachments.js.map +1 -0
  7. package/dist/composer.d.ts +34 -0
  8. package/dist/composer.d.ts.map +1 -0
  9. package/dist/composer.js +41 -0
  10. package/dist/composer.js.map +1 -0
  11. package/dist/dictation.d.ts +60 -0
  12. package/dist/dictation.d.ts.map +1 -0
  13. package/dist/dictation.js +93 -0
  14. package/dist/dictation.js.map +1 -0
  15. package/dist/index.d.ts +11 -0
  16. package/dist/index.d.ts.map +1 -0
  17. package/dist/index.js +11 -0
  18. package/dist/index.js.map +1 -0
  19. package/dist/markdown.d.ts +64 -0
  20. package/dist/markdown.d.ts.map +1 -0
  21. package/dist/markdown.js +129 -0
  22. package/dist/markdown.js.map +1 -0
  23. package/dist/react/Composer.d.ts +85 -0
  24. package/dist/react/Composer.d.ts.map +1 -0
  25. package/dist/react/Composer.js +132 -0
  26. package/dist/react/Composer.js.map +1 -0
  27. package/dist/react/Markdown.d.ts +27 -0
  28. package/dist/react/Markdown.d.ts.map +1 -0
  29. package/dist/react/Markdown.js +62 -0
  30. package/dist/react/Markdown.js.map +1 -0
  31. package/dist/react/Thread.d.ts +90 -0
  32. package/dist/react/Thread.d.ts.map +1 -0
  33. package/dist/react/Thread.js +64 -0
  34. package/dist/react/Thread.js.map +1 -0
  35. package/dist/react/hooks.d.ts +40 -0
  36. package/dist/react/hooks.d.ts.map +1 -0
  37. package/dist/react/hooks.js +99 -0
  38. package/dist/react/hooks.js.map +1 -0
  39. package/dist/react/icons.d.ts +12 -0
  40. package/dist/react/icons.d.ts.map +1 -0
  41. package/dist/react/icons.js +21 -0
  42. package/dist/react/icons.js.map +1 -0
  43. package/dist/react/index.d.ts +21 -0
  44. package/dist/react/index.d.ts.map +1 -0
  45. package/dist/react/index.js +16 -0
  46. package/dist/react/index.js.map +1 -0
  47. package/dist/react/use-attachments.d.ts +26 -0
  48. package/dist/react/use-attachments.d.ts.map +1 -0
  49. package/dist/react/use-attachments.js +202 -0
  50. package/dist/react/use-attachments.js.map +1 -0
  51. package/dist/react/use-dictation.d.ts +49 -0
  52. package/dist/react/use-dictation.d.ts.map +1 -0
  53. package/dist/react/use-dictation.js +365 -0
  54. package/dist/react/use-dictation.js.map +1 -0
  55. package/dist/scroll.d.ts +13 -0
  56. package/dist/scroll.d.ts.map +1 -0
  57. package/dist/scroll.js +11 -0
  58. package/dist/scroll.js.map +1 -0
  59. package/package.json +83 -0
  60. 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"}
@@ -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"}
@@ -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