samagotchi 0.79.0 → 0.80.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 +30 -1
- data/README.md +7 -5
- data/docs/cli.md +24 -7
- data/docs/configuration.md +7 -4
- data/docs/internals/anthropic.md +3 -1
- data/docs/internals/bundles.md +4 -0
- data/docs/internals/engine.md +13 -3
- data/docs/plugins.md +144 -3
- data/docs/releasing.md +2 -2
- data/docs/sessions.md +1 -1
- data/docs/testing.md +32 -2
- data/lib/samagotchi/bootstrap/config_writer.rb +13 -3
- data/lib/samagotchi/bootstrap/probe.rb +153 -31
- data/lib/samagotchi/bootstrap_command.rb +92 -15
- data/lib/samagotchi/bridge.rb +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/self_map.md +1 -1
- data/lib/samagotchi/continue_offer.rb +5 -5
- data/lib/samagotchi/engine.rb +40 -37
- data/lib/samagotchi/guardrails/approval.rb +6 -4
- data/lib/samagotchi/guardrails/gate.rb +7 -3
- data/lib/samagotchi/hooks/bundle_loader.rb +31 -16
- data/lib/samagotchi/hooks/registry.rb +44 -3
- data/lib/samagotchi/host_registry.rb +12 -4
- data/lib/samagotchi/llm/adapter_common.rb +4 -2
- data/lib/samagotchi/llm/anthropic_binding_recovery.rb +3 -0
- data/lib/samagotchi/llm/anthropic_messages.rb +2 -1
- data/lib/samagotchi/llm/http.rb +12 -7
- data/lib/samagotchi/plugin/card.rb +44 -0
- data/lib/samagotchi/plugin/context.rb +53 -8
- data/lib/samagotchi/plugin/http.rb +154 -0
- data/lib/samagotchi/plugin/http_transport.rb +77 -0
- data/lib/samagotchi/plugin/loader.rb +29 -10
- data/lib/samagotchi/question_answer.rb +29 -0
- data/lib/samagotchi/question_desk.rb +20 -19
- data/lib/samagotchi/relay_verifier.rb +1 -1
- data/lib/samagotchi/testing/boundary.rb +156 -0
- data/lib/samagotchi/testing/bundle.rb +349 -0
- data/lib/samagotchi/testing/fake_executable.rb +42 -0
- data/lib/samagotchi/testing/fake_http.rb +137 -0
- data/lib/samagotchi/testing/helpers.rb +56 -0
- data/lib/samagotchi/testing/host.rb +76 -0
- data/lib/samagotchi/testing/minitest.rb +31 -0
- data/lib/samagotchi/testing/recorder.rb +138 -0
- data/lib/samagotchi/testing/recording_api.rb +27 -0
- data/lib/samagotchi/testing/rspec.rb +31 -0
- data/lib/samagotchi/testing/sandbox.rb +163 -0
- data/lib/samagotchi/testing/sessions.rb +77 -0
- data/lib/samagotchi/testing/verdict.rb +28 -0
- data/lib/samagotchi/testing/waiting.rb +36 -0
- data/lib/samagotchi/testing.rb +26 -0
- data/lib/samagotchi/tools/delegate_relay.rb +7 -6
- data/lib/samagotchi/tools/tool_index.rb +3 -0
- data/lib/samagotchi/turn_state.rb +28 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/public/app.js +47 -23
- data/lib/samagotchi/web/public/images.js +33 -0
- data/lib/samagotchi/web/public/index.html +71 -38
- data/lib/samagotchi/web/public/level_chip.js +11 -11
- data/lib/samagotchi/web/public/lists_view.js +19 -14
- data/lib/samagotchi/web/public/llm_context_chip.js +15 -15
- data/lib/samagotchi/web/public/model_picker.js +1 -1
- data/lib/samagotchi/web/public/scope.js +3 -3
- data/lib/samagotchi/web/public/strip.js +35 -9
- data/lib/samagotchi/web/public/strip_grip.js +76 -0
- data/lib/samagotchi/web/public/thinking_chip.js +5 -5
- metadata +21 -1
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../plugin/context"
|
|
4
|
+
|
|
5
|
+
module Samagotchi
|
|
6
|
+
module Testing
|
|
7
|
+
# One ctx.sessions call: the method's name and its arguments.
|
|
8
|
+
SessionCall = Data.define(:name, :args, :options)
|
|
9
|
+
|
|
10
|
+
# ctx.sessions recorded (Plugin::Sessions' methods, same signatures:
|
|
11
|
+
# the contract spec checks): each call lands in #calls and returns a
|
|
12
|
+
# scripted answer (#on), or by default: fork a new id ("fork-1"…), send
|
|
13
|
+
# and stop the id they got, children [], read raises
|
|
14
|
+
# Plugin::Sessions::Error (no such session).
|
|
15
|
+
#
|
|
16
|
+
# bundle.sessions.on(:children) { |all:| [{ id: "c1", status: "running" }] }
|
|
17
|
+
# bundle.sessions.on(:send) { |id, text, delivery:| raise Samagotchi::Plugin::Sessions::Error, "busy" }
|
|
18
|
+
class Sessions
|
|
19
|
+
METHODS = %i[fork send read children stop].freeze
|
|
20
|
+
|
|
21
|
+
def initialize
|
|
22
|
+
@calls = []
|
|
23
|
+
@scripts = {}
|
|
24
|
+
@forks = 0
|
|
25
|
+
@mutex = Mutex.new
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# @return [Array<SessionCall>]
|
|
29
|
+
def calls = @mutex.synchronize { @calls.dup }
|
|
30
|
+
|
|
31
|
+
# The calls of one method.
|
|
32
|
+
def calls_to(method) = calls.select { |call| call.name == method }
|
|
33
|
+
|
|
34
|
+
# Answer +method+'s calls with the block (it gets the call's
|
|
35
|
+
# arguments; a raise is the call's).
|
|
36
|
+
def on(method, &block)
|
|
37
|
+
raise ArgumentError, "ctx.sessions has no #{method} (#{METHODS.join(", ")})" unless METHODS.include?(method)
|
|
38
|
+
raise ArgumentError, "on(#{method}) needs a block" unless block
|
|
39
|
+
|
|
40
|
+
@mutex.synchronize { @scripts[method] = block }
|
|
41
|
+
self
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def fork(messages:, title: nil, prompt: nil)
|
|
45
|
+
answer(:fork, [], { messages: messages, title: title, prompt: prompt }) do
|
|
46
|
+
"fork-#{@mutex.synchronize { @forks += 1 }}"
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def send(id, text, delivery: nil)
|
|
51
|
+
answer(:send, [id, text], { delivery: delivery }) { id }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def read(id)
|
|
55
|
+
answer(:read, [id], {}) { raise Plugin::Sessions::Error, "no session #{id}" }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def children(all: false)
|
|
59
|
+
answer(:children, [], { all: all }) { [] }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def stop(id)
|
|
63
|
+
answer(:stop, [id], {}) { id }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def answer(method, args, options)
|
|
69
|
+
script = @mutex.synchronize do
|
|
70
|
+
@calls << SessionCall.new(name: method, args: args.freeze, options: options.freeze)
|
|
71
|
+
@scripts[method]
|
|
72
|
+
end
|
|
73
|
+
script ? script.call(*args, **options) : yield
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../guardrails/verdict"
|
|
4
|
+
|
|
5
|
+
module Samagotchi
|
|
6
|
+
module Testing
|
|
7
|
+
# One deny! or ask! a voter cast, kept or not.
|
|
8
|
+
Vote = Data.define(:decision, :reason, :options)
|
|
9
|
+
|
|
10
|
+
# A real Guardrails::Verdict that also records every deny! and ask!
|
|
11
|
+
# (#votes), for tests that count votes or check their keywords
|
|
12
|
+
# (advice:, rule:), even the ones a stricter earlier vote ignored.
|
|
13
|
+
class Verdict < Guardrails::Verdict
|
|
14
|
+
# @return [Array<Vote>]
|
|
15
|
+
def votes = (@votes ||= [])
|
|
16
|
+
|
|
17
|
+
def deny!(reason, **options)
|
|
18
|
+
votes << Vote.new(decision: :deny, reason: reason, options: options)
|
|
19
|
+
super
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def ask!(reason, **options)
|
|
23
|
+
votes << Vote.new(decision: :ask, reason: reason, options: options)
|
|
24
|
+
super
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Samagotchi
|
|
4
|
+
module Testing
|
|
5
|
+
# Waiting with a deadline, for a test that waits on a thread or a
|
|
6
|
+
# process (Helpers has them).
|
|
7
|
+
module Waiting
|
|
8
|
+
# The block's value once truthy, polled every +interval+ s; after
|
|
9
|
+
# +timeout+ s its last (falsy) value.
|
|
10
|
+
def wait_until(timeout: 5, interval: 0.01)
|
|
11
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
|
|
12
|
+
until (value = yield)
|
|
13
|
+
return yield if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
|
|
14
|
+
|
|
15
|
+
sleep(interval)
|
|
16
|
+
end
|
|
17
|
+
value
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# #wait_until, raising "timed out after N s waiting for <what>" when
|
|
21
|
+
# the time runs out: for a wait no check follows.
|
|
22
|
+
def wait_until!(what, timeout: 5, interval: 0.01, &)
|
|
23
|
+
wait_until(timeout: timeout, interval: interval, &) || raise("timed out after #{timeout} s waiting for #{what}")
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Whether no process +pid+ is left (a killed grandchild is reaped a
|
|
27
|
+
# moment later: wait_until { process_gone?(pid) }).
|
|
28
|
+
def process_gone?(pid)
|
|
29
|
+
Process.kill(0, pid)
|
|
30
|
+
false
|
|
31
|
+
rescue Errno::ESRCH
|
|
32
|
+
true
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# The bundle test kit (docs/plugins.md, "Testing a bundle"): load a bundle's
|
|
4
|
+
# plugin and hook files through chi's own loaders, registries, Context and
|
|
5
|
+
# guardrail Gate, with only the edges recorded (the session's Host, the hook
|
|
6
|
+
# runtime, ctx.sessions, init tasks), in a sandboxed environment.
|
|
7
|
+
#
|
|
8
|
+
# EXPERIMENTAL: it may change in any release before 1.0.0; it is not part of
|
|
9
|
+
# "Stable for bundles".
|
|
10
|
+
#
|
|
11
|
+
# require "samagotchi/testing" # plain Ruby
|
|
12
|
+
# require "samagotchi/testing/rspec" # RSpec helpers
|
|
13
|
+
# require "samagotchi/testing/minitest" # Samagotchi::Testing::Case
|
|
14
|
+
require_relative "version"
|
|
15
|
+
require_relative "testing/sandbox"
|
|
16
|
+
require_relative "testing/recorder"
|
|
17
|
+
require_relative "testing/host"
|
|
18
|
+
require_relative "testing/sessions"
|
|
19
|
+
require_relative "testing/fake_http"
|
|
20
|
+
require_relative "testing/verdict"
|
|
21
|
+
require_relative "testing/recording_api"
|
|
22
|
+
require_relative "testing/waiting"
|
|
23
|
+
require_relative "testing/fake_executable"
|
|
24
|
+
require_relative "testing/bundle"
|
|
25
|
+
require_relative "testing/helpers"
|
|
26
|
+
require_relative "testing/boundary"
|
|
@@ -5,6 +5,7 @@ require_relative "../bridge_client"
|
|
|
5
5
|
require_relative "../relay"
|
|
6
6
|
require_relative "../guardrails/approval"
|
|
7
7
|
require_relative "../log"
|
|
8
|
+
require_relative "../question_answer"
|
|
8
9
|
|
|
9
10
|
module Samagotchi
|
|
10
11
|
# Loaded on first use (a require cycle otherwise; see delegate.rb).
|
|
@@ -84,10 +85,10 @@ module Samagotchi
|
|
|
84
85
|
return closed(answer[:reason].to_s, what, client: client, relay_id: relay_id, qid: qid)
|
|
85
86
|
end
|
|
86
87
|
|
|
87
|
-
dismissed = !
|
|
88
|
-
by = answer.is_a?(
|
|
89
|
-
desk.record(relay_id, selected_indices: dismissed ? [] :
|
|
90
|
-
freeform: dismissed ? nil : answer
|
|
88
|
+
dismissed = !answer.is_a?(QuestionAnswer)
|
|
89
|
+
by = answer.is_a?(QuestionAnswer) && answer.parent_agent? ? "parent_agent" : "user"
|
|
90
|
+
desk.record(relay_id, selected_indices: dismissed ? [] : answer.selected_indices,
|
|
91
|
+
freeform: dismissed ? nil : answer.freeform, dismissed: dismissed, by: by)
|
|
91
92
|
response = post(client, "answered", relay_id, qid)
|
|
92
93
|
Outcome.new(line: line(what, delivered(response, answer, fields, dismissed)), stopped: false)
|
|
93
94
|
end
|
|
@@ -118,7 +119,7 @@ module Samagotchi
|
|
|
118
119
|
# allowed once / allowed for its session / denied ("why")
|
|
119
120
|
def verdict(answer, fields)
|
|
120
121
|
scopes = Array(fields.dig(:approval, :scopes))
|
|
121
|
-
index =
|
|
122
|
+
index = answer.selected_indices.first
|
|
122
123
|
if index && index < scopes.size
|
|
123
124
|
return case scopes[index].to_s
|
|
124
125
|
when "once" then "allowed once"
|
|
@@ -129,7 +130,7 @@ module Samagotchi
|
|
|
129
130
|
end
|
|
130
131
|
end
|
|
131
132
|
|
|
132
|
-
why = answer
|
|
133
|
+
why = answer.freeform.to_s.strip
|
|
133
134
|
why.empty? ? "denied" : "denied (#{why.inspect})"
|
|
134
135
|
end
|
|
135
136
|
|
|
@@ -194,6 +194,9 @@ module Samagotchi
|
|
|
194
194
|
nil
|
|
195
195
|
end
|
|
196
196
|
|
|
197
|
+
# @return [Array<Source>] the sources added, in order
|
|
198
|
+
def sources = @sources.dup
|
|
199
|
+
|
|
197
200
|
# Every group, now: the index's own (#registry_groups), then the
|
|
198
201
|
# sources'. A source group with a reserved name, or one an earlier
|
|
199
202
|
# group has, is left out of search, lookup and the description (said
|
|
@@ -22,6 +22,8 @@ module Samagotchi
|
|
|
22
22
|
@running = false
|
|
23
23
|
@controller = nil
|
|
24
24
|
@sink = nil
|
|
25
|
+
@thread = nil
|
|
26
|
+
@ended = false
|
|
25
27
|
@steers = []
|
|
26
28
|
@carried_steers = []
|
|
27
29
|
end
|
|
@@ -64,12 +66,15 @@ module Samagotchi
|
|
|
64
66
|
@lock.synchronize { @running }
|
|
65
67
|
end
|
|
66
68
|
|
|
67
|
-
# A turn begins
|
|
69
|
+
# A turn begins, on the calling thread (the turn's own): running, with
|
|
70
|
+
# its controller and sink, in one step.
|
|
68
71
|
def begin!(controller:, sink:)
|
|
69
72
|
@lock.synchronize do
|
|
70
73
|
@running = true
|
|
71
74
|
@controller = controller
|
|
72
75
|
@sink = sink
|
|
76
|
+
@thread = Thread.current
|
|
77
|
+
@ended = false
|
|
73
78
|
@steers.concat(@carried_steers)
|
|
74
79
|
@carried_steers = []
|
|
75
80
|
end
|
|
@@ -82,15 +87,37 @@ module Samagotchi
|
|
|
82
87
|
@running = false
|
|
83
88
|
@controller = nil
|
|
84
89
|
@sink = nil
|
|
90
|
+
@thread = nil
|
|
91
|
+
@ended = false
|
|
85
92
|
@steers.tap { @steers = [] }
|
|
86
93
|
end
|
|
87
94
|
end
|
|
88
95
|
|
|
96
|
+
# The turn's end is decided (its end event is out): what runs after,
|
|
97
|
+
# its after_turn / session_end hooks, is post-turn work, up to #finish!.
|
|
98
|
+
def ended!
|
|
99
|
+
@lock.synchronize { @ended = true }
|
|
100
|
+
end
|
|
101
|
+
|
|
89
102
|
# @return [CancellationController, nil] the running turn's controller
|
|
90
103
|
def controller
|
|
91
104
|
@lock.synchronize { @controller }
|
|
92
105
|
end
|
|
93
106
|
|
|
107
|
+
# The controller for work done for the turn itself (a plugin's HTTP
|
|
108
|
+
# call): the running turn's, on the turn's thread or +handler+ (inside a
|
|
109
|
+
# plugin's event handler, whatever thread fires it), until the turn has
|
|
110
|
+
# ended; nil for any other thread (an anytime command, a service) and
|
|
111
|
+
# outside a turn.
|
|
112
|
+
# @return [CancellationController, nil]
|
|
113
|
+
def own_controller(handler: false)
|
|
114
|
+
@lock.synchronize do
|
|
115
|
+
return nil unless @running && !@ended
|
|
116
|
+
|
|
117
|
+
handler || @thread.equal?(Thread.current) ? @controller : nil
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
94
121
|
# Cancel the running turn, if any (outside the lock).
|
|
95
122
|
# @return [Boolean] whether a cancellation was triggered
|
|
96
123
|
def cancel!(reason)
|
data/lib/samagotchi/version.rb
CHANGED
|
@@ -24,7 +24,7 @@ import {
|
|
|
24
24
|
} from "./data.js";
|
|
25
25
|
import { createContextChips } from "./context_chips.js";
|
|
26
26
|
import {
|
|
27
|
-
chipModel as llmContextChipModel, choiceWords, createLlmContextControl,
|
|
27
|
+
chipModel as llmContextChipModel, choiceWords, createLlmContextControl, NEW_SESSION,
|
|
28
28
|
withChoice,
|
|
29
29
|
} from "./llm_context_chip.js";
|
|
30
30
|
import {
|
|
@@ -38,7 +38,7 @@ import { createShownTexts } from "./shown_texts.js";
|
|
|
38
38
|
import { MODEL_KEY } from "./model_pick.js";
|
|
39
39
|
import { emptySessionState, sessionStateFrom } from "./session_state.js";
|
|
40
40
|
import { createModelPicker } from "./model_picker.js";
|
|
41
|
-
import { chipName, chipsTemplate, imageFiles, restoredChips, thumbsTemplate, turnRefs, turnText, uploadChips } from "./images.js";
|
|
41
|
+
import { admitImages, chipName, chipsTemplate, imageFiles, restoredChips, thumbsTemplate, turnRefs, turnText, uploadChips } from "./images.js";
|
|
42
42
|
import { normalize, firstPreview, failedTurnText, deleteConfirmText, goneSessionNotice, cantText, withLiveStatus, withoutWorker } from "./format.js";
|
|
43
43
|
import { createTurnView, turnHistoryNodes } from "./turn_view.js";
|
|
44
44
|
import { elementOf, emptyAnswerTemplate } from "./turn_html.js";
|
|
@@ -66,6 +66,7 @@ import { scopeDir, scopeTemplate } from "./scope.js";
|
|
|
66
66
|
import { html, nothing, render } from "./vendor/lit-html.js";
|
|
67
67
|
import { easeScrollTo, FOLLOW_MS, shouldFollowScroll } from "./scroll.js";
|
|
68
68
|
import { SHORT_WINDOW_QUERY, stripAutoHidden } from "./strip.js";
|
|
69
|
+
import { createStripGrip } from "./strip_grip.js";
|
|
69
70
|
import { waitingOn } from "./notify.js";
|
|
70
71
|
import { applySessionEvent, inTree, sortedByUpdated } from "./sessions_list.js";
|
|
71
72
|
import { createNotifications } from "./notifications.js";
|
|
@@ -121,7 +122,7 @@ const llmContextControl = createLlmContextControl({
|
|
|
121
122
|
run: (line) => runSessionCommand(line),
|
|
122
123
|
});
|
|
123
124
|
// The start page's LLM context chip, beside the model picker: the picked
|
|
124
|
-
// model's values, and a choice for the next new
|
|
125
|
+
// model's values, and a choice for the next new session only (sent with its
|
|
125
126
|
// create, then dropped; never remembered: a reload or opening a session
|
|
126
127
|
// drops it too, a failed create keeps it).
|
|
127
128
|
const llmContextNewEl = $("#llmContextNew");
|
|
@@ -130,7 +131,7 @@ const newChatLlmSummary = () => (selected || modelPickEl.hidden ? null : modelPi
|
|
|
130
131
|
const llmContextNewControl = createLlmContextControl({
|
|
131
132
|
el: llmContextNewEl,
|
|
132
133
|
summary: newChatLlmSummary,
|
|
133
|
-
kind:
|
|
134
|
+
kind: NEW_SESSION,
|
|
134
135
|
choice: () => newChatLlmContext,
|
|
135
136
|
choose: (next) => {
|
|
136
137
|
newChatLlmContext = next;
|
|
@@ -159,14 +160,14 @@ const reasoningHistoryControl = createReasoningHistoryControl({
|
|
|
159
160
|
run: (line) => runSessionCommand(line),
|
|
160
161
|
});
|
|
161
162
|
// The start page's thinking chip: the picked model's level, and a level
|
|
162
|
-
// for the next new
|
|
163
|
+
// for the next new session only (sent with its create, then dropped).
|
|
163
164
|
const thinkingNewEl = $("#thinkingNew");
|
|
164
165
|
let newChatThinking = null;
|
|
165
166
|
const newChatThinkingSummary = () => (selected || modelPickEl.hidden ? null : modelPicker.chosenRow()?.thinking || null);
|
|
166
167
|
const thinkingNewControl = createThinkingControl({
|
|
167
168
|
el: thinkingNewEl,
|
|
168
169
|
summary: newChatThinkingSummary,
|
|
169
|
-
kind:
|
|
170
|
+
kind: NEW_SESSION,
|
|
170
171
|
choice: () => newChatThinking,
|
|
171
172
|
choose: (next) => {
|
|
172
173
|
newChatThinking = next;
|
|
@@ -177,7 +178,7 @@ const thinkingNewControl = createThinkingControl({
|
|
|
177
178
|
anchor: "#thinkingNew",
|
|
178
179
|
});
|
|
179
180
|
function renderNewChatThinking() {
|
|
180
|
-
const chip = thinkingChipModel(withThinkingChoice(newChatThinkingSummary(), newChatThinking), { kind:
|
|
181
|
+
const chip = thinkingChipModel(withThinkingChoice(newChatThinkingSummary(), newChatThinking), { kind: NEW_SESSION });
|
|
181
182
|
thinkingNewEl.hidden = !chip;
|
|
182
183
|
if (!chip) return thinkingNewControl.close();
|
|
183
184
|
thinkingNewEl.textContent = chip.text;
|
|
@@ -186,7 +187,7 @@ function renderNewChatThinking() {
|
|
|
186
187
|
}
|
|
187
188
|
// Hidden without the model's values (the list not in, an older server).
|
|
188
189
|
function renderNewChatLlmContext() {
|
|
189
|
-
const chip = llmContextChipModel(withChoice(newChatLlmSummary(), newChatLlmContext), { kind:
|
|
190
|
+
const chip = llmContextChipModel(withChoice(newChatLlmSummary(), newChatLlmContext), { kind: NEW_SESSION });
|
|
190
191
|
llmContextNewEl.hidden = !chip;
|
|
191
192
|
if (!chip) return llmContextNewControl.close();
|
|
192
193
|
llmContextNewEl.textContent = chip.text;
|
|
@@ -205,6 +206,8 @@ let pageEditorUrl = null;
|
|
|
205
206
|
const promptEl = $("#prompt");
|
|
206
207
|
const actionBtn = $("#actionBtn");
|
|
207
208
|
const cancelBtn = $("#cancelBtn");
|
|
209
|
+
const attachBtn = $("#attachBtn");
|
|
210
|
+
const attachInput = $("#attachInput");
|
|
208
211
|
const infoCancelBtn = $("#infoCancelBtn");
|
|
209
212
|
const modelPickEl = $("#modelPick");
|
|
210
213
|
const composeHintEl = $("#composeHint");
|
|
@@ -357,7 +360,7 @@ const sentIds = new Set();
|
|
|
357
360
|
const earlyRestores = new Map();
|
|
358
361
|
|
|
359
362
|
// The page's scope (scope.js): ?dir= is the folder a project view came
|
|
360
|
-
// from (new
|
|
363
|
+
// from (new sessions start there); the server names its project, if any.
|
|
361
364
|
const scope = {
|
|
362
365
|
dir: scopeDir(location.search),
|
|
363
366
|
projectName: document.body.dataset.projectName || null,
|
|
@@ -467,7 +470,7 @@ function followSelectedSession(sess) {
|
|
|
467
470
|
|
|
468
471
|
// The header's scope chip: "<project> · all" in a project view (all links
|
|
469
472
|
// to the same place without ?dir); "all projects" otherwise, with where a
|
|
470
|
-
// new
|
|
473
|
+
// new session starts on the start page.
|
|
471
474
|
function renderScope() {
|
|
472
475
|
const el = $("#scope");
|
|
473
476
|
render(scopeTemplate(scope, { error: scopeError, where: () => ({ hash: location.hash, search: location.search }) }), el);
|
|
@@ -500,7 +503,6 @@ const lists = createListsView({
|
|
|
500
503
|
allViewOpen: () => allViewOpen(),
|
|
501
504
|
openAllView: () => openAllView(),
|
|
502
505
|
leaveAllView: (hash) => leaveAllView(hash),
|
|
503
|
-
setStripHidden: (hidden) => setStripHidden(hidden),
|
|
504
506
|
});
|
|
505
507
|
|
|
506
508
|
// Delete a session (the info bar's button, an all-sessions card's ✕) after
|
|
@@ -544,7 +546,7 @@ function closeAllView() {
|
|
|
544
546
|
leaveAllView(sessionHash(selected));
|
|
545
547
|
}
|
|
546
548
|
}
|
|
547
|
-
// Leave all sessions for a place of our own (a picked session, a new
|
|
549
|
+
// Leave all sessions for a place of our own (a picked session, a new session):
|
|
548
550
|
// replace its history entry, since history.back() would come back later
|
|
549
551
|
// with the session the view was opened from.
|
|
550
552
|
function leaveAllView(hash) {
|
|
@@ -620,7 +622,7 @@ function updateComposerMode() {
|
|
|
620
622
|
syncModelPick();
|
|
621
623
|
if (selected) {
|
|
622
624
|
llmContextNewControl.close();
|
|
623
|
-
// The start page's choice was for a new
|
|
625
|
+
// The start page's choice was for a new session: opening a session drops it.
|
|
624
626
|
if (newChatLlmContext) {
|
|
625
627
|
newChatLlmContext = null;
|
|
626
628
|
renderNewChatLlmContext();
|
|
@@ -633,6 +635,9 @@ function updateComposerMode() {
|
|
|
633
635
|
}
|
|
634
636
|
const running = liveTurn.running && !!selected;
|
|
635
637
|
cancelBtn.style.display = running ? "" : "none";
|
|
638
|
+
// No images for a session a terminal owns (it takes no message from here);
|
|
639
|
+
// on a phone none while a turn runs either: Stop turn needs the row's room.
|
|
640
|
+
attachBtn.hidden = (!!selected && current.owner === "tui") || (running && narrowScreen.matches);
|
|
636
641
|
syncDeliveryLine();
|
|
637
642
|
if (running) {
|
|
638
643
|
setPlaceholder("Running… a message goes in at the next step · Enter to send · Alt+Enter queues", "Steer the turn…");
|
|
@@ -1535,7 +1540,7 @@ async function handleCancel() {
|
|
|
1535
1540
|
|
|
1536
1541
|
// The chi logo: back to the empty start, where the composer makes a new
|
|
1537
1542
|
// session (from all sessions too).
|
|
1538
|
-
// The typed draft stays in the composer: paste in a session, then + new
|
|
1543
|
+
// The typed draft stays in the composer: paste in a session, then + new session
|
|
1539
1544
|
// starts from it (with the model picker). clearSelection leaves #prompt alone.
|
|
1540
1545
|
$("#newBtn").addEventListener("click", () => {
|
|
1541
1546
|
if (allViewOpen()) leaveAllView("");
|
|
@@ -1553,7 +1558,7 @@ $("#newBtn").addEventListener("click", () => {
|
|
|
1553
1558
|
// tooltip; a route that fails leaves the picker hidden and the server's
|
|
1554
1559
|
// default in charge. The search list itself is model_picker.js'; a pick
|
|
1555
1560
|
// sends the focus to the message box.
|
|
1556
|
-
const START_PICK_TITLE = "The model the new
|
|
1561
|
+
const START_PICK_TITLE = "The model the new session starts on";
|
|
1557
1562
|
let startPickTitle = START_PICK_TITLE;
|
|
1558
1563
|
// The fresh link the picker shows (null: the start page, or a session it
|
|
1559
1564
|
// is hidden in), and a link pick's /model still in flight (a send waits).
|
|
@@ -1624,7 +1629,7 @@ function syncModelPick() {
|
|
|
1624
1629
|
modelPickEl.setAttribute("aria-label", "Model this link starts on");
|
|
1625
1630
|
} else {
|
|
1626
1631
|
modelPickEl.title = startPickTitle;
|
|
1627
|
-
modelPickEl.setAttribute("aria-label", "Model for the new
|
|
1632
|
+
modelPickEl.setAttribute("aria-label", "Model for the new session");
|
|
1628
1633
|
}
|
|
1629
1634
|
}
|
|
1630
1635
|
// Leaving a session: the start page's remembered model again (no fetch).
|
|
@@ -1680,9 +1685,14 @@ function clearChips() {
|
|
|
1680
1685
|
setChips([]);
|
|
1681
1686
|
}
|
|
1682
1687
|
|
|
1688
|
+
// A paste's, a drop's or the image button's files: the images the message
|
|
1689
|
+
// may still take become chips (images.js admitImages); a toast says why the
|
|
1690
|
+
// rest stayed out. Whether any went in.
|
|
1683
1691
|
function addImageFiles(files) {
|
|
1684
|
-
|
|
1685
|
-
|
|
1692
|
+
const { take, refused } = admitImages(files, chips.length);
|
|
1693
|
+
if (refused) showToast(refused);
|
|
1694
|
+
if (!take.length) return false;
|
|
1695
|
+
for (const file of take) chips.push({ file, name: chipName(file, chips.length), src: URL.createObjectURL(file) });
|
|
1686
1696
|
renderChips();
|
|
1687
1697
|
focusPrompt();
|
|
1688
1698
|
return true;
|
|
@@ -1695,6 +1705,13 @@ chipsEl.addEventListener("click", (e) => {
|
|
|
1695
1705
|
if (chip?.file && chip.src) URL.revokeObjectURL(chip.src);
|
|
1696
1706
|
renderChips();
|
|
1697
1707
|
});
|
|
1708
|
+
// The image button: the file picker's images join the chips. Its value is
|
|
1709
|
+
// cleared after each pick, so the same file can be picked again.
|
|
1710
|
+
attachBtn.addEventListener("click", () => attachInput.click());
|
|
1711
|
+
attachInput.addEventListener("change", () => {
|
|
1712
|
+
addImageFiles([...attachInput.files]);
|
|
1713
|
+
attachInput.value = "";
|
|
1714
|
+
});
|
|
1698
1715
|
// ── Composer height: grows with the text; the top-edge grip sets a floor ───
|
|
1699
1716
|
let promptFloor = Number(readStored(GRIP_KEY)) || null;
|
|
1700
1717
|
|
|
@@ -2093,15 +2110,24 @@ function stripHiddenSaved() {
|
|
|
2093
2110
|
return saved === null ? readStored("chi_zen") === "1" : saved === "1";
|
|
2094
2111
|
}
|
|
2095
2112
|
// A short window hides the strip by itself, so the stage keeps room for a
|
|
2096
|
-
// card that waits (its question and choices); the
|
|
2097
|
-
//
|
|
2098
|
-
// user's own hide
|
|
2113
|
+
// card that waits (its question and choices); the grip brings it back
|
|
2114
|
+
// until the window is tall again. Not saved: the saved choice is the
|
|
2115
|
+
// user's own hide or show.
|
|
2099
2116
|
const shortWindow = window.matchMedia(SHORT_WINDOW_QUERY);
|
|
2100
2117
|
let stripShownWhileShort = false;
|
|
2118
|
+
// The strip's grip (strip_grip.js): the handle on the edge between the strip
|
|
2119
|
+
// and the chat. It is given the hidden state and the toggle; it speaks its own
|
|
2120
|
+
// title and aria-expanded, refreshed below after every change.
|
|
2121
|
+
const stripGripRefresh = createStripGrip({
|
|
2122
|
+
gripEl: $("#stripGrip"),
|
|
2123
|
+
isHidden: () => document.body.classList.contains("strip-hidden"),
|
|
2124
|
+
setHidden: (hidden) => setStripHidden(hidden),
|
|
2125
|
+
});
|
|
2101
2126
|
function applyStripHidden() {
|
|
2102
2127
|
document.body.classList.toggle("strip-hidden", stripAutoHidden({
|
|
2103
2128
|
saved: stripHiddenSaved(), short: shortWindow.matches, shownWhileShort: stripShownWhileShort,
|
|
2104
2129
|
}));
|
|
2130
|
+
stripGripRefresh();
|
|
2105
2131
|
updateSessionTitle();
|
|
2106
2132
|
}
|
|
2107
2133
|
function setStripHidden(hidden) {
|
|
@@ -2115,8 +2141,6 @@ shortWindow.addEventListener("change", () => {
|
|
|
2115
2141
|
if (!shortWindow.matches) stripShownWhileShort = false;
|
|
2116
2142
|
applyStripHidden();
|
|
2117
2143
|
});
|
|
2118
|
-
$("#stripShow").addEventListener("click", () => setStripHidden(false));
|
|
2119
|
-
$("#stripHideTop").addEventListener("click", () => setStripHidden(true));
|
|
2120
2144
|
|
|
2121
2145
|
// Esc leaves select mode first, then the all-sessions view.
|
|
2122
2146
|
document.addEventListener("keydown", (e) => {
|
|
@@ -15,6 +15,39 @@ export function imageFiles(dataTransfer) {
|
|
|
15
15
|
return files.filter((f) => /^image\//.test(f?.type || ""));
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
// The server's caps, mirrored so the composer refuses at once what a send
|
|
19
|
+
// would (spec/web/images_spec.rb pins both): ImageStore::MAX_TURN_REFS
|
|
20
|
+
// images a message, Web::App::MAX_IMAGE_UPLOAD_BYTES a file.
|
|
21
|
+
export const MAX_TURN_IMAGES = 20;
|
|
22
|
+
export const MAX_IMAGE_MB = 20;
|
|
23
|
+
const MAX_IMAGE_BYTES = MAX_IMAGE_MB * 1024 * 1024;
|
|
24
|
+
|
|
25
|
+
function filesText(n) {
|
|
26
|
+
return n === 1 ? "1 file" : `${n} files`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// What the composer takes of +files+ (a paste's, a drop's or the image
|
|
30
|
+
// button's picks) while it holds +held+ chips: images only, none over
|
|
31
|
+
// MAX_IMAGE_MB, and no more than MAX_TURN_IMAGES in all. +refused+ says
|
|
32
|
+
// why the rest stayed out, for a toast ("" when none did).
|
|
33
|
+
export function admitImages(files, held = 0) {
|
|
34
|
+
const take = [];
|
|
35
|
+
let notImage = 0;
|
|
36
|
+
let tooBig = 0;
|
|
37
|
+
let overCap = 0;
|
|
38
|
+
for (const file of files || []) {
|
|
39
|
+
if (!/^image\//.test(file?.type || "")) notImage += 1;
|
|
40
|
+
else if ((file.size || 0) > MAX_IMAGE_BYTES) tooBig += 1;
|
|
41
|
+
else if (held + take.length >= MAX_TURN_IMAGES) overCap += 1;
|
|
42
|
+
else take.push(file);
|
|
43
|
+
}
|
|
44
|
+
const why = [];
|
|
45
|
+
if (overCap) why.push(`${filesText(overCap)} not added: at most ${MAX_TURN_IMAGES} images a message`);
|
|
46
|
+
if (tooBig) why.push(`${filesText(tooBig)} not added: over ${MAX_IMAGE_MB} MB`);
|
|
47
|
+
if (notImage) why.push(`${filesText(notImage)} not added: not an image`);
|
|
48
|
+
return { take, refused: why.join(" · ") };
|
|
49
|
+
}
|
|
50
|
+
|
|
18
51
|
// A pasted screenshot is often called "image.png": number it so the chips
|
|
19
52
|
// (and what the model reads) tell them apart.
|
|
20
53
|
export function chipName(file, index) {
|