samagotchi 0.64.0 → 0.65.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 +41 -1
- data/docs/architecture.md +20 -3
- data/docs/cli.md +25 -1
- data/docs/configuration.md +128 -3
- data/docs/internals/engine.md +15 -3
- data/docs/sessions.md +3 -2
- data/lib/samagotchi/bridge.rb +11 -1
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +2 -2
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/client.rb +4 -2
- data/lib/samagotchi/config.rb +5 -4
- data/lib/samagotchi/engine.rb +39 -7
- data/lib/samagotchi/generation_phase.rb +17 -0
- data/lib/samagotchi/host_config.rb +27 -6
- data/lib/samagotchi/host_registry.rb +34 -11
- data/lib/samagotchi/idle_client.rb +62 -25
- data/lib/samagotchi/idle_recap.rb +5 -2
- data/lib/samagotchi/idle_target.rb +30 -7
- data/lib/samagotchi/llm/adapters.rb +41 -0
- data/lib/samagotchi/llm/anthropic_assembly.rb +210 -0
- data/lib/samagotchi/llm/anthropic_messages.rb +416 -0
- data/lib/samagotchi/llm/anthropic_wire.rb +238 -0
- data/lib/samagotchi/llm/api_key.rb +26 -9
- data/lib/samagotchi/llm/chat_loop.rb +113 -14
- data/lib/samagotchi/llm/dialect.rb +57 -0
- data/lib/samagotchi/llm/errors.rb +81 -12
- data/lib/samagotchi/llm/model_listing.rb +111 -0
- data/lib/samagotchi/llm/model_result.rb +9 -2
- data/lib/samagotchi/llm/openai_chat.rb +46 -93
- data/lib/samagotchi/model_profile.rb +1 -1
- data/lib/samagotchi/reasoning_history.rb +14 -4
- data/lib/samagotchi/recap_setup.rb +2 -1
- data/lib/samagotchi/self_report.rb +18 -28
- data/lib/samagotchi/session.rb +3 -2
- data/lib/samagotchi/session_metrics.rb +6 -11
- data/lib/samagotchi/steer_cut.rb +4 -0
- data/lib/samagotchi/terminal_ui/attached_loop.rb +8 -2
- data/lib/samagotchi/terminal_ui/event_renderer.rb +2 -1
- data/lib/samagotchi/terminal_ui/formatting.rb +4 -3
- data/lib/samagotchi/terminal_ui/status_row.rb +24 -3
- data/lib/samagotchi/turn_note.rb +22 -5
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +2 -1
- data/lib/samagotchi/web/public/activity.js +3 -0
- data/lib/samagotchi/web/public/app.js +90 -13
- data/lib/samagotchi/web/public/delivery_line.js +228 -0
- data/lib/samagotchi/web/public/index.html +29 -0
- data/lib/samagotchi/web/public/live_turn.js +8 -0
- data/lib/samagotchi/web/public/stage_model.js +27 -7
- data/lib/samagotchi/web/public/stage_view.js +32 -10
- data/lib/samagotchi/web/public/stream_handlers.js +8 -2
- data/lib/samagotchi/web/public/turn_events.js +22 -8
- data/lib/samagotchi/web/public/turn_html.js +20 -4
- data/lib/samagotchi/web/public/turn_model.js +16 -9
- data/lib/samagotchi/web/public/turn_view.js +6 -4
- metadata +8 -1
|
@@ -1,8 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "json"
|
|
4
|
-
require "net/http"
|
|
5
|
-
require "uri"
|
|
6
3
|
require_relative "version"
|
|
7
4
|
require_relative "config"
|
|
8
5
|
require_relative "context_window"
|
|
@@ -16,8 +13,7 @@ require_relative "thinking"
|
|
|
16
13
|
require_relative "reasoning_history"
|
|
17
14
|
require_relative "served_model"
|
|
18
15
|
require_relative "host_registry"
|
|
19
|
-
require_relative "llm/
|
|
20
|
-
require_relative "llm/api_key"
|
|
16
|
+
require_relative "llm/adapters"
|
|
21
17
|
require_relative "tools/memory"
|
|
22
18
|
require_relative "hooks/loader"
|
|
23
19
|
require_relative "memory_bundle/provenance"
|
|
@@ -273,7 +269,9 @@ module Samagotchi
|
|
|
273
269
|
entry = HostRegistry.new(env: env).resolve(model).entry
|
|
274
270
|
return "-" unless entry
|
|
275
271
|
|
|
276
|
-
|
|
272
|
+
return "native (raw prompt)" unless entry.chat?
|
|
273
|
+
|
|
274
|
+
entry.dialect ? "chat (api: #{entry.api}, dialect: #{entry.dialect})" : "chat (api: #{entry.api})"
|
|
277
275
|
end
|
|
278
276
|
|
|
279
277
|
NO_PROPS = "reported per turn (the server has no /props)"
|
|
@@ -281,7 +279,7 @@ module Samagotchi
|
|
|
281
279
|
# chi self's one server call: a single-model llama.cpp answers any name
|
|
282
280
|
# with the model it loaded, and its /props names it (model_alias). One
|
|
283
281
|
# GET with the probe's short timeouts; a remote host isn't asked. A local
|
|
284
|
-
# chat host (api: openai) has no /props, so it gets
|
|
282
|
+
# chat host (api: openai or anthropic) has no /props, so it gets its model list
|
|
285
283
|
# with the same timeouts: up/down and the ids it serves.
|
|
286
284
|
def served_model_for(model, env)
|
|
287
285
|
registry = HostRegistry.new(env: env)
|
|
@@ -306,36 +304,28 @@ module Samagotchi
|
|
|
306
304
|
"unknown (#{e.class})"
|
|
307
305
|
end
|
|
308
306
|
|
|
309
|
-
# A local chat host's reachability:
|
|
310
|
-
#
|
|
311
|
-
#
|
|
307
|
+
# A local chat host's reachability: its model list through its own
|
|
308
|
+
# adapter (LLM::Adapters, not the registry's cached one) with the native
|
|
309
|
+
# probe's short timeouts (Client's /props probe), no retry. Up with the
|
|
310
|
+
# ids it serves, down when it doesn't answer.
|
|
312
311
|
def chat_served_model_for(entry, bare, env)
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
read_timeout: Client::CONTEXT_WINDOW_PROBE_READ_TIMEOUT,
|
|
317
|
-
retry_policy: LLM::HTTP::RetryPolicy.none,
|
|
318
|
-
api_key: LLM::ApiKey.for(entry.api_key_env, host: entry.host, env: env))
|
|
319
|
-
response = http.fetch(uri, request, retries: false, check_status: false, log_fields: { purpose: "models" })
|
|
320
|
-
return "down (HTTP #{response.code})" unless response.code.to_s == "200"
|
|
321
|
-
|
|
322
|
-
ids = chat_model_ids(response.body)
|
|
312
|
+
adapter = LLM::Adapters.for(entry, open_timeout: Client::CONTEXT_WINDOW_PROBE_OPEN_TIMEOUT,
|
|
313
|
+
timeout: Client::CONTEXT_WINDOW_PROBE_READ_TIMEOUT, retries: false, env: env)
|
|
314
|
+
ids = adapter.list_models.map(&:id).reject(&:empty?)
|
|
323
315
|
return "up (no models listed)" if ids.empty?
|
|
324
316
|
|
|
325
317
|
note = ids.any? { |id| id.casecmp?(bare.to_s) } ? "" : " (not #{bare})"
|
|
326
318
|
"up: #{ids.join(", ")}#{note}"
|
|
319
|
+
rescue LLM::ProviderError => e
|
|
320
|
+
# A status (an error, or a 3xx such as a proxy's login page) is down;
|
|
321
|
+
# a 200 whose body isn't a model list (ProtocolError) is up.
|
|
322
|
+
return "down (HTTP #{e.status})" if e.status
|
|
323
|
+
|
|
324
|
+
e.is_a?(LLM::ProtocolError) ? "up (no models listed)" : "down (the server didn't answer; is it running?)"
|
|
327
325
|
rescue StandardError
|
|
328
326
|
"down (the server didn't answer; is it running?)"
|
|
329
327
|
end
|
|
330
328
|
|
|
331
|
-
def chat_model_ids(body)
|
|
332
|
-
parsed = JSON.parse(body.to_s)
|
|
333
|
-
data = parsed.is_a?(Hash) ? parsed["data"] : parsed
|
|
334
|
-
Array(data).filter_map { |raw| raw.is_a?(Hash) ? (raw["id"] || raw[:id]).to_s : raw.to_s }.reject(&:empty?)
|
|
335
|
-
rescue JSON::ParserError
|
|
336
|
-
[]
|
|
337
|
-
end
|
|
338
|
-
|
|
339
329
|
# Offline, so no /props probe: where nothing is configured, a native
|
|
340
330
|
# llama.cpp host's chat template decides at runtime.
|
|
341
331
|
def profile_for(model, env)
|
data/lib/samagotchi/session.rb
CHANGED
|
@@ -722,14 +722,15 @@ module Samagotchi
|
|
|
722
722
|
symbolize_empty_answer(message)
|
|
723
723
|
end
|
|
724
724
|
|
|
725
|
-
# An empty-answer note's marker (TurnNote.empty)
|
|
726
|
-
# they were saved.
|
|
725
|
+
# An empty-answer note's marker (TurnNote.empty), its steps and its
|
|
726
|
+
# refusal, as they were saved.
|
|
727
727
|
def symbolize_empty_answer(message)
|
|
728
728
|
marker = message[:empty_answer]
|
|
729
729
|
return message unless marker.is_a?(Hash)
|
|
730
730
|
|
|
731
731
|
marker = marker.transform_keys(&:to_sym)
|
|
732
732
|
marker[:steps] = marker[:steps].map { |step| step.is_a?(Hash) ? step.transform_keys(&:to_sym) : step } if marker[:steps].is_a?(Array)
|
|
733
|
+
marker[:refusal] = marker[:refusal].transform_keys(&:to_sym) if marker[:refusal].is_a?(Hash)
|
|
733
734
|
message.merge(empty_answer: marker)
|
|
734
735
|
end
|
|
735
736
|
|
|
@@ -883,7 +883,7 @@ module Samagotchi
|
|
|
883
883
|
payload = event[:payload]
|
|
884
884
|
payload = {} unless payload.is_a?(Hash)
|
|
885
885
|
|
|
886
|
-
@mutex.synchronize { note_first_chunk(
|
|
886
|
+
@mutex.synchronize { note_first_chunk(event) }
|
|
887
887
|
usage = TokenUsage.from_payload(payload)
|
|
888
888
|
if usage
|
|
889
889
|
@mutex.synchronize { note_server_usage(usage) if @turn }
|
|
@@ -915,21 +915,16 @@ module Samagotchi
|
|
|
915
915
|
turn.gen_had_server = true
|
|
916
916
|
end
|
|
917
917
|
|
|
918
|
-
# The first chunk with content, reasoning or a tool call
|
|
919
|
-
#
|
|
920
|
-
# holds the mutex.
|
|
921
|
-
def note_first_chunk(
|
|
918
|
+
# The first chunk with content, reasoning or a tool call (the loop marks
|
|
919
|
+
# its event tool_call: true, whatever the wire) starts the generation's
|
|
920
|
+
# decode clock (an estimated speed runs from it). Caller holds the mutex.
|
|
921
|
+
def note_first_chunk(event)
|
|
922
922
|
return unless @turn && @turn.gen_open && @turn.gen_first_chunk_at.nil?
|
|
923
|
-
return unless !event[:content].to_s.empty? ||
|
|
923
|
+
return unless !event[:content].to_s.empty? || event[:tool_call]
|
|
924
924
|
|
|
925
925
|
@turn.gen_first_chunk_at = monotonic_time
|
|
926
926
|
end
|
|
927
927
|
|
|
928
|
-
def tool_call_delta?(payload)
|
|
929
|
-
choice = payload["choices"].is_a?(Array) ? payload["choices"].first : nil
|
|
930
|
-
choice.is_a?(Hash) && choice["delta"].is_a?(Hash) && !Array(choice["delta"]["tool_calls"]).empty?
|
|
931
|
-
end
|
|
932
|
-
|
|
933
928
|
def event_content_chars(payload, event)
|
|
934
929
|
len = payload_content(payload, event).length
|
|
935
930
|
len.positive? ? len : nil
|
data/lib/samagotchi/steer_cut.rb
CHANGED
|
@@ -82,6 +82,10 @@ module Samagotchi
|
|
|
82
82
|
@waiting.epoch
|
|
83
83
|
end
|
|
84
84
|
|
|
85
|
+
# The running generation's phase (GenerationPhase::Snapshot), nil with
|
|
86
|
+
# none running.
|
|
87
|
+
def generation_snapshot = @phase.snapshot
|
|
88
|
+
|
|
85
89
|
# The turn's stream: the generation's phase follows it, and a thinking
|
|
86
90
|
# chunk rechecks a waiting message. +progress+: a chunk's lanes
|
|
87
91
|
# (.lanes), read from it when not given.
|
|
@@ -260,13 +260,17 @@ module Samagotchi
|
|
|
260
260
|
take_session_state(event[:session_state_snapshot] || {})
|
|
261
261
|
return render_snapshot(event[:snapshot] || {}, reset: event[:type] == :reset)
|
|
262
262
|
when :turn_enqueued then show_enqueued(event)
|
|
263
|
-
when :turn_started
|
|
263
|
+
when :turn_started
|
|
264
|
+
@status.take_event(event)
|
|
265
|
+
start_turn(event)
|
|
264
266
|
when :turn_completed
|
|
267
|
+
@status.take_event(event)
|
|
265
268
|
complete_turn(event)
|
|
266
269
|
refresh_children
|
|
267
270
|
own_turn_ended(event)
|
|
268
271
|
# The renderer says how it ended (the REPL's words too).
|
|
269
272
|
when :turn_canceled, :turn_failed
|
|
273
|
+
@status.take_event(event)
|
|
270
274
|
@renderer.call(event)
|
|
271
275
|
end_turn
|
|
272
276
|
refresh_children
|
|
@@ -1239,7 +1243,7 @@ module Samagotchi
|
|
|
1239
1243
|
# A turn that ended with no answer: its notice, as live.
|
|
1240
1244
|
empty = messages.drop(from + 1).filter_map { |m| TurnNote.empty_answer(m) }.last
|
|
1241
1245
|
if empty
|
|
1242
|
-
@screen.commit(format_empty_answer_line(empty[:retries] || empty["retries"]))
|
|
1246
|
+
@screen.commit(format_empty_answer_line(empty[:retries] || empty["retries"], empty[:refusal] || empty["refusal"]))
|
|
1243
1247
|
elsif failed
|
|
1244
1248
|
# A failed turn whose steps stayed: the line the live view left,
|
|
1245
1249
|
# and no step text as if it were the answer (the web keeps a
|
|
@@ -1379,6 +1383,8 @@ module Samagotchi
|
|
|
1379
1383
|
def render_current_turn(turn)
|
|
1380
1384
|
@running = !turn.nil?
|
|
1381
1385
|
@joined_mid_turn = @running
|
|
1386
|
+
# A turn running at the join: the status row's steer hint.
|
|
1387
|
+
@status.update(running: @running)
|
|
1382
1388
|
return unless turn
|
|
1383
1389
|
|
|
1384
1390
|
continues = turn[:prompt].nil?
|
|
@@ -179,7 +179,8 @@ module Samagotchi
|
|
|
179
179
|
end
|
|
180
180
|
# No answer: chi's muted notice, never an empty answer line.
|
|
181
181
|
if summary[:empty_answer]
|
|
182
|
-
|
|
182
|
+
empty = summary[:empty_answer]
|
|
183
|
+
@view.print_line(@view.format_empty_answer_line(empty[:retries], empty[:refusal]))
|
|
183
184
|
else
|
|
184
185
|
@view.print_line(summary[:output])
|
|
185
186
|
end
|
|
@@ -135,9 +135,10 @@ module Samagotchi
|
|
|
135
135
|
end
|
|
136
136
|
|
|
137
137
|
# "no answer: the model returned nothing (after 1 retry)", dim like the
|
|
138
|
-
# retry row: a turn that ended with no answer
|
|
139
|
-
|
|
140
|
-
|
|
138
|
+
# retry row: a turn that ended with no answer, or "no answer: refused
|
|
139
|
+
# by the provider (cyber)" for a +refusal+ (TurnNote.empty_answer_line).
|
|
140
|
+
def format_empty_answer_line(retries, refusal = nil)
|
|
141
|
+
paint(TurnNote.empty_answer_line(retries, refusal: refusal), 90)
|
|
141
142
|
end
|
|
142
143
|
|
|
143
144
|
# "↻ retrying (503) in 4.0s, 2/5": the live status while the provider
|
|
@@ -28,7 +28,15 @@ module Samagotchi
|
|
|
28
28
|
# preloaded its --memory list (shown until a turn
|
|
29
29
|
# records them as used)
|
|
30
30
|
# muted its --mute list
|
|
31
|
-
|
|
31
|
+
# running a turn runs: the row trails STEER_HINT
|
|
32
|
+
FIELDS = %i[model default_model served parent_id children context used_memories preloaded muted running].freeze
|
|
33
|
+
|
|
34
|
+
# The tail while a turn runs: where a typed line goes (the next step)
|
|
35
|
+
# and the two other choices. Dropped whole first when the row is cut.
|
|
36
|
+
STEER_HINT = " · typing goes in at the next step (/cut, /queue)"
|
|
37
|
+
|
|
38
|
+
# The turn events that end a turn (the hint goes).
|
|
39
|
+
TURN_END_EVENTS = %i[turn_completed turn_canceled turn_failed].freeze
|
|
32
40
|
|
|
33
41
|
# @param surface [Surface] with #columns
|
|
34
42
|
def initialize(surface)
|
|
@@ -77,13 +85,16 @@ module Samagotchi
|
|
|
77
85
|
|
|
78
86
|
# Take what a turn event says about the row (the Engine's events in
|
|
79
87
|
# the REPL, the Bridge's in attached mode): ctx, the memories used,
|
|
80
|
-
# the model the server served
|
|
88
|
+
# the model the server served, whether a turn runs (the steer
|
|
89
|
+
# hint). Other events change nothing.
|
|
81
90
|
def take_event(event)
|
|
82
91
|
case event[:type]
|
|
83
92
|
when :context_status then update(context: { est_pct: event.dig(:usage, :estimated_pct), bucket: event[:bucket] })
|
|
84
93
|
when :used_memories_updated then update(used_memories: Array(event[:used_memory_names]))
|
|
85
94
|
when :generation_completed
|
|
86
95
|
update(served: [event[:served_model], event[:requested_model], event[:served_expected_by]]) if event[:served_model]
|
|
96
|
+
when :turn_started then update(running: true)
|
|
97
|
+
when *TURN_END_EVENTS then update(running: false)
|
|
87
98
|
end
|
|
88
99
|
end
|
|
89
100
|
|
|
@@ -119,11 +130,21 @@ module Samagotchi
|
|
|
119
130
|
status_context_text(estimate: @values[:context]),
|
|
120
131
|
status_memory_text(Array(@values[:used_memories]) | Array(@values[:preloaded]), MEMORY_LIMIT),
|
|
121
132
|
status_memory_text(@values[:muted], MEMORY_LIMIT, label: "muted")].reject(&:empty?)
|
|
122
|
-
status_rows(segments, width)
|
|
133
|
+
status_rows(with_steer_hint(segments, width), width)
|
|
123
134
|
end
|
|
124
135
|
|
|
125
136
|
private
|
|
126
137
|
|
|
138
|
+
# +segments+ with STEER_HINT after the last while a turn runs, when
|
|
139
|
+
# the whole row fits +width+ with it: on a narrow terminal the hint
|
|
140
|
+
# goes first, whole, before anything else is cut.
|
|
141
|
+
def with_steer_hint(segments, width)
|
|
142
|
+
return segments unless @values[:running] && segments.any?
|
|
143
|
+
return segments if "status> #{segments.join(" | ")}#{STEER_HINT}".length > width
|
|
144
|
+
|
|
145
|
+
segments[0..-2] + ["#{segments.last}#{STEER_HINT}"]
|
|
146
|
+
end
|
|
147
|
+
|
|
127
148
|
# `⑂ 2 running · 1 waiting`, the counts there are; "" with neither (no
|
|
128
149
|
# children, or all ended). Not "not reported": a REPL parent is never
|
|
129
150
|
# given its children's replies (no wakes there), and a worker moves
|
data/lib/samagotchi/turn_note.rb
CHANGED
|
@@ -136,13 +136,27 @@ module Samagotchi
|
|
|
136
136
|
# text cut to its last STEP_CHARS. Never
|
|
137
137
|
# sent: the payload builders send a system message's content, and the
|
|
138
138
|
# copies for hooks, plugins and the recap drop it (AnswerDisplay.strip).
|
|
139
|
-
|
|
139
|
+
# +refusal+: the provider refused the answer (ModelResult#refusal:
|
|
140
|
+
# {category:} or {}), kept on the marker and said in the note.
|
|
141
|
+
def empty(retries: 0, steps: [], refusal: nil)
|
|
140
142
|
marker = { retries: retries.to_i }
|
|
141
143
|
marker[:steps] = steps.map { |step| capped_step(step) } unless steps.empty?
|
|
142
|
-
|
|
144
|
+
marker[:refusal] = refusal if refusal
|
|
145
|
+
what = if refusal
|
|
146
|
+
"the previous turn's answer was refused by the provider#{refusal_category(refusal)}"
|
|
147
|
+
else
|
|
148
|
+
"the previous turn ended with no visible answer (thinking only, or nothing)"
|
|
149
|
+
end
|
|
150
|
+
message("#{what}. The user's last message is still unanswered.")
|
|
143
151
|
.merge(EMPTY_ANSWER => marker)
|
|
144
152
|
end
|
|
145
153
|
|
|
154
|
+
# " (cyber)" for a refusal that names its category (either key type), else "".
|
|
155
|
+
def refusal_category(refusal)
|
|
156
|
+
category = (refusal[:category] || refusal["category"] if refusal.is_a?(Hash)).to_s.strip
|
|
157
|
+
category.empty? ? "" : " (#{one_line(category)})"
|
|
158
|
+
end
|
|
159
|
+
|
|
146
160
|
def capped_step(step)
|
|
147
161
|
step.slice(:role, :content, :thinking).to_h do |key, value|
|
|
148
162
|
[key, key == :role || !value.is_a?(String) ? value : tail_of(value)]
|
|
@@ -167,9 +181,12 @@ module Samagotchi
|
|
|
167
181
|
end
|
|
168
182
|
|
|
169
183
|
# The notice every UI shows for a turn that ended with no answer:
|
|
170
|
-
# "no answer: the model returned nothing (after 1 retry)"
|
|
171
|
-
#
|
|
172
|
-
|
|
184
|
+
# "no answer: the model returned nothing (after 1 retry)", or for a
|
|
185
|
+
# +refusal+ (the marker's) "no answer: refused by the provider (cyber)"
|
|
186
|
+
# (turn_events.js emptyAnswerLine words it the same).
|
|
187
|
+
def empty_answer_line(retries, refusal: nil)
|
|
188
|
+
return "no answer: refused by the provider#{refusal_category(refusal)}" if refusal
|
|
189
|
+
|
|
173
190
|
count = retries.to_i
|
|
174
191
|
after = if count.positive?
|
|
175
192
|
" (after #{count} #{count == 1 ? "retry" : "retries"})"
|
data/lib/samagotchi/version.rb
CHANGED
data/lib/samagotchi/web/app.rb
CHANGED
|
@@ -1944,7 +1944,8 @@ module Samagotchi
|
|
|
1944
1944
|
did = step.is_a?(Hash) ? MessageParts.for_message(step, [], cwd: cwd) : nil
|
|
1945
1945
|
{ role: "assistant", content: "", parts: did } if did
|
|
1946
1946
|
end
|
|
1947
|
-
|
|
1947
|
+
refusal = marker[:refusal] || marker["refusal"]
|
|
1948
|
+
items << { role: "empty_answer", content: "", retries: (marker[:retries] || marker["retries"]).to_i, refusal: refusal }.compact
|
|
1948
1949
|
end
|
|
1949
1950
|
|
|
1950
1951
|
# The last turn failed and its prompt went back to the user: the
|
|
@@ -103,8 +103,11 @@ export function addStarted(model, event = {}, now = null) {
|
|
|
103
103
|
return { row, created: true };
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
+
// A replayed running call a snapshot timed (its elapsed_ms so far) started
|
|
107
|
+
// that long before +now+; any other replayed start is no clock.
|
|
106
108
|
function markStart(row, event, now) {
|
|
107
109
|
if (now !== null && !event.replayed) row.startedAt = now;
|
|
110
|
+
else if (now !== null && event.elapsed_ms != null) row.startedAt = now - Number(event.elapsed_ms);
|
|
108
111
|
else delete row.startedAt;
|
|
109
112
|
}
|
|
110
113
|
|
|
@@ -76,6 +76,10 @@ import { createComposerCommands, routeComposerLine, routeSessionLine } from "./c
|
|
|
76
76
|
import { composerAfterAck, dropEarlyRestores, restoreOnAck } from "./restore.js";
|
|
77
77
|
import { createStreamHandlers } from "./stream_handlers.js";
|
|
78
78
|
import { createListsView } from "./lists_view.js";
|
|
79
|
+
import { createDeliveryControl, QUEUE_NEEDS_WORKER } from "./delivery_line.js";
|
|
80
|
+
import { liveSlots } from "./stage_model.js";
|
|
81
|
+
import { currentGen } from "./turn_model.js";
|
|
82
|
+
import { toolName } from "./activity.js";
|
|
79
83
|
|
|
80
84
|
const $ = (s) => document.querySelector(s);
|
|
81
85
|
const topStripEl = $("#topStrip");
|
|
@@ -251,6 +255,28 @@ const liveTurn = createLiveTurn({
|
|
|
251
255
|
onTick: () => updateInfoBar(),
|
|
252
256
|
});
|
|
253
257
|
|
|
258
|
+
// The composer's delivery line while the open session's turn runs
|
|
259
|
+
// (delivery_line.js): where a message typed now goes in, and the cut and
|
|
260
|
+
// queue links.
|
|
261
|
+
const deliveryControl = createDeliveryControl({
|
|
262
|
+
el: $("#deliveryLine"),
|
|
263
|
+
read: () => {
|
|
264
|
+
const turn = turnDom.current?.().turn || null;
|
|
265
|
+
const running = turn ? turn.gens.flatMap((g) => g.tools).reverse().find((r) => r.status === "running") : null;
|
|
266
|
+
return {
|
|
267
|
+
phase: turnDom.phase?.() || liveSlots(turn).phase,
|
|
268
|
+
gen: turn ? currentGen(turn) : null,
|
|
269
|
+
tool: running ? { name: toolName(running), startedAt: running.startedAt ?? null } : null,
|
|
270
|
+
cutAfter: liveTurn.cutAfter,
|
|
271
|
+
serverGen: liveTurn.serverGen,
|
|
272
|
+
canQueue: current.workerFeatures.includes("delivery"),
|
|
273
|
+
model: current.model,
|
|
274
|
+
};
|
|
275
|
+
},
|
|
276
|
+
composerEmpty: () => !promptEl.value.trim() && !chips.length,
|
|
277
|
+
send: (delivery) => handleSendTurn({ delivery }),
|
|
278
|
+
});
|
|
279
|
+
|
|
254
280
|
// This tab's id in the session's events: tells its own prompts from the
|
|
255
281
|
// ones other clients (an attached TUI, another tab, reminders) sent.
|
|
256
282
|
const clientId = newClientId();
|
|
@@ -552,8 +578,9 @@ function updateComposerMode() {
|
|
|
552
578
|
}
|
|
553
579
|
const running = liveTurn.running && !!selected;
|
|
554
580
|
cancelBtn.style.display = running ? "" : "none";
|
|
581
|
+
syncDeliveryLine();
|
|
555
582
|
if (running) {
|
|
556
|
-
setPlaceholder("Running… a message
|
|
583
|
+
setPlaceholder("Running… a message goes in at the next step · Enter to send · Alt+Enter queues", "Steer the turn…");
|
|
557
584
|
composeHintEl.textContent = SEND_HINT;
|
|
558
585
|
actionBtn.textContent = "Send";
|
|
559
586
|
emptyEl.classList.add("hidden");
|
|
@@ -574,7 +601,8 @@ function updateComposerMode() {
|
|
|
574
601
|
actionBtn.disabled = true;
|
|
575
602
|
emptyEl.classList.add("hidden");
|
|
576
603
|
} else {
|
|
577
|
-
|
|
604
|
+
// The keys are in the delivery line's row above.
|
|
605
|
+
setPlaceholder("Message chi…", "Message chi…");
|
|
578
606
|
composeHintEl.textContent = SEND_HINT;
|
|
579
607
|
actionBtn.textContent = "Send";
|
|
580
608
|
actionBtn.disabled = false;
|
|
@@ -582,6 +610,14 @@ function updateComposerMode() {
|
|
|
582
610
|
}
|
|
583
611
|
}
|
|
584
612
|
|
|
613
|
+
// The delivery line's row in a session (delivery_line.js): the key hint
|
|
614
|
+
// while idle, where a message goes in while the turn runs.
|
|
615
|
+
function syncDeliveryLine() {
|
|
616
|
+
// A terminal owns the session: the composer is off, so is its row.
|
|
617
|
+
if (!selected || current.owner === "tui") deliveryControl.show("off");
|
|
618
|
+
else deliveryControl.show(liveTurn.running && current.owner !== "tui" ? "running" : "idle");
|
|
619
|
+
}
|
|
620
|
+
|
|
585
621
|
function setTurnRunning(running) {
|
|
586
622
|
liveTurn.running = !!running;
|
|
587
623
|
updateComposerMode();
|
|
@@ -790,6 +826,9 @@ function renderSessionView(data, { kept = [] } = {}) {
|
|
|
790
826
|
});
|
|
791
827
|
// Marked replayed: a call's duration isn't timed from the replay's clock.
|
|
792
828
|
replay.forEach((event) => streamHandlers[event.type]?.({ ...event, replayed: true }));
|
|
829
|
+
// A join mid-generation: the worker's word on it clocks the delivery line.
|
|
830
|
+
const gen = data.current_turn?.generation;
|
|
831
|
+
liveTurn.serverGen = gen ? { ...gen, at: Date.now() } : null;
|
|
793
832
|
if (!data.current_turn) {
|
|
794
833
|
setTurnRunning(current.status === "running");
|
|
795
834
|
if (liveTurn.running && current.timing.activeTurn?.started_at) liveTurn.timing.start(current.timing.activeTurn.started_at, activeTurnId);
|
|
@@ -997,11 +1036,13 @@ function endTurnView(kind, endText = null, { emptyAnswer = null, stopped = false
|
|
|
997
1036
|
// prompts are echoed at send (data-own) and tagged by their turn_enqueued.
|
|
998
1037
|
|
|
999
1038
|
// The bubble a reload draws (turn_html.js), with this page's own fields.
|
|
1000
|
-
|
|
1039
|
+
// +delivery+: how it went into a running turn (next_step, cut, queue): its
|
|
1040
|
+
// queued badge says what it waits for.
|
|
1041
|
+
function makeUserBubble(prompt, { state = null, label = null, enqueuedId = null, own = false, images = null, delivery = null } = {}) {
|
|
1001
1042
|
const hint = historyEl.querySelector(".hint");
|
|
1002
1043
|
if (hint) hint.remove();
|
|
1003
1044
|
const div = elementOf(userBubbleTemplate({ content: prompt }, {
|
|
1004
|
-
label, state, own, enqueuedId, key: normalize(prompt), thumbs: thumbsTemplate(selected, images),
|
|
1045
|
+
label, state, own, enqueuedId, delivery, key: normalize(prompt), thumbs: thumbsTemplate(selected, images),
|
|
1005
1046
|
}));
|
|
1006
1047
|
const follow = isNearBottom();
|
|
1007
1048
|
appendToHistory(div);
|
|
@@ -1018,7 +1059,17 @@ function setBubbleState(bubble, state) {
|
|
|
1018
1059
|
}
|
|
1019
1060
|
bubble.classList.add(state);
|
|
1020
1061
|
bubble.dataset.userState = state;
|
|
1021
|
-
bubble.append(elementOf(stateBadgeTemplate(state)));
|
|
1062
|
+
bubble.append(elementOf(stateBadgeTemplate(state, bubble.dataset.delivery || null)));
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
// A bubble's delivery changed (the ack's cut: off goes in at the next
|
|
1066
|
+
// step; a queue to a turn that ended meanwhile is just the next turn): its
|
|
1067
|
+
// badge follows.
|
|
1068
|
+
function setBubbleDelivery(bubble, delivery) {
|
|
1069
|
+
if ((bubble.dataset.delivery || null) === (delivery || null)) return;
|
|
1070
|
+
if (delivery) bubble.dataset.delivery = delivery;
|
|
1071
|
+
else delete bubble.dataset.delivery;
|
|
1072
|
+
if (bubble.dataset.userState) setBubbleState(bubble, bubble.dataset.userState);
|
|
1022
1073
|
}
|
|
1023
1074
|
|
|
1024
1075
|
function bubbleById(enqueuedId) {
|
|
@@ -1047,15 +1098,18 @@ function applyPromptOps(event) {
|
|
|
1047
1098
|
if (op.op === "report") {
|
|
1048
1099
|
liveTurn.unmatchedMerge = "report";
|
|
1049
1100
|
} else if (op.op === "add") {
|
|
1050
|
-
const bubble = makeUserBubble(op.prompt, { state: op.state, label: op.label, enqueuedId: op.enqueuedId, images: op.images });
|
|
1101
|
+
const bubble = makeUserBubble(op.prompt, { state: op.state, label: op.label, enqueuedId: op.enqueuedId, images: op.images, delivery: op.delivery });
|
|
1051
1102
|
if (op.state === "started") started = bubble;
|
|
1052
1103
|
if (event.type === "pending_input_merged") liveTurn.wakeStart = false;
|
|
1053
1104
|
} else if (op.op === "tag") {
|
|
1054
1105
|
if (bubbleById(op.enqueuedId)) continue;
|
|
1055
1106
|
const echo = untaggedOwnBubble(op.prompt);
|
|
1056
|
-
if (echo)
|
|
1107
|
+
if (echo) {
|
|
1108
|
+
echo.dataset.enqueuedId = op.enqueuedId;
|
|
1109
|
+
if (op.delivery) setBubbleDelivery(echo, op.delivery);
|
|
1110
|
+
}
|
|
1057
1111
|
// Re-rendered from a snapshot: the echo is gone, the prompt still queued.
|
|
1058
|
-
else makeUserBubble(op.prompt, { state: "queued", own: true, enqueuedId: op.enqueuedId, images: op.images });
|
|
1112
|
+
else makeUserBubble(op.prompt, { state: "queued", own: true, enqueuedId: op.enqueuedId, images: op.images, delivery: op.delivery });
|
|
1059
1113
|
} else if (op.op === "start") {
|
|
1060
1114
|
const bubble = bubbleById(op.enqueuedId) || untaggedOwnBubble(op.prompt);
|
|
1061
1115
|
// A resync (a new worker woken by this page's send) re-rendered the
|
|
@@ -1683,10 +1737,14 @@ function handleCreate() {
|
|
|
1683
1737
|
return handleSendTurn();
|
|
1684
1738
|
}
|
|
1685
1739
|
|
|
1686
|
-
|
|
1740
|
+
// +delivery+: the composer's choice for a running turn (Alt+Enter: queue;
|
|
1741
|
+
// a delivery-line link: cut or queue); else the armed one; a typed /cut or
|
|
1742
|
+
// /queue wins over both.
|
|
1743
|
+
async function handleSendTurn({ delivery: chosen = null } = {}) {
|
|
1687
1744
|
if (current.owner === "tui") return;
|
|
1688
1745
|
let prompt = turnText(promptEl.value, chips);
|
|
1689
1746
|
if (!prompt) return;
|
|
1747
|
+
const armed = deliveryControl.take();
|
|
1690
1748
|
// An image-only send's text is its placeholders: not for ↑.
|
|
1691
1749
|
const typed = promptEl.value.trim() !== "";
|
|
1692
1750
|
// The composer holds the line as typed until the ack (composerAfterAck).
|
|
@@ -1727,14 +1785,18 @@ async function handleSendTurn() {
|
|
|
1727
1785
|
}
|
|
1728
1786
|
if (route.command) return composerCommands.send(route.command);
|
|
1729
1787
|
prompt = route.send;
|
|
1730
|
-
const
|
|
1788
|
+
const running = liveTurn.running && !!selected;
|
|
1789
|
+
const delivery = route.delivery || (running ? chosen || armed : null);
|
|
1731
1790
|
const hint = historyEl.querySelector(".hint");
|
|
1732
1791
|
if (hint) hint.remove();
|
|
1733
1792
|
// Sending moves a finished turn out of the stage at once (else the echo
|
|
1734
1793
|
// would land among its rows); into a running one it is a queued row there.
|
|
1735
1794
|
turnDom.handOff?.({ immediate: true });
|
|
1736
1795
|
const sending = chips.slice();
|
|
1737
|
-
|
|
1796
|
+
// Into a running turn the echo says what it waits for (its delivery); a
|
|
1797
|
+
// line with images always waits for the turn's end (the worker queues it).
|
|
1798
|
+
const shownDelivery = running ? (sending.length ? "queue" : delivery || "next_step") : null;
|
|
1799
|
+
const echo = makeUserBubble(prompt, { state: "queued", own: true, images: sending, delivery: shownDelivery });
|
|
1738
1800
|
if (!turnDom.holds?.()) historyEl.scrollTop = historyEl.scrollHeight;
|
|
1739
1801
|
if (!current.firstPreview || current.firstPreview==="—") {
|
|
1740
1802
|
current.firstPreview = firstPreview(prompt);
|
|
@@ -1756,6 +1818,11 @@ async function handleSendTurn() {
|
|
|
1756
1818
|
return;
|
|
1757
1819
|
}
|
|
1758
1820
|
if (ack?.enqueued_id) sentIds.add(ack.enqueued_id);
|
|
1821
|
+
// A cut the worker didn't take (no thinking-only step: cut off) goes in
|
|
1822
|
+
// at the next step; a queue whose turn ended meanwhile is the next turn.
|
|
1823
|
+
if (ack?.cut === "off" || (ack?.delivery === "next_step" && echo.dataset.delivery === "queue")) {
|
|
1824
|
+
setBubbleDelivery(echo, ack.cut === "off" ? "next_step" : null);
|
|
1825
|
+
}
|
|
1759
1826
|
refreshHistory();
|
|
1760
1827
|
// A turn that failed fast was restored before this ack: its prompt
|
|
1761
1828
|
// (and images) go back into the composer, without replacing text typed
|
|
@@ -2118,10 +2185,20 @@ function historyKey(e) {
|
|
|
2118
2185
|
promptEl.addEventListener("keydown", (e) => {
|
|
2119
2186
|
if (commandList.handleKey(e)) return;
|
|
2120
2187
|
if (historyKey(e)) return;
|
|
2121
|
-
|
|
2188
|
+
// Esc lets go of a choice armed on the delivery line.
|
|
2189
|
+
if (e.key === "Escape" && deliveryControl.disarm()) {
|
|
2190
|
+
e.preventDefault();
|
|
2191
|
+
return;
|
|
2192
|
+
}
|
|
2193
|
+
// An IME's Enter picks the composition, it doesn't send.
|
|
2194
|
+
if (e.key === "Enter" && !e.shiftKey && !e.isComposing) {
|
|
2122
2195
|
e.preventDefault();
|
|
2123
2196
|
if (!selected) handleCreate();
|
|
2124
|
-
|
|
2197
|
+
// Alt/Option+Enter: queued for after the running turn (D6), only to a
|
|
2198
|
+
// worker that queues (else it would steer): the text stays, a note says why.
|
|
2199
|
+
else if (e.altKey && liveTurn.running && !current.workerFeatures.includes("delivery")) {
|
|
2200
|
+
addCommandBubble({ label: null, line: promptEl.value.trim(), text: QUEUE_NEEDS_WORKER, failed: true });
|
|
2201
|
+
} else handleSendTurn(e.altKey ? { delivery: "queue" } : {});
|
|
2125
2202
|
}
|
|
2126
2203
|
});
|
|
2127
2204
|
$("#infoStopBtn").addEventListener("click", async () => {
|