samagotchi 0.91.0 → 0.93.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +88 -1
- data/README.md +5 -2
- data/bin/chi +8 -8
- data/docs/architecture.md +20 -16
- data/docs/bundle-authoring.md +2 -1
- data/docs/cli.md +5 -5
- data/docs/configuration.md +3 -1
- data/docs/context.md +5 -3
- data/docs/guardrails.md +4 -5
- data/docs/hooks.md +3 -3
- data/docs/internals/background-tasks.md +2 -2
- data/docs/internals/bundles.md +4 -4
- data/docs/internals/engine.md +27 -8
- data/docs/internals/tool-guardrails.md +10 -2
- data/docs/memory.md +6 -0
- data/docs/plugins.md +30 -13
- data/docs/releasing.md +3 -0
- data/docs/sessions.md +4 -4
- data/docs/testing.md +2 -1
- data/lib/samagotchi/bootstrap/probe.rb +2 -2
- data/lib/samagotchi/bridge.rb +8 -9
- data/lib/samagotchi/bundle_command/check.rb +43 -12
- data/lib/samagotchi/bundle_command/scaffold.rb +12 -0
- data/lib/samagotchi/bundle_command/test_run.rb +12 -0
- data/lib/samagotchi/bundle_command/try/command.rb +164 -0
- data/lib/samagotchi/bundle_command/try/render.rb +80 -0
- data/lib/samagotchi/bundle_command/try/run.rb +35 -17
- data/lib/samagotchi/bundle_command/try/sandbox.rb +113 -34
- data/lib/samagotchi/bundle_command/try/trace.rb +20 -26
- data/lib/samagotchi/bundle_command.rb +11 -213
- data/lib/samagotchi/bundle_session_settings.rb +82 -45
- data/lib/samagotchi/bundles/core/manifest.yml +2 -2
- data/lib/samagotchi/bundles/cost-cap/manifest.yml +6 -5
- data/lib/samagotchi/bundles/cost-cap/plugin.rb +57 -12
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/child_env.rb +50 -0
- data/lib/samagotchi/child_reports.rb +12 -14
- data/lib/samagotchi/client_id.rb +18 -0
- data/lib/samagotchi/config/restart_watch.rb +112 -0
- data/lib/samagotchi/config.rb +18 -59
- data/lib/samagotchi/engine.rb +26 -26
- data/lib/samagotchi/guardrails/data_readers.rb +34 -30
- data/lib/samagotchi/guardrails/parent_levels.rb +31 -0
- data/lib/samagotchi/guardrails/parent_policy.rb +9 -20
- data/lib/samagotchi/guardrails/shell_lex.rb +28 -7
- data/lib/samagotchi/hook_question.rb +21 -0
- data/lib/samagotchi/hook_routes.rb +6 -6
- data/lib/samagotchi/hooks/registry.rb +7 -5
- data/lib/samagotchi/host_registry.rb +1 -1
- data/lib/samagotchi/kernel_loop.rb +14 -3
- data/lib/samagotchi/llm/anthropic_messages.rb +5 -0
- data/lib/samagotchi/llm/chat_loop.rb +3 -1
- data/lib/samagotchi/llm/errors.rb +4 -4
- data/lib/samagotchi/memory_bundle/builder.rb +3 -2
- data/lib/samagotchi/memory_bundle/installed_bundle.rb +7 -2
- data/lib/samagotchi/memory_bundle/installer.rb +1 -0
- data/lib/samagotchi/memory_bundle/manifest.rb +42 -8
- data/lib/samagotchi/memory_bundle/provenance.rb +2 -1
- data/lib/samagotchi/parent_answers_command.rb +6 -6
- data/lib/samagotchi/parent_report.rb +12 -0
- data/lib/samagotchi/plugin/context.rb +6 -5
- data/lib/samagotchi/plugin/http_transport.rb +14 -3
- data/lib/samagotchi/plugin/sessions.rb +2 -3
- data/lib/samagotchi/question_desk.rb +12 -9
- data/lib/samagotchi/reply_wait/poll.rb +267 -0
- data/lib/samagotchi/reply_wait.rb +51 -190
- data/lib/samagotchi/sandbox_layout.rb +66 -0
- data/lib/samagotchi/send_command/parent_gate.rb +83 -0
- data/lib/samagotchi/send_command/wait_target.rb +55 -0
- data/lib/samagotchi/send_command.rb +34 -103
- data/lib/samagotchi/session.rb +10 -9
- data/lib/samagotchi/session_inbox.rb +31 -8
- data/lib/samagotchi/session_manager.rb +60 -35
- data/lib/samagotchi/session_metrics.rb +22 -3
- data/lib/samagotchi/sessions_command.rb +4 -4
- data/lib/samagotchi/task_watch.rb +64 -21
- data/lib/samagotchi/terminal_ui/attached_loop.rb +3 -3
- data/lib/samagotchi/terminal_ui/formatting.rb +8 -5
- data/lib/samagotchi/testing/bundle.rb +33 -20
- data/lib/samagotchi/testing/host.rb +2 -8
- data/lib/samagotchi/testing/install.rb +26 -17
- data/lib/samagotchi/testing/recorder.rb +6 -9
- data/lib/samagotchi/testing/sandbox.rb +45 -37
- data/lib/samagotchi/testing/waiting.rb +5 -7
- data/lib/samagotchi/tool_response.rb +28 -6
- data/lib/samagotchi/tools/delegate.rb +10 -10
- data/lib/samagotchi/tools/delegate_cursor.rb +32 -8
- data/lib/samagotchi/tools/delegate_relay.rb +31 -16
- data/lib/samagotchi/tools/delegate_wait.rb +4 -4
- data/lib/samagotchi/tools/task_runtime.rb +2 -2
- data/lib/samagotchi/turn_checkpoint.rb +23 -10
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +46 -63
- data/lib/samagotchi/web/public/app.js +130 -554
- data/lib/samagotchi/web/public/cards_place.js +4 -1
- data/lib/samagotchi/web/public/images.js +6 -0
- data/lib/samagotchi/web/public/index.html +1 -1
- data/lib/samagotchi/web/public/model_pick.js +10 -0
- data/lib/samagotchi/web/public/send_turn.js +173 -0
- data/lib/samagotchi/web/public/session_actions.js +203 -0
- data/lib/samagotchi/web/public/start_page.js +236 -0
- data/lib/samagotchi/web/public/toasts.js +17 -9
- data/lib/samagotchi/web/public/unqueue.js +20 -4
- data/lib/samagotchi/worker.rb +77 -185
- data/lib/samagotchi/worker_idle_exit.rb +20 -5
- data/lib/samagotchi/worker_inbound.rb +5 -5
- data/lib/samagotchi/worker_task_wakes.rb +160 -0
- data/lib/samagotchi/worker_wakes.rb +0 -3
- metadata +15 -1
|
@@ -150,7 +150,10 @@ export function createCardsPlace({
|
|
|
150
150
|
const el = card.type === "card" && cardElement(card.id);
|
|
151
151
|
if (el) refreshCardBody(el, card);
|
|
152
152
|
}
|
|
153
|
-
} catch (_) {
|
|
153
|
+
} catch (_) {
|
|
154
|
+
// The live text stays (a failed re-read is only a lost render);
|
|
155
|
+
// the next card or load renders it.
|
|
156
|
+
}
|
|
154
157
|
}, 150);
|
|
155
158
|
}
|
|
156
159
|
|
|
@@ -94,6 +94,12 @@ export function chipsTemplate(chips) {
|
|
|
94
94
|
html`<span class="chip" data-index=${i}><img src=${c.src} alt=""><span class="chip-name">${c.name}</span><button type="button" class="chip-remove" data-index=${i} aria-label=${`remove ${c.name}`}>×</button></span>`);
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
+
// The chips left once a send is acked: those added while it was in flight
|
|
98
|
+
// (an upload can take seconds) stay for the next message.
|
|
99
|
+
export function chipsAfterSend(chips, sent) {
|
|
100
|
+
return (chips || []).filter((c) => !sent.includes(c));
|
|
101
|
+
}
|
|
102
|
+
|
|
97
103
|
// The {file, name} refs a turn names.
|
|
98
104
|
export function turnRefs(chips) {
|
|
99
105
|
return (chips || []).filter((c) => c.ref).map((c) => ({ file: c.ref.file, name: c.name }));
|
|
@@ -646,7 +646,7 @@ body.has-session #composer{flex-direction:row;align-items:flex-end;padding:10px}
|
|
|
646
646
|
body.has-session #composer textarea{min-height:0}
|
|
647
647
|
body.has-session #composeHint{display:none}
|
|
648
648
|
body.has-session #composer .actions-col{flex-wrap:nowrap;flex-shrink:0}
|
|
649
|
-
/* The model picker (the start page, and a fresh chain link:
|
|
649
|
+
/* The model picker (the start page, and a fresh chain link: start_page.js syncModelPick): reads as a label, "host:model ▾", left of Start; a button under a drawn caret that opens a searchable list (model_picker.js). Its name ellipsizes within max-width. */
|
|
650
650
|
#composer .actions-col .model-pick{order:0;position:relative;display:inline-block;max-width:min(50%,380px);height:28px;min-height:28px;padding:0 22px 0 8px;border-radius:8px;overflow:hidden;background:transparent;color:var(--text-muted);font-size:12px;line-height:28px;text-align:left;text-overflow:ellipsis;transition:background .12s ease,color .12s ease}
|
|
651
651
|
#composer .actions-col .model-pick:hover,#composer .actions-col .model-pick[aria-expanded="true"]{filter:none;background:color-mix(in srgb,var(--bg-hover) 75%,transparent);color:var(--text-2)}
|
|
652
652
|
#composer .actions-col .model-pick[hidden],body.has-session #composer .actions-col .model-pick{display:none}
|
|
@@ -1,7 +1,17 @@
|
|
|
1
1
|
// The start page's model picker: pure helpers over GET /api/models' payload.
|
|
2
|
+
import { cantText } from "./format.js";
|
|
2
3
|
|
|
3
4
|
export const MODEL_KEY = "chi_model";
|
|
4
5
|
|
|
6
|
+
// A failed GET /api/models' toast: the picker stays hidden, so it says why
|
|
7
|
+
// and what a new session gets (+ new session asks again). "" (no toast) in
|
|
8
|
+
// a session, where no picker shows (+asked+: + new session's own load),
|
|
9
|
+
// and for a fetch aborted or cut by the page unloading.
|
|
10
|
+
export function modelsFailedText(error, { startPage = true, asked = false, leaving = false } = {}) {
|
|
11
|
+
if (leaving || error?.name === "AbortError" || !(startPage || asked)) return "";
|
|
12
|
+
return `${cantText("list the models", error)}. A new session starts on the default model.`;
|
|
13
|
+
}
|
|
14
|
+
|
|
5
15
|
// The name to preselect from the rows (modelRows): the remembered one when
|
|
6
16
|
// it is still offered (any case), else the server's default, both even
|
|
7
17
|
// when their host is down; else the first row whose host answered, else
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { routeComposerLine, routeSessionLine } from "./composer.js";
|
|
2
|
+
import { chipsAfterSend, restoredChips, turnRefs, turnText, uploadChips } from "./images.js";
|
|
3
|
+
import { cantText, firstPreview } from "./format.js";
|
|
4
|
+
import { setBubbleDelivery } from "./bubbles.js";
|
|
5
|
+
import { composerAfterAck, dropEarlyRestores, restoreOnAck } from "./restore.js";
|
|
6
|
+
|
|
7
|
+
// The composer's send (used by app.js): a line into the open session, or
|
|
8
|
+
// on the start page a new session first (an idle one, then the line as for
|
|
9
|
+
// any later one, so a failed first turn's prompt comes back into the
|
|
10
|
+
// composer too). The route (composer.js), the echo, the images' upload, the
|
|
11
|
+
// /turn and its ack, a fast failure's restore (restore.js) and the error's
|
|
12
|
+
// words.
|
|
13
|
+
//
|
|
14
|
+
// Hooks: +session()+ the open session's id or null; +state()+ its fields
|
|
15
|
+
// (session_state.js: owner, commands, firstPreview); +running()+ a turn
|
|
16
|
+
// runs; +clientId+; +sentIds+ / +earlyRestores+ (restore.js' bookkeeping);
|
|
17
|
+
// +api+: {createIdleSession, uploadImage, sendTurn} (data.js); +dir+: the
|
|
18
|
+
// page's scope folder (new sessions start there); +startPage+
|
|
19
|
+
// (start_page.js: pendingPick(), chosenModel(), newChoices(),
|
|
20
|
+
// clearChoices()); +takeDelivery()+ the delivery line's armed choice;
|
|
21
|
+
// +composer+: {el (the text box), button (Send/Start), chips(),
|
|
22
|
+
// setChips(list), fit(), clear(), openForVisit(), focus()}; +historyEl+,
|
|
23
|
+
// +turnDom+; +makeUserBubble(prompt, opts)+, +bubbleById(id)+,
|
|
24
|
+
// +addCommandBubble(view)+; +addCreatedSession(created)+, +select(id)+,
|
|
25
|
+
// +showNotice(text)+, +updateInfoBar()+, +updateComposerMode()+,
|
|
26
|
+
// +followWokenWorker()+, +refreshHistory()+, +leaveGoneSession(id, why)+,
|
|
27
|
+
// +runCommand(command)+ (composer.js send), +toast(text, action, options)+;
|
|
28
|
+
// +alert+: the browser's.
|
|
29
|
+
export function createSendTurn({
|
|
30
|
+
session, state, running, clientId, sentIds, earlyRestores, api, dir, startPage, takeDelivery, composer,
|
|
31
|
+
historyEl, turnDom, makeUserBubble, bubbleById, addCommandBubble,
|
|
32
|
+
addCreatedSession, select, showNotice, updateInfoBar, updateComposerMode, followWokenWorker, refreshHistory,
|
|
33
|
+
leaveGoneSession, runCommand, toast, alert = (text) => globalThis.alert(text),
|
|
34
|
+
}) {
|
|
35
|
+
const promptEl = composer.el;
|
|
36
|
+
const actionBtn = composer.button;
|
|
37
|
+
|
|
38
|
+
// +delivery+: the composer's choice for a running turn (Alt+Enter: queue;
|
|
39
|
+
// a delivery-line link: cut or queue); else the armed one; a typed /cut or
|
|
40
|
+
// /queue wins over both.
|
|
41
|
+
async function send({ delivery: chosen = null } = {}) {
|
|
42
|
+
if (state().owner === "tui") return;
|
|
43
|
+
// A fresh link's pick first: its /model is queued in the worker once
|
|
44
|
+
// acked, and the worker runs queued commands before a turn's input.
|
|
45
|
+
const pick = startPage.pendingPick();
|
|
46
|
+
if (pick) await pick;
|
|
47
|
+
let prompt = turnText(promptEl.value, composer.chips());
|
|
48
|
+
if (!prompt) return;
|
|
49
|
+
const armed = takeDelivery();
|
|
50
|
+
// An image-only send's text is its placeholders: not for ↑.
|
|
51
|
+
const typed = promptEl.value.trim() !== "";
|
|
52
|
+
// The composer holds the line as typed until the ack (composerAfterAck).
|
|
53
|
+
const line = prompt;
|
|
54
|
+
// Where the line goes (composer.js): the page's own reply, a session to
|
|
55
|
+
// make first (the start page), a command, or a turn.
|
|
56
|
+
let route = routeComposerLine(prompt, {
|
|
57
|
+
session: !!session(), running: running(), commands: state().commands, images: composer.chips().length,
|
|
58
|
+
});
|
|
59
|
+
if (route.create) {
|
|
60
|
+
actionBtn.disabled = true;
|
|
61
|
+
try {
|
|
62
|
+
const s = await api.createIdleSession({ dir, model: startPage.chosenModel(), preview: route.send,
|
|
63
|
+
...startPage.newChoices() });
|
|
64
|
+
// The choice was for this chat only.
|
|
65
|
+
startPage.clearChoices();
|
|
66
|
+
addCreatedSession(s);
|
|
67
|
+
await select(s.id);
|
|
68
|
+
} catch (e) {
|
|
69
|
+
alert(e.message);
|
|
70
|
+
return;
|
|
71
|
+
} finally {
|
|
72
|
+
actionBtn.disabled = false;
|
|
73
|
+
}
|
|
74
|
+
// In the new session, by its command list (select read it).
|
|
75
|
+
route = routeSessionLine(route.send, { commands: state().commands, images: composer.chips().length, delivery: route.delivery });
|
|
76
|
+
}
|
|
77
|
+
if (route.reply) {
|
|
78
|
+
// The start page's reply is a notice: no session is made for it (2.29).
|
|
79
|
+
if (session()) addCommandBubble({ label: null, line: route.line, text: route.reply });
|
|
80
|
+
else showNotice(route.reply);
|
|
81
|
+
composer.clear();
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
if (route.command) return runCommand(route.command);
|
|
85
|
+
prompt = route.send;
|
|
86
|
+
const isRunning = running() && !!session();
|
|
87
|
+
const delivery = route.delivery || (isRunning ? chosen || armed : null);
|
|
88
|
+
const hint = historyEl.querySelector(".hint");
|
|
89
|
+
if (hint) hint.remove();
|
|
90
|
+
// Sending moves a finished turn out of the stage at once (else the echo
|
|
91
|
+
// would land among its rows); into a running one it is a queued row there.
|
|
92
|
+
turnDom.handOff?.({ immediate: true });
|
|
93
|
+
const sending = composer.chips().slice();
|
|
94
|
+
// Into a running turn the echo says what it waits for (its delivery); a
|
|
95
|
+
// line with images always waits for the turn's end (the worker queues it).
|
|
96
|
+
const shownDelivery = isRunning ? (sending.length ? "queue" : delivery || "next_step") : null;
|
|
97
|
+
const echo = makeUserBubble(prompt, { state: "queued", own: true, images: sending, delivery: shownDelivery });
|
|
98
|
+
if (!turnDom.holds?.()) historyEl.scrollTop = historyEl.scrollHeight;
|
|
99
|
+
if (!state().firstPreview || state().firstPreview === "—") {
|
|
100
|
+
state().firstPreview = firstPreview(prompt);
|
|
101
|
+
updateInfoBar();
|
|
102
|
+
}
|
|
103
|
+
actionBtn.disabled = true;
|
|
104
|
+
// The session the line goes to: the page may switch to another while an
|
|
105
|
+
// image uploads or the turn is sent, and what follows stays with this one.
|
|
106
|
+
const sentTo = session();
|
|
107
|
+
try {
|
|
108
|
+
await uploadChips(sending, sentTo, { upload: api.uploadImage });
|
|
109
|
+
const ack = await api.sendTurn(sentTo, prompt, { clientId, images: turnRefs(sending), history: typed, delivery });
|
|
110
|
+
if (ack?.command_id) {
|
|
111
|
+
// The worker knew it as a command (its list was newer than ours):
|
|
112
|
+
// its command_ran shows it, not a prompt bubble.
|
|
113
|
+
echo.remove();
|
|
114
|
+
composer.clear();
|
|
115
|
+
if (sentTo === session()) followWokenWorker();
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
if (ack?.enqueued_id) sentIds.add(ack.enqueued_id);
|
|
119
|
+
// A cut the worker didn't take (no thinking-only step: cut off) goes in
|
|
120
|
+
// at the next step; a queue whose turn ended meanwhile is the next turn.
|
|
121
|
+
if (ack?.cut === "off" || (ack?.delivery === "next_step" && echo.dataset.delivery === "queue")) {
|
|
122
|
+
setBubbleDelivery(echo, ack.cut === "off" ? "next_step" : null);
|
|
123
|
+
}
|
|
124
|
+
refreshHistory();
|
|
125
|
+
// A turn that failed fast was restored before this ack: its prompt
|
|
126
|
+
// (and images) go back into the composer, without replacing text typed
|
|
127
|
+
// since (4.13). The composer still holds the text just sent: it goes.
|
|
128
|
+
const restored = restoreOnAck(ack?.enqueued_id, earlyRestores, { myId: clientId, sentIds });
|
|
129
|
+
// A restored line must be seen: the composer opens for this visit.
|
|
130
|
+
if (restored?.refill && sentTo === session()) composer.openForVisit();
|
|
131
|
+
promptEl.value = composerAfterAck({ sent: line, current: promptEl.value, refill: restored?.refill || "" });
|
|
132
|
+
composer.fit();
|
|
133
|
+
// Only the sent chips go: images added since stay (they go up at their own send).
|
|
134
|
+
const kept = chipsAfterSend(composer.chips(), sending);
|
|
135
|
+
composer.setChips(restored?.images?.length ? kept.concat(restoredChips(sentTo, restored.images)) : kept);
|
|
136
|
+
// Its turn_enqueued usually tagged the echo already; the ack covers a
|
|
137
|
+
// worker that wasn't live (no turn_enqueued then).
|
|
138
|
+
if (ack?.enqueued_id && !echo.dataset.enqueuedId && !bubbleById(ack.enqueued_id)) {
|
|
139
|
+
echo.dataset.enqueuedId = ack.enqueued_id;
|
|
140
|
+
}
|
|
141
|
+
// A live stream carries this turn already; restarting it would drop the
|
|
142
|
+
// events in between. Without one, POST /turn just woke a worker: its
|
|
143
|
+
// events start after the seq the session was rendered at (0 for a new
|
|
144
|
+
// worker process). A switch since then opened the other session's own.
|
|
145
|
+
if (sentTo === session()) followWokenWorker();
|
|
146
|
+
} catch (e) {
|
|
147
|
+
echo.remove();
|
|
148
|
+
// The turn never reached the worker's queue: an early restore waiting
|
|
149
|
+
// for its ack is never taken back (4.13), so drop it here. The prompt
|
|
150
|
+
// is still in the composer, as the echo was removed.
|
|
151
|
+
dropEarlyRestores(earlyRestores);
|
|
152
|
+
// The chips stay (uploaded ones keep their ref, to sentTo) for another try: an
|
|
153
|
+
// image refused (bad_image, too_large, bad_images) says why below.
|
|
154
|
+
if (e.code === "owned_by_tui") {
|
|
155
|
+
if (sentTo !== session()) return toast(cantText(`send to ${sentTo.slice(0, 8)}`, e), null, { sticky: true });
|
|
156
|
+
state().owner = "tui";
|
|
157
|
+
updateComposerMode();
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
// A stale tab sent into a session deleted meanwhile: the start page
|
|
161
|
+
// says so, and the line stays in the composer.
|
|
162
|
+
if (e.code === "not_found") return leaveGoneSession(sentTo, "not_found");
|
|
163
|
+
// A /queue to a worker that can't queue (it would steer): its words, the text kept.
|
|
164
|
+
if (e.code === "delivery_unavailable" && sentTo === session()) return addCommandBubble({ label: null, line, text: e.body?.detail || e.message, failed: true });
|
|
165
|
+
toast(cantText("send", e), null, { sticky: true });
|
|
166
|
+
} finally {
|
|
167
|
+
actionBtn.disabled = state().owner === "tui";
|
|
168
|
+
composer.focus();
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return { send };
|
|
173
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { continueRefusal, continuedText, openChildrenTemplate } from "./chains.js";
|
|
2
|
+
import { cantText, deleteConfirmText, goneSessionNotice } from "./format.js";
|
|
3
|
+
import { sessionHash } from "./route.js";
|
|
4
|
+
import { inTree } from "./sessions_list.js";
|
|
5
|
+
|
|
6
|
+
// What the page does to a whole session (used by app.js): Continue, Archive
|
|
7
|
+
// and Unarchive, Delete and Stop, from the info bar's buttons, a card's or a
|
|
8
|
+
// batch; and the start page when the open session is gone.
|
|
9
|
+
//
|
|
10
|
+
// Hooks: +selected()+ the open session's id or null; +state()+ its fields
|
|
11
|
+
// (session_state.js); +sessions()+ / +setSessions(list)+ the page's list;
|
|
12
|
+
// +api+: {continueSession, archiveSession, unarchiveSession, deleteSession,
|
|
13
|
+
// stopSession} (data.js); +eventsLive()+ the hub's stream is open (else
|
|
14
|
+
// +refresh()+ fetches the list); +lists+ (lists_view.js: render);
|
|
15
|
+
// +select(id)+, +clearSelection()+, +showNotice(text)+, +updateInfoBar()+,
|
|
16
|
+
// +toast(text, action, options)+, +addCreatedSession(created)+,
|
|
17
|
+
// +fillOpener(created)+ (the new link's composer), +closeLiveStream()+ (the live
|
|
18
|
+
// stream's close, the stream left as it is) and +workerGone(id)+ the page's.
|
|
19
|
+
// +alert+ / +confirm+: the browser's.
|
|
20
|
+
export function createSessionActions({
|
|
21
|
+
selected, state, sessions, setSessions, api, eventsLive, refresh, lists,
|
|
22
|
+
select, clearSelection, showNotice, updateInfoBar, toast, addCreatedSession, fillOpener, closeLiveStream, workerGone,
|
|
23
|
+
alert = (text) => globalThis.alert(text), confirm = (text) => globalThis.confirm(text),
|
|
24
|
+
}) {
|
|
25
|
+
// The session this tab is stopping: its stream may close before the stop
|
|
26
|
+
// request returns (workerGone reads it).
|
|
27
|
+
let stoppingId = null;
|
|
28
|
+
|
|
29
|
+
// Delete a session (the info bar's button, an all-sessions card's ✕) after
|
|
30
|
+
// a confirm; the list drops it without a reload. The server stops a live
|
|
31
|
+
// worker first and refuses a session a terminal chi has open.
|
|
32
|
+
async function confirmDelete(id) {
|
|
33
|
+
const sess = sessions().find((s) => s.id === id) || { id };
|
|
34
|
+
if (sess.owner === "tui") {
|
|
35
|
+
alert(`Session ${id.slice(0, 8)} is open in a terminal chi; close it there first.`);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
if (!confirm(deleteConfirmText(sess))) return;
|
|
39
|
+
try {
|
|
40
|
+
await api.deleteSession(id);
|
|
41
|
+
} catch (e) {
|
|
42
|
+
alert(`Could not delete session ${id.slice(0, 8)}: ${e.message}`);
|
|
43
|
+
if (!eventsLive()) refresh();
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
setSessions(sessions().filter((s) => s.id !== id));
|
|
47
|
+
if (selected() === id) clearSelection();
|
|
48
|
+
lists.render();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// The open session no longer exists: back to the start page, saying why.
|
|
52
|
+
function leaveGoneSession(id, why) {
|
|
53
|
+
if (selected() !== id) return;
|
|
54
|
+
clearSelection();
|
|
55
|
+
showNotice(goneSessionNotice(id, why));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// The info bar's Stop: the open session's worker, after a confirm.
|
|
59
|
+
async function stopSelected() {
|
|
60
|
+
if (!selected()) return;
|
|
61
|
+
if (!confirm(`Stop session ${selected()}?`)) return;
|
|
62
|
+
const id = selected();
|
|
63
|
+
stoppingId = id;
|
|
64
|
+
try {
|
|
65
|
+
await api.stopSession(id);
|
|
66
|
+
if (id === selected()) stoppedWorker(id);
|
|
67
|
+
} catch (e) {
|
|
68
|
+
// A refusal (a terminal holds it) or a worker that won't stop: said, as
|
|
69
|
+
// a failed restart is.
|
|
70
|
+
toast(cantText(`stop ${id.slice(0, 8)}`, e), null, { sticky: true });
|
|
71
|
+
} finally {
|
|
72
|
+
stoppingId = null;
|
|
73
|
+
}
|
|
74
|
+
// The list hears of the stop from the hub (the stop route rescans it).
|
|
75
|
+
if (!eventsLive()) refresh();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ── Continue (session chains) ─────────────────────────────────────────────
|
|
79
|
+
// Continue → starts the next link of the open session's chain (the server
|
|
80
|
+
// waits up to 30 s for its recap, archives it, starts the new one with the
|
|
81
|
+
// recap as a note) and opens it with the opener in the composer, not sent
|
|
82
|
+
// (fillOpener: the previous link's own first message when it continues one).
|
|
83
|
+
// A link continued already opens that next link; its open delegates move
|
|
84
|
+
// to the new link (the toast counts them); a refusal for open delegates
|
|
85
|
+
// that can't move names them, each a link.
|
|
86
|
+
// One continue per session at a time (the server locks it too); another
|
|
87
|
+
// session's continue may run beside it.
|
|
88
|
+
const continuingIds = new Set();
|
|
89
|
+
|
|
90
|
+
async function continueSelected() {
|
|
91
|
+
if (!selected() || continuingIds.has(selected())) return;
|
|
92
|
+
const id = selected();
|
|
93
|
+
const short = id.slice(0, 8);
|
|
94
|
+
continuingIds.add(id);
|
|
95
|
+
updateInfoBar();
|
|
96
|
+
try {
|
|
97
|
+
const created = await api.continueSession(id);
|
|
98
|
+
markArchived([id], true);
|
|
99
|
+
addCreatedSession(created);
|
|
100
|
+
const next = created.id;
|
|
101
|
+
// A model fallback's warning stays until closed.
|
|
102
|
+
const sticky = !!created.model_warning;
|
|
103
|
+
if (selected() !== id) {
|
|
104
|
+
toast(continuedText(id, created, { opened: false }), { label: "Open", run: () => select(next) }, { sticky });
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
await select(next);
|
|
108
|
+
if (selected() === next) fillOpener(created);
|
|
109
|
+
toast(continuedText(id, created), null, { sticky });
|
|
110
|
+
} catch (e) {
|
|
111
|
+
const refusal = continueRefusal(e, sessions());
|
|
112
|
+
if (refusal.kind === "open") {
|
|
113
|
+
if (selected() === id) await select(refusal.id);
|
|
114
|
+
toast(`${short} is continued already: ${selected() === refusal.id ? "opened" : "see"} ${refusal.id.slice(0, 8)}`);
|
|
115
|
+
} else if (refusal.kind === "children") {
|
|
116
|
+
toast(openChildrenTemplate(id, refusal.children, sessionHash), null, { sticky: true });
|
|
117
|
+
} else {
|
|
118
|
+
toast(`Can't continue ${short}: ${refusal.text}`, null, { sticky: true });
|
|
119
|
+
}
|
|
120
|
+
} finally {
|
|
121
|
+
continuingIds.delete(id);
|
|
122
|
+
updateInfoBar();
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ── Archive ───────────────────────────────────────────────────────────────
|
|
127
|
+
// Archive hides a session (and its delegates) from the strip and the list,
|
|
128
|
+
// kept for good; "include archived" in all sessions finds it. No confirm:
|
|
129
|
+
// the toast's Undo takes it back. Archiving the open session stays on it
|
|
130
|
+
// (the button flips to unarchive); its worker was stopped by the server.
|
|
131
|
+
|
|
132
|
+
function selectedArchived() {
|
|
133
|
+
return !!sessions().find((s) => s.id === selected())?.archived;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// The ids in the server's answer flip here at once; the hub's events
|
|
137
|
+
// follow with the same.
|
|
138
|
+
function markArchived(ids, archived) {
|
|
139
|
+
const set = new Set(ids);
|
|
140
|
+
setSessions(sessions().map((s) => (set.has(s.id) ? { ...s, archived } : s)));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// The open session's worker was stopped from here (the stop button, an
|
|
144
|
+
// archive): don't wait for its stream to notice.
|
|
145
|
+
function stoppedWorker(id) {
|
|
146
|
+
closeLiveStream();
|
|
147
|
+
state().status = "stopped";
|
|
148
|
+
workerGone(id);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// One archive / unarchive request, the core the info bar, a card's button
|
|
152
|
+
// and a batch share. The ids in the answer flip in the list at once. When
|
|
153
|
+
// the open session's worker was stopped (it, or a delegate of the archived
|
|
154
|
+
// one), its stream ending is a stop, not a lost worker.
|
|
155
|
+
// @return {ok: true, result} | {ok: false, error}
|
|
156
|
+
async function archiveOne(id, archive) {
|
|
157
|
+
// A live worker in the tree is stopped by the archive: the open
|
|
158
|
+
// session's stream ending then is a stop.
|
|
159
|
+
const open = selected();
|
|
160
|
+
const mine = archive && open && inTree(sessions(), id, open) ? open : null;
|
|
161
|
+
if (mine) stoppingId = mine;
|
|
162
|
+
try {
|
|
163
|
+
const result = archive ? await api.archiveSession(id) : await api.unarchiveSession(id);
|
|
164
|
+
markArchived(archive ? result.archived : result.unarchived, archive);
|
|
165
|
+
if (mine && mine === selected() && (result.stopped || []).includes(mine)) stoppedWorker(mine);
|
|
166
|
+
return { ok: true, result };
|
|
167
|
+
} catch (error) {
|
|
168
|
+
return { ok: false, error };
|
|
169
|
+
} finally {
|
|
170
|
+
// Another call may have taken it meanwhile: clear only our own.
|
|
171
|
+
if (mine && stoppingId === mine) stoppingId = null;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function setArchived(id, archive, { undo = true } = {}) {
|
|
176
|
+
const short = id.slice(0, 8);
|
|
177
|
+
const answer = await archiveOne(id, archive);
|
|
178
|
+
if (!answer.ok) {
|
|
179
|
+
alert(`Could not ${archive ? "archive" : "unarchive"} session ${short}: ${answer.error.message}`);
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
lists.render();
|
|
183
|
+
updateInfoBar();
|
|
184
|
+
if (!eventsLive()) refresh();
|
|
185
|
+
const what = archive ? "Archived" : "Unarchived";
|
|
186
|
+
toast(`${what} ${short}`, undo ? { label: "Undo", run: () => setArchived(id, !archive, { undo: false }) } : null);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// The info bar's Archive / Unarchive: the open session's, by its state.
|
|
190
|
+
function toggleArchiveSelected() {
|
|
191
|
+
if (!selected()) return;
|
|
192
|
+
if (selectedArchived()) setArchived(selected(), false);
|
|
193
|
+
else setArchived(selected(), true);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return {
|
|
197
|
+
confirmDelete, leaveGoneSession, stopSelected, continueSelected, archiveOne, setArchived, toggleArchiveSelected,
|
|
198
|
+
// The sessions whose Continue is under way (the info bar's button).
|
|
199
|
+
continuing: continuingIds,
|
|
200
|
+
// The session this tab is stopping, or null.
|
|
201
|
+
stoppingId: () => stoppingId,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import {
|
|
2
|
+
chipModel as llmContextChipModel, choiceWords, createLlmContextControl, NEW_SESSION, withChoice,
|
|
3
|
+
} from "./llm_context_chip.js";
|
|
4
|
+
import { chipModel as thinkingChipModel, createThinkingControl, withChoice as withThinkingChoice } from "./thinking_chip.js";
|
|
5
|
+
import { MODEL_KEY, modelsFailedText } from "./model_pick.js";
|
|
6
|
+
import { createModelPicker } from "./model_picker.js";
|
|
7
|
+
import { chainNeighbours, freshLink } from "./chains.js";
|
|
8
|
+
import { readStored } from "./storage.js";
|
|
9
|
+
|
|
10
|
+
// ── The start page's model picker ──────────────────────────────────────────
|
|
11
|
+
// The hosts' models from GET /api/models, the choice this browser made last
|
|
12
|
+
// preselected while it is still offered (else the server's default). Hidden
|
|
13
|
+
// until the list is in, and in a session (its model is in the info bar),
|
|
14
|
+
// but a fresh chain link's (chains.js freshLink): there it shows the link's
|
|
15
|
+
// model and a pick runs /model in the link, remembered neither as this
|
|
16
|
+
// browser's choice nor in Recent. A host that failed is the picker's
|
|
17
|
+
// tooltip; a route that fails leaves the picker hidden and the server's
|
|
18
|
+
// default in charge. The search list itself is model_picker.js'; a pick
|
|
19
|
+
// sends the focus to the message box.
|
|
20
|
+
//
|
|
21
|
+
// Beside it, the start page's LLM context and thinking chips: the picked
|
|
22
|
+
// model's values, and a choice for the next new session only (sent with its
|
|
23
|
+
// create, then dropped; never remembered: a reload or opening a session
|
|
24
|
+
// drops it too, a failed create keeps it).
|
|
25
|
+
export const START_PICK_TITLE = "The model the new session starts on";
|
|
26
|
+
|
|
27
|
+
// Used by app.js. Elements: +pickEl+ (the picker's button), +panelEl+,
|
|
28
|
+
// +searchEl+, +listEl+, +composerEl+, +centerEl+ (model_picker.js'),
|
|
29
|
+
// +llmContextEl+ / +thinkingEl+ (the two chips).
|
|
30
|
+
// Hooks: +selected()+ the open session's id or null; +state()+ the open
|
|
31
|
+
// session's fields (session_state.js: model, continues…); +running()+ a turn
|
|
32
|
+
// runs; +sessions()+ the list; +run(line)+ a session command from a control
|
|
33
|
+
// (resolves true once the worker took it); +focusPrompt()+; +toast(text)+;
|
|
34
|
+
// +listModels()+ GET /api/models (data.js). +win+: the page's unload events.
|
|
35
|
+
export function createStartPage({
|
|
36
|
+
pickEl, panelEl, searchEl, listEl, composerEl, centerEl, llmContextEl, thinkingEl,
|
|
37
|
+
selected, state, running, sessions, run, focusPrompt, toast, listModels,
|
|
38
|
+
doc = globalThis.document, win = globalThis.window,
|
|
39
|
+
}) {
|
|
40
|
+
// The new session's own choices (null: follow the picked model's).
|
|
41
|
+
let newChatLlmContext = null;
|
|
42
|
+
let newChatThinking = null;
|
|
43
|
+
let startPickTitle = START_PICK_TITLE;
|
|
44
|
+
// The fresh link the picker shows (null: the start page, or a session it
|
|
45
|
+
// is hidden in), and a link pick's /model still in flight (a send waits).
|
|
46
|
+
let pickLinkId = null;
|
|
47
|
+
let pickSelected = null;
|
|
48
|
+
let pendingPick = null;
|
|
49
|
+
let pendingPickId = null;
|
|
50
|
+
// A command_ran came while the pick was in flight (its sync was skipped):
|
|
51
|
+
// the ack syncs then.
|
|
52
|
+
let pickRanEarly = false;
|
|
53
|
+
// A reload cuts the fetch: its error is no news (modelsFailedText).
|
|
54
|
+
let pageLeaving = false;
|
|
55
|
+
|
|
56
|
+
const newChatLlmSummary = () => (selected() || pickEl.hidden ? null : modelPicker.chosenRow()?.llm_context || null);
|
|
57
|
+
const llmContextNewControl = createLlmContextControl({
|
|
58
|
+
el: llmContextEl,
|
|
59
|
+
summary: newChatLlmSummary,
|
|
60
|
+
kind: NEW_SESSION,
|
|
61
|
+
choice: () => newChatLlmContext,
|
|
62
|
+
choose: (next) => {
|
|
63
|
+
newChatLlmContext = next;
|
|
64
|
+
renderNewChatLlmContext();
|
|
65
|
+
focusPrompt();
|
|
66
|
+
},
|
|
67
|
+
id: "llmContextNewPopover",
|
|
68
|
+
anchor: "#llmContextNew",
|
|
69
|
+
});
|
|
70
|
+
const newChatThinkingSummary = () => (selected() || pickEl.hidden ? null : modelPicker.chosenRow()?.thinking || null);
|
|
71
|
+
const thinkingNewControl = createThinkingControl({
|
|
72
|
+
el: thinkingEl,
|
|
73
|
+
summary: newChatThinkingSummary,
|
|
74
|
+
kind: NEW_SESSION,
|
|
75
|
+
choice: () => newChatThinking,
|
|
76
|
+
choose: (next) => {
|
|
77
|
+
newChatThinking = next;
|
|
78
|
+
renderNewChatThinking();
|
|
79
|
+
focusPrompt();
|
|
80
|
+
},
|
|
81
|
+
id: "thinkingNewPopover",
|
|
82
|
+
anchor: "#thinkingNew",
|
|
83
|
+
});
|
|
84
|
+
function renderNewChatThinking() {
|
|
85
|
+
const chip = thinkingChipModel(withThinkingChoice(newChatThinkingSummary(), newChatThinking), { kind: NEW_SESSION });
|
|
86
|
+
thinkingEl.hidden = !chip;
|
|
87
|
+
if (!chip) return thinkingNewControl.close();
|
|
88
|
+
thinkingEl.textContent = chip.text;
|
|
89
|
+
thinkingEl.title = chip.title;
|
|
90
|
+
thinkingEl.classList.toggle("own", chip.own);
|
|
91
|
+
}
|
|
92
|
+
// Hidden without the model's values (the list not in, an older server).
|
|
93
|
+
function renderNewChatLlmContext() {
|
|
94
|
+
const chip = llmContextChipModel(withChoice(newChatLlmSummary(), newChatLlmContext), { kind: NEW_SESSION });
|
|
95
|
+
llmContextEl.hidden = !chip;
|
|
96
|
+
if (!chip) return llmContextNewControl.close();
|
|
97
|
+
llmContextEl.textContent = chip.text;
|
|
98
|
+
llmContextEl.title = chip.title;
|
|
99
|
+
llmContextEl.classList.toggle("own", chip.own);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const modelPicker = createModelPicker({
|
|
103
|
+
button: pickEl,
|
|
104
|
+
panel: panelEl,
|
|
105
|
+
input: searchEl,
|
|
106
|
+
list: listEl,
|
|
107
|
+
composer: composerEl,
|
|
108
|
+
center: centerEl,
|
|
109
|
+
remember: () => !selected(),
|
|
110
|
+
onPick: (name) => {
|
|
111
|
+
if (selected()) pickForLink(name);
|
|
112
|
+
else {
|
|
113
|
+
renderNewChatLlmContext();
|
|
114
|
+
renderNewChatThinking();
|
|
115
|
+
}
|
|
116
|
+
focusPrompt();
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
// A pick on a fresh link: /model in its worker (its command bubble and the
|
|
120
|
+
// info bar follow from command_ran); refused, the button goes back to the
|
|
121
|
+
// link's model, if the page is still on that link.
|
|
122
|
+
function pickForLink(name) {
|
|
123
|
+
const id = selected();
|
|
124
|
+
const ran = run(`/model ${name}`).then((ok) => {
|
|
125
|
+
const ranEarly = pendingPick === ran && pickRanEarly;
|
|
126
|
+
if (pendingPick === ran) {
|
|
127
|
+
pendingPick = null;
|
|
128
|
+
pendingPickId = null;
|
|
129
|
+
pickRanEarly = false;
|
|
130
|
+
}
|
|
131
|
+
if ((!ok || ranEarly) && selected() === id) syncModelPick();
|
|
132
|
+
return ok;
|
|
133
|
+
});
|
|
134
|
+
pendingPick = ran;
|
|
135
|
+
pendingPickId = id;
|
|
136
|
+
pickRanEarly = false;
|
|
137
|
+
}
|
|
138
|
+
// The button's name, tooltip and label for what the page shows: a fresh
|
|
139
|
+
// link's model, else the start page's (whose choice stays as it is).
|
|
140
|
+
// Toggles the fresh-link class (the CSS shows the button in a session
|
|
141
|
+
// only with it); the panel closes when the link changes or stops being fresh.
|
|
142
|
+
function syncModelPick() {
|
|
143
|
+
const open = selected();
|
|
144
|
+
const current = state();
|
|
145
|
+
// The list walk only for a link that is fresh otherwise (the info bar
|
|
146
|
+
// calls this every second while a turn runs).
|
|
147
|
+
const fresh = !!open && freshLink(current, { running: running() })
|
|
148
|
+
&& !chainNeighbours(sessions(), open, { continues: current.continues }).next;
|
|
149
|
+
doc.body.classList.toggle("fresh-link", fresh);
|
|
150
|
+
const linkId = fresh ? open : null;
|
|
151
|
+
// Another session (or the start page), or the link stopped being fresh:
|
|
152
|
+
// a panel left open would come back open where it no longer belongs.
|
|
153
|
+
if (open !== pickSelected || linkId !== pickLinkId) modelPicker.close();
|
|
154
|
+
pickSelected = open;
|
|
155
|
+
pickLinkId = linkId;
|
|
156
|
+
if (fresh) {
|
|
157
|
+
// A pick in flight keeps its name until its /model is taken (its
|
|
158
|
+
// command_ran syncs again then).
|
|
159
|
+
if (!(pendingPick && pendingPickId === open)) modelPicker.show(current.model);
|
|
160
|
+
pickEl.title = "The model this link starts on";
|
|
161
|
+
pickEl.setAttribute("aria-label", "Model this link starts on");
|
|
162
|
+
} else {
|
|
163
|
+
pickEl.title = startPickTitle;
|
|
164
|
+
pickEl.setAttribute("aria-label", "Model for the new session");
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
// A command_ran with a model (stream_handlers.js): while a pick is in
|
|
168
|
+
// flight its ack syncs again.
|
|
169
|
+
function commandRan() {
|
|
170
|
+
if (pendingPick) pickRanEarly = true;
|
|
171
|
+
syncModelPick();
|
|
172
|
+
}
|
|
173
|
+
// Leaving a session: the start page's remembered model again (no fetch).
|
|
174
|
+
function restoreStartPick() {
|
|
175
|
+
modelPicker.restore(readStored(MODEL_KEY));
|
|
176
|
+
renderNewChatLlmContext();
|
|
177
|
+
renderNewChatThinking();
|
|
178
|
+
}
|
|
179
|
+
win.addEventListener("beforeunload", () => { pageLeaving = true; });
|
|
180
|
+
win.addEventListener("pagehide", () => { pageLeaving = true; });
|
|
181
|
+
win.addEventListener("pageshow", () => { pageLeaving = false; }); // back from the bfcache
|
|
182
|
+
// +asked+: + new session's load, for the start page it opens.
|
|
183
|
+
async function loadModels({ asked = false } = {}) {
|
|
184
|
+
let payload;
|
|
185
|
+
try {
|
|
186
|
+
payload = await listModels();
|
|
187
|
+
} catch (e) {
|
|
188
|
+
const text = modelsFailedText(e, { startPage: !selected(), asked, leaving: pageLeaving });
|
|
189
|
+
if (text) toast(text);
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
const stored = readStored(MODEL_KEY);
|
|
193
|
+
if (!modelPicker.setModels(payload, stored)) return;
|
|
194
|
+
startPickTitle = payload.warning ? `${START_PICK_TITLE} · ${payload.warning}` : START_PICK_TITLE;
|
|
195
|
+
pickEl.hidden = false;
|
|
196
|
+
// A fresh link open meanwhile shows its own model again, not the stored one.
|
|
197
|
+
syncModelPick();
|
|
198
|
+
renderNewChatLlmContext();
|
|
199
|
+
renderNewChatThinking();
|
|
200
|
+
}
|
|
201
|
+
// The picker's choice for a create request; nothing (the server's default)
|
|
202
|
+
// while the list is not in.
|
|
203
|
+
function chosenModel() {
|
|
204
|
+
return pickEl.hidden ? undefined : modelPicker.chosen() || undefined;
|
|
205
|
+
}
|
|
206
|
+
// The create request's own choices (the chips').
|
|
207
|
+
function newChoices() {
|
|
208
|
+
return { llm_context: choiceWords(newChatLlmContext), thinking: newChatThinking || undefined };
|
|
209
|
+
}
|
|
210
|
+
// The choices were for that new session only: a create took them.
|
|
211
|
+
function clearChoices() {
|
|
212
|
+
newChatLlmContext = null;
|
|
213
|
+
renderNewChatLlmContext();
|
|
214
|
+
newChatThinking = null;
|
|
215
|
+
renderNewChatThinking();
|
|
216
|
+
}
|
|
217
|
+
// A session opened: the start page's choices were for a new session, they go.
|
|
218
|
+
function dropChoices() {
|
|
219
|
+
llmContextNewControl.close();
|
|
220
|
+
if (newChatLlmContext) {
|
|
221
|
+
newChatLlmContext = null;
|
|
222
|
+
renderNewChatLlmContext();
|
|
223
|
+
}
|
|
224
|
+
thinkingNewControl.close();
|
|
225
|
+
if (newChatThinking) {
|
|
226
|
+
newChatThinking = null;
|
|
227
|
+
renderNewChatThinking();
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return {
|
|
232
|
+
syncModelPick, commandRan, restoreStartPick, loadModels, chosenModel, newChoices, clearChoices, dropChoices,
|
|
233
|
+
// A fresh link's /model still in flight (a send waits for it), or null.
|
|
234
|
+
pendingPick: () => pendingPick,
|
|
235
|
+
};
|
|
236
|
+
}
|