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.
Files changed (130) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +23 -0
  3. data/LICENSE +17 -0
  4. data/README.md +132 -0
  5. data/app/concerns/attachment.rb +12 -0
  6. data/app/concerns/context.rb +147 -0
  7. data/app/concerns/roda.rb +50 -0
  8. data/app/concerns/view.rb +90 -0
  9. data/app/forms/mcp/forgejo.rb +55 -0
  10. data/app/forms/mcp/github.rb +47 -0
  11. data/app/forms/mcp.rb +89 -0
  12. data/app/hooks/require_user.rb +10 -0
  13. data/app/init/database.rb +36 -0
  14. data/app/init/env.rb +21 -0
  15. data/app/init/router.rb +164 -0
  16. data/app/models/context.rb +82 -0
  17. data/app/models/mcp/preset.rb +60 -0
  18. data/app/models/mcp.rb +165 -0
  19. data/app/models/model_record.rb +70 -0
  20. data/app/models/song.rb +11 -0
  21. data/app/models/user.rb +31 -0
  22. data/app/pages/base.rb +25 -0
  23. data/app/pages/chat.rb +18 -0
  24. data/app/pages/mcp.rb +12 -0
  25. data/app/pages/sign_in.rb +14 -0
  26. data/app/prompts/system.md +129 -0
  27. data/app/resources/jukebox.yml +90 -0
  28. data/app/routes/base.rb +36 -0
  29. data/app/routes/clear_attachment.rb +13 -0
  30. data/app/routes/list_chat.rb +11 -0
  31. data/app/routes/list_contexts.rb +17 -0
  32. data/app/routes/list_controls.rb +11 -0
  33. data/app/routes/list_mcp.rb +16 -0
  34. data/app/routes/list_models.rb +14 -0
  35. data/app/routes/list_providers.rb +11 -0
  36. data/app/routes/list_tools.rb +13 -0
  37. data/app/routes/mcp/base.rb +16 -0
  38. data/app/routes/mcp/create.rb +19 -0
  39. data/app/routes/mcp/delete.rb +17 -0
  40. data/app/routes/mcp/form.rb +11 -0
  41. data/app/routes/mcp/new.rb +16 -0
  42. data/app/routes/mcp/show.rb +17 -0
  43. data/app/routes/mcp/toggle.rb +17 -0
  44. data/app/routes/mcp/update.rb +20 -0
  45. data/app/routes/settings/new_context.rb +23 -0
  46. data/app/routes/settings/set_context.rb +26 -0
  47. data/app/routes/settings/set_model.rb +23 -0
  48. data/app/routes/settings/set_provider.rb +38 -0
  49. data/app/routes/sign_in.rb +39 -0
  50. data/app/routes/upload_attachment.rb +35 -0
  51. data/app/routes/websocket/connection.rb +247 -0
  52. data/app/routes/websocket/interrupt.rb +25 -0
  53. data/app/routes/websocket/stream.rb +46 -0
  54. data/app/routes/websocket.rb +62 -0
  55. data/app/tools/add_song.rb +27 -0
  56. data/app/tools/juke_box.rb +41 -0
  57. data/app/tools/relay_knowledge.rb +59 -0
  58. data/app/tools/remove_song.rb +53 -0
  59. data/app/validators/mcp.rb +42 -0
  60. data/app/views/fragments/_append_message.erb +1 -0
  61. data/app/views/fragments/_chat.erb +15 -0
  62. data/app/views/fragments/_contexts.erb +7 -0
  63. data/app/views/fragments/_contexts_body.erb +35 -0
  64. data/app/views/fragments/_controls.erb +15 -0
  65. data/app/views/fragments/_iframe.erb +8 -0
  66. data/app/views/fragments/_input.erb +67 -0
  67. data/app/views/fragments/_mcp_settings.erb +52 -0
  68. data/app/views/fragments/_message.erb +31 -0
  69. data/app/views/fragments/_models.erb +25 -0
  70. data/app/views/fragments/_providers.erb +26 -0
  71. data/app/views/fragments/_remove_empty_state.erb +1 -0
  72. data/app/views/fragments/_replace_last_message.erb +1 -0
  73. data/app/views/fragments/_sidebar_menu.erb +11 -0
  74. data/app/views/fragments/_sidebar_status.erb +21 -0
  75. data/app/views/fragments/_status.erb +40 -0
  76. data/app/views/fragments/_stream.erb +26 -0
  77. data/app/views/fragments/_tools.erb +34 -0
  78. data/app/views/fragments/_tools_panel.erb +4 -0
  79. data/app/views/fragments/mcp/_editor.erb +54 -0
  80. data/app/views/fragments/mcp/_fields_forgejo.erb +16 -0
  81. data/app/views/fragments/mcp/_fields_github.erb +12 -0
  82. data/app/views/fragments/mcp/_list.erb +55 -0
  83. data/app/views/fragments/mcp/_workspace.erb +14 -0
  84. data/app/views/fragments/models/_loading.erb +4 -0
  85. data/app/views/fragments/settings/_chat.erb +1 -0
  86. data/app/views/fragments/settings/_input.erb +1 -0
  87. data/app/views/fragments/settings/_replace_contexts.erb +1 -0
  88. data/app/views/fragments/settings/_workspace.erb +4 -0
  89. data/app/views/layout.erb +19 -0
  90. data/app/views/pages/chat.erb +13 -0
  91. data/app/views/pages/mcps.erb +10 -0
  92. data/app/views/pages/sign_in.erb +45 -0
  93. data/app/views/partials/_sidebar.erb +24 -0
  94. data/bin/relay +38 -0
  95. data/config.ru +21 -0
  96. data/db/migrate/20260319131927_create_users.rb +12 -0
  97. data/db/migrate/20260327000000_create_contexts.rb +20 -0
  98. data/db/migrate/20260426130000_create_mcps.rb +19 -0
  99. data/db/migrate/20260426170000_create_model_infos.rb +20 -0
  100. data/db/migrate/20260503120000_create_songs.rb +17 -0
  101. data/db/migrate/20260503153000_drop_chat_from_model_infos.rb +8 -0
  102. data/db/migrate/20260503160000_rename_model_infos_to_model_records.rb +5 -0
  103. data/db/seeds.rb +13 -0
  104. data/lib/relay/attachment/session.rb +154 -0
  105. data/lib/relay/attachment.rb +55 -0
  106. data/lib/relay/cache/in_memory_cache.rb +60 -0
  107. data/lib/relay/cache.rb +5 -0
  108. data/lib/relay/jukebox.rb +96 -0
  109. data/lib/relay/markdown.rb +45 -0
  110. data/lib/relay/model.rb +12 -0
  111. data/lib/relay/reloader.rb +29 -0
  112. data/lib/relay/task.rb +66 -0
  113. data/lib/relay/task_monitor.rb +80 -0
  114. data/lib/relay/test.rb +11 -0
  115. data/lib/relay/theme.rb +5 -0
  116. data/lib/relay/tool.rb +12 -0
  117. data/lib/relay/version.rb +5 -0
  118. data/lib/relay.rb +183 -0
  119. data/libexec/relay/bootstrap +10 -0
  120. data/libexec/relay/configure +100 -0
  121. data/libexec/relay/migrate +7 -0
  122. data/libexec/relay/setup +10 -0
  123. data/libexec/relay/start +31 -0
  124. data/public/.gitkeep +0 -0
  125. data/public/images/relay.png +0 -0
  126. data/public/js/relay.js +68669 -0
  127. data/public/js/relay.js.map +1 -0
  128. data/public/stylesheets/application.css +2292 -0
  129. data/public/stylesheets/application.css.map +1 -0
  130. 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
+ ![Relay screenshot](./relay3.png)
48
+
49
+ **Chat**
50
+
51
+ ![Relay screenshot](./relay1.png)
52
+
53
+ **MCP**
54
+
55
+ ![Relay screenshot](./relay2.png)
56
+
57
+ ## How easy is it to setup?
58
+
59
+ Very easy.
60
+
61
+ ![demo](./demo.gif)
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,12 @@
1
+ module Relay::Concerns
2
+ module Attachment
3
+ def attachment
4
+ Relay::Attachment.session(
5
+ session:,
6
+ root: Relay.home,
7
+ user: respond_to?(:user) ? user : nil,
8
+ provider: session["provider"]
9
+ )
10
+ end
11
+ end
12
+ end
@@ -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
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Relay::Hooks
4
+ module RequireUser
5
+ def call(*args, **kwargs)
6
+ @user = Relay::Models::User[session["user_id"]]
7
+ @user.nil? ? r.redirect("/sign-in") : super(*args, **kwargs)
8
+ end
9
+ end
10
+ end