@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,34 @@
|
|
|
1
|
+
import type { ToolCatalogEntry } from "./catalogue";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The agent's arsenal — the composer's `+` menu.
|
|
5
|
+
*
|
|
6
|
+
* Absent ⇒ no `+` at all. A replay has no arsenal to offer.
|
|
7
|
+
*
|
|
8
|
+
* The catalogue is the HOST's: registration is conditional on the server's
|
|
9
|
+
* capabilities, so a list shipped here would promise what a turn cannot deliver.
|
|
10
|
+
*
|
|
11
|
+
* A pick does NOT run the tool. Running one needs arguments and a form; the
|
|
12
|
+
* agent decides how to do the work.
|
|
13
|
+
*/
|
|
14
|
+
export interface ToolsCapability {
|
|
15
|
+
/** The catalogue, in the order it should be offered. */
|
|
16
|
+
readonly entries: readonly ToolCatalogEntry[];
|
|
17
|
+
/**
|
|
18
|
+
* The server has not answered yet. `entries` is then EMPTY BECAUSE UNKNOWN,
|
|
19
|
+
* not because it has none — three states, not two.
|
|
20
|
+
*
|
|
21
|
+
* Without it a slow answer renders identically to "no tools": the trigger
|
|
22
|
+
* vanishes, reappears when the reply lands, and vanishes again on the next
|
|
23
|
+
* room whose answer is not cached. The control holds its place instead.
|
|
24
|
+
*/
|
|
25
|
+
readonly loading?: boolean;
|
|
26
|
+
/**
|
|
27
|
+
* Told that a pick happened, AFTER it is staged. Absent ⇒ nothing else runs.
|
|
28
|
+
*
|
|
29
|
+
* A notification, not a decision: the package stages the pick on the draft and
|
|
30
|
+
* `buildOutbound` turns it into a marker, so a host that also composed text
|
|
31
|
+
* here would make two writers of one outbound turn.
|
|
32
|
+
*/
|
|
33
|
+
readonly onPick?: (entry: ToolCatalogEntry) => void;
|
|
34
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// The tool catalogue as data. Registration on the server is conditional on
|
|
2
|
+
// what it can reach, so the list is read, never assumed.
|
|
3
|
+
//
|
|
4
|
+
// May import: ./rank.
|
|
5
|
+
|
|
6
|
+
import { scoreTool } from "./rank";
|
|
7
|
+
|
|
8
|
+
/** One capability, as the registry already describes it. */
|
|
9
|
+
export interface ToolCatalogEntry {
|
|
10
|
+
/** The registry's own name, e.g. `transcribe_audio`. Also what a pick names. */
|
|
11
|
+
readonly id: string;
|
|
12
|
+
/** What the row READS. Short enough for a narrow pane. */
|
|
13
|
+
readonly name: string;
|
|
14
|
+
/**
|
|
15
|
+
* One human line, authored separately from the tool's own description — that
|
|
16
|
+
* one is model-facing and mostly disambiguation against neighbouring tools.
|
|
17
|
+
*/
|
|
18
|
+
readonly summary: string;
|
|
19
|
+
/** The registry's grouping key (`stt`, `web`, `disk`, …), used verbatim. */
|
|
20
|
+
readonly category: string;
|
|
21
|
+
/**
|
|
22
|
+
* The registry's own search keywords. Searching "voice" must reach a tool
|
|
23
|
+
* spelled `transcribe_audio`.
|
|
24
|
+
*/
|
|
25
|
+
readonly keywords?: readonly string[];
|
|
26
|
+
/**
|
|
27
|
+
* Glyph KEY, never a node — the catalogue crosses the transport as data. An unmapped
|
|
28
|
+
* key draws nothing rather than failing.
|
|
29
|
+
*/
|
|
30
|
+
readonly icon?: string;
|
|
31
|
+
/**
|
|
32
|
+
* What a pick still wants, when anything.
|
|
33
|
+
*
|
|
34
|
+
* Absent for most tools by design: picking states an intent and the object
|
|
35
|
+
* arrives as the sentence typed next. The exceptions want something a
|
|
36
|
+
* sentence cannot carry, and the composer says so rather than letting the
|
|
37
|
+
* turn send and the agent ask.
|
|
38
|
+
*
|
|
39
|
+
* Derived on the server from the tool's own required arguments, never authored here.
|
|
40
|
+
*/
|
|
41
|
+
readonly needs?: "file" | "skill";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** A category and the tools in it, in display order. */
|
|
45
|
+
export interface ToolCatalogGroup {
|
|
46
|
+
readonly category: string;
|
|
47
|
+
readonly entries: readonly ToolCatalogEntry[];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Group by the registry's own category; a second taxonomy here would drift.
|
|
52
|
+
* Order follows first appearance, so the host's ordering decides.
|
|
53
|
+
*/
|
|
54
|
+
export function groupToolsByCategory(
|
|
55
|
+
entries: readonly ToolCatalogEntry[],
|
|
56
|
+
): readonly ToolCatalogGroup[] {
|
|
57
|
+
const order: string[] = [];
|
|
58
|
+
const byCategory = new Map<string, ToolCatalogEntry[]>();
|
|
59
|
+
|
|
60
|
+
for (const entry of entries) {
|
|
61
|
+
const bucket = byCategory.get(entry.category);
|
|
62
|
+
if (bucket === undefined) {
|
|
63
|
+
order.push(entry.category);
|
|
64
|
+
byCategory.set(entry.category, [entry]);
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
bucket.push(entry);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return order.map((category) => ({
|
|
71
|
+
category,
|
|
72
|
+
entries: byCategory.get(category) ?? [],
|
|
73
|
+
}));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Matches id, name, summary and keywords, in any keyboard layout. Name alone is
|
|
78
|
+
* not enough: a user with a recording types "voice", which `transcribe_audio`
|
|
79
|
+
* does not contain.
|
|
80
|
+
*
|
|
81
|
+
* Matching and ordering read the same score, so a row can never rank above
|
|
82
|
+
* another it does not also out-match.
|
|
83
|
+
*/
|
|
84
|
+
export function toolMatchesQuery(entry: ToolCatalogEntry, query: string): boolean {
|
|
85
|
+
return scoreTool(entry, query) > 0;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The catalogue narrowed to a query, ranked, grouped for display.
|
|
90
|
+
*
|
|
91
|
+
* Ranking runs BEFORE grouping and orders entries within each group; the groups
|
|
92
|
+
* themselves keep the registry's order. Sorting groups by their best hit would
|
|
93
|
+
* move a heading up and down as the user types, and a category that jumps is
|
|
94
|
+
* harder to scan than one that stays put.
|
|
95
|
+
*
|
|
96
|
+
* Sort is stable on equal scores, so an empty query — where every entry scores
|
|
97
|
+
* alike — leaves the registry's order untouched.
|
|
98
|
+
*/
|
|
99
|
+
export function filterToolCatalogue(
|
|
100
|
+
entries: readonly ToolCatalogEntry[],
|
|
101
|
+
query: string,
|
|
102
|
+
): readonly ToolCatalogGroup[] {
|
|
103
|
+
const scored = entries
|
|
104
|
+
.map((entry) => ({ entry, score: scoreTool(entry, query) }))
|
|
105
|
+
.filter((row) => row.score > 0)
|
|
106
|
+
.sort((a, b) => b.score - a.score);
|
|
107
|
+
|
|
108
|
+
return groupToolsByCategory(scored.map((row) => row.entry));
|
|
109
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// The tool arsenal: what this agent can do here, as data.
|
|
2
|
+
//
|
|
3
|
+
// Pure by design — the catalogue arrives from the host (Go owns the registry),
|
|
4
|
+
// and the surface rendering it is meant to be swappable. May import: nothing.
|
|
5
|
+
|
|
6
|
+
export type { ToolsCapability } from "./capability";
|
|
7
|
+
export {
|
|
8
|
+
filterToolCatalogue,
|
|
9
|
+
groupToolsByCategory,
|
|
10
|
+
toolMatchesQuery,
|
|
11
|
+
type ToolCatalogEntry,
|
|
12
|
+
type ToolCatalogGroup,
|
|
13
|
+
} from "./catalogue";
|
|
14
|
+
export { queryLayoutForms } from "./layout";
|
|
15
|
+
export { nextRow, stepForKey, NO_ROW } from "./nav";
|
|
16
|
+
export { scoreTool } from "./rank";
|
|
17
|
+
export { forgetTool, recentTools, rememberTool, RECENT_LIMIT } from "./recent";
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// Keyboard-layout transliteration for search input.
|
|
2
|
+
//
|
|
3
|
+
// May import: nothing.
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* QWERTY→ЙЦУКЕН by physical key. The pair is positional, not phonetic: `g`
|
|
7
|
+
* and `п` are the same key, so someone who meant "generate" and left the
|
|
8
|
+
* layout on Russian produces "пуньфеу".
|
|
9
|
+
*/
|
|
10
|
+
const RU_BY_KEY = "йцукенгшщзхъфывапролджэячсмитьбю.ёЙЦУКЕНГШЩЗХЪФЫВАПРОЛДЖЭЯЧСМИТЬБЮ,Ё";
|
|
11
|
+
const EN_BY_KEY = "qwertyuiop[]asdfghjkl;'zxcvbnm,./`QWERTYUIOP{}ASDFGHJKL:\"ZXCVBNM<>?~";
|
|
12
|
+
|
|
13
|
+
const TO_EN = new Map<string, string>();
|
|
14
|
+
const TO_RU = new Map<string, string>();
|
|
15
|
+
for (const [index, ru] of [...RU_BY_KEY].entries()) {
|
|
16
|
+
const en = EN_BY_KEY[index];
|
|
17
|
+
if (en === undefined) continue;
|
|
18
|
+
TO_EN.set(ru, en);
|
|
19
|
+
TO_RU.set(en, ru);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function transliterate(value: string, table: ReadonlyMap<string, string>): string {
|
|
23
|
+
let out = "";
|
|
24
|
+
for (const char of value) out += table.get(char) ?? char;
|
|
25
|
+
return out;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* What was typed, plus each layout reading of it.
|
|
30
|
+
*
|
|
31
|
+
* Both directions: the mistake runs either way, and guessing which costs a
|
|
32
|
+
* keystroke. The original comes first, so a caller can rank an exact hit above
|
|
33
|
+
* a transliterated one.
|
|
34
|
+
*/
|
|
35
|
+
export function queryLayoutForms(query: string): readonly string[] {
|
|
36
|
+
const typed = query.trim();
|
|
37
|
+
if (typed === "") return [];
|
|
38
|
+
|
|
39
|
+
const forms = [typed];
|
|
40
|
+
for (const candidate of [transliterate(typed, TO_EN), transliterate(typed, TO_RU)]) {
|
|
41
|
+
if (candidate !== typed && !forms.includes(candidate)) forms.push(candidate);
|
|
42
|
+
}
|
|
43
|
+
return forms;
|
|
44
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// Where an arrow key moves a menu cursor.
|
|
2
|
+
//
|
|
3
|
+
// May import: nothing.
|
|
4
|
+
|
|
5
|
+
/** Nothing is selected yet — the state a freshly opened menu is in. */
|
|
6
|
+
export const NO_ROW = -1;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The next cursor position, or `NO_ROW` when the key does not move it.
|
|
10
|
+
*
|
|
11
|
+
* Movement WRAPS. The list is short and the user is aiming at one row, so
|
|
12
|
+
* stopping at the end costs a direction change to reach what is one step the
|
|
13
|
+
* other way.
|
|
14
|
+
*
|
|
15
|
+
* From nothing selected, Down opens at the top and Up at the BOTTOM: the last
|
|
16
|
+
* row is then one keystroke away instead of `count - 1` of them.
|
|
17
|
+
*
|
|
18
|
+
* Pure, and separate from the hook binding it, because this package has no DOM
|
|
19
|
+
* to render a hook into — the same reason the recall ladder is a pure module.
|
|
20
|
+
*/
|
|
21
|
+
export function nextRow(current: number, step: number, count: number): number {
|
|
22
|
+
if (count === 0 || step === 0) return NO_ROW;
|
|
23
|
+
if (current < 0) return step > 0 ? 0 : count - 1;
|
|
24
|
+
return (current + step + count) % count;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The step an arrow key asks for; 0 for every other key. */
|
|
28
|
+
export function stepForKey(key: string): number {
|
|
29
|
+
if (key === "ArrowDown") return 1;
|
|
30
|
+
if (key === "ArrowUp") return -1;
|
|
31
|
+
return 0;
|
|
32
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// How well a catalogue entry answers a query.
|
|
2
|
+
//
|
|
3
|
+
// May import: ./catalogue, ./layout.
|
|
4
|
+
|
|
5
|
+
import type { ToolCatalogEntry } from "./catalogue";
|
|
6
|
+
import { queryLayoutForms } from "./layout";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Field weights. A summary hit is often incidental: "gen" matches "the agent's
|
|
10
|
+
* browser", which is how an unranked filter put Browser navigate first.
|
|
11
|
+
*/
|
|
12
|
+
const NAME = 1000;
|
|
13
|
+
const ID = 800;
|
|
14
|
+
const KEYWORD = 600;
|
|
15
|
+
const SUMMARY = 200;
|
|
16
|
+
|
|
17
|
+
/** A prefix hit beats one mid-word: "gen" means Generate before Regenerate. */
|
|
18
|
+
const PREFIX_BONUS = 100;
|
|
19
|
+
|
|
20
|
+
/** Every layout form after the first is a guess about what the user meant. */
|
|
21
|
+
const TRANSLITERATED_PENALTY = 50;
|
|
22
|
+
|
|
23
|
+
function fieldScore(haystack: string, needle: string, weight: number): number {
|
|
24
|
+
const at = haystack.toLowerCase().indexOf(needle);
|
|
25
|
+
if (at < 0) return 0;
|
|
26
|
+
return weight + (at === 0 ? PREFIX_BONUS : 0);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function scoreForNeedle(entry: ToolCatalogEntry, needle: string): number {
|
|
30
|
+
const keywords = (entry.keywords ?? []).reduce(
|
|
31
|
+
(best, keyword) => Math.max(best, fieldScore(keyword, needle, KEYWORD)),
|
|
32
|
+
0,
|
|
33
|
+
);
|
|
34
|
+
// Best field, not the sum: summing lets a long summary outrank an exact name.
|
|
35
|
+
return Math.max(
|
|
36
|
+
fieldScore(entry.name, needle, NAME),
|
|
37
|
+
fieldScore(entry.id, needle, ID),
|
|
38
|
+
keywords,
|
|
39
|
+
fieldScore(entry.summary, needle, SUMMARY),
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* 0 when the entry does not match at all; higher is more relevant.
|
|
45
|
+
*
|
|
46
|
+
* An empty query scores every entry equally, so the caller's own order (the
|
|
47
|
+
* registry's) survives — the menu at rest is not a search result.
|
|
48
|
+
*/
|
|
49
|
+
export function scoreTool(entry: ToolCatalogEntry, query: string): number {
|
|
50
|
+
const forms = queryLayoutForms(query);
|
|
51
|
+
if (forms.length === 0) return 1;
|
|
52
|
+
|
|
53
|
+
let best = 0;
|
|
54
|
+
for (const [index, form] of forms.entries()) {
|
|
55
|
+
const score = scoreForNeedle(entry, form.toLowerCase());
|
|
56
|
+
if (score === 0) continue;
|
|
57
|
+
best = Math.max(best, score - (index === 0 ? 0 : TRANSLITERATED_PENALTY));
|
|
58
|
+
}
|
|
59
|
+
return best;
|
|
60
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Recently used tools, as data.
|
|
2
|
+
//
|
|
3
|
+
// May import: ./catalogue.
|
|
4
|
+
|
|
5
|
+
import type { ToolCatalogEntry } from "./catalogue";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* How many recents a user can hold in their head before the group stops being
|
|
9
|
+
* a shortcut and becomes a second catalogue to read.
|
|
10
|
+
*/
|
|
11
|
+
export const RECENT_LIMIT = 5;
|
|
12
|
+
|
|
13
|
+
/** Most recent first, capped, no duplicates. */
|
|
14
|
+
export function rememberTool(recent: readonly string[], id: string): readonly string[] {
|
|
15
|
+
return [id, ...recent.filter((known) => known !== id)].slice(0, RECENT_LIMIT);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function forgetTool(recent: readonly string[], id: string): readonly string[] {
|
|
19
|
+
return recent.filter((known) => known !== id);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Recent ids resolved against the CURRENT catalogue, in recency order.
|
|
24
|
+
*
|
|
25
|
+
* A tool the host no longer registers drops out rather than rendering a row
|
|
26
|
+
* that picks nothing. The stored list is not pruned — the tool may return.
|
|
27
|
+
*/
|
|
28
|
+
export function recentTools(
|
|
29
|
+
entries: readonly ToolCatalogEntry[],
|
|
30
|
+
recent: readonly string[],
|
|
31
|
+
): readonly ToolCatalogEntry[] {
|
|
32
|
+
const byId = new Map(entries.map((entry) => [entry.id, entry]));
|
|
33
|
+
const resolved: ToolCatalogEntry[] = [];
|
|
34
|
+
for (const id of recent) {
|
|
35
|
+
const entry = byId.get(id);
|
|
36
|
+
if (entry !== undefined) resolved.push(entry);
|
|
37
|
+
}
|
|
38
|
+
return resolved;
|
|
39
|
+
}
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The transcript reducer — the ONE place a stream becomes messages.
|
|
3
|
+
*
|
|
4
|
+
* Pure and synchronous on purpose: every streaming bug the predecessor shipped
|
|
5
|
+
* (duplicated fragments after a retry, tool output attached to the wrong call,
|
|
6
|
+
* a failed turn rendered as a normal reply) is reproducible here in
|
|
7
|
+
* milliseconds, with no React and no DOM. `@rules/testing.md` calls this the
|
|
8
|
+
* main rung for exactly that reason.
|
|
9
|
+
*
|
|
10
|
+
* Normalized as `ids[] + byId{}` so a message can re-render without the list
|
|
11
|
+
* re-rendering — the transcript subscribes to ids, each row to its own id.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { StreamChunk } from "./chunks";
|
|
15
|
+
import type { ChatMessage, MessagePart, MessageStatus, ToolPart, ToolState } from "./parts";
|
|
16
|
+
|
|
17
|
+
export interface TranscriptState {
|
|
18
|
+
readonly ids: readonly string[];
|
|
19
|
+
readonly byId: Readonly<Record<string, ChatMessage>>;
|
|
20
|
+
/** The message currently receiving chunks, if any. */
|
|
21
|
+
readonly streamingId: string | null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const emptyTranscript: TranscriptState = { ids: [], byId: {}, streamingId: null };
|
|
25
|
+
|
|
26
|
+
function replacePart(
|
|
27
|
+
message: ChatMessage,
|
|
28
|
+
at: number,
|
|
29
|
+
part: MessagePart,
|
|
30
|
+
): ChatMessage {
|
|
31
|
+
const parts = message.parts.slice();
|
|
32
|
+
parts[at] = part;
|
|
33
|
+
return { ...message, parts };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function appendPart(message: ChatMessage, part: MessagePart): ChatMessage {
|
|
37
|
+
return { ...message, parts: [...message.parts, part] };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Append the part, or REPLACE the one already carrying that id.
|
|
42
|
+
*
|
|
43
|
+
* For WHOLE parts only — `file` and `data`, which arrive complete in one chunk.
|
|
44
|
+
* A repeated id there is the same part revised, never a second part: the
|
|
45
|
+
* server re-sends its plan block under the stable id `plan-current` on every
|
|
46
|
+
* step, and the transport's own accumulator (`collectBlock`,
|
|
47
|
+
* `transport/web/chat-map.ts`) is replace-by-id for exactly that reason. A
|
|
48
|
+
* blind append stacks one plan card per step.
|
|
49
|
+
*
|
|
50
|
+
* Deliberately NOT used by `text-start` / `reasoning-start` / `tool-input-start`,
|
|
51
|
+
* which create an EMPTY part that deltas then fill. Replacement there would let
|
|
52
|
+
* a repeated start chunk wipe the text already accumulated under that id —
|
|
53
|
+
* strictly worse than the duplicate empty part it would be preventing.
|
|
54
|
+
*
|
|
55
|
+
* Replacement keeps the ORIGINAL position rather than moving the part to the
|
|
56
|
+
* end: the plan was authored where it first appeared, and a card that jumps
|
|
57
|
+
* down the transcript on each revision makes a different claim about the turn.
|
|
58
|
+
*/
|
|
59
|
+
function upsertPart(message: ChatMessage, part: MessagePart): ChatMessage {
|
|
60
|
+
const at = message.parts.findIndex((p) => p.id === part.id);
|
|
61
|
+
if (at === -1) return appendPart(message, part);
|
|
62
|
+
const parts = [...message.parts];
|
|
63
|
+
parts[at] = part;
|
|
64
|
+
return { ...message, parts };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function withMessage(state: TranscriptState, message: ChatMessage): TranscriptState {
|
|
68
|
+
const known = message.id in state.byId;
|
|
69
|
+
return {
|
|
70
|
+
ids: known ? state.ids : [...state.ids, message.id],
|
|
71
|
+
byId: { ...state.byId, [message.id]: message },
|
|
72
|
+
streamingId: state.streamingId,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Locate a part by key, or -1. Keys are unique within a message by construction. */
|
|
77
|
+
function findPart(
|
|
78
|
+
message: ChatMessage,
|
|
79
|
+
match: (part: MessagePart) => boolean,
|
|
80
|
+
): number {
|
|
81
|
+
for (let i = message.parts.length - 1; i >= 0; i -= 1) {
|
|
82
|
+
const part = message.parts[i];
|
|
83
|
+
if (part !== undefined && match(part)) return i;
|
|
84
|
+
}
|
|
85
|
+
return -1;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function findTool(message: ChatMessage, toolCallId: string): number {
|
|
89
|
+
return findPart(message, (p): boolean => p.type === "tool" && p.id === toolCallId);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Preserve the input we already have when a state transition does not carry it. */
|
|
93
|
+
function inputOf(state: ToolState): unknown {
|
|
94
|
+
return state.state === "input-streaming" ? undefined : state.input;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function updateTool(
|
|
98
|
+
message: ChatMessage,
|
|
99
|
+
toolCallId: string,
|
|
100
|
+
next: (part: ToolPart) => ToolPart,
|
|
101
|
+
): ChatMessage {
|
|
102
|
+
const at = findTool(message, toolCallId);
|
|
103
|
+
if (at < 0) return message;
|
|
104
|
+
const part = message.parts[at];
|
|
105
|
+
if (part === undefined || part.type !== "tool") return message;
|
|
106
|
+
return replacePart(message, at, next(part));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Text and reasoning deltas share everything but the discriminant, so they
|
|
111
|
+
* share the code — a copied pair drifts, and the two paths must stay identical
|
|
112
|
+
* or `reset` will clear one and not the other.
|
|
113
|
+
*/
|
|
114
|
+
function applyDelta(
|
|
115
|
+
message: ChatMessage,
|
|
116
|
+
kind: "text" | "reasoning",
|
|
117
|
+
partId: string,
|
|
118
|
+
delta: string,
|
|
119
|
+
): ChatMessage {
|
|
120
|
+
const at = findPart(message, (p) => p.type === kind && p.id === partId);
|
|
121
|
+
if (at < 0) return message;
|
|
122
|
+
const part = message.parts[at];
|
|
123
|
+
if (part === undefined || (part.type !== "text" && part.type !== "reasoning")) return message;
|
|
124
|
+
return replacePart(message, at, { ...part, text: part.text + delta });
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function reduce(state: TranscriptState, chunk: StreamChunk): TranscriptState {
|
|
128
|
+
switch (chunk.type) {
|
|
129
|
+
case "message-start": {
|
|
130
|
+
const message: ChatMessage = {
|
|
131
|
+
id: chunk.messageId,
|
|
132
|
+
role: "assistant",
|
|
133
|
+
parts: [],
|
|
134
|
+
status: { state: "streaming" },
|
|
135
|
+
createdAt: chunk.createdAt,
|
|
136
|
+
};
|
|
137
|
+
return { ...withMessage(state, message), streamingId: chunk.messageId };
|
|
138
|
+
}
|
|
139
|
+
case "reset": {
|
|
140
|
+
const current = currentMessage(state);
|
|
141
|
+
if (current === null) return state;
|
|
142
|
+
return withMessage(state, { ...current, parts: [] });
|
|
143
|
+
}
|
|
144
|
+
case "finish":
|
|
145
|
+
return finishWith(state, { state: "complete" });
|
|
146
|
+
case "error":
|
|
147
|
+
return finishWith(state, { state: "failed", message: chunk.message });
|
|
148
|
+
case "interrupted":
|
|
149
|
+
return finishWith(state, { state: "interrupted" });
|
|
150
|
+
default:
|
|
151
|
+
return reduceIntoCurrent(state, chunk);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function currentMessage(state: TranscriptState): ChatMessage | null {
|
|
156
|
+
if (state.streamingId === null) return null;
|
|
157
|
+
return state.byId[state.streamingId] ?? null;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function finishWith(state: TranscriptState, status: MessageStatus): TranscriptState {
|
|
161
|
+
const current = currentMessage(state);
|
|
162
|
+
if (current === null) return state;
|
|
163
|
+
return { ...withMessage(state, { ...current, status }), streamingId: null };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Chunks that target the in-flight message.
|
|
168
|
+
*
|
|
169
|
+
* A chunk arriving with no message in flight is DROPPED rather than implicitly
|
|
170
|
+
* opening one. Silent auto-creation hides an adapter that forgot
|
|
171
|
+
* `message-start`, and the resulting transcript looks plausible while being
|
|
172
|
+
* wrong — the failure has to be visible in a test, not smoothed over.
|
|
173
|
+
*/
|
|
174
|
+
function reduceIntoCurrent(
|
|
175
|
+
state: TranscriptState,
|
|
176
|
+
chunk: Exclude<StreamChunk, { type: "message-start" | "reset" | "finish" | "error" | "interrupted" }>,
|
|
177
|
+
): TranscriptState {
|
|
178
|
+
const current = currentMessage(state);
|
|
179
|
+
if (current === null) return state;
|
|
180
|
+
|
|
181
|
+
switch (chunk.type) {
|
|
182
|
+
case "text-start":
|
|
183
|
+
return withMessage(
|
|
184
|
+
state,
|
|
185
|
+
appendPart(current, { type: "text", id: chunk.partId, text: "" }),
|
|
186
|
+
);
|
|
187
|
+
case "text-delta":
|
|
188
|
+
return withMessage(state, applyDelta(current, "text", chunk.partId, chunk.delta));
|
|
189
|
+
case "reasoning-start":
|
|
190
|
+
return withMessage(
|
|
191
|
+
state,
|
|
192
|
+
appendPart(current, {
|
|
193
|
+
type: "reasoning",
|
|
194
|
+
id: chunk.partId,
|
|
195
|
+
text: "",
|
|
196
|
+
streaming: true,
|
|
197
|
+
}),
|
|
198
|
+
);
|
|
199
|
+
case "reasoning-delta":
|
|
200
|
+
return withMessage(state, applyDelta(current, "reasoning", chunk.partId, chunk.delta));
|
|
201
|
+
case "reasoning-end": {
|
|
202
|
+
const at = findPart(current, (p) => p.type === "reasoning" && p.id === chunk.partId);
|
|
203
|
+
const part = at < 0 ? undefined : current.parts[at];
|
|
204
|
+
// An end for a block that never opened is dropped rather than synthesised:
|
|
205
|
+
// a part with no text and no measurement is an empty disclosure.
|
|
206
|
+
if (part === undefined || part.type !== "reasoning") return state;
|
|
207
|
+
return withMessage(
|
|
208
|
+
state,
|
|
209
|
+
replacePart(current, at, {
|
|
210
|
+
...part,
|
|
211
|
+
streaming: false,
|
|
212
|
+
// Keep what the engine measured; absent stays absent rather than
|
|
213
|
+
// becoming a zero, which would read as "thought for 0ms".
|
|
214
|
+
...(chunk.elapsedMs === undefined ? {} : { elapsedMs: chunk.elapsedMs }),
|
|
215
|
+
...(chunk.tokens === undefined ? {} : { tokens: chunk.tokens }),
|
|
216
|
+
}),
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
case "tool-input-start":
|
|
220
|
+
return withMessage(
|
|
221
|
+
state,
|
|
222
|
+
appendPart(current, {
|
|
223
|
+
type: "tool",
|
|
224
|
+
id: chunk.toolCallId,
|
|
225
|
+
toolName: chunk.toolName,
|
|
226
|
+
toolState: { state: "input-streaming", inputText: "" },
|
|
227
|
+
}),
|
|
228
|
+
);
|
|
229
|
+
case "tool-input-delta":
|
|
230
|
+
return withMessage(
|
|
231
|
+
state,
|
|
232
|
+
updateTool(current, chunk.toolCallId, (part) =>
|
|
233
|
+
part.toolState.state === "input-streaming"
|
|
234
|
+
? {
|
|
235
|
+
...part,
|
|
236
|
+
toolState: {
|
|
237
|
+
state: "input-streaming",
|
|
238
|
+
inputText: part.toolState.inputText + chunk.delta,
|
|
239
|
+
},
|
|
240
|
+
}
|
|
241
|
+
: part,
|
|
242
|
+
),
|
|
243
|
+
);
|
|
244
|
+
case "tool-input-available":
|
|
245
|
+
return withMessage(
|
|
246
|
+
state,
|
|
247
|
+
updateTool(current, chunk.toolCallId, (part) => ({
|
|
248
|
+
...part,
|
|
249
|
+
toolState: { state: "input-available", input: chunk.input },
|
|
250
|
+
})),
|
|
251
|
+
);
|
|
252
|
+
case "tool-output":
|
|
253
|
+
return withMessage(
|
|
254
|
+
state,
|
|
255
|
+
updateTool(current, chunk.toolCallId, (part) => ({
|
|
256
|
+
...part,
|
|
257
|
+
toolState: {
|
|
258
|
+
state: "output-available",
|
|
259
|
+
input: inputOf(part.toolState),
|
|
260
|
+
output: chunk.output,
|
|
261
|
+
},
|
|
262
|
+
})),
|
|
263
|
+
);
|
|
264
|
+
case "tool-error":
|
|
265
|
+
return withMessage(
|
|
266
|
+
state,
|
|
267
|
+
updateTool(current, chunk.toolCallId, (part) => ({
|
|
268
|
+
...part,
|
|
269
|
+
toolState: {
|
|
270
|
+
state: "output-error",
|
|
271
|
+
input: inputOf(part.toolState),
|
|
272
|
+
error: chunk.error,
|
|
273
|
+
},
|
|
274
|
+
})),
|
|
275
|
+
);
|
|
276
|
+
case "data":
|
|
277
|
+
return withMessage(state, upsertPart(current, chunk.part));
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** Append a message the user authored. Never streams, so it lands complete. */
|
|
282
|
+
export function appendUserMessage(
|
|
283
|
+
state: TranscriptState,
|
|
284
|
+
message: ChatMessage,
|
|
285
|
+
): TranscriptState {
|
|
286
|
+
return withMessage(state, message);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export function reduceAll(
|
|
290
|
+
state: TranscriptState,
|
|
291
|
+
chunks: readonly StreamChunk[],
|
|
292
|
+
): TranscriptState {
|
|
293
|
+
return chunks.reduce(reduce, state);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
export function selectMessages(state: TranscriptState): readonly ChatMessage[] {
|
|
297
|
+
return state.ids.flatMap((id) => {
|
|
298
|
+
const message = state.byId[id];
|
|
299
|
+
return message === undefined ? [] : [message];
|
|
300
|
+
});
|
|
301
|
+
}
|