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
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Relay::Routes
4
+ ##
5
+ # Handles authentication for the sign-in form.
6
+ class SignIn < Base
7
+ include Relay::Models
8
+
9
+ ##
10
+ # Authenticates a user and stores their session
11
+ # @return [void]
12
+ def call
13
+ user = find_user
14
+ if user&.authenticate(params["password"])
15
+ sign_in(user)
16
+ r.redirect("/")
17
+ else
18
+ r.redirect("/sign-in")
19
+ end
20
+ end
21
+
22
+ private
23
+
24
+ ##
25
+ # Finds the user for the submitted email address
26
+ # @return [Relay::Models::User, nil]
27
+ def find_user
28
+ Relay::Models::User.where(email: params["email"] || params["username"]).first
29
+ end
30
+
31
+ ##
32
+ # Persists the authenticated user in the session
33
+ # @param [Relay::Models::User] user
34
+ # @return [void]
35
+ def sign_in(user)
36
+ session["user_id"] = user.id
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cgi"
4
+
5
+ module Relay::Routes
6
+ class UploadAttachment < Base
7
+ prepend Relay::Hooks::RequireUser
8
+
9
+ def call
10
+ raise ArgumentError, attachment.unsupported_message unless attachment.type_supported?(filename:, type:)
11
+ attachment.attach(io: request.body, filename:, type:)
12
+ response.status = 200
13
+ response["content-type"] = "text/html"
14
+ partial("fragments/input", locals: {swap_oob: false})
15
+ rescue ArgumentError => e
16
+ attachment.error = e.message
17
+ response.status = 422
18
+ response["content-type"] = "text/html"
19
+ partial("fragments/input", locals: {swap_oob: false})
20
+ end
21
+
22
+ private
23
+
24
+ def filename
25
+ value = request.get_header("HTTP_X_FILE_NAME").to_s
26
+ value = CGI.unescape(value)
27
+ raise ArgumentError, "a file is required" if value.empty?
28
+ value
29
+ end
30
+
31
+ def type
32
+ request.get_header("CONTENT_TYPE")
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,247 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "protocol/websocket/message"
4
+
5
+ class Relay::Routes::Websocket
6
+ module Connection
7
+ ##
8
+ # Establishes the WebSocket connection and handles incoming messages
9
+ # @param [Async::WebSocket::Adapters::Rack] conn
10
+ # The WebSocket connection object
11
+ # @param [LLM::Provider] llm
12
+ # The selected LLM provider
13
+ # @param [Relay::Models::Context] ctx
14
+ # The current context
15
+ # @return [void]
16
+ def on_connect(conn, llm, ctx, params)
17
+ vars[:messages] = ctx.messages
18
+ write(conn, fragment(:status, status_bar(ctx:)))
19
+ while (message = conn.read)
20
+ dispatch(conn, ctx, parse_message(message), params)
21
+ end
22
+ rescue EOFError, Protocol::WebSocket::ClosedError
23
+ nil
24
+ ensure
25
+ @task = nil
26
+ end
27
+
28
+ ##
29
+ # Dispatches an incoming websocket payload to the appropriate handler.
30
+ # @param [Async::WebSocket::Adapters::Rack] conn
31
+ # The WebSocket connection object
32
+ # @param [Relay::Models::Context] ctx
33
+ # The current context
34
+ # @param [Hash] payload
35
+ # The parsed websocket payload
36
+ # @param [Hash] params
37
+ # The mutable request params for the current turn
38
+ # @return [void]
39
+ def dispatch(conn, ctx, payload, params)
40
+ case
41
+ when interrupt?(payload) then interrupt!(conn, ctx)
42
+ when request_in_flight?
43
+ write(conn, fragment(:status, status_bar(status: "Busy", ctx:)))
44
+ else
45
+ @task = Async { on_message(conn, ctx, payload, params) }
46
+ end
47
+ end
48
+
49
+ ##
50
+ # Writes an HTML fragment to the websocket as a text frame
51
+ # @param [Async::WebSocket::Adapters::Rack] conn
52
+ # The WebSocket connection object
53
+ # @param [String] message
54
+ # The rendered HTML fragment
55
+ # @return [void]
56
+ def write(conn, message)
57
+ conn.write(Protocol::WebSocket::TextMessage.new(String(message)))
58
+ conn.flush
59
+ rescue Errno::EPIPE, IOError, Protocol::WebSocket::ClosedError
60
+ nil
61
+ end
62
+
63
+ ##
64
+ # Reads an incoming message, sends it to the LLM session, and handles any function calls
65
+ # @param [Async::WebSocket::Adapters::Rack] conn
66
+ # The WebSocket connection object
67
+ # @param [Relay::Models::Context] ctx
68
+ # The current context
69
+ # @param [String] message
70
+ # The incoming message
71
+ # @return [void]
72
+ def on_message(conn, ctx, payload, params)
73
+ file = attachment_from_payload(payload) || attachment.consume
74
+ prompt = build_prompt(ctx, payload["message"], file)
75
+ return if prompt.empty?
76
+ vars[:messages].concat [{role: :user, content: prompt}, {role: :assistant, content: +""}]
77
+ write(conn, fragment(:status, status_bar(status: "Thinking...", ctx:)))
78
+ write(conn, fragment(:remove_empty_state)) if vars[:messages].length == 2
79
+ write(conn, fragment(:append_message, message: vars[:messages][-2]))
80
+ write(conn, fragment(:append_message, message: vars[:messages][-1]))
81
+ write(conn, fragment(:input))
82
+ yield_tools(ctx) do |tools|
83
+ params[:tools] = tools
84
+ wait_with_heartbeat(conn, proc { talk(ctx, prompt, params) })
85
+ resolve_functions(ctx, conn, params)
86
+ end
87
+ write(conn, fragment(:status, status_bar(ctx:)))
88
+ @contexts = nil
89
+ write(conn, fragment(:contexts, contexts: contexts))
90
+ rescue LLM::Interrupt
91
+ on_interrupt(conn, ctx)
92
+ rescue LLM::NoSuchRegistryError, LLM::NoSuchModelError
93
+ write(conn, fragment(:status, status_bar(cost: "unknown")))
94
+ rescue => e
95
+ pp e.class, e.message, e.backtrace
96
+ write(conn, fragment(:status, status_bar(status: "#{e.class}: #{e.message}")))
97
+ ensure
98
+ @task = nil
99
+ end
100
+
101
+ ##
102
+ # Sends a message to the LLM session
103
+ # @param [Relay::Models::Context] ctx
104
+ # The current context
105
+ # @param [String] message
106
+ # The message to send
107
+ # @return [void]
108
+ def talk(ctx, prompt, params)
109
+ if ctx.messages.empty?
110
+ ctx.talk initial_prompt(prompt), params
111
+ else
112
+ ctx.talk(prompt, params)
113
+ end
114
+ end
115
+
116
+ ##
117
+ # Invokes any pending function calls in the LLM session
118
+ # @param [Relay::Models::Context] ctx
119
+ # The current context
120
+ # @param [Async::WebSocket::Adapters::Rack] conn
121
+ # The WebSocket connection object
122
+ # @return [void]
123
+ def resolve_functions(ctx, conn, params)
124
+ return if ctx.functions.empty?
125
+ returns = wait_with_heartbeat(conn, ctx.wait(:task))
126
+ wait_with_heartbeat(conn, proc { ctx.talk(returns, params) })
127
+ if ctx.functions.any?
128
+ resolve_functions(ctx, conn, params)
129
+ end
130
+ end
131
+
132
+ ##
133
+ # Appends a streamed assistant chunk to the last assistant message and re-renders chat
134
+ # @param [Async::WebSocket::Adapters::Rack] conn
135
+ # The WebSocket connection object
136
+ # @param [String] chunk
137
+ # The streamed assistant text chunk
138
+ # @return [void]
139
+ def stream(conn, chunk)
140
+ message = vars[:messages].reverse_each.find { _1[:role] == :assistant }
141
+ message[:content] << chunk
142
+ write conn, fragment(:replace_last_message, message:)
143
+ end
144
+
145
+ ##
146
+ # Renders a websocket fragment using the retained fragment state
147
+ # @param [Symbol] name
148
+ # The fragment name
149
+ # @param [Hash] locals
150
+ # The local values to merge into the retained fragment state
151
+ # @return [String]
152
+ # The rendered HTML fragment
153
+ def fragment(name, locals = nil, **kwargs)
154
+ vars.merge!((locals || {}).merge(kwargs))
155
+ case name
156
+ when :append_message then partial("fragments/append_message", locals: vars)
157
+ when :chat then partial("fragments/stream", locals: vars)
158
+ when :contexts then partial("fragments/settings/replace_contexts", locals: vars)
159
+ when :input then partial("fragments/input", locals: {swap_oob: true})
160
+ when :remove_empty_state then partial("fragments/remove_empty_state")
161
+ when :replace_last_message then partial("fragments/replace_last_message", locals: vars)
162
+ when :status then partial("fragments/status", locals: vars.merge(swap_oob: true))
163
+ end
164
+ end
165
+
166
+ ##
167
+ # Parses an incoming websocket frame from HTMX and extracts the message text
168
+ # @param [Protocol::WebSocket::Message] message
169
+ # The websocket message frame
170
+ # @return [String]
171
+ # The message text, or an empty string if parsing fails
172
+ def parse_message(message)
173
+ JSON.parse(message.buffer)
174
+ rescue JSON::ParserError
175
+ {}
176
+ end
177
+
178
+ ##
179
+ # Returns the fragments variables
180
+ # @return [Hash]
181
+ def vars
182
+ @temp ||= {messages: []}
183
+ end
184
+
185
+ def request_in_flight?
186
+ @task&.alive?
187
+ end
188
+
189
+ ##
190
+ # Waits for a runnable to finish while sending websocket heartbeats
191
+ # @param [LLM::Function::ThreadGroup, Proc] runnable
192
+ # The runnable value to wait for
193
+ # @param [Async::WebSocket::Adapters::Rack] conn
194
+ # The WebSocket connection object
195
+ # @return [Array<LLM::Function::Return>, nil]
196
+ # Returns thread-group values, or nil for proc work
197
+ def wait_with_heartbeat(conn, runner)
198
+ runnable = if Proc === runner
199
+ Async { runner.call }
200
+ elsif Array === runner
201
+ Async { runner }
202
+ else
203
+ runner
204
+ end
205
+ while runnable.alive?
206
+ write conn, "<!-- heartbeat -->"
207
+ pause(0.5)
208
+ end
209
+ runnable.wait
210
+ end
211
+
212
+ def pause(seconds)
213
+ Async::Task.current.sleep(seconds)
214
+ end
215
+
216
+ def build_prompt(ctx, message, file)
217
+ text = message.to_s.strip
218
+ return text if file.nil?
219
+ parts = []
220
+ parts << text unless text.empty?
221
+ parts << ctx.local_file(file.path)
222
+ parts
223
+ end
224
+
225
+ def attachment_from_payload(payload)
226
+ path = payload["attachment_path"].to_s
227
+ return if path.empty? || !File.file?(path)
228
+ Relay::Attachment.new(
229
+ name: payload["attachment_name"],
230
+ path:,
231
+ type: payload["attachment_type"]
232
+ )
233
+ end
234
+
235
+ ##
236
+ # @param [Relay::Models::Context] servers
237
+ # @yieldparam [Array<LLM::Tool>] tools
238
+ # @return [void]
239
+ def yield_tools(ctx)
240
+ servers = ctx.mcps
241
+ servers.each(&:start)
242
+ yield [*LLM::Tool.registry.reject(&:mcp?), *servers.flat_map(&:tools)]
243
+ ensure
244
+ servers&.each { _1.stop rescue nil }
245
+ end
246
+ end
247
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Relay::Routes::Websocket
4
+ module Interrupt
5
+ def interrupt?(payload)
6
+ payload["type"] == "interrupt"
7
+ end
8
+
9
+ def interrupt!(conn, ctx)
10
+ return unless request_in_flight?
11
+ ctx.interrupt!
12
+ write(conn, fragment(:status, status_bar(status: "Cancelling...", ctx:)))
13
+ end
14
+
15
+ def on_interrupt(conn, ctx)
16
+ message = vars[:messages].reverse_each.find { _1[:role] == :assistant }
17
+ if message && message[:content].to_s.empty?
18
+ message[:content] = "Request cancelled."
19
+ write(conn, fragment(:replace_last_message, message:))
20
+ end
21
+ write(conn, fragment(:status, status_bar(status: "Request cancelled", ctx:)))
22
+ write(conn, fragment(:input))
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Relay::Routes::Websocket
4
+ class Stream < LLM::Stream
5
+ ##
6
+ # @param [Async::WebSocket::Adapters::Rack] conn
7
+ # The WebSocket connection object
8
+ # @param [Relay::Routes::Websocket] sock
9
+ # The websocket route object
10
+ def initialize(conn, sock)
11
+ @conn = conn
12
+ @sock = sock
13
+ end
14
+
15
+ ##
16
+ # Writes a streamed text chunk
17
+ # @param [String] chunk
18
+ # The streamed text chunk
19
+ # @return [void]
20
+ def on_content(chunk)
21
+ @sock.stream(@conn, chunk.to_s)
22
+ end
23
+
24
+ ##
25
+ # On tool call
26
+ # @return [void]
27
+ def on_tool_call(tool, error)
28
+ @sock.report_tool_status(@conn, tool)
29
+ queue << (error || tool.spawn(:task))
30
+ end
31
+
32
+ ##
33
+ # Reports compaction start in the chat status bar.
34
+ # @return [void]
35
+ def on_compaction(ctx, compactor)
36
+ @sock.write(@conn, @sock.fragment(:status, @sock.status_bar(status: "Compacting...", ctx:)))
37
+ end
38
+
39
+ ##
40
+ # Reports compaction completion with refreshed usage details.
41
+ # @return [void]
42
+ def on_compaction_finish(ctx, compactor)
43
+ @sock.write(@conn, @sock.fragment(:status, @sock.status_bar(status: "Compaction finished", ctx:)))
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Relay::Routes
4
+ class Websocket < Base
5
+ require_relative "websocket/connection"
6
+ require_relative "websocket/interrupt"
7
+ require_relative "websocket/stream"
8
+
9
+ prepend Relay::Hooks::RequireUser
10
+
11
+ include Interrupt
12
+ include Connection
13
+ include Relay::Tools
14
+
15
+ def call
16
+ Async::WebSocket::Adapters::Rack.open(request.env) do |conn|
17
+ context = ctx
18
+ stream = Relay::Routes::Websocket::Stream.new(conn, self)
19
+ params = {model: context[:model], stream:, tools: []}
20
+ on_connect conn, context.llm, context, params
21
+ end || upgrade_required
22
+ end
23
+
24
+ def tool_status(functions)
25
+ names = functions.filter_map(&:name).reject(&:empty?).uniq
26
+ return "Running tools…" if names.empty?
27
+ "Running #{names.join(", ")}…"
28
+ end
29
+
30
+ def report_tool_status(conn, tool)
31
+ write(conn, fragment(:status, status_bar(status: tool_status([tool]))))
32
+ end
33
+
34
+ private
35
+
36
+ def upgrade_required
37
+ response.status = 426
38
+ response["content-type"] = "text/plain"
39
+ response["upgrade"] = "websocket"
40
+ "Expected a WebSocket upgrade request\n"
41
+ end
42
+
43
+ def instructions
44
+ File.read File.join(root, "app", "prompts", "system.md")
45
+ end
46
+
47
+ def initial_prompt(message)
48
+ LLM::Prompt.new(llm) do
49
+ _1.system instructions
50
+ _1.user(Array === message ? message : message.to_s)
51
+ end
52
+ end
53
+
54
+ ##
55
+ # Returns a logging tracer
56
+ # @return [LLM::Tracer]
57
+ def logger(llm)
58
+ filename = format("%s-%s.log", llm.name, Date.today.strftime("%Y-%m-%d"))
59
+ LLM::Tracer::Logger.new(llm, path: File.join(root, "tmp", filename))
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Relay::Tools
4
+ class AddSong < LLM::Tool
5
+ include Relay::Tool
6
+
7
+ name "add-song"
8
+ description "Adds a new track from an artist, title, and YouTube link"
9
+ param :name, String, "The artist or performer name", required: true
10
+ param :title, String, "The track title", required: true
11
+ param :url, String, "A YouTube watch/share/embed URL", required: true
12
+
13
+ def call(name:, title:, url:)
14
+ entry = jukebox.add(name:, title:, track: url)
15
+ {
16
+ message: "Added jukebox entry",
17
+ entry:
18
+ }
19
+ end
20
+
21
+ private
22
+
23
+ def jukebox
24
+ @jukebox ||= Relay::Jukebox.new
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Relay::Tools
4
+ ##
5
+ # Returns the built-in jukebox playlist and embeddable iframe HTML for
6
+ # each track. The playlist is stored in the songs table.
7
+ class JukeBox < LLM::Tool
8
+ include Relay::Tool
9
+
10
+ name "jukebox"
11
+ description "Returns a small built-in playlist of playable music videos"
12
+
13
+ ##
14
+ # @return [Array<Hash>]
15
+ def call
16
+ jukebox.load.map do |entry|
17
+ {
18
+ name: entry["name"],
19
+ title: entry["title"],
20
+ track: entry["track"],
21
+ html: Relay.erb("fragments/_iframe.erb", {entry:}),
22
+ directions:,
23
+ }
24
+ end
25
+ end
26
+
27
+ private
28
+
29
+ def jukebox
30
+ @jukebox ||= Relay::Jukebox.new
31
+ end
32
+
33
+ def directions
34
+ [
35
+ "Use the list to tell the user what songs are available.",
36
+ "When the user wants to play a specific track, embed that track's iframe HTML exactly as returned.",
37
+ "Do not use `data-play` attributes."
38
+ ]
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Relay::Tools
4
+ ##
5
+ # The {Relay::Tools::RelayKnowledge} tool provides the LLM
6
+ # with knowledge about Relay through its README documentation.
7
+ # This helps inform the LLM what about Relay is and what it does,
8
+ # since it is unlikely to be heard of by an LLM.
9
+ class RelayKnowledge < LLM::Tool
10
+ include Relay::Tool
11
+
12
+ name "relay-knowledge"
13
+ description "Returns Relay or llm.rb documentation so answers can cite project details"
14
+ param :topic, Enum["relay", "llm.rb"], "The knowledge topic", required: true
15
+
16
+ ##
17
+ # Provides the Relay documentation
18
+ # @return [Hash]
19
+ def call(topic:)
20
+ case topic
21
+ when "relay" then {directions:, documentation: relay_documentation}
22
+ when "llm.rb" then {directions:, documentation: llmrb_documentation}
23
+ else {error: "unknown topic: #{topic}"}
24
+ end
25
+ end
26
+
27
+ private
28
+
29
+ def relay_documentation
30
+ relay_resources.each_with_object({}) do |(key, url), h|
31
+ res = Net::HTTP.get_response URI.parse(url)
32
+ h[key] = res.body
33
+ end
34
+ end
35
+
36
+ def relay_resources
37
+ {"readme" => "https://raw.githubusercontent.com/llmrb/relay/refs/heads/main/README.md"}
38
+ end
39
+
40
+ def llmrb_documentation
41
+ llmrb_resources.each_with_object({}) do |(key, url), h|
42
+ res = Net::HTTP.get_response URI.parse(url)
43
+ h[key] = res.body
44
+ end
45
+ end
46
+
47
+ def llmrb_resources
48
+ {
49
+ "readme" => "https://raw.githubusercontent.com/llmrb/llm.rb/refs/heads/main/README.md",
50
+ "deepdive" => "https://raw.githubusercontent.com/llmrb/llm.rb/refs/heads/main/resources/deepdive.md",
51
+ "changelog" => "https://raw.githubusercontent.com/llmrb/llm.rb/refs/heads/main/CHANGELOG.md"
52
+ }
53
+ end
54
+
55
+ def directions
56
+ "Reference links from the associated document in your response"
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Relay::Tools
4
+ class RemoveSong < LLM::Tool
5
+ include Relay::Tool
6
+
7
+ name "remove-song"
8
+ description "Removes one or more matching tracks from the jukebox"
9
+ param :by, Enum["name", "title", "url"], "The jukebox field to match against", required: true
10
+ param :value, String, "The artist name, track title, or YouTube URL to remove", required: true
11
+
12
+ def call(by:, value:)
13
+ params = remove_params(by, value)
14
+ result = jukebox.remove(**params)
15
+ if result[:removed].zero?
16
+ {
17
+ ok: true,
18
+ by:,
19
+ value:,
20
+ message: "No matching jukebox entries were found; the requested song may already be absent",
21
+ removed: 0,
22
+ entries: []
23
+ }
24
+ else
25
+ {
26
+ ok: true,
27
+ by:,
28
+ value:,
29
+ message: "Removed #{result[:removed]} jukebox entr#{result[:removed] == 1 ? "y" : "ies"}",
30
+ removed: result[:removed],
31
+ entries: result[:entries]
32
+ }
33
+ end
34
+ end
35
+
36
+ private
37
+
38
+ def jukebox
39
+ @jukebox ||= Relay::Jukebox.new
40
+ end
41
+
42
+ def remove_params(by, value)
43
+ text = value.to_s.strip
44
+ raise ArgumentError, "value is required" if text.empty?
45
+ case by
46
+ when "name" then {name: text}
47
+ when "title" then {title: text}
48
+ when "url" then {track: jukebox.normalize_track(text)}
49
+ else raise ArgumentError, "unsupported match field"
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Relay::Validators
4
+ class MCP
5
+ def initialize(model)
6
+ @model = model
7
+ end
8
+
9
+ def call
10
+ validate_data
11
+ validate_transport_fields
12
+ validate_preset_fields
13
+ end
14
+
15
+ private
16
+
17
+ attr_reader :model
18
+
19
+ def validate_data
20
+ model.errors.add(:data, "is invalid") unless model.data.is_a?(Hash)
21
+ end
22
+
23
+ def validate_transport_fields
24
+ case model.transport
25
+ when "http"
26
+ model.errors.add(:url, "is required") if model.url.empty?
27
+ when "stdio"
28
+ model.errors.add(:command, "is required") if model.command.empty?
29
+ end
30
+ end
31
+
32
+ def validate_preset_fields
33
+ case model.data["preset"]
34
+ when "github"
35
+ model.errors.add(:token, "is required") if model.headers["Authorization"].to_s.delete_prefix("Bearer ").strip.empty?
36
+ when "forgejo"
37
+ model.errors.add(:url, "is required") if model.env["FORGEJO_URL"].to_s.strip.empty?
38
+ model.errors.add(:token, "is required") if model.env["FORGEJO_TOKEN"].to_s.strip.empty?
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1 @@
1
+ <div hx-swap-oob="beforeend:#chatbot-messages"><%== partial("fragments/message", locals: {message:}) %></div>