samagotchi 0.18.1 → 0.20.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 +167 -1
- data/README.md +4 -3
- data/docs/architecture.md +21 -2
- data/docs/cli.md +48 -11
- data/docs/configuration.md +12 -5
- data/docs/guardrails.md +92 -14
- data/docs/memory.md +4 -0
- data/docs/plugins.md +5 -0
- data/docs/sessions.md +7 -5
- data/lib/samagotchi/bootstrap/config_writer.rb +14 -3
- data/lib/samagotchi/bootstrap_command.rb +26 -5
- 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 +24 -5
- 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/mcp/manifest.yml +2 -2
- data/lib/samagotchi/bundles/mcp/plugin.rb +18 -4
- 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 +2 -2
- 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 +25 -5
- data/lib/samagotchi/guardrail_wiring.rb +10 -3
- data/lib/samagotchi/guardrails/approval.rb +14 -7
- data/lib/samagotchi/guardrails/approvals.rb +69 -15
- 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/host_registry.rb +22 -4
- data/lib/samagotchi/idle_recap.rb +7 -2
- 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/llm/usage.rb +1 -1
- data/lib/samagotchi/memory_bundle/builder.rb +25 -6
- data/lib/samagotchi/memory_bundle/index_sync.rb +1 -9
- data/lib/samagotchi/memory_bundle/installer.rb +79 -13
- data/lib/samagotchi/memory_bundle/shipped_update.rb +3 -1
- data/lib/samagotchi/memory_bundle/status.rb +17 -5
- data/lib/samagotchi/memory_bundle/uninstaller.rb +7 -7
- data/lib/samagotchi/memory_paths.rb +6 -0
- 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/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 +166 -11
- data/lib/samagotchi/session_metrics.rb +260 -32
- 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/formatting.rb +52 -1
- data/lib/samagotchi/terminal_ui/image_input.rb +11 -4
- data/lib/samagotchi/terminal_ui/version_lines.rb +48 -0
- data/lib/samagotchi/token_usage.rb +57 -41
- data/lib/samagotchi/tool_runner.rb +21 -2
- data/lib/samagotchi/tools/builtins.rb +21 -5
- data/lib/samagotchi/tools/execute.rb +9 -8
- 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 +83 -11
- data/lib/samagotchi/web/public/app.js +135 -43
- data/lib/samagotchi/web/public/card.js +34 -2
- data/lib/samagotchi/web/public/ctx.js +59 -0
- data/lib/samagotchi/web/public/data.js +6 -0
- data/lib/samagotchi/web/public/format.js +15 -2
- data/lib/samagotchi/web/public/index.html +37 -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/question_card.js +14 -1
- 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/timing.js +6 -2
- data/lib/samagotchi/web/public/turn_events.js +108 -4
- 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 +11 -2
- 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 +7 -1
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"
|
|
@@ -82,7 +83,7 @@ module Samagotchi
|
|
|
82
83
|
# lists and GET /api/events streams from (Server always builds one);
|
|
83
84
|
# without one both routes answer 503
|
|
84
85
|
# @param registry [HostRegistry, nil] the hosts GET /api/models lists
|
|
85
|
-
# (built from
|
|
86
|
+
# (nil: built from config.yml's hosts:, again when they change)
|
|
86
87
|
# @param models_wait_timeout [Float] bounded seconds GET /api/models
|
|
87
88
|
# waits for the hosts' lists
|
|
88
89
|
# @param lan [Hash, nil] LAN mode (web.host: lan): { ip:, token: },
|
|
@@ -96,6 +97,7 @@ module Samagotchi
|
|
|
96
97
|
registry: nil, models_wait_timeout: MODELS_WAIT_TIMEOUT, lan: nil)
|
|
97
98
|
@manager = manager || SessionManager
|
|
98
99
|
@registry = registry
|
|
100
|
+
@registry_given = !registry.nil?
|
|
99
101
|
@models_wait_timeout = models_wait_timeout
|
|
100
102
|
@models_mutex = Mutex.new
|
|
101
103
|
@models_thread = nil
|
|
@@ -138,6 +140,7 @@ module Samagotchi
|
|
|
138
140
|
["GET", %r{\A/api/sessions/([^/]+)/output\z}, :handle_output],
|
|
139
141
|
["POST", %r{\A/api/sessions/([^/]+)/cancel\z}, :handle_cancel],
|
|
140
142
|
["POST", %r{\A/api/sessions/([^/]+)/stop\z}, :handle_stop],
|
|
143
|
+
["POST", %r{\A/api/sessions/([^/]+)/restart\z}, :handle_restart],
|
|
141
144
|
["POST", %r{\A/api/sessions/([^/]+)/archive\z}, :handle_archive],
|
|
142
145
|
["POST", %r{\A/api/sessions/([^/]+)/unarchive\z}, :handle_unarchive],
|
|
143
146
|
["POST", %r{\A/api/sessions/([^/]+)/turn\z}, :handle_turn],
|
|
@@ -375,10 +378,12 @@ module Samagotchi
|
|
|
375
378
|
end
|
|
376
379
|
|
|
377
380
|
# What a second `chi web` probes before starting its own server: this
|
|
378
|
-
# is chi web, and it knows ?dir (features).
|
|
381
|
+
# is chi web, and it knows ?dir (features). installed: the newest chi
|
|
382
|
+
# on this machine (nil until the hub has looked); newest_workers: the
|
|
383
|
+
# workers it starts run that one, not this chi web's version.
|
|
379
384
|
def handle_info(_req)
|
|
380
|
-
json_response(200, { app: "chi-web", version: Samagotchi::VERSION,
|
|
381
|
-
features: [
|
|
385
|
+
json_response(200, { app: "chi-web", version: Samagotchi::VERSION, installed: @hub&.installed, pid: Process.pid,
|
|
386
|
+
cwd: Dir.pwd, features: %w[dir newest_workers], lan: @lan && @lan[:ip] })
|
|
382
387
|
end
|
|
383
388
|
|
|
384
389
|
# The models a new session can start on, spelled as chi spells them
|
|
@@ -436,13 +441,44 @@ module Samagotchi
|
|
|
436
441
|
if default && models.none? { |m| m[:name].casecmp?(default) }
|
|
437
442
|
models.unshift({ name: default, host: nil, id: default })
|
|
438
443
|
end
|
|
444
|
+
add_sampling(models, registry)
|
|
439
445
|
payload = { default: default, models: models }
|
|
440
446
|
payload[:warning] = warnings.join("; ") unless warnings.empty?
|
|
441
447
|
json_response(200, payload)
|
|
442
448
|
end
|
|
443
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
|
+
|
|
467
|
+
# Built again when config.yml's hosts: changed (compared as read: the
|
|
468
|
+
# YAML read is mtime-cached), so the model picker lists a host added
|
|
469
|
+
# while chi web runs. An injected registry (specs) stays.
|
|
444
470
|
def host_registry
|
|
445
|
-
@models_mutex.synchronize
|
|
471
|
+
@models_mutex.synchronize do
|
|
472
|
+
next @registry if @registry_given
|
|
473
|
+
|
|
474
|
+
hosts = ConfigFile.hosts_config
|
|
475
|
+
if @registry.nil? || hosts != @registry_hosts
|
|
476
|
+
@registry = HostRegistry.new(hosts_config: hosts)
|
|
477
|
+
@registry_hosts = hosts
|
|
478
|
+
@models_thread = nil # a listing of the old hosts
|
|
479
|
+
end
|
|
480
|
+
@registry
|
|
481
|
+
end
|
|
446
482
|
end
|
|
447
483
|
|
|
448
484
|
# The hosts' model lists (cached ones as they are), or the last complete
|
|
@@ -785,8 +821,10 @@ module Samagotchi
|
|
|
785
821
|
# worker_timeout (+what+ didn't happen), a 404 for a route the worker
|
|
786
822
|
# doesn't have → 501 not_supported (it runs an older chi that +cant+;
|
|
787
823
|
# 501, not 503: it is live, and the page reads 503 as "not running"),
|
|
788
|
-
#
|
|
789
|
-
#
|
|
824
|
+
# another unmapped status → 502 bridge_error with the worker's detail
|
|
825
|
+
# (a live worker that failed the request; never 503, which the page
|
|
826
|
+
# reads as "session not running"), and only no live bridge, a refused
|
|
827
|
+
# connection or one that says it isn't this session's → 503 not_live.
|
|
790
828
|
# @param request [#call] sends with the client, answers its Response
|
|
791
829
|
def relay(id, client, request, what:, cant:)
|
|
792
830
|
return not_live(id) unless client
|
|
@@ -801,7 +839,7 @@ module Samagotchi
|
|
|
801
839
|
return not_live(id) if reply.json&.dig("error") == "unknown_session"
|
|
802
840
|
|
|
803
841
|
error_response(501, "not_supported", BridgeClient.stale_worker_message(id, cant: cant))
|
|
804
|
-
else
|
|
842
|
+
else bridge_error(reply, what)
|
|
805
843
|
end
|
|
806
844
|
rescue Errno::ETIMEDOUT
|
|
807
845
|
worker_timeout(what)
|
|
@@ -813,6 +851,15 @@ module Samagotchi
|
|
|
813
851
|
error_response(503, "not_live", "no live bridge for session #{id}")
|
|
814
852
|
end
|
|
815
853
|
|
|
854
|
+
# A live worker refused or failed the request (a 500 in its handler, a
|
|
855
|
+
# status this route doesn't know): 502, with its detail when it sent
|
|
856
|
+
# one. 503 would have the page say the session isn't running, which is
|
|
857
|
+
# untrue and sends the user to restart a healthy worker.
|
|
858
|
+
def bridge_error(reply, what)
|
|
859
|
+
detail = reply_detail(reply, "#{what}: the session's worker answered #{reply.status}")
|
|
860
|
+
error_response(502, "bridge_error", detail)
|
|
861
|
+
end
|
|
862
|
+
|
|
816
863
|
# The Bridge's detail for a refusal, or +fallback+.
|
|
817
864
|
def reply_detail(reply, fallback)
|
|
818
865
|
reply.json&.dig("detail") || fallback
|
|
@@ -976,6 +1023,26 @@ module Samagotchi
|
|
|
976
1023
|
error_response(404, "not_found", e.message)
|
|
977
1024
|
end
|
|
978
1025
|
|
|
1026
|
+
# POST /api/sessions/:id/restart: hand the session to a new worker on
|
|
1027
|
+
# the newest chi installed (SessionManager.restart_session). 200
|
|
1028
|
+
# {status: "restarted", session_id, from_version, version} (version nil:
|
|
1029
|
+
# the new worker is still starting; its bridge_up follows); 409
|
|
1030
|
+
# {error: "held" | "not_running" | "unsupported" | "failed", reason, detail} in words; 409
|
|
1031
|
+
# owned_by_tui; 404.
|
|
1032
|
+
def handle_restart(_req, id)
|
|
1033
|
+
result = @manager.restart_session(id, state_dir: @state_dir, client_id: "web:restart")
|
|
1034
|
+
@hub&.touch(id)
|
|
1035
|
+
json_response(200, { status: "restarted", session_id: id, from_version: result.from_version,
|
|
1036
|
+
version: result.version })
|
|
1037
|
+
rescue SessionManager::RestartRefused => e
|
|
1038
|
+
error = %i[not_running unsupported failed].include?(e.reason) ? e.reason.to_s : "held"
|
|
1039
|
+
json_response(409, { error: error, reason: e.reason.to_s, detail: e.message, session_id: id })
|
|
1040
|
+
rescue SessionManager::OwnedByTUI => e
|
|
1041
|
+
error_response(409, "owned_by_tui", e.message)
|
|
1042
|
+
rescue ArgumentError => e
|
|
1043
|
+
error_response(404, "not_found", e.message)
|
|
1044
|
+
end
|
|
1045
|
+
|
|
979
1046
|
# POST /api/sessions/:id/archive: hide it (and its delegates) from the
|
|
980
1047
|
# lists, keep it for good. A live idle worker is stopped first; a turn
|
|
981
1048
|
# running in it or a delegate, a chi REPL's session and a scratch one
|
|
@@ -1062,7 +1129,8 @@ module Samagotchi
|
|
|
1062
1129
|
# GET /api/events: the session list as one SSE stream per tab. The
|
|
1063
1130
|
# first frame is the hub's snapshot (scoped by ?dir= as the list is,
|
|
1064
1131
|
# with chi's version),
|
|
1065
|
-
# then `session` for an upsert
|
|
1132
|
+
# then `session` for an upsert, `session_gone` for a removal and `chi`
|
|
1133
|
+
# when the newest installed chi changed, with
|
|
1066
1134
|
# a `: ping` while idle. Frame ids are the hub's seq, for the log's
|
|
1067
1135
|
# sake: there is no replay, a reconnect starts with a fresh snapshot.
|
|
1068
1136
|
def handle_events(req)
|
|
@@ -1598,11 +1666,15 @@ module Samagotchi
|
|
|
1598
1666
|
@server_running = server_running
|
|
1599
1667
|
@queue = Bridge::BoundedQueue.new(capacity: capacity)
|
|
1600
1668
|
@handle, @snapshot = @hub.subscribe(->(event) { @queue.push(event) }, snapshot: true, project_root: project_root)
|
|
1669
|
+
# Read after subscribing: a change in between is also a queued event.
|
|
1670
|
+
@installed = @hub.installed
|
|
1601
1671
|
end
|
|
1602
1672
|
|
|
1603
1673
|
def each
|
|
1604
|
-
# version: the page compares it with the one it was served by
|
|
1605
|
-
|
|
1674
|
+
# version: the page compares it with the one it was served by;
|
|
1675
|
+
# installed: the newest chi on this machine (a later change is a
|
|
1676
|
+
# `chi` event).
|
|
1677
|
+
yield frame(nil, "snapshot", sessions: @snapshot, version: Samagotchi::VERSION, installed: @installed)
|
|
1606
1678
|
last_write = monotonic
|
|
1607
1679
|
loop do
|
|
1608
1680
|
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,
|
|
@@ -19,7 +20,7 @@ import { MODEL_KEY } from "./model_pick.js";
|
|
|
19
20
|
import { emptySessionState } from "./session_state.js";
|
|
20
21
|
import { createModelPicker } from "./model_picker.js";
|
|
21
22
|
import { chipName, chipsHtml, imageFiles, restoredChips, thumbsHtml, turnRefs, turnText } from "./images.js";
|
|
22
|
-
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";
|
|
23
24
|
import { EMPTY_ANSWER_CLASS, createTurnView, turnHistoryHtml } from "./turn_view.js";
|
|
24
25
|
import { createStageView } from "./stage_view.js";
|
|
25
26
|
import { appendQuote } from "./annotations.js";
|
|
@@ -27,9 +28,9 @@ import { parsePresets } from "./annotate_presets.js";
|
|
|
27
28
|
import { createAnnotateBar } from "./annotate_bar.js";
|
|
28
29
|
import { COPY_SOURCE_ATTR, copySource, copyText, installCopy, showsAnswer } from "./copy.js";
|
|
29
30
|
import { routeChunk } from "./chunk_router.js";
|
|
30
|
-
import { extractCtxPct, savedCtxPct, cardCtxText } from "./ctx.js";
|
|
31
|
+
import { extractCtxPct, savedCtxPct, cardCtxText, generationTokens, lastSpeedText, speedText, tokensTipText } from "./ctx.js";
|
|
31
32
|
import { createQuestionCards } from "./question_cards.js";
|
|
32
|
-
import { cardClass, cardInnerHtml, cardPlace, isCard, splitSnapshotCards, turnNoticePlace } from "./card.js";
|
|
33
|
+
import { cardClass, cardInnerHtml, cardPlace, isCard, isNoticeCard, noticeInnerHtml, splitSnapshotCards, turnNoticePlace } from "./card.js";
|
|
33
34
|
import { gripFloor, promptHeight } from "./composer_size.js";
|
|
34
35
|
import { createCommandList } from "./command_list.js";
|
|
35
36
|
import { createPromptHistory } from "./prompt_history.js";
|
|
@@ -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
|
|
|
@@ -304,7 +317,7 @@ function cardHtml(s, { deletable = false } = {}) {
|
|
|
304
317
|
const folderHtml = folder ? `<span class="folder" title="${escapeHtml(s.working_directory)}">${escapeHtml(folder)}</span>` : "";
|
|
305
318
|
const archivedHtml = s.archived ? `<span class="archived-badge" title="Archived: hidden from the lists, kept for good">archived</span>` : "";
|
|
306
319
|
const ctx = cardCtxText(s.ctx_pct);
|
|
307
|
-
const ctxHtml = ctx ? `<span class="ctx" title="
|
|
320
|
+
const ctxHtml = ctx ? `<span class="ctx" title="${escapeHtml(tokensTipText(s.tokens))}">${escapeHtml(ctx)}</span>` : "";
|
|
308
321
|
const waiting = waitingBadge(s);
|
|
309
322
|
const waitingHtml = waiting ? `<span class="attn attn-${waiting.kind}" title="${escapeHtml(waiting.title)}">${escapeHtml(waiting.text)}</span>` : "";
|
|
310
323
|
const dotWord = waiting ? waiting.title : s.status;
|
|
@@ -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;
|
|
@@ -1150,13 +1173,26 @@ function cardElement(id) {
|
|
|
1150
1173
|
return findInTurnRoots(".plugin-card").find((el) => el.dataset.cardId === String(id)) || null;
|
|
1151
1174
|
}
|
|
1152
1175
|
|
|
1176
|
+
// A notice is a <details> (a one-line row that opens), a card a <div>: a
|
|
1177
|
+
// card that turns into the other kind (btw's "thinking…" → its answer) is
|
|
1178
|
+
// drawn on a new element in its place. Returns the element drawn.
|
|
1153
1179
|
function drawCard(el, card) {
|
|
1180
|
+
const notice = isNoticeCard(card);
|
|
1181
|
+
if (el.tagName !== (notice ? "DETAILS" : "DIV")) {
|
|
1182
|
+
const fresh = document.createElement(notice ? "details" : "div");
|
|
1183
|
+
el.replaceWith(fresh);
|
|
1184
|
+
el = fresh;
|
|
1185
|
+
}
|
|
1154
1186
|
el.className = cardClass(card);
|
|
1155
1187
|
el.dataset.cardId = card.id;
|
|
1156
1188
|
// A running turn's own card (the stage's status waits on one that asks).
|
|
1157
1189
|
if (card.in_turn) el.dataset.inTurn = "1";
|
|
1158
1190
|
else delete el.dataset.inTurn;
|
|
1159
|
-
if (
|
|
1191
|
+
if (notice) {
|
|
1192
|
+
const open = el.open;
|
|
1193
|
+
el.innerHTML = noticeInnerHtml(card);
|
|
1194
|
+
el.open = open;
|
|
1195
|
+
} else if (card.body_html === undefined && el.dataset.body === String(card.body ?? "") && el.querySelector(".card-body")) {
|
|
1160
1196
|
// The same body again from the stream: keep the rendered one.
|
|
1161
1197
|
const body = el.querySelector(".card-body").innerHTML;
|
|
1162
1198
|
el.innerHTML = cardInnerHtml({ ...card, body_html: body });
|
|
@@ -1164,6 +1200,7 @@ function drawCard(el, card) {
|
|
|
1164
1200
|
el.innerHTML = cardInnerHtml(card);
|
|
1165
1201
|
}
|
|
1166
1202
|
el.dataset.body = String(card.body ?? "");
|
|
1203
|
+
return el;
|
|
1167
1204
|
}
|
|
1168
1205
|
|
|
1169
1206
|
// Show +card+: update the one with its id where it is, else add it (a
|
|
@@ -1175,14 +1212,13 @@ function upsertCard(card, { before = null } = {}) {
|
|
|
1175
1212
|
if (!card || !card.id) return;
|
|
1176
1213
|
const existing = cardElement(card.id);
|
|
1177
1214
|
if (existing) {
|
|
1178
|
-
drawCard(existing, card);
|
|
1179
|
-
|
|
1180
|
-
setTimeout(() =>
|
|
1215
|
+
const drawn = drawCard(existing, card);
|
|
1216
|
+
drawn.classList.add("card-updated");
|
|
1217
|
+
setTimeout(() => drawn.classList.remove("card-updated"), 900);
|
|
1181
1218
|
return;
|
|
1182
1219
|
}
|
|
1183
1220
|
removeHintIfEmpty();
|
|
1184
|
-
const el = document.createElement("div");
|
|
1185
|
-
drawCard(el, card);
|
|
1221
|
+
const el = drawCard(document.createElement(isNoticeCard(card) ? "details" : "div"), card);
|
|
1186
1222
|
const asks = (card.actions || []).some((a) => a && a.command);
|
|
1187
1223
|
if (card.in_turn && !before && !asks && turnDom.card(el)) return;
|
|
1188
1224
|
const follow = isNearBottom();
|
|
@@ -1322,6 +1358,9 @@ function removeRecap() {
|
|
|
1322
1358
|
// The info bar's "copy chi --attach": the full id, which the page shows
|
|
1323
1359
|
// only short; "copied ✓" for 2 s.
|
|
1324
1360
|
let attachCopiedAt = 0;
|
|
1361
|
+
infoTextEl.addEventListener("click", (e) => {
|
|
1362
|
+
if (e.target.closest(".worker-restart")) restartWorker();
|
|
1363
|
+
});
|
|
1325
1364
|
infoTextEl.addEventListener("click", async (e) => {
|
|
1326
1365
|
if (!e.target.closest(".copy-attach") || !selected) return;
|
|
1327
1366
|
if (!(await copyText(`chi --attach ${selected}`))) return;
|
|
@@ -1330,6 +1369,44 @@ infoTextEl.addEventListener("click", async (e) => {
|
|
|
1330
1369
|
setTimeout(updateInfoBar, 2000);
|
|
1331
1370
|
});
|
|
1332
1371
|
|
|
1372
|
+
// The open session's worker runs an older chi than the newest installed:
|
|
1373
|
+
// a badge, with Restart when the worker can (else the stop command).
|
|
1374
|
+
function currentWorkerBadge() {
|
|
1375
|
+
if (!selected || current.owner !== "worker") return null;
|
|
1376
|
+
return workerBadge({ workerVersion: current.workerVersion, features: current.workerFeatures,
|
|
1377
|
+
installed: versions.installed, served: versions.served, sessionId: selected });
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
function workerBadgeHtml() {
|
|
1381
|
+
const badge = currentWorkerBadge();
|
|
1382
|
+
if (!badge) return "";
|
|
1383
|
+
const busy = restartingId === selected;
|
|
1384
|
+
const button = badge.restart
|
|
1385
|
+
? `<button type="button" class="worker-restart"${busy ? " disabled" : ""}>${busy ? "restarting…" : "Restart"}</button>`
|
|
1386
|
+
: "";
|
|
1387
|
+
return `<span class="worker-badge" title="${escapeHtml(badge.title)}">${escapeHtml(badge.text)}${button}</span>`;
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
// Restart: the session moves to a new worker on the newest chi; the page
|
|
1391
|
+
// follows it (its bridge_up), the composer's draft stays as it is.
|
|
1392
|
+
async function restartWorker() {
|
|
1393
|
+
const badge = currentWorkerBadge();
|
|
1394
|
+
if (!badge || !badge.restart || restartingId) return;
|
|
1395
|
+
if (!confirm(restartConfirmText(current.workerVersion, badge.newest))) return;
|
|
1396
|
+
const id = selected;
|
|
1397
|
+
restartingId = id;
|
|
1398
|
+
updateInfoBar();
|
|
1399
|
+
try {
|
|
1400
|
+
const result = await restartSession(id);
|
|
1401
|
+
showToast(result.version ? `Restarted on chi ${result.version}` : "Restarting: the new worker is starting");
|
|
1402
|
+
} catch (e) {
|
|
1403
|
+
showToast(`Can't restart: ${String(e.message).replace(/ \(\d+\)$/, "")}`, null, { sticky: true });
|
|
1404
|
+
} finally {
|
|
1405
|
+
restartingId = null;
|
|
1406
|
+
updateInfoBar();
|
|
1407
|
+
}
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1333
1410
|
function updateInfoBar() {
|
|
1334
1411
|
const stopBtn = document.getElementById("infoStopBtn");
|
|
1335
1412
|
const deleteBtn = document.getElementById("infoDeleteBtn");
|
|
@@ -1352,6 +1429,11 @@ function updateInfoBar() {
|
|
|
1352
1429
|
const idShort = selected.slice(0,8);
|
|
1353
1430
|
const memText = current.usedMemories.length ? current.usedMemories.join(", ") : "—";
|
|
1354
1431
|
const ctxText = current.ctxPct !== null ? `ctx ${Math.round(current.ctxPct)}%` : "";
|
|
1432
|
+
const tokens = current.timing.tokens;
|
|
1433
|
+
const ctxTip = tokensTipText(tokens);
|
|
1434
|
+
const speedTextNow = lastSpeedText(tokens);
|
|
1435
|
+
const avgSpeed = speedText(tokens?.avg_decode_tps, tokens?.tps_source);
|
|
1436
|
+
const speedTip = `Decode speed of the last generation${avgSpeed ? `; session average ${avgSpeed}` : ""}`;
|
|
1355
1437
|
const sessionDuration = currentSessionDuration();
|
|
1356
1438
|
const timingText = sessionDuration === null ? "" : `session ${formatDuration(sessionDuration)}`;
|
|
1357
1439
|
const statusClass = current.status ? `status ${escapeHtml(current.status)}` : "status";
|
|
@@ -1362,7 +1444,7 @@ function updateInfoBar() {
|
|
|
1362
1444
|
const attachCmd = `chi --attach ${selected}`;
|
|
1363
1445
|
const copied = Date.now() - attachCopiedAt < 2000;
|
|
1364
1446
|
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>`;
|
|
1447
|
+
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>`;
|
|
1366
1448
|
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
1449
|
infoTextEl.innerHTML = metaHtml + previewHtml;
|
|
1368
1450
|
infoTextEl.title = memoriesTooltip(current.usedMemories, current.preloadedMemories, current.mutedMemories);
|
|
@@ -1584,10 +1666,10 @@ const streamHandlers = {
|
|
|
1584
1666
|
},
|
|
1585
1667
|
generation_completed: (data) => {
|
|
1586
1668
|
setLiveTurnNote("");
|
|
1587
|
-
if (data?.served_model)
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1669
|
+
if (data?.served_model) current.served = [data.served_model, data.requested_model];
|
|
1670
|
+
// The generation's speed and the session's token totals, from the server.
|
|
1671
|
+
current.timing.tokens = generationTokens(current.timing.tokens, data);
|
|
1672
|
+
if (data?.served_model || data?.tokens) updateInfoBar();
|
|
1591
1673
|
turnDom.generationCompleted();
|
|
1592
1674
|
},
|
|
1593
1675
|
// Prompts sent while a turn ran merge into it at an iteration boundary:
|
|
@@ -1618,8 +1700,11 @@ const streamHandlers = {
|
|
|
1618
1700
|
const out = turnOutput(data);
|
|
1619
1701
|
if (out) appendChunk(out);
|
|
1620
1702
|
// after_turn hooks are running and may present the answer: its
|
|
1621
|
-
// answer_display (display: null when they didn't) comes next.
|
|
1622
|
-
|
|
1703
|
+
// answer_display (display: null when they didn't) comes next. A worker
|
|
1704
|
+
// that dies before it never sends one: workerGone releases the wait, and
|
|
1705
|
+
// displayWait's timeout ends it whatever happens (4.14).
|
|
1706
|
+
const display = data.display_pending ? displayWait() : null;
|
|
1707
|
+
if (display) displayPending = display;
|
|
1623
1708
|
turnEndRead = readTurnEnd();
|
|
1624
1709
|
await turnEndRead;
|
|
1625
1710
|
// The answer bubble the turn view held at its box's height pops now
|
|
@@ -1627,7 +1712,7 @@ const streamHandlers = {
|
|
|
1627
1712
|
// plain text pops), and the display if one was coming, so the links
|
|
1628
1713
|
// never swap in after the pop. The hold reveals by itself after
|
|
1629
1714
|
// HOLD_MS whatever comes. The stage has no hold.
|
|
1630
|
-
if (display) await display;
|
|
1715
|
+
if (display) await display.promise;
|
|
1631
1716
|
turnDom.answerReady?.();
|
|
1632
1717
|
},
|
|
1633
1718
|
// An after_turn hook presented the answer (AnswerDisplay): it comes after
|
|
@@ -1640,8 +1725,7 @@ const streamHandlers = {
|
|
|
1640
1725
|
if (Array.isArray(d.messages)) applyFinalMarkdown(d.messages);
|
|
1641
1726
|
} catch (_) {
|
|
1642
1727
|
} finally {
|
|
1643
|
-
|
|
1644
|
-
displayLanded = null;
|
|
1728
|
+
releaseDisplayWait();
|
|
1645
1729
|
}
|
|
1646
1730
|
},
|
|
1647
1731
|
turn_canceled: (data) => {
|
|
@@ -1822,7 +1906,9 @@ async function handleSendTurn() {
|
|
|
1822
1906
|
// An image-only send's text is its placeholders: not for ↑.
|
|
1823
1907
|
const typed = promptEl.value.trim() !== "";
|
|
1824
1908
|
if (!selected) {
|
|
1825
|
-
|
|
1909
|
+
// The start page's own reply (a web-local command) or the hint for a
|
|
1910
|
+
// typo: neither needs a session, so none is made for it (2.29).
|
|
1911
|
+
const local = startPageReplyOrHint(prompt, { commands: current.commands, images: chips.length });
|
|
1826
1912
|
if (local) {
|
|
1827
1913
|
promptEl.value = "";
|
|
1828
1914
|
fitPrompt();
|
|
@@ -1885,9 +1971,10 @@ async function handleSendTurn() {
|
|
|
1885
1971
|
if (ack?.enqueued_id) sentIds.add(ack.enqueued_id);
|
|
1886
1972
|
refreshHistory();
|
|
1887
1973
|
// A turn that failed fast was restored before this ack: its prompt
|
|
1888
|
-
// (and images) go back into the composer
|
|
1974
|
+
// (and images) go back into the composer, without replacing text typed
|
|
1975
|
+
// since (4.13). The composer still holds the text just sent: it goes.
|
|
1889
1976
|
const restored = restoreOnAck(ack?.enqueued_id, earlyRestores, { myId: clientId, sentIds });
|
|
1890
|
-
promptEl.value = restored?.refill || "";
|
|
1977
|
+
promptEl.value = composerAfterAck({ sent: prompt, current: promptEl.value, refill: restored?.refill || "" });
|
|
1891
1978
|
fitPrompt();
|
|
1892
1979
|
clearChips();
|
|
1893
1980
|
if (restored?.images?.length) setChips(restoredChips(selected, restored.images));
|
|
@@ -1903,6 +1990,10 @@ async function handleSendTurn() {
|
|
|
1903
1990
|
if (!streamControl?.live) startStream(selected, selectSeq ?? 0);
|
|
1904
1991
|
} catch (e) {
|
|
1905
1992
|
echo.remove();
|
|
1993
|
+
// The turn never reached the worker's queue: an early restore waiting
|
|
1994
|
+
// for its ack is never taken back (4.13), so drop it here. The prompt
|
|
1995
|
+
// is still in the composer, as the echo was removed.
|
|
1996
|
+
dropEarlyRestores(earlyRestores);
|
|
1906
1997
|
// The chips stay (uploaded ones keep their ref) for another try.
|
|
1907
1998
|
if (/bad_image|too_large/.test(e.message)) {
|
|
1908
1999
|
alert(e.message);
|
|
@@ -2359,6 +2450,7 @@ shortWindow.addEventListener("change", () => {
|
|
|
2359
2450
|
applyStripHidden();
|
|
2360
2451
|
});
|
|
2361
2452
|
$("#stripShow").addEventListener("click", () => setStripHidden(false));
|
|
2453
|
+
$("#stripHideTop").addEventListener("click", () => setStripHidden(true));
|
|
2362
2454
|
|
|
2363
2455
|
$("#filter").addEventListener("input", renderAll);
|
|
2364
2456
|
// 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
|