@kanzo-tech/ai 0.20.0 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/assist.d.ts CHANGED
@@ -39,8 +39,9 @@ export interface AssistProviderProps {
39
39
  /** Each proposal's life — shown, accepted, partly accepted, rejected, ignored. */
40
40
  onEvent?: (event: AssistEvent) => void;
41
41
  /**
42
- * Called with what the model's stream threw, whole, when a field's ask fails — including an
43
- * `AiError` coded `ai/silent` when it sent nothing for 30 s. The field also says so under itself.
42
+ * Called with what the model's stream threw, whole, when a field's ask fails — on the first
43
+ * attempt, never retried. A model from `createGateway` that goes silent arrives as its `AiError`
44
+ * coded `ai/silent`. The field also says so under itself.
44
45
  */
45
46
  onFailure?: (error: unknown) => void;
46
47
  translations?: Partial<AssistTranslations>;
@@ -1 +1 @@
1
- {"version":3,"file":"assist.d.ts","sourceRoot":"","sources":["../src/assist.tsx"],"names":[],"mappings":"AAIA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAC;AAC/B,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAGrD,OAAO,EACL,KAAK,WAAW,EAKjB,MAAM,aAAa,CAAC;AAKrB,0GAA0G;AAC1G,MAAM,WAAW,kBAAkB;IACjC,qBAAqB;IACrB,MAAM,EAAE,MAAM,CAAC;IACf,qEAAqE;IACrE,MAAM,EAAE,MAAM,CAAC;IACf,yEAAyE;IACzE,MAAM,EAAE,MAAM,CAAC;IACf,+EAA+E;IAC/E,MAAM,EAAE,MAAM,CAAC;IACf,kDAAkD;IAClD,SAAS,EAAE,MAAM,CAAC;IAClB,4BAA4B;IAC5B,UAAU,EAAE,MAAM,CAAC;IACnB,sBAAsB;IACtB,OAAO,EAAE,MAAM,CAAC;IAChB,gEAAgE;IAChE,YAAY,EAAE,MAAM,CAAC;IACrB,yCAAyC;IACzC,QAAQ,EAAE,MAAM,CAAC;IACjB,2CAA2C;IAC3C,KAAK,EAAE,MAAM,CAAC;IACd,4EAA4E;IAC5E,MAAM,EAAE,MAAM,CAAC;IACf,mDAAmD;IACnD,OAAO,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM,CAAC;CACnC;AA6BD,MAAM,WAAW,mBAAmB;IAClC,yEAAyE;IACzE,KAAK,EAAE,aAAa,CAAC;IACrB;;;OAGG;IACH,OAAO,CAAC,EAAE,MAAM,GAAG,CAAC,MAAM,MAAM,GAAG,SAAS,CAAC,CAAC;IAC9C,kFAAkF;IAClF,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,WAAW,KAAK,IAAI,CAAC;IACvC;;;OAGG;IACH,SAAS,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,IAAI,CAAC;IACrC,YAAY,CAAC,EAAE,OAAO,CAAC,kBAAkB,CAAC,CAAC;IAC3C,QAAQ,EAAE,KAAK,CAAC,SAAS,CAAC;CAC3B;AAED;;;GAGG;AACH,wBAAgB,cAAc,CAAC,KAAK,EAAE,mBAAmB,qBAaxD;AA4FD,MAAM,WAAW,WAAW,CAAC,CAAC,SAAS,MAAM,GAAG,MAAM,EAAE;IACtD;;;OAGG;IACH,KAAK,EAAE,CAAC,CAAC;IACT,aAAa,EAAE,CAAC,KAAK,EAAE,CAAC,KAAK,IAAI,CAAC;IAClC,qFAAqF;IACrF,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,iGAAiG;IACjG,QAAQ,EAAE,KAAK,CAAC,YAAY,CAAC;IAC7B,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED;;;;;;;;;GASG;AACH,wBAAgB,MAAM,CAAC,CAAC,SAAS,MAAM,GAAG,MAAM,EAAE,EAAE,KAAK,EAAE,WAAW,CAAC,CAAC,CAAC,qBAOxE"}
1
+ {"version":3,"file":"assist.d.ts","sourceRoot":"","sources":["../src/assist.tsx"],"names":[],"mappings":"AAIA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAC;AAC/B,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAGrD,OAAO,EACL,KAAK,WAAW,EAKjB,MAAM,aAAa,CAAC;AAKrB,0GAA0G;AAC1G,MAAM,WAAW,kBAAkB;IACjC,qBAAqB;IACrB,MAAM,EAAE,MAAM,CAAC;IACf,qEAAqE;IACrE,MAAM,EAAE,MAAM,CAAC;IACf,yEAAyE;IACzE,MAAM,EAAE,MAAM,CAAC;IACf,+EAA+E;IAC/E,MAAM,EAAE,MAAM,CAAC;IACf,kDAAkD;IAClD,SAAS,EAAE,MAAM,CAAC;IAClB,4BAA4B;IAC5B,UAAU,EAAE,MAAM,CAAC;IACnB,sBAAsB;IACtB,OAAO,EAAE,MAAM,CAAC;IAChB,gEAAgE;IAChE,YAAY,EAAE,MAAM,CAAC;IACrB,yCAAyC;IACzC,QAAQ,EAAE,MAAM,CAAC;IACjB,2CAA2C;IAC3C,KAAK,EAAE,MAAM,CAAC;IACd,4EAA4E;IAC5E,MAAM,EAAE,MAAM,CAAC;IACf,mDAAmD;IACnD,OAAO,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM,CAAC;CACnC;AA6BD,MAAM,WAAW,mBAAmB;IAClC,yEAAyE;IACzE,KAAK,EAAE,aAAa,CAAC;IACrB;;;OAGG;IACH,OAAO,CAAC,EAAE,MAAM,GAAG,CAAC,MAAM,MAAM,GAAG,SAAS,CAAC,CAAC;IAC9C,kFAAkF;IAClF,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,WAAW,KAAK,IAAI,CAAC;IACvC;;;;OAIG;IACH,SAAS,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,IAAI,CAAC;IACrC,YAAY,CAAC,EAAE,OAAO,CAAC,kBAAkB,CAAC,CAAC;IAC3C,QAAQ,EAAE,KAAK,CAAC,SAAS,CAAC;CAC3B;AAED;;;GAGG;AACH,wBAAgB,cAAc,CAAC,KAAK,EAAE,mBAAmB,qBAaxD;AA4FD,MAAM,WAAW,WAAW,CAAC,CAAC,SAAS,MAAM,GAAG,MAAM,EAAE;IACtD;;;OAGG;IACH,KAAK,EAAE,CAAC,CAAC;IACT,aAAa,EAAE,CAAC,KAAK,EAAE,CAAC,KAAK,IAAI,CAAC;IAClC,qFAAqF;IACrF,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,iGAAiG;IACjG,QAAQ,EAAE,KAAK,CAAC,YAAY,CAAC;IAC7B,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED;;;;;;;;;GASG;AACH,wBAAgB,MAAM,CAAC,CAAC,SAAS,MAAM,GAAG,MAAM,EAAE,EAAE,KAAK,EAAE,WAAW,CAAC,CAAC,CAAC,qBAOxE"}
package/dist/assist.js CHANGED
@@ -3,10 +3,10 @@ import { jsx as c, jsxs as E, Fragment as pe } from "react/jsx-runtime";
3
3
  import { ark as R } from "@ark-ui/react/factory";
4
4
  import { Undo2Icon as ye, XIcon as ve } from "lucide-react";
5
5
  import * as l from "react";
6
- import { cn as M, ButtonGroup as be, Suggestion as xe, Button as ke, Spinner as Ee, Suggestions as Se, Kbd as j } from "@kanzo-tech/ui";
6
+ import { cn as M, Kbd as j, ButtonGroup as be, Suggestion as xe, Button as ke, Spinner as Ee, Suggestions as Se } from "@kanzo-tech/ui";
7
7
  import { AiMark as U } from "./ai-mark.js";
8
- import { useCandidates as Y, useContinuation as Ce, MIN_CONTINUE_LENGTH as Re } from "./engine.js";
9
- import { candidates as Z, continuation as we } from "./proposals.js";
8
+ import { useContinuation as Ce, useCandidates as Y, MIN_CONTINUE_LENGTH as Re } from "./engine.js";
9
+ import { continuation as we, candidates as Z } from "./proposals.js";
10
10
  const Ne = {
11
11
  assist: "AI assist",
12
12
  accept: "Accept suggestion",
@@ -1 +1 @@
1
- {"version":3,"file":"assist.js","sources":["../src/assist.tsx"],"sourcesContent":["\"use client\";\n\nimport { ark } from \"@ark-ui/react/factory\";\nimport { Undo2Icon, XIcon } from \"lucide-react\";\nimport * as React from \"react\";\nimport type { LanguageModel } from \"@kanzo-tech/llm\";\nimport { Button, ButtonGroup, cn, Kbd, Spinner, Suggestion, Suggestions } from \"@kanzo-tech/ui\";\nimport { AiMark } from \"./ai-mark.js\";\nimport {\n type AssistEvent,\n MIN_CONTINUE_LENGTH,\n type Proposal,\n useCandidates,\n useContinuation,\n} from \"./engine.js\";\nimport { type FieldBrief, candidates, continuation } from \"./proposals.js\";\n\n// ── The words ────────────────────────────────────────────────────────────────\n\n/** Every word `Assist` draws or announces. English by default; a host sets them once, on the provider. */\nexport interface AssistTranslations {\n /** The ✨ at rest. */\n assist: string;\n /** The ✨ while a continuation is on offer — pressing it takes it. */\n accept: string;\n /** The ✨ while candidates are on offer — pressing it asks for others. */\n others: string;\n /** The ✨ right after a value was taken — pressing it puts the old one back. */\n revert: string;\n /** Beside the `Tab` key, under a continuation. */\n acceptKey: string;\n /** Beside the `Esc` key. */\n dismissKey: string;\n /** Beside `Alt ]`. */\n nextKey: string;\n /** Read once, by a screen reader, when a continuation lands. */\n announcement: string;\n /** While candidates are on their way. */\n thinking: string;\n /** When the model had nothing to offer. */\n empty: string;\n /** When asking failed and what was thrown carries no message of its own. */\n failed: string;\n /** A candidate's ✕, given the candidate's text. */\n dismiss: (text: string) => string;\n}\n\nconst ENGLISH: AssistTranslations = {\n assist: \"AI assist\",\n accept: \"Accept suggestion\",\n others: \"Suggest different values\",\n revert: \"Undo AI suggestion\",\n acceptKey: \"accept\",\n dismissKey: \"dismiss\",\n nextKey: \"next\",\n announcement: \"Suggestion ready. Press Tab to accept, Escape to dismiss.\",\n thinking: \"Thinking…\",\n empty: \"Nothing to suggest.\",\n failed: \"Couldn’t suggest anything.\",\n dismiss: (text) => `Dismiss ${text}`,\n};\n\n// ── The provider ─────────────────────────────────────────────────────────────\n\ninterface AssistSettings {\n model: LanguageModel;\n context?: () => string | undefined;\n onEvent?: (event: AssistEvent) => void;\n onFailure?: (error: unknown) => void;\n t: AssistTranslations;\n}\n\nconst Settings = React.createContext<AssistSettings | null>(null);\n\nexport interface AssistProviderProps {\n /** The model every assisted field below asks — `gateway(\"complete\")`. */\n model: LanguageModel;\n /**\n * What the rest of the form says, for every field below: read at the moment a field asks, so it\n * is never stale. A string, or a function returning one.\n */\n context?: string | (() => string | undefined);\n /** Each proposal's life — shown, accepted, partly accepted, rejected, ignored. */\n onEvent?: (event: AssistEvent) => void;\n /**\n * Called with what the model's stream threw, whole, when a field's ask fails — including an\n * `AiError` coded `ai/silent` when it sent nothing for 30 s. The field also says so under itself.\n */\n onFailure?: (error: unknown) => void;\n translations?: Partial<AssistTranslations>;\n children: React.ReactNode;\n}\n\n/**\n * The one place a product decides how its fields are assisted: which model, what the form as a\n * whole is about, where the telemetry goes, and in what words. Renders no element.\n */\nexport function AssistProvider(props: AssistProviderProps) {\n const { model, context, onEvent, onFailure, translations, children } = props;\n const value = React.useMemo<AssistSettings>(\n () => ({\n model,\n context: typeof context === \"function\" ? context : () => context,\n onEvent,\n onFailure,\n t: { ...ENGLISH, ...translations },\n }),\n [context, model, onEvent, onFailure, translations],\n );\n return <Settings.Provider value={value}>{children}</Settings.Provider>;\n}\n\n// ── Reading the field ────────────────────────────────────────────────────────\n\ntype Control = HTMLTextAreaElement | HTMLInputElement;\n\nconst textOf = (ids: string | null) =>\n (ids ?? \"\")\n .split(/\\s+/)\n .map((id) => (id ? document.getElementById(id)?.textContent?.trim() : \"\"))\n .filter(Boolean)\n .join(\" \");\n\n/**\n * What the field is, as a person filling it in is told: its accessible name and description. Ark's\n * `Field` wires the label and helper text to the control with `aria-labelledby`/`aria-describedby`,\n * so an accessible field needs nothing more — and a field without a name is a field a screen reader\n * cannot fill either.\n */\nfunction describe(el: Control | null, instructions: string | undefined, context: string | undefined): FieldBrief {\n if (!el) return { name: \"\", instructions, context };\n const name =\n textOf(el.getAttribute(\"aria-labelledby\")) ||\n [...(el.labels ?? [])].map((l) => l.textContent?.trim()).filter(Boolean).join(\" \") ||\n el.getAttribute(\"aria-label\") ||\n el.getAttribute(\"placeholder\") ||\n \"\";\n return { name, description: textOf(el.getAttribute(\"aria-describedby\")) || undefined, instructions, context };\n}\n\n// ── Geometry ─────────────────────────────────────────────────────────────────\n\nconst METRICS = [\n \"fontFamily\", \"fontSize\", \"fontWeight\", \"fontStyle\", \"letterSpacing\", \"lineHeight\",\n \"textTransform\", \"paddingTop\", \"paddingRight\", \"paddingBottom\", \"paddingLeft\",\n \"borderTopWidth\", \"borderRightWidth\", \"borderBottomWidth\", \"borderLeftWidth\",\n] as const;\n\nconst px = (v: string) => Number.parseFloat(v) || 0;\n\n/** The box `el` occupies inside `root`, as logical insets. */\nfunction insets(el: HTMLElement, root: HTMLElement, rtl: boolean) {\n const box = el.getBoundingClientRect();\n const outer = root.getBoundingClientRect();\n return {\n insetBlockStart: box.top - outer.top,\n insetBlockEnd: outer.bottom - box.bottom,\n insetInlineStart: rtl ? outer.right - box.right : box.left - outer.left,\n insetInlineEnd: rtl ? box.left - outer.left : outer.right - box.right,\n };\n}\n\n/** The ghost mirror wears the field's real metrics, so it aligns whatever the size or className. */\nfunction mirror(el: Control, root: HTMLElement) {\n const cs = getComputedStyle(el);\n const rtl = cs.direction === \"rtl\";\n // A scrolling textarea takes its scrollbar from the inline-end side in both directions.\n const gutter = el.offsetWidth - el.clientWidth - px(cs.borderLeftWidth) - px(cs.borderRightWidth);\n const box = insets(el, root, rtl);\n const metrics: Record<string, string | number> = {\n ...box,\n insetInlineEnd: box.insetInlineEnd + (gutter > 0 ? gutter : 0),\n borderStyle: \"solid\",\n borderColor: \"transparent\",\n };\n for (const k of METRICS) metrics[k] = cs[k as keyof CSSStyleDeclaration] as string;\n return { metrics: metrics as React.CSSProperties, rtl };\n}\n\n/** Room for the ✨ inside the control's box, so typed text never runs under it. */\nconst MARK_ROOM = \"2.25rem\";\n\n/** Leading space plus the next word — what one press of the accept-a-word key takes. */\nconst nextWord = (ghost: string) => /^\\s*\\S+/.exec(ghost)?.[0] ?? \"\";\n\nconst merge =\n <E,>(...handlers: (((e: E) => void) | undefined)[]) =>\n (e: E) => {\n for (const h of handlers) h?.(e);\n };\n\nconst mergeRefs =\n <T,>(...refs: (React.Ref<T> | undefined)[]) =>\n (node: T | null) => {\n for (const ref of refs) {\n if (typeof ref === \"function\") ref(node);\n else if (ref) (ref as React.RefObject<T | null>).current = node;\n }\n };\n\n// ── Assist ───────────────────────────────────────────────────────────────────\n\nexport interface AssistProps<V extends string | string[]> {\n /**\n * The field's value. A string is a text field — a `Textarea` gets a continuation at the caret, an\n * `Input` gets whole values to replace it with; a list is a tags field, and gets values to add.\n */\n value: V;\n onValueChange: (value: V) => void;\n /** One sentence for the model about this field, beyond its label and description. */\n instructions?: string;\n /** The control, from `@kanzo-tech/ui`: `Textarea`, `Input` or `TagsInput`. `Assist` wires it. */\n children: React.ReactElement;\n className?: string;\n}\n\n/**\n * A model-assisted field. Wrap the control; that is all.\n *\n * What it offers is one thing — a **proposal** for the field's value — drawn the way the control\n * calls for: a `Textarea` holds prose, so the proposal is a continuation drawn as ghost text at the\n * caret (Tab takes it, Ctrl/⌘+→ a word, Alt+] / Alt+[ the alternatives, Esc lets it go); a one-line\n * `Input` cannot show text beyond its width, so it gets whole values as a strip of candidates under\n * it; a `TagsInput` gets candidates to add. The ✨ inside the control marks it as assisted, asks,\n * takes, and — right after something was taken — puts the old value back.\n */\nexport function Assist<V extends string | string[]>(props: AssistProps<V>) {\n const settings = React.useContext(Settings);\n if (!settings) throw new Error(\"<Assist> must render inside <AssistProvider>\");\n if (Array.isArray(props.value)) {\n return <ListAssist {...(props as unknown as AssistProps<string[]>)} settings={settings} />;\n }\n return <TextAssist {...(props as unknown as AssistProps<string>)} settings={settings} />;\n}\n\ntype Inner<V extends string | string[]> = AssistProps<V> & { settings: AssistSettings };\n\n/** The control the field reads and writes — the child itself, or the input inside a compound. */\nconst controlIn = (root: HTMLElement | null) =>\n root?.querySelector<Control>(\"textarea, input:not([type=hidden])\") ?? null;\n\nfunction useEvents(settings: AssistSettings, control: () => Control | null) {\n return React.useCallback(\n (kind: AssistEvent[\"kind\"], proposal: Proposal) =>\n settings.onEvent?.({ kind, proposal, field: describe(control(), undefined, undefined).name }),\n [control, settings],\n );\n}\n\n// ── A text field: continuation (Textarea) or candidates (Input) ──────────────\n\nfunction TextAssist(props: Inner<string>) {\n const { value, onValueChange, instructions, children, className, settings } = props;\n const { t } = settings;\n const rootRef = React.useRef<HTMLDivElement>(null);\n const fieldRef = React.useRef<Control | null>(null);\n const [multiline, setMultiline] = React.useState(false);\n const [shape, setShape] = React.useState<{ metrics: React.CSSProperties; rtl: boolean; mark: React.CSSProperties }>(\n { metrics: {}, rtl: false, mark: {} },\n );\n const [caret, setCaret] = React.useState(value.length);\n const [active, setActive] = React.useState(false);\n /** The value before the last proposal was taken; the ✨ puts it back until the reader edits. */\n const [before, setBefore] = React.useState<string | null>(null);\n const landing = React.useRef<number | null>(null);\n\n const control = React.useCallback(() => fieldRef.current, []);\n const report = useEvents(settings, control);\n const brief = () => describe(fieldRef.current, instructions, settings.context?.());\n\n const completion = useContinuation({\n source: (request) => continuation(settings.model, brief(), request),\n onEvent: report,\n });\n const list = useCandidates({\n source: ({ signal }) => candidates(settings.model, brief(), { value, existing: [value], signal, count: 6, list: false }),\n existing: [value],\n onEvent: report,\n });\n useReported(settings, completion);\n useReported(settings, list);\n const ghost = multiline ? completion.ghost : \"\";\n\n React.useLayoutEffect(() => {\n const el = fieldRef.current;\n const root = rootRef.current;\n if (!el || !root) return;\n setMultiline(el instanceof HTMLTextAreaElement);\n el.style.paddingInlineEnd = MARK_ROOM;\n const sync = () => {\n const m = mirror(el, root);\n const box = insets(el, root, m.rtl);\n // Bottom-end in a textarea, where the eye lands after a paragraph; vertically centred in a\n // one-line field, as an input's own addons are.\n const mark: React.CSSProperties =\n el instanceof HTMLTextAreaElement\n ? { insetInlineEnd: box.insetInlineEnd + 4, insetBlockEnd: box.insetBlockEnd + 4 }\n : { insetInlineEnd: box.insetInlineEnd + 2, insetBlockStart: box.insetBlockStart, height: el.offsetHeight };\n setShape({ ...m, mark });\n };\n sync();\n if (typeof ResizeObserver === \"undefined\") return;\n const ro = new ResizeObserver(sync);\n ro.observe(el);\n ro.observe(root);\n return () => ro.disconnect();\n }, []);\n\n // Where the caret must land after WE changed the value; the browser puts it anywhere otherwise.\n React.useLayoutEffect(() => {\n const el = fieldRef.current;\n const at = landing.current;\n if (!el || at === null) return;\n landing.current = null;\n el.setSelectionRange(at, at);\n setCaret(at);\n }, [value]);\n\n /** A selection is not an insertion point, so an offer cannot survive one. */\n const syncCaret = React.useCallback(() => {\n const el = fieldRef.current;\n if (!el || landing.current !== null) return;\n const start = el.selectionStart ?? el.value.length;\n if ((el.selectionEnd ?? start) !== start) {\n completion.dismiss(\"ignored\");\n return;\n }\n setCaret(start);\n }, [completion]);\n\n React.useEffect(() => {\n const onSelectionChange = () => {\n if (document.activeElement === fieldRef.current) syncCaret();\n };\n document.addEventListener(\"selectionchange\", onSelectionChange);\n return () => document.removeEventListener(\"selectionchange\", onSelectionChange);\n }, [syncCaret]);\n\n const put = (next: string, at: number) => {\n landing.current = at;\n onValueChange(next);\n };\n\n const accept = () => {\n if (!ghost) return;\n setBefore(value);\n put(value.slice(0, caret) + ghost + value.slice(caret), caret + ghost.length);\n completion.settle(\"accepted\", ghost);\n fieldRef.current?.focus();\n };\n\n const acceptWord = () => {\n const word = nextWord(ghost);\n if (!word) return;\n const next = value.slice(0, caret) + word + value.slice(caret);\n put(next, caret + word.length);\n completion.settle(\"partial\", word);\n completion.setValue(next, caret + word.length);\n };\n\n const pick = (text: string) => {\n setBefore(value);\n put(text, text.length);\n list.take(text);\n };\n\n const revert = () => {\n if (before === null) return;\n put(before, before.length);\n setBefore(null);\n fieldRef.current?.focus();\n };\n\n const request = () => {\n const el = fieldRef.current;\n const at = el && el === document.activeElement ? (el.selectionStart ?? caret) : caret;\n setCaret(at);\n completion.ask(value, at);\n el?.focus();\n el?.setSelectionRange(at, at);\n };\n\n const onChange = (e: React.ChangeEvent<Control>) => {\n const el = e.target;\n const at = el.selectionStart ?? el.value.length;\n setCaret(at);\n setBefore(null);\n onValueChange(el.value);\n if (multiline) completion.setValue(el.value, at);\n };\n\n const onKeyDown = (e: React.KeyboardEvent<Control>) => {\n if (e.defaultPrevented) return;\n if (!multiline) {\n if (e.key === \"Escape\" && list.items.length > 0) list.cancel();\n return;\n }\n // Forward, not right: \"next word\" is mirrored under RTL.\n const forward = shape.rtl ? \"ArrowLeft\" : \"ArrowRight\";\n if (e.altKey && (e.key === \"]\" || e.key === \"[\" || e.code === \"BracketRight\" || e.code === \"BracketLeft\")) {\n e.preventDefault();\n completion.cycle(e.key === \"]\" || e.code === \"BracketRight\" ? 1 : -1);\n return;\n }\n if (!ghost) return;\n if (e.key === \"Tab\") {\n e.preventDefault();\n accept();\n } else if (e.key === forward && (e.ctrlKey || e.metaKey)) {\n e.preventDefault();\n acceptWord();\n } else if (e.key === \"Escape\") {\n completion.dismiss(\"rejected\");\n }\n };\n\n const child = children as React.ReactElement<Record<string, unknown>>;\n const own = child.props;\n const field = React.cloneElement(child, {\n value,\n onChange: merge(own.onChange as never, onChange),\n onKeyDown: merge(own.onKeyDown as never, onKeyDown),\n onClick: merge(own.onClick as never, syncCaret),\n onKeyUp: merge(own.onKeyUp as never, syncCaret),\n onSelect: merge(own.onSelect as never, syncCaret),\n ref: mergeRefs(own.ref as React.Ref<Control>, fieldRef),\n \"aria-keyshortcuts\": ghost ? \"Tab Escape Alt+]\" : undefined,\n });\n\n const offering = multiline ? ghost.length > 0 : list.items.length > 0;\n const busy = (multiline ? completion.status : list.status) === \"loading\" && !offering;\n const reverting = before !== null && !offering;\n const askable = !multiline || value.trim().length >= MIN_CONTINUE_LENGTH;\n\n return (\n <ark.div\n className={cn(\"relative flex w-full min-w-0 flex-col gap-2\", className)}\n data-slot=\"assist\"\n onBlur={(e) => {\n if (!e.currentTarget.contains(e.relatedTarget)) setActive(false);\n }}\n onFocus={() => setActive(true)}\n ref={rootRef}\n >\n {field}\n {multiline && <Ghost caret={caret} field={fieldRef} ghost={ghost} metrics={shape.metrics} value={value} />}\n <ark.span\n className={cn(\"absolute flex items-center\", shape.mark.insetInlineEnd === undefined && \"invisible\")}\n data-slot=\"assist-mark\"\n style={shape.mark}\n >\n {reverting ? (\n <AiMark label={t.revert} onClick={revert}>\n <Undo2Icon />\n </AiMark>\n ) : (\n <AiMark\n busy={busy}\n disabled={!offering && !askable}\n label={offering ? (multiline ? t.accept : t.others) : t.assist}\n offering={offering}\n onClick={multiline ? (offering ? accept : request) : list.press}\n />\n )}\n </ark.span>\n {multiline ? (\n <>\n {ghost && <Keys t={t} />}\n <span aria-live=\"polite\" className=\"sr-only\" data-slot=\"assist-status\">\n {ghost ? t.announcement : \"\"}\n </span>\n <Failure error={completion.error} status={completion.status} t={t} />\n </>\n ) : (\n active && <Candidates list={list} onPick={pick} t={t} />\n )}\n </ark.div>\n );\n}\n\n/** The continuation drawn where it lands, over the field; the field grows to hold it. */\nfunction Ghost(props: {\n field: React.RefObject<Control | null>;\n ghost: string;\n value: string;\n caret: number;\n metrics: React.CSSProperties;\n}) {\n const { field, ghost, value, caret, metrics } = props;\n const boxRef = React.useRef<HTMLDivElement>(null);\n\n // `rows` is a floor: while an offer is on the table the field takes the height it needs, and\n // gives it back when there is none — what a textarea does anyway when you type into it.\n React.useLayoutEffect(() => {\n const el = field.current;\n const box = boxRef.current;\n if (!el || !box) return;\n if (!ghost) {\n el.style.removeProperty(\"min-height\");\n return;\n }\n const cs = getComputedStyle(el);\n const needed = box.scrollHeight + px(cs.borderTopWidth) + px(cs.borderBottomWidth);\n if (needed > el.getBoundingClientRect().height + 1) el.style.setProperty(\"min-height\", `${needed}px`);\n }, [field, ghost, value]);\n\n React.useLayoutEffect(() => {\n const el = field.current;\n const box = boxRef.current;\n if (!el || !box) return;\n const sync = () => {\n box.scrollLeft = el.scrollLeft;\n box.scrollTop = el.scrollTop;\n };\n sync();\n el.addEventListener(\"scroll\", sync);\n return () => el.removeEventListener(\"scroll\", sync);\n }, [caret, field, ghost, value]);\n\n const at = Math.min(caret, value.length);\n return (\n <ark.div\n aria-hidden\n className={cn(\n \"pointer-events-none absolute overflow-hidden whitespace-pre-wrap break-words text-transparent\",\n !ghost && \"invisible\",\n )}\n data-slot=\"assist-ghost\"\n ref={boxRef}\n style={metrics}\n >\n <span className=\"invisible\">{value.slice(0, at)}</span>\n <span className=\"text-faint\">{ghost}</span>\n <span className=\"invisible\">{value.slice(at)}</span>\n </ark.div>\n );\n}\n\n/** The keys, while there is something to take: the visible twin of the screen-reader announcement. */\nfunction Keys({ t }: { t: AssistTranslations }) {\n return (\n <ark.span className=\"inline-flex flex-wrap items-center gap-3 text-muted-foreground text-xs\" data-slot=\"assist-keys\">\n <span className=\"inline-flex items-center gap-1 whitespace-nowrap\">\n <Kbd>Tab</Kbd> {t.acceptKey}\n </span>\n <span className=\"inline-flex items-center gap-1 whitespace-nowrap\">\n <Kbd>Alt ]</Kbd> {t.nextKey}\n </span>\n <span className=\"inline-flex items-center gap-1 whitespace-nowrap\">\n <Kbd>Esc</Kbd> {t.dismissKey}\n </span>\n </ark.span>\n );\n}\n\n/** Hands the provider's `onFailure` each failure once, whether or not the field is showing it. */\nfunction useReported(settings: AssistSettings, stream: { status: string; error: unknown }) {\n const { onFailure } = settings;\n const { status, error } = stream;\n React.useEffect(() => {\n if (status === \"error\") onFailure?.(error);\n }, [status, error, onFailure]);\n}\n\nfunction Failure(props: { error: unknown; status: string; t: AssistTranslations }) {\n const { error, status, t } = props;\n if (status !== \"error\") return null;\n return (\n <ark.p className=\"text-destructive-foreground text-sm\" data-slot=\"assist-error\" role=\"alert\">\n {error instanceof Error && error.message ? error.message : t.failed}\n </ark.p>\n );\n}\n\n/**\n * The strip, in the flow under the field, while it has focus. Two buttons per candidate in a group —\n * a `<button>` cannot hold a button — and the ✕ always drawn: a hover-only control is unreachable by\n * keyboard and absent on touch. The rationale is the pill's `title`: it reaches the keyboard through\n * focus and costs no box.\n */\nfunction Candidates(props: {\n list: ReturnType<typeof useCandidates>;\n onPick: (text: string) => void;\n t: AssistTranslations;\n}) {\n const { list, onPick, t } = props;\n const body =\n list.status === \"error\" ? (\n <Failure error={list.error} status={list.status} t={t} />\n ) : list.items.length > 0 ? (\n list.items.map((item) => (\n <ButtonGroup aria-label={item.text} key={item.text} slot=\"assist-candidate\">\n <Suggestion onSelect={onPick} title={item.rationale} value={item.text}>\n {item.text}\n </Suggestion>\n <Button\n aria-label={t.dismiss(item.text)}\n className=\"h-auto min-h-[24px] self-stretch px-2 py-1\"\n onClick={() => list.dismiss(item.text)}\n pill\n size=\"sm\"\n slot=\"assist-dismiss\"\n variant=\"outline\"\n >\n <XIcon />\n </Button>\n </ButtonGroup>\n ))\n ) : list.status === \"loading\" ? (\n <span className=\"inline-flex items-center gap-2 text-muted-foreground text-sm\" data-slot=\"assist-pending\">\n <Spinner aria-hidden />\n {t.thinking}\n </span>\n ) : list.status === \"ready\" ? (\n <span className=\"text-muted-foreground text-sm\" data-slot=\"assist-empty\">\n {t.empty}\n </span>\n ) : null;\n\n if (body === null) return null;\n return (\n <Suggestions className=\"min-h-8\" slot=\"assist-candidates\">\n {body}\n </Suggestions>\n );\n}\n\n// ── A list field: candidates to add ──────────────────────────────────────────\n\nfunction ListAssist(props: Inner<string[]>) {\n const { value, onValueChange, instructions, children, className, settings } = props;\n const { t } = settings;\n const rootRef = React.useRef<HTMLDivElement>(null);\n const [active, setActive] = React.useState(false);\n const [mark, setMark] = React.useState<React.CSSProperties>({});\n\n const control = React.useCallback(() => controlIn(rootRef.current), []);\n const report = useEvents(settings, control);\n const list = useCandidates({\n source: ({ signal }) =>\n candidates(settings.model, describe(control(), instructions, settings.context?.()), {\n value: \"\",\n existing: value,\n signal,\n count: 6,\n list: true,\n }),\n existing: value,\n onEvent: report,\n });\n useReported(settings, list);\n\n // The ✨ sits at the end of the control's own box — Ark marks it `data-part=\"control\"`.\n React.useLayoutEffect(() => {\n const root = rootRef.current;\n const box = root?.querySelector<HTMLElement>(\"[data-part=control]\") ?? (root?.firstElementChild as HTMLElement | null);\n if (!root || !box) return;\n box.style.paddingInlineEnd = MARK_ROOM;\n const sync = () => {\n const rtl = getComputedStyle(box).direction === \"rtl\";\n const b = insets(box, root, rtl);\n setMark({ insetInlineEnd: b.insetInlineEnd + 2, insetBlockStart: b.insetBlockStart, height: box.offsetHeight });\n };\n sync();\n if (typeof ResizeObserver === \"undefined\") return;\n const ro = new ResizeObserver(sync);\n ro.observe(box);\n return () => ro.disconnect();\n }, []);\n\n const child = children as React.ReactElement<Record<string, unknown>>;\n const own = child.props;\n const field = React.cloneElement(child, {\n value,\n onValueChange: merge(own.onValueChange as never, (details: { value: string[] }) => onValueChange(details.value)),\n });\n\n const offering = list.items.length > 0;\n return (\n <ark.div\n className={cn(\"relative flex w-full min-w-0 flex-col gap-2\", className)}\n data-slot=\"assist\"\n onBlur={(e) => {\n if (!e.currentTarget.contains(e.relatedTarget)) setActive(false);\n }}\n onFocus={() => setActive(true)}\n ref={rootRef}\n >\n {field}\n <ark.span\n className={cn(\"absolute flex items-center\", mark.insetInlineEnd === undefined && \"invisible\")}\n data-slot=\"assist-mark\"\n style={mark}\n >\n <AiMark\n busy={list.status === \"loading\" && !offering}\n label={offering ? t.others : t.assist}\n offering={offering}\n onClick={list.press}\n />\n </ark.span>\n {active && (\n <Candidates\n list={list}\n onPick={(text) => {\n onValueChange([...value, text]);\n list.take(text);\n }}\n t={t}\n />\n )}\n </ark.div>\n );\n}\n"],"names":[],"mappings":";;;;;;;;;AA+CA;AAAoC;AAC1B;AACA;AACA;AACA;AACG;AACC;AACH;AACK;AACJ;AACH;AACC;AAEV;AAqCO;AACL;AACoB;AACX;AACL;AACyD;AACzD;AACA;AACoB;AAAa;AAEc;AAEnD;AACF;AAMA;;AAGkB;AAAwD;AAU1E;AACE;;AAGoC;AAAe;AAKrD;AAIA;AAAgB;AACd;AAAc;AAAY;AAAc;AAAa;AAAiB;AACtE;AAAiB;AAAc;AAAgB;AAAiB;AAChE;AAAkB;AAAoB;AACxC;AAKA;AACE;AAEA;AAAO;AAC4B;AACC;AACiC;AACH;AAEpE;AAGA;AACE;AAKiD;AAC5C;AACyD;AAC/C;AACA;AAEf;AACA;AACF;AAGA;;AAGoC;AAA8B;AAK9D;AACF;AAKE;AACE;AAGJ;AA4BK;AACL;AACA;AACA;AAIF;AAKA;AAGA;AACE;AAAa;;AAET;;;AACgB;AAEtB;AAIA;AACE;AAKgC;AACI;;AAUhB;AAA6D;AAE9C;AACiC;AACzD;AAEgB;AAC8F;AACvG;AACP;AAEX;AAEA;AAEA;AACE;AAEA;AACA;AAEA;AACE;AAQA;AAAuB;AAGzB;AACA;AACA;AAEgB;AAKhB;AAEA;AAGW;AAIb;AACE;AACA;AACA;AACA;AACE;AACA;AAAA;AAEF;AAAc;AAGhB;AACE;AACE;AAAiD;AAEnD;AAC8E;AAGhF;AACE;AACkB;;AAIlB;AAIkB;AAIlB;AACA;AACA;AACA;AAE6C;AAI7C;AAEc;;AAId;AAGkB;AAIlB;AAEA;AAG0B;AAI1B;AAEA;AAG+C;AAI/C;AACA;AACE;AACA;AAAA;AAGF;AACA;AACE;AAEA;AAAA;AAEF;AAQ+B;AAMO;AACtC;AAC+C;AACG;AACJ;AACA;AACE;AACM;AACJ;AAQpD;AACE;AAAK;AAAJ;AACuE;AAC5D;AAER;AAA+D;AACjE;AAC6B;AACxB;AAEJ;AAAA;AACuG;AACxG;AAAK;AAAJ;AACmG;AACxF;AACG;AAOX;AAAC;AAAA;AACC;AACwB;AACgC;AACxD;AAC0D;AAAA;AAAA;AAC5D;AAAA;AAKC;AAAqB;AAGtB;AACmE;AAGf;AAAA;AAAA;AAI9D;AAGA;AAOE;AAKA;AACE;AAEA;AACA;AACE;AACA;AAAA;AAEF;AAEA;AAAoG;AAIpG;AAEA;AACA;AACE;AACmB;AAErB;AAEkD;AAGpD;AACA;AACE;AAAK;AAAJ;AACY;AACA;AACT;AACU;AAAA;AAEF;AACL;AACE;AAEP;AAAgD;AACZ;AACS;AAAA;AAAA;AAGnD;AAGA;AACE;AAEI;AACE;AAAQ;AAAM;AAAI;AACpB;AAEE;AAAU;AAAM;AAAI;AACtB;AAEE;AAAQ;AAAM;AAAI;AACpB;AAGN;AAGA;AACE;AAEA;AACE;AAAoC;AAExC;AAEA;AACE;AACA;AAMF;AAQA;AAKE;AAOQ;AAEA;AACA;AAAC;AAAA;AACgC;AACrB;AAC2B;AACjC;AACC;AACA;AACG;AAED;AAAA;AAAA;AAMX;AAAqB;AAClB;AAQT;AAMF;AAIA;AACE;AAQ2B;;AAEvB;AAAoF;AAC3E;AACG;AACV;AACO;AACD;AACP;AAAA;AACO;AACD;AAEX;AAIE;AAEA;AACA;AACA;AACE;AAEA;AAA8G;AAGhH;AACA;AACA;AACgB;AAGlB;AAEwC;AACtC;AAC+G;AAIjH;AACE;AAAK;AAAJ;AACuE;AAC5D;AAER;AAA+D;AACjE;AAC6B;AACxB;AAEJ;AAAA;AACD;AAAK;AAAJ;AAC6F;AAClF;AACH;AAEP;AAAC;AAAA;AACqC;AACL;AAC/B;AACc;AAAA;AAAA;AAChB;AAAA;AAGA;AAAC;AAAA;AACC;AAEE;AACc;AAChB;AACA;AAAA;AAAA;AACF;AAAA;AAIR;;;;;"}
1
+ {"version":3,"file":"assist.js","sources":["../src/assist.tsx"],"sourcesContent":["\"use client\";\n\nimport { ark } from \"@ark-ui/react/factory\";\nimport { Undo2Icon, XIcon } from \"lucide-react\";\nimport * as React from \"react\";\nimport type { LanguageModel } from \"@kanzo-tech/llm\";\nimport { Button, ButtonGroup, cn, Kbd, Spinner, Suggestion, Suggestions } from \"@kanzo-tech/ui\";\nimport { AiMark } from \"./ai-mark.js\";\nimport {\n type AssistEvent,\n MIN_CONTINUE_LENGTH,\n type Proposal,\n useCandidates,\n useContinuation,\n} from \"./engine.js\";\nimport { type FieldBrief, candidates, continuation } from \"./proposals.js\";\n\n// ── The words ────────────────────────────────────────────────────────────────\n\n/** Every word `Assist` draws or announces. English by default; a host sets them once, on the provider. */\nexport interface AssistTranslations {\n /** The ✨ at rest. */\n assist: string;\n /** The ✨ while a continuation is on offer — pressing it takes it. */\n accept: string;\n /** The ✨ while candidates are on offer — pressing it asks for others. */\n others: string;\n /** The ✨ right after a value was taken — pressing it puts the old one back. */\n revert: string;\n /** Beside the `Tab` key, under a continuation. */\n acceptKey: string;\n /** Beside the `Esc` key. */\n dismissKey: string;\n /** Beside `Alt ]`. */\n nextKey: string;\n /** Read once, by a screen reader, when a continuation lands. */\n announcement: string;\n /** While candidates are on their way. */\n thinking: string;\n /** When the model had nothing to offer. */\n empty: string;\n /** When asking failed and what was thrown carries no message of its own. */\n failed: string;\n /** A candidate's ✕, given the candidate's text. */\n dismiss: (text: string) => string;\n}\n\nconst ENGLISH: AssistTranslations = {\n assist: \"AI assist\",\n accept: \"Accept suggestion\",\n others: \"Suggest different values\",\n revert: \"Undo AI suggestion\",\n acceptKey: \"accept\",\n dismissKey: \"dismiss\",\n nextKey: \"next\",\n announcement: \"Suggestion ready. Press Tab to accept, Escape to dismiss.\",\n thinking: \"Thinking…\",\n empty: \"Nothing to suggest.\",\n failed: \"Couldn’t suggest anything.\",\n dismiss: (text) => `Dismiss ${text}`,\n};\n\n// ── The provider ─────────────────────────────────────────────────────────────\n\ninterface AssistSettings {\n model: LanguageModel;\n context?: () => string | undefined;\n onEvent?: (event: AssistEvent) => void;\n onFailure?: (error: unknown) => void;\n t: AssistTranslations;\n}\n\nconst Settings = React.createContext<AssistSettings | null>(null);\n\nexport interface AssistProviderProps {\n /** The model every assisted field below asks — `gateway(\"complete\")`. */\n model: LanguageModel;\n /**\n * What the rest of the form says, for every field below: read at the moment a field asks, so it\n * is never stale. A string, or a function returning one.\n */\n context?: string | (() => string | undefined);\n /** Each proposal's life — shown, accepted, partly accepted, rejected, ignored. */\n onEvent?: (event: AssistEvent) => void;\n /**\n * Called with what the model's stream threw, whole, when a field's ask fails — on the first\n * attempt, never retried. A model from `createGateway` that goes silent arrives as its `AiError`\n * coded `ai/silent`. The field also says so under itself.\n */\n onFailure?: (error: unknown) => void;\n translations?: Partial<AssistTranslations>;\n children: React.ReactNode;\n}\n\n/**\n * The one place a product decides how its fields are assisted: which model, what the form as a\n * whole is about, where the telemetry goes, and in what words. Renders no element.\n */\nexport function AssistProvider(props: AssistProviderProps) {\n const { model, context, onEvent, onFailure, translations, children } = props;\n const value = React.useMemo<AssistSettings>(\n () => ({\n model,\n context: typeof context === \"function\" ? context : () => context,\n onEvent,\n onFailure,\n t: { ...ENGLISH, ...translations },\n }),\n [context, model, onEvent, onFailure, translations],\n );\n return <Settings.Provider value={value}>{children}</Settings.Provider>;\n}\n\n// ── Reading the field ────────────────────────────────────────────────────────\n\ntype Control = HTMLTextAreaElement | HTMLInputElement;\n\nconst textOf = (ids: string | null) =>\n (ids ?? \"\")\n .split(/\\s+/)\n .map((id) => (id ? document.getElementById(id)?.textContent?.trim() : \"\"))\n .filter(Boolean)\n .join(\" \");\n\n/**\n * What the field is, as a person filling it in is told: its accessible name and description. Ark's\n * `Field` wires the label and helper text to the control with `aria-labelledby`/`aria-describedby`,\n * so an accessible field needs nothing more — and a field without a name is a field a screen reader\n * cannot fill either.\n */\nfunction describe(el: Control | null, instructions: string | undefined, context: string | undefined): FieldBrief {\n if (!el) return { name: \"\", instructions, context };\n const name =\n textOf(el.getAttribute(\"aria-labelledby\")) ||\n [...(el.labels ?? [])].map((l) => l.textContent?.trim()).filter(Boolean).join(\" \") ||\n el.getAttribute(\"aria-label\") ||\n el.getAttribute(\"placeholder\") ||\n \"\";\n return { name, description: textOf(el.getAttribute(\"aria-describedby\")) || undefined, instructions, context };\n}\n\n// ── Geometry ─────────────────────────────────────────────────────────────────\n\nconst METRICS = [\n \"fontFamily\", \"fontSize\", \"fontWeight\", \"fontStyle\", \"letterSpacing\", \"lineHeight\",\n \"textTransform\", \"paddingTop\", \"paddingRight\", \"paddingBottom\", \"paddingLeft\",\n \"borderTopWidth\", \"borderRightWidth\", \"borderBottomWidth\", \"borderLeftWidth\",\n] as const;\n\nconst px = (v: string) => Number.parseFloat(v) || 0;\n\n/** The box `el` occupies inside `root`, as logical insets. */\nfunction insets(el: HTMLElement, root: HTMLElement, rtl: boolean) {\n const box = el.getBoundingClientRect();\n const outer = root.getBoundingClientRect();\n return {\n insetBlockStart: box.top - outer.top,\n insetBlockEnd: outer.bottom - box.bottom,\n insetInlineStart: rtl ? outer.right - box.right : box.left - outer.left,\n insetInlineEnd: rtl ? box.left - outer.left : outer.right - box.right,\n };\n}\n\n/** The ghost mirror wears the field's real metrics, so it aligns whatever the size or className. */\nfunction mirror(el: Control, root: HTMLElement) {\n const cs = getComputedStyle(el);\n const rtl = cs.direction === \"rtl\";\n // A scrolling textarea takes its scrollbar from the inline-end side in both directions.\n const gutter = el.offsetWidth - el.clientWidth - px(cs.borderLeftWidth) - px(cs.borderRightWidth);\n const box = insets(el, root, rtl);\n const metrics: Record<string, string | number> = {\n ...box,\n insetInlineEnd: box.insetInlineEnd + (gutter > 0 ? gutter : 0),\n borderStyle: \"solid\",\n borderColor: \"transparent\",\n };\n for (const k of METRICS) metrics[k] = cs[k as keyof CSSStyleDeclaration] as string;\n return { metrics: metrics as React.CSSProperties, rtl };\n}\n\n/** Room for the ✨ inside the control's box, so typed text never runs under it. */\nconst MARK_ROOM = \"2.25rem\";\n\n/** Leading space plus the next word — what one press of the accept-a-word key takes. */\nconst nextWord = (ghost: string) => /^\\s*\\S+/.exec(ghost)?.[0] ?? \"\";\n\nconst merge =\n <E,>(...handlers: (((e: E) => void) | undefined)[]) =>\n (e: E) => {\n for (const h of handlers) h?.(e);\n };\n\nconst mergeRefs =\n <T,>(...refs: (React.Ref<T> | undefined)[]) =>\n (node: T | null) => {\n for (const ref of refs) {\n if (typeof ref === \"function\") ref(node);\n else if (ref) (ref as React.RefObject<T | null>).current = node;\n }\n };\n\n// ── Assist ───────────────────────────────────────────────────────────────────\n\nexport interface AssistProps<V extends string | string[]> {\n /**\n * The field's value. A string is a text field — a `Textarea` gets a continuation at the caret, an\n * `Input` gets whole values to replace it with; a list is a tags field, and gets values to add.\n */\n value: V;\n onValueChange: (value: V) => void;\n /** One sentence for the model about this field, beyond its label and description. */\n instructions?: string;\n /** The control, from `@kanzo-tech/ui`: `Textarea`, `Input` or `TagsInput`. `Assist` wires it. */\n children: React.ReactElement;\n className?: string;\n}\n\n/**\n * A model-assisted field. Wrap the control; that is all.\n *\n * What it offers is one thing — a **proposal** for the field's value — drawn the way the control\n * calls for: a `Textarea` holds prose, so the proposal is a continuation drawn as ghost text at the\n * caret (Tab takes it, Ctrl/⌘+→ a word, Alt+] / Alt+[ the alternatives, Esc lets it go); a one-line\n * `Input` cannot show text beyond its width, so it gets whole values as a strip of candidates under\n * it; a `TagsInput` gets candidates to add. The ✨ inside the control marks it as assisted, asks,\n * takes, and — right after something was taken — puts the old value back.\n */\nexport function Assist<V extends string | string[]>(props: AssistProps<V>) {\n const settings = React.useContext(Settings);\n if (!settings) throw new Error(\"<Assist> must render inside <AssistProvider>\");\n if (Array.isArray(props.value)) {\n return <ListAssist {...(props as unknown as AssistProps<string[]>)} settings={settings} />;\n }\n return <TextAssist {...(props as unknown as AssistProps<string>)} settings={settings} />;\n}\n\ntype Inner<V extends string | string[]> = AssistProps<V> & { settings: AssistSettings };\n\n/** The control the field reads and writes — the child itself, or the input inside a compound. */\nconst controlIn = (root: HTMLElement | null) =>\n root?.querySelector<Control>(\"textarea, input:not([type=hidden])\") ?? null;\n\nfunction useEvents(settings: AssistSettings, control: () => Control | null) {\n return React.useCallback(\n (kind: AssistEvent[\"kind\"], proposal: Proposal) =>\n settings.onEvent?.({ kind, proposal, field: describe(control(), undefined, undefined).name }),\n [control, settings],\n );\n}\n\n// ── A text field: continuation (Textarea) or candidates (Input) ──────────────\n\nfunction TextAssist(props: Inner<string>) {\n const { value, onValueChange, instructions, children, className, settings } = props;\n const { t } = settings;\n const rootRef = React.useRef<HTMLDivElement>(null);\n const fieldRef = React.useRef<Control | null>(null);\n const [multiline, setMultiline] = React.useState(false);\n const [shape, setShape] = React.useState<{ metrics: React.CSSProperties; rtl: boolean; mark: React.CSSProperties }>(\n { metrics: {}, rtl: false, mark: {} },\n );\n const [caret, setCaret] = React.useState(value.length);\n const [active, setActive] = React.useState(false);\n /** The value before the last proposal was taken; the ✨ puts it back until the reader edits. */\n const [before, setBefore] = React.useState<string | null>(null);\n const landing = React.useRef<number | null>(null);\n\n const control = React.useCallback(() => fieldRef.current, []);\n const report = useEvents(settings, control);\n const brief = () => describe(fieldRef.current, instructions, settings.context?.());\n\n const completion = useContinuation({\n source: (request) => continuation(settings.model, brief(), request),\n onEvent: report,\n });\n const list = useCandidates({\n source: ({ signal }) => candidates(settings.model, brief(), { value, existing: [value], signal, count: 6, list: false }),\n existing: [value],\n onEvent: report,\n });\n useReported(settings, completion);\n useReported(settings, list);\n const ghost = multiline ? completion.ghost : \"\";\n\n React.useLayoutEffect(() => {\n const el = fieldRef.current;\n const root = rootRef.current;\n if (!el || !root) return;\n setMultiline(el instanceof HTMLTextAreaElement);\n el.style.paddingInlineEnd = MARK_ROOM;\n const sync = () => {\n const m = mirror(el, root);\n const box = insets(el, root, m.rtl);\n // Bottom-end in a textarea, where the eye lands after a paragraph; vertically centred in a\n // one-line field, as an input's own addons are.\n const mark: React.CSSProperties =\n el instanceof HTMLTextAreaElement\n ? { insetInlineEnd: box.insetInlineEnd + 4, insetBlockEnd: box.insetBlockEnd + 4 }\n : { insetInlineEnd: box.insetInlineEnd + 2, insetBlockStart: box.insetBlockStart, height: el.offsetHeight };\n setShape({ ...m, mark });\n };\n sync();\n if (typeof ResizeObserver === \"undefined\") return;\n const ro = new ResizeObserver(sync);\n ro.observe(el);\n ro.observe(root);\n return () => ro.disconnect();\n }, []);\n\n // Where the caret must land after WE changed the value; the browser puts it anywhere otherwise.\n React.useLayoutEffect(() => {\n const el = fieldRef.current;\n const at = landing.current;\n if (!el || at === null) return;\n landing.current = null;\n el.setSelectionRange(at, at);\n setCaret(at);\n }, [value]);\n\n /** A selection is not an insertion point, so an offer cannot survive one. */\n const syncCaret = React.useCallback(() => {\n const el = fieldRef.current;\n if (!el || landing.current !== null) return;\n const start = el.selectionStart ?? el.value.length;\n if ((el.selectionEnd ?? start) !== start) {\n completion.dismiss(\"ignored\");\n return;\n }\n setCaret(start);\n }, [completion]);\n\n React.useEffect(() => {\n const onSelectionChange = () => {\n if (document.activeElement === fieldRef.current) syncCaret();\n };\n document.addEventListener(\"selectionchange\", onSelectionChange);\n return () => document.removeEventListener(\"selectionchange\", onSelectionChange);\n }, [syncCaret]);\n\n const put = (next: string, at: number) => {\n landing.current = at;\n onValueChange(next);\n };\n\n const accept = () => {\n if (!ghost) return;\n setBefore(value);\n put(value.slice(0, caret) + ghost + value.slice(caret), caret + ghost.length);\n completion.settle(\"accepted\", ghost);\n fieldRef.current?.focus();\n };\n\n const acceptWord = () => {\n const word = nextWord(ghost);\n if (!word) return;\n const next = value.slice(0, caret) + word + value.slice(caret);\n put(next, caret + word.length);\n completion.settle(\"partial\", word);\n completion.setValue(next, caret + word.length);\n };\n\n const pick = (text: string) => {\n setBefore(value);\n put(text, text.length);\n list.take(text);\n };\n\n const revert = () => {\n if (before === null) return;\n put(before, before.length);\n setBefore(null);\n fieldRef.current?.focus();\n };\n\n const request = () => {\n const el = fieldRef.current;\n const at = el && el === document.activeElement ? (el.selectionStart ?? caret) : caret;\n setCaret(at);\n completion.ask(value, at);\n el?.focus();\n el?.setSelectionRange(at, at);\n };\n\n const onChange = (e: React.ChangeEvent<Control>) => {\n const el = e.target;\n const at = el.selectionStart ?? el.value.length;\n setCaret(at);\n setBefore(null);\n onValueChange(el.value);\n if (multiline) completion.setValue(el.value, at);\n };\n\n const onKeyDown = (e: React.KeyboardEvent<Control>) => {\n if (e.defaultPrevented) return;\n if (!multiline) {\n if (e.key === \"Escape\" && list.items.length > 0) list.cancel();\n return;\n }\n // Forward, not right: \"next word\" is mirrored under RTL.\n const forward = shape.rtl ? \"ArrowLeft\" : \"ArrowRight\";\n if (e.altKey && (e.key === \"]\" || e.key === \"[\" || e.code === \"BracketRight\" || e.code === \"BracketLeft\")) {\n e.preventDefault();\n completion.cycle(e.key === \"]\" || e.code === \"BracketRight\" ? 1 : -1);\n return;\n }\n if (!ghost) return;\n if (e.key === \"Tab\") {\n e.preventDefault();\n accept();\n } else if (e.key === forward && (e.ctrlKey || e.metaKey)) {\n e.preventDefault();\n acceptWord();\n } else if (e.key === \"Escape\") {\n completion.dismiss(\"rejected\");\n }\n };\n\n const child = children as React.ReactElement<Record<string, unknown>>;\n const own = child.props;\n const field = React.cloneElement(child, {\n value,\n onChange: merge(own.onChange as never, onChange),\n onKeyDown: merge(own.onKeyDown as never, onKeyDown),\n onClick: merge(own.onClick as never, syncCaret),\n onKeyUp: merge(own.onKeyUp as never, syncCaret),\n onSelect: merge(own.onSelect as never, syncCaret),\n ref: mergeRefs(own.ref as React.Ref<Control>, fieldRef),\n \"aria-keyshortcuts\": ghost ? \"Tab Escape Alt+]\" : undefined,\n });\n\n const offering = multiline ? ghost.length > 0 : list.items.length > 0;\n const busy = (multiline ? completion.status : list.status) === \"loading\" && !offering;\n const reverting = before !== null && !offering;\n const askable = !multiline || value.trim().length >= MIN_CONTINUE_LENGTH;\n\n return (\n <ark.div\n className={cn(\"relative flex w-full min-w-0 flex-col gap-2\", className)}\n data-slot=\"assist\"\n onBlur={(e) => {\n if (!e.currentTarget.contains(e.relatedTarget)) setActive(false);\n }}\n onFocus={() => setActive(true)}\n ref={rootRef}\n >\n {field}\n {multiline && <Ghost caret={caret} field={fieldRef} ghost={ghost} metrics={shape.metrics} value={value} />}\n <ark.span\n className={cn(\"absolute flex items-center\", shape.mark.insetInlineEnd === undefined && \"invisible\")}\n data-slot=\"assist-mark\"\n style={shape.mark}\n >\n {reverting ? (\n <AiMark label={t.revert} onClick={revert}>\n <Undo2Icon />\n </AiMark>\n ) : (\n <AiMark\n busy={busy}\n disabled={!offering && !askable}\n label={offering ? (multiline ? t.accept : t.others) : t.assist}\n offering={offering}\n onClick={multiline ? (offering ? accept : request) : list.press}\n />\n )}\n </ark.span>\n {multiline ? (\n <>\n {ghost && <Keys t={t} />}\n <span aria-live=\"polite\" className=\"sr-only\" data-slot=\"assist-status\">\n {ghost ? t.announcement : \"\"}\n </span>\n <Failure error={completion.error} status={completion.status} t={t} />\n </>\n ) : (\n active && <Candidates list={list} onPick={pick} t={t} />\n )}\n </ark.div>\n );\n}\n\n/** The continuation drawn where it lands, over the field; the field grows to hold it. */\nfunction Ghost(props: {\n field: React.RefObject<Control | null>;\n ghost: string;\n value: string;\n caret: number;\n metrics: React.CSSProperties;\n}) {\n const { field, ghost, value, caret, metrics } = props;\n const boxRef = React.useRef<HTMLDivElement>(null);\n\n // `rows` is a floor: while an offer is on the table the field takes the height it needs, and\n // gives it back when there is none — what a textarea does anyway when you type into it.\n React.useLayoutEffect(() => {\n const el = field.current;\n const box = boxRef.current;\n if (!el || !box) return;\n if (!ghost) {\n el.style.removeProperty(\"min-height\");\n return;\n }\n const cs = getComputedStyle(el);\n const needed = box.scrollHeight + px(cs.borderTopWidth) + px(cs.borderBottomWidth);\n if (needed > el.getBoundingClientRect().height + 1) el.style.setProperty(\"min-height\", `${needed}px`);\n }, [field, ghost, value]);\n\n React.useLayoutEffect(() => {\n const el = field.current;\n const box = boxRef.current;\n if (!el || !box) return;\n const sync = () => {\n box.scrollLeft = el.scrollLeft;\n box.scrollTop = el.scrollTop;\n };\n sync();\n el.addEventListener(\"scroll\", sync);\n return () => el.removeEventListener(\"scroll\", sync);\n }, [caret, field, ghost, value]);\n\n const at = Math.min(caret, value.length);\n return (\n <ark.div\n aria-hidden\n className={cn(\n \"pointer-events-none absolute overflow-hidden whitespace-pre-wrap break-words text-transparent\",\n !ghost && \"invisible\",\n )}\n data-slot=\"assist-ghost\"\n ref={boxRef}\n style={metrics}\n >\n <span className=\"invisible\">{value.slice(0, at)}</span>\n <span className=\"text-faint\">{ghost}</span>\n <span className=\"invisible\">{value.slice(at)}</span>\n </ark.div>\n );\n}\n\n/** The keys, while there is something to take: the visible twin of the screen-reader announcement. */\nfunction Keys({ t }: { t: AssistTranslations }) {\n return (\n <ark.span className=\"inline-flex flex-wrap items-center gap-3 text-muted-foreground text-xs\" data-slot=\"assist-keys\">\n <span className=\"inline-flex items-center gap-1 whitespace-nowrap\">\n <Kbd>Tab</Kbd> {t.acceptKey}\n </span>\n <span className=\"inline-flex items-center gap-1 whitespace-nowrap\">\n <Kbd>Alt ]</Kbd> {t.nextKey}\n </span>\n <span className=\"inline-flex items-center gap-1 whitespace-nowrap\">\n <Kbd>Esc</Kbd> {t.dismissKey}\n </span>\n </ark.span>\n );\n}\n\n/** Hands the provider's `onFailure` each failure once, whether or not the field is showing it. */\nfunction useReported(settings: AssistSettings, stream: { status: string; error: unknown }) {\n const { onFailure } = settings;\n const { status, error } = stream;\n React.useEffect(() => {\n if (status === \"error\") onFailure?.(error);\n }, [status, error, onFailure]);\n}\n\nfunction Failure(props: { error: unknown; status: string; t: AssistTranslations }) {\n const { error, status, t } = props;\n if (status !== \"error\") return null;\n return (\n <ark.p className=\"text-destructive-foreground text-sm\" data-slot=\"assist-error\" role=\"alert\">\n {error instanceof Error && error.message ? error.message : t.failed}\n </ark.p>\n );\n}\n\n/**\n * The strip, in the flow under the field, while it has focus. Two buttons per candidate in a group —\n * a `<button>` cannot hold a button — and the ✕ always drawn: a hover-only control is unreachable by\n * keyboard and absent on touch. The rationale is the pill's `title`: it reaches the keyboard through\n * focus and costs no box.\n */\nfunction Candidates(props: {\n list: ReturnType<typeof useCandidates>;\n onPick: (text: string) => void;\n t: AssistTranslations;\n}) {\n const { list, onPick, t } = props;\n const body =\n list.status === \"error\" ? (\n <Failure error={list.error} status={list.status} t={t} />\n ) : list.items.length > 0 ? (\n list.items.map((item) => (\n <ButtonGroup aria-label={item.text} key={item.text} slot=\"assist-candidate\">\n <Suggestion onSelect={onPick} title={item.rationale} value={item.text}>\n {item.text}\n </Suggestion>\n <Button\n aria-label={t.dismiss(item.text)}\n className=\"h-auto min-h-[24px] self-stretch px-2 py-1\"\n onClick={() => list.dismiss(item.text)}\n pill\n size=\"sm\"\n slot=\"assist-dismiss\"\n variant=\"outline\"\n >\n <XIcon />\n </Button>\n </ButtonGroup>\n ))\n ) : list.status === \"loading\" ? (\n <span className=\"inline-flex items-center gap-2 text-muted-foreground text-sm\" data-slot=\"assist-pending\">\n <Spinner aria-hidden />\n {t.thinking}\n </span>\n ) : list.status === \"ready\" ? (\n <span className=\"text-muted-foreground text-sm\" data-slot=\"assist-empty\">\n {t.empty}\n </span>\n ) : null;\n\n if (body === null) return null;\n return (\n <Suggestions className=\"min-h-8\" slot=\"assist-candidates\">\n {body}\n </Suggestions>\n );\n}\n\n// ── A list field: candidates to add ──────────────────────────────────────────\n\nfunction ListAssist(props: Inner<string[]>) {\n const { value, onValueChange, instructions, children, className, settings } = props;\n const { t } = settings;\n const rootRef = React.useRef<HTMLDivElement>(null);\n const [active, setActive] = React.useState(false);\n const [mark, setMark] = React.useState<React.CSSProperties>({});\n\n const control = React.useCallback(() => controlIn(rootRef.current), []);\n const report = useEvents(settings, control);\n const list = useCandidates({\n source: ({ signal }) =>\n candidates(settings.model, describe(control(), instructions, settings.context?.()), {\n value: \"\",\n existing: value,\n signal,\n count: 6,\n list: true,\n }),\n existing: value,\n onEvent: report,\n });\n useReported(settings, list);\n\n // The ✨ sits at the end of the control's own box — Ark marks it `data-part=\"control\"`.\n React.useLayoutEffect(() => {\n const root = rootRef.current;\n const box = root?.querySelector<HTMLElement>(\"[data-part=control]\") ?? (root?.firstElementChild as HTMLElement | null);\n if (!root || !box) return;\n box.style.paddingInlineEnd = MARK_ROOM;\n const sync = () => {\n const rtl = getComputedStyle(box).direction === \"rtl\";\n const b = insets(box, root, rtl);\n setMark({ insetInlineEnd: b.insetInlineEnd + 2, insetBlockStart: b.insetBlockStart, height: box.offsetHeight });\n };\n sync();\n if (typeof ResizeObserver === \"undefined\") return;\n const ro = new ResizeObserver(sync);\n ro.observe(box);\n return () => ro.disconnect();\n }, []);\n\n const child = children as React.ReactElement<Record<string, unknown>>;\n const own = child.props;\n const field = React.cloneElement(child, {\n value,\n onValueChange: merge(own.onValueChange as never, (details: { value: string[] }) => onValueChange(details.value)),\n });\n\n const offering = list.items.length > 0;\n return (\n <ark.div\n className={cn(\"relative flex w-full min-w-0 flex-col gap-2\", className)}\n data-slot=\"assist\"\n onBlur={(e) => {\n if (!e.currentTarget.contains(e.relatedTarget)) setActive(false);\n }}\n onFocus={() => setActive(true)}\n ref={rootRef}\n >\n {field}\n <ark.span\n className={cn(\"absolute flex items-center\", mark.insetInlineEnd === undefined && \"invisible\")}\n data-slot=\"assist-mark\"\n style={mark}\n >\n <AiMark\n busy={list.status === \"loading\" && !offering}\n label={offering ? t.others : t.assist}\n offering={offering}\n onClick={list.press}\n />\n </ark.span>\n {active && (\n <Candidates\n list={list}\n onPick={(text) => {\n onValueChange([...value, text]);\n list.take(text);\n }}\n t={t}\n />\n )}\n </ark.div>\n );\n}\n"],"names":[],"mappings":";;;;;;;;;AA+CA;AAAoC;AAC1B;AACA;AACA;AACA;AACG;AACC;AACH;AACK;AACJ;AACH;AACC;AAEV;AAsCO;AACL;AACoB;AACX;AACL;AACyD;AACzD;AACA;AACoB;AAAa;AAEc;AAEnD;AACF;AAMA;;AAGkB;AAAwD;AAU1E;AACE;;AAGoC;AAAe;AAKrD;AAIA;AAAgB;AACd;AAAc;AAAY;AAAc;AAAa;AAAiB;AACtE;AAAiB;AAAc;AAAgB;AAAiB;AAChE;AAAkB;AAAoB;AACxC;AAKA;AACE;AAEA;AAAO;AAC4B;AACC;AACiC;AACH;AAEpE;AAGA;AACE;AAKiD;AAC5C;AACyD;AAC/C;AACA;AAEf;AACA;AACF;AAGA;;AAGoC;AAA8B;AAK9D;AACF;AAKE;AACE;AAGJ;AA4BK;AACL;AACA;AACA;AAIF;AAKA;AAGA;AACE;AAAa;;AAET;;;AACgB;AAEtB;AAIA;AACE;AAKgC;AACI;;AAUhB;AAA6D;AAE9C;AACiC;AACzD;AAEgB;AAC8F;AACvG;AACP;AAEX;AAEA;AAEA;AACE;AAEA;AACA;AAEA;AACE;AAQA;AAAuB;AAGzB;AACA;AACA;AAEgB;AAKhB;AAEA;AAGW;AAIb;AACE;AACA;AACA;AACA;AACE;AACA;AAAA;AAEF;AAAc;AAGhB;AACE;AACE;AAAiD;AAEnD;AAC8E;AAGhF;AACE;AACkB;;AAIlB;AAIkB;AAIlB;AACA;AACA;AACA;AAE6C;AAI7C;AAEc;;AAId;AAGkB;AAIlB;AAEA;AAG0B;AAI1B;AAEA;AAG+C;AAI/C;AACA;AACE;AACA;AAAA;AAGF;AACA;AACE;AAEA;AAAA;AAEF;AAQ+B;AAMO;AACtC;AAC+C;AACG;AACJ;AACA;AACE;AACM;AACJ;AAQpD;AACE;AAAK;AAAJ;AACuE;AAC5D;AAER;AAA+D;AACjE;AAC6B;AACxB;AAEJ;AAAA;AACuG;AACxG;AAAK;AAAJ;AACmG;AACxF;AACG;AAOX;AAAC;AAAA;AACC;AACwB;AACgC;AACxD;AAC0D;AAAA;AAAA;AAC5D;AAAA;AAKC;AAAqB;AAGtB;AACmE;AAGf;AAAA;AAAA;AAI9D;AAGA;AAOE;AAKA;AACE;AAEA;AACA;AACE;AACA;AAAA;AAEF;AAEA;AAAoG;AAIpG;AAEA;AACA;AACE;AACmB;AAErB;AAEkD;AAGpD;AACA;AACE;AAAK;AAAJ;AACY;AACA;AACT;AACU;AAAA;AAEF;AACL;AACE;AAEP;AAAgD;AACZ;AACS;AAAA;AAAA;AAGnD;AAGA;AACE;AAEI;AACE;AAAQ;AAAM;AAAI;AACpB;AAEE;AAAU;AAAM;AAAI;AACtB;AAEE;AAAQ;AAAM;AAAI;AACpB;AAGN;AAGA;AACE;AAEA;AACE;AAAoC;AAExC;AAEA;AACE;AACA;AAMF;AAQA;AAKE;AAOQ;AAEA;AACA;AAAC;AAAA;AACgC;AACrB;AAC2B;AACjC;AACC;AACA;AACG;AAED;AAAA;AAAA;AAMX;AAAqB;AAClB;AAQT;AAMF;AAIA;AACE;AAQ2B;;AAEvB;AAAoF;AAC3E;AACG;AACV;AACO;AACD;AACP;AAAA;AACO;AACD;AAEX;AAIE;AAEA;AACA;AACA;AACE;AAEA;AAA8G;AAGhH;AACA;AACA;AACgB;AAGlB;AAEwC;AACtC;AAC+G;AAIjH;AACE;AAAK;AAAJ;AACuE;AAC5D;AAER;AAA+D;AACjE;AAC6B;AACxB;AAEJ;AAAA;AACD;AAAK;AAAJ;AAC6F;AAClF;AACH;AAEP;AAAC;AAAA;AACqC;AACL;AAC/B;AACc;AAAA;AAAA;AAChB;AAAA;AAGA;AAAC;AAAA;AACC;AAEE;AACc;AAChB;AACA;AAAA;AAAA;AACF;AAAA;AAIR;;;;;"}
package/dist/engine.d.ts CHANGED
@@ -24,17 +24,6 @@ export interface AssistEvent {
24
24
  }
25
25
  /** Where a request has got to. Internal: no surface exposes it, they draw from it. */
26
26
  export type Status = "idle" | "loading" | "ready" | "error";
27
- /** The one failure this package names: the model stopped sending without closing the stream. */
28
- export declare class AiError extends Error {
29
- readonly code: "ai/silent";
30
- readonly data: {
31
- readonly after?: number;
32
- };
33
- readonly name = "AiError";
34
- constructor(code: "ai/silent", message: string, data?: {
35
- readonly after?: number;
36
- }, options?: ErrorOptions);
37
- }
38
27
  /**
39
28
  * Open a source and pull it to the end, handing each value to `each`; `false` from `each` stops it.
40
29
  * A new run supersedes the one in flight, and resolves to what happened to **this** run, so a loop
@@ -1 +1 @@
1
- {"version":3,"file":"engine.d.ts","sourceRoot":"","sources":["../src/engine.ts"],"names":[],"mappings":"AAQA,gGAAgG;AAChG,MAAM,WAAW,QAAQ;IACvB,IAAI,EAAE,MAAM,CAAC;IACb;;;;OAIG;IACH,KAAK,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,CAAC,CAAC;IACnC,kEAAkE;IAClE,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED;;;;;GAKG;AACH,MAAM,WAAW,WAAW;IAC1B,IAAI,EAAE,OAAO,GAAG,UAAU,GAAG,SAAS,GAAG,UAAU,GAAG,SAAS,CAAC;IAChE,QAAQ,EAAE,QAAQ,CAAC;IACnB,wDAAwD;IACxD,KAAK,EAAE,MAAM,CAAC;CACf;AAED,sFAAsF;AACtF,MAAM,MAAM,MAAM,GAAG,MAAM,GAAG,SAAS,GAAG,OAAO,GAAG,OAAO,CAAC;AAK5D,gGAAgG;AAChG,qBAAa,OAAQ,SAAQ,KAAK;IAG9B,QAAQ,CAAC,IAAI,EAAE,WAAW;IAE1B,QAAQ,CAAC,IAAI,EAAE;QAAE,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAA;KAAE;IAJ5C,SAAkB,IAAI,aAAa;gBAExB,IAAI,EAAE,WAAW,EAC1B,OAAO,EAAE,MAAM,EACN,IAAI,GAAE;QAAE,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAA;KAAO,EAC/C,OAAO,CAAC,EAAE,YAAY;CAIzB;AAqCD;;;;GAIG;AACH,wBAAgB,SAAS,CAAC,CAAC;eAgBjB,CAAC,MAAM,EAAE,WAAW,KAAK,aAAa,CAAC,CAAC,CAAC,QAAQ,CAAC,KAAK,EAAE,CAAC,KAAK,OAAO,GAAG,IAAI;;;;;EA0CtF;AA+BD;;;;GAIG;AACH,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,CAO7D;AAID,4EAA4E;AAC5E,eAAO,MAAM,mBAAmB,IAAI,CAAC;AAErC,MAAM,MAAM,OAAO,GAAG,SAAS,GAAG,WAAW,CAAC;AAE9C,MAAM,WAAW,mBAAmB;IAClC,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,OAAO,CAAC;IACjB,yFAAyF;IACzF,KAAK,EAAE,MAAM,EAAE,CAAC;IAChB,MAAM,EAAE,WAAW,CAAC;CACrB;AAQD;;;;;;;;;GASG;AACH,wBAAgB,eAAe,CAAC,OAAO,EAAE;IACvC,MAAM,EAAE,CAAC,OAAO,EAAE,mBAAmB,KAAK,aAAa,CAAC,MAAM,CAAC,CAAC;IAChE,OAAO,EAAE,CAAC,IAAI,EAAE,WAAW,CAAC,MAAM,CAAC,EAAE,QAAQ,EAAE,QAAQ,KAAK,IAAI,CAAC;IACjE,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;;;;sBAiGW,MAAM;iBAkBN,MAAM;kBAaP,CAAC,GAAG,CAAC,CAAC;mBAgBN,UAAU,GAAG,SAAS,QAAQ,MAAM;oBAhGpC,UAAU,GAAG,SAAS;EAyHhC;AAMD;;;GAGG;AACH,wBAAgB,aAAa,CAAC,OAAO,EAAE;IACrC,MAAM,EAAE,CAAC,OAAO,EAAE;QAAE,QAAQ,EAAE,MAAM,EAAE,CAAC;QAAC,MAAM,EAAE,WAAW,CAAA;KAAE,KAAK,aAAa,CAAC,QAAQ,CAAC,CAAC;IAC1F,QAAQ,EAAE,MAAM,EAAE,CAAC;IACnB,OAAO,EAAE,CAAC,IAAI,EAAE,WAAW,CAAC,MAAM,CAAC,EAAE,QAAQ,EAAE,QAAQ,KAAK,IAAI,CAAC;IACjE,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;;;;;cAuDiD,MAAM;iBAAyC,MAAM;;EACtG"}
1
+ {"version":3,"file":"engine.d.ts","sourceRoot":"","sources":["../src/engine.ts"],"names":[],"mappings":"AAQA,gGAAgG;AAChG,MAAM,WAAW,QAAQ;IACvB,IAAI,EAAE,MAAM,CAAC;IACb;;;;OAIG;IACH,KAAK,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,CAAC,CAAC;IACnC,kEAAkE;IAClE,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED;;;;;GAKG;AACH,MAAM,WAAW,WAAW;IAC1B,IAAI,EAAE,OAAO,GAAG,UAAU,GAAG,SAAS,GAAG,UAAU,GAAG,SAAS,CAAC;IAChE,QAAQ,EAAE,QAAQ,CAAC;IACnB,wDAAwD;IACxD,KAAK,EAAE,MAAM,CAAC;CACf;AAED,sFAAsF;AACtF,MAAM,MAAM,MAAM,GAAG,MAAM,GAAG,SAAS,GAAG,OAAO,GAAG,OAAO,CAAC;AAI5D;;;;GAIG;AACH,wBAAgB,SAAS,CAAC,CAAC;eAgBjB,CAAC,MAAM,EAAE,WAAW,KAAK,aAAa,CAAC,CAAC,CAAC,QAAQ,CAAC,KAAK,EAAE,CAAC,KAAK,OAAO,GAAG,IAAI;;;;;EA0CtF;AA+BD;;;;GAIG;AACH,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,CAO7D;AAID,4EAA4E;AAC5E,eAAO,MAAM,mBAAmB,IAAI,CAAC;AAErC,MAAM,MAAM,OAAO,GAAG,SAAS,GAAG,WAAW,CAAC;AAE9C,MAAM,WAAW,mBAAmB;IAClC,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,OAAO,CAAC;IACjB,yFAAyF;IACzF,KAAK,EAAE,MAAM,EAAE,CAAC;IAChB,MAAM,EAAE,WAAW,CAAC;CACrB;AAQD;;;;;;;;;GASG;AACH,wBAAgB,eAAe,CAAC,OAAO,EAAE;IACvC,MAAM,EAAE,CAAC,OAAO,EAAE,mBAAmB,KAAK,aAAa,CAAC,MAAM,CAAC,CAAC;IAChE,OAAO,EAAE,CAAC,IAAI,EAAE,WAAW,CAAC,MAAM,CAAC,EAAE,QAAQ,EAAE,QAAQ,KAAK,IAAI,CAAC;IACjE,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;;;;sBAiGW,MAAM;iBAkBN,MAAM;kBAaP,CAAC,GAAG,CAAC,CAAC;mBAgBN,UAAU,GAAG,SAAS,QAAQ,MAAM;oBAhGpC,UAAU,GAAG,SAAS;EAyHhC;AAMD;;;GAGG;AACH,wBAAgB,aAAa,CAAC,OAAO,EAAE;IACrC,MAAM,EAAE,CAAC,OAAO,EAAE;QAAE,QAAQ,EAAE,MAAM,EAAE,CAAC;QAAC,MAAM,EAAE,WAAW,CAAA;KAAE,KAAK,aAAa,CAAC,QAAQ,CAAC,CAAC;IAC1F,QAAQ,EAAE,MAAM,EAAE,CAAC;IACnB,OAAO,EAAE,CAAC,IAAI,EAAE,WAAW,CAAC,MAAM,CAAC,EAAE,QAAQ,EAAE,QAAQ,KAAK,IAAI,CAAC;IACjE,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;;;;;cAuDiD,MAAM;iBAAyC,MAAM;;EACtG"}
package/dist/engine.js CHANGED
@@ -1,235 +1,202 @@
1
1
  "use client";
2
- var V = Object.defineProperty;
3
- var $ = (o, r, n) => r in o ? V(o, r, { enumerable: !0, configurable: !0, writable: !0, value: n }) : o[r] = n;
4
- var q = (o, r, n) => $(o, typeof r != "symbol" ? r + "" : r, n);
5
- import { useState as F, useRef as L, useCallback as d, useEffect as O, useMemo as z } from "react";
6
- const M = 3e4;
7
- class j extends Error {
8
- constructor(n, i, l = {}, m) {
9
- super(i, m);
10
- q(this, "name", "AiError");
11
- this.code = n, this.data = l;
12
- }
13
- }
14
- function D(o, r) {
15
- return new Promise((n, i) => {
16
- const l = () => {
17
- clearTimeout(c), r.signal.removeEventListener("abort", m);
18
- }, m = () => {
19
- l(), i(r.signal.reason);
20
- }, c = setTimeout(
21
- () => r.abort(new j("ai/silent", `The model sent nothing for ${M} ms`, { after: M })),
22
- M
23
- );
24
- if (r.signal.aborted) return m();
25
- r.signal.addEventListener("abort", m), o.then(
26
- (g) => {
27
- l(), n(g);
28
- },
29
- (g) => {
30
- l(), i(g);
31
- }
32
- );
33
- });
34
- }
35
- function B() {
36
- const [o, r] = F("idle"), [n, i] = F(void 0), l = L("idle"), m = L(void 0), c = d((s, E) => {
37
- l.current = s, r(s), i(E);
2
+ import { useState as G, useRef as T, useCallback as i, useEffect as F, useMemo as H } from "react";
3
+ function O() {
4
+ const [f, n] = G("idle"), [c, h] = G(void 0), y = T("idle"), S = T(void 0), o = i((e, b) => {
5
+ y.current = e, n(e), h(b);
38
6
  }, []);
39
- O(() => () => {
40
- var s;
41
- return (s = m.current) == null ? void 0 : s.abort();
7
+ F(() => () => {
8
+ var e;
9
+ return (e = S.current) == null ? void 0 : e.abort();
42
10
  }, []);
43
- const g = d(
44
- (s, E) => {
45
- var x;
46
- (x = m.current) == null || x.abort();
11
+ const E = i(
12
+ (e, b) => {
13
+ var g;
14
+ (g = S.current) == null || g.abort();
47
15
  const a = new AbortController();
48
- return m.current = a, c("loading"), (async () => {
16
+ return S.current = a, o("loading"), (async () => {
49
17
  try {
50
- const p = s(a.signal)[Symbol.asyncIterator]();
18
+ const p = e(a.signal)[Symbol.asyncIterator]();
51
19
  for (; ; ) {
52
- const f = await D(p.next(), a);
20
+ const u = await p.next();
53
21
  if (a.signal.aborted) return "idle";
54
- if (f.done) break;
55
- if (E(f.value) === !1) {
22
+ if (u.done) break;
23
+ if (b(u.value) === !1) {
56
24
  a.abort();
57
25
  break;
58
26
  }
59
27
  }
60
28
  } catch (p) {
61
- return a.signal.aborted && !(a.signal.reason instanceof j) ? "idle" : (a.signal.aborted || a.abort(), c("error", p), "error");
29
+ return a.signal.aborted ? "idle" : (a.signal.aborted || a.abort(), o("error", p), "error");
62
30
  }
63
- return m.current === a && c("ready"), "ready";
31
+ return S.current === a && o("ready"), "ready";
64
32
  })();
65
33
  },
66
- [c]
67
- ), b = d(() => {
68
- var s;
69
- (s = m.current) == null || s.abort(), c("idle");
70
- }, [c]), v = d(() => {
71
- l.current !== "loading" && c("idle");
72
- }, [c]);
73
- return z(() => ({ run: g, cancel: b, reset: v, status: o, error: n }), [b, n, v, g, o]);
34
+ [o]
35
+ ), d = i(() => {
36
+ var e;
37
+ (e = S.current) == null || e.abort(), o("idle");
38
+ }, [o]), m = i(() => {
39
+ y.current !== "loading" && o("idle");
40
+ }, [o]);
41
+ return H(() => ({ run: E, cancel: d, reset: m, status: f, error: c }), [d, c, m, E, f]);
74
42
  }
75
- function J(o) {
76
- let r, n = !1;
77
- const i = () => {
78
- n = !0, r === void 0 && (r = requestAnimationFrame(() => {
79
- r = void 0, n = !1, o();
43
+ function U(f) {
44
+ let n, c = !1;
45
+ const h = () => {
46
+ c = !0, n === void 0 && (n = requestAnimationFrame(() => {
47
+ n = void 0, c = !1, f();
80
48
  }));
81
- }, l = () => {
82
- r !== void 0 && cancelAnimationFrame(r), r = void 0, n = !1;
49
+ }, y = () => {
50
+ n !== void 0 && cancelAnimationFrame(n), n = void 0, c = !1;
83
51
  };
84
- return Object.assign(i, { flush: () => {
85
- const c = n;
86
- l(), c && o();
87
- }, cancel: l });
52
+ return Object.assign(h, { flush: () => {
53
+ const o = c;
54
+ y(), o && f();
55
+ }, cancel: y });
88
56
  }
89
- function K(o, r) {
90
- const n = o.trimEnd();
91
- if (!n || !r) return r;
92
- const i = { base: n.toLowerCase(), cont: r.toLowerCase() };
93
- return i.cont.startsWith(i.base) ? r.slice(n.length) : i.base.startsWith(i.cont) ? "" : r;
57
+ function V(f, n) {
58
+ const c = f.trimEnd();
59
+ if (!c || !n) return n;
60
+ const h = { base: c.toLowerCase(), cont: n.toLowerCase() };
61
+ return h.cont.startsWith(h.base) ? n.slice(c.length) : h.base.startsWith(h.cont) ? "" : n;
94
62
  }
95
- const Q = 4;
96
- function Z(o) {
97
- const { run: r, cancel: n, status: i, error: l } = B(), [m, c] = F(""), g = L(o);
98
- g.current = o;
99
- const b = L([]), v = L(0), s = L({ value: "", position: 0 }), E = L(void 0), a = L(null), x = () => b.current[v.current] ?? null, p = d(() => {
100
- const t = x();
63
+ const z = 4;
64
+ function J(f) {
65
+ const { run: n, cancel: c, status: h, error: y } = O(), [S, o] = G(""), E = T(f);
66
+ E.current = f;
67
+ const d = T([]), m = T(0), e = T({ value: "", position: 0 }), b = T(void 0), a = T(null), g = () => d.current[m.current] ?? null, p = i(() => {
68
+ const t = g();
101
69
  if (!t) return "";
102
- const { value: e, position: h } = s.current;
103
- if (h < t.from) return "";
104
- const T = e.slice(t.from, h);
105
- return t.text.startsWith(T) ? t.text.slice(T.length) : "";
106
- }, []), f = d(() => {
107
- const t = x();
70
+ const { value: r, position: l } = e.current;
71
+ if (l < t.from) return "";
72
+ const v = r.slice(t.from, l);
73
+ return t.text.startsWith(v) ? t.text.slice(v.length) : "";
74
+ }, []), u = i(() => {
75
+ const t = g();
108
76
  if (!t) return !1;
109
- const { value: e, position: h } = s.current;
110
- if (h < t.from) return !1;
111
- const T = e.slice(t.from, h);
112
- return t.text.startsWith(T) || !t.done && T.startsWith(t.text);
113
- }, []), S = (t) => ({ text: t.text, range: [t.from, t.from] }), u = L(void 0);
114
- u.current ?? (u.current = J(() => {
77
+ const { value: r, position: l } = e.current;
78
+ if (l < t.from) return !1;
79
+ const v = r.slice(t.from, l);
80
+ return t.text.startsWith(v) || !t.done && v.startsWith(t.text);
81
+ }, []), I = (t) => ({ text: t.text, range: [t.from, t.from] }), s = T(void 0);
82
+ s.current ?? (s.current = U(() => {
115
83
  const t = p();
116
- c(t);
117
- const e = x();
118
- t && e && a.current !== e && (a.current = e, g.current.onEvent("shown", S(e)));
84
+ o(t);
85
+ const r = g();
86
+ t && r && a.current !== r && (a.current = r, E.current.onEvent("shown", I(r)));
119
87
  }));
120
- const k = d(
88
+ const k = i(
121
89
  (t) => {
122
- var h;
123
- clearTimeout(E.current), (h = u.current) == null || h.cancel();
124
- const e = x();
125
- t && e && a.current === e && g.current.onEvent(t, S(e)), b.current = [], v.current = 0, a.current = null, n(), c("");
90
+ var l;
91
+ clearTimeout(b.current), (l = s.current) == null || l.cancel();
92
+ const r = g();
93
+ t && r && a.current === r && E.current.onEvent(t, I(r)), d.current = [], m.current = 0, a.current = null, c(), o("");
126
94
  },
127
- [n]
128
- ), C = d(
129
- (t, e, h) => {
130
- const T = b.current.filter((y) => y.from === e).map((y) => y.text), A = { from: e, text: "", done: !1 };
131
- b.current = [...b.current.filter((y) => y.from === e), A], v.current = b.current.length - 1;
132
- const U = t.slice(0, e);
133
- let R = "";
134
- r(
135
- (y) => g.current.source({ value: t, position: e, trigger: h, avoid: T, signal: y }),
136
- (y) => {
95
+ [c]
96
+ ), C = i(
97
+ (t, r, l) => {
98
+ const v = d.current.filter((w) => w.from === r).map((w) => w.text), W = { from: r, text: "", done: !1 };
99
+ d.current = [...d.current.filter((w) => w.from === r), W], m.current = d.current.length - 1;
100
+ const B = t.slice(0, r);
101
+ let j = "";
102
+ n(
103
+ (w) => E.current.source({ value: t, position: r, trigger: l, avoid: v, signal: w }),
104
+ (w) => {
137
105
  var N;
138
- if (x() !== A) return !1;
139
- R += y, A.text = K(U, R), (N = u.current) == null || N.call(u);
106
+ if (g() !== W) return !1;
107
+ j += w, W.text = V(B, j), (N = s.current) == null || N.call(s);
140
108
  }
141
- ).then((y) => {
142
- var N, _;
143
- if (A.done = !0, x() === A) {
144
- if (y === "ready") return (N = u.current) == null ? void 0 : N.flush();
145
- (_ = u.current) == null || _.cancel(), y === "error" && (A.text = "", c(""));
109
+ ).then((w) => {
110
+ var N, q;
111
+ if (W.done = !0, g() === W) {
112
+ if (w === "ready") return (N = s.current) == null ? void 0 : N.flush();
113
+ (q = s.current) == null || q.cancel(), w === "error" && (W.text = "", o(""));
146
114
  }
147
115
  });
148
116
  },
149
- [r]
150
- ), w = (t) => t.trim().length < Q, I = d(
151
- (t, e = t.length) => {
152
- if (s.current = { value: t, position: e }, f()) {
153
- c(p());
117
+ [n]
118
+ ), x = (t) => t.trim().length < z, L = i(
119
+ (t, r = t.length) => {
120
+ if (e.current = { value: t, position: r }, u()) {
121
+ o(p());
154
122
  return;
155
123
  }
156
- k("ignored"), !w(t) && (E.current = setTimeout(
157
- () => C(t, e, "automatic"),
158
- g.current.debounceMs ?? 350
124
+ k("ignored"), !x(t) && (b.current = setTimeout(
125
+ () => C(t, r, "automatic"),
126
+ E.current.debounceMs ?? 350
159
127
  ));
160
128
  },
161
- [f, k, p, C]
162
- ), G = d(
163
- (t, e = t.length) => {
164
- const h = s.current.value === t && s.current.position === e;
165
- s.current = { value: t, position: e }, h || k("ignored"), clearTimeout(E.current), !w(t) && C(t, e, "invoked");
129
+ [u, k, p, C]
130
+ ), M = i(
131
+ (t, r = t.length) => {
132
+ const l = e.current.value === t && e.current.position === r;
133
+ e.current = { value: t, position: r }, l || k("ignored"), clearTimeout(b.current), !x(t) && C(t, r, "invoked");
166
134
  },
167
135
  [k, C]
168
- ), H = d(
136
+ ), R = i(
169
137
  (t) => {
170
- var h;
171
- const e = v.current + t;
172
- if (!(e < 0)) {
173
- if (e >= b.current.length) {
174
- const { value: T, position: A } = s.current;
175
- G(T, A);
138
+ var l;
139
+ const r = m.current + t;
140
+ if (!(r < 0)) {
141
+ if (r >= d.current.length) {
142
+ const { value: v, position: W } = e.current;
143
+ M(v, W);
176
144
  return;
177
145
  }
178
- v.current = e, c(p()), (h = u.current) == null || h.call(u);
146
+ m.current = r, o(p()), (l = s.current) == null || l.call(s);
179
147
  }
180
148
  },
181
- [G, p]
182
- ), P = d(
183
- (t, e) => {
184
- var T;
185
- x() && g.current.onEvent(t, { text: e, range: [s.current.position, s.current.position] }), t === "accepted" && (b.current = [], v.current = 0, a.current = null, clearTimeout(E.current), (T = u.current) == null || T.cancel(), n(), c(""));
149
+ [M, p]
150
+ ), _ = i(
151
+ (t, r) => {
152
+ var v;
153
+ g() && E.current.onEvent(t, { text: r, range: [e.current.position, e.current.position] }), t === "accepted" && (d.current = [], m.current = 0, a.current = null, clearTimeout(b.current), (v = s.current) == null || v.cancel(), c(), o(""));
186
154
  },
187
- [n]
155
+ [c]
188
156
  );
189
- return O(
157
+ return F(
190
158
  () => () => {
191
159
  var t;
192
- clearTimeout(E.current), (t = u.current) == null || t.cancel();
160
+ clearTimeout(b.current), (t = s.current) == null || t.cancel();
193
161
  },
194
162
  []
195
- ), { ghost: m, status: i, error: l, setValue: I, ask: G, cycle: H, settle: P, dismiss: k };
163
+ ), { ghost: S, status: h, error: y, setValue: L, ask: M, cycle: R, settle: _, dismiss: k };
196
164
  }
197
- const W = (o) => o.trim().toLowerCase();
198
- function tt(o) {
199
- const { run: r, cancel: n, reset: i, status: l, error: m } = B(), [c, g] = F([]), b = L([]), v = L(o);
200
- v.current = o;
201
- const s = d((f) => {
202
- b.current = f, g(f);
203
- }, []), E = d(() => {
204
- const { existing: f, limit: S = 6, source: u } = v.current, k = new Set(f.map(W).filter(Boolean)), C = [];
205
- s([]), r(
206
- (w) => u({ existing: f, signal: w }),
207
- (w) => {
208
- const I = W(w.text);
209
- return !I || k.has(I) ? !0 : (k.add(I), C.push(w), s([...C]), v.current.onEvent("shown", w), C.length < S);
165
+ const A = (f) => f.trim().toLowerCase();
166
+ function K(f) {
167
+ const { run: n, cancel: c, reset: h, status: y, error: S } = O(), [o, E] = G([]), d = T([]), m = T(f);
168
+ m.current = f;
169
+ const e = i((u) => {
170
+ d.current = u, E(u);
171
+ }, []), b = i(() => {
172
+ const { existing: u, limit: I = 6, source: s } = m.current, k = new Set(u.map(A).filter(Boolean)), C = [];
173
+ e([]), n(
174
+ (x) => s({ existing: u, signal: x }),
175
+ (x) => {
176
+ const L = A(x.text);
177
+ return !L || k.has(L) ? !0 : (k.add(L), C.push(x), e([...C]), m.current.onEvent("shown", x), C.length < I);
210
178
  }
211
179
  );
212
- }, [s, r]), a = d(() => {
213
- l !== "loading" && E();
214
- }, [E, l]), x = d(
215
- (f, S) => {
216
- const u = W(f), k = b.current.find((w) => W(w.text) === u);
217
- k && v.current.onEvent(S, k);
218
- const C = b.current.filter((w) => W(w.text) !== u);
219
- s(C), C.length === 0 && i();
180
+ }, [e, n]), a = i(() => {
181
+ y !== "loading" && b();
182
+ }, [b, y]), g = i(
183
+ (u, I) => {
184
+ const s = A(u), k = d.current.find((x) => A(x.text) === s);
185
+ k && m.current.onEvent(I, k);
186
+ const C = d.current.filter((x) => A(x.text) !== s);
187
+ e(C), C.length === 0 && h();
220
188
  },
221
- [s, i]
222
- ), p = d(() => {
223
- n(), s([]);
224
- }, [n, s]);
225
- return { items: c, status: l, error: m, press: a, take: (f) => x(f, "accepted"), dismiss: (f) => x(f, "rejected"), cancel: p };
189
+ [e, h]
190
+ ), p = i(() => {
191
+ c(), e([]);
192
+ }, [c, e]);
193
+ return { items: o, status: y, error: S, press: a, take: (u) => g(u, "accepted"), dismiss: (u) => g(u, "rejected"), cancel: p };
226
194
  }
227
195
  export {
228
- j as AiError,
229
- Q as MIN_CONTINUE_LENGTH,
230
- K as cleanGhost,
231
- tt as useCandidates,
232
- Z as useContinuation,
233
- B as useStream
196
+ z as MIN_CONTINUE_LENGTH,
197
+ V as cleanGhost,
198
+ K as useCandidates,
199
+ J as useContinuation,
200
+ O as useStream
234
201
  };
235
202
  //# sourceMappingURL=engine.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"engine.js","sources":["../src/engine.ts"],"sourcesContent":["\"use client\";\n\n// The machinery behind `Assist`, and none of it is public. A field offers a **proposal** — text the\n// reader may take — and this file is everything about getting proposals out of a stream and keeping\n// them honest while the reader types: one engine, the inline continuation, and the candidate list.\n\nimport { useCallback, useEffect, useMemo, useRef, useState } from \"react\";\n\n/** A value the model offers a field. LSP 3.18's `InlineCompletionItem`, as a field needs it. */\nexport interface Proposal {\n text: string;\n /**\n * What taking it replaces, as `[from, to)` in the field's value: an empty range at the caret is a\n * continuation, the whole value is a replacement. Absent for a new item in a list field (tags),\n * where taking it adds rather than edits.\n */\n range?: [from: number, to: number];\n /** Why the model offered it. Shown as the candidate's tooltip. */\n rationale?: string;\n}\n\n/**\n * What happened to a proposal, for a host that learns from it (HAX G13). The lifecycle VS Code's\n * inline completions report, in one callback: it was **shown**, taken whole (**accepted**) or a word\n * at a time (**partial**), turned down by the reader (**rejected**: Esc, or a candidate's ✕), or\n * left to die because they typed something else (**ignored**).\n */\nexport interface AssistEvent {\n kind: \"shown\" | \"accepted\" | \"partial\" | \"rejected\" | \"ignored\";\n proposal: Proposal;\n /** The field's accessible name — which field it was. */\n field: string;\n}\n\n/** Where a request has got to. Internal: no surface exposes it, they draw from it. */\nexport type Status = \"idle\" | \"loading\" | \"ready\" | \"error\";\n\n/** How long a stream may go without sending anything, the first chunk included. */\nconst SILENT_AFTER = 30_000;\n\n/** The one failure this package names: the model stopped sending without closing the stream. */\nexport class AiError extends Error {\n override readonly name = \"AiError\";\n constructor(\n readonly code: \"ai/silent\",\n message: string,\n readonly data: { readonly after?: number } = {},\n options?: ErrorOptions,\n ) {\n super(message, options);\n }\n}\n\n/**\n * `pending`, unless `c` aborts first or nothing arrives within {@link SILENT_AFTER} — then `c` is\n * aborted with an `ai/silent` `AiError`, which this rejects with.\n */\nfunction heard<T>(pending: Promise<T>, c: AbortController): Promise<T> {\n return new Promise<T>((resolve, reject) => {\n const settle = () => {\n clearTimeout(timer);\n c.signal.removeEventListener(\"abort\", aborted);\n };\n const aborted = () => {\n settle();\n reject(c.signal.reason);\n };\n const timer = setTimeout(\n () => c.abort(new AiError(\"ai/silent\", `The model sent nothing for ${SILENT_AFTER} ms`, { after: SILENT_AFTER })),\n SILENT_AFTER,\n );\n if (c.signal.aborted) return aborted();\n c.signal.addEventListener(\"abort\", aborted);\n pending.then(\n (value) => {\n settle();\n resolve(value);\n },\n (e: unknown) => {\n settle();\n reject(e);\n },\n );\n });\n}\n\n// ── The engine ───────────────────────────────────────────────────────────────\n\n/**\n * Open a source and pull it to the end, handing each value to `each`; `false` from `each` stops it.\n * A new run supersedes the one in flight, and resolves to what happened to **this** run, so a loop\n * that outlives its render never reads a stale status.\n */\nexport function useStream<T>() {\n const [status, setStatus] = useState<Status>(\"idle\");\n /** What the source threw, as thrown, while `status` is `\"error\"`. */\n const [error, setError] = useState<unknown>(undefined);\n const statusRef = useRef<Status>(\"idle\");\n const ctrl = useRef<AbortController>(undefined);\n\n const set = useCallback((s: Status, e?: unknown) => {\n statusRef.current = s;\n setStatus(s);\n setError(e);\n }, []);\n\n useEffect(() => () => ctrl.current?.abort(), []);\n\n const run = useCallback(\n (src: (signal: AbortSignal) => AsyncIterable<T>, each: (value: T) => boolean | void) => {\n ctrl.current?.abort();\n const c = new AbortController();\n ctrl.current = c;\n set(\"loading\");\n\n return (async (): Promise<Status> => {\n try {\n const it = src(c.signal)[Symbol.asyncIterator]();\n for (;;) {\n const res = await heard(it.next(), c);\n if (c.signal.aborted) return \"idle\";\n if (res.done) break;\n if (each(res.value) === false) {\n c.abort();\n break;\n }\n }\n } catch (e) {\n // A cancellation is not a failure to report — the caller asked for it. Silence is.\n if (c.signal.aborted && !(c.signal.reason instanceof AiError)) return \"idle\";\n if (!c.signal.aborted) c.abort();\n set(\"error\", e);\n return \"error\";\n }\n if (ctrl.current === c) set(\"ready\");\n return \"ready\";\n })();\n },\n [set],\n );\n\n const cancel = useCallback(() => {\n ctrl.current?.abort();\n set(\"idle\");\n }, [set]);\n\n const reset = useCallback(() => {\n if (statusRef.current !== \"loading\") set(\"idle\");\n }, [set]);\n\n return useMemo(() => ({ run, cancel, reset, status, error }), [cancel, error, reset, run, status]);\n}\n\n/**\n * Run `paint` at most once a frame, and once more on `flush()`. A token stream arrives faster than\n * the display refreshes, so a `setState` per chunk renders work nobody sees.\n */\nfunction coalesce(paint: () => void) {\n let frame: number | undefined;\n let queued = false;\n const run = () => {\n queued = true;\n if (frame !== undefined) return;\n frame = requestAnimationFrame(() => {\n frame = undefined;\n queued = false;\n paint();\n });\n };\n const cancel = () => {\n if (frame !== undefined) cancelAnimationFrame(frame);\n frame = undefined;\n queued = false;\n };\n const flush = () => {\n const pending = queued;\n cancel();\n if (pending) paint();\n };\n return Object.assign(run, { flush, cancel });\n}\n\n/**\n * The continuation is appended verbatim, with one safety net: a model that restated the value\n * before continuing gets that prefix dropped, and an echo still arriving renders as nothing until it\n * diverges — painting the value twice is worse than a few frames of nothing.\n */\nexport function cleanGhost(base: string, cont: string): string {\n const b = base.trimEnd();\n if (!b || !cont) return cont;\n const lower = { base: b.toLowerCase(), cont: cont.toLowerCase() };\n if (lower.cont.startsWith(lower.base)) return cont.slice(b.length);\n if (lower.base.startsWith(lower.cont)) return \"\";\n return cont;\n}\n\n// ── The continuation: one offer at the caret, with alternatives ──────────────\n\n/** Below this there is not enough text to continue, so nothing is asked. */\nexport const MIN_CONTINUE_LENGTH = 4;\n\nexport type Trigger = \"invoked\" | \"automatic\";\n\nexport interface ContinuationRequest {\n value: string;\n position: number;\n trigger: Trigger;\n /** Continuations already offered at this point; an alternative must differ from them. */\n avoid: string[];\n signal: AbortSignal;\n}\n\ninterface Offer {\n from: number;\n text: string;\n done: boolean;\n}\n\n/**\n * Ghost text that does not own the value: the field owns it and performs the insertion.\n *\n * **The offer survives typing that agrees with it.** An offer made at `from` stays on the table\n * while what was typed since is a prefix of it, and the ghost is what is left — no request at all.\n * That is LSP's `filterText` rule and the only way to Smart Compose's 60 ms budget: not asking.\n *\n * **Alternatives** are LSP's `Invoked` case: asking again at the same point appends a different\n * offer rather than replacing the one on the table, and `cycle` moves between them (Alt+] / Alt+[).\n */\nexport function useContinuation(options: {\n source: (request: ContinuationRequest) => AsyncIterable<string>;\n onEvent: (kind: AssistEvent[\"kind\"], proposal: Proposal) => void;\n debounceMs?: number;\n}) {\n const { run, cancel, status, error } = useStream<string>();\n const [ghost, setGhost] = useState(\"\");\n const opts = useRef(options);\n opts.current = options;\n\n const offers = useRef<Offer[]>([]);\n const index = useRef(0);\n const field = useRef({ value: \"\", position: 0 });\n const timer = useRef<ReturnType<typeof setTimeout>>(undefined);\n const shown = useRef<Offer | null>(null);\n\n const current = () => offers.current[index.current] ?? null;\n\n const rendered = useCallback((): string => {\n const o = current();\n if (!o) return \"\";\n const { value, position } = field.current;\n if (position < o.from) return \"\";\n const typed = value.slice(o.from, position);\n return o.text.startsWith(typed) ? o.text.slice(typed.length) : \"\";\n }, []);\n\n const alive = useCallback((): boolean => {\n const o = current();\n if (!o) return false;\n const { value, position } = field.current;\n if (position < o.from) return false;\n const typed = value.slice(o.from, position);\n // Typing ahead of a stream still arriving is not a mismatch yet: the next chunk may agree.\n return o.text.startsWith(typed) || (!o.done && typed.startsWith(o.text));\n }, []);\n\n const proposal = (o: Offer): Proposal => ({ text: o.text, range: [o.from, o.from] });\n\n const painter = useRef<ReturnType<typeof coalesce>>(undefined);\n painter.current ??= coalesce(() => {\n const next = rendered();\n setGhost(next);\n const o = current();\n if (next && o && shown.current !== o) {\n shown.current = o;\n opts.current.onEvent(\"shown\", proposal(o));\n }\n });\n\n /** Forget every offer. `why` is reported once, for the offer on screen. */\n const drop = useCallback(\n (why?: \"rejected\" | \"ignored\") => {\n clearTimeout(timer.current);\n painter.current?.cancel();\n const o = current();\n if (why && o && shown.current === o) opts.current.onEvent(why, proposal(o));\n offers.current = [];\n index.current = 0;\n shown.current = null;\n cancel();\n setGhost(\"\");\n },\n [cancel],\n );\n\n const start = useCallback(\n (value: string, position: number, trigger: Trigger) => {\n const avoid = offers.current.filter((o) => o.from === position).map((o) => o.text);\n const mine: Offer = { from: position, text: \"\", done: false };\n offers.current = [...offers.current.filter((o) => o.from === position), mine];\n index.current = offers.current.length - 1;\n const base = value.slice(0, position);\n let raw = \"\";\n void run(\n (signal) => opts.current.source({ value, position, trigger, avoid, signal }),\n (chunk) => {\n if (current() !== mine) return false;\n raw += chunk;\n mine.text = cleanGhost(base, raw);\n painter.current?.();\n },\n ).then((outcome) => {\n mine.done = true;\n if (current() !== mine) return;\n if (outcome === \"ready\") return painter.current?.flush();\n painter.current?.cancel();\n // A failed stream's half a continuation is not an offer; the failure is `error`.\n if (outcome === \"error\") {\n mine.text = \"\";\n setGhost(\"\");\n }\n });\n },\n [run],\n );\n\n const short = (value: string) => value.trim().length < MIN_CONTINUE_LENGTH;\n\n /** The field changed. Ask after a debounce — unless the offer on the table survives it. */\n const setValue = useCallback(\n (value: string, position = value.length) => {\n field.current = { value, position };\n if (alive()) {\n setGhost(rendered());\n return;\n }\n drop(\"ignored\");\n if (short(value)) return;\n timer.current = setTimeout(\n () => start(value, position, \"automatic\"),\n opts.current.debounceMs ?? 350,\n );\n },\n [alive, drop, rendered, start],\n );\n\n /** Ask now — the ✨, and Alt+] past the last alternative. */\n const ask = useCallback(\n (value: string, position = value.length) => {\n const same = field.current.value === value && field.current.position === position;\n field.current = { value, position };\n if (!same) drop(\"ignored\");\n clearTimeout(timer.current);\n if (short(value)) return;\n start(value, position, \"invoked\");\n },\n [drop, start],\n );\n\n /** Move between alternatives; past the last one, ask for another. */\n const cycle = useCallback(\n (step: 1 | -1) => {\n const next = index.current + step;\n if (next < 0) return;\n if (next >= offers.current.length) {\n const { value, position } = field.current;\n ask(value, position);\n return;\n }\n index.current = next;\n setGhost(rendered());\n painter.current?.();\n },\n [ask, rendered],\n );\n\n const settle = useCallback(\n (kind: \"accepted\" | \"partial\", text: string) => {\n const o = current();\n if (o) opts.current.onEvent(kind, { text, range: [field.current.position, field.current.position] });\n if (kind === \"accepted\") {\n offers.current = [];\n index.current = 0;\n shown.current = null;\n clearTimeout(timer.current);\n painter.current?.cancel();\n cancel();\n setGhost(\"\");\n }\n },\n [cancel],\n );\n\n useEffect(\n () => () => {\n clearTimeout(timer.current);\n painter.current?.cancel();\n },\n [],\n );\n\n return { ghost, status, error, setValue, ask, cycle, settle, dismiss: drop };\n}\n\n// ── The candidates: N whole values, deduped ──────────────────────────────────\n\nconst norm = (v: string) => v.trim().toLowerCase();\n\n/**\n * A streamed list of whole values, deduped against what the field already holds. A budget rather\n * than a window: nothing refills when one is taken or dismissed.\n */\nexport function useCandidates(options: {\n source: (request: { existing: string[]; signal: AbortSignal }) => AsyncIterable<Proposal>;\n existing: string[];\n onEvent: (kind: AssistEvent[\"kind\"], proposal: Proposal) => void;\n limit?: number;\n}) {\n const { run, cancel: abort, reset, status, error } = useStream<Proposal>();\n const [items, setItems] = useState<Proposal[]>([]);\n const shown = useRef<Proposal[]>([]);\n const opts = useRef(options);\n opts.current = options;\n\n const put = useCallback((next: Proposal[]) => {\n shown.current = next;\n setItems(next);\n }, []);\n\n const refresh = useCallback(() => {\n const { existing, limit = 6, source } = opts.current;\n const seen = new Set(existing.map(norm).filter(Boolean));\n const taken: Proposal[] = [];\n put([]);\n void run(\n (signal) => source({ existing, signal }),\n (item) => {\n const key = norm(item.text);\n if (!key || seen.has(key)) return true;\n seen.add(key);\n taken.push(item);\n put([...taken]);\n opts.current.onEvent(\"shown\", item);\n return taken.length < limit;\n },\n );\n }, [put, run]);\n\n /** The ✨: a first set, a different set, or a retry — never inert, except while loading. */\n const press = useCallback(() => {\n if (status === \"loading\") return;\n refresh();\n }, [refresh, status]);\n\n const remove = useCallback(\n (text: string, kind: \"accepted\" | \"rejected\") => {\n const key = norm(text);\n const item = shown.current.find((p) => norm(p.text) === key);\n if (item) opts.current.onEvent(kind, item);\n const next = shown.current.filter((p) => norm(p.text) !== key);\n put(next);\n // Nothing left of an answer is not an answer with nothing in it.\n if (next.length === 0) reset();\n },\n [put, reset],\n );\n\n const cancel = useCallback(() => {\n abort();\n put([]);\n }, [abort, put]);\n\n return { items, status, error, press, take: (t: string) => remove(t, \"accepted\"), dismiss: (t: string) => remove(t, \"rejected\"), cancel };\n}\n"],"names":[],"mappings":";;;;;AAsCA;AAGO;AAA4B;AAQ/B;AAPgB;AAEP;AAEA;AAKb;AAMA;AACE;AACE;AACE;AAC6C;AAG7C;AACsB;AAEV;AACoG;AAChH;AAEF;AACA;AACQ;AAEJ;AACa;AACf;AAEE;AACQ;AACV;AAAA;AAGN;AASO;AACL;AAOE;AAEU;AAGZ;;AAAsB;AAAc;AAEpC;AAAY;;AAER;AACA;AACA;AAIE;AACE;AACA;AACE;AACA;AACA;AACA;AACE;AACA;AAAA;AACF;AACF;AAGA;AAGO;AAET;AACO;AACT;AACF;AACI;;AAIJ;AACU;AAIV;AAA+C;AAGjD;AACF;AAMA;AACE;AAEA;AAEE;AAEE;AAEA;AACD;AAGD;AAES;AAOX;AAJE;AACA;AACa;AAGjB;AAOO;AACL;AACA;AACA;AACA;AAGF;AAKO;AA6BA;AAKL;AAGA;AAEA;AASE;AACA;AACA;AACA;AACA;AACA;AAA+D;AAI/D;AACA;AACA;AACA;AACA;AAEA;AAAsE;AAMxE;AACE;AACA;AACA;AACA;AAE2C;AAK7C;AAAa;;AAET;AAEA;AACA;AAKW;AACb;AACO;AAGK;AAEV;AAEA;AAEA;AACA;AACA;AAAK;AACwE;;AAEzE;AACA;AAEA;AACF;;AAGA;AACA;AACA;AAIa;AAAA;AAEd;AACH;AACI;AAMW;AAGb;AACE;AACA;AAAA;AAGF;AACgB;AAC0B;AACb;AAAA;AAE/B;AAC6B;AAInB;AAER;AAIA;AACgC;AAClC;AACY;AAIA;;AAEV;AACA;AACA;AACE;AACA;AACA;AAAA;AAEF;AAEA;AAAA;AACF;AACc;AAGD;;AAGX;AAQa;AAEf;AACO;AAGT;AAAA;;AAEI;AACiB;AACnB;AACA;AAIJ;AAIA;AAMO;AAML;AAIA;AAEA;AACE;AACa;AAIb;AAGA;AACK;AACoC;AAErC;AACA;AAKsB;AACxB;AAAA;AAMF;AACA;AAGa;AAEX;AAEA;AACA;AACA;AAEuB;AACzB;AACW;AAIX;AACM;AAGR;AACF;;;;;;;;;"}
1
+ {"version":3,"file":"engine.js","sources":["../src/engine.ts"],"sourcesContent":["\"use client\";\n\n// The machinery behind `Assist`, and none of it is public. A field offers a **proposal** — text the\n// reader may take — and this file is everything about getting proposals out of a stream and keeping\n// them honest while the reader types: one engine, the inline continuation, and the candidate list.\n\nimport { useCallback, useEffect, useMemo, useRef, useState } from \"react\";\n\n/** A value the model offers a field. LSP 3.18's `InlineCompletionItem`, as a field needs it. */\nexport interface Proposal {\n text: string;\n /**\n * What taking it replaces, as `[from, to)` in the field's value: an empty range at the caret is a\n * continuation, the whole value is a replacement. Absent for a new item in a list field (tags),\n * where taking it adds rather than edits.\n */\n range?: [from: number, to: number];\n /** Why the model offered it. Shown as the candidate's tooltip. */\n rationale?: string;\n}\n\n/**\n * What happened to a proposal, for a host that learns from it (HAX G13). The lifecycle VS Code's\n * inline completions report, in one callback: it was **shown**, taken whole (**accepted**) or a word\n * at a time (**partial**), turned down by the reader (**rejected**: Esc, or a candidate's ✕), or\n * left to die because they typed something else (**ignored**).\n */\nexport interface AssistEvent {\n kind: \"shown\" | \"accepted\" | \"partial\" | \"rejected\" | \"ignored\";\n proposal: Proposal;\n /** The field's accessible name — which field it was. */\n field: string;\n}\n\n/** Where a request has got to. Internal: no surface exposes it, they draw from it. */\nexport type Status = \"idle\" | \"loading\" | \"ready\" | \"error\";\n\n// ── The engine ───────────────────────────────────────────────────────────────\n\n/**\n * Open a source and pull it to the end, handing each value to `each`; `false` from `each` stops it.\n * A new run supersedes the one in flight, and resolves to what happened to **this** run, so a loop\n * that outlives its render never reads a stale status.\n */\nexport function useStream<T>() {\n const [status, setStatus] = useState<Status>(\"idle\");\n /** What the source threw, as thrown, while `status` is `\"error\"`. */\n const [error, setError] = useState<unknown>(undefined);\n const statusRef = useRef<Status>(\"idle\");\n const ctrl = useRef<AbortController>(undefined);\n\n const set = useCallback((s: Status, e?: unknown) => {\n statusRef.current = s;\n setStatus(s);\n setError(e);\n }, []);\n\n useEffect(() => () => ctrl.current?.abort(), []);\n\n const run = useCallback(\n (src: (signal: AbortSignal) => AsyncIterable<T>, each: (value: T) => boolean | void) => {\n ctrl.current?.abort();\n const c = new AbortController();\n ctrl.current = c;\n set(\"loading\");\n\n return (async (): Promise<Status> => {\n try {\n const it = src(c.signal)[Symbol.asyncIterator]();\n for (;;) {\n const res = await it.next();\n if (c.signal.aborted) return \"idle\";\n if (res.done) break;\n if (each(res.value) === false) {\n c.abort();\n break;\n }\n }\n } catch (e) {\n // A cancellation is not a failure to report — the caller asked for it.\n if (c.signal.aborted) return \"idle\";\n if (!c.signal.aborted) c.abort();\n set(\"error\", e);\n return \"error\";\n }\n if (ctrl.current === c) set(\"ready\");\n return \"ready\";\n })();\n },\n [set],\n );\n\n const cancel = useCallback(() => {\n ctrl.current?.abort();\n set(\"idle\");\n }, [set]);\n\n const reset = useCallback(() => {\n if (statusRef.current !== \"loading\") set(\"idle\");\n }, [set]);\n\n return useMemo(() => ({ run, cancel, reset, status, error }), [cancel, error, reset, run, status]);\n}\n\n/**\n * Run `paint` at most once a frame, and once more on `flush()`. A token stream arrives faster than\n * the display refreshes, so a `setState` per chunk renders work nobody sees.\n */\nfunction coalesce(paint: () => void) {\n let frame: number | undefined;\n let queued = false;\n const run = () => {\n queued = true;\n if (frame !== undefined) return;\n frame = requestAnimationFrame(() => {\n frame = undefined;\n queued = false;\n paint();\n });\n };\n const cancel = () => {\n if (frame !== undefined) cancelAnimationFrame(frame);\n frame = undefined;\n queued = false;\n };\n const flush = () => {\n const pending = queued;\n cancel();\n if (pending) paint();\n };\n return Object.assign(run, { flush, cancel });\n}\n\n/**\n * The continuation is appended verbatim, with one safety net: a model that restated the value\n * before continuing gets that prefix dropped, and an echo still arriving renders as nothing until it\n * diverges — painting the value twice is worse than a few frames of nothing.\n */\nexport function cleanGhost(base: string, cont: string): string {\n const b = base.trimEnd();\n if (!b || !cont) return cont;\n const lower = { base: b.toLowerCase(), cont: cont.toLowerCase() };\n if (lower.cont.startsWith(lower.base)) return cont.slice(b.length);\n if (lower.base.startsWith(lower.cont)) return \"\";\n return cont;\n}\n\n// ── The continuation: one offer at the caret, with alternatives ──────────────\n\n/** Below this there is not enough text to continue, so nothing is asked. */\nexport const MIN_CONTINUE_LENGTH = 4;\n\nexport type Trigger = \"invoked\" | \"automatic\";\n\nexport interface ContinuationRequest {\n value: string;\n position: number;\n trigger: Trigger;\n /** Continuations already offered at this point; an alternative must differ from them. */\n avoid: string[];\n signal: AbortSignal;\n}\n\ninterface Offer {\n from: number;\n text: string;\n done: boolean;\n}\n\n/**\n * Ghost text that does not own the value: the field owns it and performs the insertion.\n *\n * **The offer survives typing that agrees with it.** An offer made at `from` stays on the table\n * while what was typed since is a prefix of it, and the ghost is what is left — no request at all.\n * That is LSP's `filterText` rule and the only way to Smart Compose's 60 ms budget: not asking.\n *\n * **Alternatives** are LSP's `Invoked` case: asking again at the same point appends a different\n * offer rather than replacing the one on the table, and `cycle` moves between them (Alt+] / Alt+[).\n */\nexport function useContinuation(options: {\n source: (request: ContinuationRequest) => AsyncIterable<string>;\n onEvent: (kind: AssistEvent[\"kind\"], proposal: Proposal) => void;\n debounceMs?: number;\n}) {\n const { run, cancel, status, error } = useStream<string>();\n const [ghost, setGhost] = useState(\"\");\n const opts = useRef(options);\n opts.current = options;\n\n const offers = useRef<Offer[]>([]);\n const index = useRef(0);\n const field = useRef({ value: \"\", position: 0 });\n const timer = useRef<ReturnType<typeof setTimeout>>(undefined);\n const shown = useRef<Offer | null>(null);\n\n const current = () => offers.current[index.current] ?? null;\n\n const rendered = useCallback((): string => {\n const o = current();\n if (!o) return \"\";\n const { value, position } = field.current;\n if (position < o.from) return \"\";\n const typed = value.slice(o.from, position);\n return o.text.startsWith(typed) ? o.text.slice(typed.length) : \"\";\n }, []);\n\n const alive = useCallback((): boolean => {\n const o = current();\n if (!o) return false;\n const { value, position } = field.current;\n if (position < o.from) return false;\n const typed = value.slice(o.from, position);\n // Typing ahead of a stream still arriving is not a mismatch yet: the next chunk may agree.\n return o.text.startsWith(typed) || (!o.done && typed.startsWith(o.text));\n }, []);\n\n const proposal = (o: Offer): Proposal => ({ text: o.text, range: [o.from, o.from] });\n\n const painter = useRef<ReturnType<typeof coalesce>>(undefined);\n painter.current ??= coalesce(() => {\n const next = rendered();\n setGhost(next);\n const o = current();\n if (next && o && shown.current !== o) {\n shown.current = o;\n opts.current.onEvent(\"shown\", proposal(o));\n }\n });\n\n /** Forget every offer. `why` is reported once, for the offer on screen. */\n const drop = useCallback(\n (why?: \"rejected\" | \"ignored\") => {\n clearTimeout(timer.current);\n painter.current?.cancel();\n const o = current();\n if (why && o && shown.current === o) opts.current.onEvent(why, proposal(o));\n offers.current = [];\n index.current = 0;\n shown.current = null;\n cancel();\n setGhost(\"\");\n },\n [cancel],\n );\n\n const start = useCallback(\n (value: string, position: number, trigger: Trigger) => {\n const avoid = offers.current.filter((o) => o.from === position).map((o) => o.text);\n const mine: Offer = { from: position, text: \"\", done: false };\n offers.current = [...offers.current.filter((o) => o.from === position), mine];\n index.current = offers.current.length - 1;\n const base = value.slice(0, position);\n let raw = \"\";\n void run(\n (signal) => opts.current.source({ value, position, trigger, avoid, signal }),\n (chunk) => {\n if (current() !== mine) return false;\n raw += chunk;\n mine.text = cleanGhost(base, raw);\n painter.current?.();\n },\n ).then((outcome) => {\n mine.done = true;\n if (current() !== mine) return;\n if (outcome === \"ready\") return painter.current?.flush();\n painter.current?.cancel();\n // A failed stream's half a continuation is not an offer; the failure is `error`.\n if (outcome === \"error\") {\n mine.text = \"\";\n setGhost(\"\");\n }\n });\n },\n [run],\n );\n\n const short = (value: string) => value.trim().length < MIN_CONTINUE_LENGTH;\n\n /** The field changed. Ask after a debounce — unless the offer on the table survives it. */\n const setValue = useCallback(\n (value: string, position = value.length) => {\n field.current = { value, position };\n if (alive()) {\n setGhost(rendered());\n return;\n }\n drop(\"ignored\");\n if (short(value)) return;\n timer.current = setTimeout(\n () => start(value, position, \"automatic\"),\n opts.current.debounceMs ?? 350,\n );\n },\n [alive, drop, rendered, start],\n );\n\n /** Ask now — the ✨, and Alt+] past the last alternative. */\n const ask = useCallback(\n (value: string, position = value.length) => {\n const same = field.current.value === value && field.current.position === position;\n field.current = { value, position };\n if (!same) drop(\"ignored\");\n clearTimeout(timer.current);\n if (short(value)) return;\n start(value, position, \"invoked\");\n },\n [drop, start],\n );\n\n /** Move between alternatives; past the last one, ask for another. */\n const cycle = useCallback(\n (step: 1 | -1) => {\n const next = index.current + step;\n if (next < 0) return;\n if (next >= offers.current.length) {\n const { value, position } = field.current;\n ask(value, position);\n return;\n }\n index.current = next;\n setGhost(rendered());\n painter.current?.();\n },\n [ask, rendered],\n );\n\n const settle = useCallback(\n (kind: \"accepted\" | \"partial\", text: string) => {\n const o = current();\n if (o) opts.current.onEvent(kind, { text, range: [field.current.position, field.current.position] });\n if (kind === \"accepted\") {\n offers.current = [];\n index.current = 0;\n shown.current = null;\n clearTimeout(timer.current);\n painter.current?.cancel();\n cancel();\n setGhost(\"\");\n }\n },\n [cancel],\n );\n\n useEffect(\n () => () => {\n clearTimeout(timer.current);\n painter.current?.cancel();\n },\n [],\n );\n\n return { ghost, status, error, setValue, ask, cycle, settle, dismiss: drop };\n}\n\n// ── The candidates: N whole values, deduped ──────────────────────────────────\n\nconst norm = (v: string) => v.trim().toLowerCase();\n\n/**\n * A streamed list of whole values, deduped against what the field already holds. A budget rather\n * than a window: nothing refills when one is taken or dismissed.\n */\nexport function useCandidates(options: {\n source: (request: { existing: string[]; signal: AbortSignal }) => AsyncIterable<Proposal>;\n existing: string[];\n onEvent: (kind: AssistEvent[\"kind\"], proposal: Proposal) => void;\n limit?: number;\n}) {\n const { run, cancel: abort, reset, status, error } = useStream<Proposal>();\n const [items, setItems] = useState<Proposal[]>([]);\n const shown = useRef<Proposal[]>([]);\n const opts = useRef(options);\n opts.current = options;\n\n const put = useCallback((next: Proposal[]) => {\n shown.current = next;\n setItems(next);\n }, []);\n\n const refresh = useCallback(() => {\n const { existing, limit = 6, source } = opts.current;\n const seen = new Set(existing.map(norm).filter(Boolean));\n const taken: Proposal[] = [];\n put([]);\n void run(\n (signal) => source({ existing, signal }),\n (item) => {\n const key = norm(item.text);\n if (!key || seen.has(key)) return true;\n seen.add(key);\n taken.push(item);\n put([...taken]);\n opts.current.onEvent(\"shown\", item);\n return taken.length < limit;\n },\n );\n }, [put, run]);\n\n /** The ✨: a first set, a different set, or a retry — never inert, except while loading. */\n const press = useCallback(() => {\n if (status === \"loading\") return;\n refresh();\n }, [refresh, status]);\n\n const remove = useCallback(\n (text: string, kind: \"accepted\" | \"rejected\") => {\n const key = norm(text);\n const item = shown.current.find((p) => norm(p.text) === key);\n if (item) opts.current.onEvent(kind, item);\n const next = shown.current.filter((p) => norm(p.text) !== key);\n put(next);\n // Nothing left of an answer is not an answer with nothing in it.\n if (next.length === 0) reset();\n },\n [put, reset],\n );\n\n const cancel = useCallback(() => {\n abort();\n put([]);\n }, [abort, put]);\n\n return { items, status, error, press, take: (t: string) => remove(t, \"accepted\"), dismiss: (t: string) => remove(t, \"rejected\"), cancel };\n}\n"],"names":[],"mappings":";;AA4CO;AACL;AAOE;AAEU;AAGZ;;AAAsB;AAAc;AAEpC;AAAY;;AAER;AACA;AACA;AAIE;AACE;AACA;AACE;AACA;AACA;AACA;AACE;AACA;AAAA;AACF;AACF;AAGA;AAGO;AAET;AACO;AACT;AACF;AACI;;AAIJ;AACU;AAIV;AAA+C;AAGjD;AACF;AAMA;AACE;AAEA;AAEE;AAEE;AAEA;AACD;AAGD;AAES;AAOX;AAJE;AACA;AACa;AAGjB;AAOO;AACL;AACA;AACA;AACA;AAGF;AAKO;AA6BA;AAKL;AAGA;AAEA;AASE;AACA;AACA;AACA;AACA;AACA;AAA+D;AAI/D;AACA;AACA;AACA;AACA;AAEA;AAAsE;AAMxE;AACE;AACA;AACA;AACA;AAE2C;AAK7C;AAAa;;AAET;AAEA;AACA;AAKW;AACb;AACO;AAGK;AAEV;AAEA;AAEA;AACA;AACA;AAAK;AACwE;;AAEzE;AACA;AAEA;AACF;;AAGA;AACA;AACA;AAIa;AAAA;AAEd;AACH;AACI;AAMW;AAGb;AACE;AACA;AAAA;AAGF;AACgB;AAC0B;AACb;AAAA;AAE/B;AAC6B;AAInB;AAER;AAIA;AACgC;AAClC;AACY;AAIA;;AAEV;AACA;AACA;AACE;AACA;AACA;AAAA;AAEF;AAEA;AAAA;AACF;AACc;AAGD;;AAGX;AAQa;AAEf;AACO;AAGT;AAAA;;AAEI;AACiB;AACnB;AACA;AAIJ;AAIA;AAMO;AAML;AAIA;AAEA;AACE;AACa;AAIb;AAGA;AACK;AACoC;AAErC;AACA;AAKsB;AACxB;AAAA;AAMF;AACA;AAGa;AAEX;AAEA;AACA;AACA;AAEuB;AACzB;AACW;AAIX;AACM;AAGR;AACF;;;;;;;;"}
package/dist/index.d.ts CHANGED
@@ -1,6 +1,5 @@
1
1
  export { AssistProvider, Assist } from './assist.js';
2
2
  export type { AssistProviderProps, AssistProps, AssistTranslations } from './assist.js';
3
- export { AiError } from './engine.js';
4
3
  export type { AssistEvent, Proposal } from './engine.js';
5
4
  export { Chat } from './chat.js';
6
5
  export type { ChatProps, ChatToolRenderers, ChatTranslations } from './chat.js';
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAQA,OAAO,EAAE,cAAc,EAAE,MAAM,EAAE,MAAM,aAAa,CAAC;AACrD,YAAY,EAAE,mBAAmB,EAAE,WAAW,EAAE,kBAAkB,EAAE,MAAM,aAAa,CAAC;AACxF,OAAO,EAAE,OAAO,EAAE,MAAM,aAAa,CAAC;AACtC,YAAY,EAAE,WAAW,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAC;AAEzD,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AACjC,YAAY,EAAE,SAAS,EAAE,iBAAiB,EAAE,gBAAgB,EAAE,MAAM,WAAW,CAAC;AAChF,YAAY,EAAE,QAAQ,EAAE,MAAM,WAAW,CAAC;AAE1C,OAAO,EAAE,OAAO,EAAE,MAAM,eAAe,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAQA,OAAO,EAAE,cAAc,EAAE,MAAM,EAAE,MAAM,aAAa,CAAC;AACrD,YAAY,EAAE,mBAAmB,EAAE,WAAW,EAAE,kBAAkB,EAAE,MAAM,aAAa,CAAC;AACxF,YAAY,EAAE,WAAW,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAC;AAEzD,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AACjC,YAAY,EAAE,SAAS,EAAE,iBAAiB,EAAE,gBAAgB,EAAE,MAAM,WAAW,CAAC;AAChF,YAAY,EAAE,QAAQ,EAAE,MAAM,WAAW,CAAC;AAE1C,OAAO,EAAE,OAAO,EAAE,MAAM,eAAe,CAAC"}
package/dist/index.js CHANGED
@@ -1,12 +1,10 @@
1
- import { Assist as t, AssistProvider as s } from "./assist.js";
2
- import { AiError as f } from "./engine.js";
3
- import { Chat as m } from "./chat.js";
4
- import { useChat as x } from "@ai-sdk/react";
1
+ import { Assist as s, AssistProvider as t } from "./assist.js";
2
+ import { Chat as f } from "./chat.js";
3
+ import { useChat as m } from "@ai-sdk/react";
5
4
  export {
6
- f as AiError,
7
- t as Assist,
8
- s as AssistProvider,
9
- m as Chat,
10
- x as useChat
5
+ s as Assist,
6
+ t as AssistProvider,
7
+ f as Chat,
8
+ m as useChat
11
9
  };
12
10
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sources":[],"sourcesContent":[],"names":[],"mappings":";;;;"}
1
+ {"version":3,"file":"index.js","sources":[],"sourcesContent":[],"names":[],"mappings":";;;"}
@@ -1 +1 @@
1
- {"version":3,"file":"proposals.d.ts","sourceRoot":"","sources":["../src/proposals.ts"],"names":[],"mappings":"AAIA,OAAO,EAAE,KAAK,aAAa,EAAkC,MAAM,iBAAiB,CAAC;AACrF,OAAO,KAAK,EAAE,mBAAmB,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAC;AAEjE,4DAA4D;AAC5D,MAAM,WAAW,UAAU;IACzB,sDAAsD;IACtD,IAAI,EAAE,MAAM,CAAC;IACb,6DAA6D;IAC7D,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,2EAA2E;IAC3E,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,mEAAmE;IACnE,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AA+BD,+CAA+C;AAC/C,wBAAuB,YAAY,CACjC,KAAK,EAAE,aAAa,EACpB,KAAK,EAAE,UAAU,EACjB,EAAE,KAAK,EAAE,QAAQ,EAAE,OAAO,EAAE,KAAK,EAAE,MAAM,EAAE,EAAE,mBAAmB,GAC/D,aAAa,CAAC,MAAM,CAAC,CAcvB;AAcD;;;GAGG;AACH,wBAAuB,UAAU,CAC/B,KAAK,EAAE,aAAa,EACpB,KAAK,EAAE,UAAU,EACjB,EAAE,KAAK,EAAE,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,IAAI,EAAE,EAAE;IACxC,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,EAAE,MAAM,EAAE,CAAC;IACnB,MAAM,EAAE,WAAW,CAAC;IACpB,KAAK,EAAE,MAAM,CAAC;IACd,sFAAsF;IACtF,IAAI,EAAE,OAAO,CAAC;CACf,GACA,aAAa,CAAC,QAAQ,CAAC,CAmBzB"}
1
+ {"version":3,"file":"proposals.d.ts","sourceRoot":"","sources":["../src/proposals.ts"],"names":[],"mappings":"AAIA,OAAO,EAAE,KAAK,aAAa,EAAkC,MAAM,iBAAiB,CAAC;AACrF,OAAO,KAAK,EAAE,mBAAmB,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAC;AAEjE,4DAA4D;AAC5D,MAAM,WAAW,UAAU;IACzB,sDAAsD;IACtD,IAAI,EAAE,MAAM,CAAC;IACb,6DAA6D;IAC7D,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,2EAA2E;IAC3E,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,mEAAmE;IACnE,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AA+BD,+CAA+C;AAC/C,wBAAuB,YAAY,CACjC,KAAK,EAAE,aAAa,EACpB,KAAK,EAAE,UAAU,EACjB,EAAE,KAAK,EAAE,QAAQ,EAAE,OAAO,EAAE,KAAK,EAAE,MAAM,EAAE,EAAE,mBAAmB,GAC/D,aAAa,CAAC,MAAM,CAAC,CAevB;AAcD;;;GAGG;AACH,wBAAuB,UAAU,CAC/B,KAAK,EAAE,aAAa,EACpB,KAAK,EAAE,UAAU,EACjB,EAAE,KAAK,EAAE,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,IAAI,EAAE,EAAE;IACxC,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,EAAE,MAAM,EAAE,CAAC;IACnB,MAAM,EAAE,WAAW,CAAC;IACpB,KAAK,EAAE,MAAM,CAAC;IACd,sFAAsF;IACtF,IAAI,EAAE,OAAO,CAAC;CACf,GACA,aAAa,CAAC,QAAQ,CAAC,CAoBzB"}
package/dist/proposals.js CHANGED
@@ -1,5 +1,5 @@
1
- import { streamText as p, Output as h, jsonSchema as f } from "@kanzo-tech/llm";
2
- const m = (e) => [
1
+ import { streamText as m, Output as h, jsonSchema as f } from "@kanzo-tech/llm";
2
+ const p = (e) => [
3
3
  `Field: ${e.name || "(unnamed)"}`,
4
4
  e.description && `Field description: ${e.description}`,
5
5
  e.instructions && `Instructions: ${e.instructions}`,
@@ -23,15 +23,16 @@ async function* E(e, n, { value: t, position: o, trigger: c, avoid: a, signal: i
23
23
  Offer something different from these earlier suggestions:
24
24
  ${a.map((r) => `- ${r.trim()}`).join(`
25
25
  `)}` : "", l = d();
26
- yield* p({
26
+ yield* m({
27
27
  model: e,
28
28
  system: g,
29
- prompt: `${m(n)}
29
+ prompt: `${p(n)}
30
30
  Request: ${c}${s}
31
31
 
32
32
  Text:
33
33
  ${u}`,
34
34
  abortSignal: i,
35
+ maxRetries: 0,
35
36
  onError: l.onError
36
37
  }).textStream, l.rethrow();
37
38
  }
@@ -43,19 +44,20 @@ const y = f({
43
44
  },
44
45
  required: ["value", "rationale"],
45
46
  additionalProperties: !1
46
- }), $ = "You suggest values for a form field. Each value must be complete and ready to enter as-is, in the language of the form.";
47
+ }), x = "You suggest values for a form field. Each value must be complete and ready to enter as-is, in the language of the form.";
47
48
  async function* w(e, n, { value: t, existing: o, signal: c, count: a, list: i }) {
48
49
  const u = i ? `Current items: ${o.length ? o.join(", ") : "(none)"}
49
50
  Suggest NEW items, not already present.` : `Current value: ${t || "(empty)"}
50
- Suggest alternatives to replace it${t ? ", different from it" : ""}.`, s = d(), l = p({
51
+ Suggest alternatives to replace it${t ? ", different from it" : ""}.`, s = d(), l = m({
51
52
  model: e,
52
53
  onError: s.onError,
53
- system: $,
54
- prompt: `${m(n)}
54
+ system: x,
55
+ prompt: `${p(n)}
55
56
  ${u}
56
57
  Give ${a}.`,
57
58
  output: h.array({ element: y }),
58
- abortSignal: c
59
+ abortSignal: c,
60
+ maxRetries: 0
59
61
  });
60
62
  for await (const r of l.elementStream)
61
63
  yield i ? { text: r.value, rationale: r.rationale } : { text: r.value, rationale: r.rationale, range: [0, t.length] };
@@ -1 +1 @@
1
- {"version":3,"file":"proposals.js","sources":["../src/proposals.ts"],"sourcesContent":["// What a field asks the model, written once. A host names the model and, at most, a sentence of\n// instructions; the field describes itself through its accessible name and description, which is\n// what a person filling it in reads too.\n\nimport { type LanguageModel, Output, jsonSchema, streamText } from \"@kanzo-tech/llm\";\nimport type { ContinuationRequest, Proposal } from \"./engine.js\";\n\n/** What the model is told about the field it is filling. */\nexport interface FieldBrief {\n /** The accessible name — the label a person reads. */\n name: string;\n /** The accessible description — the helper text under it. */\n description?: string;\n /** The host's own instruction for this field (`<Assist instructions>`). */\n instructions?: string;\n /** What the rest of the form says (`<AssistProvider context>`). */\n context?: string;\n}\n\nconst brief = (field: FieldBrief) =>\n [\n `Field: ${field.name || \"(unnamed)\"}`,\n field.description && `Field description: ${field.description}`,\n field.instructions && `Instructions: ${field.instructions}`,\n field.context && `Form context:\\n${field.context}`,\n ]\n .filter(Boolean)\n .join(\"\\n\");\n\nconst CONTINUE = `You complete text inside a form field, like an editor's inline completion.\nReply with ONLY the text to insert at <caret/>: no quotes, no commentary, never repeat text that is already there.\nKeep the language, tone and format of the existing text. Stop at a natural point: the end of the sentence for an automatic suggestion; up to a short paragraph when explicitly asked.`;\n\n/**\n * The AI SDK reports a failed stream to `onError` and then ends the stream as if it had finished, so\n * a refused or broken call would read as a model with nothing to say. This keeps what was reported\n * and throws it, whole, once the stream ends.\n */\nfunction failures() {\n let failed: { error: unknown } | undefined;\n return {\n onError: ({ error }: { error: unknown }) => void (failed ??= { error }),\n rethrow: () => {\n if (failed) throw failed.error;\n },\n };\n}\n\n/** One continuation at the caret, streamed. */\nexport async function* continuation(\n model: LanguageModel,\n field: FieldBrief,\n { value, position, trigger, avoid, signal }: ContinuationRequest,\n): AsyncIterable<string> {\n const text = `${value.slice(0, position)}<caret/>${value.slice(position)}`;\n const differ = avoid.length\n ? `\\nOffer something different from these earlier suggestions:\\n${avoid.map((a) => `- ${a.trim()}`).join(\"\\n\")}`\n : \"\";\n const reported = failures();\n yield* streamText({\n model,\n system: CONTINUE,\n prompt: `${brief(field)}\\nRequest: ${trigger}${differ}\\n\\nText:\\n${text}`,\n abortSignal: signal,\n onError: reported.onError,\n }).textStream;\n reported.rethrow();\n}\n\nconst CANDIDATE = jsonSchema<{ value: string; rationale: string }>({\n type: \"object\",\n properties: {\n value: { type: \"string\", description: \"The whole value, exactly as it would be entered.\" },\n rationale: { type: \"string\", description: \"Why it fits, in one short sentence.\" },\n },\n required: [\"value\", \"rationale\"],\n additionalProperties: false,\n});\n\nconst CANDIDATES = `You suggest values for a form field. Each value must be complete and ready to enter as-is, in the language of the form.`;\n\n/**\n * Whole values for the field, each arriving as soon as it is complete (`elementStream`), so the\n * first candidate is on screen while the model writes the rest.\n */\nexport async function* candidates(\n model: LanguageModel,\n field: FieldBrief,\n { value, existing, signal, count, list }: {\n value: string;\n existing: string[];\n signal: AbortSignal;\n count: number;\n /** The field holds a list (tags): a candidate is one more item, not a replacement. */\n list: boolean;\n },\n): AsyncIterable<Proposal> {\n const holds = list\n ? `Current items: ${existing.length ? existing.join(\", \") : \"(none)\"}\\nSuggest NEW items, not already present.`\n : `Current value: ${value || \"(empty)\"}\\nSuggest alternatives to replace it${value ? \", different from it\" : \"\"}.`;\n const reported = failures();\n const result = streamText({\n model,\n onError: reported.onError,\n system: CANDIDATES,\n prompt: `${brief(field)}\\n${holds}\\nGive ${count}.`,\n output: Output.array({ element: CANDIDATE }),\n abortSignal: signal,\n });\n for await (const item of result.elementStream) {\n yield list\n ? { text: item.value, rationale: item.rationale }\n : { text: item.value, rationale: item.rationale, range: [0, value.length] };\n }\n reported.rethrow();\n}\n"],"names":["brief","field","CONTINUE","failures","failed","error","continuation","model","value","position","trigger","avoid","signal","text","differ","a","reported","streamText","CANDIDATE","jsonSchema","CANDIDATES","candidates","existing","count","list","holds","result","Output","item"],"mappings":";AAmBA,MAAMA,IAAQ,CAACC,MACb;AAAA,EACE,UAAUA,EAAM,QAAQ,WAAW;AAAA,EACnCA,EAAM,eAAe,sBAAsBA,EAAM,WAAW;AAAA,EAC5DA,EAAM,gBAAgB,iBAAiBA,EAAM,YAAY;AAAA,EACzDA,EAAM,WAAW;AAAA,EAAkBA,EAAM,OAAO;AAClD,EACG,OAAO,OAAO,EACd,KAAK;AAAA,CAAI,GAERC,IAAW;AAAA;AAAA;AASjB,SAASC,IAAW;AAClB,MAAIC;AACJ,SAAO;AAAA,IACL,SAAS,CAAC,EAAE,OAAAC,EAAA,MAAgC,MAAMD,UAAW,EAAE,OAAAC;IAC/D,SAAS,MAAM;AACb,UAAID,SAAcA,EAAO;AAAA,IAC3B;AAAA,EAAA;AAEJ;AAGA,gBAAuBE,EACrBC,GACAN,GACA,EAAE,OAAAO,GAAO,UAAAC,GAAU,SAAAC,GAAS,OAAAC,GAAO,QAAAC,KACZ;AACvB,QAAMC,IAAO,GAAGL,EAAM,MAAM,GAAGC,CAAQ,CAAC,WAAWD,EAAM,MAAMC,CAAQ,CAAC,IAClEK,IAASH,EAAM,SACjB;AAAA;AAAA,EAAgEA,EAAM,IAAI,CAACI,MAAM,KAAKA,EAAE,KAAA,CAAM,EAAE,EAAE,KAAK;AAAA,CAAI,CAAC,KAC5G,IACEC,IAAWb,EAAA;AACjB,SAAOc,EAAW;AAAA,IAChB,OAAAV;AAAA,IACA,QAAQL;AAAA,IACR,QAAQ,GAAGF,EAAMC,CAAK,CAAC;AAAA,WAAcS,CAAO,GAAGI,CAAM;AAAA;AAAA;AAAA,EAAcD,CAAI;AAAA,IACvE,aAAaD;AAAA,IACb,SAASI,EAAS;AAAA,EAAA,CACnB,EAAE,YACHA,EAAS,QAAA;AACX;AAEA,MAAME,IAAYC,EAAiD;AAAA,EACjE,MAAM;AAAA,EACN,YAAY;AAAA,IACV,OAAO,EAAE,MAAM,UAAU,aAAa,mDAAA;AAAA,IACtC,WAAW,EAAE,MAAM,UAAU,aAAa,sCAAA;AAAA,EAAsC;AAAA,EAElF,UAAU,CAAC,SAAS,WAAW;AAAA,EAC/B,sBAAsB;AACxB,CAAC,GAEKC,IAAa;AAMnB,gBAAuBC,EACrBd,GACAN,GACA,EAAE,OAAAO,GAAO,UAAAc,GAAU,QAAAV,GAAQ,OAAAW,GAAO,MAAAC,KAQT;AACzB,QAAMC,IAAQD,IACV,kBAAkBF,EAAS,SAASA,EAAS,KAAK,IAAI,IAAI,QAAQ;AAAA,2CAClE,kBAAkBd,KAAS,SAAS;AAAA,oCAAuCA,IAAQ,wBAAwB,EAAE,KAC3GQ,IAAWb,EAAA,GACXuB,IAAST,EAAW;AAAA,IACxB,OAAAV;AAAA,IACA,SAASS,EAAS;AAAA,IAClB,QAAQI;AAAA,IACR,QAAQ,GAAGpB,EAAMC,CAAK,CAAC;AAAA,EAAKwB,CAAK;AAAA,OAAUF,CAAK;AAAA,IAChD,QAAQI,EAAO,MAAM,EAAE,SAAST,GAAW;AAAA,IAC3C,aAAaN;AAAA,EAAA,CACd;AACD,mBAAiBgB,KAAQF,EAAO;AAC9B,UAAMF,IACF,EAAE,MAAMI,EAAK,OAAO,WAAWA,EAAK,cACpC,EAAE,MAAMA,EAAK,OAAO,WAAWA,EAAK,WAAW,OAAO,CAAC,GAAGpB,EAAM,MAAM,EAAA;AAE5E,EAAAQ,EAAS,QAAA;AACX;"}
1
+ {"version":3,"file":"proposals.js","sources":["../src/proposals.ts"],"sourcesContent":["// What a field asks the model, written once. A host names the model and, at most, a sentence of\n// instructions; the field describes itself through its accessible name and description, which is\n// what a person filling it in reads too.\n\nimport { type LanguageModel, Output, jsonSchema, streamText } from \"@kanzo-tech/llm\";\nimport type { ContinuationRequest, Proposal } from \"./engine.js\";\n\n/** What the model is told about the field it is filling. */\nexport interface FieldBrief {\n /** The accessible name — the label a person reads. */\n name: string;\n /** The accessible description — the helper text under it. */\n description?: string;\n /** The host's own instruction for this field (`<Assist instructions>`). */\n instructions?: string;\n /** What the rest of the form says (`<AssistProvider context>`). */\n context?: string;\n}\n\nconst brief = (field: FieldBrief) =>\n [\n `Field: ${field.name || \"(unnamed)\"}`,\n field.description && `Field description: ${field.description}`,\n field.instructions && `Instructions: ${field.instructions}`,\n field.context && `Form context:\\n${field.context}`,\n ]\n .filter(Boolean)\n .join(\"\\n\");\n\nconst CONTINUE = `You complete text inside a form field, like an editor's inline completion.\nReply with ONLY the text to insert at <caret/>: no quotes, no commentary, never repeat text that is already there.\nKeep the language, tone and format of the existing text. Stop at a natural point: the end of the sentence for an automatic suggestion; up to a short paragraph when explicitly asked.`;\n\n/**\n * The AI SDK reports a failed stream to `onError` and then ends the stream as if it had finished, so\n * a refused or broken call would read as a model with nothing to say. This keeps what was reported\n * and throws it, whole, once the stream ends.\n */\nfunction failures() {\n let failed: { error: unknown } | undefined;\n return {\n onError: ({ error }: { error: unknown }) => void (failed ??= { error }),\n rethrow: () => {\n if (failed) throw failed.error;\n },\n };\n}\n\n/** One continuation at the caret, streamed. */\nexport async function* continuation(\n model: LanguageModel,\n field: FieldBrief,\n { value, position, trigger, avoid, signal }: ContinuationRequest,\n): AsyncIterable<string> {\n const text = `${value.slice(0, position)}<caret/>${value.slice(position)}`;\n const differ = avoid.length\n ? `\\nOffer something different from these earlier suggestions:\\n${avoid.map((a) => `- ${a.trim()}`).join(\"\\n\")}`\n : \"\";\n const reported = failures();\n yield* streamText({\n model,\n system: CONTINUE,\n prompt: `${brief(field)}\\nRequest: ${trigger}${differ}\\n\\nText:\\n${text}`,\n abortSignal: signal,\n maxRetries: 0,\n onError: reported.onError,\n }).textStream;\n reported.rethrow();\n}\n\nconst CANDIDATE = jsonSchema<{ value: string; rationale: string }>({\n type: \"object\",\n properties: {\n value: { type: \"string\", description: \"The whole value, exactly as it would be entered.\" },\n rationale: { type: \"string\", description: \"Why it fits, in one short sentence.\" },\n },\n required: [\"value\", \"rationale\"],\n additionalProperties: false,\n});\n\nconst CANDIDATES = `You suggest values for a form field. Each value must be complete and ready to enter as-is, in the language of the form.`;\n\n/**\n * Whole values for the field, each arriving as soon as it is complete (`elementStream`), so the\n * first candidate is on screen while the model writes the rest.\n */\nexport async function* candidates(\n model: LanguageModel,\n field: FieldBrief,\n { value, existing, signal, count, list }: {\n value: string;\n existing: string[];\n signal: AbortSignal;\n count: number;\n /** The field holds a list (tags): a candidate is one more item, not a replacement. */\n list: boolean;\n },\n): AsyncIterable<Proposal> {\n const holds = list\n ? `Current items: ${existing.length ? existing.join(\", \") : \"(none)\"}\\nSuggest NEW items, not already present.`\n : `Current value: ${value || \"(empty)\"}\\nSuggest alternatives to replace it${value ? \", different from it\" : \"\"}.`;\n const reported = failures();\n const result = streamText({\n model,\n onError: reported.onError,\n system: CANDIDATES,\n prompt: `${brief(field)}\\n${holds}\\nGive ${count}.`,\n output: Output.array({ element: CANDIDATE }),\n abortSignal: signal,\n maxRetries: 0,\n });\n for await (const item of result.elementStream) {\n yield list\n ? { text: item.value, rationale: item.rationale }\n : { text: item.value, rationale: item.rationale, range: [0, value.length] };\n }\n reported.rethrow();\n}\n"],"names":["brief","field","CONTINUE","failures","failed","error","continuation","model","value","position","trigger","avoid","signal","text","differ","a","reported","streamText","CANDIDATE","jsonSchema","CANDIDATES","candidates","existing","count","list","holds","result","Output","item"],"mappings":";AAmBA,MAAMA,IAAQ,CAACC,MACb;AAAA,EACE,UAAUA,EAAM,QAAQ,WAAW;AAAA,EACnCA,EAAM,eAAe,sBAAsBA,EAAM,WAAW;AAAA,EAC5DA,EAAM,gBAAgB,iBAAiBA,EAAM,YAAY;AAAA,EACzDA,EAAM,WAAW;AAAA,EAAkBA,EAAM,OAAO;AAClD,EACG,OAAO,OAAO,EACd,KAAK;AAAA,CAAI,GAERC,IAAW;AAAA;AAAA;AASjB,SAASC,IAAW;AAClB,MAAIC;AACJ,SAAO;AAAA,IACL,SAAS,CAAC,EAAE,OAAAC,EAAA,MAAgC,MAAMD,UAAW,EAAE,OAAAC;IAC/D,SAAS,MAAM;AACb,UAAID,SAAcA,EAAO;AAAA,IAC3B;AAAA,EAAA;AAEJ;AAGA,gBAAuBE,EACrBC,GACAN,GACA,EAAE,OAAAO,GAAO,UAAAC,GAAU,SAAAC,GAAS,OAAAC,GAAO,QAAAC,KACZ;AACvB,QAAMC,IAAO,GAAGL,EAAM,MAAM,GAAGC,CAAQ,CAAC,WAAWD,EAAM,MAAMC,CAAQ,CAAC,IAClEK,IAASH,EAAM,SACjB;AAAA;AAAA,EAAgEA,EAAM,IAAI,CAACI,MAAM,KAAKA,EAAE,KAAA,CAAM,EAAE,EAAE,KAAK;AAAA,CAAI,CAAC,KAC5G,IACEC,IAAWb,EAAA;AACjB,SAAOc,EAAW;AAAA,IAChB,OAAAV;AAAA,IACA,QAAQL;AAAA,IACR,QAAQ,GAAGF,EAAMC,CAAK,CAAC;AAAA,WAAcS,CAAO,GAAGI,CAAM;AAAA;AAAA;AAAA,EAAcD,CAAI;AAAA,IACvE,aAAaD;AAAA,IACb,YAAY;AAAA,IACZ,SAASI,EAAS;AAAA,EAAA,CACnB,EAAE,YACHA,EAAS,QAAA;AACX;AAEA,MAAME,IAAYC,EAAiD;AAAA,EACjE,MAAM;AAAA,EACN,YAAY;AAAA,IACV,OAAO,EAAE,MAAM,UAAU,aAAa,mDAAA;AAAA,IACtC,WAAW,EAAE,MAAM,UAAU,aAAa,sCAAA;AAAA,EAAsC;AAAA,EAElF,UAAU,CAAC,SAAS,WAAW;AAAA,EAC/B,sBAAsB;AACxB,CAAC,GAEKC,IAAa;AAMnB,gBAAuBC,EACrBd,GACAN,GACA,EAAE,OAAAO,GAAO,UAAAc,GAAU,QAAAV,GAAQ,OAAAW,GAAO,MAAAC,KAQT;AACzB,QAAMC,IAAQD,IACV,kBAAkBF,EAAS,SAASA,EAAS,KAAK,IAAI,IAAI,QAAQ;AAAA,2CAClE,kBAAkBd,KAAS,SAAS;AAAA,oCAAuCA,IAAQ,wBAAwB,EAAE,KAC3GQ,IAAWb,EAAA,GACXuB,IAAST,EAAW;AAAA,IACxB,OAAAV;AAAA,IACA,SAASS,EAAS;AAAA,IAClB,QAAQI;AAAA,IACR,QAAQ,GAAGpB,EAAMC,CAAK,CAAC;AAAA,EAAKwB,CAAK;AAAA,OAAUF,CAAK;AAAA,IAChD,QAAQI,EAAO,MAAM,EAAE,SAAST,GAAW;AAAA,IAC3C,aAAaN;AAAA,IACb,YAAY;AAAA,EAAA,CACb;AACD,mBAAiBgB,KAAQF,EAAO;AAC9B,UAAMF,IACF,EAAE,MAAMI,EAAK,OAAO,WAAWA,EAAK,cACpC,EAAE,MAAMA,EAAK,OAAO,WAAWA,EAAK,WAAW,OAAO,CAAC,GAAGpB,EAAM,MAAM,EAAA;AAE5E,EAAAQ,EAAS,QAAA;AACX;"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kanzo-tech/ai",
3
- "version": "0.20.0",
3
+ "version": "0.21.0",
4
4
  "description": "Kanzo AI surfaces — a field the model helps fill (Assist) and a conversation with one (Chat), over the AI SDK through @kanzo-tech/llm. Sibling of @kanzo-tech/ui, not part of it.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -30,12 +30,12 @@
30
30
  "lucide-react": ">=1",
31
31
  "react": ">=19",
32
32
  "react-dom": ">=19",
33
- "@kanzo-tech/ui": "0.20.0"
33
+ "@kanzo-tech/ui": "0.21.0"
34
34
  },
35
35
  "dependencies": {
36
36
  "streamdown": "^2.5.0",
37
37
  "tailwind-variants": "^3.2.2",
38
- "@kanzo-tech/llm": "0.20.0"
38
+ "@kanzo-tech/llm": "0.21.0"
39
39
  },
40
40
  "devDependencies": {
41
41
  "@ai-sdk/react": "^4.0.129",
@@ -51,8 +51,8 @@
51
51
  "react": "^19.0.0",
52
52
  "react-dom": "^19.0.0",
53
53
  "rollup-plugin-preserve-directives": "^0.4.0",
54
- "@kanzo-tech/theme": "0.20.0",
55
- "@kanzo-tech/ui": "0.20.0"
54
+ "@kanzo-tech/theme": "0.21.0",
55
+ "@kanzo-tech/ui": "0.21.0"
56
56
  },
57
57
  "//size-limit": "The root barrel, measured without the peers and dependencies a host already has. Assist and Chat tree-shake apart: a host that only assists fields does not ship the transcript, and the markdown parser comes with Chat alone.",
58
58
  "size-limit": [