UNPKG

ranui

Version:

A framework-agnostic Web Components UI library built on native custom elements, with TypeScript types, light/dark theming, SSR and PWA support.

185 lines (184 loc) 8.08 kB
import { EventManager } from '../../utils/builder'; import { RanElement } from '../../utils'; import type { ConversationNode, ConversationNodeDefinition } from 'ranuts/conversation'; /** * Name of the event the paging affordance fires. * * The element does not fetch anything — it does not know where a conversation lives. It * states that the reader asked, having already captured the anchor; the owner prepends its * rows and calls {@link Conversation.restoreAnchor}. */ export declare const OLDER_REQUEST = "olderrequest"; /** * One kind of conversation content: the state machine that owns its events, plus how to * put its state on screen. * * The two halves are declared together because a kind that renders nothing and a kind that * renders are the same thing to the projection — only `mount` differs. Omit `mount` for a * definition that exists so other definitions can read it through `reader.previous`. */ export interface ConversationNodeView<Event = unknown, State = unknown> extends ConversationNodeDefinition<Event, State> { /** * Builds the row for a newly opened node. * * @param node The node, with its initial state. * @returns The element to insert, or null to render nothing for this node. */ mount?(node: ConversationNode<State>): HTMLElement | null; /** * Applies a node's latest state to the row `mount` returned. * * Named apart from the definition's `update`, which folds state, because this folds * nothing: it is the one-way write of already-folded state onto an existing element. * Called on every publication that touched the node, so it should be cheap — this is * the path a streaming message takes once per frame. Omitting it means the row never * changes after it is created. * * @param element The row previously returned by `mount`. * @param node The node, with its current state. */ patch?(element: HTMLElement, node: ConversationNode<State>): void; } /** * `<r-conversation>` — renders an append-only event log as a conversation. * * The element owns three things that are tedious and easy to get wrong, and nothing else: * projecting events into nodes (`ranuts/conversation`), keeping the view pinned to its * floor without fighting the reader (`createBottomFollower`), and reconciling rows against * the node list. What a message, a tool call, or a status line *looks like* is not its * business — that is a registered view. * * ```ts * const chat = document.querySelector('r-conversation'); * chat.register({ * kind: 'message', * match: (e) => (e.type === 'start' ? { id: e.id, role: 'start' } : e.type === 'delta' ? { id: e.id, role: 'update' } : null), * start: () => ({ text: '' }), * update: (state, e) => ({ text: state.text + e.text }), * publication: (e) => (e.type === 'delta' ? 'animation-frame' : 'immediate'), * mount: () => document.createElement('r-markdown'), * patch: (el, node) => { (el as HTMLElement & { content: string }).content = node.state.text; }, * }); * chat.push({ type: 'start', id: 'm1' }); * ``` * * Attributes: `follow` (`true` by default — set `follow="false"` to leave the reader in * control from the start), `empty` (text shown while there are no rows), `sheet`. * Fires `pinnedchange` with `detail.pinned` whenever bottom-follow is gained or lost, so a * "jump to latest" affordance can track it. */ export declare class Conversation extends RanElement { _events: EventManager; _shadowDom: ShadowRoot; _scroll: HTMLElement; _list: HTMLElement; _older: HTMLElement; _olderButton: HTMLButtonElement; _footer: HTMLElement; private _views; private _engine; private _unsubscribe; private _follower; /** Row element per node key, so a publication patches rather than rebuilds. */ private _rows; private _emptyRow; static get observedAttributes(): string[]; constructor(); /** * Label for the paging affordance above the first row. Empty hides it. * * A conversation log only grows, so a client that renders all of it eventually renders * more than anyone will read. Paging keeps the rendered set bounded without pretending * the rest is gone: the button is the statement that there is more, and * {@link Conversation.olderrequest} is the request for it. */ get older(): string; set older(value: string); /** Whether a page is in flight; the affordance stays visible and goes inert. */ get loadingOlder(): boolean; set loadingOlder(value: boolean); /** Whether new content is followed until the reader scrolls away from the floor. */ get follow(): boolean; set follow(value: boolean); /** Text shown while the projection has produced no rows. */ get empty(): string; set empty(value: string); get sheet(): string; set sheet(value: string); /** Whether the view is currently following new content. */ get pinned(): boolean; /** * Registers one kind of conversation content. * * Every registration must land before the first {@link push}: the projection is built * once from the registered set, and a definition added afterwards would silently miss * every event already folded in. * * @param view The definition and its renderer. * @throws When events have already been pushed, or the kind is already registered. */ register<Event, State>(view: ConversationNodeView<Event, State>): void; /** * Projects one event and renders whatever it changed. * * @param event The log event, in the shape the registered views match on. */ push<Event>(event: Event): void; /** * Drops one row and every row opened after it. * * Editing a message, regenerating an answer and branching are the same operation: the * conversation diverges here, and what follows the divergence is no longer part of it. * * @param key The `kind:id` of the first row to drop. * @returns How many rows were dropped. Zero means no such row is live, which is a * caller's cue that its own idea of the conversation is stale. */ truncate(key: string): number; /** * Runs a burst of pushes as one render. * * Replaying a stored conversation without this is quadratic: every event publishes and * every publication walks the whole transcript. Use it for any replay, restore, or bulk * insert; a live stream does not need it, because one delta changes one row. * * @param run The pushes to run. */ batch(run: () => void): void; /** Drops every node and row, keeping the registered views. */ reset(): void; /** Scrolls to the floor and resumes following, whatever the reader did. */ scrollToBottom(): void; /** * Remembers a row's position before older content is prepended, so the reader keeps * looking at what they were looking at. * * @param key The node key to hold still; defaults to the topmost rendered row. * @returns Whether a row was captured. */ captureAnchor(key?: string): boolean; /** * Restores the row captured by {@link captureAnchor}. * * @returns Whether an anchor was restored. */ restoreAnchor(): boolean; connectedCallback(): void; disconnectedCallback(): void; attributeChangedCallback(name: string, old: string, next: string): void; handlerExternalCss: () => void; private _ensureEngine; private _viewFor; private _renderNodes; /** * Asks the owner for the page above the first row, holding the reader's position. * * The anchor is captured before the request rather than after it lands: by then the * prepend has already moved everything, and there is no earlier position left to record. * The owner restores it with {@link Conversation.restoreAnchor} once its rows are in. */ private _requestOlder; private _syncOlder; private _syncEmpty; } export default Conversation;