samagotchi 0.51.0 → 0.52.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 +46 -1
- data/README.md +1 -1
- data/bin/chi +11 -3
- data/docs/architecture.md +1 -1
- data/docs/cli.md +23 -8
- data/docs/configuration.md +59 -19
- data/docs/memory.md +11 -0
- data/docs/plugins.md +2 -1
- data/docs/sessions.md +5 -1
- data/docs/testing.md +6 -1
- data/lib/samagotchi/bootstrap/config_writer.rb +6 -37
- data/lib/samagotchi/bridge/ring_buffer.rb +0 -5
- data/lib/samagotchi/bridge.rb +10 -13
- data/lib/samagotchi/broadcast/recipients.rb +1 -2
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/children_status.rb +1 -1
- data/lib/samagotchi/commands/registry.rb +2 -1
- data/lib/samagotchi/config.rb +50 -182
- data/lib/samagotchi/config_text_edit.rb +8 -32
- data/lib/samagotchi/context_sources.rb +0 -7
- data/lib/samagotchi/context_window.rb +2 -2
- data/lib/samagotchi/continue_offer.rb +5 -4
- data/lib/samagotchi/cut_policy.rb +0 -1
- data/lib/samagotchi/delivery.rb +23 -0
- data/lib/samagotchi/engine.rb +48 -17
- data/lib/samagotchi/engine_peers.rb +1 -0
- data/lib/samagotchi/host_config.rb +183 -0
- data/lib/samagotchi/host_registry.rb +5 -10
- data/lib/samagotchi/last_turn.rb +48 -0
- data/lib/samagotchi/live_versions.rb +0 -6
- data/lib/samagotchi/llm/native_tool_normalizer.rb +0 -5
- data/lib/samagotchi/llm_context_override.rb +3 -22
- data/lib/samagotchi/llm_context_strategy.rb +50 -16
- data/lib/samagotchi/memory_bundle/placeholder.rb +0 -5
- data/lib/samagotchi/model_profile.rb +4 -4
- data/lib/samagotchi/model_settings.rb +46 -0
- data/lib/samagotchi/plugin/context.rb +3 -3
- data/lib/samagotchi/plugin/sessions.rb +2 -2
- data/lib/samagotchi/queued_command.rb +35 -0
- data/lib/samagotchi/reminder_store.rb +0 -27
- data/lib/samagotchi/reply_wait.rb +11 -13
- data/lib/samagotchi/sampling_settings.rb +4 -4
- data/lib/samagotchi/self_report.rb +8 -5
- data/lib/samagotchi/send_command.rb +37 -19
- data/lib/samagotchi/session.rb +45 -24
- data/lib/samagotchi/session_commands.rb +36 -7
- data/lib/samagotchi/session_inbox.rb +18 -9
- data/lib/samagotchi/session_manager.rb +8 -7
- data/lib/samagotchi/session_setup.rb +30 -0
- data/lib/samagotchi/steer_cut.rb +9 -2
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +10 -4
- data/lib/samagotchi/terminal_ui/attached_loop.rb +1 -1
- data/lib/samagotchi/terminal_ui/formatting.rb +21 -3
- data/lib/samagotchi/terminal_ui.rb +8 -3
- data/lib/samagotchi/thinking.rb +67 -7
- data/lib/samagotchi/thinking_command.rb +72 -0
- data/lib/samagotchi/tools/builtins.rb +7 -2
- data/lib/samagotchi/tools/peers.rb +3 -2
- data/lib/samagotchi/tools/task_runtime.rb +1 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/vision_support.rb +4 -3
- data/lib/samagotchi/web/app.rb +54 -6
- data/lib/samagotchi/web/public/app.js +133 -20
- data/lib/samagotchi/web/public/data.js +8 -4
- data/lib/samagotchi/web/public/format.js +8 -4
- data/lib/samagotchi/web/public/index.html +8 -5
- data/lib/samagotchi/web/public/kept_commands.js +52 -0
- data/lib/samagotchi/web/public/model_pick.js +3 -1
- data/lib/samagotchi/web/public/thinking_chip.js +169 -0
- data/lib/samagotchi/web/public/timing.js +15 -0
- data/lib/samagotchi/web/public/turn_events.js +23 -0
- data/lib/samagotchi/web/public/turn_view.js +328 -310
- data/lib/samagotchi/web/public/vendor/lit-html.js +8 -0
- data/lib/samagotchi/web/session_summary.rb +1 -1
- data/lib/samagotchi/worker.rb +43 -42
- data/lib/samagotchi/worker_inbound.rb +5 -5
- data/lib/samagotchi/worker_wakes.rb +0 -2
- data/lib/samagotchi/yaml_lines.rb +72 -0
- metadata +11 -1
data/lib/samagotchi/web/app.rb
CHANGED
|
@@ -10,6 +10,7 @@ require "rack"
|
|
|
10
10
|
require "rack/request"
|
|
11
11
|
|
|
12
12
|
require_relative "../client_id"
|
|
13
|
+
require_relative "../delivery"
|
|
13
14
|
require_relative "../answer_tail"
|
|
14
15
|
require_relative "../bridge_client"
|
|
15
16
|
require_relative "../bridge/bounded_queue"
|
|
@@ -23,6 +24,7 @@ require_relative "../host_registry"
|
|
|
23
24
|
require_relative "../llm_context_override"
|
|
24
25
|
require_relative "../model_catalog"
|
|
25
26
|
require_relative "../model_profile"
|
|
27
|
+
require_relative "../thinking"
|
|
26
28
|
require_relative "../sampling_settings"
|
|
27
29
|
require_relative "../served_model"
|
|
28
30
|
require_relative "../project_scope"
|
|
@@ -476,6 +478,7 @@ module Samagotchi
|
|
|
476
478
|
summary = SamplingSettings.summary(target, names: names, models: settings)
|
|
477
479
|
model[:sampling] = summary if summary
|
|
478
480
|
add_llm_context(model, target, names, settings)
|
|
481
|
+
add_thinking(model, target, names, settings)
|
|
479
482
|
end
|
|
480
483
|
rescue StandardError
|
|
481
484
|
nil
|
|
@@ -489,6 +492,14 @@ module Samagotchi
|
|
|
489
492
|
nil
|
|
490
493
|
end
|
|
491
494
|
|
|
495
|
+
# The thinking level +model+ starts at, with where it came from (the
|
|
496
|
+
# start page's think chip; Thinking::Explained#summary, no session).
|
|
497
|
+
def add_thinking(model, target, names, settings)
|
|
498
|
+
model[:thinking] = Thinking.explain(target, names: names, models: settings).summary
|
|
499
|
+
rescue StandardError
|
|
500
|
+
nil
|
|
501
|
+
end
|
|
502
|
+
|
|
492
503
|
# The served model a session's last generation reported (analytics.json,
|
|
493
504
|
# SessionMetrics#persist), for a session no worker runs: shown only
|
|
494
505
|
# while the name it answered is still the session's model, as
|
|
@@ -525,6 +536,19 @@ module Samagotchi
|
|
|
525
536
|
LLMContextStrategy.for_session(session, registry: host_registry)
|
|
526
537
|
end
|
|
527
538
|
|
|
539
|
+
# A session's thinking level with where it came from and its own
|
|
540
|
+
# (Thinking::Explained#summary), as its worker would resolve it; nil
|
|
541
|
+
# when it can't be. Config and the registry only: no network.
|
|
542
|
+
def thinking_for(session)
|
|
543
|
+
registry = host_registry
|
|
544
|
+
name = session.model_name.to_s.strip.empty? ? ModelProfile.required_model_name(nil) : session.model_name
|
|
545
|
+
target = registry.resolve(name)
|
|
546
|
+
names = registry.lookup_names(session.model_typed || name, resolved: name, target: target)
|
|
547
|
+
Thinking.explain(target, names: names, session: session.thinking).summary
|
|
548
|
+
rescue StandardError
|
|
549
|
+
nil
|
|
550
|
+
end
|
|
551
|
+
|
|
528
552
|
# Built again when config.yml's hosts: changed (compared as read: the
|
|
529
553
|
# YAML read is mtime-cached), so the model picker lists a host added
|
|
530
554
|
# while chi web runs. An injected registry (specs) stays.
|
|
@@ -607,6 +631,11 @@ module Samagotchi
|
|
|
607
631
|
llm_context, error = request_llm_context(body["llm_context"])
|
|
608
632
|
return error if error
|
|
609
633
|
|
|
634
|
+
# thinking: the session's own thinking level (the start page's think
|
|
635
|
+
# chip; chi --thinking), checked the same way.
|
|
636
|
+
thinking, error = request_thinking(body["thinking"])
|
|
637
|
+
return error if error
|
|
638
|
+
|
|
610
639
|
folder = dir ? { working_directory: dir } : {}
|
|
611
640
|
# model: the model the session starts on (the start page's picker,
|
|
612
641
|
# spelled as GET /api/models lists it); blank means the default.
|
|
@@ -617,7 +646,8 @@ module Samagotchi
|
|
|
617
646
|
# until that turn is saved.
|
|
618
647
|
preview = idle ? body["preview"].to_s.strip : ""
|
|
619
648
|
folder[:title] = preview unless preview.empty?
|
|
620
|
-
|
|
649
|
+
setup = SessionSetup.new(llm_context: llm_context, thinking: thinking)
|
|
650
|
+
folder[:setup] = setup unless setup.empty?
|
|
621
651
|
begin
|
|
622
652
|
session = @manager.spawn_session(prompt: idle ? nil : prompt.to_s, state_dir: @state_dir, **folder)
|
|
623
653
|
rescue ModelProfile::MissingModel => e
|
|
@@ -635,8 +665,8 @@ module Samagotchi
|
|
|
635
665
|
|
|
636
666
|
# POST /api/sessions with continues: <id, prefix or last:id>: the next
|
|
637
667
|
# link of that session's chain (SessionManager.continue_session), in its
|
|
638
|
-
# folder, on its model and with its llm_context
|
|
639
|
-
# llm_context are refused beside it. 409 continued (with next_id, the
|
|
668
|
+
# folder, on its model and with its llm_context and thinking level, so
|
|
669
|
+
# dir, model, llm_context and thinking are refused beside it. 409 continued (with next_id, the
|
|
640
670
|
# link that continues it already: the page opens that one),
|
|
641
671
|
# open_children (ids), folder_gone, busy, scratch or owned_by_tui.
|
|
642
672
|
def handle_continue(body, prompt, idle)
|
|
@@ -644,9 +674,9 @@ module Samagotchi
|
|
|
644
674
|
unless given.is_a?(String) && !given.strip.empty?
|
|
645
675
|
return error_response(400, "invalid_continues", "continues is a session id")
|
|
646
676
|
end
|
|
647
|
-
if (fields = %w[dir model model_name llm_context].select { |key| body.key?(key) }).any?
|
|
677
|
+
if (fields = %w[dir model model_name llm_context thinking].select { |key| body.key?(key) }).any?
|
|
648
678
|
return error_response(400, "invalid_continues",
|
|
649
|
-
"continues takes the previous session's folder, model and
|
|
679
|
+
"continues takes the previous session's folder, model, llm_context and thinking; " \
|
|
650
680
|
"leave out #{fields.join(", ")}")
|
|
651
681
|
end
|
|
652
682
|
|
|
@@ -701,6 +731,20 @@ module Samagotchi
|
|
|
701
731
|
[nil, error_response(400, "invalid_llm_context", e.message)]
|
|
702
732
|
end
|
|
703
733
|
|
|
734
|
+
# The create request's "thinking" (a level word as /thinking takes it,
|
|
735
|
+
# "default" for none) as the session's own level: [level, nil] (nil
|
|
736
|
+
# when absent or default), or [nil, a 400 response] for anything else.
|
|
737
|
+
def request_thinking(raw)
|
|
738
|
+
return [nil, nil] if raw.nil?
|
|
739
|
+
|
|
740
|
+
word = raw.is_a?(String) ? raw.strip.downcase : nil
|
|
741
|
+
unless word && Thinking::LEVELS.map(&:to_s).include?(word)
|
|
742
|
+
return [nil, error_response(400, "invalid_thinking", "thinking is one of #{Thinking::LEVELS.join(", ")}")]
|
|
743
|
+
end
|
|
744
|
+
|
|
745
|
+
[Thinking.session_level(word), nil]
|
|
746
|
+
end
|
|
747
|
+
|
|
704
748
|
def handle_show(req, id)
|
|
705
749
|
# The page's light re-reads: never the whole conversation.
|
|
706
750
|
return handle_cards_read(id) if req.params["cards"] == "1"
|
|
@@ -750,6 +794,8 @@ module Samagotchi
|
|
|
750
794
|
# The LLM context strategy the next turn runs under (the info bar's
|
|
751
795
|
# chip): the worker's, else worked out here from the session file.
|
|
752
796
|
session_json = session_json.merge(llm_context: (snapshot && snapshot["llm_context"]) || llm_context_for(session))
|
|
797
|
+
# The thinking level the next turn runs at (the think chip), the same way.
|
|
798
|
+
session_json = session_json.merge(thinking: (snapshot && snapshot["thinking"]) || thinking_for(session))
|
|
753
799
|
raw_messages = turn_snapshot ? turn_snapshot["messages"] : session.messages
|
|
754
800
|
timing = timing_payload(id, live_metrics: snapshot && snapshot["metrics"], persisted: persisted)
|
|
755
801
|
json_response(200, {
|
|
@@ -1066,7 +1112,9 @@ module Samagotchi
|
|
|
1066
1112
|
return error_response(400, "bad_images", images) if images.is_a?(String)
|
|
1067
1113
|
|
|
1068
1114
|
delivery = body["delivery"] || body[:delivery]
|
|
1069
|
-
|
|
1115
|
+
if (refused = Delivery.refusal(delivery))
|
|
1116
|
+
return error_response(400, refused[:error], refused[:detail])
|
|
1117
|
+
end
|
|
1070
1118
|
|
|
1071
1119
|
result = SessionManager.deliver_turn(id, prompt: prompt.to_s, client_id: client_id, images: images,
|
|
1072
1120
|
delivery: delivery, state_dir: @state_dir, manager: @manager,
|
|
@@ -26,14 +26,18 @@ import {
|
|
|
26
26
|
chipHtml as llmContextChipHtml, chipModel as llmContextChipModel, choiceWords, createLlmContextControl, NEW_CHAT,
|
|
27
27
|
withChoice,
|
|
28
28
|
} from "./llm_context_chip.js";
|
|
29
|
+
import {
|
|
30
|
+
chipHtml as thinkingChipHtml, chipModel as thinkingChipModel, createThinkingControl,
|
|
31
|
+
withChoice as withThinkingChoice,
|
|
32
|
+
} from "./thinking_chip.js";
|
|
29
33
|
import { renderStable } from "./stable_html.js";
|
|
30
34
|
import { createShownTexts } from "./shown_texts.js";
|
|
31
35
|
import { MODEL_KEY } from "./model_pick.js";
|
|
32
36
|
import { emptySessionState } from "./session_state.js";
|
|
33
37
|
import { createModelPicker } from "./model_picker.js";
|
|
34
38
|
import { chipName, chipsHtml, imageFiles, restoredChips, thumbsHtml, turnRefs, turnText } from "./images.js";
|
|
35
|
-
import { escapeHtml, memChipText, memChipTitle, noteHtml, normalize, steerRowHtml, ownerBadge, previewForCard, relativeTime, failedTurnText, modelLabel, deleteConfirmText, recapLabel, canStopSession, goneSessionNotice, withLiveStatus, withoutWorker, recapPlace, delegatedBy, childrenSummary, promptNotesChip } from "./format.js";
|
|
36
|
-
import { createTurnView,
|
|
39
|
+
import { escapeHtml, memChipText, memChipTitle, noteHtml, normalize, steerRowHtml, ownerBadge, previewForCard, firstPreview, relativeTime, failedTurnText, modelLabel, deleteConfirmText, recapLabel, canStopSession, goneSessionNotice, withLiveStatus, withoutWorker, recapPlace, delegatedBy, childrenSummary, promptNotesChip } from "./format.js";
|
|
40
|
+
import { createTurnView, turnHistoryNodes } from "./turn_view.js";
|
|
37
41
|
import { BADGES, emptyAnswerHtml, htmlElement, stateBadgeHtml, userBubbleHtml } from "./turn_html.js";
|
|
38
42
|
import { createStageView } from "./stage_view.js";
|
|
39
43
|
import { createFamilyPop, familyChipHtml, familyRowsHtml } from "./families_view.js";
|
|
@@ -58,12 +62,13 @@ import { applySessionEvent, batchCounts, batchTargets, batchToastText, families,
|
|
|
58
62
|
import { createNotifications } from "./notifications.js";
|
|
59
63
|
import { tabTitle, topBarTitle } from "./session_title.js";
|
|
60
64
|
import { restartConfirmText, versionNotice, workerBadge } from "./update_notice.js";
|
|
65
|
+
import { anchorInsertIndex, commandAnchor, isShellCommandLine, keepsCommandBubble } from "./kept_commands.js";
|
|
61
66
|
import {
|
|
62
|
-
appendAboveLiveTiming, cancelLineText,
|
|
67
|
+
appendAboveLiveTiming, cancelLineText, formatDuration, mergeTiming, normalizeTiming, sessionDurationNow, STOPPED_LINE_CLASS,
|
|
63
68
|
} from "./timing.js";
|
|
64
69
|
import { createLiveTurn } from "./live_turn.js";
|
|
65
70
|
import { DONE_MS, applyInitEvent, dropDone, initRowHtml, newInitTasks, seedInitTasks } from "./init_row.js";
|
|
66
|
-
import { clientLabel, commandView, isReportClient, displayWait, dropEarlyRestores, emptyRetryLine, historyHasCommandBubble, hookNoticeLabel, initWaitLine, isOwn, llmContextHover, llmContextLine, sessionCommandLine, keepEarlyRestore, newClientId, noticeLine, promptOps, reminderText, restoreAction, composerAfterAck, restoreOnAck, replaysDrawnTurn, retryStatusLine, snapshotEvents, steerCutLine, startPageReplyOrHint, turnOutput, unknownCommandHint, webLocalReply, workerGoneText } from "./turn_events.js";
|
|
71
|
+
import { clientLabel, commandView, isReportClient, displayWait, dropEarlyRestores, emptyRetryLine, historyHasCommandBubble, hookNoticeLabel, initWaitLine, isOwn, llmContextHover, llmContextLine, sessionCommandLine, keepEarlyRestore, newClientId, noticeLine, promptOps, reminderText, restoreAction, composerAfterAck, restoreOnAck, replaysDrawnTurn, retryStatusLine, snapshotEvents, steerChoiceRoute, steerCutLine, startPageReplyOrHint, turnOutput, unknownCommandHint, webLocalReply, workerGoneText } from "./turn_events.js";
|
|
67
72
|
|
|
68
73
|
const $ = (s) => document.querySelector(s);
|
|
69
74
|
const topStripEl = $("#topStrip");
|
|
@@ -117,6 +122,43 @@ const llmContextNewControl = createLlmContextControl({
|
|
|
117
122
|
id: "llmContextNewPopover",
|
|
118
123
|
anchor: "#llmContextNew",
|
|
119
124
|
});
|
|
125
|
+
// The open session's thinking chip, beside the llm ctx one: /thinking runs
|
|
126
|
+
// in the session's worker.
|
|
127
|
+
const thinkingControl = createThinkingControl({
|
|
128
|
+
el: infoTextEl,
|
|
129
|
+
escapeHtml,
|
|
130
|
+
summary: () => (selected ? current.thinking : null),
|
|
131
|
+
session: () => selected,
|
|
132
|
+
running: () => liveTurn.running,
|
|
133
|
+
run: (line) => runSessionCommand(line),
|
|
134
|
+
});
|
|
135
|
+
// The start page's thinking chip: the picked model's level, and a level
|
|
136
|
+
// for the next new chat only (sent with its create, then dropped).
|
|
137
|
+
const thinkingNewEl = $("#thinkingNew");
|
|
138
|
+
let newChatThinking = null;
|
|
139
|
+
const newChatThinkingSummary = () => (modelPickEl.hidden ? null : modelPicker.chosenRow()?.thinking || null);
|
|
140
|
+
const thinkingNewControl = createThinkingControl({
|
|
141
|
+
el: thinkingNewEl,
|
|
142
|
+
escapeHtml,
|
|
143
|
+
summary: newChatThinkingSummary,
|
|
144
|
+
kind: NEW_CHAT,
|
|
145
|
+
choice: () => newChatThinking,
|
|
146
|
+
choose: (next) => {
|
|
147
|
+
newChatThinking = next;
|
|
148
|
+
renderNewChatThinking();
|
|
149
|
+
promptEl.focus();
|
|
150
|
+
},
|
|
151
|
+
id: "thinkingNewPopover",
|
|
152
|
+
anchor: "#thinkingNew",
|
|
153
|
+
});
|
|
154
|
+
function renderNewChatThinking() {
|
|
155
|
+
const chip = thinkingChipModel(withThinkingChoice(newChatThinkingSummary(), newChatThinking), { kind: NEW_CHAT });
|
|
156
|
+
thinkingNewEl.hidden = !chip;
|
|
157
|
+
if (!chip) return thinkingNewControl.close();
|
|
158
|
+
thinkingNewEl.textContent = chip.text;
|
|
159
|
+
thinkingNewEl.title = chip.title;
|
|
160
|
+
thinkingNewEl.classList.toggle("own", chip.own);
|
|
161
|
+
}
|
|
120
162
|
// Hidden without the model's values (the list not in, an older server).
|
|
121
163
|
function renderNewChatLlmContext() {
|
|
122
164
|
const chip = llmContextChipModel(withChoice(newChatLlmSummary(), newChatLlmContext), { kind: NEW_CHAT });
|
|
@@ -1057,6 +1099,11 @@ function updateComposerMode() {
|
|
|
1057
1099
|
newChatLlmContext = null;
|
|
1058
1100
|
renderNewChatLlmContext();
|
|
1059
1101
|
}
|
|
1102
|
+
thinkingNewControl.close();
|
|
1103
|
+
if (newChatThinking) {
|
|
1104
|
+
newChatThinking = null;
|
|
1105
|
+
renderNewChatThinking();
|
|
1106
|
+
}
|
|
1060
1107
|
}
|
|
1061
1108
|
const running = liveTurn.running && !!selected;
|
|
1062
1109
|
cancelBtn.style.display = running ? "" : "none";
|
|
@@ -1233,8 +1280,9 @@ async function resync({ endText = null } = {}) {
|
|
|
1233
1280
|
return;
|
|
1234
1281
|
}
|
|
1235
1282
|
if (id !== selected) return;
|
|
1283
|
+
const kept = keptCommandBubbles(id);
|
|
1236
1284
|
resetTurnView();
|
|
1237
|
-
renderSessionView(data);
|
|
1285
|
+
renderSessionView(data, { kept });
|
|
1238
1286
|
// Preview is read-only: a worker is woken only by POST /turn, so attach
|
|
1239
1287
|
// only when a live bridge exists.
|
|
1240
1288
|
selectSeq = typeof data.last_event_seq === "number" ? (data.last_event_id || data.last_event_seq) : null;
|
|
@@ -1249,10 +1297,45 @@ async function resync({ endText = null } = {}) {
|
|
|
1249
1297
|
}
|
|
1250
1298
|
}
|
|
1251
1299
|
|
|
1300
|
+
// The prompt and command bubbles in the history, in order, by kind
|
|
1301
|
+
// (kept_commands.js): a prompt, a `!cmd`'s (saved, so redrawn) or another.
|
|
1302
|
+
function historyAnchors() {
|
|
1303
|
+
const els = [...historyEl.querySelectorAll(".bubble.user, .bubble.command")];
|
|
1304
|
+
const kinds = els.map((el) => {
|
|
1305
|
+
if (el.classList.contains("user")) return "user";
|
|
1306
|
+
const line = el.querySelector(":scope > .command-line")?.textContent;
|
|
1307
|
+
return !el.classList.contains("failed") && isShellCommandLine(line) ? "shell" : "other";
|
|
1308
|
+
});
|
|
1309
|
+
return { els, kinds };
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
// This session's own command bubbles (/help, /model …: drawn by this page,
|
|
1313
|
+
// never saved) with their anchors, before a resync redraws the history.
|
|
1314
|
+
// In-flight ones (busy, queued) are left out: the snapshot draws them again.
|
|
1315
|
+
function keptCommandBubbles(id) {
|
|
1316
|
+
const { els, kinds } = historyAnchors();
|
|
1317
|
+
return els
|
|
1318
|
+
.map((el, i) => ({ el, anchor: commandAnchor(kinds, i) }))
|
|
1319
|
+
.filter(({ el }) => el.dataset.keptFor === id && !el.classList.contains("busy") && !el.classList.contains("queued"))
|
|
1320
|
+
.filter(({ el }) => keepsCommandBubble(el.querySelector(":scope > .command-line")?.textContent));
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
// The kept bubbles back into the redrawn history, each after its turn.
|
|
1324
|
+
function restoreCommandBubbles(kept) {
|
|
1325
|
+
if (!kept.length) return;
|
|
1326
|
+
historyEl.querySelector(".hint")?.remove();
|
|
1327
|
+
kept.forEach(({ el, anchor }) => {
|
|
1328
|
+
const { els, kinds } = historyAnchors();
|
|
1329
|
+
const at = anchorInsertIndex(kinds, anchor);
|
|
1330
|
+
if (at < els.length) els[at].before(el);
|
|
1331
|
+
else historyEl.appendChild(el);
|
|
1332
|
+
});
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1252
1335
|
// A session view: its messages, then the turn in progress and the queued
|
|
1253
1336
|
// prompts (a live worker's snapshot) replayed through the stream handlers,
|
|
1254
1337
|
// then a pending question.
|
|
1255
|
-
function renderSessionView(data) {
|
|
1338
|
+
function renderSessionView(data, { kept = [] } = {}) {
|
|
1256
1339
|
const s = data.session;
|
|
1257
1340
|
setMarkdownWarning(data.markdown_warning);
|
|
1258
1341
|
setCapabilities(data.capabilities);
|
|
@@ -1279,20 +1362,23 @@ function renderSessionView(data) {
|
|
|
1279
1362
|
current.promptNotes = Array.isArray(s.prompt_notes) ? s.prompt_notes : [];
|
|
1280
1363
|
current.llmContext = s.llm_context || null;
|
|
1281
1364
|
llmContextControl.refresh();
|
|
1365
|
+
current.thinking = s.thinking || null;
|
|
1366
|
+
thinkingControl.refresh();
|
|
1282
1367
|
current.parentId = s.parent_id || null;
|
|
1283
1368
|
current.continues = s.continues || null;
|
|
1284
1369
|
current.commands = Array.isArray(data.commands) ? data.commands : [];
|
|
1285
1370
|
if (data.messages && data.messages.length) {
|
|
1286
1371
|
const first = data.messages.find((m) => m.role === "user");
|
|
1287
|
-
if (first && first.content) current.firstPreview =
|
|
1372
|
+
if (first && first.content) current.firstPreview = firstPreview(first.content);
|
|
1288
1373
|
renderHistory(data.messages);
|
|
1289
1374
|
} else {
|
|
1290
1375
|
renderHistory(data.history || []);
|
|
1291
1376
|
if (!current.firstPreview && data.history && data.history.length) {
|
|
1292
|
-
const txt =
|
|
1293
|
-
if(txt) current.firstPreview = txt;
|
|
1377
|
+
const txt = firstPreview(data.history[0]);
|
|
1378
|
+
if (txt) current.firstPreview = txt;
|
|
1294
1379
|
}
|
|
1295
1380
|
}
|
|
1381
|
+
restoreCommandBubbles(kept);
|
|
1296
1382
|
// A failed turn's prompt went back to the user: shown after the
|
|
1297
1383
|
// conversation, marked failed, as it was live (prompt_restored).
|
|
1298
1384
|
if (data.failed_turn) {
|
|
@@ -1352,7 +1438,7 @@ function renderHistory(items) {
|
|
|
1352
1438
|
return;
|
|
1353
1439
|
}
|
|
1354
1440
|
if (typeof items[0] === "object" && items[0].role) {
|
|
1355
|
-
historyEl.
|
|
1441
|
+
historyEl.replaceChildren(turnHistoryNodes(items, current.timing, { thumbs: (images) => thumbsHtml(selected, images) }));
|
|
1356
1442
|
} else {
|
|
1357
1443
|
historyEl.innerHTML = items.map((c) => `<div class="bubble output">${escapeHtml(c)}</div>`).join("");
|
|
1358
1444
|
}
|
|
@@ -1965,7 +2051,7 @@ function updateInfoBar() {
|
|
|
1965
2051
|
const attachCmd = `chi --attach ${selected}`;
|
|
1966
2052
|
const copied = Date.now() - attachCopiedAt < 2000;
|
|
1967
2053
|
const copyHtml = `<button type="button" class="copy-attach" title="Copy "${escapeHtml(attachCmd)}" to attach a terminal">${copied ? "copied ✓" : "copy chi --attach"}</button>`;
|
|
1968
|
-
const metaHtml = `<div class="meta"><span class="id" title="${escapeHtml(selected)}">${escapeHtml(idShort)}</span>${copyHtml}<span class="${statusClass}">${escapeHtml(current.status||"")}</span><span class="model${label.mismatch ? " served-mismatch" : ""}" title="${escapeHtml(label.title)}">${escapeHtml(label.text)}</span>${chainHtml}${parentHtml}${kidsHtml}${llmContextChipHtml(current.llmContext, escapeHtml)}${notesHtml}${current.usedMemories.length ? `<span class="model mem-chip" title="${escapeHtml(memChipTitle(current.usedMemories))}">${escapeHtml(memChipText(current.usedMemories))}</span>` : ""}<span class="dir" title="${escapeHtml(current.dir||"")}"><bdi>${escapeHtml(current.dir||"")}</bdi></span>${ctxText ? `<span class="model ctx" title="${escapeHtml(ctxTip)}">${escapeHtml(ctxText)}</span>` : ""}${speedTextNow ? `<span class="model speed" title="${escapeHtml(speedTip)}">${escapeHtml(speedTextNow)}</span>` : ""}${timingText ? `<span class="model session-time"></span>` : ""}${workerBadgeHtml()}</div>`;
|
|
2054
|
+
const metaHtml = `<div class="meta"><span class="id" title="${escapeHtml(selected)}">${escapeHtml(idShort)}</span>${copyHtml}<span class="${statusClass}">${escapeHtml(current.status||"")}</span><span class="model${label.mismatch ? " served-mismatch" : ""}" title="${escapeHtml(label.title)}">${escapeHtml(label.text)}</span>${chainHtml}${parentHtml}${kidsHtml}${llmContextChipHtml(current.llmContext, escapeHtml)}${thinkingChipHtml(current.thinking, escapeHtml)}${notesHtml}${current.usedMemories.length ? `<span class="model mem-chip" title="${escapeHtml(memChipTitle(current.usedMemories))}">${escapeHtml(memChipText(current.usedMemories))}</span>` : ""}<span class="dir" title="${escapeHtml(current.dir||"")}"><bdi>${escapeHtml(current.dir||"")}</bdi></span>${ctxText ? `<span class="model ctx" title="${escapeHtml(ctxTip)}">${escapeHtml(ctxText)}</span>` : ""}${speedTextNow ? `<span class="model speed" title="${escapeHtml(speedTip)}">${escapeHtml(speedTextNow)}</span>` : ""}${timingText ? `<span class="model session-time"></span>` : ""}${workerBadgeHtml()}</div>`;
|
|
1969
2055
|
// The session clock ticks every second while a turn runs: only its text
|
|
1970
2056
|
// changes, the bar's other nodes (a tooltip, a button mid-press) stay.
|
|
1971
2057
|
renderStable(infoTextEl, metaHtml, { ".session-time": timingText });
|
|
@@ -2020,10 +2106,7 @@ function memoriesTooltip(used, preloaded, muted) {
|
|
|
2020
2106
|
}
|
|
2021
2107
|
|
|
2022
2108
|
function currentSessionDuration() {
|
|
2023
|
-
|
|
2024
|
-
return elapsedSince(current.timing.startedAt);
|
|
2025
|
-
}
|
|
2026
|
-
return current.timing.sessionDurationMs;
|
|
2109
|
+
return sessionDurationNow(current.timing, liveTurn.running);
|
|
2027
2110
|
}
|
|
2028
2111
|
|
|
2029
2112
|
// The stage takes a running turn's rows (its extras); else the history.
|
|
@@ -2281,6 +2364,12 @@ const streamHandlers = {
|
|
|
2281
2364
|
updateInfoBar();
|
|
2282
2365
|
llmContextControl.refresh();
|
|
2283
2366
|
}
|
|
2367
|
+
// /thinking (or /model) changed the level the next turn runs at.
|
|
2368
|
+
if (data.thinking) {
|
|
2369
|
+
current.thinking = data.thinking;
|
|
2370
|
+
updateInfoBar();
|
|
2371
|
+
thinkingControl.refresh();
|
|
2372
|
+
}
|
|
2284
2373
|
if (view.resync) await resync();
|
|
2285
2374
|
// A card's action that isn't anytime (the step-limit question's
|
|
2286
2375
|
// /continue) has no bubble: nothing to remove. An anytime or queued
|
|
@@ -2433,10 +2522,27 @@ function handleCreate() {
|
|
|
2433
2522
|
|
|
2434
2523
|
async function handleSendTurn() {
|
|
2435
2524
|
if (current.owner === "tui") return;
|
|
2436
|
-
|
|
2525
|
+
let prompt = turnText(promptEl.value, chips);
|
|
2437
2526
|
if (!prompt) return;
|
|
2438
2527
|
// An image-only send's text is its placeholders: not for ↑.
|
|
2439
2528
|
const typed = promptEl.value.trim() !== "";
|
|
2529
|
+
// /cut TEXT and /queue TEXT (the terminals' words for a delivery): the
|
|
2530
|
+
// text as a cut, or the page's own answer (/queue, the bare word).
|
|
2531
|
+
let delivery = null;
|
|
2532
|
+
const choice = steerChoiceRoute(prompt, { running: liveTurn.running && !!selected });
|
|
2533
|
+
if (choice?.reply) {
|
|
2534
|
+
if (selected) addCommandBubble({ label: null, line: prompt, text: choice.reply });
|
|
2535
|
+
else showNotice(choice.reply);
|
|
2536
|
+
promptEl.value = "";
|
|
2537
|
+
fitPrompt();
|
|
2538
|
+
return;
|
|
2539
|
+
}
|
|
2540
|
+
// The composer holds the line as typed until the ack (composerAfterAck).
|
|
2541
|
+
const line = prompt;
|
|
2542
|
+
if (choice) {
|
|
2543
|
+
prompt = choice.send;
|
|
2544
|
+
delivery = choice.delivery || null;
|
|
2545
|
+
}
|
|
2440
2546
|
if (!selected) {
|
|
2441
2547
|
// The start page's own reply (a web-local command) or the hint for a
|
|
2442
2548
|
// typo: neither needs a session, so none is made for it (2.29).
|
|
@@ -2450,10 +2556,13 @@ async function handleSendTurn() {
|
|
|
2450
2556
|
actionBtn.disabled = true;
|
|
2451
2557
|
try {
|
|
2452
2558
|
const s = await createIdleSession({ dir: scope.dir, model: chosenModel(), preview: prompt,
|
|
2453
|
-
llm_context: choiceWords(newChatLlmContext)
|
|
2559
|
+
llm_context: choiceWords(newChatLlmContext),
|
|
2560
|
+
thinking: newChatThinking || undefined });
|
|
2454
2561
|
// The choice was for this chat only.
|
|
2455
2562
|
newChatLlmContext = null;
|
|
2456
2563
|
renderNewChatLlmContext();
|
|
2564
|
+
newChatThinking = null;
|
|
2565
|
+
renderNewChatThinking();
|
|
2457
2566
|
addCreatedSession(s);
|
|
2458
2567
|
await select(s.id);
|
|
2459
2568
|
} catch (e) {
|
|
@@ -2485,7 +2594,7 @@ async function handleSendTurn() {
|
|
|
2485
2594
|
const echo = makeUserBubble(prompt, { state: "queued", own: true, images: sending });
|
|
2486
2595
|
if (!turnDom.holds?.()) historyEl.scrollTop = historyEl.scrollHeight;
|
|
2487
2596
|
if (!current.firstPreview || current.firstPreview==="—") {
|
|
2488
|
-
current.firstPreview =
|
|
2597
|
+
current.firstPreview = firstPreview(prompt);
|
|
2489
2598
|
updateInfoBar();
|
|
2490
2599
|
}
|
|
2491
2600
|
actionBtn.disabled = true;
|
|
@@ -2493,7 +2602,7 @@ async function handleSendTurn() {
|
|
|
2493
2602
|
for (const chip of sending) {
|
|
2494
2603
|
if (!chip.ref) chip.ref = await uploadImage(selected, chip.file, chip.name);
|
|
2495
2604
|
}
|
|
2496
|
-
const ack = await sendTurn(selected, prompt, { clientId, images: turnRefs(sending), history: typed });
|
|
2605
|
+
const ack = await sendTurn(selected, prompt, { clientId, images: turnRefs(sending), history: typed, delivery });
|
|
2497
2606
|
if (ack?.command_id) {
|
|
2498
2607
|
// The worker knew it as a command (its list was newer than ours):
|
|
2499
2608
|
// its command_ran shows it, not a prompt bubble.
|
|
@@ -2509,7 +2618,7 @@ async function handleSendTurn() {
|
|
|
2509
2618
|
// (and images) go back into the composer, without replacing text typed
|
|
2510
2619
|
// since (4.13). The composer still holds the text just sent: it goes.
|
|
2511
2620
|
const restored = restoreOnAck(ack?.enqueued_id, earlyRestores, { myId: clientId, sentIds });
|
|
2512
|
-
promptEl.value = composerAfterAck({ sent:
|
|
2621
|
+
promptEl.value = composerAfterAck({ sent: line, current: promptEl.value, refill: restored?.refill || "" });
|
|
2513
2622
|
fitPrompt();
|
|
2514
2623
|
clearChips();
|
|
2515
2624
|
if (restored?.images?.length) setChips(restoredChips(selected, restored.images));
|
|
@@ -2589,6 +2698,8 @@ async function runSessionCommand(line) {
|
|
|
2589
2698
|
function addCommandBubble(view) {
|
|
2590
2699
|
removeHintIfEmpty();
|
|
2591
2700
|
const div = document.createElement("div");
|
|
2701
|
+
// Page-only: kept across a resync of this session (keptCommandBubbles).
|
|
2702
|
+
if (selected) div.dataset.keptFor = selected;
|
|
2592
2703
|
drawCommandBubble(div, view);
|
|
2593
2704
|
const follow = isNearBottom();
|
|
2594
2705
|
appendToHistory(div);
|
|
@@ -2667,6 +2778,7 @@ const modelPicker = createModelPicker({
|
|
|
2667
2778
|
center: $("#center"),
|
|
2668
2779
|
onPick: () => {
|
|
2669
2780
|
renderNewChatLlmContext();
|
|
2781
|
+
renderNewChatThinking();
|
|
2670
2782
|
promptEl.focus();
|
|
2671
2783
|
},
|
|
2672
2784
|
});
|
|
@@ -2683,6 +2795,7 @@ async function loadModels() {
|
|
|
2683
2795
|
modelPickEl.title = payload.warning ? `The model the new chat starts on · ${payload.warning}` : "The model the new chat starts on";
|
|
2684
2796
|
modelPickEl.hidden = false;
|
|
2685
2797
|
renderNewChatLlmContext();
|
|
2798
|
+
renderNewChatThinking();
|
|
2686
2799
|
}
|
|
2687
2800
|
// The picker's choice for a create request; nothing (the server's default)
|
|
2688
2801
|
// while the list is not in.
|
|
@@ -35,10 +35,11 @@ export function listSessions(sort, order, { dir, ...opts } = {}) {
|
|
|
35
35
|
// it); either left out means the server's default. preview: that message,
|
|
36
36
|
// which names the session until its turn is saved. llm_context: the
|
|
37
37
|
// session's own LLM context words ({strategy, apply, budget}), when the
|
|
38
|
-
// start page's chip set some
|
|
39
|
-
|
|
38
|
+
// start page's chip set some; thinking: its own level ("low"), when the
|
|
39
|
+
// start page's think chip set one.
|
|
40
|
+
export function createIdleSession({ dir, model, preview, llm_context: llmContext, thinking, ...opts } = {}) {
|
|
40
41
|
const body = { idle: true, ...(dir ? { dir } : {}), ...(model ? { model } : {}), ...(preview ? { preview } : {}),
|
|
41
|
-
...(llmContext ? { llm_context: llmContext } : {}) };
|
|
42
|
+
...(llmContext ? { llm_context: llmContext } : {}), ...(thinking ? { thinking } : {}) };
|
|
42
43
|
return api("/api/sessions", { method: "POST", body: JSON.stringify(body), ...opts });
|
|
43
44
|
}
|
|
44
45
|
|
|
@@ -90,9 +91,12 @@ export function getSession(id, { tail, recent, turnId, timing, parts, cards, ...
|
|
|
90
91
|
// opts.images: refs ({file, name}) to images uploaded into the session.
|
|
91
92
|
// history: false keeps the prompt out of the shared prompt history (an
|
|
92
93
|
// image-only send, whose text is only its [image: …] placeholders).
|
|
93
|
-
|
|
94
|
+
// delivery: what the message does to a running turn ("cut": /cut TEXT);
|
|
95
|
+
// none = the server's default (the next step).
|
|
96
|
+
export function sendTurn(id, prompt, { clientId, images, history = true, delivery = null, ...opts } = {}) {
|
|
94
97
|
const body = clientId ? { prompt, client_id: clientId } : { prompt };
|
|
95
98
|
if (images?.length) body.images = images;
|
|
99
|
+
if (delivery) body.delivery = delivery;
|
|
96
100
|
if (history === false) body.history = false;
|
|
97
101
|
return api(`/api/sessions/${id}/turn`, {
|
|
98
102
|
method: "POST",
|
|
@@ -94,13 +94,17 @@ export function previewOf(lastPrompt) {
|
|
|
94
94
|
return normalized;
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
+
// A session's first message as the info bar's title: whitespace collapsed,
|
|
98
|
+
// cut at FIRST_PREVIEW_CHARS with an ellipsis ("" for none).
|
|
99
|
+
export function firstPreview(text) {
|
|
100
|
+
const n = normalize(text);
|
|
101
|
+
return n.length > FIRST_PREVIEW_CHARS ? `${n.slice(0, FIRST_PREVIEW_CHARS)}\u2026` : n;
|
|
102
|
+
}
|
|
103
|
+
|
|
97
104
|
export function previewForCard(session) {
|
|
98
105
|
// Unified card preview: first message (80) else previewOf(last_prompt)
|
|
99
106
|
const raw = session.first_preview || session.last_prompt || "";
|
|
100
|
-
|
|
101
|
-
if (!n) return "\u2014";
|
|
102
|
-
if (n.length > FIRST_PREVIEW_CHARS) return `${n.slice(0, FIRST_PREVIEW_CHARS)}\u2026`;
|
|
103
|
-
return n;
|
|
107
|
+
return firstPreview(raw) || "\u2014";
|
|
104
108
|
}
|
|
105
109
|
|
|
106
110
|
const DELETE_PREVIEW_CHARS = 40;
|
|
@@ -502,8 +502,10 @@ body.has-session #composer .actions-col{flex-wrap:nowrap;flex-shrink:0}
|
|
|
502
502
|
#composer .actions-col .llmctx-new:hover{filter:none;color:var(--text);border-color:var(--accent-border);background:var(--accent-bg)}
|
|
503
503
|
#composer .actions-col .llmctx-new.own{color:var(--accent);border-color:var(--accent-border)}
|
|
504
504
|
#composer .actions-col .llmctx-new[hidden],body.has-session #composer .actions-col .llmctx-new{display:none}
|
|
505
|
+
/* The new chat's thinking chip, after it: shorter text, so a smaller share of the row. */
|
|
506
|
+
#composer .actions-col .think-new{max-width:min(25%,140px)}
|
|
505
507
|
/* Phones: picker · chip · Start on one row (the key hint is gone there); names cut short rather than wrapping, the chip first. */
|
|
506
|
-
@media(max-width:600px){#composer .actions-col:has(.llmctx-new:not([hidden])){flex-wrap:nowrap}#composer .actions-col .model-pick{flex:0 1 auto;min-width:0}#composer .actions-col .llmctx-new{max-width:45%;flex-shrink:4}}
|
|
508
|
+
@media(max-width:600px){#composer .actions-col:has(.llmctx-new:not([hidden])){flex-wrap:nowrap}#composer .actions-col .model-pick{flex:0 1 auto;min-width:0}#composer .actions-col .llmctx-new{max-width:45%;flex-shrink:4}#composer .actions-col .think-new{max-width:30%}}
|
|
507
509
|
/* The model search: opaque like #commandList (a list over the conversation
|
|
508
510
|
must stay readable), above the composer, or below it (.down) when the
|
|
509
511
|
start page leaves no room above, or over the composer (.over) when
|
|
@@ -694,10 +696,10 @@ body.stage-view #dock,body.stage-view #dock:focus-within{background:transparent;
|
|
|
694
696
|
#infoBar #infoArchiveBtn:hover{color:var(--text);background:var(--bg-hover)}
|
|
695
697
|
#infoBar #infoDeleteBtn{background:transparent;color:var(--text-muted);border:1px solid transparent}
|
|
696
698
|
#infoBar #infoDeleteBtn:hover{color:var(--on-solid);background:var(--danger-bg);border-color:var(--danger-bg)}
|
|
697
|
-
/* The LLM context
|
|
698
|
-
#infoBar .meta .llmctx-chip{padding:2px 6px;border-radius:6px;font-size:11px;font-weight:400;border:1px solid transparent;cursor:pointer;font-family:inherit;white-space:nowrap}
|
|
699
|
-
#infoBar .meta .llmctx-chip:hover{color:var(--text);border-color:var(--accent-border);background:var(--accent-bg)}
|
|
700
|
-
#infoBar .meta .llmctx-chip.own{color:var(--accent);border-color:var(--accent-border)}
|
|
699
|
+
/* The LLM context and thinking chips (llm_context_chip.js, thinking_chip.js): model-like chips that open their popovers; accent when the session set its own. */
|
|
700
|
+
#infoBar .meta .llmctx-chip,#infoBar .meta .think-chip{padding:2px 6px;border-radius:6px;font-size:11px;font-weight:400;border:1px solid transparent;cursor:pointer;font-family:inherit;white-space:nowrap}
|
|
701
|
+
#infoBar .meta .llmctx-chip:hover,#infoBar .meta .think-chip:hover{color:var(--text);border-color:var(--accent-border);background:var(--accent-bg)}
|
|
702
|
+
#infoBar .meta .llmctx-chip.own,#infoBar .meta .think-chip.own{color:var(--accent);border-color:var(--accent-border)}
|
|
701
703
|
.llmctx-form{display:flex;flex-direction:column;gap:8px;margin:0}
|
|
702
704
|
.llmctx-form .ctx-pop-head strong{font-family:inherit}
|
|
703
705
|
.llmctx-field{display:flex;align-items:center;gap:8px}
|
|
@@ -1046,6 +1048,7 @@ a{color:var(--accent)}
|
|
|
1046
1048
|
<div class="actions-col">
|
|
1047
1049
|
<button type="button" id="modelPick" class="model-pick" title="The model the new chat starts on" aria-label="Model for the new chat" aria-haspopup="listbox" aria-expanded="false" hidden><span class="model-pick-name"></span></button>
|
|
1048
1050
|
<button type="button" id="llmContextNew" class="llmctx-new" aria-haspopup="dialog" hidden></button>
|
|
1051
|
+
<button type="button" id="thinkingNew" class="llmctx-new think-new" aria-haspopup="dialog" hidden></button>
|
|
1049
1052
|
<div id="composeHint">Enter to start · Shift+Enter for a newline</div>
|
|
1050
1053
|
<button id="cancelBtn" title="Cancel the running turn, whoever sent it" style="display:none">Stop turn</button>
|
|
1051
1054
|
<div class="split">
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// Command bubbles (/help, /model, /stats, /llm-context …) live only in the
|
|
2
|
+
// page: the session saves just a `!cmd`'s output (a message, redrawn as its
|
|
3
|
+
// command bubble). A resync (a `!cmd`, a `!rollback`, a dropped stream)
|
|
4
|
+
// redraws the history from the server, so the page keeps its own command
|
|
5
|
+
// bubbles across it: each one's anchor is the prompts and saved shell
|
|
6
|
+
// commands before it, and it goes back after them in the redrawn history.
|
|
7
|
+
// A reload still drops them.
|
|
8
|
+
//
|
|
9
|
+
// +kinds+: the history's children in order, each "user" (a prompt bubble),
|
|
10
|
+
// "shell" (a `!cmd`'s bubble, which the redrawn history has too) or
|
|
11
|
+
// anything else.
|
|
12
|
+
|
|
13
|
+
// @return {{users: number, shells: number}} the anchor of kinds[index]
|
|
14
|
+
export function commandAnchor(kinds, index) {
|
|
15
|
+
const before = kinds.slice(0, index);
|
|
16
|
+
return {
|
|
17
|
+
users: before.filter((k) => k === "user").length,
|
|
18
|
+
shells: before.filter((k) => k === "shell").length,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// The index to insert a kept bubble at: before the first prompt or shell
|
|
23
|
+
// command past its anchor (it ran after its turn), else at the end (a
|
|
24
|
+
// history a !rollback made shorter too).
|
|
25
|
+
export function anchorInsertIndex(kinds, { users = 0, shells = 0 } = {}) {
|
|
26
|
+
let seenUsers = 0;
|
|
27
|
+
let seenShells = 0;
|
|
28
|
+
for (let i = 0; i < kinds.length; i += 1) {
|
|
29
|
+
if (kinds[i] === "user") {
|
|
30
|
+
if (seenUsers >= users) return i;
|
|
31
|
+
seenUsers += 1;
|
|
32
|
+
} else if (kinds[i] === "shell") {
|
|
33
|
+
if (seenShells >= shells) return i;
|
|
34
|
+
seenShells += 1;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return kinds.length;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const SHELL_LINE = /^!\s*\S/;
|
|
41
|
+
const ROLLBACK_LINE = /^!rollback(\s|$)/;
|
|
42
|
+
|
|
43
|
+
// Whether a command bubble's line is kept across a resync: a `!cmd`'s is
|
|
44
|
+
// redrawn from its saved message (keeping it too would show it twice).
|
|
45
|
+
export function isShellCommandLine(line) {
|
|
46
|
+
const text = String(line ?? "");
|
|
47
|
+
return SHELL_LINE.test(text) && !ROLLBACK_LINE.test(text);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function keepsCommandBubble(line) {
|
|
51
|
+
return !isShellCommandLine(line);
|
|
52
|
+
}
|
|
@@ -22,7 +22,8 @@ export const RECENT_KEY = "chi_model_recent";
|
|
|
22
22
|
|
|
23
23
|
// The payload's models as rows: {name, host, id, isDefaultHost}, and
|
|
24
24
|
// sampling (the configured request parameters as /model words them) when
|
|
25
|
-
// the model has some,
|
|
25
|
+
// the model has some, thinking (the level it starts at, Thinking::Explained#summary),
|
|
26
|
+
// and llm_context (the LLM context it starts under,
|
|
26
27
|
// LLMContextStrategy::Explained#summary) when the server sent one, and
|
|
27
28
|
// configured when the host declares the id (hosts.<name>.models), and
|
|
28
29
|
// unavailable when that host failed to list its models. The
|
|
@@ -42,6 +43,7 @@ export function modelRows(payload) {
|
|
|
42
43
|
if (m.configured === true) row.configured = true;
|
|
43
44
|
if (m.unavailable === true) row.unavailable = true;
|
|
44
45
|
if (m.llm_context && typeof m.llm_context === "object") row.llm_context = m.llm_context;
|
|
46
|
+
if (m.thinking && typeof m.thinking === "object") row.thinking = m.thinking;
|
|
45
47
|
return row;
|
|
46
48
|
});
|
|
47
49
|
}
|