relay.app 0.1.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 +7 -0
- data/CHANGELOG.md +23 -0
- data/LICENSE +17 -0
- data/README.md +132 -0
- data/app/concerns/attachment.rb +12 -0
- data/app/concerns/context.rb +147 -0
- data/app/concerns/roda.rb +50 -0
- data/app/concerns/view.rb +90 -0
- data/app/forms/mcp/forgejo.rb +55 -0
- data/app/forms/mcp/github.rb +47 -0
- data/app/forms/mcp.rb +89 -0
- data/app/hooks/require_user.rb +10 -0
- data/app/init/database.rb +36 -0
- data/app/init/env.rb +21 -0
- data/app/init/router.rb +164 -0
- data/app/models/context.rb +82 -0
- data/app/models/mcp/preset.rb +60 -0
- data/app/models/mcp.rb +165 -0
- data/app/models/model_record.rb +70 -0
- data/app/models/song.rb +11 -0
- data/app/models/user.rb +31 -0
- data/app/pages/base.rb +25 -0
- data/app/pages/chat.rb +18 -0
- data/app/pages/mcp.rb +12 -0
- data/app/pages/sign_in.rb +14 -0
- data/app/prompts/system.md +129 -0
- data/app/resources/jukebox.yml +90 -0
- data/app/routes/base.rb +36 -0
- data/app/routes/clear_attachment.rb +13 -0
- data/app/routes/list_chat.rb +11 -0
- data/app/routes/list_contexts.rb +17 -0
- data/app/routes/list_controls.rb +11 -0
- data/app/routes/list_mcp.rb +16 -0
- data/app/routes/list_models.rb +14 -0
- data/app/routes/list_providers.rb +11 -0
- data/app/routes/list_tools.rb +13 -0
- data/app/routes/mcp/base.rb +16 -0
- data/app/routes/mcp/create.rb +19 -0
- data/app/routes/mcp/delete.rb +17 -0
- data/app/routes/mcp/form.rb +11 -0
- data/app/routes/mcp/new.rb +16 -0
- data/app/routes/mcp/show.rb +17 -0
- data/app/routes/mcp/toggle.rb +17 -0
- data/app/routes/mcp/update.rb +20 -0
- data/app/routes/settings/new_context.rb +23 -0
- data/app/routes/settings/set_context.rb +26 -0
- data/app/routes/settings/set_model.rb +23 -0
- data/app/routes/settings/set_provider.rb +38 -0
- data/app/routes/sign_in.rb +39 -0
- data/app/routes/upload_attachment.rb +35 -0
- data/app/routes/websocket/connection.rb +247 -0
- data/app/routes/websocket/interrupt.rb +25 -0
- data/app/routes/websocket/stream.rb +46 -0
- data/app/routes/websocket.rb +62 -0
- data/app/tools/add_song.rb +27 -0
- data/app/tools/juke_box.rb +41 -0
- data/app/tools/relay_knowledge.rb +59 -0
- data/app/tools/remove_song.rb +53 -0
- data/app/validators/mcp.rb +42 -0
- data/app/views/fragments/_append_message.erb +1 -0
- data/app/views/fragments/_chat.erb +15 -0
- data/app/views/fragments/_contexts.erb +7 -0
- data/app/views/fragments/_contexts_body.erb +35 -0
- data/app/views/fragments/_controls.erb +15 -0
- data/app/views/fragments/_iframe.erb +8 -0
- data/app/views/fragments/_input.erb +67 -0
- data/app/views/fragments/_mcp_settings.erb +52 -0
- data/app/views/fragments/_message.erb +31 -0
- data/app/views/fragments/_models.erb +25 -0
- data/app/views/fragments/_providers.erb +26 -0
- data/app/views/fragments/_remove_empty_state.erb +1 -0
- data/app/views/fragments/_replace_last_message.erb +1 -0
- data/app/views/fragments/_sidebar_menu.erb +11 -0
- data/app/views/fragments/_sidebar_status.erb +21 -0
- data/app/views/fragments/_status.erb +40 -0
- data/app/views/fragments/_stream.erb +26 -0
- data/app/views/fragments/_tools.erb +34 -0
- data/app/views/fragments/_tools_panel.erb +4 -0
- data/app/views/fragments/mcp/_editor.erb +54 -0
- data/app/views/fragments/mcp/_fields_forgejo.erb +16 -0
- data/app/views/fragments/mcp/_fields_github.erb +12 -0
- data/app/views/fragments/mcp/_list.erb +55 -0
- data/app/views/fragments/mcp/_workspace.erb +14 -0
- data/app/views/fragments/models/_loading.erb +4 -0
- data/app/views/fragments/settings/_chat.erb +1 -0
- data/app/views/fragments/settings/_input.erb +1 -0
- data/app/views/fragments/settings/_replace_contexts.erb +1 -0
- data/app/views/fragments/settings/_workspace.erb +4 -0
- data/app/views/layout.erb +19 -0
- data/app/views/pages/chat.erb +13 -0
- data/app/views/pages/mcps.erb +10 -0
- data/app/views/pages/sign_in.erb +45 -0
- data/app/views/partials/_sidebar.erb +24 -0
- data/bin/relay +38 -0
- data/config.ru +21 -0
- data/db/migrate/20260319131927_create_users.rb +12 -0
- data/db/migrate/20260327000000_create_contexts.rb +20 -0
- data/db/migrate/20260426130000_create_mcps.rb +19 -0
- data/db/migrate/20260426170000_create_model_infos.rb +20 -0
- data/db/migrate/20260503120000_create_songs.rb +17 -0
- data/db/migrate/20260503153000_drop_chat_from_model_infos.rb +8 -0
- data/db/migrate/20260503160000_rename_model_infos_to_model_records.rb +5 -0
- data/db/seeds.rb +13 -0
- data/lib/relay/attachment/session.rb +154 -0
- data/lib/relay/attachment.rb +55 -0
- data/lib/relay/cache/in_memory_cache.rb +60 -0
- data/lib/relay/cache.rb +5 -0
- data/lib/relay/jukebox.rb +96 -0
- data/lib/relay/markdown.rb +45 -0
- data/lib/relay/model.rb +12 -0
- data/lib/relay/reloader.rb +29 -0
- data/lib/relay/task.rb +66 -0
- data/lib/relay/task_monitor.rb +80 -0
- data/lib/relay/test.rb +11 -0
- data/lib/relay/theme.rb +5 -0
- data/lib/relay/tool.rb +12 -0
- data/lib/relay/version.rb +5 -0
- data/lib/relay.rb +183 -0
- data/libexec/relay/bootstrap +10 -0
- data/libexec/relay/configure +100 -0
- data/libexec/relay/migrate +7 -0
- data/libexec/relay/setup +10 -0
- data/libexec/relay/start +31 -0
- data/public/.gitkeep +0 -0
- data/public/images/relay.png +0 -0
- data/public/js/relay.js +68669 -0
- data/public/js/relay.js.map +1 -0
- data/public/stylesheets/application.css +2292 -0
- data/public/stylesheets/application.css.map +1 -0
- metadata +465 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 86ff616504751463b8f52d81dadebd1ac6a64d0258891af2072bac9b1409195d
|
|
4
|
+
data.tar.gz: 8296385a194399d28d981e0def2f45616db12582dfeb4f375587e66a5ee5d2a8
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: c211e06e2f5497419e68caab08d305b0506f58945a25f958e869b7b1ee821945d82b964f2e351015da0ce85acecb4722aca0629f0272dc76cedd42a8dd16ccf3
|
|
7
|
+
data.tar.gz: 1856dc06dfdb09efa579498920c27306a43da5aa7173f25210e3e63c089cd6bb64697b6938c5e484481b638d12542f42782559a8a69fab2492ad6c922a323fbc
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## v0.1.0
|
|
4
|
+
|
|
5
|
+
First stable release.
|
|
6
|
+
|
|
7
|
+
### Change
|
|
8
|
+
|
|
9
|
+
* **Load tools during boot through `Relay.reload`** <br>
|
|
10
|
+
Call `Relay.reload` from app boot so Relay registers tools before the
|
|
11
|
+
first request, instead of waiting for a later reload pass.
|
|
12
|
+
|
|
13
|
+
* **Load user tools from `~/.relay/tools`** <br>
|
|
14
|
+
Extend `Relay.reload` to load tools from both `app/tools/*.rb` and
|
|
15
|
+
`~/.relay/tools/*.rb`, so user-installed tools participate in the same
|
|
16
|
+
registration flow as built-in tools.
|
|
17
|
+
|
|
18
|
+
### Fix
|
|
19
|
+
|
|
20
|
+
* **Warn and continue on tool load failures** <br>
|
|
21
|
+
Rescue tool load errors during `Relay.reload`, print a warning with the
|
|
22
|
+
exception and backtrace, and continue loading the remaining tool files
|
|
23
|
+
instead of aborting the full reload pass.
|
data/LICENSE
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
Copyright (C) 2026
|
|
2
|
+
Antar Azri <azantar@proton.me>
|
|
3
|
+
0x1eef <0x1eef@hardenedbsd.org>
|
|
4
|
+
|
|
5
|
+
Permission to use, copy, modify, and/or distribute this
|
|
6
|
+
software for any purpose with or without fee is hereby
|
|
7
|
+
granted.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS
|
|
10
|
+
ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL
|
|
11
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO
|
|
12
|
+
EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
|
13
|
+
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER
|
|
14
|
+
RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
|
15
|
+
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION,
|
|
16
|
+
ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE
|
|
17
|
+
OF THIS SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
## About
|
|
2
|
+
|
|
3
|
+
Relay is a self-hostable LLM environment with support for OpenAI, DeepSeek,
|
|
4
|
+
Anthropic, xAI and zAI out of the box. It is incredibly simple to setup
|
|
5
|
+
and get started. The application is distributed as a RubyGem. It has a minimal
|
|
6
|
+
set of dependencies - built on Roda, Sequel, Falcon, [llm.rb](https://github.com/llmrb/llm.rb),
|
|
7
|
+
HTMX and web sockets.
|
|
8
|
+
|
|
9
|
+
There is support for connecting to MCP servers too - both HTTP and stdio. You can
|
|
10
|
+
add your own tools to `~/.relay/tools` which is a neat way to extend the environment
|
|
11
|
+
with your own functionality. The database uses SQLite3 to keep things simple - the
|
|
12
|
+
goal is to have something you can setup in under two minutes.
|
|
13
|
+
|
|
14
|
+
## Getting started
|
|
15
|
+
|
|
16
|
+
#### Install
|
|
17
|
+
|
|
18
|
+
Install the gem:
|
|
19
|
+
|
|
20
|
+
```sh
|
|
21
|
+
gem install relay.app
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Go through interactive setup, start the server, and visit
|
|
25
|
+
http://localhost:9292.
|
|
26
|
+
|
|
27
|
+
```sh
|
|
28
|
+
relay setup
|
|
29
|
+
relay start
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Features
|
|
33
|
+
|
|
34
|
+
* Install and setup in 2 minutes
|
|
35
|
+
* Localize your chats and mcp settings to your user account
|
|
36
|
+
* Connect to multiple providers (OpenAI, xAI, Anthropic, Google, DeepSeek, zAI)
|
|
37
|
+
* Connect to MCP servers
|
|
38
|
+
* Cancel in-flight requests and tool execution cleanly
|
|
39
|
+
* Run tools concurrently
|
|
40
|
+
* Make it yours: extend and customize with your own tools and system prompt
|
|
41
|
+
* Lightweight architecture
|
|
42
|
+
|
|
43
|
+
## Sounds cool, how does it look?
|
|
44
|
+
|
|
45
|
+
**Sign-in**
|
|
46
|
+
|
|
47
|
+

|
|
48
|
+
|
|
49
|
+
**Chat**
|
|
50
|
+
|
|
51
|
+

|
|
52
|
+
|
|
53
|
+
**MCP**
|
|
54
|
+
|
|
55
|
+

|
|
56
|
+
|
|
57
|
+
## How easy is it to setup?
|
|
58
|
+
|
|
59
|
+
Very easy.
|
|
60
|
+
|
|
61
|
+

|
|
62
|
+
|
|
63
|
+
## How do I add my own tool?
|
|
64
|
+
|
|
65
|
+
Before running `relay start` you should add a `~/.relay/tools/<yourtool>.rb`.
|
|
66
|
+
The tool will be automatically made available to the LLM. This is how a tool
|
|
67
|
+
might look - it is not very useful because it does not emit command output
|
|
68
|
+
but it serves as a simple example that you can modify and change to meet
|
|
69
|
+
your requirements:
|
|
70
|
+
|
|
71
|
+
```ruby
|
|
72
|
+
class Shell < LLM::Tool
|
|
73
|
+
name "shell"
|
|
74
|
+
description "Run a shell command"
|
|
75
|
+
parameter :command, String, "The command to run"
|
|
76
|
+
parameter :arguments, Array[String], "The command arguments"
|
|
77
|
+
required %i[command]
|
|
78
|
+
|
|
79
|
+
def call(command:, arguments:)
|
|
80
|
+
{ok: system(command, *arguments)}
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## Wait, what is a tool?
|
|
86
|
+
|
|
87
|
+
A tool contains a name, a description, and optional parameters. It is attached
|
|
88
|
+
to a method, and that method that can be called. The model or LLM decides when
|
|
89
|
+
and how to call a tool. A tool can do anything you can imagine, and it can extend
|
|
90
|
+
the abilities of the LLM. Suddenly a LLM can search the web, run code, and anything
|
|
91
|
+
you can think of. They're a powerful way to extend the capabilities of an LLM.
|
|
92
|
+
|
|
93
|
+
An MCP server can also expose pre-packaged tools, and those can be especially
|
|
94
|
+
powerful for talking to GitHub or your own Forgejo instance.
|
|
95
|
+
|
|
96
|
+
## What are the default tools?
|
|
97
|
+
|
|
98
|
+
The `relay-knowledge` tool returns documentation for both Relay
|
|
99
|
+
and [llm.rb](https://github.com/llmrb/llm.rb) - ask about either
|
|
100
|
+
of those, and you will be able to have an informed conversation
|
|
101
|
+
about both. Good for learning how to use llm.rb, and write your
|
|
102
|
+
own tools.
|
|
103
|
+
|
|
104
|
+
There is also a set of tools that manage a playlist of songs that
|
|
105
|
+
can be played inline in the chat, and you can also add your own
|
|
106
|
+
songs or remove existing ones through the same tools. The only
|
|
107
|
+
requirement is that it is a YouTube URL.
|
|
108
|
+
|
|
109
|
+
## What provider is the best value?
|
|
110
|
+
|
|
111
|
+
DeepSeek. I highly recommend it. The context window is 1M. I have been using it
|
|
112
|
+
all the time - especially for Relay development, and despite my heavy usage, it
|
|
113
|
+
cost only 80 cents overall. It's almost free. I used it **a lot**. I'd estimate
|
|
114
|
+
that a 1M context window costs 14 cents or so.
|
|
115
|
+
|
|
116
|
+
## What about Ollama and friends?
|
|
117
|
+
|
|
118
|
+
[llm.rb](https://github.com/llmrb/llm.rb#readme) provides support ollama, llama.cpp,
|
|
119
|
+
and any OpenAI-compatible endpoint. But Relay does not surface it as a feature. I haven't
|
|
120
|
+
had the time or resources to setup either ollama or llamacpp locally.
|
|
121
|
+
|
|
122
|
+
## Sources
|
|
123
|
+
|
|
124
|
+
* [GitHub.com](https://github.com/llmrb/relay)
|
|
125
|
+
* [GitLab.com](https://gitlab.com/llmrb/relay)
|
|
126
|
+
* [Codeberg.org](https://codeberg.org/llmrb/relay)
|
|
127
|
+
|
|
128
|
+
## License
|
|
129
|
+
|
|
130
|
+
[BSD Zero Clause](https://choosealicense.com/licenses/0bsd/)
|
|
131
|
+
<br>
|
|
132
|
+
See [LICENSE](./LICENSE)
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Relay::Concerns
|
|
4
|
+
##
|
|
5
|
+
# Shared Relay provider, model, and persisted context selection.
|
|
6
|
+
#
|
|
7
|
+
# This concern centralizes the session-backed logic for resolving the
|
|
8
|
+
# current provider, chat model, and
|
|
9
|
+
# {Relay::Models::Context Relay::Models::Context} so pages and routes
|
|
10
|
+
# stay in sync.
|
|
11
|
+
module Context
|
|
12
|
+
##
|
|
13
|
+
# @return [String]
|
|
14
|
+
# The requested provider, defaulting to deepseek.
|
|
15
|
+
def provider
|
|
16
|
+
session["provider"] || "deepseek"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
##
|
|
20
|
+
# @return [String, nil]
|
|
21
|
+
# The requested model.
|
|
22
|
+
def model
|
|
23
|
+
session["model"] = normalize_model(session["model"])
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
##
|
|
27
|
+
# @return [LLM::Provider]
|
|
28
|
+
# The selected provider object.
|
|
29
|
+
def llm
|
|
30
|
+
ctx.llm
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
##
|
|
34
|
+
# @return [Relay::Models::Context]
|
|
35
|
+
# The active persisted context for the current user and provider.
|
|
36
|
+
def ctx
|
|
37
|
+
@ctx ||= begin
|
|
38
|
+
context = current_context || default_context
|
|
39
|
+
sync_context!(context)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
##
|
|
44
|
+
# @return [Array<Relay::Models::Context>]
|
|
45
|
+
# Saved contexts for the current user and provider, newest first.
|
|
46
|
+
def contexts
|
|
47
|
+
@contexts ||= Relay::Models::Context.where(user_id: user.id, provider:)
|
|
48
|
+
.reverse_order(:updated_at)
|
|
49
|
+
.all
|
|
50
|
+
.select { valid_model?(_1[:model]) }
|
|
51
|
+
.select { _1.messages.any? }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
##
|
|
55
|
+
# @return [Array<Relay::Models::MCP>]
|
|
56
|
+
# Saved MCP servers for the current user, newest first.
|
|
57
|
+
def mcps
|
|
58
|
+
@mcps ||= user ? Relay::Models::MCP.summary_dataset(user.mcps_dataset).
|
|
59
|
+
reverse_order(:created_at).
|
|
60
|
+
all : []
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
##
|
|
64
|
+
# @return [Relay::Models::Context, nil]
|
|
65
|
+
# The currently selected context for the session, if it matches the
|
|
66
|
+
# current provider.
|
|
67
|
+
def current_context
|
|
68
|
+
return unless session["context_id"]
|
|
69
|
+
context = Relay::Models::Context.where(user_id: user.id, provider:, id: session["context_id"]).first
|
|
70
|
+
return context if context && valid_model?(context[:model])
|
|
71
|
+
session.delete("context_id")
|
|
72
|
+
nil
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
##
|
|
76
|
+
# @return [Relay::Models::Context]
|
|
77
|
+
# The default context for the current provider/model selection.
|
|
78
|
+
def default_context
|
|
79
|
+
Relay::Models::Context.where(user_id: user.id, provider:, model:)
|
|
80
|
+
.reverse_order(:updated_at)
|
|
81
|
+
.first || Relay::Models::Context.create(user_id: user.id, provider:, model:)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
##
|
|
85
|
+
# @param [Relay::Models::Context] context
|
|
86
|
+
# @return [Relay::Models::Context]
|
|
87
|
+
def sync_context!(context)
|
|
88
|
+
session["context_id"] = context.id
|
|
89
|
+
session["model"] = normalize_model(context[:model])
|
|
90
|
+
context
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
##
|
|
94
|
+
# @return [Hash<String, LLM::Provider>]
|
|
95
|
+
# A map of initialized LLM providers.
|
|
96
|
+
def llms
|
|
97
|
+
@llms ||= {
|
|
98
|
+
"openai" => LLM.openai(key: ENV["OPENAI_SECRET"]),
|
|
99
|
+
"google" => LLM.google(key: ENV["GOOGLE_SECRET"]),
|
|
100
|
+
"anthropic" => LLM.anthropic(key: ENV["ANTHROPIC_SECRET"]),
|
|
101
|
+
"deepseek" => LLM.deepseek(key: ENV["DEEPSEEK_SECRET"]),
|
|
102
|
+
"xai" => LLM.xai(key: ENV["XAI_SECRET"])
|
|
103
|
+
}.transform_values(&:persist!)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
##
|
|
107
|
+
# @return [Array<Relay::Models::ModelRecord>]
|
|
108
|
+
# Models for the current provider.
|
|
109
|
+
def models
|
|
110
|
+
Relay::Models::ModelRecord.where(provider:).order(:name).all
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
##
|
|
114
|
+
# @return [String]
|
|
115
|
+
# Returns the default chat model for the current provider.
|
|
116
|
+
def default_model
|
|
117
|
+
case (provider = llms.fetch(self.provider)).name
|
|
118
|
+
when :deepseek then "deepseek-v4-flash"
|
|
119
|
+
when :openai then "gpt-5.4"
|
|
120
|
+
when :xai then "grok-3"
|
|
121
|
+
else provider.default_model
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
##
|
|
126
|
+
# @param [String, nil] id
|
|
127
|
+
# @return [Boolean]
|
|
128
|
+
def valid_model?(id)
|
|
129
|
+
models.any? { _1.model_id == id }
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
##
|
|
133
|
+
# @param [String, nil] id
|
|
134
|
+
# @return [String]
|
|
135
|
+
def normalize_model(id)
|
|
136
|
+
return id if id && valid_model?(id)
|
|
137
|
+
default_model
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
##
|
|
141
|
+
# @return [Relay::Models::User, nil]
|
|
142
|
+
def user
|
|
143
|
+
@user ||= Relay::Models::User[session["user_id"]] if session["user_id"]
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
end
|
|
147
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Relay::Concerns
|
|
4
|
+
##
|
|
5
|
+
# Shared Roda integration for Relay page and route base classes.
|
|
6
|
+
#
|
|
7
|
+
# This concern stores the current Roda instance, exposes the request
|
|
8
|
+
# object as `r`, and delegates unknown helper calls back to Roda so
|
|
9
|
+
# pages and routes can use methods like `view`, `partial`, `session`,
|
|
10
|
+
# `request`, and `response` without re-defining that plumbing.
|
|
11
|
+
module Roda
|
|
12
|
+
##
|
|
13
|
+
# @param [Roda] roda
|
|
14
|
+
# @return [void]
|
|
15
|
+
def initialize(roda)
|
|
16
|
+
@roda = roda
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
##
|
|
20
|
+
# @return [Roda::RodaRequest]
|
|
21
|
+
# Alias the request object as `r` to match Roda route blocks.
|
|
22
|
+
def r
|
|
23
|
+
@roda.request
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
##
|
|
27
|
+
# Delegates missing methods to the current Roda instance.
|
|
28
|
+
# @param [Symbol] name
|
|
29
|
+
# @param [Array] args
|
|
30
|
+
# @param [Hash] kwargs
|
|
31
|
+
# @return [Object]
|
|
32
|
+
def method_missing(name, *args, **kwargs, &block)
|
|
33
|
+
if @roda.respond_to?(name)
|
|
34
|
+
@roda.send(name, *args, **kwargs, &block)
|
|
35
|
+
else
|
|
36
|
+
super
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
##
|
|
41
|
+
# Returns true when the current Roda instance can respond to a
|
|
42
|
+
# delegated method.
|
|
43
|
+
# @param [Symbol] name
|
|
44
|
+
# @param [Boolean] include_private
|
|
45
|
+
# @return [Boolean]
|
|
46
|
+
def respond_to_missing?(name, include_private = false)
|
|
47
|
+
@roda.respond_to?(name) || super
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Relay::Concerns
|
|
4
|
+
##
|
|
5
|
+
# Shared view-layer functionality for page and route renderers.
|
|
6
|
+
#
|
|
7
|
+
# This concern exists to hold presentation-focused helpers that shape
|
|
8
|
+
# data for templates and fragments, such as status-bar labels and
|
|
9
|
+
# formatted cost/context-window values. Keeping these helpers here
|
|
10
|
+
# separates view concerns from session/context resolution.
|
|
11
|
+
module View
|
|
12
|
+
##
|
|
13
|
+
# @param [#to_s] text
|
|
14
|
+
# @return [String]
|
|
15
|
+
# Returns up to two initials for compact UI badges.
|
|
16
|
+
def initials(text)
|
|
17
|
+
words = text.to_s.strip.split(/\s+/).reject(&:empty?)
|
|
18
|
+
return "?" if words.empty?
|
|
19
|
+
words.first(2).map { _1[0] }.join.upcase
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
##
|
|
23
|
+
# @param [String] text
|
|
24
|
+
# @return [String]
|
|
25
|
+
# Renders markdown to HTML for templates and fragments.
|
|
26
|
+
def markdown(text)
|
|
27
|
+
Relay.markdown(text)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
##
|
|
31
|
+
# @return [Hash]
|
|
32
|
+
# Returns the status-bar payload for the current context.
|
|
33
|
+
def status_bar(status: "Ready", ctx: self.ctx, context_window: nil, cost: nil)
|
|
34
|
+
{
|
|
35
|
+
status:,
|
|
36
|
+
context_window: context_window || context_window(ctx),
|
|
37
|
+
cost: cost || format_cost(ctx.cost)
|
|
38
|
+
}
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
##
|
|
42
|
+
# @param [String] status
|
|
43
|
+
# @return [Boolean]
|
|
44
|
+
# Returns true when the status represents an interruptible request.
|
|
45
|
+
def cancellable?(status)
|
|
46
|
+
text = status.to_s
|
|
47
|
+
text.start_with?("Thinking", "Running", "Compacting")
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
##
|
|
51
|
+
# @param [Relay::Models::Context] ctx
|
|
52
|
+
# @return [Hash]
|
|
53
|
+
# Returns the current context-window display payload.
|
|
54
|
+
def context_window(ctx)
|
|
55
|
+
if ctx.compacted?
|
|
56
|
+
max = ctx.context_window || 0
|
|
57
|
+
{used: 0, max:, label: "Context compacted"}
|
|
58
|
+
else
|
|
59
|
+
used = ctx.usage.total_tokens || 0
|
|
60
|
+
max = ctx.context_window || 0
|
|
61
|
+
{used:, max:, label: "#{used} / #{max} tokens"}
|
|
62
|
+
end
|
|
63
|
+
rescue LLM::NoSuchModelError, LLM::NoSuchRegistryError
|
|
64
|
+
{used: 0, max: 0, label: "0 / 0 tokens"}
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
##
|
|
68
|
+
# @param [String] cost
|
|
69
|
+
# @return [String]
|
|
70
|
+
# Returns the formatted cost string.
|
|
71
|
+
def format_cost(cost)
|
|
72
|
+
return "unknown" if cost == "unknown"
|
|
73
|
+
"$#{cost}"
|
|
74
|
+
rescue LLM::NoSuchModelError, LLM::NoSuchRegistryError
|
|
75
|
+
"unknown"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
##
|
|
79
|
+
# @param [LLM::Provider]
|
|
80
|
+
# @return [String]
|
|
81
|
+
def format_name(name)
|
|
82
|
+
case name
|
|
83
|
+
when :openai then "OpenAI"
|
|
84
|
+
when :xai then "xAI"
|
|
85
|
+
when :deepseek then "DeepSeek"
|
|
86
|
+
else name.to_s.capitalize
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Relay::Forms::MCP
|
|
4
|
+
##
|
|
5
|
+
# The {Relay::Forms::MCP::Forgejo} class represents form state for
|
|
6
|
+
# Relay's Forgejo MCP preset.
|
|
7
|
+
class Forgejo < self
|
|
8
|
+
##
|
|
9
|
+
# @return [String]
|
|
10
|
+
# Returns the Forgejo instance URL
|
|
11
|
+
attr_reader :url
|
|
12
|
+
|
|
13
|
+
##
|
|
14
|
+
# @return [String]
|
|
15
|
+
# Returns the Forgejo access token
|
|
16
|
+
attr_reader :token
|
|
17
|
+
|
|
18
|
+
##
|
|
19
|
+
# @param [String] url The Forgejo instance URL
|
|
20
|
+
# @param [String] token The Forgejo access token
|
|
21
|
+
# @param [Hash] attributes
|
|
22
|
+
# @return [Relay::Forms::MCP::Forgejo]
|
|
23
|
+
def initialize(url: "", token: "", **attributes)
|
|
24
|
+
super(**attributes)
|
|
25
|
+
@url = url.to_s.strip
|
|
26
|
+
@token = token.to_s.strip
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
##
|
|
30
|
+
# @return [String]
|
|
31
|
+
# Returns the preset id
|
|
32
|
+
def preset
|
|
33
|
+
"forgejo"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
##
|
|
37
|
+
# @return [String]
|
|
38
|
+
# Returns the backing MCP transport
|
|
39
|
+
def transport
|
|
40
|
+
"stdio"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
##
|
|
44
|
+
# @return [Hash]
|
|
45
|
+
# Returns the preset-specific MCP data overrides
|
|
46
|
+
def data
|
|
47
|
+
{
|
|
48
|
+
"env" => {
|
|
49
|
+
"FORGEJO_URL" => url,
|
|
50
|
+
"FORGEJO_TOKEN" => token
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Relay::Forms::MCP
|
|
4
|
+
##
|
|
5
|
+
# The {Relay::Forms::MCP::GitHub} class represents form state for
|
|
6
|
+
# Relay's GitHub MCP preset.
|
|
7
|
+
class GitHub < self
|
|
8
|
+
##
|
|
9
|
+
# @return [String]
|
|
10
|
+
# Returns the GitHub bearer token without the `Bearer ` prefix
|
|
11
|
+
attr_reader :token
|
|
12
|
+
|
|
13
|
+
##
|
|
14
|
+
# @param [String] token The GitHub bearer token without the `Bearer ` prefix
|
|
15
|
+
# @param [Hash] attributes
|
|
16
|
+
# @return [Relay::Forms::MCP::GitHub]
|
|
17
|
+
def initialize(token: "", **attributes)
|
|
18
|
+
super(**attributes)
|
|
19
|
+
@token = token.to_s.strip
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
##
|
|
23
|
+
# @return [String]
|
|
24
|
+
# Returns the preset id
|
|
25
|
+
def preset
|
|
26
|
+
"github"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
##
|
|
30
|
+
# @return [String]
|
|
31
|
+
# Returns the backing MCP transport
|
|
32
|
+
def transport
|
|
33
|
+
"http"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
##
|
|
37
|
+
# @return [Hash]
|
|
38
|
+
# Returns the preset-specific MCP data overrides
|
|
39
|
+
def data
|
|
40
|
+
{
|
|
41
|
+
"headers" => {
|
|
42
|
+
"Authorization" => "Bearer #{token}"
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
data/app/forms/mcp.rb
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Relay::Forms
|
|
4
|
+
##
|
|
5
|
+
# The {Relay::Forms::MCP} class represents preset-backed MCP form state.
|
|
6
|
+
# It holds the shared persisted fields for Relay's MCP UI and dispatches
|
|
7
|
+
# to preset-specific subclasses for GitHub and Forgejo.
|
|
8
|
+
class MCP
|
|
9
|
+
##
|
|
10
|
+
# @return [Integer, String, nil]
|
|
11
|
+
# Returns the MCP row id when editing an existing server
|
|
12
|
+
attr_reader :id
|
|
13
|
+
|
|
14
|
+
##
|
|
15
|
+
# @param [Relay::Models::MCP] mcp
|
|
16
|
+
# The persisted MCP server
|
|
17
|
+
# @return [Relay::Forms::MCP]
|
|
18
|
+
# A preset-specific form instance
|
|
19
|
+
def self.from_model(mcp)
|
|
20
|
+
common = {
|
|
21
|
+
id: mcp.id,
|
|
22
|
+
persisted: true
|
|
23
|
+
}
|
|
24
|
+
case preset = mcp.data["preset"]
|
|
25
|
+
when "forgejo"
|
|
26
|
+
attributes = common.merge(url: mcp.env["FORGEJO_URL"], token: mcp.env["FORGEJO_TOKEN"])
|
|
27
|
+
build(preset:, attributes:)
|
|
28
|
+
when "github"
|
|
29
|
+
attributes = common.merge(token: mcp.headers["Authorization"].to_s.delete_prefix("Bearer ").strip)
|
|
30
|
+
build(preset:, attributes:)
|
|
31
|
+
else
|
|
32
|
+
raise ArgumentError, "Unknown MCP preset: #{mcp.data["preset"].inspect}"
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
##
|
|
37
|
+
# @param [Hash] params
|
|
38
|
+
# The submitted MCP form params
|
|
39
|
+
# @return [Relay::Forms::MCP]
|
|
40
|
+
# A preset-specific form instance
|
|
41
|
+
def self.from_params(params)
|
|
42
|
+
common = {
|
|
43
|
+
id: params["id"],
|
|
44
|
+
persisted: false
|
|
45
|
+
}
|
|
46
|
+
case preset = params["preset"]
|
|
47
|
+
when "forgejo"
|
|
48
|
+
attributes = common.merge(url: params["url"], token: params["token"])
|
|
49
|
+
build(preset:, attributes:)
|
|
50
|
+
when "github"
|
|
51
|
+
build(preset:, attributes: common.merge(token: params["token"]))
|
|
52
|
+
else
|
|
53
|
+
raise ArgumentError, "Unknown MCP preset: #{params["preset"].inspect}"
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
##
|
|
58
|
+
# @param [String] preset
|
|
59
|
+
# The MCP preset id
|
|
60
|
+
# @param [Hash] attributes
|
|
61
|
+
# Shared form attributes
|
|
62
|
+
# @return [Relay::Forms::MCP]
|
|
63
|
+
# A preset-specific form instance
|
|
64
|
+
def self.build(preset:, attributes: {})
|
|
65
|
+
case preset
|
|
66
|
+
when "forgejo" then Forgejo.new(**attributes)
|
|
67
|
+
when "github" then GitHub.new(**attributes)
|
|
68
|
+
else raise ArgumentError, "Unknown MCP preset: #{preset.inspect}"
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
##
|
|
73
|
+
# @param [Integer, String, nil] id
|
|
74
|
+
# The MCP row id
|
|
75
|
+
# @param [Boolean] persisted
|
|
76
|
+
# Whether the form is backed by a persisted MCP row
|
|
77
|
+
def initialize(id: nil, persisted: false)
|
|
78
|
+
@id = id
|
|
79
|
+
@persisted = persisted
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
##
|
|
83
|
+
# @return [Boolean]
|
|
84
|
+
# Returns true when this form was built from a persisted MCP row
|
|
85
|
+
def persisted?
|
|
86
|
+
@persisted
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|