['Ctrl', 'Enter'], 'what' => 'Ask', 'id' => 'send'], ['keys' => ['↑'], 'what' => 'Your last question, to edit', 'id' => 'recall'], ['keys' => ['Esc'], 'what' => 'Close a menu, or clear the box', 'id' => 'esc'], ['keys' => ['Alt', 'N'], 'what' => 'New conversation', 'id' => 'new'], ['keys' => ['Alt', 'E'], 'what' => 'Expand or collapse', 'id' => 'grow'], ['keys' => ['Alt', 'S'], 'what' => 'Medium or large', 'id' => 'size'], ['keys' => ['Alt', 'H'], 'what' => 'Saved conversations', 'id' => 'hist'], ]; } // One instance's markup. The prefix composes every id, so a page may render more than one. // // Every placement renders the same thing. What a page chooses is how much room it gets — width is // the container's business, the two heights are this function's — and which extra controls belong // to that surface. Everything else is fixed on purpose: the control row, its order, what the // buttons do. A chat that rearranges itself per tab is three components wearing one name. // // profile which profile starts active // compact card-sized chrome, for a chat living inside a card // height resting transcript height, the collapsed state. Defaults per mode rather than being // left empty: the expand control is standard, and a control with no second height to // move to is a button that does nothing on the surface that forgot to pass a number. // tall expanded at the Medium setting. Defaults to 2x height. // tallLarge expanded at the Large setting. Defaults to 3x height. // Two expanded sizes because ⤢ and the banner's Medium/Large answer different // questions: whether the conversation is expanded, and how much that is worth. The // first is an action taken constantly, the second a preference set once. // title banner text. Defaults to "Assistant" // icon raw SVG for the banner, page-supplied and emitted unescaped // empty empty-state text // scope what this conversation is about — string, or a function re-read at send time for a // surface whose subject follows the view the operator has open // controls extra markup dropped in at the head of the action group function vv_ai_chat_markup(string $prefix, array $o = []): void { $p = htmlspecialchars($prefix, ENT_QUOTES); $compact = !empty($o['compact']); // A card-sized chat and a full-page one want very different resting heights, and neither wants // none: see `height` above. $height = $o['height'] ?? ($compact ? '300px' : '46vh'); $empty = $o['empty'] ?? 'Ask Varaverk about itself.'; // Derived from the resting height, so a page need only state the one number it actually cares // about. Large is 3x, capped at 85vh — vh is a share of the viewport and an expand that puts // the composer out of reach is worse than no expand. // // Medium is placed between resting and large rather than at a multiple of its own, because a // fixed multiple collapses under that cap: at 52vh resting, 2x and 3x are both 85vh after // clamping, and the Medium/Large button would have had two settings that did the same thing. $sizes = function (string $from): array { if (!preg_match('/^(\d+(?:\.\d+)?)(px|vh|em|rem)$/', $from, $m)) return ['', '']; $fmt = fn(float $n) => rtrim(rtrim(number_format($n, 2, '.', ''), '0'), '.') . $m[2]; $base = (float)$m[1]; $lg = $base * 3.0; if ($m[2] === 'vh') $lg = min($lg, 85.0); return [$fmt($base + ($lg - $base) * 0.55), $fmt($lg)]; }; [$defMed, $defLarge] = $sizes($height); $tall = $o['tall'] ?? $defMed; $large = $o['tallLarge'] ?? $defLarge; $style = $height !== '' ? ' style="height:' . htmlspecialchars($height, ENT_QUOTES) . ';max-height:none;"' . ' data-h="' . htmlspecialchars($height, ENT_QUOTES) . '"' . ' data-h-tall="' . htmlspecialchars($tall, ENT_QUOTES) . '"' . ' data-h-large="' . htmlspecialchars($large, ENT_QUOTES) . '"' : ''; ?>
>
$def): ?>
>
loading…