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
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 04b904262b238719fac85842e3ae48532e8bc31988c3323165fe19abf3c116b7
|
|
4
|
+
data.tar.gz: 6461c66fb622adb4874e7eced683247026d30655ad0e24ec708fb0030a306222
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 98cabb7f7ea51eb42d84bc6750f5d2f47ca36d9bbd507dc095088ebd6bef4b1f15629f4599f852c71e94d7fb6b1ab1cdd9373a13cab71dd181ece556839f396b
|
|
7
|
+
data.tar.gz: 2b623bfb33bd537e499b36b937f092ae732bbdf2f5f2f827275b9824d52a70a7e6546333805132cec1882f0424a5cba419d9470e011cb4896f4cb09dc44219bb
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
name: Deploy Documentation to GitHub Pages
|
|
2
|
+
on:
|
|
3
|
+
push:
|
|
4
|
+
branches:
|
|
5
|
+
- main
|
|
6
|
+
- develop
|
|
7
|
+
paths:
|
|
8
|
+
- "docs/**"
|
|
9
|
+
- "mkdocs.yml"
|
|
10
|
+
- ".github/workflows/deploy-github-pages.yml"
|
|
11
|
+
workflow_dispatch:
|
|
12
|
+
|
|
13
|
+
permissions:
|
|
14
|
+
contents: write
|
|
15
|
+
pages: write
|
|
16
|
+
id-token: write
|
|
17
|
+
|
|
18
|
+
jobs:
|
|
19
|
+
deploy:
|
|
20
|
+
runs-on: ubuntu-latest
|
|
21
|
+
steps:
|
|
22
|
+
- name: Checkout code
|
|
23
|
+
uses: actions/checkout@v4
|
|
24
|
+
with:
|
|
25
|
+
fetch-depth: 0
|
|
26
|
+
|
|
27
|
+
- name: Setup Python
|
|
28
|
+
uses: actions/setup-python@v5
|
|
29
|
+
with:
|
|
30
|
+
python-version: 3.x
|
|
31
|
+
|
|
32
|
+
- name: Install dependencies
|
|
33
|
+
run: |
|
|
34
|
+
pip install mkdocs
|
|
35
|
+
pip install mkdocs-material
|
|
36
|
+
pip install mkdocs-macros-plugin
|
|
37
|
+
pip install mike
|
|
38
|
+
|
|
39
|
+
- name: Configure Git
|
|
40
|
+
run: |
|
|
41
|
+
git config --local user.email "action@github.com"
|
|
42
|
+
git config --local user.name "GitHub Action"
|
|
43
|
+
|
|
44
|
+
- name: Build MkDocs site
|
|
45
|
+
run: mkdocs build
|
|
46
|
+
|
|
47
|
+
- name: Deploy to GitHub Pages
|
|
48
|
+
uses: peaceiris/actions-gh-pages@v4
|
|
49
|
+
with:
|
|
50
|
+
github_token: ${{ secrets.GITHUB_TOKEN }}
|
|
51
|
+
publish_dir: ./site
|
|
52
|
+
keep_files: true
|
data/.loki
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# robot_lab-web — Sinatra + SSE web console for driving RobotLab robots.
|
|
3
|
+
|
|
4
|
+
import_up "repo_dev.loki"
|
|
5
|
+
|
|
6
|
+
class Tasks
|
|
7
|
+
@@gem_name ||= "robot_lab-web".freeze
|
|
8
|
+
|
|
9
|
+
header "robot_lab-web v#{gem_version} — web console (Sinatra + HTMX + SSE)"
|
|
10
|
+
|
|
11
|
+
desc "Start the web console (pass a boot file, e.g. asgard server examples/boot.rb)"
|
|
12
|
+
def server(*args)
|
|
13
|
+
sh "bundle exec ruby exe/robot_lab-web #{args.join(' ')}"
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
1 lib/robot_lab/web.rb
|
|
2
|
+
1 lib/robot_lab/web/components/chat.rb
|
|
3
|
+
1 lib/robot_lab/web/components/dashboard.rb
|
|
4
|
+
1 lib/robot_lab/web/components/layout.rb
|
|
5
|
+
1 lib/robot_lab/web/components/message.rb
|
|
6
|
+
4 lib/robot_lab/web/event.rb
|
|
7
|
+
1 lib/robot_lab/web/event_sink.rb
|
|
8
|
+
1 lib/robot_lab/web/registry.rb
|
data/.rubocop.yml
ADDED
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
## [Unreleased]
|
|
2
|
+
|
|
3
|
+
### Changed
|
|
4
|
+
- Use **Falcon** as the application server (replacing Puma). Its async/fiber
|
|
5
|
+
reactor is the right fit for the long-lived SSE streams this console serves —
|
|
6
|
+
fiber-per-connection concurrency, verified with two interleaving streams. The
|
|
7
|
+
`exe` launches a single Falcon reactor; `config.ru` stays server-agnostic.
|
|
8
|
+
- Style with **Tailwind CSS** (Play CDN) instead of hand-rolled CSS. The dark
|
|
9
|
+
theme is preserved; styles are Tailwind utilities composed via `@apply` so the
|
|
10
|
+
server-rendered and JS-built message bubbles share one definition.
|
|
11
|
+
- Render views as **Phlex components** (replacing Slim) — `Layout`, `Dashboard`,
|
|
12
|
+
`Chat`, `Message`, `ErrorPage` — via `phlex-sinatra`, with inline SVG icons
|
|
13
|
+
from `phlex-icons-hero`. Drops the `slim` dependency.
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
- Core (Sinatra-free) surface: `RobotLab::Web::Event` (immutable, role-validated,
|
|
17
|
+
`to_h`/`from_h`), `ActivityLog` (bounded thread-safe ring buffer), `EventSink`
|
|
18
|
+
(thread-local capture), and `Registry` (in-memory robot registry).
|
|
19
|
+
- `RobotLab::Web::StreamHook` — taps robot_lab's hook system and turns each
|
|
20
|
+
run/tool/error moment into an `Event` delivered to the current sink.
|
|
21
|
+
- `RobotLab::Web.run(robot, message) { |event| ... }` — stream a run's events
|
|
22
|
+
from plain Ruby.
|
|
23
|
+
- Token-delta streaming: `RobotLab::Web.run` passes a streaming block to the
|
|
24
|
+
robot, emitting a `:delta` event per RubyLLM content chunk so the reply renders
|
|
25
|
+
token by token. Deltas bypass the `ActivityLog`; the final `:robot` event still
|
|
26
|
+
carries the whole reply. The browser accumulates deltas into a live bubble.
|
|
27
|
+
- Opt-in Sinatra app (`require "robot_lab/web/app"`): dashboard, chat page,
|
|
28
|
+
`POST /robots/:name/stream` (Server-Sent Events) and a non-streaming
|
|
29
|
+
`POST /robots/:name/chat` HTMX fallback. CSRF + hashed session secret +
|
|
30
|
+
cookie hardening + production boot guard.
|
|
31
|
+
- `robot_lab-web` executable and `config.ru` launcher; `examples/boot.rb`.
|
|
32
|
+
|
|
33
|
+
## [0.1.0] - 2026-06-20
|
|
34
|
+
|
|
35
|
+
- Initial release
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Dewayne VanHoozer
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# RobotLab::Web
|
|
2
|
+
|
|
3
|
+
A Rails-free **web console for [robot_lab](../robot_lab)**: register your robots,
|
|
4
|
+
chat with them from the browser, and watch each lifecycle event — tool calls,
|
|
5
|
+
tool results, the final reply, errors — **stream in real time** over
|
|
6
|
+
Server-Sent Events.
|
|
7
|
+
|
|
8
|
+
It's the standalone counterpart to `robot_lab-rails`: one event stream, a
|
|
9
|
+
Sinatra + HTMX front end, no Turbo and no Rails required.
|
|
10
|
+
|
|
11
|
+
> ⚠️ **Developer tool.** The console is **unauthenticated by default**. Run it on
|
|
12
|
+
> localhost or a trusted private network. It ships sensible hardening (CSRF with
|
|
13
|
+
> constant-time compare, a hashed session secret, `httponly`/`SameSite` cookies,
|
|
14
|
+
> a production boot guard) but is not meant to face untrusted users.
|
|
15
|
+
|
|
16
|
+
## How it works
|
|
17
|
+
|
|
18
|
+
`robot_lab-web` registers a per-run hook (`RobotLab::Web::StreamHook`) on
|
|
19
|
+
robot_lab's own hook system. As a robot runs, the hook turns each moment into an
|
|
20
|
+
immutable `RobotLab::Web::Event` and pushes it to the current sink — an SSE
|
|
21
|
+
stream for the browser, or a plain array in tests. This is robot_lab's native
|
|
22
|
+
equivalent of an `on_event:` callback.
|
|
23
|
+
|
|
24
|
+
```
|
|
25
|
+
browser ──POST /robots/:name/stream──▶ Sinatra App
|
|
26
|
+
└─ RobotLab::Web.run(robot, msg) { |event| write_sse }
|
|
27
|
+
└─ robot.run(msg, hooks: [StreamHook])
|
|
28
|
+
└─ StreamHook ─emits─▶ EventSink ─▶ SSE frames
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Usage
|
|
32
|
+
|
|
33
|
+
Write a boot file that builds your robots and registers each one:
|
|
34
|
+
|
|
35
|
+
```ruby
|
|
36
|
+
# my_robots.rb
|
|
37
|
+
require "robot_lab"
|
|
38
|
+
require "robot_lab/web"
|
|
39
|
+
|
|
40
|
+
assistant = RobotLab.build(name: "assistant", system_prompt: "You are concise.")
|
|
41
|
+
RobotLab::Web.register(assistant)
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Launch the console (Falcon):
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
robot_lab-web my_robots.rb # http://127.0.0.1:9292
|
|
48
|
+
robot_lab-web --port 4567 my_robots.rb
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
…or point Falcon (or any Rack server) at the server-agnostic `config.ru`:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
ROBOT_LAB_WEB_BOOT=my_robots.rb falcon serve -c config.ru
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Then open the dashboard, pick a robot, and chat. (Running a robot needs the
|
|
58
|
+
provider key for its model, e.g. `ANTHROPIC_API_KEY`.)
|
|
59
|
+
|
|
60
|
+
### Consuming the stream from Ruby
|
|
61
|
+
|
|
62
|
+
The same path the web routes use is available directly — handy for tests or a
|
|
63
|
+
custom front end:
|
|
64
|
+
|
|
65
|
+
```ruby
|
|
66
|
+
RobotLab::Web.run(assistant, "Hello") do |event|
|
|
67
|
+
puts "#{event.role}: #{event.text}"
|
|
68
|
+
end
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Endpoints
|
|
72
|
+
|
|
73
|
+
| Method | Path | Purpose |
|
|
74
|
+
|--------|--------------------------|------------------------------------------------------|
|
|
75
|
+
| GET | `/` | Dashboard: registered robots + recent activity |
|
|
76
|
+
| GET | `/robots/:name` | Chat page for a robot |
|
|
77
|
+
| POST | `/robots/:name/stream` | Run a task, stream events as SSE (`message`/`done`/`error`) |
|
|
78
|
+
| POST | `/robots/:name/chat` | Non-streaming fallback; returns rendered transcript HTML |
|
|
79
|
+
|
|
80
|
+
## Development
|
|
81
|
+
|
|
82
|
+
After checking out the repo, run `bin/setup` to install dependencies, then
|
|
83
|
+
`rake test`. The core (event model, activity log, sink, stream hook) is
|
|
84
|
+
pure-Ruby and unit-tested in isolation; the Sinatra app is covered with
|
|
85
|
+
`rack-test`.
|
|
86
|
+
|
|
87
|
+
Views are **Phlex components** (`lib/robot_lab/web/components/`), rendered in
|
|
88
|
+
Sinatra via `phlex-sinatra`, styled with Tailwind, with Heroicons from
|
|
89
|
+
`phlex-icons-hero`.
|
|
90
|
+
|
|
91
|
+
The Sinatra stack is opt-in: `require "robot_lab/web"` loads only the core;
|
|
92
|
+
`require "robot_lab/web/app"` (or `RobotLab::Web.app`) pulls in Sinatra/Falcon/Phlex.
|
|
93
|
+
|
|
94
|
+
## License
|
|
95
|
+
|
|
96
|
+
Available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
data/Rakefile
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'bundler/gem_tasks'
|
|
4
|
+
require 'rake/testtask'
|
|
5
|
+
|
|
6
|
+
Rake::TestTask.new(:test) do |t|
|
|
7
|
+
t.libs << 'test'
|
|
8
|
+
t.libs << 'lib'
|
|
9
|
+
t.test_files = FileList['test/**/*_test.rb', 'test/**/test_*.rb'].exclude('**/*_helper.rb')
|
|
10
|
+
t.verbose = true
|
|
11
|
+
t.ruby_opts << '-rtest_helper'
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
task default: :test
|
|
15
|
+
|
|
16
|
+
desc 'Run tests with verbose output'
|
|
17
|
+
task :test_verbose do
|
|
18
|
+
ENV['TESTOPTS'] = '--verbose'
|
|
19
|
+
Rake::Task[:test].invoke
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
desc 'Run a single test file'
|
|
23
|
+
task :test_file, [:file] do |_t, args|
|
|
24
|
+
ruby "test/#{args[:file]}"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
desc 'Check code complexity with Flog (warn >=20, fail >=50)'
|
|
28
|
+
task :flog_check do
|
|
29
|
+
require 'flog'
|
|
30
|
+
|
|
31
|
+
method_warn = 20.0
|
|
32
|
+
method_fail = 50.0
|
|
33
|
+
|
|
34
|
+
flogger = Flog.new(all: true)
|
|
35
|
+
flogger.flog(*Dir.glob('lib/**/*.rb'))
|
|
36
|
+
|
|
37
|
+
warnings = []
|
|
38
|
+
failures = []
|
|
39
|
+
|
|
40
|
+
flogger.each_by_score do |method, score|
|
|
41
|
+
next if method.end_with?('#none')
|
|
42
|
+
|
|
43
|
+
if score > method_fail
|
|
44
|
+
failures << "#{format('%.1f', score)}: #{method}"
|
|
45
|
+
elsif score > method_warn
|
|
46
|
+
warnings << "#{format('%.1f', score)}: #{method}"
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
unless warnings.empty?
|
|
51
|
+
puts "\nFlog warnings (#{method_warn}–#{method_fail}) — target for future refactoring:"
|
|
52
|
+
warnings.each { |v| puts " #{v}" }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
if failures.empty?
|
|
56
|
+
puts "\nFlog: no methods exceed the failure threshold (>=#{method_fail})"
|
|
57
|
+
else
|
|
58
|
+
puts "\nFlog failures (>=#{method_fail}) — must be refactored:"
|
|
59
|
+
failures.each { |v| puts " #{v}" }
|
|
60
|
+
abort "\nFlog quality gate failed: #{failures.size} method(s) exceed #{method_fail}"
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
desc 'Check for structural code duplication with Flay (mass >= 50)'
|
|
65
|
+
task :flay_check do
|
|
66
|
+
require 'flay'
|
|
67
|
+
|
|
68
|
+
mass_threshold = 50
|
|
69
|
+
|
|
70
|
+
flay = Flay.new({ mass: mass_threshold, diff: false, verbose: false, summary: false, timeout: 60 })
|
|
71
|
+
flay.process(*Dir.glob('lib/**/*.rb'))
|
|
72
|
+
flay.analyze
|
|
73
|
+
|
|
74
|
+
if flay.hashes.empty?
|
|
75
|
+
puts "\nFlay: no structural duplication detected (mass >= #{mass_threshold})"
|
|
76
|
+
else
|
|
77
|
+
puts "\nFlay found structural duplication (mass >= #{mass_threshold}):"
|
|
78
|
+
flay.report
|
|
79
|
+
abort "\nFlay quality gate failed: #{flay.hashes.length} pattern(s) detected"
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
desc 'Run all quality checks: tests (with coverage), RuboCop, Flog, and Flay'
|
|
84
|
+
task :quality do
|
|
85
|
+
gates = [
|
|
86
|
+
['Tests + Coverage', 'bundle exec rake test'],
|
|
87
|
+
['RuboCop', 'bundle exec rubocop'],
|
|
88
|
+
['Flog Complexity', 'bundle exec rake flog_check'],
|
|
89
|
+
['Flay Duplication', 'bundle exec rake flay_check']
|
|
90
|
+
]
|
|
91
|
+
|
|
92
|
+
results = gates.map do |label, command|
|
|
93
|
+
puts "\n#{'=' * 60}"
|
|
94
|
+
puts "Quality Gate: #{label}"
|
|
95
|
+
puts '=' * 60
|
|
96
|
+
[label, system(command) ? :pass : :fail]
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
green = ->(s) { "\e[32m#{s}\e[0m" }
|
|
100
|
+
red = ->(s) { "\e[31m#{s}\e[0m" }
|
|
101
|
+
width = results.map { |label, _| label.length }.max
|
|
102
|
+
|
|
103
|
+
puts "\n#{'=' * 60}"
|
|
104
|
+
puts 'Quality Gate Summary'
|
|
105
|
+
puts '=' * 60
|
|
106
|
+
results.each do |label, status|
|
|
107
|
+
badge = status == :pass ? green.call('PASS') : red.call('FAIL')
|
|
108
|
+
puts " [#{badge}] #{label.ljust(width)}"
|
|
109
|
+
end
|
|
110
|
+
puts '-' * 60
|
|
111
|
+
|
|
112
|
+
passed = results.count { |_, s| s == :pass }
|
|
113
|
+
failed = results.count { |_, s| s == :fail }
|
|
114
|
+
tally = "#{passed} passed, #{failed} failed"
|
|
115
|
+
puts " #{failed.zero? ? green.call(tally) : red.call(tally)}"
|
|
116
|
+
puts '=' * 60
|
|
117
|
+
|
|
118
|
+
abort "\n#{red.call('Quality gate failed.')}" unless failed.zero?
|
|
119
|
+
puts "\n#{green.call('All quality gates passed.')}"
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
namespace :docs do
|
|
123
|
+
desc 'Build MkDocs documentation'
|
|
124
|
+
task :build do
|
|
125
|
+
sh 'mkdocs build'
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
desc 'Serve MkDocs documentation locally on http://localhost:8000'
|
|
129
|
+
task :serve do
|
|
130
|
+
sh 'mkdocs serve'
|
|
131
|
+
end
|
|
132
|
+
end
|
data/config.ru
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Server-agnostic Rack entry point for robot_lab-web. Falcon is the default
|
|
4
|
+
# server (see exe/robot_lab-web), but any Rack server can run this file:
|
|
5
|
+
#
|
|
6
|
+
# falcon serve -c config.ru # boots the console at localhost:9292
|
|
7
|
+
#
|
|
8
|
+
# By default no robots are registered. Point ROBOT_LAB_WEB_BOOT at a Ruby file
|
|
9
|
+
# that requires robot_lab, builds your robots, and calls RobotLab::Web.register
|
|
10
|
+
# on each — it is loaded here before the app starts.
|
|
11
|
+
#
|
|
12
|
+
# ROBOT_LAB_WEB_BOOT=./my_robots.rb falcon serve -c config.ru
|
|
13
|
+
|
|
14
|
+
require 'robot_lab/web'
|
|
15
|
+
|
|
16
|
+
boot = ENV.fetch('ROBOT_LAB_WEB_BOOT', nil)
|
|
17
|
+
require File.expand_path(boot) if boot && File.exist?(boot)
|
|
18
|
+
|
|
19
|
+
run RobotLab::Web.app
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
# API Reference
|
|
2
|
+
|
|
3
|
+
Every public class, module, and method in `robot_lab-web`. See [How It Works](how_it_works.md) for the concepts behind each one.
|
|
4
|
+
|
|
5
|
+
## `RobotLab::Web` (module)
|
|
6
|
+
|
|
7
|
+
### `register(robot, name: nil) → String`
|
|
8
|
+
|
|
9
|
+
Registers `robot` (anything responding to `#run` and `#name`) so the console can list and drive it. Returns the string key it was stored under. Delegates to `Registry.register`.
|
|
10
|
+
|
|
11
|
+
```ruby
|
|
12
|
+
RobotLab::Web.register(assistant)
|
|
13
|
+
RobotLab::Web.register(assistant, name: "support")
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
### `run(robot, message, sink = nil, &block) → RobotResult`
|
|
17
|
+
|
|
18
|
+
Runs `robot` against `message`, delivering each lifecycle `Event` to `sink` (or `block`, if `sink` is omitted) as it happens, and returning the robot's `RobotResult` once the run completes. This is the one call both Sinatra routes wrap, and the cleanest way to consume the stream from plain Ruby or a test. See [How It Works — `RobotLab::Web.run`](how_it_works.md#robotlabwebrun-the-one-call-that-ties-it-together) for exactly what it wires up.
|
|
19
|
+
|
|
20
|
+
```ruby
|
|
21
|
+
events = []
|
|
22
|
+
RobotLab::Web.run(assistant, "hello") { |event| events << event }
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
### `app → RobotLab::Web::App`
|
|
26
|
+
|
|
27
|
+
Lazily requires `"robot_lab/web/app"` (pulling in Sinatra/Falcon/Phlex on first call) and returns the `App` class — a Rack app, suitable for `run RobotLab::Web.app` in a `config.ru`.
|
|
28
|
+
|
|
29
|
+
### `Error`
|
|
30
|
+
|
|
31
|
+
`RobotLab::Web::Error < StandardError` — reserved for gem-specific error conditions. Not currently raised by any code path in this gem's own logic.
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
## `RobotLab::Web::Event`
|
|
36
|
+
|
|
37
|
+
```ruby
|
|
38
|
+
Event = Struct.new(:role, :content, :robot_name, :timestamp, :event_id, keyword_init: true)
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Immutable (deep-frozen on construction). See [How It Works — The Event Model](how_it_works.md#the-event-model) for the full role/content table.
|
|
42
|
+
|
|
43
|
+
### `new(role:, content:, robot_name: nil, timestamp: nil, event_id: nil)`
|
|
44
|
+
|
|
45
|
+
Raises `ArgumentError` if `role` isn't one of `Event::ROLES` (`:user`, `:delta`, `:robot`, `:tool_call`, `:tool_result`, `:error`). `timestamp` defaults to `Time.now.utc`; `event_id` defaults to `SecureRandom.uuid`.
|
|
46
|
+
|
|
47
|
+
### Readers
|
|
48
|
+
|
|
49
|
+
`role`, `content`, `robot_name`, `timestamp`, `event_id` — plain `Struct` readers.
|
|
50
|
+
|
|
51
|
+
### `tool_name → String, nil`
|
|
52
|
+
|
|
53
|
+
`content[:name]`/`content['name']` when `content` is a Hash (works for both `:tool_call` and `:tool_result`); `nil` otherwise.
|
|
54
|
+
|
|
55
|
+
### `error? → Boolean`
|
|
56
|
+
|
|
57
|
+
`true` when `role == :error`.
|
|
58
|
+
|
|
59
|
+
### `error_message → String, nil`
|
|
60
|
+
|
|
61
|
+
`content[:message]`/`content['message']` (or `content.to_s` for a non-Hash) when `error?`; `nil` otherwise.
|
|
62
|
+
|
|
63
|
+
### `text → Object`
|
|
64
|
+
|
|
65
|
+
A role-agnostic display value: for Hash content, `content[:result] || content[:message] || content[:args] || content`; for anything else, `content` itself unchanged.
|
|
66
|
+
|
|
67
|
+
### `to_h → Hash`
|
|
68
|
+
|
|
69
|
+
`{role:, content:, robot_name:, timestamp: (ISO 8601, ms precision), event_id:}` — JSON-serializable.
|
|
70
|
+
|
|
71
|
+
### `self.from_h(hash) → Event, nil`
|
|
72
|
+
|
|
73
|
+
Rebuilds an `Event` from a Hash with either symbol or string keys (as produced by parsing JSON). Returns `nil` instead of raising if the role fails validation.
|
|
74
|
+
|
|
75
|
+
---
|
|
76
|
+
|
|
77
|
+
## `RobotLab::Web::EventSink` (module)
|
|
78
|
+
|
|
79
|
+
The thread-local hook-to-consumer bridge. See [How It Works](how_it_works.md#eventsink-a-thread-local-mailbox).
|
|
80
|
+
|
|
81
|
+
### `capture(sink) { ... } → Object`
|
|
82
|
+
|
|
83
|
+
Installs `sink` (anything responding to `#call`) as the current consumer for the duration of the block, restoring whatever was previously installed (supports nesting) once the block returns or raises.
|
|
84
|
+
|
|
85
|
+
### `current → #call, nil`
|
|
86
|
+
|
|
87
|
+
The sink installed by the innermost enclosing `capture` call on this thread, or `nil`.
|
|
88
|
+
|
|
89
|
+
### `emit(event) → void`
|
|
90
|
+
|
|
91
|
+
Delivers `event` to `current` if one is installed. Any `StandardError` the sink raises is caught and discarded — a broken consumer must never break the run it's observing.
|
|
92
|
+
|
|
93
|
+
---
|
|
94
|
+
|
|
95
|
+
## `RobotLab::Web::ActivityLog`
|
|
96
|
+
|
|
97
|
+
A thread-safe, bounded ring buffer. See [How It Works](how_it_works.md#activitylog).
|
|
98
|
+
|
|
99
|
+
### Class methods (delegate to a process-wide singleton, `ActivityLog.instance`)
|
|
100
|
+
|
|
101
|
+
| Method | Description |
|
|
102
|
+
|---|---|
|
|
103
|
+
| `log(type, details = {})` | Records an entry (`{type:, details:, timestamp:}`); raises if `type`/`details` are malformed |
|
|
104
|
+
| `safe_log(type, details = {})` | Same, but swallows any error — use this from instrumentation paths |
|
|
105
|
+
| `recent(limit = 10)` | The `limit` most recent entries, newest first |
|
|
106
|
+
| `clear` | Empties the log |
|
|
107
|
+
|
|
108
|
+
### Instance
|
|
109
|
+
|
|
110
|
+
`MAX_EVENTS = 50` — the ring buffer's capacity; oldest entries are dropped once exceeded. `#log`/`#recent`/`#clear` are the same operations, mutex-guarded, on a fresh instance if you don't want the process-wide singleton (mainly useful in tests).
|
|
111
|
+
|
|
112
|
+
---
|
|
113
|
+
|
|
114
|
+
## `RobotLab::Web::Registry` (module)
|
|
115
|
+
|
|
116
|
+
An in-memory `Hash` of registered robots, keyed by name. See [How It Works](how_it_works.md#registry).
|
|
117
|
+
|
|
118
|
+
### `Registry.register(robot, name: nil) → String`
|
|
119
|
+
|
|
120
|
+
Stores `robot` under `(name || robot.name).to_s`, returning that key.
|
|
121
|
+
|
|
122
|
+
### `fetch(name) → robot, nil`
|
|
123
|
+
|
|
124
|
+
Looks up a registered robot by name (coerced to `String`).
|
|
125
|
+
|
|
126
|
+
### `names → Array<String>`
|
|
127
|
+
|
|
128
|
+
All registered names, alphabetically sorted.
|
|
129
|
+
|
|
130
|
+
### `all → Array`
|
|
131
|
+
|
|
132
|
+
All registered robot objects, in insertion order.
|
|
133
|
+
|
|
134
|
+
### `clear → void`
|
|
135
|
+
|
|
136
|
+
Empties the registry — mainly useful in tests.
|
|
137
|
+
|
|
138
|
+
---
|
|
139
|
+
|
|
140
|
+
## `RobotLab::Web::StreamHook`
|
|
141
|
+
|
|
142
|
+
`RobotLab::Hook` subclass, `namespace = :web_stream`. See [How It Works — The Hook-to-Sink Bridge](how_it_works.md#the-hook-to-sink-bridge) for the full callback table. Not instantiated directly — registered per-run via `robot.run(message, hooks: [RobotLab::Web::StreamHook])`, which is exactly what `RobotLab::Web.run` does for you.
|
|
143
|
+
|
|
144
|
+
---
|
|
145
|
+
|
|
146
|
+
## `RobotLab::Web::App` *(requires `"robot_lab/web/app"`)*
|
|
147
|
+
|
|
148
|
+
A `Sinatra::Base` subclass — a Rack app. See [Security](security.md) for the session/CSRF/host-authorization configuration, and [How It Works — The SSE Wire Protocol](how_it_works.md#the-sse-wire-protocol) for the streaming route's frame format.
|
|
149
|
+
|
|
150
|
+
| Route | Behavior |
|
|
151
|
+
|---|---|
|
|
152
|
+
| `GET /` | Renders `Components::Dashboard` with `Registry.names` and `ActivityLog.recent(15)` |
|
|
153
|
+
| `GET /robots/:name` | 404s (via `Components::ErrorPage`) if unregistered; else renders `Components::Chat` |
|
|
154
|
+
| `POST /robots/:name/stream` | SSE stream of one run — see [How It Works](how_it_works.md#the-sse-wire-protocol) |
|
|
155
|
+
| `POST /robots/:name/chat` | Runs to completion, returns rendered transcript HTML fragments (delta events excluded) |
|
|
156
|
+
|
|
157
|
+
`self.session_secret_source` / `self.persisted_dev_secret` — the session-secret resolution logic; see [Security — Session Secret](security.md#session-secret-persistence).
|
|
158
|
+
|
|
159
|
+
---
|
|
160
|
+
|
|
161
|
+
## `RobotLab::Web::Components` *(requires `"robot_lab/web/app"`)*
|
|
162
|
+
|
|
163
|
+
Phlex (`Phlex::HTML`) view components, each a small, focused constructor + `view_template`:
|
|
164
|
+
|
|
165
|
+
| Component | Constructor | Renders |
|
|
166
|
+
|---|---|---|
|
|
167
|
+
| `Layout` | `new(csrf:, content:)` | The HTML document shell — Tailwind CDN + shared styles, top nav, and the wrapped page `content` |
|
|
168
|
+
| `Dashboard` | `new(robots:, activity:)` | Registered robot list + recent-activity feed |
|
|
169
|
+
| `Chat` | `new(name:)` | The transcript container, composer form, and the inline vanilla-JS SSE streaming client |
|
|
170
|
+
| `Message` | `new(event:)` | One transcript message from an `Event` — used server-side by the non-streaming `/chat` fallback |
|
|
171
|
+
| `ErrorPage` | `new(message:)` | The 404 page body |
|
|
172
|
+
|
|
173
|
+
These aren't meant to be used outside `App` itself, but are documented here since they're real, independently-instantiable public classes (not `private_constant`).
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# Getting Started
|
|
2
|
+
|
|
3
|
+
## Prerequisites
|
|
4
|
+
|
|
5
|
+
- Ruby 3.2+ (per the gemspec's `required_ruby_version`)
|
|
6
|
+
- `robot_lab` — a hard dependency; `robot_lab/web` requires it directly (see [How It Works — Core vs. Opt-In Web Stack](how_it_works.md#core-vs-opt-in-web-stack)), so load order relative to your own `require "robot_lab"` never matters
|
|
7
|
+
- A provider API key for whatever model your robots use (e.g. `ANTHROPIC_API_KEY`) — the console itself needs no credentials, but actually running a robot through it does
|
|
8
|
+
|
|
9
|
+
## Installation
|
|
10
|
+
|
|
11
|
+
```ruby
|
|
12
|
+
gem "robot_lab"
|
|
13
|
+
gem "robot_lab-web"
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
```sh
|
|
17
|
+
bundle install
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Writing a Boot File
|
|
21
|
+
|
|
22
|
+
A boot file just builds your robots and registers each one — the console reads this registry to list and drive them:
|
|
23
|
+
|
|
24
|
+
```ruby
|
|
25
|
+
# my_robots.rb
|
|
26
|
+
require "robot_lab"
|
|
27
|
+
require "robot_lab/web"
|
|
28
|
+
|
|
29
|
+
assistant = RobotLab.build(name: "assistant", system_prompt: "You are a concise, friendly assistant.")
|
|
30
|
+
RobotLab::Web.register(assistant)
|
|
31
|
+
|
|
32
|
+
# Register as many as you like:
|
|
33
|
+
# RobotLab::Web.register(RobotLab.build(name: "support", template: :support))
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
`register(robot, name: nil)` keys the robot by `name:` if given, otherwise by `robot.name` — see [API Reference](api_reference.md#registryregisterrobot-name-nil-string).
|
|
37
|
+
|
|
38
|
+
## Launching the Console
|
|
39
|
+
|
|
40
|
+
**Via the bundled executable** (Falcon, the default server):
|
|
41
|
+
|
|
42
|
+
```sh
|
|
43
|
+
robot_lab-web my_robots.rb # http://127.0.0.1:9292
|
|
44
|
+
robot_lab-web --port 4567 my_robots.rb # custom port
|
|
45
|
+
robot_lab-web --host 0.0.0.0 my_robots.rb # custom bind host
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
**Or via `config.ru`**, with any Rack server (the app itself is server-agnostic — `exe/robot_lab-web` picks Falcon specifically for its async/fiber reactor, but nothing about the Sinatra app requires it):
|
|
49
|
+
|
|
50
|
+
```sh
|
|
51
|
+
ROBOT_LAB_WEB_BOOT=my_robots.rb falcon serve -c config.ru
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
`ROBOT_LAB_WEB_BOOT` (or the boot file argument to the executable) is required by path before the app starts — `config.ru`/`exe/robot_lab-web` both check `File.exist?(boot)` and simply skip loading it if the path doesn't resolve, so a typo'd path silently produces a console with zero registered robots rather than an error.
|
|
55
|
+
|
|
56
|
+
Then open the dashboard, pick a robot, and start chatting.
|
|
57
|
+
|
|
58
|
+
## Running from a Source Checkout
|
|
59
|
+
|
|
60
|
+
If you're working inside a clone of `robot_lab-web` itself (rather than the installed gem), `exe/robot_lab-web` calls `require 'bundler/setup'` when it finds a `Gemfile` sitting beside it — this is what resolves the gem's own `lib` and its dependencies, including a local `robot_lab` path dependency during development (see `Gemfile.local`). Run it from the gem's own directory (or via `bundle exec`) so that path resolution works; an installed gem has no adjacent `Gemfile` and falls through to plain RubyGems resolution instead.
|
|
61
|
+
|
|
62
|
+
## Consuming the Stream from Ruby
|
|
63
|
+
|
|
64
|
+
The same path the web routes use is available directly — useful in tests, a script, or a custom front end that isn't the bundled Sinatra app:
|
|
65
|
+
|
|
66
|
+
```ruby
|
|
67
|
+
require "robot_lab"
|
|
68
|
+
require "robot_lab/web"
|
|
69
|
+
|
|
70
|
+
RobotLab::Web.run(assistant, "Hello") do |event|
|
|
71
|
+
puts "#{event.role}: #{event.text}"
|
|
72
|
+
end
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
This doesn't touch Sinatra/Falcon/Phlex at all — see [How It Works](how_it_works.md#core-vs-opt-in-web-stack) for exactly what loading `"robot_lab/web"` alone gives you versus `"robot_lab/web/app"`.
|
|
76
|
+
|
|
77
|
+
## The Four Endpoints
|
|
78
|
+
|
|
79
|
+
| Method | Path | Purpose |
|
|
80
|
+
|---|---|---|
|
|
81
|
+
| `GET` | `/` | Dashboard: registered robots + a recent-activity feed |
|
|
82
|
+
| `GET` | `/robots/:name` | Chat page for one robot |
|
|
83
|
+
| `POST` | `/robots/:name/stream` | Run a task, stream events as Server-Sent Events (`message`/`done`/`error` frames) |
|
|
84
|
+
| `POST` | `/robots/:name/chat` | Non-streaming fallback — runs to completion and returns rendered transcript HTML fragments (for HTMX `hx-swap="beforeend"`-style appending) |
|
|
85
|
+
|
|
86
|
+
See [How It Works](how_it_works.md#the-sse-wire-protocol) for the exact frame format and why the streaming route can't just be a plain `EventSource`.
|
|
87
|
+
|
|
88
|
+
## Development
|
|
89
|
+
|
|
90
|
+
```sh
|
|
91
|
+
bin/setup # install dependencies
|
|
92
|
+
rake test # run the test suite
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
The core (event model, activity log, sink, stream hook) is pure Ruby and unit-tested in isolation; the Sinatra app is covered with `rack-test`.
|