robot_lab-web 0.2.6

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.
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RobotLab
4
+ module Web
5
+ module Components
6
+ # The chat page for one robot: transcript, composer, and the small vanilla
7
+ # SSE client that streams a run token-by-token into the transcript.
8
+ class Chat < Phlex::HTML
9
+ def initialize(name:)
10
+ @name = name
11
+ end
12
+
13
+ def view_template
14
+ h1(class: "flex items-center gap-2") do
15
+ a(href: "/", class: "inline-flex") do
16
+ render PhlexIcons::Hero::ArrowLeft.new(class: "w-6 h-6")
17
+ end
18
+ plain @name
19
+ end
20
+
21
+ div(id: "transcript", class: "transcript")
22
+
23
+ form(id: "composer", class: "composer", data: { stream_url: "/robots/#{@name}/stream" }) do
24
+ input(
25
+ type: "text", name: "message", id: "message",
26
+ placeholder: "Message #{@name}…", autocomplete: "off", autofocus: true
27
+ )
28
+ button(type: "submit", id: "send", class: "flex items-center gap-1.5") do
29
+ plain "Send"
30
+ render PhlexIcons::Hero::PaperAirplane.new(class: "w-4 h-4")
31
+ end
32
+ end
33
+
34
+ script { raw(safe(CLIENT_JS)) }
35
+ end
36
+
37
+ # The robot name only reaches JS via the form's data-stream-url attribute
38
+ # (set above), so this script needs no server interpolation.
39
+ CLIENT_JS = <<~'JS'
40
+ const csrf = document.querySelector('meta[name="csrf-token"]').content;
41
+ const streamUrl = document.getElementById('composer').dataset.streamUrl;
42
+ const transcript = document.getElementById('transcript');
43
+ const form = document.getElementById('composer');
44
+ const input = document.getElementById('message');
45
+ const send = document.getElementById('send');
46
+
47
+ function el(role, html) {
48
+ const d = document.createElement('div');
49
+ d.className = 'msg ' + role;
50
+ d.innerHTML = html;
51
+ transcript.appendChild(d);
52
+ transcript.scrollTop = transcript.scrollHeight;
53
+ return d;
54
+ }
55
+ const esc = (s) => String(s ?? '').replace(/[&<>]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;'}[c]));
56
+ function header(role, robot) {
57
+ return '<div class="role">' + esc(role) + (robot ? ' · ' + esc(robot) : '') + '</div>';
58
+ }
59
+
60
+ // The robot bubble currently being filled by streamed :delta events.
61
+ let streaming = null;
62
+
63
+ function appendDelta(ev) {
64
+ if (!streaming) {
65
+ const bubble = el('robot', header('robot', ev.robot_name) + '<span class="body"></span>');
66
+ streaming = bubble.querySelector('.body');
67
+ }
68
+ streaming.textContent += ev.content ?? '';
69
+ transcript.scrollTop = transcript.scrollHeight;
70
+ }
71
+
72
+ function renderEvent(ev) {
73
+ const c = ev.content || {};
74
+ if (ev.role === 'delta') { appendDelta(ev); return; }
75
+ if (ev.role === 'user') return; // already shown optimistically
76
+
77
+ if (ev.role === 'robot' && streaming) {
78
+ streaming.textContent = typeof ev.content === 'string' ? ev.content : streaming.textContent;
79
+ streaming = null;
80
+ return;
81
+ }
82
+ streaming = null;
83
+
84
+ let body;
85
+ switch (ev.role) {
86
+ case 'tool_call': body = esc(c.name) + '(' + esc(JSON.stringify(c.args)) + ')'; break;
87
+ case 'tool_result': body = c.error ? esc(c.name) + ' → error: ' + esc(c.error)
88
+ : esc(c.name) + ' → ' + esc(JSON.stringify(c.result)); break;
89
+ case 'error': body = esc(c.message || ev.content); break;
90
+ default: body = esc(typeof ev.content === 'string' ? ev.content : JSON.stringify(ev.content));
91
+ }
92
+ el(ev.role, header(ev.role, ev.robot_name) + body);
93
+ }
94
+
95
+ async function streamRun(message) {
96
+ const res = await fetch(streamUrl, {
97
+ method: 'POST',
98
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-CSRF-Token': csrf },
99
+ body: 'message=' + encodeURIComponent(message)
100
+ });
101
+ const reader = res.body.getReader();
102
+ const decoder = new TextDecoder();
103
+ let buf = '';
104
+ while (true) {
105
+ const { value, done } = await reader.read();
106
+ if (done) break;
107
+ buf += decoder.decode(value, { stream: true });
108
+ const frames = buf.split('\n\n');
109
+ buf = frames.pop();
110
+ for (const frame of frames) {
111
+ let type = 'message', data = '';
112
+ for (const line of frame.split('\n')) {
113
+ if (line.startsWith('event:')) type = line.slice(6).trim();
114
+ else if (line.startsWith('data:')) data += line.slice(5).trim();
115
+ }
116
+ if (!data) continue;
117
+ const payload = JSON.parse(data);
118
+ if (type === 'message') renderEvent(payload);
119
+ else if (type === 'error') el('error', header('error') + esc(payload.message));
120
+ }
121
+ }
122
+ }
123
+
124
+ form.addEventListener('submit', async (e) => {
125
+ e.preventDefault();
126
+ const message = input.value.trim();
127
+ if (!message) return;
128
+ streaming = null;
129
+ el('user', header('user') + esc(message));
130
+ input.value = '';
131
+ input.disabled = send.disabled = true;
132
+ try { await streamRun(message); }
133
+ catch (err) { el('error', header('error') + esc(err.message)); }
134
+ finally { input.disabled = send.disabled = false; input.focus(); }
135
+ });
136
+ JS
137
+ end
138
+ end
139
+ end
140
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RobotLab
4
+ module Web
5
+ module Components
6
+ # The dashboard: registered robots + the recent-activity feed.
7
+ class Dashboard < Phlex::HTML
8
+ def initialize(robots:, activity:)
9
+ @robots = robots
10
+ @activity = activity
11
+ end
12
+
13
+ def view_template
14
+ h1 { "Robots" }
15
+ if @robots.empty?
16
+ p { "No robots registered. In your boot script:" }
17
+ pre { %(require "robot_lab/web"\n\nRobotLab::Web.register(my_robot)) }
18
+ else
19
+ ul(class: "robots") do
20
+ @robots.each do |name|
21
+ li { a(href: "/robots/#{name}") { name } }
22
+ end
23
+ end
24
+ end
25
+
26
+ h2 { "Recent activity" }
27
+ if @activity.empty?
28
+ p(class: "muted") { "Nothing yet." }
29
+ else
30
+ ul(class: "activity") do
31
+ @activity.each { |entry| li { plain activity_line(entry) } }
32
+ end
33
+ end
34
+ end
35
+
36
+ private
37
+
38
+ def activity_line(entry)
39
+ parts = [entry[:timestamp].strftime("%H:%M:%S"), entry[:type].to_s]
40
+ details = (entry[:details] || {}).compact
41
+ parts << details.map { |k, v| "#{k}=#{v}" }.join(" ") unless details.empty?
42
+ parts.join(" · ")
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RobotLab
4
+ module Web
5
+ module Components
6
+ # The 404 page body.
7
+ class ErrorPage < Phlex::HTML
8
+ def initialize(message:)
9
+ @message = message
10
+ end
11
+
12
+ def view_template
13
+ h1 { "Not found" }
14
+ p { @message }
15
+ p do
16
+ a(href: "/", class: "inline-flex items-center gap-1") do
17
+ render PhlexIcons::Hero::ArrowLeft.new(class: "w-4 h-4")
18
+ plain "Dashboard"
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RobotLab
4
+ module Web
5
+ module Components
6
+ # The HTML document shell: Tailwind (Play CDN) + the shared component
7
+ # styles, the top nav, and a slot for the page component passed as +content+.
8
+ class Layout < Phlex::HTML
9
+ def initialize(csrf:, content:)
10
+ @csrf = csrf
11
+ @content = content
12
+ end
13
+
14
+ def view_template
15
+ doctype
16
+ html(lang: "en", class: "dark") do
17
+ head do
18
+ meta(charset: "utf-8")
19
+ meta(name: "viewport", content: "width=device-width, initial-scale=1")
20
+ meta(name: "csrf-token", content: @csrf)
21
+ title { "robot_lab-web" }
22
+ # Tailwind via the Play CDN (dev tool — fine for localhost).
23
+ script(src: "https://cdn.tailwindcss.com")
24
+ # Styles are Tailwind utilities composed with @apply so the server
25
+ # components and the SSE client's JS-built nodes share one
26
+ # definition (the message bubble is rendered by both Message and
27
+ # chat.js).
28
+ style(type: "text/tailwindcss") { raw(safe(STYLES)) }
29
+ end
30
+ body(class: "bg-zinc-950 text-zinc-200 font-sans text-[15px] leading-relaxed") do
31
+ header(class: "flex items-center gap-3 px-5 py-3.5 border-b border-zinc-800") do
32
+ span(class: "flex items-center gap-2 text-violet-400 font-bold tracking-wide") do
33
+ render PhlexIcons::Hero::CpuChip.new(class: "w-5 h-5")
34
+ plain "robot_lab-web"
35
+ end
36
+ a(href: "/", class: "text-indigo-400 no-underline hover:underline") { "Dashboard" }
37
+ end
38
+ main(class: "max-w-4xl mx-auto px-5 pt-6 pb-16") { render @content }
39
+ end
40
+ end
41
+ end
42
+
43
+ STYLES = <<~CSS
44
+ @layer base {
45
+ html { color-scheme: dark; }
46
+ a { @apply text-indigo-400 no-underline hover:underline; }
47
+ h1 { @apply text-2xl font-semibold; }
48
+ h2 { @apply text-xl font-semibold mt-8 mb-2; }
49
+ pre { @apply bg-zinc-900 border border-zinc-800 rounded-md p-3 text-sm overflow-x-auto; }
50
+ input[type=text] { @apply flex-1 rounded-lg border border-zinc-700 bg-zinc-900 text-zinc-200 px-3.5 py-2.5 outline-none focus:border-indigo-500; }
51
+ button { @apply rounded-lg bg-indigo-500 text-zinc-950 font-semibold px-4 py-2.5 cursor-pointer; }
52
+ button:disabled { @apply opacity-50 cursor-default; }
53
+ }
54
+ @layer components {
55
+ .robots { @apply list-none p-0; }
56
+ .robots li { @apply mb-2 rounded-lg border border-zinc-800 bg-zinc-900 px-3.5 py-2.5; }
57
+ .activity { @apply list-none p-0; }
58
+ .activity li { @apply py-1.5 border-b border-zinc-800/70 text-[13px] text-zinc-400; }
59
+ .muted { @apply text-zinc-500; }
60
+ .transcript { @apply flex flex-col gap-2.5 min-h-[240px] py-2; }
61
+ .composer { @apply flex gap-2 sticky bottom-0 bg-zinc-950 py-3; }
62
+ .msg { @apply max-w-[92%] whitespace-pre-wrap break-words rounded-xl border border-zinc-800 px-3.5 py-2.5; }
63
+ .msg.user { @apply self-end bg-indigo-950/50 border-indigo-800/60; }
64
+ .msg.robot { @apply self-start bg-emerald-950/40 border-emerald-900/60; }
65
+ .msg.tool_call, .msg.tool_result { @apply self-start bg-zinc-900 text-zinc-300 text-[13px] font-mono; }
66
+ .msg.error { @apply self-start bg-rose-950/50 border-rose-900/60 text-rose-200; }
67
+ .msg .role { @apply text-[11px] uppercase tracking-wider text-zinc-500 mb-1; }
68
+ }
69
+ CSS
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RobotLab
4
+ module Web
5
+ module Components
6
+ # One transcript message. Used server-side for the non-streaming /chat
7
+ # fallback; the SSE client builds the same markup (same classes) in JS.
8
+ class Message < Phlex::HTML
9
+ def initialize(event:)
10
+ @event = event
11
+ end
12
+
13
+ def view_template
14
+ div(class: "msg #{@event.role}") do
15
+ div(class: "role") { plain role_header }
16
+ plain body_text
17
+ end
18
+ end
19
+
20
+ private
21
+
22
+ def role_header
23
+ @event.robot_name ? "#{@event.role} · #{@event.robot_name}" : @event.role.to_s
24
+ end
25
+
26
+ def body_text
27
+ c = @event.content
28
+ case @event.role
29
+ when :tool_call
30
+ "#{@event.tool_name}(#{c[:args].to_json})"
31
+ when :tool_result
32
+ c[:error] ? "#{@event.tool_name} → error: #{c[:error]}" : "#{@event.tool_name} → #{c[:result]}"
33
+ when :error
34
+ @event.error_message.to_s
35
+ else
36
+ @event.text.to_s
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'time'
4
+ require 'json'
5
+ require 'securerandom'
6
+
7
+ module RobotLab
8
+ module Web
9
+ # Frontend-neutral, immutable value object for a single step in a robot run.
10
+ #
11
+ # One Event model backs both the persisted transcript and the live SSE
12
+ # stream — serialize with #to_h, rebuild with .from_h. Role validation is
13
+ # kept strict so a bad role fails fast rather than rendering blank.
14
+ #
15
+ # Roles:
16
+ # :user — text the human sent
17
+ # :delta — one streamed token/content delta (content is the text fragment)
18
+ # :robot — the robot's final reply (content is the full final text)
19
+ # :tool_call — a tool invocation (content: { name:, args: })
20
+ # :tool_result — a tool's return (content: { name:, result:/error: })
21
+ # :error — the run raised (content: { class:, message: })
22
+ Event = Struct.new(:role, :content, :robot_name, :timestamp, :event_id, keyword_init: true) do
23
+ ROLES = %i[user delta robot tool_call tool_result error].freeze # rubocop:disable Lint/ConstantDefinitionInBlock
24
+
25
+ def initialize(role:, content:, robot_name: nil, timestamp: nil, event_id: nil)
26
+ role = role.to_sym
27
+ raise ArgumentError, "invalid role: #{role.inspect} (expected one of #{ROLES.inspect})" unless ROLES.include?(role)
28
+
29
+ super(
30
+ role: role,
31
+ content: deep_freeze(content),
32
+ robot_name: robot_name,
33
+ timestamp: timestamp || Time.now.utc,
34
+ event_id: event_id || SecureRandom.uuid
35
+ )
36
+ freeze
37
+ end
38
+
39
+ # --- Convenience readers so callers never reach into the content hash ---
40
+
41
+ def tool_name
42
+ content.is_a?(Hash) ? (content[:name] || content['name']) : nil
43
+ end
44
+
45
+ def error?
46
+ role == :error
47
+ end
48
+
49
+ def error_message
50
+ return nil unless error?
51
+
52
+ content.is_a?(Hash) ? (content[:message] || content['message']) : content.to_s
53
+ end
54
+
55
+ # The display text for this event, whatever its role.
56
+ def text
57
+ return content unless content.is_a?(Hash)
58
+
59
+ content[:result] || content[:message] || content[:args] || content
60
+ end
61
+
62
+ # --- Serialization (round-trips through JSON for SSE + storage) ---
63
+
64
+ def to_h
65
+ {
66
+ role: role,
67
+ content: content,
68
+ robot_name: robot_name,
69
+ timestamp: timestamp.iso8601(3),
70
+ event_id: event_id
71
+ }
72
+ end
73
+
74
+ def self.from_h(hash)
75
+ fetch = ->(key) { hash.key?(key) ? hash[key] : hash[key.to_s] }
76
+ ts = fetch.call(:timestamp)
77
+ new(
78
+ role: fetch.call(:role)&.to_sym,
79
+ content: fetch.call(:content),
80
+ robot_name: fetch.call(:robot_name),
81
+ timestamp: ts ? Time.iso8601(ts) : Time.now.utc,
82
+ event_id: fetch.call(:event_id)
83
+ )
84
+ rescue ArgumentError
85
+ nil
86
+ end
87
+
88
+ private
89
+
90
+ def deep_freeze(obj)
91
+ case obj
92
+ when Hash
93
+ obj.each_value { |v| deep_freeze(v) }
94
+ obj.freeze
95
+ when Array
96
+ obj.each { |v| deep_freeze(v) }
97
+ obj.freeze
98
+ when String
99
+ obj.freeze
100
+ else
101
+ obj
102
+ end
103
+ end
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RobotLab
4
+ module Web
5
+ # The bridge between robot_lab's synchronous hook dispatch and a consumer
6
+ # (an SSE stream, a collecting array, anything responding to #call).
7
+ #
8
+ # robot_lab hooks are class-level singletons, so there is no instance on
9
+ # which to hang a per-connection callback. We instead stash the consumer in
10
+ # a thread-local for the duration of a run. Because Robot#run dispatches its
11
+ # hooks synchronously on the calling thread, the StreamHook sees the right
12
+ # sink. (Caveat: a robot that executes tools on other threads/Ractors would
13
+ # not propagate this — fine for the core synchronous path.)
14
+ #
15
+ # In effect this gives a robot run an `on_event:` callback without changing
16
+ # robot_lab's API.
17
+ module EventSink
18
+ KEY = :robot_lab_web_event_sink
19
+
20
+ module_function
21
+
22
+ # Run the block with +sink+ (any callable taking an Event) installed as
23
+ # the current consumer. Restores the previous sink afterward.
24
+ def capture(sink)
25
+ previous = Thread.current[KEY]
26
+ Thread.current[KEY] = sink
27
+ yield
28
+ ensure
29
+ Thread.current[KEY] = previous
30
+ end
31
+
32
+ def current
33
+ Thread.current[KEY]
34
+ end
35
+
36
+ # Deliver an Event to the current sink (if any). Never raises into the
37
+ # run — a broken consumer must not break the robot.
38
+ def emit(event)
39
+ sink = current
40
+ sink&.call(event)
41
+ rescue StandardError
42
+ nil
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RobotLab
4
+ module Web
5
+ # In-memory registry of the robots the web UI can drive, keyed by name.
6
+ # The host app registers robots at boot; the Sinatra app reads from here.
7
+ module Registry
8
+ module_function
9
+
10
+ def store
11
+ @store ||= {}
12
+ end
13
+
14
+ # Register a robot (anything responding to #run and #name).
15
+ def register(robot, name: nil)
16
+ key = (name || robot.name).to_s
17
+ store[key] = robot
18
+ key
19
+ end
20
+
21
+ def fetch(name)
22
+ store[name.to_s]
23
+ end
24
+
25
+ def names
26
+ store.keys.sort
27
+ end
28
+
29
+ def all
30
+ store.values
31
+ end
32
+
33
+ def clear
34
+ store.clear
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'event'
4
+ require_relative 'event_sink'
5
+ require_relative 'activity_log'
6
+
7
+ module RobotLab
8
+ module Web
9
+ # Taps robot_lab's hook system and turns each lifecycle moment into a
10
+ # RobotLab::Web::Event, delivered to the current EventSink and recorded in
11
+ # the ActivityLog. This is the producer half of the `on_event -> SSE`
12
+ # bridge that drives the browser console.
13
+ #
14
+ # Register per-run so it only fires for web-driven runs:
15
+ # robot.run(message, hooks: [RobotLab::Web::StreamHook])
16
+ # ...inside an EventSink.capture { } block.
17
+ class StreamHook < RobotLab::Hook
18
+ self.namespace = :web_stream
19
+
20
+ class << self
21
+ def before_run(ctx)
22
+ emit(ctx, role: :user, content: ctx.request.to_s)
23
+ end
24
+
25
+ def after_run(ctx)
26
+ if ctx.error
27
+ emit_error(ctx, ctx.error)
28
+ else
29
+ emit(ctx, role: :robot, content: final_text(ctx.response))
30
+ end
31
+ end
32
+
33
+ def before_tool_call(ctx)
34
+ emit(ctx, role: :tool_call, content: { name: ctx.tool_name, args: ctx.tool_args })
35
+ end
36
+
37
+ def after_tool_call(ctx)
38
+ content =
39
+ if ctx.tool_error
40
+ { name: ctx.tool_name, error: ctx.tool_error.message }
41
+ else
42
+ { name: ctx.tool_name, result: ctx.tool_result }
43
+ end
44
+ emit(ctx, role: :tool_result, content: content)
45
+ end
46
+
47
+ def on_error(ctx)
48
+ emit_error(ctx, ctx.error)
49
+ end
50
+
51
+ private
52
+
53
+ def emit(ctx, role:, content:)
54
+ event = Event.new(role: role, content: content, robot_name: robot_name(ctx))
55
+ EventSink.emit(event)
56
+ ActivityLog.safe_log(role, robot: event.robot_name, tool: event.tool_name)
57
+ event
58
+ end
59
+
60
+ def emit_error(ctx, error)
61
+ emit(ctx, role: :error, content: { class: error.class.name, message: error.message })
62
+ end
63
+
64
+ def robot_name(ctx)
65
+ ctx.respond_to?(:robot) && ctx.robot ? ctx.robot.name : nil
66
+ end
67
+
68
+ def final_text(response)
69
+ return response.reply if response.respond_to?(:reply)
70
+ return response.last_text_content if response.respond_to?(:last_text_content)
71
+
72
+ response.to_s
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RobotLab
4
+ module Web
5
+ VERSION = "0.2.6"
6
+ end
7
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'web/version'
4
+
5
+ # Core (Sinatra-free) surface of robot_lab-web. Requiring this gives you the
6
+ # event model, activity log, sink, registry, and — when robot_lab is loaded —
7
+ # the StreamHook. The Sinatra app and its heavy deps (sinatra, falcon, phlex) are
8
+ # opt-in: `require "robot_lab/web/app"`, which keeps the web stack off the core
9
+ # load path.
10
+ require_relative 'web/event'
11
+ require_relative 'web/activity_log'
12
+ require_relative 'web/event_sink'
13
+ require_relative 'web/registry'
14
+
15
+ # robot_lab is a hard dependency: StreamHook subclasses RobotLab::Hook. Require
16
+ # it here so load order never matters (the exe/config.ru may require this file
17
+ # before a user boot script gets to require "robot_lab").
18
+ require 'robot_lab'
19
+ require_relative 'web/stream_hook'
20
+
21
+ module RobotLab
22
+ # Browser front end for a robot_lab project: stream a robot's run to a web
23
+ # page over Server-Sent Events (an on_event -> SSE bridge, an immutable Event
24
+ # value object, an in-memory ActivityLog, and a hardened dev-tool security
25
+ # posture).
26
+ module Web
27
+ class Error < StandardError; end
28
+
29
+ module_function
30
+
31
+ # Register a robot the web UI can drive. Returns the string key.
32
+ #
33
+ # RobotLab::Web.register(my_robot)
34
+ # RobotLab::Web.register(my_robot, name: "support")
35
+ def register(robot, name: nil)
36
+ Registry.register(robot, name: name)
37
+ end
38
+
39
+ # Run +robot+ against +message+, delivering each lifecycle Event to +sink+
40
+ # (a callable taking an Event) as it happens, and returning the robot's
41
+ # RobotResult. This is the one call the web routes wrap; it is also the
42
+ # cleanest way to consume the stream from plain Ruby or a test.
43
+ #
44
+ # Two layers of events arrive at the sink:
45
+ # * StreamHook turns robot_lab hook moments into :user / :tool_call /
46
+ # :tool_result / :robot / :error events.
47
+ # * the streaming block below turns each RubyLLM content chunk into a
48
+ # :delta event, so the reply can be rendered token by token. (A model or
49
+ # run that doesn't stream simply produces no deltas — the final :robot
50
+ # event still carries the whole reply.)
51
+ #
52
+ # events = []
53
+ # RobotLab::Web.run(robot, "hello") { |event| events << event }
54
+ def run(robot, message, sink = nil, &block)
55
+ sink ||= block
56
+ name = robot.respond_to?(:name) ? robot.name : nil
57
+ EventSink.capture(sink) do
58
+ robot.run(message, hooks: [StreamHook]) do |chunk|
59
+ delta = chunk.respond_to?(:content) ? chunk.content : chunk.to_s
60
+ next if delta.nil? || delta.empty?
61
+
62
+ # Emit straight to the sink — deltas bypass the ActivityLog so token
63
+ # spam never floods the dashboard's recent-activity feed.
64
+ EventSink.emit(Event.new(role: :delta, content: delta, robot_name: name))
65
+ end
66
+ end
67
+ end
68
+
69
+ # Boot the Sinatra app. Requires the opt-in web stack on first call.
70
+ #
71
+ # RobotLab::Web.app # => RobotLab::Web::App (a Rack app)
72
+ def app
73
+ require_relative 'web/app'
74
+ App
75
+ end
76
+ end
77
+ end