samagotchi 0.18.1 → 0.19.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 +85 -1
- data/README.md +4 -3
- data/docs/architecture.md +15 -2
- data/docs/cli.md +34 -8
- data/docs/configuration.md +8 -4
- data/docs/memory.md +4 -0
- data/docs/sessions.md +6 -4
- data/lib/samagotchi/bridge.rb +25 -9
- data/lib/samagotchi/bridge_client/event_stream.rb +37 -1
- data/lib/samagotchi/bridge_client.rb +9 -5
- data/lib/samagotchi/bundle_command.rb +2 -1
- data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +7 -4
- data/lib/samagotchi/bundles/source-links/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
- data/lib/samagotchi/engine.rb +9 -3
- data/lib/samagotchi/guardrail_wiring.rb +8 -2
- data/lib/samagotchi/guardrails/approval.rb +10 -4
- data/lib/samagotchi/guardrails/approvals.rb +60 -12
- data/lib/samagotchi/host_registry.rb +22 -4
- data/lib/samagotchi/idle_scheduler.rb +1 -1
- data/lib/samagotchi/installed_versions.rb +91 -0
- data/lib/samagotchi/live_versions.rb +5 -3
- data/lib/samagotchi/memory_bundle/builder.rb +24 -1
- data/lib/samagotchi/memory_bundle/index_sync.rb +1 -9
- data/lib/samagotchi/memory_bundle/installer.rb +33 -0
- data/lib/samagotchi/memory_bundle/status.rb +5 -1
- data/lib/samagotchi/memory_bundle/uninstaller.rb +6 -2
- data/lib/samagotchi/model_overlay.rb +39 -0
- data/lib/samagotchi/model_profile.rb +4 -0
- data/lib/samagotchi/relay_desk.rb +9 -0
- data/lib/samagotchi/session_manager.rb +166 -11
- data/lib/samagotchi/session_metrics.rb +36 -13
- data/lib/samagotchi/sessions_command.rb +43 -4
- data/lib/samagotchi/system_prompt.rb +10 -1
- data/lib/samagotchi/terminal_ui/attached_loop.rb +48 -3
- data/lib/samagotchi/terminal_ui/image_input.rb +11 -4
- data/lib/samagotchi/terminal_ui/version_lines.rb +48 -0
- data/lib/samagotchi/tools/builtins.rb +21 -5
- data/lib/samagotchi/turn_note.rb +3 -2
- data/lib/samagotchi/update_command.rb +33 -12
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +64 -11
- data/lib/samagotchi/web/public/app.js +102 -29
- data/lib/samagotchi/web/public/data.js +6 -0
- data/lib/samagotchi/web/public/format.js +4 -2
- data/lib/samagotchi/web/public/index.html +11 -0
- data/lib/samagotchi/web/public/question_card.js +12 -0
- data/lib/samagotchi/web/public/question_cards.js +17 -11
- data/lib/samagotchi/web/public/scope.js +15 -6
- data/lib/samagotchi/web/public/session_state.js +4 -0
- data/lib/samagotchi/web/public/turn_events.js +107 -3
- data/lib/samagotchi/web/public/update_notice.js +87 -14
- data/lib/samagotchi/web/server.rb +28 -1
- data/lib/samagotchi/web/session_hub.rb +30 -3
- data/lib/samagotchi/web/session_summary.rb +5 -0
- data/lib/samagotchi/worker.rb +25 -8
- data/lib/samagotchi/worker_idle_exit.rb +31 -2
- data/lib/samagotchi/worker_sidecar.rb +7 -5
- metadata +3 -1
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../installed_versions"
|
|
4
|
+
require_relative "../version"
|
|
5
|
+
|
|
6
|
+
module Samagotchi
|
|
7
|
+
class TerminalUI
|
|
8
|
+
# The line an attaching terminal shows when chi versions differ: the
|
|
9
|
+
# session's worker (its snapshot's chi_version), this terminal (VERSION)
|
|
10
|
+
# and the newest installed. Neither process can reload itself: the worker
|
|
11
|
+
# moves with `chi sessions restart`, the terminal by attaching again.
|
|
12
|
+
module VersionLines
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
# @param worker [String, nil] the worker's chi_version (nil: a worker
|
|
16
|
+
# from before it said so; nothing is said then)
|
|
17
|
+
# @param installed [String, nil] InstalledVersions#newest
|
|
18
|
+
# @return [String, nil] one "chi> ..." line, or nil when all is current
|
|
19
|
+
def at_attach(worker:, installed:, session_id:, terminal: VERSION)
|
|
20
|
+
return nil if worker.to_s.empty?
|
|
21
|
+
|
|
22
|
+
newest = [installed, terminal].select { |v| Gem::Version.correct?(v.to_s) && !v.to_s.empty? }
|
|
23
|
+
.max_by { |v| Gem::Version.new(v) }
|
|
24
|
+
worker_old = InstalledVersions.newer?(newest, worker)
|
|
25
|
+
terminal_old = InstalledVersions.newer?(newest, terminal)
|
|
26
|
+
return nil unless worker_old || terminal_old
|
|
27
|
+
|
|
28
|
+
id = session_id.to_s[0, 8]
|
|
29
|
+
restart = "chi sessions restart #{id}"
|
|
30
|
+
attach = "/detach, then chi --attach #{id}"
|
|
31
|
+
if worker_old && terminal_old
|
|
32
|
+
"chi> chi #{newest} is installed; this session's worker runs #{worker} and this terminal #{terminal}. " \
|
|
33
|
+
"#{restart} moves the worker; #{attach} for the terminal."
|
|
34
|
+
elsif worker_old
|
|
35
|
+
"chi> chi #{newest} is installed; this session's worker runs #{worker}. #{restart} moves it there " \
|
|
36
|
+
"(this terminal follows)."
|
|
37
|
+
else
|
|
38
|
+
"chi> chi #{newest} is installed; this terminal runs #{terminal}. #{attach} to use it."
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# After the stream moved to a new worker (a restart).
|
|
43
|
+
def restarted(worker)
|
|
44
|
+
worker.to_s.empty? ? "chi> the session's worker restarted" : "chi> the session's worker restarted on chi #{worker}"
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -22,6 +22,7 @@ require_relative "delegate"
|
|
|
22
22
|
require_relative "delegate_result"
|
|
23
23
|
require_relative "ask_user_question"
|
|
24
24
|
require_relative "../guardrails/parent_approvals"
|
|
25
|
+
require_relative "../model_profile"
|
|
25
26
|
|
|
26
27
|
module Samagotchi
|
|
27
28
|
module Tools
|
|
@@ -73,7 +74,8 @@ module Samagotchi
|
|
|
73
74
|
TaskCreate::NAME => lambda do |call, kctx|
|
|
74
75
|
next Execute::NOT_RUN_ON_STOP if cancelled_proc(kctx).call
|
|
75
76
|
|
|
76
|
-
TaskCreate.call(call[:content], cwd: call[:cwd], env: call[:env],
|
|
77
|
+
TaskCreate.call(call[:content], cwd: call[:cwd], env: call[:env],
|
|
78
|
+
marker_env: task_marker_env(kctx, call[:env]))
|
|
77
79
|
end,
|
|
78
80
|
TaskWait::NAME => lambda do |call, kctx|
|
|
79
81
|
TaskWait.call(call[:content], timeout: call[:timeout], tail_lines: call[:tail_lines],
|
|
@@ -108,17 +110,31 @@ module Samagotchi
|
|
|
108
110
|
# wrapper. The session's id, "chi" without one. And the model the
|
|
109
111
|
# session runs on (SAMAGOTCHI_SESSION_MODEL, read live, so right after
|
|
110
112
|
# /model), which chi self reports; unset when unknown, so a value
|
|
111
|
-
# inherited from an outer session never passes for this one.
|
|
112
|
-
|
|
113
|
+
# inherited from an outer session never passes for this one. A
|
|
114
|
+
# worker's `chi --model X` default (ModelProfile::MODEL_FROM_CLI_ENV)
|
|
115
|
+
# is unset: it was that run's, not the default of a chi run here.
|
|
116
|
+
def parent_env(kctx, env: ENV)
|
|
113
117
|
peers = kctx.peers
|
|
114
118
|
id = peers.respond_to?(:session_id) ? peers.session_id.to_s : ""
|
|
115
119
|
model = peers.respond_to?(:model_ref) ? peers.model_ref.to_s : ""
|
|
116
|
-
{ Guardrails::ParentApprovals::PARENT_SESSION_ENV => id.empty? ? "chi" : id,
|
|
117
|
-
|
|
120
|
+
vars = { Guardrails::ParentApprovals::PARENT_SESSION_ENV => id.empty? ? "chi" : id,
|
|
121
|
+
SESSION_MODEL_ENV => model.empty? ? nil : model }
|
|
122
|
+
if env[ModelProfile::MODEL_FROM_CLI_ENV]
|
|
123
|
+
vars[ModelProfile::MODEL_ENV] = nil
|
|
124
|
+
vars[ModelProfile::MODEL_FROM_CLI_ENV] = nil
|
|
125
|
+
end
|
|
126
|
+
vars
|
|
118
127
|
end
|
|
119
128
|
|
|
120
129
|
SESSION_MODEL_ENV = "SAMAGOTCHI_SESSION_MODEL"
|
|
121
130
|
|
|
131
|
+
# task_create's parent_env, less the unsets of a variable the model
|
|
132
|
+
# set in its env (a SAMAGOTCHI_DEFAULT_MODEL it asked for stays).
|
|
133
|
+
def task_marker_env(kctx, env)
|
|
134
|
+
given = env.is_a?(Hash) ? env.keys.map(&:to_s) : []
|
|
135
|
+
parent_env(kctx).reject { |key, value| value.nil? && given.include?(key) }
|
|
136
|
+
end
|
|
137
|
+
|
|
122
138
|
# Stop flips the turn's controller, seen through the Engine's PeerView
|
|
123
139
|
# (as DelegateWait does); a bare kernel has no peers: never cancelled.
|
|
124
140
|
def cancelled_proc(kctx)
|
data/lib/samagotchi/turn_note.rb
CHANGED
|
@@ -37,7 +37,8 @@ module Samagotchi
|
|
|
37
37
|
# @param reason [Symbol, String, nil] the cancel reason (:ctrl_c, :user…)
|
|
38
38
|
# @param seconds [Numeric, nil] how long the turn had run
|
|
39
39
|
# @param shown [Boolean] visible text had streamed (an `[interrupted]`
|
|
40
|
-
# model message precedes this note)
|
|
40
|
+
# model message precedes this note): an answer, or only narration
|
|
41
|
+
# before a tool call, so the note says "reply"
|
|
41
42
|
# @param running_tasks [Array<Hash>] {id:, command:} of this session's
|
|
42
43
|
# background tasks still running: a Stop doesn't end them
|
|
43
44
|
# @param stopped_by [Hash, nil] {by:, reason:} of a hook that stopped
|
|
@@ -45,7 +46,7 @@ module Samagotchi
|
|
|
45
46
|
def cancelled(reason, seconds: nil, shown: false, running_tasks: [], stopped_by: nil)
|
|
46
47
|
why = reason.to_s.empty? ? "" : " (#{reason.to_s.tr("_", "-")}#{stopper(stopped_by)})"
|
|
47
48
|
after = seconds ? " after #{seconds.round}s" : ""
|
|
48
|
-
what = shown ? "the
|
|
49
|
+
what = shown ? "the reply above ends where it was cut off." : "no answer had been shown."
|
|
49
50
|
message("the previous turn was cancelled#{why}#{after}; #{what}#{still_running(running_tasks)}")
|
|
50
51
|
end
|
|
51
52
|
|
|
@@ -34,8 +34,9 @@ module Samagotchi
|
|
|
34
34
|
--no-gem don't install a newer gem (config update.gem: false)
|
|
35
35
|
--no-bundles leave the shipped bundles alone (config update.bundles: false)
|
|
36
36
|
--no-desktop leave the desktop helper alone (config update.desktop: false)
|
|
37
|
-
Running sessions keep their chi until
|
|
38
|
-
|
|
37
|
+
Running sessions keep their chi until restarted (chi sessions
|
|
38
|
+
restart ID) or until they idle out with nothing attached; a running
|
|
39
|
+
chi web needs a restart (the sessions it starts run the new chi).
|
|
39
40
|
TEXT
|
|
40
41
|
|
|
41
42
|
# Help is a flag here: it doesn't stop the parse (an unknown option
|
|
@@ -238,22 +239,42 @@ module Samagotchi
|
|
|
238
239
|
def live_rows
|
|
239
240
|
rows = []
|
|
240
241
|
stale = LiveVersions.stale_workers(VERSION, state_dir: @state_dir)
|
|
241
|
-
unless stale.empty?
|
|
242
|
-
versions = stale.map { |w| w.version || "an older chi" }.uniq.join(", ")
|
|
243
|
-
ids = stale.map { |w| w.session_id[0, 8] }.join(" ")
|
|
244
|
-
rows << Row.new(component: "workers",
|
|
245
|
-
status: "#{stale.size} live on #{versions}: they move to #{VERSION} at idle exit (30 min) " \
|
|
246
|
-
"or chi sessions stop #{ids}")
|
|
247
|
-
end
|
|
242
|
+
rows << Row.new(component: "workers", status: workers_status(stale)) unless stale.empty?
|
|
248
243
|
host, port = @web
|
|
249
|
-
|
|
250
|
-
|
|
244
|
+
info = LiveVersions.web_info(host, port)
|
|
245
|
+
web = info && info["version"].to_s
|
|
246
|
+
if web && !web.empty? && web != VERSION
|
|
247
|
+
# Since newest_workers, the sessions it starts run the newest chi.
|
|
248
|
+
spreads = !Array(info["features"]).include?("newest_workers")
|
|
251
249
|
rows << Row.new(component: "chi web :#{port}", from: web,
|
|
252
|
-
status: "running old code: new sessions it starts run #{web} too; restart it")
|
|
250
|
+
status: "running old code#{": new sessions it starts run #{web} too" if spreads}; restart it")
|
|
253
251
|
end
|
|
254
252
|
rows
|
|
255
253
|
end
|
|
256
254
|
|
|
255
|
+
# A worker keeps its chi until restarted: `chi sessions restart` for
|
|
256
|
+
# one that can, a stop for an older one (the next prompt starts it on
|
|
257
|
+
# the newest). It idles out on its own only with nothing attached.
|
|
258
|
+
def workers_status(stale)
|
|
259
|
+
versions = stale.map { |w| w.version || "an older chi" }.uniq.join(", ")
|
|
260
|
+
restart, stop = stale.partition(&:restart).map { |ws| ws.map { |w| w.session_id[0, 8] }.join(" ") }
|
|
261
|
+
moves = []
|
|
262
|
+
moves << "chi sessions restart #{restart}" unless restart.empty?
|
|
263
|
+
moves << "chi sessions stop #{stop}" unless stop.empty?
|
|
264
|
+
"#{stale.size} live on #{versions}: they keep it until restarted (#{moves.join("; ")})#{idle_note}"
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def idle_note
|
|
268
|
+
minutes = begin
|
|
269
|
+
Config.get("session.idle_exit_minutes").to_f
|
|
270
|
+
rescue StandardError
|
|
271
|
+
30.0
|
|
272
|
+
end
|
|
273
|
+
return "; they never idle out (session.idle_exit_minutes: 0)" unless minutes.positive?
|
|
274
|
+
|
|
275
|
+
"; or they idle out after #{minutes.round(1).to_s.delete_suffix(".0")} min with nothing attached"
|
|
276
|
+
end
|
|
277
|
+
|
|
257
278
|
def footers(options)
|
|
258
279
|
return [] if off(options, "bundles")
|
|
259
280
|
|
data/lib/samagotchi/version.rb
CHANGED
data/lib/samagotchi/web/app.rb
CHANGED
|
@@ -82,7 +82,7 @@ module Samagotchi
|
|
|
82
82
|
# lists and GET /api/events streams from (Server always builds one);
|
|
83
83
|
# without one both routes answer 503
|
|
84
84
|
# @param registry [HostRegistry, nil] the hosts GET /api/models lists
|
|
85
|
-
# (built from
|
|
85
|
+
# (nil: built from config.yml's hosts:, again when they change)
|
|
86
86
|
# @param models_wait_timeout [Float] bounded seconds GET /api/models
|
|
87
87
|
# waits for the hosts' lists
|
|
88
88
|
# @param lan [Hash, nil] LAN mode (web.host: lan): { ip:, token: },
|
|
@@ -96,6 +96,7 @@ module Samagotchi
|
|
|
96
96
|
registry: nil, models_wait_timeout: MODELS_WAIT_TIMEOUT, lan: nil)
|
|
97
97
|
@manager = manager || SessionManager
|
|
98
98
|
@registry = registry
|
|
99
|
+
@registry_given = !registry.nil?
|
|
99
100
|
@models_wait_timeout = models_wait_timeout
|
|
100
101
|
@models_mutex = Mutex.new
|
|
101
102
|
@models_thread = nil
|
|
@@ -138,6 +139,7 @@ module Samagotchi
|
|
|
138
139
|
["GET", %r{\A/api/sessions/([^/]+)/output\z}, :handle_output],
|
|
139
140
|
["POST", %r{\A/api/sessions/([^/]+)/cancel\z}, :handle_cancel],
|
|
140
141
|
["POST", %r{\A/api/sessions/([^/]+)/stop\z}, :handle_stop],
|
|
142
|
+
["POST", %r{\A/api/sessions/([^/]+)/restart\z}, :handle_restart],
|
|
141
143
|
["POST", %r{\A/api/sessions/([^/]+)/archive\z}, :handle_archive],
|
|
142
144
|
["POST", %r{\A/api/sessions/([^/]+)/unarchive\z}, :handle_unarchive],
|
|
143
145
|
["POST", %r{\A/api/sessions/([^/]+)/turn\z}, :handle_turn],
|
|
@@ -375,10 +377,12 @@ module Samagotchi
|
|
|
375
377
|
end
|
|
376
378
|
|
|
377
379
|
# What a second `chi web` probes before starting its own server: this
|
|
378
|
-
# is chi web, and it knows ?dir (features).
|
|
380
|
+
# is chi web, and it knows ?dir (features). installed: the newest chi
|
|
381
|
+
# on this machine (nil until the hub has looked); newest_workers: the
|
|
382
|
+
# workers it starts run that one, not this chi web's version.
|
|
379
383
|
def handle_info(_req)
|
|
380
|
-
json_response(200, { app: "chi-web", version: Samagotchi::VERSION,
|
|
381
|
-
features: [
|
|
384
|
+
json_response(200, { app: "chi-web", version: Samagotchi::VERSION, installed: @hub&.installed, pid: Process.pid,
|
|
385
|
+
cwd: Dir.pwd, features: %w[dir newest_workers], lan: @lan && @lan[:ip] })
|
|
382
386
|
end
|
|
383
387
|
|
|
384
388
|
# The models a new session can start on, spelled as chi spells them
|
|
@@ -441,8 +445,21 @@ module Samagotchi
|
|
|
441
445
|
json_response(200, payload)
|
|
442
446
|
end
|
|
443
447
|
|
|
448
|
+
# Built again when config.yml's hosts: changed (compared as read: the
|
|
449
|
+
# YAML read is mtime-cached), so the model picker lists a host added
|
|
450
|
+
# while chi web runs. An injected registry (specs) stays.
|
|
444
451
|
def host_registry
|
|
445
|
-
@models_mutex.synchronize
|
|
452
|
+
@models_mutex.synchronize do
|
|
453
|
+
next @registry if @registry_given
|
|
454
|
+
|
|
455
|
+
hosts = ConfigFile.hosts_config
|
|
456
|
+
if @registry.nil? || hosts != @registry_hosts
|
|
457
|
+
@registry = HostRegistry.new(hosts_config: hosts)
|
|
458
|
+
@registry_hosts = hosts
|
|
459
|
+
@models_thread = nil # a listing of the old hosts
|
|
460
|
+
end
|
|
461
|
+
@registry
|
|
462
|
+
end
|
|
446
463
|
end
|
|
447
464
|
|
|
448
465
|
# The hosts' model lists (cached ones as they are), or the last complete
|
|
@@ -785,8 +802,10 @@ module Samagotchi
|
|
|
785
802
|
# worker_timeout (+what+ didn't happen), a 404 for a route the worker
|
|
786
803
|
# doesn't have → 501 not_supported (it runs an older chi that +cant+;
|
|
787
804
|
# 501, not 503: it is live, and the page reads 503 as "not running"),
|
|
788
|
-
#
|
|
789
|
-
#
|
|
805
|
+
# another unmapped status → 502 bridge_error with the worker's detail
|
|
806
|
+
# (a live worker that failed the request; never 503, which the page
|
|
807
|
+
# reads as "session not running"), and only no live bridge, a refused
|
|
808
|
+
# connection or one that says it isn't this session's → 503 not_live.
|
|
790
809
|
# @param request [#call] sends with the client, answers its Response
|
|
791
810
|
def relay(id, client, request, what:, cant:)
|
|
792
811
|
return not_live(id) unless client
|
|
@@ -801,7 +820,7 @@ module Samagotchi
|
|
|
801
820
|
return not_live(id) if reply.json&.dig("error") == "unknown_session"
|
|
802
821
|
|
|
803
822
|
error_response(501, "not_supported", BridgeClient.stale_worker_message(id, cant: cant))
|
|
804
|
-
else
|
|
823
|
+
else bridge_error(reply, what)
|
|
805
824
|
end
|
|
806
825
|
rescue Errno::ETIMEDOUT
|
|
807
826
|
worker_timeout(what)
|
|
@@ -813,6 +832,15 @@ module Samagotchi
|
|
|
813
832
|
error_response(503, "not_live", "no live bridge for session #{id}")
|
|
814
833
|
end
|
|
815
834
|
|
|
835
|
+
# A live worker refused or failed the request (a 500 in its handler, a
|
|
836
|
+
# status this route doesn't know): 502, with its detail when it sent
|
|
837
|
+
# one. 503 would have the page say the session isn't running, which is
|
|
838
|
+
# untrue and sends the user to restart a healthy worker.
|
|
839
|
+
def bridge_error(reply, what)
|
|
840
|
+
detail = reply_detail(reply, "#{what}: the session's worker answered #{reply.status}")
|
|
841
|
+
error_response(502, "bridge_error", detail)
|
|
842
|
+
end
|
|
843
|
+
|
|
816
844
|
# The Bridge's detail for a refusal, or +fallback+.
|
|
817
845
|
def reply_detail(reply, fallback)
|
|
818
846
|
reply.json&.dig("detail") || fallback
|
|
@@ -976,6 +1004,26 @@ module Samagotchi
|
|
|
976
1004
|
error_response(404, "not_found", e.message)
|
|
977
1005
|
end
|
|
978
1006
|
|
|
1007
|
+
# POST /api/sessions/:id/restart: hand the session to a new worker on
|
|
1008
|
+
# the newest chi installed (SessionManager.restart_session). 200
|
|
1009
|
+
# {status: "restarted", session_id, from_version, version} (version nil:
|
|
1010
|
+
# the new worker is still starting; its bridge_up follows); 409
|
|
1011
|
+
# {error: "held" | "not_running" | "unsupported" | "failed", reason, detail} in words; 409
|
|
1012
|
+
# owned_by_tui; 404.
|
|
1013
|
+
def handle_restart(_req, id)
|
|
1014
|
+
result = @manager.restart_session(id, state_dir: @state_dir, client_id: "web:restart")
|
|
1015
|
+
@hub&.touch(id)
|
|
1016
|
+
json_response(200, { status: "restarted", session_id: id, from_version: result.from_version,
|
|
1017
|
+
version: result.version })
|
|
1018
|
+
rescue SessionManager::RestartRefused => e
|
|
1019
|
+
error = %i[not_running unsupported failed].include?(e.reason) ? e.reason.to_s : "held"
|
|
1020
|
+
json_response(409, { error: error, reason: e.reason.to_s, detail: e.message, session_id: id })
|
|
1021
|
+
rescue SessionManager::OwnedByTUI => e
|
|
1022
|
+
error_response(409, "owned_by_tui", e.message)
|
|
1023
|
+
rescue ArgumentError => e
|
|
1024
|
+
error_response(404, "not_found", e.message)
|
|
1025
|
+
end
|
|
1026
|
+
|
|
979
1027
|
# POST /api/sessions/:id/archive: hide it (and its delegates) from the
|
|
980
1028
|
# lists, keep it for good. A live idle worker is stopped first; a turn
|
|
981
1029
|
# running in it or a delegate, a chi REPL's session and a scratch one
|
|
@@ -1062,7 +1110,8 @@ module Samagotchi
|
|
|
1062
1110
|
# GET /api/events: the session list as one SSE stream per tab. The
|
|
1063
1111
|
# first frame is the hub's snapshot (scoped by ?dir= as the list is,
|
|
1064
1112
|
# with chi's version),
|
|
1065
|
-
# then `session` for an upsert
|
|
1113
|
+
# then `session` for an upsert, `session_gone` for a removal and `chi`
|
|
1114
|
+
# when the newest installed chi changed, with
|
|
1066
1115
|
# a `: ping` while idle. Frame ids are the hub's seq, for the log's
|
|
1067
1116
|
# sake: there is no replay, a reconnect starts with a fresh snapshot.
|
|
1068
1117
|
def handle_events(req)
|
|
@@ -1598,11 +1647,15 @@ module Samagotchi
|
|
|
1598
1647
|
@server_running = server_running
|
|
1599
1648
|
@queue = Bridge::BoundedQueue.new(capacity: capacity)
|
|
1600
1649
|
@handle, @snapshot = @hub.subscribe(->(event) { @queue.push(event) }, snapshot: true, project_root: project_root)
|
|
1650
|
+
# Read after subscribing: a change in between is also a queued event.
|
|
1651
|
+
@installed = @hub.installed
|
|
1601
1652
|
end
|
|
1602
1653
|
|
|
1603
1654
|
def each
|
|
1604
|
-
# version: the page compares it with the one it was served by
|
|
1605
|
-
|
|
1655
|
+
# version: the page compares it with the one it was served by;
|
|
1656
|
+
# installed: the newest chi on this machine (a later change is a
|
|
1657
|
+
# `chi` event).
|
|
1658
|
+
yield frame(nil, "snapshot", sessions: @snapshot, version: Samagotchi::VERSION, installed: @installed)
|
|
1606
1659
|
last_write = monotonic
|
|
1607
1660
|
loop do
|
|
1608
1661
|
event = @queue.pop([@heartbeat, EVENTS_POLL].min)
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
sendTurn,
|
|
5
5
|
cancelTurn,
|
|
6
6
|
stopSession,
|
|
7
|
+
restartSession,
|
|
7
8
|
deleteSession,
|
|
8
9
|
archiveSession,
|
|
9
10
|
unarchiveSession,
|
|
@@ -39,12 +40,12 @@ import { shouldFollowScroll, keepFollowing } from "./scroll.js";
|
|
|
39
40
|
import { SHORT_WINDOW_QUERY, stripAutoHidden, stripColumns, stripShowParts } from "./strip.js";
|
|
40
41
|
import { applySessionEvent, heldOrder, listedSessions, sortedByUpdated, waitingBadge, waitingFirst, waitingSearchText, withChildrenAfterParents } from "./sessions_list.js";
|
|
41
42
|
import { createNotifications } from "./notifications.js";
|
|
42
|
-
import {
|
|
43
|
+
import { restartConfirmText, versionNotice, workerBadge } from "./update_notice.js";
|
|
43
44
|
import {
|
|
44
45
|
appendAboveLiveTiming, cancelLineText, elapsedSince, formatDuration, mergeTiming, normalizeTiming, turnTimingText,
|
|
45
46
|
} from "./timing.js";
|
|
46
47
|
import { DONE_MS, applyInitEvent, dropDone, initRowHtml, newInitTasks, seedInitTasks } from "./init_row.js";
|
|
47
|
-
import { clientLabel, commandView, emptyAnswerLine, emptyRetryLine, hookNoticeLabel, initWaitLine, isOwn, sessionCommandLine, keepEarlyRestore, newClientId, noticeLine, promptOps, reminderText, restoreAction, restoreOnAck, retryStatusLine, snapshotEvents,
|
|
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
49
|
|
|
49
50
|
const $ = (s) => document.querySelector(s);
|
|
50
51
|
const topStripEl = $("#topStrip");
|
|
@@ -179,7 +180,7 @@ async function refresh() {
|
|
|
179
180
|
function openSessionEvents() {
|
|
180
181
|
eventsControl = openEvents({
|
|
181
182
|
snapshot: (data) => {
|
|
182
|
-
|
|
183
|
+
noticeVersions(data);
|
|
183
184
|
notifications.noticeSessions("snapshot", data);
|
|
184
185
|
allSessions = applySessionEvent(allSessions, "snapshot", data);
|
|
185
186
|
render();
|
|
@@ -201,6 +202,7 @@ function openSessionEvents() {
|
|
|
201
202
|
}
|
|
202
203
|
leaveGoneSession(data.id, "deleted");
|
|
203
204
|
},
|
|
205
|
+
chi: (data) => noticeVersions(data),
|
|
204
206
|
}, {
|
|
205
207
|
dir: scope.dir,
|
|
206
208
|
onError: () => {
|
|
@@ -210,15 +212,24 @@ function openSessionEvents() {
|
|
|
210
212
|
});
|
|
211
213
|
}
|
|
212
214
|
|
|
213
|
-
// chi
|
|
214
|
-
//
|
|
215
|
-
//
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
215
|
+
// Which chi serves this page now and which is the newest installed (the
|
|
216
|
+
// events snapshot, and `chi` frames when that changes). A toast says when
|
|
217
|
+
// chi web was upgraded while this tab stayed open (offering the reload,
|
|
218
|
+
// never forcing it) or when a newer chi is installed than chi web runs.
|
|
219
|
+
// Once per notice per tab: every reconnect's snapshot says it again.
|
|
220
|
+
const versions = { served: null, installed: null };
|
|
221
|
+
// The session whose Restart is under way (its button says so).
|
|
222
|
+
let restartingId = null;
|
|
223
|
+
const versionNoticesShown = new Set();
|
|
224
|
+
function noticeVersions(data) {
|
|
225
|
+
if (typeof data.version === "string") versions.served = data.version;
|
|
226
|
+
versions.installed = typeof data.installed === "string" ? data.installed : null;
|
|
227
|
+
const notice = versionNotice({ loaded: document.body.dataset.version, served: versions.served,
|
|
228
|
+
installed: versions.installed, shown: versionNoticesShown });
|
|
229
|
+
updateInfoBar(); // the worker badge compares with the newest installed
|
|
230
|
+
if (!notice) return;
|
|
231
|
+
versionNoticesShown.add(notice.key);
|
|
232
|
+
showToast(notice.text, notice.reload ? { label: "Reload", run: () => location.reload() } : null, { sticky: true });
|
|
222
233
|
}
|
|
223
234
|
|
|
224
235
|
// The selected session's card changed: its owner and, without a live
|
|
@@ -227,6 +238,8 @@ function noticeUpdate(served) {
|
|
|
227
238
|
// gets the stream attached through one resync.
|
|
228
239
|
function followSelectedSession(sess) {
|
|
229
240
|
current.owner = sess.owner || null;
|
|
241
|
+
current.workerVersion = sess.worker_version || null;
|
|
242
|
+
current.workerFeatures = Array.isArray(sess.worker_features) ? sess.worker_features : [];
|
|
230
243
|
const streaming = !!(streamControl && streamControl.live);
|
|
231
244
|
if (!streaming) current.status = sess.status;
|
|
232
245
|
updateInfoBar();
|
|
@@ -240,7 +253,7 @@ function followSelectedSession(sess) {
|
|
|
240
253
|
// new chat starts on the start page.
|
|
241
254
|
function renderScope() {
|
|
242
255
|
const el = $("#scope");
|
|
243
|
-
const allLink = `<a class="scope-all" href="${escapeHtml(allScopeHref(location.hash, scope.dir))}" title="Sessions of every project">all</a>`;
|
|
256
|
+
const allLink = `<a class="scope-all" href="${escapeHtml(allScopeHref(location.hash, scope.dir, location.search))}" title="Sessions of every project">all</a>`;
|
|
244
257
|
if (scopeError) {
|
|
245
258
|
el.innerHTML = `<span class="scope-name error" title="${escapeHtml(scopeError)}">folder not found</span>${allLink}`;
|
|
246
259
|
} else if (scope.projectName) {
|
|
@@ -248,7 +261,7 @@ function renderScope() {
|
|
|
248
261
|
} else {
|
|
249
262
|
const where = scope.dir || scope.serverDir;
|
|
250
263
|
const backLink = scope.backName
|
|
251
|
-
? `<a class="scope-all scope-back" href="${escapeHtml(projectScopeHref(scope.backDir, location.hash))}" title="Sessions of ${escapeHtml(scope.backName)} only">← ${escapeHtml(scope.backName)}</a>`
|
|
264
|
+
? `<a class="scope-all scope-back" href="${escapeHtml(projectScopeHref(scope.backDir, location.hash, location.search))}" title="Sessions of ${escapeHtml(scope.backName)} only">← ${escapeHtml(scope.backName)}</a>`
|
|
252
265
|
: "";
|
|
253
266
|
el.innerHTML = `<span class="scope-name" title="Sessions of every project">all projects</span>${backLink}${where ? `<span class="scope-where" title="New chats start in ${escapeHtml(where)}">new chat in<span class="path"><bdi>${escapeHtml(where)}</bdi></span></span>` : ""}`;
|
|
254
267
|
}
|
|
@@ -256,10 +269,10 @@ function renderScope() {
|
|
|
256
269
|
notifications.setBaseTitle(scope.projectName ? `Chi · ${scope.projectName}` : "Chi");
|
|
257
270
|
// The links follow the hash (an open session, the all-sessions view).
|
|
258
271
|
el.querySelector(".scope-all:not(.scope-back)")?.addEventListener("click", (e) => {
|
|
259
|
-
e.currentTarget.href = allScopeHref(location.hash, scope.dir);
|
|
272
|
+
e.currentTarget.href = allScopeHref(location.hash, scope.dir, location.search);
|
|
260
273
|
});
|
|
261
274
|
el.querySelector(".scope-back")?.addEventListener("click", (e) => {
|
|
262
|
-
e.currentTarget.href = projectScopeHref(scope.backDir, location.hash);
|
|
275
|
+
e.currentTarget.href = projectScopeHref(scope.backDir, location.hash, location.search);
|
|
263
276
|
});
|
|
264
277
|
}
|
|
265
278
|
|
|
@@ -388,7 +401,7 @@ function renderTop(sessions) {
|
|
|
388
401
|
topStripEl.classList.remove("loading");
|
|
389
402
|
renderStripShow();
|
|
390
403
|
if (scopeError) {
|
|
391
|
-
topStripEl.innerHTML = `<div class="hint scope-error">${escapeHtml(scopeError)} · <a href="${escapeHtml(allScopeHref(""))}">show every session</a></div>`;
|
|
404
|
+
topStripEl.innerHTML = `<div class="hint scope-error">${escapeHtml(scopeError)} · <a href="${escapeHtml(allScopeHref("", null, location.search))}">show every session</a></div>`;
|
|
392
405
|
return;
|
|
393
406
|
}
|
|
394
407
|
// Only archived sessions: no cards, but the tile stays, the way to them.
|
|
@@ -686,6 +699,7 @@ function resetTurnView() {
|
|
|
686
699
|
questionCards.forgetContinue();
|
|
687
700
|
unmatchedMerge = false;
|
|
688
701
|
setTurnRunning(false);
|
|
702
|
+
releaseDisplayWait();
|
|
689
703
|
}
|
|
690
704
|
|
|
691
705
|
// (Re)render the selected session from the server and stream on from the
|
|
@@ -711,6 +725,7 @@ async function resync({ endText = null } = {}) {
|
|
|
711
725
|
return;
|
|
712
726
|
}
|
|
713
727
|
if (id !== selected) return;
|
|
728
|
+
releaseDisplayWait();
|
|
714
729
|
resetTurnView();
|
|
715
730
|
renderSessionView(data);
|
|
716
731
|
// Preview is read-only: a worker is woken only by POST /turn, so attach
|
|
@@ -842,9 +857,17 @@ function setMarkdownWarning(warning) {
|
|
|
842
857
|
// markdown, the status and the timing. Kept as turnEndRead, so an
|
|
843
858
|
// answer_display re-read waits for it.
|
|
844
859
|
let turnEndRead = Promise.resolve();
|
|
845
|
-
//
|
|
846
|
-
//
|
|
847
|
-
|
|
860
|
+
// The wait of a turn_completed with display_pending for its answer_display
|
|
861
|
+
// (turn_events.js displayWait). The worker may die in between and never send
|
|
862
|
+
// one, so workerGone (through resetTurnView) and a re-render release it as
|
|
863
|
+
// well as its own timeout: it must never hold the turn's answer forever.
|
|
864
|
+
let displayPending = null;
|
|
865
|
+
|
|
866
|
+
function releaseDisplayWait() {
|
|
867
|
+
displayPending?.finish();
|
|
868
|
+
displayPending = null;
|
|
869
|
+
}
|
|
870
|
+
|
|
848
871
|
async function readTurnEnd() {
|
|
849
872
|
if (!selected) return;
|
|
850
873
|
const id = selected;
|
|
@@ -1322,6 +1345,9 @@ function removeRecap() {
|
|
|
1322
1345
|
// The info bar's "copy chi --attach": the full id, which the page shows
|
|
1323
1346
|
// only short; "copied ✓" for 2 s.
|
|
1324
1347
|
let attachCopiedAt = 0;
|
|
1348
|
+
infoTextEl.addEventListener("click", (e) => {
|
|
1349
|
+
if (e.target.closest(".worker-restart")) restartWorker();
|
|
1350
|
+
});
|
|
1325
1351
|
infoTextEl.addEventListener("click", async (e) => {
|
|
1326
1352
|
if (!e.target.closest(".copy-attach") || !selected) return;
|
|
1327
1353
|
if (!(await copyText(`chi --attach ${selected}`))) return;
|
|
@@ -1330,6 +1356,44 @@ infoTextEl.addEventListener("click", async (e) => {
|
|
|
1330
1356
|
setTimeout(updateInfoBar, 2000);
|
|
1331
1357
|
});
|
|
1332
1358
|
|
|
1359
|
+
// The open session's worker runs an older chi than the newest installed:
|
|
1360
|
+
// a badge, with Restart when the worker can (else the stop command).
|
|
1361
|
+
function currentWorkerBadge() {
|
|
1362
|
+
if (!selected || current.owner !== "worker") return null;
|
|
1363
|
+
return workerBadge({ workerVersion: current.workerVersion, features: current.workerFeatures,
|
|
1364
|
+
installed: versions.installed, served: versions.served, sessionId: selected });
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
function workerBadgeHtml() {
|
|
1368
|
+
const badge = currentWorkerBadge();
|
|
1369
|
+
if (!badge) return "";
|
|
1370
|
+
const busy = restartingId === selected;
|
|
1371
|
+
const button = badge.restart
|
|
1372
|
+
? `<button type="button" class="worker-restart"${busy ? " disabled" : ""}>${busy ? "restarting…" : "Restart"}</button>`
|
|
1373
|
+
: "";
|
|
1374
|
+
return `<span class="worker-badge" title="${escapeHtml(badge.title)}">${escapeHtml(badge.text)}${button}</span>`;
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
// Restart: the session moves to a new worker on the newest chi; the page
|
|
1378
|
+
// follows it (its bridge_up), the composer's draft stays as it is.
|
|
1379
|
+
async function restartWorker() {
|
|
1380
|
+
const badge = currentWorkerBadge();
|
|
1381
|
+
if (!badge || !badge.restart || restartingId) return;
|
|
1382
|
+
if (!confirm(restartConfirmText(current.workerVersion, badge.newest))) return;
|
|
1383
|
+
const id = selected;
|
|
1384
|
+
restartingId = id;
|
|
1385
|
+
updateInfoBar();
|
|
1386
|
+
try {
|
|
1387
|
+
const result = await restartSession(id);
|
|
1388
|
+
showToast(result.version ? `Restarted on chi ${result.version}` : "Restarting: the new worker is starting");
|
|
1389
|
+
} catch (e) {
|
|
1390
|
+
showToast(`Can't restart: ${String(e.message).replace(/ \(\d+\)$/, "")}`, null, { sticky: true });
|
|
1391
|
+
} finally {
|
|
1392
|
+
restartingId = null;
|
|
1393
|
+
updateInfoBar();
|
|
1394
|
+
}
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1333
1397
|
function updateInfoBar() {
|
|
1334
1398
|
const stopBtn = document.getElementById("infoStopBtn");
|
|
1335
1399
|
const deleteBtn = document.getElementById("infoDeleteBtn");
|
|
@@ -1362,7 +1426,7 @@ function updateInfoBar() {
|
|
|
1362
1426
|
const attachCmd = `chi --attach ${selected}`;
|
|
1363
1427
|
const copied = Date.now() - attachCopiedAt < 2000;
|
|
1364
1428
|
const copyHtml = `<button type="button" class="copy-attach" title="Copy "${escapeHtml(attachCmd)}" to attach a terminal">${copied ? "copied ✓" : "copy chi --attach"}</button>`;
|
|
1365
|
-
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>` : ""}</div>`;
|
|
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>`;
|
|
1366
1430
|
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>`;
|
|
1367
1431
|
infoTextEl.innerHTML = metaHtml + previewHtml;
|
|
1368
1432
|
infoTextEl.title = memoriesTooltip(current.usedMemories, current.preloadedMemories, current.mutedMemories);
|
|
@@ -1618,8 +1682,11 @@ const streamHandlers = {
|
|
|
1618
1682
|
const out = turnOutput(data);
|
|
1619
1683
|
if (out) appendChunk(out);
|
|
1620
1684
|
// after_turn hooks are running and may present the answer: its
|
|
1621
|
-
// answer_display (display: null when they didn't) comes next.
|
|
1622
|
-
|
|
1685
|
+
// answer_display (display: null when they didn't) comes next. A worker
|
|
1686
|
+
// that dies before it never sends one: workerGone releases the wait, and
|
|
1687
|
+
// displayWait's timeout ends it whatever happens (4.14).
|
|
1688
|
+
const display = data.display_pending ? displayWait() : null;
|
|
1689
|
+
if (display) displayPending = display;
|
|
1623
1690
|
turnEndRead = readTurnEnd();
|
|
1624
1691
|
await turnEndRead;
|
|
1625
1692
|
// The answer bubble the turn view held at its box's height pops now
|
|
@@ -1627,7 +1694,7 @@ const streamHandlers = {
|
|
|
1627
1694
|
// plain text pops), and the display if one was coming, so the links
|
|
1628
1695
|
// never swap in after the pop. The hold reveals by itself after
|
|
1629
1696
|
// HOLD_MS whatever comes. The stage has no hold.
|
|
1630
|
-
if (display) await display;
|
|
1697
|
+
if (display) await display.promise;
|
|
1631
1698
|
turnDom.answerReady?.();
|
|
1632
1699
|
},
|
|
1633
1700
|
// An after_turn hook presented the answer (AnswerDisplay): it comes after
|
|
@@ -1640,8 +1707,7 @@ const streamHandlers = {
|
|
|
1640
1707
|
if (Array.isArray(d.messages)) applyFinalMarkdown(d.messages);
|
|
1641
1708
|
} catch (_) {
|
|
1642
1709
|
} finally {
|
|
1643
|
-
|
|
1644
|
-
displayLanded = null;
|
|
1710
|
+
releaseDisplayWait();
|
|
1645
1711
|
}
|
|
1646
1712
|
},
|
|
1647
1713
|
turn_canceled: (data) => {
|
|
@@ -1822,7 +1888,9 @@ async function handleSendTurn() {
|
|
|
1822
1888
|
// An image-only send's text is its placeholders: not for ↑.
|
|
1823
1889
|
const typed = promptEl.value.trim() !== "";
|
|
1824
1890
|
if (!selected) {
|
|
1825
|
-
|
|
1891
|
+
// The start page's own reply (a web-local command) or the hint for a
|
|
1892
|
+
// typo: neither needs a session, so none is made for it (2.29).
|
|
1893
|
+
const local = startPageReplyOrHint(prompt, { commands: current.commands, images: chips.length });
|
|
1826
1894
|
if (local) {
|
|
1827
1895
|
promptEl.value = "";
|
|
1828
1896
|
fitPrompt();
|
|
@@ -1885,9 +1953,10 @@ async function handleSendTurn() {
|
|
|
1885
1953
|
if (ack?.enqueued_id) sentIds.add(ack.enqueued_id);
|
|
1886
1954
|
refreshHistory();
|
|
1887
1955
|
// A turn that failed fast was restored before this ack: its prompt
|
|
1888
|
-
// (and images) go back into the composer
|
|
1956
|
+
// (and images) go back into the composer, without replacing text typed
|
|
1957
|
+
// since (4.13). The composer still holds the text just sent: it goes.
|
|
1889
1958
|
const restored = restoreOnAck(ack?.enqueued_id, earlyRestores, { myId: clientId, sentIds });
|
|
1890
|
-
promptEl.value = restored?.refill || "";
|
|
1959
|
+
promptEl.value = composerAfterAck({ sent: prompt, current: promptEl.value, refill: restored?.refill || "" });
|
|
1891
1960
|
fitPrompt();
|
|
1892
1961
|
clearChips();
|
|
1893
1962
|
if (restored?.images?.length) setChips(restoredChips(selected, restored.images));
|
|
@@ -1903,6 +1972,10 @@ async function handleSendTurn() {
|
|
|
1903
1972
|
if (!streamControl?.live) startStream(selected, selectSeq ?? 0);
|
|
1904
1973
|
} catch (e) {
|
|
1905
1974
|
echo.remove();
|
|
1975
|
+
// The turn never reached the worker's queue: an early restore waiting
|
|
1976
|
+
// for its ack is never taken back (4.13), so drop it here. The prompt
|
|
1977
|
+
// is still in the composer, as the echo was removed.
|
|
1978
|
+
dropEarlyRestores(earlyRestores);
|
|
1906
1979
|
// The chips stay (uploaded ones keep their ref) for another try.
|
|
1907
1980
|
if (/bad_image|too_large/.test(e.message)) {
|
|
1908
1981
|
alert(e.message);
|