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
|
@@ -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>
|