@djangocfg/widget-chat 0.1.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 +124 -0
- package/package.json +95 -0
- package/src/composer/composer.tsx +100 -0
- package/src/composer/context.ts +33 -0
- package/src/composer/controls/button.tsx +104 -0
- package/src/composer/controls/language-menu.tsx +123 -0
- package/src/composer/controls/send.tsx +83 -0
- package/src/composer/controls/think.tsx +50 -0
- package/src/composer/controls/tools.tsx +130 -0
- package/src/composer/controls/tooltip.tsx +10 -0
- package/src/composer/controls/voice.tsx +266 -0
- package/src/composer/field/field.tsx +140 -0
- package/src/composer/field/input.tsx +214 -0
- package/src/composer/field/keys.ts +44 -0
- package/src/composer/field/use-autosize.ts +134 -0
- package/src/composer/focus-field.ts +27 -0
- package/src/composer/index.ts +86 -0
- package/src/composer/stories/field.tsx +112 -0
- package/src/composer/stories/harness.tsx +127 -0
- package/src/composer/tools/menu.tsx +129 -0
- package/src/composer/tools/rows.tsx +180 -0
- package/src/composer/tools/use-menu-nav.ts +66 -0
- package/src/composer/tools/use-recent-tools.ts +30 -0
- package/src/composer/tray/banner.tsx +157 -0
- package/src/composer/tray/command-progress.tsx +116 -0
- package/src/composer/tray/picked-tools.tsx +104 -0
- package/src/composer/tray/queue.tsx +202 -0
- package/src/composer/tray/use-replace-text.ts +58 -0
- package/src/composer/use-draft-persistence.ts +79 -0
- package/src/content/index.ts +16 -0
- package/src/content/media-content.tsx +38 -0
- package/src/content/response.tsx +221 -0
- package/src/core/adapter.ts +29 -0
- package/src/core/capabilities.ts +286 -0
- package/src/core/chunks.ts +66 -0
- package/src/core/content/incomplete-math.ts +118 -0
- package/src/core/content/index.ts +9 -0
- package/src/core/draft-storage.ts +62 -0
- package/src/core/events.ts +88 -0
- package/src/core/focus/index.ts +10 -0
- package/src/core/focus/roving.ts +56 -0
- package/src/core/group-parts.ts +80 -0
- package/src/core/index.ts +34 -0
- package/src/core/links/index.ts +33 -0
- package/src/core/links/prose.ts +191 -0
- package/src/core/links/scheme.ts +98 -0
- package/src/core/links/tool-id.ts +26 -0
- package/src/core/media/allowed-src.ts +138 -0
- package/src/core/media/block.ts +254 -0
- package/src/core/media/index.ts +22 -0
- package/src/core/media/kinds.ts +85 -0
- package/src/core/outbound.ts +86 -0
- package/src/core/parts.ts +169 -0
- package/src/core/queue/index.ts +15 -0
- package/src/core/queue/queue.ts +100 -0
- package/src/core/recall/boundary.ts +77 -0
- package/src/core/recall/from-transcript.ts +47 -0
- package/src/core/recall/index.ts +23 -0
- package/src/core/recall/ladder.ts +171 -0
- package/src/core/reload-decision.ts +36 -0
- package/src/core/scroll/index.ts +17 -0
- package/src/core/scroll/missed.ts +106 -0
- package/src/core/scroll/sent.ts +54 -0
- package/src/core/text-replace.ts +20 -0
- package/src/core/tool-name.ts +31 -0
- package/src/core/tool-picks-set.ts +48 -0
- package/src/core/tool-picks.ts +142 -0
- package/src/core/tool-prompt.ts +47 -0
- package/src/core/tools/capability.ts +34 -0
- package/src/core/tools/catalogue.ts +109 -0
- package/src/core/tools/index.ts +17 -0
- package/src/core/tools/layout.ts +44 -0
- package/src/core/tools/nav.ts +32 -0
- package/src/core/tools/rank.ts +60 -0
- package/src/core/tools/recent.ts +39 -0
- package/src/core/transcript.ts +301 -0
- package/src/core/voice/earcons.ts +58 -0
- package/src/core/voice/index.ts +26 -0
- package/src/core/voice/language-search.ts +62 -0
- package/src/core/voice/languages.ts +267 -0
- package/src/core/voice/resolve-language.ts +93 -0
- package/src/core/voice/session.ts +196 -0
- package/src/core/voice/web-speech.ts +170 -0
- package/src/i18n/index.ts +87 -0
- package/src/i18n/locales/ar.ts +97 -0
- package/src/i18n/locales/da.ts +90 -0
- package/src/i18n/locales/de.ts +90 -0
- package/src/i18n/locales/en.ts +90 -0
- package/src/i18n/locales/es.ts +90 -0
- package/src/i18n/locales/fr.ts +90 -0
- package/src/i18n/locales/it.ts +90 -0
- package/src/i18n/locales/ja.ts +83 -0
- package/src/i18n/locales/ko.ts +83 -0
- package/src/i18n/locales/nl.ts +90 -0
- package/src/i18n/locales/no.ts +90 -0
- package/src/i18n/locales/pl.ts +96 -0
- package/src/i18n/locales/ptBR.ts +90 -0
- package/src/i18n/locales/ru.ts +96 -0
- package/src/i18n/locales/sv.ts +90 -0
- package/src/i18n/locales/tr.ts +89 -0
- package/src/i18n/locales/zh.ts +83 -0
- package/src/i18n/plural.ts +62 -0
- package/src/i18n/types.ts +257 -0
- package/src/index.ts +18 -0
- package/src/primitives/index.ts +43 -0
- package/src/primitives/message.tsx +85 -0
- package/src/primitives/part-registry.tsx +44 -0
- package/src/primitives/stories-harness.tsx +118 -0
- package/src/primitives/suggestions.tsx +112 -0
- package/src/primitives/tool-call.tsx +145 -0
- package/src/primitives/tooltip.tsx +48 -0
- package/src/primitives/transcript.tsx +285 -0
- package/src/primitives/use-dismiss.ts +100 -0
- package/src/primitives/use-missed-count.ts +30 -0
- package/src/primitives/use-overflows.ts +69 -0
- package/src/primitives/use-return-on-send.ts +29 -0
- package/src/primitives/use-roving-focus.ts +69 -0
- package/src/primitives/use-select-all-transcript.ts +111 -0
- package/src/primitives/use-stick-to-bottom.ts +83 -0
- package/src/primitives/use-turn-activity.ts +25 -0
- package/src/store/context.ts +28 -0
- package/src/store/hooks.ts +215 -0
- package/src/store/index.ts +31 -0
- package/src/store/provider.tsx +97 -0
- package/src/store/store.ts +394 -0
- package/src/store/subscribers.ts +55 -0
- package/src/store/surfaces.ts +174 -0
- package/src/store/use-draft.ts +168 -0
- package/src/store/use-queue.ts +61 -0
- package/src/store/use-recall.ts +264 -0
- package/src/store/use-reload-on-revision.ts +67 -0
- package/src/store/use-speech-language.ts +84 -0
- package/src/store/use-voice.ts +116 -0
- package/src/styles/block.css +128 -0
- package/src/styles/composer.css +449 -0
- package/src/styles/index.css +47 -0
- package/src/styles/markdown.css +368 -0
- package/src/styles/surface.css +102 -0
- package/src/styles/tool.css +128 -0
- package/src/styles/turn-mark.css +97 -0
- package/src/testing/index.ts +15 -0
- package/src/testing/mock-adapter.ts +69 -0
- package/src/testing/mock-tools.ts +101 -0
- package/src/testing/mock-voice.ts +81 -0
- package/src/ui/chat-composer.tsx +286 -0
- package/src/ui/chat-reply-chip.tsx +42 -0
- package/src/ui/chat-suggestions.tsx +87 -0
- package/src/ui/chat-transcript.tsx +157 -0
- package/src/ui/chat.tsx +197 -0
- package/src/ui/collapsible-body.tsx +180 -0
- package/src/ui/collapsible-row.tsx +81 -0
- package/src/ui/day-divider.tsx +49 -0
- package/src/ui/index.ts +54 -0
- package/src/ui/jump-to-latest.tsx +60 -0
- package/src/ui/link-chip.tsx +73 -0
- package/src/ui/message-actions.tsx +163 -0
- package/src/ui/message-quote.tsx +47 -0
- package/src/ui/message.tsx +295 -0
- package/src/ui/pending.tsx +85 -0
- package/src/ui/renderers/data.tsx +63 -0
- package/src/ui/renderers/reasoning.tsx +73 -0
- package/src/ui/renderers/tool.tsx +176 -0
- package/src/ui/turn-mark.tsx +31 -0
- package/src/utils/day-heading.ts +46 -0
- package/src/utils/error-message.ts +10 -0
- package/src/utils/index.ts +12 -0
- package/src/utils/markdown-text.ts +175 -0
- package/src/widgets/index.ts +14 -0
- package/src/widgets/media.tsx +239 -0
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hide an UNCLOSED `$$` from the math parser.
|
|
3
|
+
*
|
|
4
|
+
* ## The defect, measured
|
|
5
|
+
*
|
|
6
|
+
* streamdown completes half-arrived markdown so a bold run styles itself while
|
|
7
|
+
* the model is still typing it. Applied to math that behaviour is wrong rather
|
|
8
|
+
* than merely early: `complexity is $$O(n \log` renders a typeset `O(n log` —
|
|
9
|
+
* a DIFFERENT expression from the one being written, which then jumps to
|
|
10
|
+
* another as tokens land. A reader cannot tell an intermediate formula from a
|
|
11
|
+
* final one, so a plausible wrong formula is presented as an answer.
|
|
12
|
+
*
|
|
13
|
+
* ## Why not `parseIncompleteMarkdown: false`
|
|
14
|
+
*
|
|
15
|
+
* Because the flag is global, and completion is the whole reason this
|
|
16
|
+
* dependency was chosen. On this exact input set:
|
|
17
|
+
*
|
|
18
|
+
* | unclosed | flag on | flag off |
|
|
19
|
+
* |---|---|---|
|
|
20
|
+
* | `**important` | bold | plain |
|
|
21
|
+
* | `` `foo( `` | code | plain |
|
|
22
|
+
* | `[docs](` | link | plain |
|
|
23
|
+
* | `$$O(n \log` | KATEX ← the defect | plain |
|
|
24
|
+
*
|
|
25
|
+
* Turning it off fixes one row by breaking three. So completion stays on and
|
|
26
|
+
* the ONE construct where a premature guess is a lie gets neutralised here.
|
|
27
|
+
*
|
|
28
|
+
* ## How
|
|
29
|
+
*
|
|
30
|
+
* The last `$$` is escaped only when it opens a run nothing closes. Escaping
|
|
31
|
+
* (rather than deleting) is what keeps the literal `$$` visible: the user sees
|
|
32
|
+
* the characters the model has actually emitted, which is the honest state.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
/** The delimiter. Inline math uses the same one — `singleDollarTextMath` is off. */
|
|
36
|
+
const DELIMITER = "$$";
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Escape a trailing unclosed `$$`, leaving balanced math untouched.
|
|
40
|
+
*
|
|
41
|
+
* Fenced code is skipped: `` ```sh\necho $$ `` is a shell PID, not an opening
|
|
42
|
+
* delimiter, and rewriting inside a fence would corrupt code the user copies.
|
|
43
|
+
*/
|
|
44
|
+
export function maskIncompleteMath(source: string): string {
|
|
45
|
+
const segments = splitOnFences(source);
|
|
46
|
+
let count = 0;
|
|
47
|
+
|
|
48
|
+
// Count across prose segments only — a `$$` inside a fence closes nothing.
|
|
49
|
+
for (const segment of segments) {
|
|
50
|
+
if (segment.fenced) continue;
|
|
51
|
+
count += occurrences(segment.text);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (count % 2 === 0) return source;
|
|
55
|
+
|
|
56
|
+
// Odd ⇒ the LAST prose `$$` is the unclosed opener.
|
|
57
|
+
for (let i = segments.length - 1; i >= 0; i -= 1) {
|
|
58
|
+
const segment = segments[i];
|
|
59
|
+
if (!segment || segment.fenced) continue;
|
|
60
|
+
const at = segment.text.lastIndexOf(DELIMITER);
|
|
61
|
+
if (at === -1) continue;
|
|
62
|
+
segments[i] = {
|
|
63
|
+
fenced: false,
|
|
64
|
+
text: `${segment.text.slice(0, at)}\\$\\$${segment.text.slice(at + DELIMITER.length)}`,
|
|
65
|
+
};
|
|
66
|
+
break;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return segments.map((segment) => segment.text).join("");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
interface Segment {
|
|
73
|
+
readonly fenced: boolean;
|
|
74
|
+
readonly text: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function occurrences(text: string): number {
|
|
78
|
+
let n = 0;
|
|
79
|
+
let from = 0;
|
|
80
|
+
for (;;) {
|
|
81
|
+
const at = text.indexOf(DELIMITER, from);
|
|
82
|
+
if (at === -1) return n;
|
|
83
|
+
n += 1;
|
|
84
|
+
from = at + DELIMITER.length;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Split into alternating prose and fenced-code segments.
|
|
90
|
+
*
|
|
91
|
+
* An UNCLOSED fence swallows the rest of the string — which is correct: while a
|
|
92
|
+
* fence is open, everything after it is code, including any `$$`.
|
|
93
|
+
*/
|
|
94
|
+
function splitOnFences(source: string): Segment[] {
|
|
95
|
+
const segments: Segment[] = [];
|
|
96
|
+
const fence = /^[ \t]*(?:```|~~~)/gm;
|
|
97
|
+
let cursor = 0;
|
|
98
|
+
let open: number | null = null;
|
|
99
|
+
|
|
100
|
+
for (let match = fence.exec(source); match !== null; match = fence.exec(source)) {
|
|
101
|
+
const lineEnd = source.indexOf("\n", match.index);
|
|
102
|
+
const after = lineEnd === -1 ? source.length : lineEnd + 1;
|
|
103
|
+
|
|
104
|
+
if (open === null) {
|
|
105
|
+
segments.push({ fenced: false, text: source.slice(cursor, match.index) });
|
|
106
|
+
open = match.index;
|
|
107
|
+
cursor = match.index;
|
|
108
|
+
} else {
|
|
109
|
+
segments.push({ fenced: true, text: source.slice(open, after) });
|
|
110
|
+
open = null;
|
|
111
|
+
cursor = after;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// A fence left open owns everything to the end.
|
|
116
|
+
segments.push({ fenced: open !== null, text: source.slice(cursor) });
|
|
117
|
+
return segments;
|
|
118
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// Pure decisions about a markdown string BEFORE it reaches a parser.
|
|
2
|
+
//
|
|
3
|
+
// Here rather than in content/ because none of it renders: these are string →
|
|
4
|
+
// string functions with no React and no dependency on the parser, so they run
|
|
5
|
+
// in microseconds and can be reasoned about without one.
|
|
6
|
+
//
|
|
7
|
+
// May import: utils.
|
|
8
|
+
|
|
9
|
+
export { maskIncompleteMath } from "./incomplete-math";
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a persisted draft is worth keeping, as a pure decision.
|
|
3
|
+
*
|
|
4
|
+
* The effects that read and write live in `composer/use-draft-persistence.ts`;
|
|
5
|
+
* the RULES live here so they can be tested without a DOM. This workspace has
|
|
6
|
+
* no jsdom, so a rule that stays inside a hook is a rule nothing can assert.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** A draft larger than this is a paste, not typing, and belongs in a chip. */
|
|
10
|
+
export const DRAFT_MAX_LENGTH = 100_000;
|
|
11
|
+
|
|
12
|
+
const PREFIX = "djc.chat.draft:";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The storage key for a room. Spelled like `store/use-speech-language.ts`, so
|
|
16
|
+
* chat's keys share one convention.
|
|
17
|
+
*
|
|
18
|
+
* Unversioned: the value is a plain string, so the only migration a version
|
|
19
|
+
* could gate is "it became structured" — and that wants a new prefix, leaving
|
|
20
|
+
* the old entries to expire with their session.
|
|
21
|
+
*/
|
|
22
|
+
export function draftStorageKey(room: string): string {
|
|
23
|
+
return PREFIX + room;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* What to do with a value read back from storage.
|
|
28
|
+
*
|
|
29
|
+
* `null` for both "nothing stored" and "stored empty": an empty string would
|
|
30
|
+
* otherwise CLEAR a field the host had already seeded, which is the one
|
|
31
|
+
* outcome a restore must never produce. Over-long values are truncated rather
|
|
32
|
+
* than dropped — losing the tail of a long draft beats losing all of it.
|
|
33
|
+
*/
|
|
34
|
+
export function restoredDraft(stored: string | null): string | null {
|
|
35
|
+
if (stored === null || stored === "") return null;
|
|
36
|
+
return stored.length > DRAFT_MAX_LENGTH ? stored.slice(0, DRAFT_MAX_LENGTH) : stored;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Whether writing this text means storing it or removing the entry.
|
|
41
|
+
*
|
|
42
|
+
* Empty means REMOVE, which is what makes a send clear the draft with no
|
|
43
|
+
* send-specific branch: `send` resets the draft, the save path sees `""`, and
|
|
44
|
+
* the entry goes. Keeping an empty string instead would leave every sent room
|
|
45
|
+
* holding a useless entry forever.
|
|
46
|
+
*/
|
|
47
|
+
export function draftWrite(text: string): { readonly remove: true } | { readonly value: string } {
|
|
48
|
+
if (text === "") return { remove: true };
|
|
49
|
+
return { value: text.slice(0, DRAFT_MAX_LENGTH) };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Whether the save path may write yet.
|
|
54
|
+
*
|
|
55
|
+
* Before a room's restore has run, the draft still holds the PREVIOUS room's
|
|
56
|
+
* text (or the empty initial value). Writing then puts one room's draft under
|
|
57
|
+
* another's key, or erases a stored draft with the emptiness that precedes its
|
|
58
|
+
* own restore — the second is silent and destroys real work.
|
|
59
|
+
*/
|
|
60
|
+
export function mayPersist(restoredRoom: string | null, room: string): boolean {
|
|
61
|
+
return restoredRoom === room;
|
|
62
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What happens inside a chat, as data.
|
|
3
|
+
*
|
|
4
|
+
* The alternative this replaces is a prop threaded from the component that
|
|
5
|
+
* KNOWS a thing happened to the component that must react — through every layer
|
|
6
|
+
* between them, none of which cares. `ComposerQueue` needed the composer's
|
|
7
|
+
* field; the ask card needs the composer's focus; a host wants to know a prompt
|
|
8
|
+
* was taken back. Each on its own is one more prop on `<Chat>`, and the
|
|
9
|
+
* predecessor reached 83 of them that way.
|
|
10
|
+
*
|
|
11
|
+
* Modelled on `a typed transport`'s `IEventBus` — one typed map, no untyped
|
|
12
|
+
* escape hatch — but declared here rather than imported: the chat package must
|
|
13
|
+
* not depend on the transport to talk to itself.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { Draft } from "./outbound";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Every event, with its payload. `void` means the event carries nothing.
|
|
20
|
+
*
|
|
21
|
+
* Names are `subject:verb`, past tense: an event states what HAPPENED. A name
|
|
22
|
+
* in the imperative ("focus:composer") is a command wearing an event's clothes,
|
|
23
|
+
* and a bus that carries commands becomes a second, untyped API for the thing
|
|
24
|
+
* it is commanding.
|
|
25
|
+
*/
|
|
26
|
+
export interface ChatEventMap {
|
|
27
|
+
/** A queued prompt was taken back for editing; it has left the queue. */
|
|
28
|
+
"queue:taken": { readonly id: string; readonly draft: Draft };
|
|
29
|
+
/** A prompt was promoted past the running turn, which is now aborted. */
|
|
30
|
+
"queue:promoted": { readonly id: string };
|
|
31
|
+
/** A turn was sent. Carries what went out, not what comes back. */
|
|
32
|
+
"turn:sent": { readonly draft: Draft };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export type ChatEventName = keyof ChatEventMap;
|
|
36
|
+
|
|
37
|
+
export type ChatEventHandler<E extends ChatEventName> = (payload: ChatEventMap[E]) => void;
|
|
38
|
+
|
|
39
|
+
export type Unsubscribe = () => void;
|
|
40
|
+
|
|
41
|
+
/** Names whose payload is `void`: emitted with no second argument. */
|
|
42
|
+
export type VoidChatEvent = {
|
|
43
|
+
[E in ChatEventName]: ChatEventMap[E] extends void ? E : never;
|
|
44
|
+
}[ChatEventName];
|
|
45
|
+
|
|
46
|
+
export type DataChatEvent = Exclude<ChatEventName, VoidChatEvent>;
|
|
47
|
+
|
|
48
|
+
export interface ChatEvents {
|
|
49
|
+
on<E extends DataChatEvent>(event: E, handler: ChatEventHandler<E>): Unsubscribe;
|
|
50
|
+
on<E extends VoidChatEvent>(event: E, handler: () => void): Unsubscribe;
|
|
51
|
+
emit<E extends DataChatEvent>(event: E, payload: ChatEventMap[E]): void;
|
|
52
|
+
emit<E extends VoidChatEvent>(event: E): void;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* An in-memory bus, one per chat.
|
|
57
|
+
*
|
|
58
|
+
* Per chat and not module-scoped: two conversations open side by side must not
|
|
59
|
+
* hear each other's events, which a singleton cannot express. Created beside
|
|
60
|
+
* the store, handed down the same provider.
|
|
61
|
+
*
|
|
62
|
+
* Handlers are copied before dispatch, which fixes the listener list at the
|
|
63
|
+
* moment of the emit. A `Set` tolerates deleting the CURRENT entry mid-loop, so
|
|
64
|
+
* a one-shot unsubscribing itself is safe either way — the case that is not is
|
|
65
|
+
* one handler unsubscribing ANOTHER. Without the copy that second handler is
|
|
66
|
+
* silently skipped for this emit, which reads exactly like an event that never
|
|
67
|
+
* fired. Everyone listening when the event happened hears it.
|
|
68
|
+
*/
|
|
69
|
+
export function createChatEvents(): ChatEvents {
|
|
70
|
+
const handlers = new Map<ChatEventName, Set<(payload: never) => void>>();
|
|
71
|
+
|
|
72
|
+
function on(event: ChatEventName, handler: (payload: never) => void): Unsubscribe {
|
|
73
|
+
const set = handlers.get(event) ?? new Set();
|
|
74
|
+
handlers.set(event, set);
|
|
75
|
+
set.add(handler);
|
|
76
|
+
return () => {
|
|
77
|
+
set.delete(handler);
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function emit(event: ChatEventName, payload?: unknown): void {
|
|
82
|
+
const set = handlers.get(event);
|
|
83
|
+
if (set === undefined) return;
|
|
84
|
+
for (const handler of [...set]) (handler as (p: unknown) => void)(payload);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return { on, emit } as ChatEvents;
|
|
88
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// Which row is the transcript's single tab stop.
|
|
2
|
+
//
|
|
3
|
+
// The DOM part is three lines and lives in primitives/; the part that can be
|
|
4
|
+
// wrong — which index a key selects, and what happens when the list shrinks
|
|
5
|
+
// under a stale one — lives here where it is testable without a renderer.
|
|
6
|
+
//
|
|
7
|
+
// May import: utils.
|
|
8
|
+
|
|
9
|
+
export { CHAT_ITEM_ATTR, clampIndex, isRovingKey, nextIndex } from "./roving";
|
|
10
|
+
export type { RovingKey } from "./roving";
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The transcript is ONE tab stop; arrows move within it.
|
|
3
|
+
*
|
|
4
|
+
* Without this, Tab walks every message, every tool card and every link before
|
|
5
|
+
* reaching the composer — so on a transcript of any length a keyboard user
|
|
6
|
+
* cannot reach the input at all. The APG calls it roving tabindex.
|
|
7
|
+
*
|
|
8
|
+
* The DOM part is three lines and lives in `primitives/`. What lives here is
|
|
9
|
+
* the part that can be wrong: which index a key selects.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export const CHAT_ITEM_ATTR = "data-chat-item";
|
|
13
|
+
|
|
14
|
+
/** Keys that move within the transcript, mapped to a target index. */
|
|
15
|
+
export type RovingKey = "ArrowDown" | "ArrowUp" | "Home" | "End";
|
|
16
|
+
|
|
17
|
+
export function isRovingKey(key: string): key is RovingKey {
|
|
18
|
+
return key === "ArrowDown" || key === "ArrowUp" || key === "Home" || key === "End";
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Where a key moves the active row, or `null` when it moves nowhere.
|
|
23
|
+
*
|
|
24
|
+
* `null` — rather than "the same index" — is what lets the caller decide
|
|
25
|
+
* whether to call `preventDefault()`. Swallowing ArrowDown at the last row
|
|
26
|
+
* would stop the PAGE from scrolling, which is the browser behaviour a user at
|
|
27
|
+
* the end of a transcript is relying on.
|
|
28
|
+
*/
|
|
29
|
+
export function nextIndex(key: RovingKey, active: number, count: number): number | null {
|
|
30
|
+
if (count === 0) return null;
|
|
31
|
+
|
|
32
|
+
const last = count - 1;
|
|
33
|
+
// Clamped, never wrapped. Wrapping from the newest message to the first is
|
|
34
|
+
// disorienting in a transcript, where position carries meaning — MUI X Chat
|
|
35
|
+
// clamps here too.
|
|
36
|
+
const target =
|
|
37
|
+
key === "Home"
|
|
38
|
+
? 0
|
|
39
|
+
: key === "End"
|
|
40
|
+
? last
|
|
41
|
+
: Math.min(last, Math.max(0, active + (key === "ArrowDown" ? 1 : -1)));
|
|
42
|
+
|
|
43
|
+
return target === active ? null : target;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Keep the active index valid as the transcript grows and shrinks.
|
|
48
|
+
*
|
|
49
|
+
* Messages arrive while the user reads. An index that outlives its row leaves
|
|
50
|
+
* EVERY row with `tabIndex={-1}` — the transcript silently stops being
|
|
51
|
+
* reachable by Tab at all, and nothing about it looks broken.
|
|
52
|
+
*/
|
|
53
|
+
export function clampIndex(active: number, count: number): number {
|
|
54
|
+
if (count === 0) return 0;
|
|
55
|
+
return Math.min(count - 1, Math.max(0, active));
|
|
56
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Collapse runs of same-kind parts into groups.
|
|
3
|
+
*
|
|
4
|
+
* A turn that thinks, calls four tools, thinks again and answers renders as six
|
|
5
|
+
* separate cards without this — the answer ends up below a wall of tool calls.
|
|
6
|
+
* Grouping puts the run behind one "Thinking" or "4 tools" affordance, which is
|
|
7
|
+
* what assistant-ui does and what makes a tool-heavy transcript readable.
|
|
8
|
+
*
|
|
9
|
+
* Only ADJACENT parts coalesce. Reordering to gather all tool calls together
|
|
10
|
+
* would misrepresent the turn: the sequence is the agent's reasoning, and
|
|
11
|
+
* "searched, then read what it found" is different information from "searched
|
|
12
|
+
* and read".
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { MessagePart } from "./parts";
|
|
16
|
+
|
|
17
|
+
export type PartGroup =
|
|
18
|
+
/** A single part, rendered on its own. */
|
|
19
|
+
| { readonly kind: "single"; readonly key: string; readonly part: MessagePart }
|
|
20
|
+
/** Two or more adjacent parts of one type. */
|
|
21
|
+
| {
|
|
22
|
+
readonly kind: "run";
|
|
23
|
+
readonly key: string;
|
|
24
|
+
readonly type: MessagePart["type"];
|
|
25
|
+
readonly parts: readonly MessagePart[];
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/** Types worth collapsing. Text is never grouped — prose is the answer itself. */
|
|
29
|
+
const GROUPABLE = new Set<MessagePart["type"]>(["reasoning", "tool"]);
|
|
30
|
+
|
|
31
|
+
export function groupParts(parts: readonly MessagePart[]): readonly PartGroup[] {
|
|
32
|
+
const groups: PartGroup[] = [];
|
|
33
|
+
let run: MessagePart[] = [];
|
|
34
|
+
|
|
35
|
+
const flush = (): void => {
|
|
36
|
+
const first = run[0];
|
|
37
|
+
if (first === undefined) return;
|
|
38
|
+
groups.push(
|
|
39
|
+
run.length === 1
|
|
40
|
+
? { kind: "single", key: first.id, part: first }
|
|
41
|
+
// Keyed on the FIRST member: a run that grows while streaming keeps its
|
|
42
|
+
// key, so the group does not remount on every new tool call.
|
|
43
|
+
: { kind: "run", key: first.id, type: first.type, parts: run },
|
|
44
|
+
);
|
|
45
|
+
run = [];
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
for (const part of parts) {
|
|
49
|
+
const previous = run[0];
|
|
50
|
+
const continues =
|
|
51
|
+
previous !== undefined && previous.type === part.type && GROUPABLE.has(part.type);
|
|
52
|
+
if (!continues) flush();
|
|
53
|
+
if (GROUPABLE.has(part.type)) {
|
|
54
|
+
run.push(part);
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
groups.push({ kind: "single", key: part.id, part });
|
|
58
|
+
}
|
|
59
|
+
flush();
|
|
60
|
+
|
|
61
|
+
return groups;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Should a group be open?
|
|
66
|
+
*
|
|
67
|
+
* Open while the run is still producing, collapsed once it settles — the user
|
|
68
|
+
* wants to watch a tool work and then get it out of the way. Derived rather
|
|
69
|
+
* than stored so it cannot disagree with the parts; a host that wants manual
|
|
70
|
+
* control layers its own state over this default.
|
|
71
|
+
*/
|
|
72
|
+
export function isGroupActive(group: PartGroup): boolean {
|
|
73
|
+
const parts = group.kind === "run" ? group.parts : [group.part];
|
|
74
|
+
return parts.some(
|
|
75
|
+
(part) =>
|
|
76
|
+
part.type === "tool" &&
|
|
77
|
+
(part.toolState.state === "input-streaming" ||
|
|
78
|
+
part.toolState.state === "input-available"),
|
|
79
|
+
);
|
|
80
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export * from "./parts";
|
|
2
|
+
export * from "./group-parts";
|
|
3
|
+
export * from "./adapter";
|
|
4
|
+
export * from "./capabilities";
|
|
5
|
+
export * from "./outbound";
|
|
6
|
+
export * from "./tool-picks";
|
|
7
|
+
// What a pick still wants. A rule about the DRAFT, so it ships with the model
|
|
8
|
+
// rather than being re-derived at the surface that renders the hint.
|
|
9
|
+
export * from "./tool-prompt";
|
|
10
|
+
export { addToolPick, addToolPicks, removeToolPick, toggleToolPick } from "./tool-picks-set";
|
|
11
|
+
export * from "./chunks";
|
|
12
|
+
// Every adapter that mints a tool part owes the reader the same name, so the
|
|
13
|
+
// rule ships with the model rather than being re-derived at each entry point.
|
|
14
|
+
export * from "./tool-name";
|
|
15
|
+
export * from "./transcript";
|
|
16
|
+
export * from "./media";
|
|
17
|
+
export * from "./tools";
|
|
18
|
+
export * from "./recall";
|
|
19
|
+
export * from "./voice";
|
|
20
|
+
export * from "./queue";
|
|
21
|
+
export * from "./focus";
|
|
22
|
+
export * from "./scroll";
|
|
23
|
+
export * from "./content";
|
|
24
|
+
export * from "./draft-storage";
|
|
25
|
+
export * from "./events";
|
|
26
|
+
export * from "./reload-decision";
|
|
27
|
+
// The host implements `AskReplace` and so must be able to name its return.
|
|
28
|
+
// `composer/index.ts` states this barrel already carries it and declines to
|
|
29
|
+
// re-export for that reason — the two must agree, or the type is reachable from
|
|
30
|
+
// nowhere and every host writes its own union.
|
|
31
|
+
export * from "./text-replace";
|
|
32
|
+
// The mention URL scheme. Exported because `configureMentionScheme` is a host
|
|
33
|
+
// call — the scheme is the host's product name, and nothing else can supply it.
|
|
34
|
+
export * from "./links";
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Links, both directions: the ones this package WRITES into a message, and the
|
|
2
|
+
// ones it READS back out of one.
|
|
3
|
+
//
|
|
4
|
+
// Together because they are one round trip. A picked tool is written as a
|
|
5
|
+
// markdown link and parsed back on render, and `scheme.ts` owns both halves so
|
|
6
|
+
// the shape can never disagree with itself. `prose.ts` handles the other kind —
|
|
7
|
+
// ordinary web links the model or the user wrote — which are only ever read.
|
|
8
|
+
//
|
|
9
|
+
// Pure functions of a string, no React and no DOM: the chip decision and the
|
|
10
|
+
// label are testable without rendering anything, which is what keeps them
|
|
11
|
+
// honest while the renderer changes.
|
|
12
|
+
|
|
13
|
+
export {
|
|
14
|
+
configureMentionScheme,
|
|
15
|
+
mentionScheme,
|
|
16
|
+
toolURL,
|
|
17
|
+
parse,
|
|
18
|
+
type ParsedMention,
|
|
19
|
+
type ParseResult,
|
|
20
|
+
} from "./scheme";
|
|
21
|
+
|
|
22
|
+
// Reading half only. A picked tool needs no preprocess step — the composer
|
|
23
|
+
// writes the link itself, so the id is never recovered from prose.
|
|
24
|
+
export { extractToolId } from "./tool-id";
|
|
25
|
+
|
|
26
|
+
export {
|
|
27
|
+
isBareUrlLink,
|
|
28
|
+
isExternalHref,
|
|
29
|
+
linkChipLabel,
|
|
30
|
+
normalizeHref,
|
|
31
|
+
splitUrl,
|
|
32
|
+
type SplitUrl,
|
|
33
|
+
} from "./prose";
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which links in a message body read as a bare reference, and what a compact
|
|
3
|
+
* label for one looks like.
|
|
4
|
+
*
|
|
5
|
+
* A bare url and `[the docs](…)` are indistinguishable in the tree: both are an
|
|
6
|
+
* ordinary mdast `link`, and their hast properties are byte-identical. There is
|
|
7
|
+
* no autolink marker to read. The signal is the CHILD — an autolink's sole text
|
|
8
|
+
* child equals its url.
|
|
9
|
+
*
|
|
10
|
+
* Label shape adapted from the retired `@djangocfg/ui-tools`
|
|
11
|
+
* (`src/tools/forms/MarkdownEditor/url/detect.ts`) (`splitUrl`, `normalizeHref`,
|
|
12
|
+
* the Chrome-omnibox form). `truncateUrlLabel` is reworked here; `faviconUrl`
|
|
13
|
+
* does not come across — it would make a third-party request per link.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const ELLIPSIS = "…";
|
|
17
|
+
|
|
18
|
+
/** Default budget for a whole label. Wide enough for `domain/first/…/last`. */
|
|
19
|
+
const DEFAULT_MAX_CHARS = 40;
|
|
20
|
+
|
|
21
|
+
/** A url split into the parts a label is built from. */
|
|
22
|
+
export interface SplitUrl {
|
|
23
|
+
/** Host without a leading `www.`. Empty when unparseable. */
|
|
24
|
+
readonly domain: string;
|
|
25
|
+
/** Path segments, empty ones dropped. */
|
|
26
|
+
readonly segments: readonly string[];
|
|
27
|
+
/** `#fragment` including the hash, or empty. */
|
|
28
|
+
readonly hash: string;
|
|
29
|
+
/** `mailto:` address without the scheme, or empty. */
|
|
30
|
+
readonly mail: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Prepend a scheme to a schemeless `www.` host so `URL` can parse it. */
|
|
34
|
+
export function normalizeHref(raw: string): string {
|
|
35
|
+
if (/^www\./i.test(raw)) return `https://${raw}`;
|
|
36
|
+
return raw;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Split an href into the label's parts.
|
|
41
|
+
*
|
|
42
|
+
* The hash is its OWN field, not fused into the path: that is what lets a label
|
|
43
|
+
* shed a fragment before it sheds a path segment.
|
|
44
|
+
*/
|
|
45
|
+
export function splitUrl(href: string): SplitUrl {
|
|
46
|
+
if (/^mailto:/i.test(href)) {
|
|
47
|
+
const mail = href.slice("mailto:".length);
|
|
48
|
+
const at = mail.lastIndexOf("@");
|
|
49
|
+
return {
|
|
50
|
+
domain: at >= 0 ? mail.slice(at + 1) : "",
|
|
51
|
+
segments: [],
|
|
52
|
+
hash: "",
|
|
53
|
+
mail,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
try {
|
|
57
|
+
const u = new URL(normalizeHref(href));
|
|
58
|
+
return {
|
|
59
|
+
domain: u.hostname.replace(/^www\./i, ""),
|
|
60
|
+
segments: `${u.pathname}${u.search}`.split("/").filter((s) => s.length > 0),
|
|
61
|
+
hash: u.hash,
|
|
62
|
+
mail: "",
|
|
63
|
+
};
|
|
64
|
+
} catch {
|
|
65
|
+
// A label is presentation and must not throw on a token `URL` refuses.
|
|
66
|
+
const noScheme = normalizeHref(href).replace(/^[a-z][a-z0-9+.-]*:\/\//i, "");
|
|
67
|
+
const hashAt = noScheme.indexOf("#");
|
|
68
|
+
const body = hashAt >= 0 ? noScheme.slice(0, hashAt) : noScheme;
|
|
69
|
+
const slash = body.indexOf("/");
|
|
70
|
+
return {
|
|
71
|
+
domain: (slash >= 0 ? body.slice(0, slash) : body).replace(/^www\./i, ""),
|
|
72
|
+
segments: slash >= 0 ? body.slice(slash).split("/").filter((s) => s.length > 0) : [],
|
|
73
|
+
hash: hashAt >= 0 ? noScheme.slice(hashAt) : "",
|
|
74
|
+
mail: "",
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Join a domain with the segments a candidate keeps.
|
|
81
|
+
*
|
|
82
|
+
* `…` claims something was dropped, so it is emitted only when `total` exceeds
|
|
83
|
+
* what was kept. An unconditional mark reads as correct while standing for
|
|
84
|
+
* nothing.
|
|
85
|
+
*/
|
|
86
|
+
function join(domain: string, kept: readonly string[], total: number, hash: string): string {
|
|
87
|
+
// The mark sits where the gap is: between two survivors, or leading when only
|
|
88
|
+
// the last survives. Always after `kept[0]` would read as `last/…`.
|
|
89
|
+
const marked =
|
|
90
|
+
total <= kept.length
|
|
91
|
+
? [...kept]
|
|
92
|
+
: kept.length <= 1
|
|
93
|
+
? [ELLIPSIS, ...kept]
|
|
94
|
+
: [kept[0] as string, ELLIPSIS, ...kept.slice(1)];
|
|
95
|
+
const body = marked.join("/");
|
|
96
|
+
return `${domain}${body === "" ? "" : `/${body}`}${hash}`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* A compact, Chrome-omnibox-style label for a url: the domain in full, the
|
|
101
|
+
* path's middle collapsed, the last segment kept.
|
|
102
|
+
*
|
|
103
|
+
* A fragment is shed before a path segment: the last segment names the PAGE, a
|
|
104
|
+
* fragment only locates a spot inside it. Fusing them into one string puts a
|
|
105
|
+
* long fragment on the last segment and forces every candidate over budget.
|
|
106
|
+
*
|
|
107
|
+
* The last resort truncates the last segment and KEEPS it. Never a bare
|
|
108
|
+
* `domain/…`, which identifies nothing when every link shares the domain.
|
|
109
|
+
*/
|
|
110
|
+
export function linkChipLabel(href: string, maxChars: number = DEFAULT_MAX_CHARS): string {
|
|
111
|
+
const { domain, segments, hash, mail } = splitUrl(href);
|
|
112
|
+
|
|
113
|
+
if (mail !== "") {
|
|
114
|
+
if (mail.length <= maxChars) return mail;
|
|
115
|
+
return `${ELLIPSIS}${mail.slice(mail.length - (maxChars - 1))}`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const total = segments.length;
|
|
119
|
+
if (total === 0) {
|
|
120
|
+
const bare = `${domain}${hash}`;
|
|
121
|
+
return bare.length <= maxChars ? bare : domain;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const last = segments[total - 1] as string;
|
|
125
|
+
const first = segments[0] as string;
|
|
126
|
+
// Widest first, and at each width the fragment goes before a path segment
|
|
127
|
+
// does: `domain/cli/daemon/service` beats `domain/…/service#install-options`,
|
|
128
|
+
// because the segments name the page and the fragment only locates a spot
|
|
129
|
+
// inside it.
|
|
130
|
+
const kept: readonly (readonly string[])[] = [
|
|
131
|
+
segments,
|
|
132
|
+
total >= 3 ? [first, last] : segments,
|
|
133
|
+
[last],
|
|
134
|
+
];
|
|
135
|
+
const candidates: readonly string[] = kept.flatMap((k) => [
|
|
136
|
+
join(domain, k, total, hash),
|
|
137
|
+
join(domain, k, total, ""),
|
|
138
|
+
]);
|
|
139
|
+
for (const candidate of candidates) {
|
|
140
|
+
if (candidate.length <= maxChars) return candidate;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Nothing fit, so the last segment is itself too long. Keep the page's own
|
|
144
|
+
// name, cut to what the domain leaves, so two links into the same domain
|
|
145
|
+
// still read differently. The leading mark is still conditional: a
|
|
146
|
+
// single-segment path dropped nothing, and claiming otherwise is the same
|
|
147
|
+
// lie `join` exists to prevent.
|
|
148
|
+
const lead = total > 1 ? `${ELLIPSIS}/` : "";
|
|
149
|
+
const room = Math.max(4, maxChars - domain.length - lead.length - 2);
|
|
150
|
+
return `${domain}/${lead}${last.slice(0, room)}${ELLIPSIS}`;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Whether an anchor is a BARE url — the reader faces the raw string, with no
|
|
155
|
+
* label the author chose.
|
|
156
|
+
*
|
|
157
|
+
* `text` is the anchor's sole text child, or undefined when the child is not a
|
|
158
|
+
* lone text node. A formatted label (`**…**`, inline code, several children)
|
|
159
|
+
* is never bare: the author who formatted a label meant it to read inline.
|
|
160
|
+
*
|
|
161
|
+
* The comparison runs on a normalised form so a schemeless `www.` host still
|
|
162
|
+
* matches: gfm rewrites the URL and leaves the text alone, so raw equality
|
|
163
|
+
* would fail on exactly the shape a model writes when it elides the scheme.
|
|
164
|
+
* One comparison rather than a second rule — one place to be wrong.
|
|
165
|
+
*
|
|
166
|
+
* A label that IS the url counts, though it is technically a markdown link.
|
|
167
|
+
* The rule is about what the READER faces, and that is the same either way.
|
|
168
|
+
*/
|
|
169
|
+
export function isBareUrlLink(href: string | undefined, text: string | undefined): boolean {
|
|
170
|
+
if (href === undefined || text === undefined) return false;
|
|
171
|
+
const label = text.trim();
|
|
172
|
+
if (label === "") return false;
|
|
173
|
+
return stripScheme(href) === stripScheme(label);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Drop a leading `http://` or `https://` and any trailing slash. */
|
|
177
|
+
function stripScheme(value: string): string {
|
|
178
|
+
return value.replace(/^https?:\/\//i, "").replace(/\/+$/, "");
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Whether an href is a web target a new tab makes sense for.
|
|
183
|
+
*
|
|
184
|
+
* An in-app scheme opened with `target="_blank"` spawns a blank tab
|
|
185
|
+
* the browser cannot navigate. This is about the navigation target, not about
|
|
186
|
+
* safety — `rehype-harden` already decides which protocols survive at all.
|
|
187
|
+
*/
|
|
188
|
+
export function isExternalHref(href: string | undefined): boolean {
|
|
189
|
+
if (href === undefined) return false;
|
|
190
|
+
return /^(https?|mailto|tel):/i.test(href);
|
|
191
|
+
}
|