samagotchi 0.51.0 → 0.53.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 +72 -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/releasing.md +6 -3
- data/docs/sessions.md +5 -1
- data/docs/testing.md +8 -1
- data/lib/samagotchi/analytics_file.rb +35 -0
- 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/bundle_command.rb +9 -13
- 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 +63 -18
- data/lib/samagotchi/engine_peers.rb +1 -0
- data/lib/samagotchi/hooks/bundle_loader.rb +9 -8
- data/lib/samagotchi/host_config.rb +183 -0
- data/lib/samagotchi/host_registry.rb +18 -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/builder.rb +23 -21
- data/lib/samagotchi/memory_bundle/bundle_hook.rb +51 -0
- data/lib/samagotchi/memory_bundle/installer.rb +4 -4
- data/lib/samagotchi/memory_bundle/manifest.rb +6 -28
- data/lib/samagotchi/memory_bundle/placeholder.rb +0 -5
- data/lib/samagotchi/memory_bundle/provenance.rb +8 -17
- 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 +40 -46
- data/lib/samagotchi/session.rb +45 -24
- data/lib/samagotchi/session_commands.rb +68 -11
- data/lib/samagotchi/session_inbox.rb +18 -9
- data/lib/samagotchi/session_manager.rb +44 -11
- data/lib/samagotchi/session_metrics.rb +15 -20
- data/lib/samagotchi/session_setup.rb +30 -0
- data/lib/samagotchi/sessions_command.rb +3 -2
- 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 +26 -4
- data/lib/samagotchi/terminal_ui.rb +21 -4
- 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 +56 -13
- data/lib/samagotchi/web/public/app.js +131 -20
- data/lib/samagotchi/web/public/command_complete.js +7 -2
- 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 +179 -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 +13 -1
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "thinking"
|
|
4
|
+
|
|
5
|
+
module Samagotchi
|
|
6
|
+
# /thinking, the session's own thinking level (Session#thinking): with no
|
|
7
|
+
# argument, the level the next turn runs at and where it came from;
|
|
8
|
+
# `/thinking off|low|medium|high` sets the session's own, `/thinking
|
|
9
|
+
# default` unsets it (the session follows the process default, the
|
|
10
|
+
# model, its host, then thinking.level). A change takes effect at the
|
|
11
|
+
# next turn's start (the REPL and the worker run it between turns), and
|
|
12
|
+
# the reply says what it costs the prompt cache (Thinking.cache_cost).
|
|
13
|
+
class ThinkingCommand
|
|
14
|
+
NAME = "/thinking"
|
|
15
|
+
SETTABLE = (Thinking::LEVELS - [Thinking::DEFAULT]).freeze
|
|
16
|
+
USAGE = "usage: #{NAME} [#{Thinking::LEVELS.join("|")}]".freeze
|
|
17
|
+
|
|
18
|
+
COST_NOTES = {
|
|
19
|
+
full: "On this model the whole prompt is read again (Gemma's <|think|> starts it).",
|
|
20
|
+
provider: "The request's thinking fields change: a local llama.cpp or Splash server keeps its cache; " \
|
|
21
|
+
"hosted APIs (OpenRouter → Claude, OpenAI) may restart theirs.",
|
|
22
|
+
tail: "Only the prompt's tail changes: the cache keeps the rest."
|
|
23
|
+
}.freeze
|
|
24
|
+
|
|
25
|
+
# @param engine [Engine] #thinking_explained, #thinking_override(=),
|
|
26
|
+
# #session, #thinking_cache_cost
|
|
27
|
+
# @param save [#call] saves the session after a change
|
|
28
|
+
def initialize(engine:, save:)
|
|
29
|
+
@engine = engine
|
|
30
|
+
@save = save
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# @param args [String] what follows /thinking
|
|
34
|
+
# @return [Array(String, Boolean)] the reply and whether the level changed
|
|
35
|
+
# @raise [ArgumentError] a word that isn't a level (with USAGE)
|
|
36
|
+
def run(args)
|
|
37
|
+
word = args.to_s.strip.downcase
|
|
38
|
+
return [listing(@engine.thinking_explained), false] if word.empty?
|
|
39
|
+
|
|
40
|
+
level = word.to_sym
|
|
41
|
+
raise ArgumentError, "unknown thinking level #{word}\n#{USAGE}" unless Thinking::LEVELS.include?(level)
|
|
42
|
+
|
|
43
|
+
session = @engine.session or raise ArgumentError, "no session yet"
|
|
44
|
+
before = @engine.thinking_explained
|
|
45
|
+
@engine.thinking_override = SETTABLE.include?(level) ? level : nil
|
|
46
|
+
@save.call(session)
|
|
47
|
+
after = @engine.thinking_explained
|
|
48
|
+
[[listing(after), *notes(before, after)].join("\n"), true]
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# The level, where it came from, and what the session sets itself.
|
|
52
|
+
def listing(explained)
|
|
53
|
+
return "thinking: can't be resolved for this model" unless explained
|
|
54
|
+
|
|
55
|
+
lines = ["thinking: #{explained.label}"]
|
|
56
|
+
unless explained.own
|
|
57
|
+
lines << " this session's own: none (follows chi web --thinking / SAMAGOTCHI_THINKING_LEVEL, the model, " \
|
|
58
|
+
"its host, then thinking.level)"
|
|
59
|
+
end
|
|
60
|
+
lines.join("\n")
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def notes(before, after)
|
|
66
|
+
return [] unless before && after
|
|
67
|
+
return ["(no change to what the next turn runs under)"] if before.level == after.level
|
|
68
|
+
|
|
69
|
+
["From the next turn's start (a running turn keeps its own).", COST_NOTES[@engine.thinking_cache_cost]].compact
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -119,15 +119,19 @@ module Samagotchi
|
|
|
119
119
|
# wrapper. The session's id, "chi" without one. And the model the
|
|
120
120
|
# session runs on (SAMAGOTCHI_SESSION_MODEL, read live, so right after
|
|
121
121
|
# /model), which chi self reports; unset when unknown, so a value
|
|
122
|
-
# inherited from an outer session never passes for this one.
|
|
122
|
+
# inherited from an outer session never passes for this one. So too
|
|
123
|
+
# the session's own thinking level (SAMAGOTCHI_SESSION_THINKING, unset
|
|
124
|
+
# without one), which chi self puts first. A
|
|
123
125
|
# worker's `chi --model X` default (ModelProfile::MODEL_FROM_CLI_ENV)
|
|
124
126
|
# is unset: it was that run's, not the default of a chi run here.
|
|
125
127
|
def parent_env(kctx, env: ENV)
|
|
126
128
|
peers = kctx.peers
|
|
127
129
|
id = peers.respond_to?(:session_id) ? peers.session_id.to_s : ""
|
|
128
130
|
model = peers.respond_to?(:model_ref) ? peers.model_ref.to_s : ""
|
|
131
|
+
thinking = peers.respond_to?(:thinking) ? peers.thinking&.to_s : nil
|
|
129
132
|
{ Guardrails::ParentApprovals::PARENT_SESSION_ENV => id.empty? ? "chi" : id,
|
|
130
|
-
SESSION_MODEL_ENV => model.empty? ? nil : model
|
|
133
|
+
SESSION_MODEL_ENV => model.empty? ? nil : model,
|
|
134
|
+
SESSION_THINKING_ENV => thinking }.merge(cli_model_unset(env))
|
|
131
135
|
end
|
|
132
136
|
|
|
133
137
|
# The unsets that keep a `chi --model X` worker's default from passing
|
|
@@ -139,6 +143,7 @@ module Samagotchi
|
|
|
139
143
|
end
|
|
140
144
|
|
|
141
145
|
SESSION_MODEL_ENV = "SAMAGOTCHI_SESSION_MODEL"
|
|
146
|
+
SESSION_THINKING_ENV = "SAMAGOTCHI_SESSION_THINKING"
|
|
142
147
|
|
|
143
148
|
# task_create's parent_env, less the unsets of a variable the model
|
|
144
149
|
# set in its env (a SAMAGOTCHI_DEFAULT_MODEL it asked for stays).
|
|
@@ -8,10 +8,11 @@ module Samagotchi
|
|
|
8
8
|
# canceled (a waiting tool returns on it), and the question flow a
|
|
9
9
|
# delegate's approval is relayed through (relay: nil when this session
|
|
10
10
|
# can't host one), and the model the session runs on (model_ref: the
|
|
11
|
-
# resolved ref, live after /model)
|
|
11
|
+
# resolved ref, live after /model), and the session's own thinking level
|
|
12
|
+
# (thinking: nil without one). Engine hands KernelLoop one that follows its current
|
|
12
13
|
# session (Engine::PeerView); this plain one is for specs and a kernel
|
|
13
14
|
# without an Engine.
|
|
14
|
-
Peers = Struct.new(:session_id, :cwd, :state_dir, :cancelled, :relay, :model_ref, keyword_init: true) do
|
|
15
|
+
Peers = Struct.new(:session_id, :cwd, :state_dir, :cancelled, :relay, :model_ref, :thinking, keyword_init: true) do
|
|
15
16
|
# @return [Boolean] the turn was canceled (:cancelled is a proc or a value)
|
|
16
17
|
def cancelled?
|
|
17
18
|
cancelled.respond_to?(:call) ? !!cancelled.call : !!cancelled
|
|
@@ -22,7 +22,7 @@ module Samagotchi
|
|
|
22
22
|
OUTPUT_TAIL_READ_BYTES = 16 * 1024
|
|
23
23
|
SANITIZED_ENV_KEYS = %w[RUBYOPT RUBYLIB BUNDLE_GEMFILE BUNDLE_BIN_PATH BUNDLER_VERSION].freeze
|
|
24
24
|
# Set by chi (Builtins.parent_env); the model's env can't change it.
|
|
25
|
-
MARKER_ENV_KEYS = %w[SAMAGOTCHI_PARENT_SESSION SAMAGOTCHI_SESSION_MODEL].freeze
|
|
25
|
+
MARKER_ENV_KEYS = %w[SAMAGOTCHI_PARENT_SESSION SAMAGOTCHI_SESSION_MODEL SAMAGOTCHI_SESSION_THINKING].freeze
|
|
26
26
|
# task_wait and task_get add it to a task the user stopped (the web's
|
|
27
27
|
# stop-task button), so the model doesn't rerun the job.
|
|
28
28
|
# A record whose pid isn't a process group chi started (missing, 0, 1,
|
data/lib/samagotchi/version.rb
CHANGED
|
@@ -24,7 +24,7 @@ module Samagotchi
|
|
|
24
24
|
# @param target [HostRegistry::ModelTarget]
|
|
25
25
|
# @param profile [ModelProfile, nil] the model's prompt profile (native)
|
|
26
26
|
# @param adapter [LLM::OpenAIChat, nil] the chat host's adapter
|
|
27
|
-
# @param models [Hash, nil] ConfigFile.model_settings (specs)
|
|
27
|
+
# @param models [Hash{String => ModelSettings}, nil] ConfigFile.model_settings (specs)
|
|
28
28
|
# @param names [Array<String>] the model's lookup names (HostRegistry#lookup_names)
|
|
29
29
|
# @return [Answer]
|
|
30
30
|
def self.for(target, names:, profile: nil, adapter: nil, models: nil)
|
|
@@ -51,8 +51,9 @@ module Samagotchi
|
|
|
51
51
|
rescue StandardError
|
|
52
52
|
{}
|
|
53
53
|
end
|
|
54
|
-
|
|
55
|
-
|
|
54
|
+
if (setting = ConfigFile.model_setting(names, :vision, models: models))
|
|
55
|
+
return Answer.new(value: setting.value, reason: setting.value ? nil : "models: #{setting.key} sets vision: false")
|
|
56
|
+
end
|
|
56
57
|
return nil if entry.vision.nil?
|
|
57
58
|
|
|
58
59
|
Answer.new(value: entry.vision, reason: entry.vision ? nil : "hosts.#{entry.name} sets vision: false")
|
data/lib/samagotchi/web/app.rb
CHANGED
|
@@ -9,7 +9,9 @@ require "uri"
|
|
|
9
9
|
require "rack"
|
|
10
10
|
require "rack/request"
|
|
11
11
|
|
|
12
|
+
require_relative "../analytics_file"
|
|
12
13
|
require_relative "../client_id"
|
|
14
|
+
require_relative "../delivery"
|
|
13
15
|
require_relative "../answer_tail"
|
|
14
16
|
require_relative "../bridge_client"
|
|
15
17
|
require_relative "../bridge/bounded_queue"
|
|
@@ -23,6 +25,7 @@ require_relative "../host_registry"
|
|
|
23
25
|
require_relative "../llm_context_override"
|
|
24
26
|
require_relative "../model_catalog"
|
|
25
27
|
require_relative "../model_profile"
|
|
28
|
+
require_relative "../thinking"
|
|
26
29
|
require_relative "../sampling_settings"
|
|
27
30
|
require_relative "../served_model"
|
|
28
31
|
require_relative "../project_scope"
|
|
@@ -476,6 +479,7 @@ module Samagotchi
|
|
|
476
479
|
summary = SamplingSettings.summary(target, names: names, models: settings)
|
|
477
480
|
model[:sampling] = summary if summary
|
|
478
481
|
add_llm_context(model, target, names, settings)
|
|
482
|
+
add_thinking(model, target, names, settings)
|
|
479
483
|
end
|
|
480
484
|
rescue StandardError
|
|
481
485
|
nil
|
|
@@ -489,6 +493,14 @@ module Samagotchi
|
|
|
489
493
|
nil
|
|
490
494
|
end
|
|
491
495
|
|
|
496
|
+
# The thinking level +model+ starts at, with where it came from (the
|
|
497
|
+
# start page's think chip; Thinking::Explained#summary, no session).
|
|
498
|
+
def add_thinking(model, target, names, settings)
|
|
499
|
+
model[:thinking] = Thinking.explain(target, names: names, models: settings).summary
|
|
500
|
+
rescue StandardError
|
|
501
|
+
nil
|
|
502
|
+
end
|
|
503
|
+
|
|
492
504
|
# The served model a session's last generation reported (analytics.json,
|
|
493
505
|
# SessionMetrics#persist), for a session no worker runs: shown only
|
|
494
506
|
# while the name it answered is still the session's model, as
|
|
@@ -525,6 +537,19 @@ module Samagotchi
|
|
|
525
537
|
LLMContextStrategy.for_session(session, registry: host_registry)
|
|
526
538
|
end
|
|
527
539
|
|
|
540
|
+
# A session's thinking level with where it came from and its own
|
|
541
|
+
# (Thinking::Explained#summary), as its worker would resolve it; nil
|
|
542
|
+
# when it can't be. Config and the registry only: no network.
|
|
543
|
+
def thinking_for(session)
|
|
544
|
+
registry = host_registry
|
|
545
|
+
name = session.model_name.to_s.strip.empty? ? ModelProfile.required_model_name(nil) : session.model_name
|
|
546
|
+
target = registry.resolve(name)
|
|
547
|
+
names = registry.lookup_names(session.model_typed || name, resolved: name, target: target)
|
|
548
|
+
Thinking.explain(target, names: names, session: session.thinking).summary
|
|
549
|
+
rescue StandardError
|
|
550
|
+
nil
|
|
551
|
+
end
|
|
552
|
+
|
|
528
553
|
# Built again when config.yml's hosts: changed (compared as read: the
|
|
529
554
|
# YAML read is mtime-cached), so the model picker lists a host added
|
|
530
555
|
# while chi web runs. An injected registry (specs) stays.
|
|
@@ -607,6 +632,11 @@ module Samagotchi
|
|
|
607
632
|
llm_context, error = request_llm_context(body["llm_context"])
|
|
608
633
|
return error if error
|
|
609
634
|
|
|
635
|
+
# thinking: the session's own thinking level (the start page's think
|
|
636
|
+
# chip; chi --thinking), checked the same way.
|
|
637
|
+
thinking, error = request_thinking(body["thinking"])
|
|
638
|
+
return error if error
|
|
639
|
+
|
|
610
640
|
folder = dir ? { working_directory: dir } : {}
|
|
611
641
|
# model: the model the session starts on (the start page's picker,
|
|
612
642
|
# spelled as GET /api/models lists it); blank means the default.
|
|
@@ -617,7 +647,8 @@ module Samagotchi
|
|
|
617
647
|
# until that turn is saved.
|
|
618
648
|
preview = idle ? body["preview"].to_s.strip : ""
|
|
619
649
|
folder[:title] = preview unless preview.empty?
|
|
620
|
-
|
|
650
|
+
setup = SessionSetup.new(llm_context: llm_context, thinking: thinking)
|
|
651
|
+
folder[:setup] = setup unless setup.empty?
|
|
621
652
|
begin
|
|
622
653
|
session = @manager.spawn_session(prompt: idle ? nil : prompt.to_s, state_dir: @state_dir, **folder)
|
|
623
654
|
rescue ModelProfile::MissingModel => e
|
|
@@ -635,8 +666,8 @@ module Samagotchi
|
|
|
635
666
|
|
|
636
667
|
# POST /api/sessions with continues: <id, prefix or last:id>: the next
|
|
637
668
|
# 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
|
|
669
|
+
# folder, on its model and with its llm_context and thinking level, so
|
|
670
|
+
# dir, model, llm_context and thinking are refused beside it. 409 continued (with next_id, the
|
|
640
671
|
# link that continues it already: the page opens that one),
|
|
641
672
|
# open_children (ids), folder_gone, busy, scratch or owned_by_tui.
|
|
642
673
|
def handle_continue(body, prompt, idle)
|
|
@@ -644,9 +675,9 @@ module Samagotchi
|
|
|
644
675
|
unless given.is_a?(String) && !given.strip.empty?
|
|
645
676
|
return error_response(400, "invalid_continues", "continues is a session id")
|
|
646
677
|
end
|
|
647
|
-
if (fields = %w[dir model model_name llm_context].select { |key| body.key?(key) }).any?
|
|
678
|
+
if (fields = %w[dir model model_name llm_context thinking].select { |key| body.key?(key) }).any?
|
|
648
679
|
return error_response(400, "invalid_continues",
|
|
649
|
-
"continues takes the previous session's folder, model and
|
|
680
|
+
"continues takes the previous session's folder, model, llm_context and thinking; " \
|
|
650
681
|
"leave out #{fields.join(", ")}")
|
|
651
682
|
end
|
|
652
683
|
|
|
@@ -701,6 +732,20 @@ module Samagotchi
|
|
|
701
732
|
[nil, error_response(400, "invalid_llm_context", e.message)]
|
|
702
733
|
end
|
|
703
734
|
|
|
735
|
+
# The create request's "thinking" (a level word as /thinking takes it,
|
|
736
|
+
# "default" for none) as the session's own level: [level, nil] (nil
|
|
737
|
+
# when absent or default), or [nil, a 400 response] for anything else.
|
|
738
|
+
def request_thinking(raw)
|
|
739
|
+
return [nil, nil] if raw.nil?
|
|
740
|
+
|
|
741
|
+
word = raw.is_a?(String) ? raw.strip.downcase : nil
|
|
742
|
+
unless word && Thinking::LEVELS.map(&:to_s).include?(word)
|
|
743
|
+
return [nil, error_response(400, "invalid_thinking", "thinking is one of #{Thinking::LEVELS.join(", ")}")]
|
|
744
|
+
end
|
|
745
|
+
|
|
746
|
+
[Thinking.session_level(word), nil]
|
|
747
|
+
end
|
|
748
|
+
|
|
704
749
|
def handle_show(req, id)
|
|
705
750
|
# The page's light re-reads: never the whole conversation.
|
|
706
751
|
return handle_cards_read(id) if req.params["cards"] == "1"
|
|
@@ -750,6 +795,8 @@ module Samagotchi
|
|
|
750
795
|
# The LLM context strategy the next turn runs under (the info bar's
|
|
751
796
|
# chip): the worker's, else worked out here from the session file.
|
|
752
797
|
session_json = session_json.merge(llm_context: (snapshot && snapshot["llm_context"]) || llm_context_for(session))
|
|
798
|
+
# The thinking level the next turn runs at (the think chip), the same way.
|
|
799
|
+
session_json = session_json.merge(thinking: (snapshot && snapshot["thinking"]) || thinking_for(session))
|
|
753
800
|
raw_messages = turn_snapshot ? turn_snapshot["messages"] : session.messages
|
|
754
801
|
timing = timing_payload(id, live_metrics: snapshot && snapshot["metrics"], persisted: persisted)
|
|
755
802
|
json_response(200, {
|
|
@@ -1066,7 +1113,9 @@ module Samagotchi
|
|
|
1066
1113
|
return error_response(400, "bad_images", images) if images.is_a?(String)
|
|
1067
1114
|
|
|
1068
1115
|
delivery = body["delivery"] || body[:delivery]
|
|
1069
|
-
|
|
1116
|
+
if (refused = Delivery.refusal(delivery))
|
|
1117
|
+
return error_response(400, refused[:error], refused[:detail])
|
|
1118
|
+
end
|
|
1070
1119
|
|
|
1071
1120
|
result = SessionManager.deliver_turn(id, prompt: prompt.to_s, client_id: client_id, images: images,
|
|
1072
1121
|
delivery: delivery, state_dir: @state_dir, manager: @manager,
|
|
@@ -1674,13 +1723,7 @@ module Samagotchi
|
|
|
1674
1723
|
end
|
|
1675
1724
|
|
|
1676
1725
|
def read_analytics(session_id)
|
|
1677
|
-
|
|
1678
|
-
return {} unless File.file?(path)
|
|
1679
|
-
|
|
1680
|
-
data = JSON.parse(File.read(path))
|
|
1681
|
-
data.is_a?(Hash) ? data : {}
|
|
1682
|
-
rescue JSON::ParserError, SystemCallError
|
|
1683
|
-
{}
|
|
1726
|
+
AnalyticsFile.read(@session_class.session_dir(session_id, state_dir: default_state_dir)) || {}
|
|
1684
1727
|
end
|
|
1685
1728
|
|
|
1686
1729
|
def merge_timing_records(persisted, live)
|
|
@@ -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,41 @@ 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
|
+
summary: () => (selected ? current.thinking : null),
|
|
130
|
+
session: () => selected,
|
|
131
|
+
running: () => liveTurn.running,
|
|
132
|
+
run: (line) => runSessionCommand(line),
|
|
133
|
+
});
|
|
134
|
+
// The start page's thinking chip: the picked model's level, and a level
|
|
135
|
+
// for the next new chat only (sent with its create, then dropped).
|
|
136
|
+
const thinkingNewEl = $("#thinkingNew");
|
|
137
|
+
let newChatThinking = null;
|
|
138
|
+
const newChatThinkingSummary = () => (modelPickEl.hidden ? null : modelPicker.chosenRow()?.thinking || null);
|
|
139
|
+
const thinkingNewControl = createThinkingControl({
|
|
140
|
+
el: thinkingNewEl,
|
|
141
|
+
summary: newChatThinkingSummary,
|
|
142
|
+
kind: NEW_CHAT,
|
|
143
|
+
choice: () => newChatThinking,
|
|
144
|
+
choose: (next) => {
|
|
145
|
+
newChatThinking = next;
|
|
146
|
+
renderNewChatThinking();
|
|
147
|
+
promptEl.focus();
|
|
148
|
+
},
|
|
149
|
+
id: "thinkingNewPopover",
|
|
150
|
+
anchor: "#thinkingNew",
|
|
151
|
+
});
|
|
152
|
+
function renderNewChatThinking() {
|
|
153
|
+
const chip = thinkingChipModel(withThinkingChoice(newChatThinkingSummary(), newChatThinking), { kind: NEW_CHAT });
|
|
154
|
+
thinkingNewEl.hidden = !chip;
|
|
155
|
+
if (!chip) return thinkingNewControl.close();
|
|
156
|
+
thinkingNewEl.textContent = chip.text;
|
|
157
|
+
thinkingNewEl.title = chip.title;
|
|
158
|
+
thinkingNewEl.classList.toggle("own", chip.own);
|
|
159
|
+
}
|
|
120
160
|
// Hidden without the model's values (the list not in, an older server).
|
|
121
161
|
function renderNewChatLlmContext() {
|
|
122
162
|
const chip = llmContextChipModel(withChoice(newChatLlmSummary(), newChatLlmContext), { kind: NEW_CHAT });
|
|
@@ -1057,6 +1097,11 @@ function updateComposerMode() {
|
|
|
1057
1097
|
newChatLlmContext = null;
|
|
1058
1098
|
renderNewChatLlmContext();
|
|
1059
1099
|
}
|
|
1100
|
+
thinkingNewControl.close();
|
|
1101
|
+
if (newChatThinking) {
|
|
1102
|
+
newChatThinking = null;
|
|
1103
|
+
renderNewChatThinking();
|
|
1104
|
+
}
|
|
1060
1105
|
}
|
|
1061
1106
|
const running = liveTurn.running && !!selected;
|
|
1062
1107
|
cancelBtn.style.display = running ? "" : "none";
|
|
@@ -1233,8 +1278,9 @@ async function resync({ endText = null } = {}) {
|
|
|
1233
1278
|
return;
|
|
1234
1279
|
}
|
|
1235
1280
|
if (id !== selected) return;
|
|
1281
|
+
const kept = keptCommandBubbles(id);
|
|
1236
1282
|
resetTurnView();
|
|
1237
|
-
renderSessionView(data);
|
|
1283
|
+
renderSessionView(data, { kept });
|
|
1238
1284
|
// Preview is read-only: a worker is woken only by POST /turn, so attach
|
|
1239
1285
|
// only when a live bridge exists.
|
|
1240
1286
|
selectSeq = typeof data.last_event_seq === "number" ? (data.last_event_id || data.last_event_seq) : null;
|
|
@@ -1249,10 +1295,45 @@ async function resync({ endText = null } = {}) {
|
|
|
1249
1295
|
}
|
|
1250
1296
|
}
|
|
1251
1297
|
|
|
1298
|
+
// The prompt and command bubbles in the history, in order, by kind
|
|
1299
|
+
// (kept_commands.js): a prompt, a `!cmd`'s (saved, so redrawn) or another.
|
|
1300
|
+
function historyAnchors() {
|
|
1301
|
+
const els = [...historyEl.querySelectorAll(".bubble.user, .bubble.command")];
|
|
1302
|
+
const kinds = els.map((el) => {
|
|
1303
|
+
if (el.classList.contains("user")) return "user";
|
|
1304
|
+
const line = el.querySelector(":scope > .command-line")?.textContent;
|
|
1305
|
+
return !el.classList.contains("failed") && isShellCommandLine(line) ? "shell" : "other";
|
|
1306
|
+
});
|
|
1307
|
+
return { els, kinds };
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
// This session's own command bubbles (/help, /model …: drawn by this page,
|
|
1311
|
+
// never saved) with their anchors, before a resync redraws the history.
|
|
1312
|
+
// In-flight ones (busy, queued) are left out: the snapshot draws them again.
|
|
1313
|
+
function keptCommandBubbles(id) {
|
|
1314
|
+
const { els, kinds } = historyAnchors();
|
|
1315
|
+
return els
|
|
1316
|
+
.map((el, i) => ({ el, anchor: commandAnchor(kinds, i) }))
|
|
1317
|
+
.filter(({ el }) => el.dataset.keptFor === id && !el.classList.contains("busy") && !el.classList.contains("queued"))
|
|
1318
|
+
.filter(({ el }) => keepsCommandBubble(el.querySelector(":scope > .command-line")?.textContent));
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
// The kept bubbles back into the redrawn history, each after its turn.
|
|
1322
|
+
function restoreCommandBubbles(kept) {
|
|
1323
|
+
if (!kept.length) return;
|
|
1324
|
+
historyEl.querySelector(".hint")?.remove();
|
|
1325
|
+
kept.forEach(({ el, anchor }) => {
|
|
1326
|
+
const { els, kinds } = historyAnchors();
|
|
1327
|
+
const at = anchorInsertIndex(kinds, anchor);
|
|
1328
|
+
if (at < els.length) els[at].before(el);
|
|
1329
|
+
else historyEl.appendChild(el);
|
|
1330
|
+
});
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1252
1333
|
// A session view: its messages, then the turn in progress and the queued
|
|
1253
1334
|
// prompts (a live worker's snapshot) replayed through the stream handlers,
|
|
1254
1335
|
// then a pending question.
|
|
1255
|
-
function renderSessionView(data) {
|
|
1336
|
+
function renderSessionView(data, { kept = [] } = {}) {
|
|
1256
1337
|
const s = data.session;
|
|
1257
1338
|
setMarkdownWarning(data.markdown_warning);
|
|
1258
1339
|
setCapabilities(data.capabilities);
|
|
@@ -1279,20 +1360,23 @@ function renderSessionView(data) {
|
|
|
1279
1360
|
current.promptNotes = Array.isArray(s.prompt_notes) ? s.prompt_notes : [];
|
|
1280
1361
|
current.llmContext = s.llm_context || null;
|
|
1281
1362
|
llmContextControl.refresh();
|
|
1363
|
+
current.thinking = s.thinking || null;
|
|
1364
|
+
thinkingControl.refresh();
|
|
1282
1365
|
current.parentId = s.parent_id || null;
|
|
1283
1366
|
current.continues = s.continues || null;
|
|
1284
1367
|
current.commands = Array.isArray(data.commands) ? data.commands : [];
|
|
1285
1368
|
if (data.messages && data.messages.length) {
|
|
1286
1369
|
const first = data.messages.find((m) => m.role === "user");
|
|
1287
|
-
if (first && first.content) current.firstPreview =
|
|
1370
|
+
if (first && first.content) current.firstPreview = firstPreview(first.content);
|
|
1288
1371
|
renderHistory(data.messages);
|
|
1289
1372
|
} else {
|
|
1290
1373
|
renderHistory(data.history || []);
|
|
1291
1374
|
if (!current.firstPreview && data.history && data.history.length) {
|
|
1292
|
-
const txt =
|
|
1293
|
-
if(txt) current.firstPreview = txt;
|
|
1375
|
+
const txt = firstPreview(data.history[0]);
|
|
1376
|
+
if (txt) current.firstPreview = txt;
|
|
1294
1377
|
}
|
|
1295
1378
|
}
|
|
1379
|
+
restoreCommandBubbles(kept);
|
|
1296
1380
|
// A failed turn's prompt went back to the user: shown after the
|
|
1297
1381
|
// conversation, marked failed, as it was live (prompt_restored).
|
|
1298
1382
|
if (data.failed_turn) {
|
|
@@ -1352,7 +1436,7 @@ function renderHistory(items) {
|
|
|
1352
1436
|
return;
|
|
1353
1437
|
}
|
|
1354
1438
|
if (typeof items[0] === "object" && items[0].role) {
|
|
1355
|
-
historyEl.
|
|
1439
|
+
historyEl.replaceChildren(turnHistoryNodes(items, current.timing, { thumbs: (images) => thumbsHtml(selected, images) }));
|
|
1356
1440
|
} else {
|
|
1357
1441
|
historyEl.innerHTML = items.map((c) => `<div class="bubble output">${escapeHtml(c)}</div>`).join("");
|
|
1358
1442
|
}
|
|
@@ -1965,7 +2049,7 @@ function updateInfoBar() {
|
|
|
1965
2049
|
const attachCmd = `chi --attach ${selected}`;
|
|
1966
2050
|
const copied = Date.now() - attachCopiedAt < 2000;
|
|
1967
2051
|
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>`;
|
|
2052
|
+
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
2053
|
// The session clock ticks every second while a turn runs: only its text
|
|
1970
2054
|
// changes, the bar's other nodes (a tooltip, a button mid-press) stay.
|
|
1971
2055
|
renderStable(infoTextEl, metaHtml, { ".session-time": timingText });
|
|
@@ -2020,10 +2104,7 @@ function memoriesTooltip(used, preloaded, muted) {
|
|
|
2020
2104
|
}
|
|
2021
2105
|
|
|
2022
2106
|
function currentSessionDuration() {
|
|
2023
|
-
|
|
2024
|
-
return elapsedSince(current.timing.startedAt);
|
|
2025
|
-
}
|
|
2026
|
-
return current.timing.sessionDurationMs;
|
|
2107
|
+
return sessionDurationNow(current.timing, liveTurn.running);
|
|
2027
2108
|
}
|
|
2028
2109
|
|
|
2029
2110
|
// The stage takes a running turn's rows (its extras); else the history.
|
|
@@ -2281,6 +2362,12 @@ const streamHandlers = {
|
|
|
2281
2362
|
updateInfoBar();
|
|
2282
2363
|
llmContextControl.refresh();
|
|
2283
2364
|
}
|
|
2365
|
+
// /thinking (or /model) changed the level the next turn runs at.
|
|
2366
|
+
if (data.thinking) {
|
|
2367
|
+
current.thinking = data.thinking;
|
|
2368
|
+
updateInfoBar();
|
|
2369
|
+
thinkingControl.refresh();
|
|
2370
|
+
}
|
|
2284
2371
|
if (view.resync) await resync();
|
|
2285
2372
|
// A card's action that isn't anytime (the step-limit question's
|
|
2286
2373
|
// /continue) has no bubble: nothing to remove. An anytime or queued
|
|
@@ -2433,10 +2520,27 @@ function handleCreate() {
|
|
|
2433
2520
|
|
|
2434
2521
|
async function handleSendTurn() {
|
|
2435
2522
|
if (current.owner === "tui") return;
|
|
2436
|
-
|
|
2523
|
+
let prompt = turnText(promptEl.value, chips);
|
|
2437
2524
|
if (!prompt) return;
|
|
2438
2525
|
// An image-only send's text is its placeholders: not for ↑.
|
|
2439
2526
|
const typed = promptEl.value.trim() !== "";
|
|
2527
|
+
// /cut TEXT and /queue TEXT (the terminals' words for a delivery): the
|
|
2528
|
+
// text as a cut, or the page's own answer (/queue, the bare word).
|
|
2529
|
+
let delivery = null;
|
|
2530
|
+
const choice = steerChoiceRoute(prompt, { running: liveTurn.running && !!selected });
|
|
2531
|
+
if (choice?.reply) {
|
|
2532
|
+
if (selected) addCommandBubble({ label: null, line: prompt, text: choice.reply });
|
|
2533
|
+
else showNotice(choice.reply);
|
|
2534
|
+
promptEl.value = "";
|
|
2535
|
+
fitPrompt();
|
|
2536
|
+
return;
|
|
2537
|
+
}
|
|
2538
|
+
// The composer holds the line as typed until the ack (composerAfterAck).
|
|
2539
|
+
const line = prompt;
|
|
2540
|
+
if (choice) {
|
|
2541
|
+
prompt = choice.send;
|
|
2542
|
+
delivery = choice.delivery || null;
|
|
2543
|
+
}
|
|
2440
2544
|
if (!selected) {
|
|
2441
2545
|
// The start page's own reply (a web-local command) or the hint for a
|
|
2442
2546
|
// typo: neither needs a session, so none is made for it (2.29).
|
|
@@ -2450,10 +2554,13 @@ async function handleSendTurn() {
|
|
|
2450
2554
|
actionBtn.disabled = true;
|
|
2451
2555
|
try {
|
|
2452
2556
|
const s = await createIdleSession({ dir: scope.dir, model: chosenModel(), preview: prompt,
|
|
2453
|
-
llm_context: choiceWords(newChatLlmContext)
|
|
2557
|
+
llm_context: choiceWords(newChatLlmContext),
|
|
2558
|
+
thinking: newChatThinking || undefined });
|
|
2454
2559
|
// The choice was for this chat only.
|
|
2455
2560
|
newChatLlmContext = null;
|
|
2456
2561
|
renderNewChatLlmContext();
|
|
2562
|
+
newChatThinking = null;
|
|
2563
|
+
renderNewChatThinking();
|
|
2457
2564
|
addCreatedSession(s);
|
|
2458
2565
|
await select(s.id);
|
|
2459
2566
|
} catch (e) {
|
|
@@ -2485,7 +2592,7 @@ async function handleSendTurn() {
|
|
|
2485
2592
|
const echo = makeUserBubble(prompt, { state: "queued", own: true, images: sending });
|
|
2486
2593
|
if (!turnDom.holds?.()) historyEl.scrollTop = historyEl.scrollHeight;
|
|
2487
2594
|
if (!current.firstPreview || current.firstPreview==="—") {
|
|
2488
|
-
current.firstPreview =
|
|
2595
|
+
current.firstPreview = firstPreview(prompt);
|
|
2489
2596
|
updateInfoBar();
|
|
2490
2597
|
}
|
|
2491
2598
|
actionBtn.disabled = true;
|
|
@@ -2493,7 +2600,7 @@ async function handleSendTurn() {
|
|
|
2493
2600
|
for (const chip of sending) {
|
|
2494
2601
|
if (!chip.ref) chip.ref = await uploadImage(selected, chip.file, chip.name);
|
|
2495
2602
|
}
|
|
2496
|
-
const ack = await sendTurn(selected, prompt, { clientId, images: turnRefs(sending), history: typed });
|
|
2603
|
+
const ack = await sendTurn(selected, prompt, { clientId, images: turnRefs(sending), history: typed, delivery });
|
|
2497
2604
|
if (ack?.command_id) {
|
|
2498
2605
|
// The worker knew it as a command (its list was newer than ours):
|
|
2499
2606
|
// its command_ran shows it, not a prompt bubble.
|
|
@@ -2509,7 +2616,7 @@ async function handleSendTurn() {
|
|
|
2509
2616
|
// (and images) go back into the composer, without replacing text typed
|
|
2510
2617
|
// since (4.13). The composer still holds the text just sent: it goes.
|
|
2511
2618
|
const restored = restoreOnAck(ack?.enqueued_id, earlyRestores, { myId: clientId, sentIds });
|
|
2512
|
-
promptEl.value = composerAfterAck({ sent:
|
|
2619
|
+
promptEl.value = composerAfterAck({ sent: line, current: promptEl.value, refill: restored?.refill || "" });
|
|
2513
2620
|
fitPrompt();
|
|
2514
2621
|
clearChips();
|
|
2515
2622
|
if (restored?.images?.length) setChips(restoredChips(selected, restored.images));
|
|
@@ -2589,6 +2696,8 @@ async function runSessionCommand(line) {
|
|
|
2589
2696
|
function addCommandBubble(view) {
|
|
2590
2697
|
removeHintIfEmpty();
|
|
2591
2698
|
const div = document.createElement("div");
|
|
2699
|
+
// Page-only: kept across a resync of this session (keptCommandBubbles).
|
|
2700
|
+
if (selected) div.dataset.keptFor = selected;
|
|
2592
2701
|
drawCommandBubble(div, view);
|
|
2593
2702
|
const follow = isNearBottom();
|
|
2594
2703
|
appendToHistory(div);
|
|
@@ -2667,6 +2776,7 @@ const modelPicker = createModelPicker({
|
|
|
2667
2776
|
center: $("#center"),
|
|
2668
2777
|
onPick: () => {
|
|
2669
2778
|
renderNewChatLlmContext();
|
|
2779
|
+
renderNewChatThinking();
|
|
2670
2780
|
promptEl.focus();
|
|
2671
2781
|
},
|
|
2672
2782
|
});
|
|
@@ -2683,6 +2793,7 @@ async function loadModels() {
|
|
|
2683
2793
|
modelPickEl.title = payload.warning ? `The model the new chat starts on · ${payload.warning}` : "The model the new chat starts on";
|
|
2684
2794
|
modelPickEl.hidden = false;
|
|
2685
2795
|
renderNewChatLlmContext();
|
|
2796
|
+
renderNewChatThinking();
|
|
2686
2797
|
}
|
|
2687
2798
|
// The picker's choice for a create request; nothing (the server's default)
|
|
2688
2799
|
// while the list is not in.
|
|
@@ -4,17 +4,22 @@
|
|
|
4
4
|
|
|
5
5
|
// The commands to offer while the composer holds one "/word" being typed
|
|
6
6
|
// (no space or newline yet): the session's slash commands that start with
|
|
7
|
-
// it, by name.
|
|
7
|
+
// it, by name. A UI's own (local) one is offered only when its uis name
|
|
8
|
+
// the web (/cut); the terminals' own (/stats, /exit …) don't run here.
|
|
8
9
|
export function commandMatches(commands, text) {
|
|
9
10
|
const typed = String(text || "");
|
|
10
11
|
if (!/^\/\S*$/.test(typed)) return [];
|
|
11
12
|
const needle = typed.toLowerCase();
|
|
12
13
|
return (Array.isArray(commands) ? commands : [])
|
|
13
|
-
.filter((c) => c &&
|
|
14
|
+
.filter((c) => c && runsInWeb(c) && typeof c.name === "string" && c.name.startsWith("/"))
|
|
14
15
|
.filter((c) => c.name.toLowerCase().startsWith(needle))
|
|
15
16
|
.sort((a, b) => a.name.localeCompare(b.name));
|
|
16
17
|
}
|
|
17
18
|
|
|
19
|
+
function runsInWeb(command) {
|
|
20
|
+
return !command.local || (Array.isArray(command.uis) && command.uis.includes("web"));
|
|
21
|
+
}
|
|
22
|
+
|
|
18
23
|
// What picking +name+ does to the composer: {text, send}. The name
|
|
19
24
|
// completes with a space for its arguments; ⏎ on a name already typed in
|
|
20
25
|
// full sends it as it is (⏎ on "/help" runs /help).
|