1558 lines
85 KiB
PHP
1558 lines
85 KiB
PHP
<?php
|
||
// ═══════════════════════════════════════════════════════════════════════════════════════════════
|
||
// PURPOSE
|
||
// The conversation surface itself — transcript, composer, profile and history pickers, source
|
||
// viewer and the stored-chat list — rendered wherever a chat belongs. All three surfaces use
|
||
// it: the AI tab, the Monitor tab's AI row, and the Scheduler's right-hand panel.
|
||
//
|
||
// WHY THIS IS AN INCLUDE
|
||
// There were two places that needed a real transcript and one implementation, which meant the
|
||
// second one was going to be a copy. Everything subtle here — the busy flag that must never
|
||
// stick, URLSearchParams instead of FormData, the poll ceiling, escaping before markdown, the
|
||
// teardown of a stale instance across an Unraid tab swap — is subtle because each of them
|
||
// already cost a diagnosis session once. A copy inherits none of the fixes that come after it.
|
||
//
|
||
// The Scheduler panel used to be exactly that copy: its own bar, send loop, poll and store
|
||
// handling. It proved the point — a thread there died on reload while the other two were saved,
|
||
// and its bar drifted into a different shape from the same control everywhere else. It is an
|
||
// instance now. What was genuinely particular to it turned into options rather than a second
|
||
// implementation: `scope` as a function for a subject that follows the open view, `beforeSend`
|
||
// for the one reply that is recorded rather than asked, `think` for reasoning on diagnosis only,
|
||
// `setHeights()` for a placement whose size is a share of a panel and not knowable until layout.
|
||
//
|
||
// WHAT A PLACEMENT MAY CHOOSE
|
||
// How much room it gets, and which extra controls belong to that surface. Not the shape: the
|
||
// control row, its order, and what the buttons do are fixed. A chat that rearranges itself per
|
||
// tab is three components wearing one name, which is the state this replaced.
|
||
//
|
||
// INSTANCES
|
||
// Every id is composed from a prefix, so two chats can coexist on one page. Each instance
|
||
// registers under window.__vvAiChat[prefix] and tears down the previous holder of that prefix
|
||
// on construction — Unraid swaps tab content by AJAX without unloading the old page's
|
||
// JavaScript, so the previous copy's timers are still running when the new one arrives.
|
||
//
|
||
// DESIGN PRINCIPLES
|
||
// The transcript and what the model is sent are not the same list.
|
||
// messages[] is everything on screen and everything stored. sendFrom is the index the
|
||
// model is allowed to see from. Switching profile moves sendFrom to the end rather than
|
||
// emptying the transcript: carrying cited, retrieval-grounded turns into a mode with no
|
||
// retrieval makes the model refer to sources it can no longer see, but erasing what the
|
||
// operator just read to achieve that is worse.
|
||
//
|
||
// Citations and sources are wired by delegation, not by onclick.
|
||
// Both carry a file path that came from the index. Interpolating one into an onclick
|
||
// attribute means a path containing a quote executes; data attributes plus one listener on
|
||
// the transcript removes the whole class rather than escaping around it.
|
||
//
|
||
// Storage is automatic and capped.
|
||
// A conversation saves itself when a turn completes and the oldest drops off past
|
||
// AI_CHAT_HISTORY_MAX. There is no Save button, because a chat worth keeping is not
|
||
// reliably one you knew was worth keeping while you were having it.
|
||
//
|
||
// OPERATIONAL SAFEGUARDS
|
||
// Every rendered string is escaped, and the minimal markdown pass runs strictly afterwards, so
|
||
// no model or file derived input can introduce markup.
|
||
//
|
||
// Every failure path ends in a visible message in the transcript. A hang is the one failure
|
||
// that reports nothing, and this endpoint has produced one: a multipart POST that left the
|
||
// browser and never reached PHP, with no status, no fatal and no entry in any log.
|
||
//
|
||
// Read-only with respect to the system. Nothing here runs a script or edits conf.
|
||
//
|
||
// EXPORTS
|
||
// vv_ai_chat_assets() styles + the VvAiChat / VvAiChatList factories, once per page
|
||
// vv_ai_chat_markup($prefix,$o) profile bar, transcript, composer for one instance
|
||
// vv_ai_chat_list_markup($prefix) the stored-conversations list container
|
||
//
|
||
// DEPENDS ON
|
||
// include/ai_profiles.php the profile registry, served to the browser rather than restated
|
||
// api/ai.php ask / poll / clear / chats / chat_get / chat_save / chat_delete
|
||
// api/readscript.php source viewer contents
|
||
// ═══════════════════════════════════════════════════════════════════════════════════════════════
|
||
|
||
// Required directly, not assumed. The Monitor tab pulls this file in without include/ai.php,
|
||
// so relying on something else having loaded the registry first works on the AI tab and fatals
|
||
// on the dashboard.
|
||
require_once __DIR__ . '/ai_profiles.php';
|
||
|
||
// The conversation store, on its own, with no styling and no chat widget attached.
|
||
//
|
||
// Emitted separately because a page may want the store without the component — a list of saved
|
||
// conversations, or a save, with no transcript rendered. Every surface currently takes both, but
|
||
// the two are not the same dependency and pulling in a stylesheet to reach a save function is how
|
||
// they would become one.
|
||
//
|
||
// Where a conversation lives is not a presentation decision, so it gets one answer for every
|
||
// surface. Anything added to the store — a new field, a new cap, a changed prune rule — arrives
|
||
// everywhere at once because there is only one writer.
|
||
function vv_ai_chat_store_script(): void {
|
||
static $done = false;
|
||
if ($done) return;
|
||
$done = true;
|
||
?>
|
||
<script>
|
||
(function () {
|
||
const API = '/plugins/varaverk/api/ai.php';
|
||
// Returns the id so a caller can keep appending to one conversation instead of minting a row
|
||
// per turn. Fire and forget on failure: a store that cannot be written is not a reason to lose
|
||
// the answer already on screen.
|
||
window.VvAiChatSave = function (msgs, profile, scope, id) {
|
||
if (!msgs || !msgs.length) return Promise.resolve(null);
|
||
return fetch(API, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
|
||
body: new URLSearchParams({
|
||
action: 'chat_save', id: id || '', profile: profile || 'chat',
|
||
scope: scope || '', messages: JSON.stringify(msgs),
|
||
}),
|
||
})
|
||
.then(r => r.json())
|
||
.then(d => (d && d.ok) ? d.id : null)
|
||
.catch(() => null);
|
||
};
|
||
})();
|
||
</script>
|
||
<?php
|
||
}
|
||
|
||
// Emitted once even if two instances are rendered. A second copy of the script would re-register
|
||
// the factories harmlessly but would also install a second Escape handler and a second copy of
|
||
// every keyframe, so the guard is cheaper than reasoning about whether it matters.
|
||
function vv_ai_chat_assets(): void {
|
||
static $done = false;
|
||
if ($done) return;
|
||
$done = true;
|
||
vv_ai_chat_store_script();
|
||
?>
|
||
<style>
|
||
/* ── Shared tone ─────────────────────────────────────────────────────────── */
|
||
.vv-ai-ok { color:#6fcf97 !important; }
|
||
.vv-ai-warn { color:#ffb74d !important; }
|
||
.vv-ai-bad { color:#e57 !important; }
|
||
.vv-ai-none { font-size:11px; color:#3a3a3a; font-style:italic; }
|
||
.vv-ai-btn { background:#152238; border:1px solid #2d4a6a; color:#8ab; font-size:12px; padding:5px 14px;
|
||
border-radius:3px; cursor:pointer; font-family:inherit; }
|
||
.vv-ai-btn:hover:not(:disabled) { background:#1d2f4d; }
|
||
.vv-ai-btn:disabled { opacity:.4; cursor:default; }
|
||
.vv-ai-btn.ghost { background:none; border-color:#262626; color:#5a5a5a; }
|
||
.vv-ai-input { width:100%; background:#0a0a0a; border:1px solid #222; border-radius:4px; color:#c8c8c8;
|
||
font-family:inherit; font-size:13px; padding:9px; resize:vertical; min-height:58px; }
|
||
.vv-ai-input:focus { outline:none; border-color:#2d4a6a; }
|
||
.vv-ai-toggle { font-size:11px; color:#6a6a6a; display:flex; align-items:center; gap:5px; cursor:pointer; }
|
||
|
||
/* Profile picker styling lives in css/varaverk.css, alongside the menus it shares its shape
|
||
with, rather than here. */
|
||
.vv-ai-switch { text-align:center; font-size:10px; color:#3a3a3a; margin:10px 0;
|
||
border-top:1px dashed #1e1e1e; padding-top:8px; }
|
||
|
||
/* ── Transcript ─────────────────────────────────────────────────────────── */
|
||
.vv-ai-chat { border:1px solid #262626; border-radius:6px; background:#0b0b0b;
|
||
min-height:340px; max-height:60vh; overflow-y:auto; padding:14px; }
|
||
.vv-ai-empty { color:#3a3a3a; font-size:12px; text-align:center; padding:60px 20px; line-height:1.7; }
|
||
.vv-ai-msg { margin-bottom:16px; }
|
||
.vv-ai-role { font-size:9px; letter-spacing:.08em; text-transform:uppercase; margin-bottom:5px; }
|
||
.vv-ai-msg.user .vv-ai-role { color:#5c7cfa; }
|
||
.vv-ai-msg.bot .vv-ai-role { color:#6fcf97; }
|
||
.vv-ai-body { font-size:13px; line-height:1.65; color:#b8b8b8; white-space:pre-wrap; word-wrap:break-word; }
|
||
.vv-ai-msg.user .vv-ai-body { color:#8a9ac8; }
|
||
.vv-ai-body code { background:#151515; padding:1px 5px; border-radius:3px; font-size:12px; color:#d4a; }
|
||
.vv-ai-body pre { background:#131313; border:1px solid #222; border-radius:4px; padding:10px;
|
||
overflow-x:auto; margin:8px 0; }
|
||
.vv-ai-body pre code { background:none; padding:0; color:#9cc; }
|
||
.vv-ai-cite { color:#5c7cfa; font-weight:bold; cursor:pointer; }
|
||
.vv-ai-cite:hover { text-decoration:underline; }
|
||
|
||
.vv-ai-danger { background:#1f0d0d; border:1px solid #4a1f1f; border-left:3px solid #e57;
|
||
border-radius:4px; padding:8px 10px; margin-bottom:9px; font-size:11px;
|
||
line-height:1.55; color:#d99; }
|
||
.vv-ai-danger strong { color:#f88; }
|
||
|
||
.vv-ai-think-t { font-size:10px; color:#4a4a4a; cursor:pointer; user-select:none; margin-bottom:6px;
|
||
display:inline-block; border:1px solid #222; border-radius:3px; padding:2px 7px; }
|
||
.vv-ai-think-t:hover { color:#777; border-color:#333; }
|
||
.vv-ai-think { display:none; font-size:11px; line-height:1.6; color:#5a5a5a; background:#0d0d0d;
|
||
border-left:2px solid #262626; padding:8px 10px; margin-bottom:8px; white-space:pre-wrap; }
|
||
.vv-ai-think.open { display:block; }
|
||
|
||
.vv-ai-src { margin-top:9px; border-top:1px solid #1c1c1c; padding-top:7px; }
|
||
.vv-ai-src-h { font-size:9px; letter-spacing:.07em; text-transform:uppercase; color:#3a3a3a; margin-bottom:4px; }
|
||
.vv-ai-src-i { font-size:11px; color:#5a5a5a; padding:2px 0; cursor:pointer; display:flex; gap:8px; }
|
||
.vv-ai-src-i:hover { color:#8a8a8a; }
|
||
.vv-ai-src-n { color:#3a4a6a; font-family:monospace; flex-shrink:0; }
|
||
.vv-ai-src-s { color:#333; font-family:monospace; margin-left:auto; flex-shrink:0; }
|
||
.vv-ai-meta { font-size:10px; color:#333; margin-top:6px; font-family:monospace; }
|
||
|
||
/* An offer's buttons sit under the message that made them, indented to the body so they read as
|
||
part of what was said rather than as composer controls that drifted up the transcript. Once
|
||
answered the pair is replaced by the decision, which is quieter than a disabled button and
|
||
still says what happened. */
|
||
.vv-ai-offer { display:flex; gap:7px; margin-top:8px; }
|
||
.vv-ai-offer-done { font-size:10px; color:#4a4a4a; font-style:italic; margin-top:6px; }
|
||
|
||
.vv-ai-pending { font-size:12px; color:#5a5a5a; display:flex; align-items:center; gap:8px; }
|
||
/* The streaming body sits in the pending bubble and is replaced wholesale by the finished message,
|
||
so it has to match .vv-ai-body or the answer visibly reflows the moment it completes. The caret
|
||
marks text as still arriving — without it a stream that pauses mid-sentence reads as finished. */
|
||
.vv-ai-stream { margin-top:6px; }
|
||
/* Stop reads as the destructive-ish action it is, and the colour change is what tells the operator
|
||
the button's job swapped — the label alone is easy to miss mid-answer. */
|
||
.vv-ai-btn.vv-ai-stop { background:#5a2b2b; border-color:#7a3b3b; color:#f0d8d8; }
|
||
.vv-ai-stream::after { content:'▌'; margin-left:1px; opacity:.55; animation:vvAiPulse 1.1s infinite; }
|
||
.vv-ai-dot { width:6px; height:6px; border-radius:50%; background:#6fcf97; animation:vvAiPulse 1.1s infinite; }
|
||
@keyframes vvAiPulse { 0%,100%{opacity:.25;} 50%{opacity:1;} }
|
||
|
||
/* ── Composer ───────────────────────────────────────────────────────────── */
|
||
.vv-ai-composer { display:flex; flex-direction:column; gap:7px; border:1px solid #262626;
|
||
border-radius:6px; padding:10px; background:#0e0e0e; }
|
||
/* Profile left, conversation actions centred, window control right. The outer groups take equal
|
||
flex and the middle takes only what it needs, which centres it against the composer above
|
||
rather than against whatever the left group currently measures — the Scheduler's chip changes
|
||
width every time the operator opens a different script. Wrapping is allowed because the Monitor
|
||
card is narrow, and when it wraps the three groups part cleanly instead of interleaving. */
|
||
.vv-ai-ctrls { display:flex; gap:8px; align-items:center; flex-wrap:wrap; }
|
||
.vv-ai-side { flex:1 1 0; min-width:0; display:flex; align-items:center; }
|
||
.vv-ai-tail { justify-content:flex-end; }
|
||
.vv-ai-actions { display:flex; gap:8px; align-items:center; flex:0 0 auto; }
|
||
.vv-ai-ctrls select { background:#0a0a0a; border:1px solid #222; color:#8a8a8a; font-size:11px;
|
||
padding:4px 7px; border-radius:3px; }
|
||
/* Square-ish, so the two icon buttons read as a pair distinct from the worded ones. */
|
||
.vv-ai-grow { min-width:30px; text-align:center; }
|
||
|
||
/* Banner. The title takes the same shape a .vv-card h3 does, so an instance dropped into a card
|
||
reads as that card's heading rather than as a second one underneath it. */
|
||
.vv-ai-head { display:flex; align-items:center; gap:6px; margin-bottom:8px; }
|
||
/* The title takes the slack, so the two controls group together at the right rather than being
|
||
spread apart — with three children, space-between would strand the size button in the middle. */
|
||
.vv-ai-head-t { display:flex; align-items:center; gap:5px; flex:1 1 auto; min-width:0;
|
||
font-size:13px; color:#888; text-transform:uppercase; letter-spacing:0.05em;
|
||
overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||
|
||
/* Shortcuts. The toggle lives in the banner with the other window controls; this is only the list
|
||
it opens. Shut, the block occupies nothing at all — no line, no margin — because the control
|
||
that speaks for it is already on screen above. Open, it is a two-column list that wraps to one
|
||
on a narrow card, and the transcript below gives up exactly its height. */
|
||
.vv-ai-keys { line-height:1; }
|
||
.vv-ai-keysbtn { font-size:10px !important; line-height:1; padding:4px 10px !important;
|
||
letter-spacing:0.04em; flex-shrink:0; }
|
||
/* line-height restored: the wrapper drops it to 1 to tighten the collapsed line, and inheriting
|
||
that here would stack the open rows on top of each other. */
|
||
.vv-ai-keys-b { display:none; grid-template-columns:repeat(auto-fit,minmax(190px,1fr));
|
||
gap:2px 14px; margin-top:6px; padding:7px 9px; background:#0d0d0d;
|
||
border:1px solid #1c1c1c; border-radius:4px; line-height:1.6; }
|
||
.vv-ai-keys.open .vv-ai-keys-b { display:grid; }
|
||
.vv-ai-key { display:flex; align-items:baseline; gap:7px; font-size:10px; min-width:0; }
|
||
.vv-ai-key-k { display:flex; gap:3px; flex-shrink:0; }
|
||
.vv-ai-key-d { color:#5a5a5a; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||
.vv-ai-key kbd { font-family:inherit; font-size:8px; color:#8a8a8a; background:#181818;
|
||
border:1px solid #2a2a2a; border-radius:3px; padding:1px 4px; line-height:1.5; }
|
||
|
||
/* ── Compact form, for a chat living inside a Monitor card ──────────────── */
|
||
/* Not a different component — the same markup with the chrome pulled in. The card supplies the
|
||
heading and the border, so the transcript drops its own and the hint text goes away rather
|
||
than wrapping to three lines at this width.
|
||
|
||
Surfaces are re-based on the card, not merely un-bordered. The standalone palette paints a
|
||
#0b0b0b transcript because on the AI tab it sits on the page ground with nothing beside it
|
||
to compare against; dropped into a .vv-card (#1e1e1e) that same fill reads as a hole cut in
|
||
the card rather than as part of it. Transparent here, and the remaining controls move to the
|
||
values the plugin already uses inside a card — .vv-btn-sm is #2a2a2a on #555 — so the whole
|
||
thing reads as one object.
|
||
|
||
This is the second hardcoded dark palette in the plugin, which is exactly one too many. When
|
||
theming happens it wants tokens (--vv-surface, --vv-line) defined once in varaverk.css, and
|
||
this block becomes a token swap instead of a second set of literals. */
|
||
.vv-ai-c .vv-ai-chat { border:none; border-radius:0; padding:10px 2px; min-height:0;
|
||
background:transparent; }
|
||
.vv-ai-c .vv-ai-empty { padding:26px 14px; }
|
||
.vv-ai-c .vv-ai-composer { border:none; padding:8px 0 0; background:none; }
|
||
.vv-ai-c .vv-ai-input { min-height:44px; font-size:12px; padding:7px;
|
||
background:#161616; border-color:#333; }
|
||
.vv-ai-c .vv-ai-input:focus { border-color:#6495ed; }
|
||
|
||
/* 12px, tighter leading, questions marked with a chevron rather than a role label. The role
|
||
labels cost a line each and say the same two things forever — in a space this size that is a
|
||
third of the visible transcript spent on "You" and "Varaverk". */
|
||
.vv-ai-c .vv-ai-role { display:none; }
|
||
.vv-ai-c .vv-ai-body { font-size:12px; line-height:1.55; }
|
||
.vv-ai-c .vv-ai-msg.user .vv-ai-body { color:#8fb0c4; }
|
||
.vv-ai-c .vv-ai-msg.user .vv-ai-body::before { content:'› '; color:#3a5a6a; }
|
||
.vv-ai-c .vv-ai-msg.user { margin-bottom:5px; }
|
||
.vv-ai-c .vv-ai-msg.bot { margin-bottom:11px; }
|
||
.vv-ai-c .vv-ai-body code { background:#1a1a1a; border:1px solid #333; border-radius:2px;
|
||
padding:0 4px; font-size:11px; color:#9ab; }
|
||
.vv-ai-c .vv-ai-body strong { color:#ddd; }
|
||
.vv-ai-c .vv-ai-src { font-size:10px; color:#4a4a4a; font-family:monospace; }
|
||
.vv-ai-c .vv-ai-src-h { display:none; }
|
||
.vv-ai-c .vv-ai-meta { font-size:9px; }
|
||
.vv-ai-c .vv-ai-think-t { font-size:9px; padding:1px 6px; }
|
||
.vv-ai-c .vv-ai-btn { padding:4px 11px; font-size:11px; }
|
||
.vv-ai-c .vv-ai-btn.ghost { border-color:#555; color:#ccc; }
|
||
.vv-ai-c .vv-ai-msg { margin-bottom:12px; }
|
||
.vv-ai-c .vv-ai-think { background:#161616; border-left-color:#3a3a3a; }
|
||
.vv-ai-c .vv-ai-body pre { background:#161616; border-color:#3a3a3a; }
|
||
.vv-ai-c .vv-ai-body code { background:#2a2a2a; }
|
||
.vv-ai-c .vv-ai-src { border-top-color:#333; }
|
||
.vv-ai-c .vv-ai-switch { border-top-color:#333; }
|
||
|
||
/* ── Stored conversations ───────────────────────────────────────────────── */
|
||
.vv-ai-clist { display:flex; flex-direction:column; gap:1px; }
|
||
.vv-ai-crow { display:flex; align-items:baseline; gap:8px; padding:5px 7px; border-radius:4px;
|
||
cursor:pointer; border:1px solid transparent; }
|
||
.vv-ai-crow:hover { background:#141414; }
|
||
.vv-ai-crow.active { background:#14202c; border-color:#2d4a6a; }
|
||
.vv-ai-crow-t { font-size:11px; color:#8a8a8a; overflow:hidden; text-overflow:ellipsis;
|
||
white-space:nowrap; flex:1; min-width:0; }
|
||
.vv-ai-crow.active .vv-ai-crow-t { color:#9bd; }
|
||
.vv-ai-crow-m { font-size:9px; color:#3a3a3a; font-family:monospace; flex-shrink:0; }
|
||
.vv-ai-chat { transition:height .14s ease; }
|
||
.vv-ai-size { font-size:10px !important; line-height:1; padding:4px 10px !important;
|
||
letter-spacing:0.04em; flex-shrink:0; }
|
||
.vv-ai-grow-on { color:#9bd !important; border-color:#2d4a6a !important; }
|
||
.vv-ai-crow-s { font-size:9px; color:#5c7cfa; font-family:monospace; }
|
||
.vv-ai-c .vv-ai-crow-s { color:#7a9ae0; }
|
||
.vv-ai-crow-x { font-size:11px; color:#333; flex-shrink:0; padding:0 2px; visibility:hidden; }
|
||
.vv-ai-crow:hover .vv-ai-crow-x { visibility:visible; }
|
||
.vv-ai-crow-x:hover { color:#e57; }
|
||
/* Hover lifts off the card rather than sinking into it. #141414 is a highlight against the AI
|
||
tab's #0e0e0e panel and a shadow against a #1e1e1e card — the same value reads as the
|
||
opposite gesture depending on what it sits on. */
|
||
.vv-ai-c .vv-ai-crow:hover { background:#2a2a2a; }
|
||
.vv-ai-c .vv-ai-crow-t { color:#ccc; }
|
||
.vv-ai-c .vv-ai-crow-m { color:#666; }
|
||
.vv-ai-c .vv-ai-crow-x { color:#666; }
|
||
.vv-ai-c .vv-ai-none { color:#666; }
|
||
.vv-ai-chead { display:flex; align-items:center; gap:8px; margin-bottom:5px; }
|
||
|
||
/* ── Source overlay ─────────────────────────────────────────────────────── */
|
||
#vv-ai-view { display:none; position:fixed; inset:0; background:rgba(0,0,0,.82); z-index:9999;
|
||
padding:36px; }
|
||
#vv-ai-view.open { display:block; }
|
||
.vv-ai-view-box { background:#0b0b0b; border:1px solid #2a2a2a; border-radius:6px; height:100%;
|
||
display:flex; flex-direction:column; }
|
||
.vv-ai-view-h { padding:9px 12px; border-bottom:1px solid #222; display:flex; align-items:center; gap:10px; }
|
||
.vv-ai-view-t { font-size:12px; color:#8a8a8a; font-family:monospace; overflow:hidden; text-overflow:ellipsis; }
|
||
.vv-ai-view-b { flex:1; overflow:auto; margin:0; padding:12px; font-size:12px; line-height:1.5;
|
||
color:#9a9a9a; white-space:pre; }
|
||
</style>
|
||
|
||
<div id="vv-ai-view" onclick="if(event.target===this)vvAiCloseView()">
|
||
<div class="vv-ai-view-box">
|
||
<div class="vv-ai-view-h">
|
||
<span class="vv-ai-view-t" id="vv-ai-view-t"></span>
|
||
<button class="vv-ai-btn ghost" style="margin-left:auto" onclick="vvAiCloseView()">Close</button>
|
||
</div>
|
||
<pre class="vv-ai-view-b" id="vv-ai-view-b"></pre>
|
||
</div>
|
||
</div>
|
||
|
||
<?php
|
||
// The profile registry, served rather than restated. This used to be a literal table in the
|
||
// script below that had already drifted from the PHP — it knew three profiles where the server
|
||
// knew four, which is why troubleshoot could not be offered here and the Scheduler had to
|
||
// hand-roll its own labels.
|
||
vv_ai_profiles_script();
|
||
?>
|
||
<script>
|
||
(function () {
|
||
const API = '/plugins/varaverk/api/ai.php';
|
||
|
||
// The server is the authority. It re-derives depth and capability on every request regardless
|
||
// of what is here — these values draw buttons and decide whether to show the kind filter, they
|
||
// do not make policy.
|
||
const PROFILES = window.VvAiProfiles;
|
||
|
||
const esc = s => String(s == null ? '' : s)
|
||
.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>')
|
||
.replace(/"/g,'"').replace(/'/g,''');
|
||
|
||
const POST_HEAD = { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' };
|
||
|
||
function ago(ts) {
|
||
if (!ts) return '—';
|
||
const d = Math.floor(Date.now()/1000) - ts;
|
||
if (d < 60) return 'just now';
|
||
if (d < 3600) return Math.floor(d/60)+'m';
|
||
if (d < 86400) return Math.floor(d/3600)+'h';
|
||
return Math.floor(d/86400)+'d';
|
||
}
|
||
|
||
// ── Source viewer, one per page ─────────────────────────────────────────
|
||
const $g = id => document.getElementById(id);
|
||
window.vvAiOpen = function (path) {
|
||
$g('vv-ai-view-t').textContent = path;
|
||
$g('vv-ai-view-b').textContent = 'Loading…';
|
||
$g('vv-ai-view').classList.add('open');
|
||
fetch('/plugins/varaverk/api/readscript.php?id=' + encodeURIComponent(path))
|
||
.then(r => r.json())
|
||
.then(d => { $g('vv-ai-view-b').textContent = d.ok ? d.content
|
||
: (d.error || 'Could not read this file.'); })
|
||
.catch(e => { $g('vv-ai-view-b').textContent = 'Could not read this file: ' + e; });
|
||
};
|
||
window.vvAiCloseView = function () { $g('vv-ai-view').classList.remove('open'); };
|
||
document.addEventListener('keydown', e => { if (e.key === 'Escape') vvAiCloseView(); });
|
||
|
||
// ── Chat instance ───────────────────────────────────────────────────────
|
||
window.__vvAiChat = window.__vvAiChat || {};
|
||
|
||
window.VvAiChat = function (o) {
|
||
const P = o.prefix;
|
||
const $ = sfx => document.getElementById(P + '-' + sfx);
|
||
const onTurn = o.onTurn || function () {};
|
||
const onChats = o.onChats || function () {};
|
||
// Called with (kind, yes, message) when the operator answers an offer the page made.
|
||
const onOffer = o.onOffer || function () {};
|
||
// Called whenever the active profile changes, however it changed — the picker, a reopened
|
||
// conversation, or the page retargeting. A page holding its own copy of "which profile" has
|
||
// no other way to stay in step with a menu it does not own.
|
||
const onProfile = o.onProfile || function () {};
|
||
// Called with the new state when the operator expands or collapses, for a page that has to
|
||
// re-lay itself out around the new height. See the grow handler.
|
||
const onResize = o.onResize || function () {};
|
||
const store = o.chats !== false;
|
||
const POLL_MS = 1200;
|
||
// Once tokens are actually arriving, 1200ms delivers them in visible lumps and the stream reads
|
||
// as stuttering rather than writing. The endpoint only reads one small file off tmpfs, so the
|
||
// extra requests are cheap — and this rate only applies while a generation is in flight.
|
||
const POLL_FAST_MS = 350;
|
||
const POLL_CEIL = 300000; // stop polling a worker that never wrote a terminal state
|
||
|
||
// An instance already holding this prefix is a leftover from a tab swap, still holding
|
||
// timers and a busy flag over DOM nodes that no longer exist.
|
||
if (window.__vvAiChat[P]) { try { window.__vvAiChat[P].teardown(); } catch (e) {} }
|
||
|
||
let profile = PROFILES[o.profile] ? o.profile : 'varaverk';
|
||
let messages = []; // whole transcript — displayed and stored
|
||
let sendFrom = 0; // index the model is allowed to see from
|
||
let chatId = ''; // '' until the store mints one
|
||
let scopeLabel = o.scopeLabel || ''; // subject shown on the chip; '' on surfaces without one
|
||
let busy = false;
|
||
let lastSources = [];
|
||
let pendingTimer = null;
|
||
|
||
const chatEl = () => $('chat');
|
||
const scroll = () => { const c = chatEl(); c.scrollTop = c.scrollHeight; };
|
||
function el(html) { const d = document.createElement('div'); d.innerHTML = html; return d.firstElementChild; }
|
||
function clearEmpty() { const e = chatEl().querySelector('.vv-ai-empty'); if (e) e.remove(); }
|
||
|
||
// ── Minimal markdown, applied strictly after escaping ────────────────
|
||
function fmt(text) {
|
||
let h = esc(text);
|
||
h = h.replace(/```(\w*)\n([\s\S]*?)```/g, (m, l, c) => `<pre><code>${c}</code></pre>`);
|
||
h = h.replace(/`([^`\n]+)`/g, '<code>$1</code>');
|
||
h = h.replace(/\*\*([^*\n]+)\*\*/g, '<strong>$1</strong>');
|
||
h = h.replace(/\[(\d+)\]/g, '<span class="vv-ai-cite" data-cite="$1">[$1]</span>');
|
||
return h;
|
||
}
|
||
|
||
// ── Transcript ───────────────────────────────────────────────────────
|
||
function addUser(text) {
|
||
clearEmpty();
|
||
chatEl().appendChild(el(`<div class="vv-ai-msg user"><div class="vv-ai-role">You</div>`
|
||
+ `<div class="vv-ai-body">${esc(text)}</div></div>`));
|
||
scroll();
|
||
}
|
||
|
||
function addPending() {
|
||
const n = el(`<div class="vv-ai-msg bot" id="${P}-pending"><div class="vv-ai-role">Varaverk</div>`
|
||
+ `<div class="vv-ai-pending"><span class="vv-ai-dot"></span>`
|
||
+ `<span id="${P}-phase">starting…</span>`
|
||
+ `<span id="${P}-elapsed" style="color:#333;font-family:monospace"></span></div>`
|
||
+ `<div class="vv-ai-body vv-ai-stream" id="${P}-stream" hidden></div></div>`);
|
||
chatEl().appendChild(n); scroll();
|
||
// An elapsed counter distinguishes "working" from "wedged" at a glance. Without it a
|
||
// stalled turn and a slow one look identical, and the slow case here is legitimately ~40s.
|
||
const t0 = Date.now();
|
||
clearInterval(pendingTimer);
|
||
pendingTimer = setInterval(() => {
|
||
const e = $('elapsed');
|
||
if (!e) { clearInterval(pendingTimer); return; }
|
||
e.textContent = Math.round((Date.now() - t0) / 1000) + 's';
|
||
}, 1000);
|
||
}
|
||
function phase(t) { const p = $('phase'); if (p) p.textContent = t; }
|
||
|
||
// A line in the transcript that is not a turn — something the page did, said where the operator
|
||
// is already looking rather than in a banner they have to notice. Lives in the closure rather
|
||
// than only on the instance because the poll loop needs it too, and a bare note() there would
|
||
// throw: the instance object is not in scope from inside its own factory.
|
||
function noteLine(text) {
|
||
chatEl().appendChild(el('<div class="vv-ai-switch">' + esc(text) + '</div>'));
|
||
scroll();
|
||
}
|
||
|
||
// Streaming is only worth having if it does not fight the reader. Answers routinely outrun the
|
||
// panel, and the moment someone scrolls up to re-read a line, an unconditional scroll() on
|
||
// every delta drags them back down eight times a second. Stick to the bottom only while they
|
||
// are already there.
|
||
const nearBottom = () => {
|
||
const c = chatEl();
|
||
return (c.scrollHeight - c.scrollTop - c.clientHeight) < 40;
|
||
};
|
||
|
||
// Rendered through the same fmt() as a finished answer, so a fence that is still being written
|
||
// formats as it arrives rather than snapping from plain text to a code block at the end. fmt
|
||
// escapes, so a half-written tag cannot break out of the bubble mid-stream.
|
||
function streamInto(text) {
|
||
const s = $('stream');
|
||
if (!s) return;
|
||
if (!text) { s.hidden = true; return; }
|
||
const stick = nearBottom();
|
||
s.hidden = false;
|
||
s.innerHTML = fmt(text);
|
||
if (stick) scroll();
|
||
}
|
||
|
||
// Paths ride in data attributes rather than an onclick. They come out of the index, and a
|
||
// path carrying a quote interpolated into an attribute is code execution, not a display bug.
|
||
function sourcesHtml(sources) {
|
||
if (!sources || !sources.length) return '';
|
||
let h = '<div class="vv-ai-src"><div class="vv-ai-src-h">Sources</div>';
|
||
sources.forEach((s, i) => {
|
||
// A web result is a page on the internet, not a file in this install: it opens in a tab
|
||
// rather than in the source viewer, it leads with its title rather than its URL, and it
|
||
// carries no retrieval score because nothing here scored it.
|
||
if (s.web && s.url) {
|
||
const label = [s.heading, s.url].filter(Boolean).join(' — ');
|
||
h += `<div class="vv-ai-src-i" data-web="${esc(s.url)}">`
|
||
+ `<span class="vv-ai-src-n">[${i+1}]</span><span>${esc(label)}</span>`
|
||
+ `<span class="vv-ai-src-s">web</span></div>`;
|
||
return;
|
||
}
|
||
const label = [s.path, s.section, s.heading].filter(Boolean).join(' › ');
|
||
h += `<div class="vv-ai-src-i" data-src="${esc(s.path)}">`
|
||
+ `<span class="vv-ai-src-n">[${i+1}]</span><span>${esc(label)}</span>`
|
||
+ `<span class="vv-ai-src-s">${Number(s.score).toFixed(3)}</span></div>`;
|
||
});
|
||
return h + '</div>';
|
||
}
|
||
|
||
function addAnswer(job) {
|
||
const p = $('pending'); if (p) p.remove();
|
||
lastSources = job.sources || [];
|
||
|
||
let h = `<div class="vv-ai-msg bot"><div class="vv-ai-role">Varaverk</div>`;
|
||
if (job.thinking) {
|
||
h += `<div class="vv-ai-think-t" data-think="1">`
|
||
+ `reasoning (${job.thinking.length.toLocaleString()} chars)</div>`
|
||
+ `<div class="vv-ai-think">${esc(job.thinking)}</div>`;
|
||
}
|
||
// Detected from the generated code, not from the model saying so. These scripts run as
|
||
// root on a schedule, so the banner is louder than whatever prose warning it may have added.
|
||
if (job.warnings && job.warnings.length) {
|
||
h += `<div class="vv-ai-danger"><strong>Destructive — read before running.</strong> `
|
||
+ `This script ${job.warnings.map(esc).join('; ')}. `
|
||
+ `Run any dry-run form first and check the paths are what you expect.</div>`;
|
||
}
|
||
h += `<div class="vv-ai-body">${fmt(job.answer)}</div>`;
|
||
h += sourcesHtml(job.sources);
|
||
const t = job.timing || {};
|
||
if (t.tok_s) {
|
||
h += `<div class="vv-ai-meta">${t.tokens} tok · ${t.tok_s} tok/s · `
|
||
+ `retrieve ${t.retrieve_ms}ms · generate ${(t.generate_ms/1000).toFixed(1)}s</div>`;
|
||
}
|
||
chatEl().appendChild(el(h + '</div>')); scroll();
|
||
}
|
||
|
||
function addError(msg) {
|
||
const p = $('pending'); if (p) p.remove();
|
||
chatEl().appendChild(el(`<div class="vv-ai-msg bot"><div class="vv-ai-role">Varaverk</div>`
|
||
+ `<div class="vv-ai-body vv-ai-bad">${esc(msg)}</div></div>`));
|
||
scroll();
|
||
}
|
||
|
||
// One listener for the whole transcript, covering nodes that do not exist yet.
|
||
chatEl().addEventListener('click', e => {
|
||
const think = e.target.closest('.vv-ai-think-t');
|
||
if (think) { think.nextElementSibling.classList.toggle('open'); return; }
|
||
const src = e.target.closest('.vv-ai-src-i');
|
||
// Through vvSafeUrl, which is the global that exists precisely so a URL from outside this
|
||
// machine cannot become a javascript: href. The server drops anything that is not http(s)
|
||
// as well; this is the second of the two, not the only one.
|
||
if (src && src.dataset.web) {
|
||
const u = vvSafeUrl(src.dataset.web);
|
||
if (u) window.open(u, '_blank', 'noopener,noreferrer');
|
||
return;
|
||
}
|
||
if (src && src.dataset.src) { vvAiOpen(src.dataset.src); return; }
|
||
const cite = e.target.closest('.vv-ai-cite');
|
||
if (cite) {
|
||
const s = lastSources[Number(cite.dataset.cite) - 1];
|
||
if (s && s.web && s.url) {
|
||
const u = vvSafeUrl(s.url);
|
||
if (u) window.open(u, '_blank', 'noopener,noreferrer');
|
||
return;
|
||
}
|
||
if (s && s.path) vvAiOpen(s.path);
|
||
return;
|
||
}
|
||
const off = e.target.closest('[data-offer]');
|
||
if (off) answerOffer(+off.dataset.offer, off.dataset.yes === '1');
|
||
});
|
||
|
||
// ── Ask / poll ───────────────────────────────────────────────────────
|
||
function send() {
|
||
// Never fail silently on a stuck flag. A turn that ends without finish() — a throw, a poll
|
||
// loop that stopped, a tab left open across a deploy — would otherwise make every later
|
||
// click a no-op with the previous "starting…" still on screen, which reads as a hang that
|
||
// produces no request and therefore no server-side trace at all.
|
||
if (busy) {
|
||
addError('A previous question is still marked in-flight, so this one was not sent. '
|
||
+ 'Reload the tab to reset it.');
|
||
return;
|
||
}
|
||
const q = $('input').value.trim();
|
||
if (!q) return;
|
||
|
||
// A page may claim what was typed instead of asking it. The Scheduler does this once, after
|
||
// an offer is accepted, when the next thing typed is the fix being recorded rather than a
|
||
// question. Checked here rather than in a wrapper the page calls, because Ask and Ctrl+Enter
|
||
// both land here directly and a wrapper would only catch the ones routed through it.
|
||
if (o.beforeSend && o.beforeSend(q)) { $('input').value = ''; return; }
|
||
|
||
busy = true;
|
||
// Ask becomes Stop rather than greying out. The one stretch of the interaction that takes
|
||
// 25-75s is exactly when the operator most wants a control, and the composer row has no
|
||
// room for a fourth button that is dead 95% of the time.
|
||
setSendMode('stop');
|
||
addUser(q);
|
||
$('input').value = '';
|
||
addPending();
|
||
|
||
// Wrapped: a synchronous throw here — from building the request, or from a fetch wrapper
|
||
// installed elsewhere on the page — would escape the promise chain entirely and leave the
|
||
// pending indicator up forever with nothing logged anywhere.
|
||
// URLSearchParams, not FormData. A multipart POST to this endpoint hangs with no status
|
||
// ever returned: the request leaves the browser with a valid token and correct body and
|
||
// never reaches PHP — no CSRF termination, no fatal, no entry log.
|
||
const kindEl = o.kindEl ? document.getElementById(o.kindEl) : null;
|
||
const thinkEl = o.thinkEl ? document.getElementById(o.thinkEl) : null;
|
||
const webEl = o.webEl ? document.getElementById(o.webEl) : null;
|
||
|
||
let res;
|
||
try {
|
||
const body = new URLSearchParams({
|
||
action: 'ask',
|
||
profile: profile,
|
||
question: q,
|
||
history: JSON.stringify(sendable()),
|
||
kind: (PROFILES[profile].kind && kindEl) ? kindEl.value : '',
|
||
// A checkbox where the page offers one, otherwise whatever the page decides from the
|
||
// profile, otherwise on. The Scheduler reasons only when diagnosing: working out what a
|
||
// log means is worth waiting ~15s for, and a lookup like "what does this setting do" is
|
||
// not — an inline answer that stalls reads as broken.
|
||
think: (typeof o.think === 'function' ? o.think(profile)
|
||
: thinkEl ? thinkEl.checked : true) ? '1' : '0',
|
||
// Only sent when the profile in force actually holds the capability, so ticking the box
|
||
// and then switching to the assistant cannot send a question about this machine to a
|
||
// search engine. The worker checks the same thing again — this is the courtesy, not
|
||
// the control.
|
||
web: (PROFILES[profile] && PROFILES[profile].web && webEl && webEl.checked)
|
||
? '1' : '0',
|
||
});
|
||
res = fetch(API, { method: 'POST', headers: POST_HEAD, body });
|
||
} catch (e) {
|
||
addError('Could not send the request: ' + (e && e.message ? e.message : e)
|
||
+ ' — this failed in the browser before reaching the server.');
|
||
finish();
|
||
return;
|
||
}
|
||
|
||
res.then(r => r.text().then(t => ({ status: r.status, text: t })))
|
||
.then(({ status, text }) => {
|
||
if (!text.trim()) {
|
||
// The CSRF prepend terminates with an empty body, so this is the shape that failure
|
||
// takes. Naming it beats a bare JSON parse error.
|
||
addError('Empty response (HTTP ' + status + '). This usually means the request was '
|
||
+ 'rejected before the endpoint ran — check the CSRF token shim.');
|
||
finish(); return;
|
||
}
|
||
let d;
|
||
try { d = JSON.parse(text); }
|
||
catch (e) { addError('Unparseable response (HTTP ' + status + '): ' + text.slice(0, 160));
|
||
finish(); return; }
|
||
if (!d.ok) { addError(d.error || 'Failed to start'); finish(); return; }
|
||
messages.push({ role: 'user', content: q });
|
||
// Held for Stop. Set before the first poll so a press in the first second has a target.
|
||
curToken = d.token;
|
||
poll(d.token, Date.now());
|
||
})
|
||
.catch(e => { addError('Request failed: ' + (e && e.message ? e.message : e)); finish(); });
|
||
}
|
||
|
||
// What the model may see: never across a profile switch, and never deeper than this
|
||
// profile's window. api/ai.php trims again regardless — this is the client half of a cap
|
||
// that exists in both places on purpose.
|
||
function sendable() {
|
||
const floor = Math.max(sendFrom, messages.length - PROFILES[profile].turns * 2);
|
||
// Reduced to role and content. Messages carry local bookkeeping now — an offer's state is
|
||
// ours, not something the model should be reading back as part of the conversation.
|
||
return messages.slice(floor).map(m => ({ role: m.role, content: m.content }));
|
||
}
|
||
|
||
function finish() {
|
||
busy = false;
|
||
curToken = null;
|
||
setSendMode('ask');
|
||
clearInterval(pendingTimer);
|
||
}
|
||
|
||
// The token of the turn in flight. Held so Stop knows what to cancel, and cleared by finish()
|
||
// so a Stop pressed against an already-terminal job is impossible rather than merely harmless.
|
||
let curToken = null;
|
||
|
||
function setSendMode(mode) {
|
||
const b = $('send');
|
||
if (!b) return;
|
||
const stopping = (mode === 'stop');
|
||
b.disabled = false;
|
||
b.textContent = stopping ? 'Stop' : 'Ask';
|
||
b.classList.toggle('vv-ai-stop', stopping);
|
||
}
|
||
|
||
function stopTurn() {
|
||
if (!curToken) return;
|
||
const t = curToken;
|
||
// Disabled immediately: the kill is quick but the poll that renders the result is up to
|
||
// POLL_FAST_MS away, and a second press in that window would signal a dead pid.
|
||
const b = $('send'); if (b) b.disabled = true;
|
||
phase('stopping…');
|
||
fetch(API, { method: 'POST', headers: POST_HEAD,
|
||
body: new URLSearchParams({ action: 'stop', token: t }) })
|
||
.then(r => r.json())
|
||
.catch(() => {});
|
||
// Nothing is rendered from the response. The poll already owns turning a terminal state into
|
||
// a message, and having two paths do it is how a turn ends up in the transcript twice.
|
||
}
|
||
|
||
function poll(token, started) {
|
||
if (Date.now() - started > POLL_CEIL) {
|
||
addError('Timed out waiting for a response.'); finish(); return;
|
||
}
|
||
fetch(API + '?action=poll&token=' + encodeURIComponent(token)).then(r => r.json()).then(d => {
|
||
if (!d.ok) { addError(d.error || 'Poll failed'); finish(); return; }
|
||
const j = d.job || {};
|
||
// Stopped is terminal and keeps whatever had been written. A turn cancelled at 80% is
|
||
// usually cancelled because there was already enough on screen, so discarding it would
|
||
// punish the operator for the one control that exists to save them time.
|
||
if (j.status === 'stopped') {
|
||
if ((j.answer || '').trim()) {
|
||
addAnswer(j);
|
||
messages.push({ role: 'assistant', content: j.answer });
|
||
noteLine('Stopped — the part already written is kept.');
|
||
} else {
|
||
const p = $('pending'); if (p) p.remove();
|
||
noteLine('Stopped before anything was written.');
|
||
}
|
||
fetch(API, { method: 'POST', headers: POST_HEAD,
|
||
body: new URLSearchParams({ action: 'clear', token }) }).catch(() => {});
|
||
finish();
|
||
save();
|
||
return;
|
||
}
|
||
|
||
if (j.status === 'done') {
|
||
addAnswer(j);
|
||
messages.push({ role: 'assistant', content: j.answer });
|
||
fetch(API, { method: 'POST', headers: POST_HEAD,
|
||
body: new URLSearchParams({ action: 'clear', token }) }).catch(() => {});
|
||
finish();
|
||
save();
|
||
// The answer is passed so a page can react to what was said, not just that a turn
|
||
// happened — the Scheduler uses it to spot a code block worth offering to the editor.
|
||
// Handlers that take no argument are unaffected.
|
||
onTurn(j.answer);
|
||
return;
|
||
}
|
||
if (j.status === 'error') { addError(j.error || 'Unknown error'); finish(); return; }
|
||
|
||
// Partial text arrives on the same envelope as the status, so the transcript fills in
|
||
// without a second channel. Empty while the model is still reasoning — thinking_chars is
|
||
// what moves then, and a page watching only partial would look wedged for ~15s.
|
||
if (j.status === 'generating') streamInto(j.partial || '');
|
||
|
||
phase(j.status === 'generating'
|
||
? (j.partial
|
||
? 'writing… (' + ((j.sources||[]).length) + ' sources)'
|
||
: (j.thinking_chars
|
||
? 'reasoning… (' + j.thinking_chars.toLocaleString() + ' chars)'
|
||
: 'generating… (' + ((j.sources||[]).length) + ' sources retrieved)'))
|
||
: j.status === 'retrieving' ? 'searching the index…' : 'starting…');
|
||
setTimeout(() => poll(token, started),
|
||
j.status === 'generating' ? POLL_FAST_MS : POLL_MS);
|
||
}).catch(e => { addError('Poll failed: ' + e); finish(); });
|
||
}
|
||
|
||
// ── Storage ──────────────────────────────────────────────────────────
|
||
// Fire and forget, deliberately. A store that cannot be written is not a reason to lose the
|
||
// answer that is already on screen, and the list refresh below reveals the failure anyway.
|
||
function save() {
|
||
if (!store || !messages.length) return;
|
||
fetch(API, { method: 'POST', headers: POST_HEAD,
|
||
body: new URLSearchParams({
|
||
action: 'chat_save', id: chatId, profile,
|
||
scope: (typeof o.scope === 'function' ? o.scope() : (o.scope || '')),
|
||
messages: JSON.stringify(messages),
|
||
}) })
|
||
.then(r => r.json())
|
||
.then(d => { if (d.ok) { chatId = d.id; onChats(chatId); } })
|
||
.catch(() => {});
|
||
}
|
||
|
||
|
||
// A reopened conversation renders as plain turns. Sources, reasoning and timings are not
|
||
// stored: they describe one generation, and redrawing them beside a transcript that may be
|
||
// continued under a different profile would be citing evidence for an answer that is no
|
||
// longer being made.
|
||
function render() {
|
||
const c = chatEl();
|
||
if (!messages.length) { reset(); return; }
|
||
c.innerHTML = '';
|
||
messages.forEach((m, i) => {
|
||
if (m.role === 'user') { addUser(m.content); return; }
|
||
c.appendChild(el(`<div class="vv-ai-msg bot"><div class="vv-ai-role">Varaverk</div>`
|
||
+ `<div class="vv-ai-body">${fmt(m.content)}</div>`
|
||
+ (m.offer ? offerHtml(m.offer, i) : '') + `</div>`));
|
||
});
|
||
scroll();
|
||
}
|
||
|
||
// ── Offers ───────────────────────────────────────────────────────────
|
||
// An assistant message that carries a decision rather than only text. The answer is recorded
|
||
// on the message itself, so a conversation reopened tomorrow shows what was decided instead of
|
||
// asking again — and an offer left unanswered is still answerable, which a transient banner
|
||
// would not be.
|
||
function offerHtml(off, i) {
|
||
if (off.state !== 'open') {
|
||
return `<div class="vv-ai-offer-done" data-offer-row="${i}">`
|
||
+ (off.state === 'taken' ? 'yes' : 'no thanks') + `</div>`;
|
||
}
|
||
return `<div class="vv-ai-offer" data-offer-row="${i}">`
|
||
+ `<button class="vv-ai-btn" type="button" data-offer="${i}" data-yes="1">Yes</button>`
|
||
+ `<button class="vv-ai-btn ghost" type="button" data-offer="${i}" data-yes="0">No</button>`
|
||
+ `</div>`;
|
||
}
|
||
|
||
// Appended, not rendered. render() rebuilds the transcript as plain turns and would strip the
|
||
// sources and reasoning off the answer the operator is looking at — see the note under
|
||
// loadChat. An offer arrives after an answer, so that is exactly when it must not happen.
|
||
function offer(kind, text) {
|
||
const i = messages.length;
|
||
messages.push({ role: 'assistant', content: text, offer: { kind: kind, state: 'open' } });
|
||
chatEl().appendChild(el(`<div class="vv-ai-msg bot"><div class="vv-ai-role">Varaverk</div>`
|
||
+ `<div class="vv-ai-body">${fmt(text)}</div>`
|
||
+ offerHtml(messages[i].offer, i) + `</div>`));
|
||
scroll();
|
||
save();
|
||
}
|
||
|
||
function answerOffer(i, yes) {
|
||
const m = messages[i];
|
||
if (!m || !m.offer || m.offer.state !== 'open') return;
|
||
m.offer.state = yes ? 'taken' : 'declined';
|
||
const row = chatEl().querySelector('[data-offer-row="' + i + '"]');
|
||
if (row) row.outerHTML = offerHtml(m.offer, i);
|
||
save();
|
||
onOffer(m.offer.kind, yes, m);
|
||
}
|
||
|
||
|
||
function reset() {
|
||
chatEl().innerHTML = `<div class="vv-ai-empty">${esc(o.empty || 'Ask Varaverk about itself.')}</div>`;
|
||
}
|
||
|
||
function loadChat(id) {
|
||
fetch(API + '?action=chat_get&id=' + encodeURIComponent(id)).then(r => r.json()).then(d => {
|
||
if (!d.ok) { addError(d.error || 'Could not open that conversation'); return; }
|
||
const c = d.chat || {};
|
||
messages = Array.isArray(c.messages) ? c.messages : [];
|
||
// Zero, not the length: reopening is meant to continue the thread, and a floor at the
|
||
// end would send the model an empty history for a transcript full of context.
|
||
sendFrom = 0;
|
||
chatId = c.id || '';
|
||
lastSources = [];
|
||
if (PROFILES[c.profile]) applyProfile(c.profile);
|
||
render();
|
||
onChats(chatId);
|
||
}).catch(e => addError('Could not open that conversation: ' + e));
|
||
}
|
||
|
||
function newChat() {
|
||
messages = []; sendFrom = 0; chatId = ''; lastSources = [];
|
||
reset();
|
||
onChats('');
|
||
}
|
||
|
||
// ── Profiles ─────────────────────────────────────────────────────────
|
||
function applyProfile(p) {
|
||
profile = p;
|
||
const chipL = $('chip-l');
|
||
// With a subject, the chip states both — the contract and the thing being asked about — and
|
||
// uses the profile's short name to keep the pair readable in a narrow bar. That pairing is
|
||
// load-bearing on the Scheduler: if the operator can see what it thinks it is looking at, a
|
||
// wrong inference costs a glance instead of a confidently wrong answer.
|
||
if (chipL) {
|
||
const def = PROFILES[p];
|
||
chipL.textContent = scopeLabel
|
||
? ((def && def.short ? def.short : p) + ' · ' + scopeLabel)
|
||
: (def ? def.label : p);
|
||
}
|
||
const menu = $('menu');
|
||
if (menu) menu.querySelectorAll('.vv-ai-opt').forEach(b =>
|
||
b.classList.toggle('active', b.dataset.prof === p));
|
||
// The profile's description is the chip's title now. It used to be a sentence sitting beside
|
||
// the chip, which cost a line on every surface and was already hidden on the compact ones.
|
||
const chip = $('chip');
|
||
if (chip) chip.title = PROFILES[p] ? PROFILES[p].hint : '';
|
||
const kindEl = o.kindEl ? document.getElementById(o.kindEl) : null;
|
||
if (kindEl) kindEl.style.display = (PROFILES[p] && PROFILES[p].kind) ? '' : 'none';
|
||
onProfile(p);
|
||
}
|
||
|
||
// Switching moves the floor rather than clearing the transcript. Carrying cited,
|
||
// retrieval-grounded turns into a mode with no retrieval makes the model keep referring to
|
||
// sources it can no longer see; erasing what the operator just read to avoid that is worse.
|
||
function setProfile(p) {
|
||
if (!PROFILES[p] || p === profile) return;
|
||
applyProfile(p);
|
||
if (messages.length > sendFrom) {
|
||
chatEl().appendChild(el('<div class="vv-ai-switch">switched to ' + esc(PROFILES[p].label)
|
||
+ ' — earlier turns are no longer carried</div>'));
|
||
scroll();
|
||
}
|
||
sendFrom = messages.length;
|
||
$('input').focus();
|
||
}
|
||
|
||
// Two pickers now — profile on the left of the control row, saved conversations on the right.
|
||
// Both close on an outside click or Escape through one pair of document listeners rather than
|
||
// a pair each, and both are removed by teardown. A listener on document that outlives its menu
|
||
// is how a torn-down tab keeps reacting to clicks on the one that replaced it, and the count
|
||
// of them to get right should not grow with the number of menus.
|
||
const picker = $('profiles') ? $('profiles').querySelector('.vv-ai-picker') : null;
|
||
const histWrap = $('hist');
|
||
|
||
const closeMenus = () => {
|
||
if (picker) { picker.classList.remove('open');
|
||
const c = $('chip'); if (c) c.setAttribute('aria-expanded', 'false'); }
|
||
if (histWrap) { histWrap.classList.remove('open');
|
||
const h = $('hist-b'); if (h) h.setAttribute('aria-expanded', 'false'); }
|
||
};
|
||
const onDocClick = e => {
|
||
if (picker && picker.contains(e.target)) return;
|
||
if (histWrap && histWrap.contains(e.target)) return;
|
||
closeMenus();
|
||
};
|
||
const onDocKey = e => { if (e.key === 'Escape') closeMenus(); };
|
||
document.addEventListener('click', onDocClick);
|
||
document.addEventListener('keydown', onDocKey);
|
||
|
||
if (picker) {
|
||
const chip = $('chip');
|
||
chip.addEventListener('click', e => {
|
||
e.stopPropagation();
|
||
const open = !picker.classList.contains('open');
|
||
closeMenus();
|
||
picker.classList.toggle('open', open);
|
||
chip.setAttribute('aria-expanded', open ? 'true' : 'false');
|
||
});
|
||
$('menu').addEventListener('click', e => {
|
||
const b = e.target.closest('.vv-ai-opt');
|
||
if (!b) return;
|
||
closeMenus();
|
||
setProfile(b.dataset.prof);
|
||
});
|
||
}
|
||
|
||
// Saved conversations, off the same store the Conversations card reads — so every placement
|
||
// can reach its history whether or not it has a card beside it. Read when opened rather than
|
||
// kept in step: a list refreshed on a timer for a menu nobody has opened is work spent on
|
||
// nothing, and the moment it is looked at is the moment it must be right.
|
||
if (histWrap) {
|
||
const histBtn = $('hist-b'), histMenu = $('hist-menu');
|
||
histBtn.addEventListener('click', e => {
|
||
e.stopPropagation();
|
||
const open = !histWrap.classList.contains('open');
|
||
closeMenus();
|
||
histWrap.classList.toggle('open', open);
|
||
histBtn.setAttribute('aria-expanded', open ? 'true' : 'false');
|
||
if (!open) return;
|
||
|
||
histMenu.innerHTML = '<div class="vv-ai-hist-msg">loading…</div>';
|
||
fetch(API + '?action=chats').then(r => r.json()).then(d => {
|
||
const rows = (d.ok && d.chats) ? d.chats : [];
|
||
if (!rows.length) {
|
||
histMenu.innerHTML = '<div class="vv-ai-hist-msg">no saved conversations yet</div>';
|
||
return;
|
||
}
|
||
// Same tagging rule as the Conversations card: a scope always says something, a profile
|
||
// says something unless it is the ordinary one.
|
||
const PR = window.VvAiProfiles || {};
|
||
histMenu.innerHTML = rows.map(c => {
|
||
const prof = (c.profile && c.profile !== 'chat' && PR[c.profile]) ? PR[c.profile].short : '';
|
||
const tag = [prof, c.scope || ''].filter(Boolean).join(' · ');
|
||
return `<button class="vv-ai-opt${c.id === chatId ? ' active' : ''}" type="button"`
|
||
+ ` role="option" data-chat="${esc(c.id)}">`
|
||
+ `<span class="vv-ai-opt-l">${esc(c.title)}</span>`
|
||
+ `<span class="vv-ai-opt-h">${esc([tag, ago(c.ts)].filter(Boolean).join(' · '))}</span>`
|
||
+ `</button>`;
|
||
}).join('');
|
||
}).catch(() => {
|
||
histMenu.innerHTML = '<div class="vv-ai-hist-msg">could not read the store</div>';
|
||
});
|
||
});
|
||
|
||
histMenu.addEventListener('click', e => {
|
||
const b = e.target.closest('[data-chat]');
|
||
if (!b) return;
|
||
closeMenus();
|
||
loadChat(b.dataset.chat);
|
||
});
|
||
}
|
||
|
||
// ── Wiring ───────────────────────────────────────────────────────────
|
||
// Ctrl+Enter is not bound here. It lives with the rest of the shortcuts on the wrapper below,
|
||
// and a second binding on the input would send the same question twice — the second landing
|
||
// on the busy guard and reporting the first as stuck.
|
||
// One button, two jobs — which one is decided by busy rather than by what the label happens to
|
||
// say, so a stale label can never send a question into a turn already running.
|
||
$('send').addEventListener('click', () => { busy ? stopTurn() : send(); });
|
||
const newBtn = $('new'); if (newBtn) newBtn.addEventListener('click', newChat);
|
||
|
||
// ── Expand / collapse ────────────────────────────────────────────────
|
||
// Both heights come from the markup, so the page decides them and this only switches
|
||
// between them.
|
||
//
|
||
// Deliberately not remembered. The only instance with this control lives on the Monitor
|
||
// dashboard, which is a page you open to glance at something else — a chat left expanded
|
||
// days ago would push every card below it down on each load, and the cause would be
|
||
// invisible to whoever eventually wondered why. Expanding is for the reading being done
|
||
// now, so the page always opens collapsed.
|
||
//
|
||
// Scroll position is pinned to the bottom afterwards. Growing the box leaves the transcript
|
||
// scrolled where it was, which puts the newest answer off-screen at the exact moment you
|
||
// asked for more room to read it.
|
||
// The two heights live on the element as data attributes rather than in a closure, so a page
|
||
// whose sizes are not knowable when the markup is written can rewrite them later — the
|
||
// Scheduler's panel takes its heights as a share of whatever room the panel has, which is a
|
||
// number that only exists after layout and changes on every resize. See setHeights().
|
||
// Two independent pieces of state, not one three-way. `big` is whether the conversation is
|
||
// expanded — an action, taken often. `large` is how much expanded is worth — a setting, chosen
|
||
// once. Collapsing and then expanding again returns to the size you picked rather than
|
||
// resetting it, which is the whole reason they are separate.
|
||
let big = false, large = false;
|
||
const growBtn = $('grow'), sizeBtn = $('size');
|
||
|
||
// What the open shortcuts list costs, including the margin that separates it from the
|
||
// transcript. Zero while shut. Measured rather than assumed: it is a grid that reflows to one
|
||
// column on a narrow card, so its height is a function of the width it was given.
|
||
function keysCost() {
|
||
const w = $('keys'), b = $('keys-b');
|
||
if (!w || !b || !w.classList.contains('open')) return 0;
|
||
const m = parseFloat(getComputedStyle(b).marginTop) || 0;
|
||
return b.offsetHeight + m;
|
||
}
|
||
|
||
function applyHeights() {
|
||
const el = chatEl();
|
||
const base = el.dataset.h || '';
|
||
const med = el.dataset.hTall || '';
|
||
const big2 = el.dataset.hLarge || med;
|
||
if (!base || !med) return;
|
||
const want = big ? (large ? big2 : med) : base;
|
||
// Subtracted, not added to. The height the page asked for is the height the whole window
|
||
// keeps; the list is taken out of the conversation's share of it. calc() because that height
|
||
// may be a viewport share while this cost is only ever pixels.
|
||
//
|
||
// Floored, because on the smallest placements the list is taller than the transcript it is
|
||
// being taken from — the Scheduler collapsed at a tenth of its panel is a couple of rows.
|
||
// Leaving a transcript of zero would be worse than briefly outgrowing the budget.
|
||
const k = keysCost();
|
||
el.style.height = k ? 'max(56px, calc(' + want + ' - ' + k + 'px))' : want;
|
||
|
||
if (growBtn) {
|
||
growBtn.textContent = big ? '⤡' : '⤢';
|
||
growBtn.title = big ? 'Back to the smaller view' : 'Give the conversation more room';
|
||
growBtn.classList.toggle('vv-ai-grow-on', big);
|
||
}
|
||
if (sizeBtn) {
|
||
// Nothing to choose between while collapsed — it would name a size that is not on screen
|
||
// and does not become so until the button beside it is pressed.
|
||
sizeBtn.style.display = big ? '' : 'none';
|
||
// Names the size in force, not the one it would switch to. A button labelled with its own
|
||
// effect reads as a statement of current state to about half of everyone who sees it, and
|
||
// the two readings disagree about what clicking does — so it states, and the title says
|
||
// what happens.
|
||
sizeBtn.textContent = large ? 'Large' : 'Medium';
|
||
sizeBtn.title = large ? 'Expanded is large — click for medium'
|
||
: 'Expanded is medium — click for large';
|
||
sizeBtn.classList.toggle('vv-ai-grow-on', large);
|
||
}
|
||
}
|
||
// Named functions, because the buttons are no longer the only way in — the shortcuts below
|
||
// drive the same two.
|
||
//
|
||
// onResize fires from these and never from setHeights(). A page whose layout depends on this
|
||
// height calls setHeights() from inside its fit routine, so announcing it there would call
|
||
// that routine from within itself, forever.
|
||
//
|
||
// A placement that is not simply taller than its neighbours has to be told. The Scheduler
|
||
// panel is pinned at the bottom and the views above it are sized from what is left, so without
|
||
// this the chat grows downward off the end of the panel and takes its own composer with it —
|
||
// which is precisely what it did.
|
||
// Announced twice: now, so a page that lays out around this tracks the change as it happens,
|
||
// and again once it has finished.
|
||
//
|
||
// The transcript animates its height — see the transition on .vv-ai-chat — so a page that
|
||
// measures the moment we change it reads a box that is still moving. The Scheduler sizes the
|
||
// view above from the panel's measured height, and re-measuring one frame after a collapse
|
||
// read a dock caught part of the way down: the view came out short by roughly the height the
|
||
// chat was shrinking from, leaving a dead strip under the content that nothing filled. It only
|
||
// ever showed on collapse, because on expand the same error sizes the view too large and flex
|
||
// quietly shrinks it back.
|
||
let settle = null;
|
||
function announceResize() {
|
||
onResize(big);
|
||
clearTimeout(settle);
|
||
// Comfortably past the 140ms transition. A timer rather than transitionend, which does not
|
||
// fire at all when the computed height happens not to change.
|
||
settle = setTimeout(() => onResize(big), 220);
|
||
}
|
||
|
||
function toggleBig() { big = !big; applyHeights(); scroll(); announceResize(); }
|
||
// Only meaningful while expanded, and the button is only reachable then. The shortcut has to
|
||
// check for itself rather than silently changing a size nobody can see.
|
||
function toggleSize() {
|
||
if (!big) return;
|
||
large = !large; applyHeights(); scroll(); announceResize();
|
||
}
|
||
|
||
if (growBtn) growBtn.addEventListener('click', toggleBig);
|
||
if (sizeBtn) sizeBtn.addEventListener('click', toggleSize);
|
||
|
||
// ── Shortcuts panel ──────────────────────────────────────────────────
|
||
// One key for every instance, not one each. The list is identical everywhere, so remembering
|
||
// it per surface would mean closing the same panel three times to be rid of it.
|
||
//
|
||
// Absent means never seen, which is the only time it opens by itself.
|
||
const KEYS_OPEN = 'vv-ai-keys-open';
|
||
const keysWrap = $('keys'), keysT = $('keys-t');
|
||
if (keysWrap && keysT) {
|
||
// applyHeights() after the class, never before: the cost is measured off the rendered list,
|
||
// and while it is still display:none that measurement is zero.
|
||
const applyKeys = open => {
|
||
keysWrap.classList.toggle('open', open);
|
||
keysT.setAttribute('aria-expanded', open ? 'true' : 'false');
|
||
keysT.classList.toggle('vv-ai-grow-on', open);
|
||
applyHeights();
|
||
};
|
||
applyKeys(localStorage.getItem(KEYS_OPEN) !== '0');
|
||
keysT.addEventListener('click', () => {
|
||
const open = !keysWrap.classList.contains('open');
|
||
localStorage.setItem(KEYS_OPEN, open ? '1' : '0');
|
||
applyKeys(open);
|
||
scroll();
|
||
// The total is meant to hold, but the floor above can break that on a small placement, so
|
||
// the page is told either way rather than being left to find out by clipping. Through the
|
||
// same settle as the size controls: the list appears at once but the transcript animates
|
||
// to its new height, so an immediate measurement is of a box still moving.
|
||
announceResize();
|
||
});
|
||
}
|
||
|
||
// Bound to the component, not to document. Alt+N and friends are browser menu accelerators;
|
||
// claiming them page-wide would break the tab this lives on. Inside the chat they are
|
||
// unambiguous, and everything they do is visible from where the operator already is.
|
||
const wrapEl = chatEl().closest('.vv-ai-chatwrap');
|
||
if (wrapEl) {
|
||
wrapEl.addEventListener('keydown', e => {
|
||
const inp = $('input');
|
||
|
||
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); send(); return; }
|
||
|
||
// Terminal habit: an empty box and Up gets back what you just asked, to edit rather than
|
||
// retype. Only when empty, or it would eat cursor movement in a question being written.
|
||
if (e.key === 'ArrowUp' && e.target === inp && inp.value === '') {
|
||
for (let i = messages.length - 1; i >= 0; i--) {
|
||
if (messages[i].role === 'user') {
|
||
e.preventDefault();
|
||
inp.value = messages[i].content;
|
||
inp.setSelectionRange(inp.value.length, inp.value.length);
|
||
break;
|
||
}
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (e.key === 'Escape') {
|
||
const wasOpen = (picker && picker.classList.contains('open'))
|
||
|| (histWrap && histWrap.classList.contains('open'));
|
||
closeMenus();
|
||
// Only clears once there is no menu left to close, so one Escape never does two things.
|
||
if (!wasOpen && e.target === inp && inp.value !== '') { e.preventDefault(); inp.value = ''; }
|
||
return;
|
||
}
|
||
|
||
if (!e.altKey || e.ctrlKey || e.metaKey) return;
|
||
const k = e.key.toLowerCase();
|
||
if (k === 'n') { e.preventDefault(); newChat(); inp.focus(); }
|
||
else if (k === 'e') { e.preventDefault(); toggleBig(); }
|
||
else if (k === 's') { e.preventDefault(); toggleSize(); }
|
||
else if (k === 'h') { const hb = $('hist-b'); if (hb) { e.preventDefault(); hb.click(); } }
|
||
});
|
||
}
|
||
|
||
// Surface any script error into the transcript. Without it a throw anywhere on the page is
|
||
// invisible unless the console happens to be open, which is how a silent hang survives a
|
||
// diagnosis session.
|
||
const onErr = e => {
|
||
if (!busy) return;
|
||
addError('Script error: ' + (e.message || 'unknown')
|
||
+ (e.filename ? ' (' + e.filename.split('/').pop() + ':' + e.lineno + ')' : ''));
|
||
finish();
|
||
};
|
||
const onRej = e => {
|
||
if (!busy) return;
|
||
addError('Unhandled rejection: ' + (e.reason && e.reason.message ? e.reason.message : e.reason));
|
||
finish();
|
||
};
|
||
window.addEventListener('error', onErr);
|
||
window.addEventListener('unhandledrejection', onRej);
|
||
|
||
applyProfile(profile);
|
||
|
||
// ── Resume ───────────────────────────────────────────────────────────
|
||
// Opens on the conversation last spoken to, rather than an empty box. Chosen by `updated`
|
||
// across the whole store, not per surface: the point of one store was that a thread started
|
||
// on the dashboard is the one you carry on in the tab, and resuming per-surface would give
|
||
// each of them its own idea of where you left off — which is the thing it was meant to stop.
|
||
//
|
||
// Silent on failure. Nothing here is worth an error message: the fallback is the empty box
|
||
// the operator would otherwise have got, and an empty box is a working chat.
|
||
if (store && o.resume !== false) {
|
||
fetch(API + '?action=chats').then(r => r.json()).then(d => {
|
||
if (!d.ok || !(d.chats || []).length) return;
|
||
// Do not clobber a conversation the operator has already started. The list arrives after
|
||
// construction, so by the time it lands they may have typed and sent something.
|
||
if (messages.length || busy) return;
|
||
const last = d.chats.slice().sort((a, b) => (b.updated || 0) - (a.updated || 0))[0];
|
||
if (last) loadChat(last.id);
|
||
}).catch(() => {});
|
||
}
|
||
|
||
const inst = {
|
||
prefix: P,
|
||
setProfile, newChat, loadChat, send, offer,
|
||
busy: () => busy,
|
||
expanded: () => big,
|
||
currentId: () => chatId,
|
||
|
||
// Heights supplied after the fact, for a placement whose room is a share of a panel rather
|
||
// than a constant. Re-applies immediately at whichever of the two states is current, so a
|
||
// resize while expanded stays expanded instead of snapping back.
|
||
setHeights(base, tallMed, tallLarge) {
|
||
const el = chatEl();
|
||
el.dataset.h = base; el.dataset.hTall = tallMed;
|
||
if (tallLarge) el.dataset.hLarge = tallLarge;
|
||
applyHeights();
|
||
},
|
||
|
||
// Same contract, different subject — the Scheduler pointing the chat at another script, log
|
||
// or conf as the operator moves around the tab. Distinct from setProfile: that changes who
|
||
// is answering, this changes what about.
|
||
//
|
||
// The transcript keeps everything and only the model's floor moves, because a troubleshooting
|
||
// thread about one script must not bleed into a question about another, while hiding that the
|
||
// earlier exchange happened is worse than carrying it visibly. chatId is dropped with it: the
|
||
// scope is part of the stored record, so appending turns about a different thing to the same
|
||
// row would produce a conversation whose stored scope describes only its first half.
|
||
retarget(prof, label, note) {
|
||
if (label !== undefined) scopeLabel = label;
|
||
applyProfile(PROFILES[prof] ? prof : profile);
|
||
if (messages.length > sendFrom && note) {
|
||
chatEl().appendChild(el('<div class="vv-ai-switch">' + esc(note) + '</div>'));
|
||
scroll();
|
||
}
|
||
sendFrom = messages.length;
|
||
chatId = '';
|
||
lastSources = [];
|
||
},
|
||
|
||
// A line in the transcript that is not a turn — something the page did, said where the
|
||
// operator is already looking rather than in a banner they have to notice.
|
||
note: noteLine,
|
||
teardown() {
|
||
clearInterval(pendingTimer);
|
||
// Would otherwise fire a re-layout at a page whose instance no longer exists.
|
||
clearTimeout(settle);
|
||
window.removeEventListener('error', onErr);
|
||
window.removeEventListener('unhandledrejection', onRej);
|
||
document.removeEventListener('click', onDocClick);
|
||
// The keydown pair was previously registered and never removed, so every tab swap left
|
||
// another Escape handler bound to a dead menu.
|
||
document.removeEventListener('keydown', onDocKey);
|
||
if (window.__vvAiChat[P] === inst) delete window.__vvAiChat[P];
|
||
},
|
||
};
|
||
window.__vvAiChat[P] = inst;
|
||
return inst;
|
||
};
|
||
|
||
// ── Stored conversations list ───────────────────────────────────────────
|
||
// Rendered wherever it is wanted and pointed at a chat instance. Both surfaces show the same
|
||
// rows off the same store, so opening a thread on Monitor and finishing it on the AI tab is
|
||
// one conversation rather than two.
|
||
window.VvAiChatList = function (o) {
|
||
const box = document.getElementById(o.into);
|
||
if (!box) return null;
|
||
let rows = [], activeId = '';
|
||
|
||
function render() {
|
||
if (!rows.length) {
|
||
box.innerHTML = '<div class="vv-ai-none">no saved conversations yet</div>';
|
||
return;
|
||
}
|
||
// The scope is shown, not just stored. A Scheduler thread is about one script or log,
|
||
// and a list of titles alone makes "why does this one talk about a log I never opened"
|
||
// an unanswerable question.
|
||
// Tagged only when the tag says something. A scope always does; a profile does unless it
|
||
// is the ordinary one — labelling every General Chat row "Chat" is a column of noise that
|
||
// makes the rows that are genuinely different harder to pick out, which is the opposite
|
||
// of the point.
|
||
const P = window.VvAiProfiles || {};
|
||
box.innerHTML = '<div class="vv-ai-clist">' + rows.map(c => {
|
||
const prof = (c.profile && c.profile !== 'chat' && P[c.profile]) ? P[c.profile].short : '';
|
||
const tag = [prof, c.scope || ''].filter(Boolean).join(' · ');
|
||
return `<div class="vv-ai-crow${c.id === activeId ? ' active' : ''}" data-id="${esc(c.id)}">`
|
||
+ `<span class="vv-ai-crow-t" title="${esc(tag ? tag + ' — ' + c.title : c.title)}">`
|
||
+ (tag ? `<span class="vv-ai-crow-s">${esc(tag)}</span> ` : '')
|
||
+ `${esc(c.title)}</span>`
|
||
+ `<span class="vv-ai-crow-m">${esc(ago(c.ts))}</span>`
|
||
+ `<span class="vv-ai-crow-x" data-del="${esc(c.id)}" title="Delete">×</span>`
|
||
+ `</div>`;
|
||
}).join('') + '</div>';
|
||
}
|
||
|
||
function load() {
|
||
fetch(API + '?action=chats').then(r => r.json())
|
||
.then(d => { if (d.ok) { rows = d.chats || []; render(); } })
|
||
.catch(() => {});
|
||
}
|
||
|
||
box.addEventListener('click', e => {
|
||
const del = e.target.closest('[data-del]');
|
||
if (del) {
|
||
e.stopPropagation();
|
||
fetch(API, { method: 'POST', headers: POST_HEAD,
|
||
body: new URLSearchParams({ action: 'chat_delete', id: del.dataset.del }) })
|
||
.then(() => {
|
||
// Deleting the conversation you are in leaves the transcript on screen but detached
|
||
// from the store, which would silently resurrect it on the next turn.
|
||
if (del.dataset.del === activeId && o.chat) o.chat.newChat();
|
||
load();
|
||
}).catch(() => {});
|
||
return;
|
||
}
|
||
const row = e.target.closest('.vv-ai-crow');
|
||
if (row && o.chat) { activeId = row.dataset.id; render(); o.chat.loadChat(row.dataset.id); }
|
||
});
|
||
|
||
return {
|
||
reload: load,
|
||
setActive(id) { activeId = id; load(); },
|
||
};
|
||
};
|
||
})();
|
||
</script>
|
||
<?php
|
||
}
|
||
|
||
// The shortcuts, in one place. Rendered into the panel by the markup below and acted on by the
|
||
// keydown handler in the script above, so a key that stops working cannot keep being advertised.
|
||
//
|
||
// Everything here fires only while focus is inside the chat. Alt combinations are browser menu
|
||
// accelerators when nothing has focus, and a chat that stole them page-wide would be a chat that
|
||
// broke the tab it lives on.
|
||
function vv_ai_chat_keys(): array {
|
||
return [
|
||
['keys' => ['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) . '"' : '';
|
||
?>
|
||
<div class="vv-ai-chatwrap<?= $compact ? ' vv-ai-c' : '' ?>" style="display:flex;flex-direction:column;gap:<?= $compact ? '4px' : '12px' ?>;min-width:0;">
|
||
|
||
<!-- Banner. Names the thing, and carries the setting rather than the action: how big expanded
|
||
is, not whether it is expanded. ⤢ below does the expanding, which is a thing you do to the
|
||
conversation and belongs with the other things you do to it. This picks what that action
|
||
will be worth, which you set once and rarely revisit.
|
||
Hidden while collapsed, because at that point it describes nothing on screen. -->
|
||
<div class="vv-ai-head">
|
||
<span class="vv-ai-head-t">
|
||
<?php if (!empty($o['icon'])): ?><span class="vv-ico"><?= $o['icon'] ?></span><?php endif; ?>
|
||
<?= htmlspecialchars($o['title'] ?? 'Assistant') ?>
|
||
</span>
|
||
<button class="vv-ai-btn ghost vv-ai-size" id="<?= $p ?>-size" type="button"
|
||
style="display:none">Medium</button>
|
||
<!-- Far right, and there whatever the conversation is doing — unlike the size button beside
|
||
it, which has nothing to say while collapsed. -->
|
||
<button class="vv-ai-btn ghost vv-ai-keysbtn" id="<?= $p ?>-keys-t" type="button"
|
||
aria-expanded="true" title="Keyboard shortcuts">Shortcuts</button>
|
||
</div>
|
||
|
||
<!-- Shortcuts. Open on a browser that has never seen it and remembered from then on, which for
|
||
most people means it is shut a minute later and never thought about again — which is the
|
||
point. A panel that is closed by default is one nobody discovers; one that cannot be closed
|
||
is clutter on every load forever.
|
||
|
||
It is paid for out of the transcript rather than added underneath the banner: the window
|
||
keeps the height it was given and the conversation gives up the rows, so opening this never
|
||
pushes the composer down or, on the Scheduler, past the end of the panel. Closing hands the
|
||
rows straight back. See applyHeights().
|
||
|
||
The list is data, so the keys shown and the keys handled come from one place and cannot
|
||
drift into describing a shortcut that no longer works. -->
|
||
<div class="vv-ai-keys" id="<?= $p ?>-keys">
|
||
<div class="vv-ai-keys-b" id="<?= $p ?>-keys-b">
|
||
<?php foreach (vv_ai_chat_keys() as $k): ?>
|
||
<div class="vv-ai-key">
|
||
<span class="vv-ai-key-k"><?php foreach ($k['keys'] as $cap): ?><kbd><?= htmlspecialchars($cap) ?></kbd><?php endforeach; ?></span>
|
||
<span class="vv-ai-key-d"><?= htmlspecialchars($k['what']) ?></span>
|
||
</div>
|
||
<?php endforeach; ?>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="vv-ai-chat" id="<?= $p ?>-chat"<?= $style ?>>
|
||
<div class="vv-ai-empty"><?= htmlspecialchars($empty) ?></div>
|
||
</div>
|
||
|
||
<div class="vv-ai-composer">
|
||
<textarea class="vv-ai-input" id="<?= $p ?>-input" rows="<?= $compact ? 1 : 2 ?>"
|
||
title="Ctrl+Enter to send"
|
||
placeholder="<?= htmlspecialchars($o['placeholder'] ?? 'Ask anything — questions about this install route to the assistant on their own.', ENT_QUOTES) ?>"></textarea>
|
||
|
||
<!-- Three groups, and everything in all of them is clickable. What is answering holds the
|
||
left; what you do to the conversation sits in the middle, where the composer above it
|
||
is; what you do to the window holds the right. Grouping by what a control acts on rather
|
||
than by importance means the middle is the only part you look at while working.
|
||
|
||
Centred by giving the two outer groups equal flex, not by margins — the middle stays put
|
||
when the chip's text changes length, which on the Scheduler it does on every view switch.
|
||
|
||
One chip, not a row of buttons: four profiles as four always-visible buttons spend a whole
|
||
line restating three choices you are not making, and only grow as profiles are added. -->
|
||
<div class="vv-ai-ctrls">
|
||
<div class="vv-ai-side" id="<?= $p ?>-profiles">
|
||
<div class="vv-ai-picker">
|
||
<button class="vv-ai-chip" id="<?= $p ?>-chip" type="button" aria-haspopup="listbox"
|
||
aria-expanded="false"><span id="<?= $p ?>-chip-l"></span><span class="vv-ai-chip-c">▾</span></button>
|
||
<div class="vv-ai-menu" id="<?= $p ?>-menu" role="listbox">
|
||
<?php foreach (vv_ai_profiles_ui() as $key => $def): ?>
|
||
<button class="vv-ai-opt" data-prof="<?= htmlspecialchars($key, ENT_QUOTES) ?>"
|
||
type="button" role="option">
|
||
<span class="vv-ai-opt-l"><?= htmlspecialchars($def['label']) ?></span>
|
||
<span class="vv-ai-opt-h"><?= htmlspecialchars($def['hint']) ?></span>
|
||
</button>
|
||
<?php endforeach; ?>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="vv-ai-actions">
|
||
<?= $o['controls'] ?? '' ?>
|
||
<button class="vv-ai-btn ghost" id="<?= $p ?>-new" type="button">New</button>
|
||
<!-- Saved conversations. The same store the Conversations card reads, so a thread opened
|
||
from either is the same thread. Worded, not a chevron: a bare ▾ four buttons away from
|
||
the chip's ▾ was two dropdowns wearing one glyph for unrelated jobs. -->
|
||
<div class="vv-ai-picker" id="<?= $p ?>-hist">
|
||
<button class="vv-ai-btn ghost" id="<?= $p ?>-hist-b" type="button"
|
||
aria-haspopup="listbox" aria-expanded="false"
|
||
title="Reopen a saved conversation">Saved</button>
|
||
<div class="vv-ai-menu vv-ai-menu-r" id="<?= $p ?>-hist-menu" role="listbox"></div>
|
||
</div>
|
||
<button class="vv-ai-btn" id="<?= $p ?>-send" type="button">Ask</button>
|
||
</div>
|
||
|
||
<div class="vv-ai-side vv-ai-tail">
|
||
<button class="vv-ai-btn ghost vv-ai-grow" id="<?= $p ?>-grow" type="button"
|
||
title="Give the conversation more room">⤢</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<?php
|
||
}
|
||
|
||
// The stored-conversations container. Rendered separately from the chat because the two live in
|
||
// different cards on Monitor and in different parts of the column on the AI tab.
|
||
//
|
||
// Takes compact for the same reason the chat does: these rows are painted against whatever they
|
||
// sit on, and a card and a panel are not the same ground.
|
||
function vv_ai_chat_list_markup(string $prefix, bool $compact = false): void {
|
||
$p = htmlspecialchars($prefix, ENT_QUOTES);
|
||
?>
|
||
<div id="<?= $p ?>-chats"<?= $compact ? ' class="vv-ai-c"' : '' ?>><div class="vv-ai-none">loading…</div></div>
|
||
<?php
|
||
}
|