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.
- checksums.yaml +7 -0
- data/.github/workflows/deploy-github-pages.yml +52 -0
- data/.loki +15 -0
- data/.quality/reek_baseline.txt +8 -0
- data/.rubocop.yml +8 -0
- data/CHANGELOG.md +35 -0
- data/LICENSE.txt +21 -0
- data/README.md +96 -0
- data/Rakefile +132 -0
- data/config.ru +19 -0
- data/docs/api_reference.md +173 -0
- data/docs/getting_started.md +95 -0
- data/docs/how_it_works.md +138 -0
- data/docs/index.md +59 -0
- data/docs/security.md +75 -0
- data/examples/boot.rb +24 -0
- data/exe/robot_lab-web +49 -0
- data/lib/robot_lab/web/activity_log.rb +61 -0
- data/lib/robot_lab/web/app.rb +170 -0
- data/lib/robot_lab/web/components/chat.rb +140 -0
- data/lib/robot_lab/web/components/dashboard.rb +47 -0
- data/lib/robot_lab/web/components/error_page.rb +25 -0
- data/lib/robot_lab/web/components/layout.rb +73 -0
- data/lib/robot_lab/web/components/message.rb +42 -0
- data/lib/robot_lab/web/event.rb +106 -0
- data/lib/robot_lab/web/event_sink.rb +46 -0
- data/lib/robot_lab/web/registry.rb +38 -0
- data/lib/robot_lab/web/stream_hook.rb +77 -0
- data/lib/robot_lab/web/version.rb +7 -0
- data/lib/robot_lab/web.rb +77 -0
- data/mkdocs.yml +118 -0
- data/sig/robot_lab/web.rbs +6 -0
- metadata +176 -0
|
@@ -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 => ({'&':'&','<':'<','>':'>'}[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,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
|