samagotchi 0.19.0 → 0.21.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 +130 -1
- data/docs/architecture.md +8 -1
- data/docs/cli.md +16 -4
- data/docs/configuration.md +4 -1
- data/docs/guardrails.md +92 -14
- data/docs/plugins.md +27 -8
- data/docs/sessions.md +3 -3
- data/lib/samagotchi/bootstrap/config_writer.rb +14 -3
- data/lib/samagotchi/bootstrap_command.rb +26 -5
- data/lib/samagotchi/bridge/sse_writer.rb +32 -0
- data/lib/samagotchi/bridge/turn_accumulator.rb +10 -3
- data/lib/samagotchi/bridge.rb +16 -4
- data/lib/samagotchi/bridge_client.rb +33 -9
- data/lib/samagotchi/bundle_command.rb +28 -4
- data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
- data/lib/samagotchi/bundles/check-in/plugin.rb +14 -5
- data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +24 -5
- data/lib/samagotchi/bundles/guardrails/manifest.yml +5 -4
- data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
- data/lib/samagotchi/bundles/loop-guard/plugin.rb +61 -5
- data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
- data/lib/samagotchi/bundles/mcp/plugin.rb +18 -4
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
- data/lib/samagotchi/commands/registry.rb +4 -3
- data/lib/samagotchi/config.rb +3 -0
- data/lib/samagotchi/context_usage.rb +3 -3
- data/lib/samagotchi/engine.rb +31 -4
- data/lib/samagotchi/events.rb +1 -1
- data/lib/samagotchi/guardrail_wiring.rb +2 -1
- data/lib/samagotchi/guardrails/approval.rb +4 -3
- data/lib/samagotchi/guardrails/approvals.rb +11 -5
- data/lib/samagotchi/guardrails/context.rb +16 -9
- data/lib/samagotchi/guardrails/parent_approvals.rb +10 -4
- data/lib/samagotchi/guardrails/read_only_shell.rb +322 -0
- data/lib/samagotchi/guardrails/rm_targets.rb +86 -0
- data/lib/samagotchi/guardrails/rules.rb +69 -5
- data/lib/samagotchi/guardrails/shell_git_dirs.rb +5 -137
- data/lib/samagotchi/guardrails/shell_lex.rb +164 -0
- data/lib/samagotchi/guardrails/shell_paths.rb +145 -0
- data/lib/samagotchi/guardrails/targets.rb +65 -4
- data/lib/samagotchi/guardrails.rb +2 -0
- data/lib/samagotchi/hooks/registry.rb +6 -2
- data/lib/samagotchi/idle_recap.rb +7 -2
- data/lib/samagotchi/llm/chat_loop.rb +1 -1
- data/lib/samagotchi/llm/openai_chat.rb +19 -5
- data/lib/samagotchi/llm/usage.rb +1 -1
- data/lib/samagotchi/log_subscriber.rb +4 -2
- data/lib/samagotchi/memory_bundle/builder.rb +1 -5
- data/lib/samagotchi/memory_bundle/installer.rb +46 -13
- data/lib/samagotchi/memory_bundle/shipped_update.rb +3 -1
- data/lib/samagotchi/memory_bundle/status.rb +12 -4
- data/lib/samagotchi/memory_bundle/uninstaller.rb +1 -5
- data/lib/samagotchi/memory_paths.rb +6 -0
- data/lib/samagotchi/self_report.rb +38 -2
- data/lib/samagotchi/session.rb +35 -6
- data/lib/samagotchi/session_commands.rb +13 -2
- data/lib/samagotchi/session_manager.rb +26 -9
- data/lib/samagotchi/session_metrics.rb +230 -23
- data/lib/samagotchi/steer.rb +1 -1
- data/lib/samagotchi/terminal_ui/attached_loop.rb +18 -9
- data/lib/samagotchi/terminal_ui/event_renderer.rb +1 -1
- data/lib/samagotchi/terminal_ui/formatting.rb +59 -3
- data/lib/samagotchi/terminal_ui/live_region.rb +41 -2
- data/lib/samagotchi/terminal_ui/reline_seam.rb +10 -0
- data/lib/samagotchi/token_usage.rb +57 -41
- data/lib/samagotchi/tool_runner.rb +21 -2
- data/lib/samagotchi/tools/execute.rb +9 -8
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +19 -0
- data/lib/samagotchi/web/public/app.js +68 -22
- data/lib/samagotchi/web/public/card.js +34 -2
- data/lib/samagotchi/web/public/ctx.js +59 -0
- data/lib/samagotchi/web/public/format.js +11 -0
- data/lib/samagotchi/web/public/index.html +33 -2
- data/lib/samagotchi/web/public/model_pick.js +6 -2
- data/lib/samagotchi/web/public/model_picker.js +2 -1
- data/lib/samagotchi/web/public/notifications.js +8 -3
- data/lib/samagotchi/web/public/notify.js +49 -7
- data/lib/samagotchi/web/public/question_card.js +2 -1
- data/lib/samagotchi/web/public/timing.js +11 -5
- data/lib/samagotchi/web/public/turn_events.js +17 -1
- data/lib/samagotchi/web/session_summary.rb +6 -2
- metadata +5 -1
|
@@ -66,6 +66,12 @@ module Samagotchi
|
|
|
66
66
|
# @api private
|
|
67
67
|
attr_writer :reading
|
|
68
68
|
|
|
69
|
+
# Monotonic time a read's cursor query went out (#reset) when the read
|
|
70
|
+
# was stopped before Reline took the reply: the reply is still on its
|
|
71
|
+
# way, and LiveRegion.close waits for it. nil with none.
|
|
72
|
+
# @return [Float, nil]
|
|
73
|
+
attr_accessor :unanswered_query_at
|
|
74
|
+
|
|
69
75
|
# @return [Boolean] this Reline has every method the seam relies on
|
|
70
76
|
def supported?
|
|
71
77
|
defined?(Reline::LineEditor::RenderedScreen) &&
|
|
@@ -107,7 +113,11 @@ module Samagotchi
|
|
|
107
113
|
end
|
|
108
114
|
|
|
109
115
|
def reset(...)
|
|
116
|
+
# Reline's reset asks the terminal for the cursor row and waits for
|
|
117
|
+
# the reply: a Stop raised meanwhile leaves this set.
|
|
118
|
+
RelineSeam.unanswered_query_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
110
119
|
super
|
|
120
|
+
RelineSeam.unanswered_query_at = nil
|
|
111
121
|
RelineSeam.reading = true
|
|
112
122
|
@rendered_screen.base_y = 0 if RelineSeam.screen
|
|
113
123
|
end
|
|
@@ -1,61 +1,58 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Samagotchi
|
|
4
|
-
# TokenUsage
|
|
5
|
-
#
|
|
4
|
+
# TokenUsage is the server-reported token accounting of one model API stream
|
|
5
|
+
# payload, read transport-agnostically.
|
|
6
6
|
#
|
|
7
7
|
# llama.cpp's /completion stream emits per-chunk `timings.prompt_n` /
|
|
8
8
|
# `timings.predicted_n` (cumulative) and a final top-level
|
|
9
|
-
# `tokens_evaluated` / `tokens_predicted`.
|
|
10
|
-
#
|
|
9
|
+
# `tokens_evaluated` / `tokens_predicted`. OpenAI-compatible servers emit a
|
|
10
|
+
# `usage` object with `prompt_tokens` / `completion_tokens`.
|
|
11
11
|
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
|
|
12
|
+
# The last chunk can say more: llama.cpp adds a `timings` block with exact
|
|
13
|
+
# speeds and `cache_n` (its native `tokens_cached` counts the new tokens too,
|
|
14
|
+
# so it is not read); OpenRouter adds `usage.cost` (USD) and the
|
|
15
|
+
# `prompt_tokens_details.cached_tokens` / `completion_tokens_details.reasoning_tokens`
|
|
16
|
+
# breakdowns. Those fields are nil when a payload doesn't carry them.
|
|
17
|
+
#
|
|
18
|
+
# from_payload returns nil when no server token counts are present, so a
|
|
19
|
+
# caller can fall back to a chars/4 heuristic. The reported values are
|
|
20
|
+
# cumulative per request, so callers should take the running MAX across
|
|
21
|
+
# chunks (see SessionMetrics).
|
|
22
|
+
class TokenUsage < Data.define(:prompt_tokens, :completion_tokens, :cached_tokens, :reasoning_tokens, :cost,
|
|
23
|
+
:predicted_per_second, :prompt_per_second, :predicted_ms, :source)
|
|
16
24
|
CHARS_PER_TOKEN = 4.0
|
|
17
25
|
|
|
18
26
|
class << self
|
|
19
27
|
# @param payload [Hash, nil] the raw streamed chunk payload
|
|
20
|
-
# @return [
|
|
28
|
+
# @return [TokenUsage, nil]
|
|
21
29
|
def from_payload(payload)
|
|
22
30
|
payload_hash = payload.is_a?(Hash) ? payload : {}
|
|
23
|
-
usage = payload_hash
|
|
24
|
-
|
|
31
|
+
usage = hash_at(payload_hash, :usage)
|
|
32
|
+
timings = hash_at(payload_hash, :timings)
|
|
25
33
|
|
|
26
34
|
prompt_tokens = first_positive(
|
|
27
|
-
usage
|
|
28
|
-
|
|
29
|
-
payload_hash["prompt_tokens"],
|
|
30
|
-
payload_hash[:prompt_tokens],
|
|
31
|
-
payload_hash["prompt_n"],
|
|
32
|
-
payload_hash[:prompt_n],
|
|
33
|
-
payload_hash["tokens_evaluated"],
|
|
34
|
-
payload_hash[:tokens_evaluated],
|
|
35
|
-
payload_hash.dig("timings", "prompt_n"),
|
|
36
|
-
payload_hash.dig(:timings, :prompt_n)
|
|
35
|
+
*both(usage, :prompt_tokens), *both(payload_hash, :prompt_tokens), *both(payload_hash, :prompt_n),
|
|
36
|
+
*both(payload_hash, :tokens_evaluated), *both(timings, :prompt_n)
|
|
37
37
|
)
|
|
38
|
-
|
|
39
38
|
completion_tokens = first_positive(
|
|
40
|
-
usage
|
|
41
|
-
|
|
42
|
-
payload_hash["completion_tokens"],
|
|
43
|
-
payload_hash[:completion_tokens],
|
|
44
|
-
payload_hash["predicted_n"],
|
|
45
|
-
payload_hash[:predicted_n],
|
|
46
|
-
payload_hash["tokens_predicted"],
|
|
47
|
-
payload_hash[:tokens_predicted],
|
|
48
|
-
payload_hash.dig("timings", "predicted_n"),
|
|
49
|
-
payload_hash.dig(:timings, :predicted_n)
|
|
39
|
+
*both(usage, :completion_tokens), *both(payload_hash, :completion_tokens),
|
|
40
|
+
*both(payload_hash, :predicted_n), *both(payload_hash, :tokens_predicted), *both(timings, :predicted_n)
|
|
50
41
|
)
|
|
51
|
-
|
|
52
42
|
return nil unless prompt_tokens || completion_tokens
|
|
53
43
|
|
|
54
|
-
|
|
44
|
+
new(
|
|
55
45
|
prompt_tokens: prompt_tokens,
|
|
56
46
|
completion_tokens: completion_tokens,
|
|
47
|
+
cached_tokens: first_positive(*both(hash_at(usage, :prompt_tokens_details), :cached_tokens),
|
|
48
|
+
*both(timings, :cache_n)),
|
|
49
|
+
reasoning_tokens: first_positive(*both(hash_at(usage, :completion_tokens_details), :reasoning_tokens)),
|
|
50
|
+
cost: number(*both(usage, :cost)),
|
|
51
|
+
predicted_per_second: positive_number(*both(timings, :predicted_per_second)),
|
|
52
|
+
prompt_per_second: positive_number(*both(timings, :prompt_per_second)),
|
|
53
|
+
predicted_ms: positive_number(*both(timings, :predicted_ms)),
|
|
57
54
|
source: :server
|
|
58
|
-
|
|
55
|
+
)
|
|
59
56
|
end
|
|
60
57
|
|
|
61
58
|
# Estimate token count from raw text length using the chars/4 heuristic.
|
|
@@ -73,16 +70,35 @@ module Samagotchi
|
|
|
73
70
|
# float never raises RangeError; non-numeric values are skipped.
|
|
74
71
|
def first_positive(*values)
|
|
75
72
|
values.each do |value|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
n = Float(value).to_i
|
|
79
|
-
return n if n.positive?
|
|
80
|
-
rescue ArgumentError, TypeError
|
|
81
|
-
next
|
|
73
|
+
n = float(value)&.to_i
|
|
74
|
+
return n if n&.positive?
|
|
82
75
|
end
|
|
83
76
|
|
|
84
77
|
nil
|
|
85
78
|
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
def hash_at(hash, key)
|
|
83
|
+
value = hash[key.to_s] || hash[key]
|
|
84
|
+
value.is_a?(Hash) ? value : {}
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def both(hash, key) = [hash[key.to_s], hash[key]]
|
|
88
|
+
|
|
89
|
+
def positive_number(*values) = values.filter_map { |value| float(value) }.find(&:positive?)
|
|
90
|
+
|
|
91
|
+
# A cost of 0 is a real answer (a free model), so zero counts here.
|
|
92
|
+
def number(*values) = values.filter_map { |value| float(value) }.find { |n| n >= 0 }
|
|
93
|
+
|
|
94
|
+
def float(value)
|
|
95
|
+
return nil if value.nil?
|
|
96
|
+
|
|
97
|
+
n = Float(value)
|
|
98
|
+
n.finite? ? n : nil
|
|
99
|
+
rescue ArgumentError, TypeError
|
|
100
|
+
nil
|
|
101
|
+
end
|
|
86
102
|
end
|
|
87
103
|
end
|
|
88
104
|
end
|
|
@@ -44,6 +44,7 @@ module Samagotchi
|
|
|
44
44
|
def run(call, iteration:, call_index:, call_count:, on_stream_event:, max_tool_output_chars:)
|
|
45
45
|
params = ToolActivity.tool_activity_params(call[:name], call, registry: tools)
|
|
46
46
|
# The gate runs first, so tool_call_started shows the call that runs.
|
|
47
|
+
original = call.dup
|
|
47
48
|
verdict = evaluate(call, iteration, params)
|
|
48
49
|
call = verdict.call
|
|
49
50
|
params = ToolActivity.tool_activity_params(call[:name], call, registry: tools)
|
|
@@ -73,6 +74,11 @@ module Samagotchi
|
|
|
73
74
|
result, images = attach_images(call, result) if result[:images]
|
|
74
75
|
|
|
75
76
|
output = scrub(result[:output].to_s)
|
|
77
|
+
# The gate may have replaced the call (known-names corrects a near
|
|
78
|
+
# miss): one leading line tells the model what actually ran.
|
|
79
|
+
if (changed = changed_call_line(original, call))
|
|
80
|
+
output = "#{changed}\n#{output}"
|
|
81
|
+
end
|
|
76
82
|
capped = output
|
|
77
83
|
truncated = false
|
|
78
84
|
if max_tool_output_chars && output.length > max_tool_output_chars
|
|
@@ -84,14 +90,16 @@ module Samagotchi
|
|
|
84
90
|
# full output: ok, error, blocked, stopped); a dispatcher that raised
|
|
85
91
|
# left none, an error.
|
|
86
92
|
status = result[:activity].is_a?(Hash) ? result[:activity][:status] : nil
|
|
87
|
-
fire(:after_tool_call, { type: :after_tool_call, iteration: iteration, tool: call[:name], output: capped,
|
|
88
|
-
status: status || "error" })
|
|
89
93
|
completed = { type: :tool_call_completed, iteration: iteration, call_count: call_count, call_index: call_index,
|
|
90
94
|
tool: call[:name], output: capped, output_truncated: truncated, activity: result[:activity] }
|
|
91
95
|
completed[:images] = images if images&.any?
|
|
92
96
|
completed[:diff] = diff if diff
|
|
93
97
|
completed[:waited_ms] = waited_ms if waited_ms
|
|
94
98
|
emit(on_stream_event, completed)
|
|
99
|
+
# After the completed event: a hook's card (check-in, skills) prints
|
|
100
|
+
# under the call's tool row, not before it.
|
|
101
|
+
fire(:after_tool_call, { type: :after_tool_call, iteration: iteration, tool: call[:name], output: capped,
|
|
102
|
+
status: status || "error" })
|
|
95
103
|
|
|
96
104
|
run = { output: output, capped_output: capped, truncated: truncated, activity: result[:activity] }
|
|
97
105
|
run[:images] = images if images&.any?
|
|
@@ -203,6 +211,17 @@ module Samagotchi
|
|
|
203
211
|
# A plugin tool's label, what the UIs show for its raw name.
|
|
204
212
|
def plugin_label(name) = ToolActivity.plugin_label(name, registry: tools)
|
|
205
213
|
|
|
214
|
+
# The one leading line the model gets when the gate changed the call it
|
|
215
|
+
# asked for: "ran as: <tool> <changed args>", or nil when nothing
|
|
216
|
+
# changed. The args are the changed call's activity params, so the line
|
|
217
|
+
# stays short.
|
|
218
|
+
def changed_call_line(original, call)
|
|
219
|
+
return nil if original == call
|
|
220
|
+
|
|
221
|
+
params = ToolActivity.tool_activity_params(call[:name], call, registry: tools)
|
|
222
|
+
["ran as: #{call[:name]}", params].compact.join(" ")
|
|
223
|
+
end
|
|
224
|
+
|
|
206
225
|
# The Engine sets the kernel's gate (its context, later the approval
|
|
207
226
|
# flow); a bare kernel (specs) gets one that only runs the hooks.
|
|
208
227
|
def gate
|
|
@@ -199,22 +199,23 @@ module Samagotchi
|
|
|
199
199
|
# thread; the text is safe to read afterwards.
|
|
200
200
|
def finish
|
|
201
201
|
@thread.join(STOP_POLL_INTERVAL_SEC) unless done?
|
|
202
|
-
|
|
202
|
+
close_quietly
|
|
203
203
|
@thread.kill unless @thread.join(STOP_GRACE_SEC)
|
|
204
204
|
end
|
|
205
205
|
|
|
206
206
|
def text
|
|
207
207
|
@buffer.dup.force_encoding(@io.external_encoding || Encoding.default_external)
|
|
208
208
|
end
|
|
209
|
-
end
|
|
210
|
-
private_constant :OutputReader
|
|
211
209
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
210
|
+
private
|
|
211
|
+
|
|
212
|
+
def close_quietly
|
|
213
|
+
@io.close unless @io.closed?
|
|
214
|
+
rescue IOError
|
|
215
|
+
nil
|
|
216
|
+
end
|
|
216
217
|
end
|
|
217
|
-
|
|
218
|
+
private_constant :OutputReader
|
|
218
219
|
|
|
219
220
|
def self.terminate_process_tree(pid)
|
|
220
221
|
signal_process(pid, "TERM")
|
data/lib/samagotchi/version.rb
CHANGED
data/lib/samagotchi/web/app.rb
CHANGED
|
@@ -21,6 +21,7 @@ require_relative "../steer"
|
|
|
21
21
|
require_relative "../host_registry"
|
|
22
22
|
require_relative "../model_catalog"
|
|
23
23
|
require_relative "../model_profile"
|
|
24
|
+
require_relative "../sampling_settings"
|
|
24
25
|
require_relative "../project_scope"
|
|
25
26
|
require_relative "../prompt_history"
|
|
26
27
|
require_relative "../version"
|
|
@@ -440,11 +441,29 @@ module Samagotchi
|
|
|
440
441
|
if default && models.none? { |m| m[:name].casecmp?(default) }
|
|
441
442
|
models.unshift({ name: default, host: nil, id: default })
|
|
442
443
|
end
|
|
444
|
+
add_sampling(models, registry)
|
|
443
445
|
payload = { default: default, models: models }
|
|
444
446
|
payload[:warning] = warnings.join("; ") unless warnings.empty?
|
|
445
447
|
json_response(200, payload)
|
|
446
448
|
end
|
|
447
449
|
|
|
450
|
+
# Each model's configured sampling as /model words it ("temperature=0.6
|
|
451
|
+
# (hosts.work)"), on the models that have some: the picker's tooltip.
|
|
452
|
+
# A config that can't be read leaves the list as it is.
|
|
453
|
+
def add_sampling(models, registry)
|
|
454
|
+
return if registry.nil? || models.empty?
|
|
455
|
+
|
|
456
|
+
settings = ConfigFile.model_settings
|
|
457
|
+
models.each do |model|
|
|
458
|
+
target = registry.resolve(model[:name])
|
|
459
|
+
summary = SamplingSettings.summary(target, names: registry.lookup_names(model[:name], target: target),
|
|
460
|
+
models: settings)
|
|
461
|
+
model[:sampling] = summary if summary
|
|
462
|
+
end
|
|
463
|
+
rescue StandardError
|
|
464
|
+
nil
|
|
465
|
+
end
|
|
466
|
+
|
|
448
467
|
# Built again when config.yml's hosts: changed (compared as read: the
|
|
449
468
|
# YAML read is mtime-cached), so the model picker lists a host added
|
|
450
469
|
# while chi web runs. An injected registry (specs) stays.
|
|
@@ -20,7 +20,7 @@ import { MODEL_KEY } from "./model_pick.js";
|
|
|
20
20
|
import { emptySessionState } from "./session_state.js";
|
|
21
21
|
import { createModelPicker } from "./model_picker.js";
|
|
22
22
|
import { chipName, chipsHtml, imageFiles, restoredChips, thumbsHtml, turnRefs, turnText } from "./images.js";
|
|
23
|
-
import { escapeHtml, noteHtml, normalize, steerRowHtml, userBodyHtml, ownerBadge, previewForCard, relativeTime, failedTurnText, modelLabel, deleteConfirmText, recapLabel, canStopSession, goneSessionNotice, withLiveStatus, withoutWorker, recapPlace, delegatedBy } from "./format.js";
|
|
23
|
+
import { escapeHtml, memChipText, memChipTitle, noteHtml, normalize, steerRowHtml, userBodyHtml, ownerBadge, previewForCard, relativeTime, failedTurnText, modelLabel, deleteConfirmText, recapLabel, canStopSession, goneSessionNotice, withLiveStatus, withoutWorker, recapPlace, delegatedBy } from "./format.js";
|
|
24
24
|
import { EMPTY_ANSWER_CLASS, createTurnView, turnHistoryHtml } from "./turn_view.js";
|
|
25
25
|
import { createStageView } from "./stage_view.js";
|
|
26
26
|
import { appendQuote } from "./annotations.js";
|
|
@@ -28,9 +28,9 @@ import { parsePresets } from "./annotate_presets.js";
|
|
|
28
28
|
import { createAnnotateBar } from "./annotate_bar.js";
|
|
29
29
|
import { COPY_SOURCE_ATTR, copySource, copyText, installCopy, showsAnswer } from "./copy.js";
|
|
30
30
|
import { routeChunk } from "./chunk_router.js";
|
|
31
|
-
import { extractCtxPct, savedCtxPct, cardCtxText } from "./ctx.js";
|
|
31
|
+
import { extractCtxPct, savedCtxPct, cardCtxText, generationTokens, lastSpeedText, speedText, tokensTipText } from "./ctx.js";
|
|
32
32
|
import { createQuestionCards } from "./question_cards.js";
|
|
33
|
-
import { cardClass, cardInnerHtml, cardPlace, isCard, splitSnapshotCards, turnNoticePlace } from "./card.js";
|
|
33
|
+
import { cardClass, cardInnerHtml, cardPlace, isCard, isNoticeCard, noticeInnerHtml, splitSnapshotCards, turnNoticePlace } from "./card.js";
|
|
34
34
|
import { gripFloor, promptHeight } from "./composer_size.js";
|
|
35
35
|
import { createCommandList } from "./command_list.js";
|
|
36
36
|
import { createPromptHistory } from "./prompt_history.js";
|
|
@@ -45,7 +45,7 @@ import {
|
|
|
45
45
|
appendAboveLiveTiming, cancelLineText, elapsedSince, formatDuration, mergeTiming, normalizeTiming, turnTimingText,
|
|
46
46
|
} from "./timing.js";
|
|
47
47
|
import { DONE_MS, applyInitEvent, dropDone, initRowHtml, newInitTasks, seedInitTasks } from "./init_row.js";
|
|
48
|
-
import { clientLabel, commandView, displayWait, dropEarlyRestores, emptyAnswerLine, emptyRetryLine, hookNoticeLabel, initWaitLine, isOwn, sessionCommandLine, keepEarlyRestore, newClientId, noticeLine, promptOps, reminderText, restoreAction, composerAfterAck, restoreOnAck, retryStatusLine, snapshotEvents, startPageReplyOrHint, turnOutput, unknownCommandHint, webLocalReply, workerGoneText } from "./turn_events.js";
|
|
48
|
+
import { clientLabel, commandView, displayWait, dropEarlyRestores, emptyAnswerLine, emptyRetryLine, hookNoticeLabel, initWaitLine, isOwn, sessionCommandLine, keepEarlyRestore, newClientId, noticeLine, promptOps, reminderText, restoreAction, composerAfterAck, restoreOnAck, replaysDrawnTurn, retryStatusLine, snapshotEvents, startPageReplyOrHint, turnOutput, unknownCommandHint, webLocalReply, workerGoneText } from "./turn_events.js";
|
|
49
49
|
|
|
50
50
|
const $ = (s) => document.querySelector(s);
|
|
51
51
|
const topStripEl = $("#topStrip");
|
|
@@ -134,6 +134,9 @@ const questionCards = createQuestionCards({
|
|
|
134
134
|
// was rendered at (null: no live worker then), where a stream opened later
|
|
135
135
|
// has to start.
|
|
136
136
|
let selectSeq = null;
|
|
137
|
+
// The stream was opened from 0 (a wake with no cursor to go on) and no
|
|
138
|
+
// new turn has started on it yet: see followWokenWorker.
|
|
139
|
+
let streamFromGuess = false;
|
|
137
140
|
// The last input_merged had an origin with no bubble: its merged text
|
|
138
141
|
// (pending_input_merged) gets one.
|
|
139
142
|
let unmatchedMerge = false;
|
|
@@ -165,6 +168,7 @@ let scopeError = null;
|
|
|
165
168
|
async function refresh() {
|
|
166
169
|
try {
|
|
167
170
|
allSessions = await listSessions("updated_at", "desc", { dir: scope.dir });
|
|
171
|
+
notifications.noticeList(allSessions);
|
|
168
172
|
} catch (e) {
|
|
169
173
|
if (e.status !== 400 || !scope.dir) throw e;
|
|
170
174
|
scopeError = e.message;
|
|
@@ -317,7 +321,7 @@ function cardHtml(s, { deletable = false } = {}) {
|
|
|
317
321
|
const folderHtml = folder ? `<span class="folder" title="${escapeHtml(s.working_directory)}">${escapeHtml(folder)}</span>` : "";
|
|
318
322
|
const archivedHtml = s.archived ? `<span class="archived-badge" title="Archived: hidden from the lists, kept for good">archived</span>` : "";
|
|
319
323
|
const ctx = cardCtxText(s.ctx_pct);
|
|
320
|
-
const ctxHtml = ctx ? `<span class="ctx" title="
|
|
324
|
+
const ctxHtml = ctx ? `<span class="ctx" title="${escapeHtml(tokensTipText(s.tokens))}">${escapeHtml(ctx)}</span>` : "";
|
|
321
325
|
const waiting = waitingBadge(s);
|
|
322
326
|
const waitingHtml = waiting ? `<span class="attn attn-${waiting.kind}" title="${escapeHtml(waiting.title)}">${escapeHtml(waiting.text)}</span>` : "";
|
|
323
327
|
const dotWord = waiting ? waiting.title : s.status;
|
|
@@ -1173,13 +1177,26 @@ function cardElement(id) {
|
|
|
1173
1177
|
return findInTurnRoots(".plugin-card").find((el) => el.dataset.cardId === String(id)) || null;
|
|
1174
1178
|
}
|
|
1175
1179
|
|
|
1180
|
+
// A notice is a <details> (a one-line row that opens), a card a <div>: a
|
|
1181
|
+
// card that turns into the other kind (btw's "thinking…" → its answer) is
|
|
1182
|
+
// drawn on a new element in its place. Returns the element drawn.
|
|
1176
1183
|
function drawCard(el, card) {
|
|
1184
|
+
const notice = isNoticeCard(card);
|
|
1185
|
+
if (el.tagName !== (notice ? "DETAILS" : "DIV")) {
|
|
1186
|
+
const fresh = document.createElement(notice ? "details" : "div");
|
|
1187
|
+
el.replaceWith(fresh);
|
|
1188
|
+
el = fresh;
|
|
1189
|
+
}
|
|
1177
1190
|
el.className = cardClass(card);
|
|
1178
1191
|
el.dataset.cardId = card.id;
|
|
1179
1192
|
// A running turn's own card (the stage's status waits on one that asks).
|
|
1180
1193
|
if (card.in_turn) el.dataset.inTurn = "1";
|
|
1181
1194
|
else delete el.dataset.inTurn;
|
|
1182
|
-
if (
|
|
1195
|
+
if (notice) {
|
|
1196
|
+
const open = el.open;
|
|
1197
|
+
el.innerHTML = noticeInnerHtml(card);
|
|
1198
|
+
el.open = open;
|
|
1199
|
+
} else if (card.body_html === undefined && el.dataset.body === String(card.body ?? "") && el.querySelector(".card-body")) {
|
|
1183
1200
|
// The same body again from the stream: keep the rendered one.
|
|
1184
1201
|
const body = el.querySelector(".card-body").innerHTML;
|
|
1185
1202
|
el.innerHTML = cardInnerHtml({ ...card, body_html: body });
|
|
@@ -1187,6 +1204,7 @@ function drawCard(el, card) {
|
|
|
1187
1204
|
el.innerHTML = cardInnerHtml(card);
|
|
1188
1205
|
}
|
|
1189
1206
|
el.dataset.body = String(card.body ?? "");
|
|
1207
|
+
return el;
|
|
1190
1208
|
}
|
|
1191
1209
|
|
|
1192
1210
|
// Show +card+: update the one with its id where it is, else add it (a
|
|
@@ -1198,14 +1216,13 @@ function upsertCard(card, { before = null } = {}) {
|
|
|
1198
1216
|
if (!card || !card.id) return;
|
|
1199
1217
|
const existing = cardElement(card.id);
|
|
1200
1218
|
if (existing) {
|
|
1201
|
-
drawCard(existing, card);
|
|
1202
|
-
|
|
1203
|
-
setTimeout(() =>
|
|
1219
|
+
const drawn = drawCard(existing, card);
|
|
1220
|
+
drawn.classList.add("card-updated");
|
|
1221
|
+
setTimeout(() => drawn.classList.remove("card-updated"), 900);
|
|
1204
1222
|
return;
|
|
1205
1223
|
}
|
|
1206
1224
|
removeHintIfEmpty();
|
|
1207
|
-
const el = document.createElement("div");
|
|
1208
|
-
drawCard(el, card);
|
|
1225
|
+
const el = drawCard(document.createElement(isNoticeCard(card) ? "details" : "div"), card);
|
|
1209
1226
|
const asks = (card.actions || []).some((a) => a && a.command);
|
|
1210
1227
|
if (card.in_turn && !before && !asks && turnDom.card(el)) return;
|
|
1211
1228
|
const follow = isNearBottom();
|
|
@@ -1328,7 +1345,7 @@ function onCardAction(e) {
|
|
|
1328
1345
|
sendCommand(selected, button.dataset.command, { clientId, card: true })
|
|
1329
1346
|
.then(() => {
|
|
1330
1347
|
// The command woke the worker: follow its stream if none is open.
|
|
1331
|
-
|
|
1348
|
+
followWokenWorker();
|
|
1332
1349
|
})
|
|
1333
1350
|
.catch((error) => {
|
|
1334
1351
|
err.textContent = error.message;
|
|
@@ -1403,6 +1420,7 @@ function updateInfoBar() {
|
|
|
1403
1420
|
archiveBtn.style.display = selected ? "" : "none";
|
|
1404
1421
|
const archived = selectedArchived();
|
|
1405
1422
|
archiveBtn.textContent = archived ? "unarchive" : "archive";
|
|
1423
|
+
archiveBtn.classList.toggle("archived", archived);
|
|
1406
1424
|
archiveBtn.title = archived ? "Unarchive: back to the lists" : "Archive: hide from the lists, keep for good";
|
|
1407
1425
|
}
|
|
1408
1426
|
if (!selected) {
|
|
@@ -1416,6 +1434,11 @@ function updateInfoBar() {
|
|
|
1416
1434
|
const idShort = selected.slice(0,8);
|
|
1417
1435
|
const memText = current.usedMemories.length ? current.usedMemories.join(", ") : "—";
|
|
1418
1436
|
const ctxText = current.ctxPct !== null ? `ctx ${Math.round(current.ctxPct)}%` : "";
|
|
1437
|
+
const tokens = current.timing.tokens;
|
|
1438
|
+
const ctxTip = tokensTipText(tokens);
|
|
1439
|
+
const speedTextNow = lastSpeedText(tokens);
|
|
1440
|
+
const avgSpeed = speedText(tokens?.avg_decode_tps, tokens?.tps_source);
|
|
1441
|
+
const speedTip = `Decode speed of the last generation${avgSpeed ? `; session average ${avgSpeed}` : ""}`;
|
|
1419
1442
|
const sessionDuration = currentSessionDuration();
|
|
1420
1443
|
const timingText = sessionDuration === null ? "" : `session ${formatDuration(sessionDuration)}`;
|
|
1421
1444
|
const statusClass = current.status ? `status ${escapeHtml(current.status)}` : "status";
|
|
@@ -1426,7 +1449,7 @@ function updateInfoBar() {
|
|
|
1426
1449
|
const attachCmd = `chi --attach ${selected}`;
|
|
1427
1450
|
const copied = Date.now() - attachCopiedAt < 2000;
|
|
1428
1451
|
const copyHtml = `<button type="button" class="copy-attach" title="Copy "${escapeHtml(attachCmd)}" to attach a terminal">${copied ? "copied ✓" : "copy chi --attach"}</button>`;
|
|
1429
|
-
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>${parentHtml}<span class="dir" title="${escapeHtml(current.dir||"")}">${escapeHtml((current.dir||"").slice(0,48))}</span>${ctxText ? `<span class="model">${escapeHtml(ctxText)}</span>` : ""}${timingText ? `<span class="model">${escapeHtml(timingText)}</span>` : ""}${workerBadgeHtml()}</div>`;
|
|
1452
|
+
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>${parentHtml}${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||"")}">${escapeHtml((current.dir||"").slice(0,48))}</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">${escapeHtml(timingText)}</span>` : ""}${workerBadgeHtml()}</div>`;
|
|
1430
1453
|
const previewHtml = `<div class="preview-line"><span class="preview">${escapeHtml(current.firstPreview || "—")}</span> · <span class="mem" title="${escapeHtml(current.usedMemories.join(", "))}">mem: ${escapeHtml(memText)}</span></div>`;
|
|
1431
1454
|
infoTextEl.innerHTML = metaHtml + previewHtml;
|
|
1432
1455
|
infoTextEl.title = memoriesTooltip(current.usedMemories, current.preloadedMemories, current.mutedMemories);
|
|
@@ -1575,7 +1598,18 @@ function setUsedMemories(names) {
|
|
|
1575
1598
|
}
|
|
1576
1599
|
}
|
|
1577
1600
|
|
|
1601
|
+
// A turn or command just woke the session's worker: follow its stream if
|
|
1602
|
+
// none is open, from the render's cursor. A render with none (no worker
|
|
1603
|
+
// then) streams from 0, the start of a new worker's events; should that
|
|
1604
|
+
// worker have been up already, turn_started says so (replaysDrawnTurn).
|
|
1605
|
+
function followWokenWorker() {
|
|
1606
|
+
if (streamControl?.live) return;
|
|
1607
|
+
startStream(selected, selectSeq ?? 0);
|
|
1608
|
+
streamFromGuess = selectSeq === null;
|
|
1609
|
+
}
|
|
1610
|
+
|
|
1578
1611
|
function startStream(id, fromSeq) {
|
|
1612
|
+
streamFromGuess = false;
|
|
1579
1613
|
if (streamControl) {
|
|
1580
1614
|
streamControl.close();
|
|
1581
1615
|
streamControl = null;
|
|
@@ -1648,16 +1682,17 @@ const streamHandlers = {
|
|
|
1648
1682
|
},
|
|
1649
1683
|
generation_completed: (data) => {
|
|
1650
1684
|
setLiveTurnNote("");
|
|
1651
|
-
if (data?.served_model)
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1685
|
+
if (data?.served_model) current.served = [data.served_model, data.requested_model];
|
|
1686
|
+
// The generation's speed and the session's token totals, from the server.
|
|
1687
|
+
current.timing.tokens = generationTokens(current.timing.tokens, data);
|
|
1688
|
+
if (data?.served_model || data?.tokens) updateInfoBar();
|
|
1655
1689
|
turnDom.generationCompleted();
|
|
1656
1690
|
},
|
|
1657
1691
|
// Prompts sent while a turn ran merge into it at an iteration boundary:
|
|
1658
1692
|
// input_merged names them (their bubbles turn "steered"), then
|
|
1659
1693
|
// pending_input_merged carries the merged text, shown only for prompts
|
|
1660
|
-
// this tab has no bubble for.
|
|
1694
|
+
// this tab has no bubble for. Its `answer` (the answer the merge
|
|
1695
|
+
// superseded) is the TUI's line: here it streamed as that step's text.
|
|
1661
1696
|
input_merged: (data) => applyPromptOps(data),
|
|
1662
1697
|
pending_input_merged: (data) => {
|
|
1663
1698
|
applyPromptOps(data);
|
|
@@ -1665,6 +1700,16 @@ const streamHandlers = {
|
|
|
1665
1700
|
},
|
|
1666
1701
|
turn_enqueued: (data) => applyPromptOps(data),
|
|
1667
1702
|
turn_started: (data) => keepFollowing(historyEl, () => {
|
|
1703
|
+
if (streamFromGuess) {
|
|
1704
|
+
streamFromGuess = false;
|
|
1705
|
+
// A replay of a turn the render drew: re-read and stream from there.
|
|
1706
|
+
if (replaysDrawnTurn(data, current.timing.turnRecords)) {
|
|
1707
|
+
streamControl?.close();
|
|
1708
|
+
streamControl = null;
|
|
1709
|
+
resync();
|
|
1710
|
+
return;
|
|
1711
|
+
}
|
|
1712
|
+
}
|
|
1668
1713
|
// A finished turn still in the stage goes first: the start op looks
|
|
1669
1714
|
// for a queued prompt among its rows.
|
|
1670
1715
|
turnDom.handOff?.({ immediate: true });
|
|
@@ -1711,7 +1756,7 @@ const streamHandlers = {
|
|
|
1711
1756
|
}
|
|
1712
1757
|
},
|
|
1713
1758
|
turn_canceled: (data) => {
|
|
1714
|
-
endTurnView("canceled", cancelLineText(data.cancellation_reason || ""));
|
|
1759
|
+
endTurnView("canceled", cancelLineText(data.cancellation_reason || "", data.cancelled_by));
|
|
1715
1760
|
setLiveStatus("idle");
|
|
1716
1761
|
refreshTimingFromSession();
|
|
1717
1762
|
},
|
|
@@ -1947,7 +1992,7 @@ async function handleSendTurn() {
|
|
|
1947
1992
|
echo.remove();
|
|
1948
1993
|
promptEl.value = "";
|
|
1949
1994
|
fitPrompt();
|
|
1950
|
-
|
|
1995
|
+
followWokenWorker();
|
|
1951
1996
|
return;
|
|
1952
1997
|
}
|
|
1953
1998
|
if (ack?.enqueued_id) sentIds.add(ack.enqueued_id);
|
|
@@ -1969,7 +2014,7 @@ async function handleSendTurn() {
|
|
|
1969
2014
|
// events in between. Without one, POST /turn just woke a worker: its
|
|
1970
2015
|
// events start after the seq the session was rendered at (0 for a new
|
|
1971
2016
|
// worker process).
|
|
1972
|
-
|
|
2017
|
+
followWokenWorker();
|
|
1973
2018
|
} catch (e) {
|
|
1974
2019
|
echo.remove();
|
|
1975
2020
|
// The turn never reached the worker's queue: an early restore waiting
|
|
@@ -2010,7 +2055,7 @@ async function handleSendCommand(line) {
|
|
|
2010
2055
|
fitPrompt();
|
|
2011
2056
|
refreshHistory();
|
|
2012
2057
|
// The command woke the worker: follow its stream if none is open.
|
|
2013
|
-
|
|
2058
|
+
followWokenWorker();
|
|
2014
2059
|
} catch (e) {
|
|
2015
2060
|
if (/owned_by_tui|409/.test(e.message)) {
|
|
2016
2061
|
current.owner = "tui";
|
|
@@ -2432,6 +2477,7 @@ shortWindow.addEventListener("change", () => {
|
|
|
2432
2477
|
applyStripHidden();
|
|
2433
2478
|
});
|
|
2434
2479
|
$("#stripShow").addEventListener("click", () => setStripHidden(false));
|
|
2480
|
+
$("#stripHideTop").addEventListener("click", () => setStripHidden(true));
|
|
2435
2481
|
|
|
2436
2482
|
$("#filter").addEventListener("input", renderAll);
|
|
2437
2483
|
// A list whose order was held under the pointer (listOrder) catches up.
|
|
@@ -3,9 +3,41 @@
|
|
|
3
3
|
// the element; this module draws it and says where it goes.
|
|
4
4
|
import { escapeHtml, recapPlace } from "./format.js";
|
|
5
5
|
|
|
6
|
-
// The card's class: a warn card wears the warning colour
|
|
6
|
+
// The card's class: a warn card wears the warning colour; a notice is a
|
|
7
|
+
// one-line row.
|
|
7
8
|
export function cardClass(card) {
|
|
8
|
-
return "bubble plugin-card" + (card?.level === "warn" ? " warn" : "");
|
|
9
|
+
return "bubble plugin-card" + (card?.level === "warn" ? " warn" : "") + (isNoticeCard(card) ? " notice" : "");
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// The longest body a notice row carries; a longer one is a card to read.
|
|
13
|
+
export const NOTICE_CHARS = 160;
|
|
14
|
+
|
|
15
|
+
// Whether a card is a notice (check-in's "Nudged the model at 105 tool
|
|
16
|
+
// calls."): info level, nothing to click, a body of one short line. It
|
|
17
|
+
// shows as a collapsed one-line row, "▸ check-in: Nudged …", as the tool
|
|
18
|
+
// rows and the resolved question cards do; a click opens it. A warn card,
|
|
19
|
+
// one that asks (actions) and one with more to read stay cards.
|
|
20
|
+
export function isNoticeCard(card) {
|
|
21
|
+
if (!card || card.level === "warn") return false;
|
|
22
|
+
if ((card.actions || []).some((a) => a && a.command)) return false;
|
|
23
|
+
const body = String(card.body ?? "").trim();
|
|
24
|
+
return !body.includes("\n") && body.length <= NOTICE_CHARS;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// A notice's one line: "title: body" (the title alone for no body).
|
|
28
|
+
export function noticeCardLine(card) {
|
|
29
|
+
const title = String(card?.title ?? "").trim();
|
|
30
|
+
const body = String(card?.body ?? "").trim();
|
|
31
|
+
return title && body ? `${title}: ${body}` : title || body;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// A notice's markup inside its <details>: the line as the summary, and
|
|
35
|
+
// where it came from when the row is opened (the line wraps then).
|
|
36
|
+
export function noticeInnerHtml(card) {
|
|
37
|
+
const title = String(card?.title ?? "").trim();
|
|
38
|
+
const source = String(card?.source ?? "").trim();
|
|
39
|
+
const from = source && source !== title ? `<div class="notice-from">from <span class="card-source">${escapeHtml(source)}</span></div>` : "";
|
|
40
|
+
return `<summary>${escapeHtml(noticeCardLine(card))}</summary>${from}`;
|
|
9
41
|
}
|
|
10
42
|
|
|
11
43
|
// Whether a card shown as a row of a step leaves the turn's block when the
|
|
@@ -48,3 +48,62 @@ export function savedCtxPct(context) {
|
|
|
48
48
|
export function cardCtxText(pct) {
|
|
49
49
|
return num(pct) === null ? "" : `${Math.round(pct)}%`;
|
|
50
50
|
}
|
|
51
|
+
|
|
52
|
+
// A decode speed: "87 tok/s", "1.9k tok/s"; "~64 tok/s" for an estimate (no
|
|
53
|
+
// server timings); "" without one. The TUI's /stats says the same
|
|
54
|
+
// (Formatting#speed_text; spec/shared/labels_matrix.json).
|
|
55
|
+
export function speedText(tps, source) {
|
|
56
|
+
const value = num(tps);
|
|
57
|
+
if (value === null || value <= 0) return "";
|
|
58
|
+
const number = value >= 1000 ? `${(Math.round(value / 100) / 10).toFixed(1)}k` : String(Math.round(value));
|
|
59
|
+
return `${source === "estimate" ? "~" : ""}${number} tok/s`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// A cost in USD: "$0.42"; under a cent, four decimals ("$0.0012"); "" for
|
|
63
|
+
// none or zero. As Formatting#cost_text.
|
|
64
|
+
export function costText(cost) {
|
|
65
|
+
const value = num(cost);
|
|
66
|
+
if (value === null || value <= 0) return "";
|
|
67
|
+
return value >= 0.01 ? `$${value.toFixed(2)}` : `$${value.toFixed(4)}`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function count(n) {
|
|
71
|
+
return Math.round(n).toLocaleString("en-US");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// The ctx tooltip (info bar and card): +title+, then the session's tokens
|
|
75
|
+
// (in summed over every request, cached with its share, out with the
|
|
76
|
+
// reasoning part) and its cost, when it has any. A delegated session keeps
|
|
77
|
+
// its own metrics, so these are this session's only.
|
|
78
|
+
export function tokensTipText(tokens, title = "Context used after the last turn") {
|
|
79
|
+
const prompt = num(tokens?.prompt_sum) || 0;
|
|
80
|
+
const completion = num(tokens?.completion_sum) || 0;
|
|
81
|
+
if (!prompt && !completion) return title;
|
|
82
|
+
const cached = num(tokens.cached_sum) || 0;
|
|
83
|
+
const reasoning = num(tokens.reasoning_sum) || 0;
|
|
84
|
+
const parts = [`in ${count(prompt)}`];
|
|
85
|
+
if (cached > 0 && prompt > 0) parts.push(`cached ${count(cached)} (${Math.round((cached * 100) / prompt)}%)`);
|
|
86
|
+
parts.push(`out ${count(completion)}${reasoning > 0 ? ` (reasoning ${count(reasoning)})` : ""}`);
|
|
87
|
+
const lines = [title, `tokens: ${parts.join(" · ")}`];
|
|
88
|
+
const cost = costText(tokens.cost_sum);
|
|
89
|
+
if (cost) lines.push(`cost: ${cost}`);
|
|
90
|
+
lines.push("this session only, all requests");
|
|
91
|
+
return lines.join("\n");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// The info bar's speed: the newest generation's decode speed, "" before one.
|
|
95
|
+
export function lastSpeedText(tokens) {
|
|
96
|
+
return speedText(tokens?.last_decode_tps, tokens?.tps_source);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// The session's tokens block after a :generation_completed: the event's
|
|
100
|
+
// running totals (the server closed the generation and put them on it),
|
|
101
|
+
// with its speed; an event without them (a replay, an older worker) keeps
|
|
102
|
+
// +previous+.
|
|
103
|
+
export function generationTokens(previous, data) {
|
|
104
|
+
const tokens = data?.tokens;
|
|
105
|
+
if (!tokens || typeof tokens !== "object") return previous ?? null;
|
|
106
|
+
const speed = data.speed;
|
|
107
|
+
if (!speed || num(speed.decode_tps) === null) return tokens;
|
|
108
|
+
return { ...tokens, last_decode_tps: speed.decode_tps, tps_source: speed.source };
|
|
109
|
+
}
|