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,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Relay::Database
|
|
4
|
+
extend self
|
|
5
|
+
|
|
6
|
+
##
|
|
7
|
+
# Loads the database config for the given environment.
|
|
8
|
+
# @param [String] env
|
|
9
|
+
# @return [Hash]
|
|
10
|
+
def load(env:)
|
|
11
|
+
erb = ERB.new(File.read(File.join(__dir__, "..", "..", "db", "config.yml")))
|
|
12
|
+
config = YAML.safe_load(erb.result, aliases: true)
|
|
13
|
+
config.fetch(env)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
##
|
|
17
|
+
# Establishes a Sequel connection for the configured environment.
|
|
18
|
+
# @param [String] env
|
|
19
|
+
# @return [Sequel::Database]
|
|
20
|
+
def connect!(env:)
|
|
21
|
+
settings = load(env:)
|
|
22
|
+
adapter = settings.fetch("adapter")
|
|
23
|
+
database = settings.fetch("database")
|
|
24
|
+
adapter = "sqlite" if adapter == "sqlite3"
|
|
25
|
+
database = File.expand_path(database, Relay.home) unless database.start_with?("/")
|
|
26
|
+
Sequel.connect(
|
|
27
|
+
adapter:,
|
|
28
|
+
database:,
|
|
29
|
+
max_connections: settings.fetch("pool", 5),
|
|
30
|
+
pool_timeout: settings.fetch("timeout", 5000) / 1000.0
|
|
31
|
+
)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
Relay::DB = Relay::Database.connect!(env: Relay.environment)
|
|
36
|
+
Sequel::Model.db = Relay::DB
|
data/app/init/env.rb
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
|
|
6
|
+
env = Relay.env_path
|
|
7
|
+
if File.readable?(env)
|
|
8
|
+
data = File.read(env)
|
|
9
|
+
lines = data.each_line
|
|
10
|
+
lines.each do |line|
|
|
11
|
+
k, v = line.split("=")
|
|
12
|
+
ENV[k] = v.chomp
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
if ENV["SESSION_SECRET"].to_s.empty?
|
|
17
|
+
FileUtils.mkdir_p Relay.home
|
|
18
|
+
ENV["SESSION_SECRET"] = SecureRandom.hex(64)
|
|
19
|
+
File.write(env, "", mode: "a") unless File.exist?(env)
|
|
20
|
+
File.write(env, "SESSION_SECRET=#{ENV["SESSION_SECRET"]}\n", mode: "a")
|
|
21
|
+
end
|
data/app/init/router.rb
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Relay
|
|
4
|
+
class Router < Roda
|
|
5
|
+
include Relay::Concerns::Attachment
|
|
6
|
+
include Relay::Concerns::Context
|
|
7
|
+
include Relay::Concerns::View
|
|
8
|
+
|
|
9
|
+
##
|
|
10
|
+
# Plugins
|
|
11
|
+
plugin :common_logger
|
|
12
|
+
|
|
13
|
+
plugin :sessions,
|
|
14
|
+
key: "relay.session",
|
|
15
|
+
secret: ENV["SESSION_SECRET"]
|
|
16
|
+
|
|
17
|
+
plugin :partials,
|
|
18
|
+
assume_fixed_locals: Relay.production?,
|
|
19
|
+
check_template_mtime: !Relay.production?,
|
|
20
|
+
escape: true,
|
|
21
|
+
layout: "layout",
|
|
22
|
+
views: File.expand_path("../views", __dir__)
|
|
23
|
+
|
|
24
|
+
##
|
|
25
|
+
# Routes
|
|
26
|
+
route do |r|
|
|
27
|
+
r.root do
|
|
28
|
+
Pages::Chat.new(self).call
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
r.is "sign-in" do
|
|
32
|
+
r.get do
|
|
33
|
+
Pages::SignIn.new(self).call
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
r.post do
|
|
37
|
+
Routes::SignIn.new(self).call
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
r.get true do
|
|
42
|
+
r.redirect "/"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
r.is "mcps" do
|
|
46
|
+
r.get do
|
|
47
|
+
Routes::ListMCP.new(self).call
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
r.post do
|
|
51
|
+
Routes::MCP::Create.new(self).call
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
r.is "mcps", "new" do
|
|
56
|
+
r.get do
|
|
57
|
+
Routes::MCP::New.new(self).call
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
r.is "mcps", "form" do
|
|
62
|
+
r.post do
|
|
63
|
+
Routes::MCP::Form.new(self).call
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
r.on "mcps", Integer do |id|
|
|
68
|
+
r.get do
|
|
69
|
+
Routes::MCP::Show.new(self).call(id)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
r.is "toggle" do
|
|
73
|
+
r.post do
|
|
74
|
+
Routes::MCP::Toggle.new(self).call(id)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
r.is "delete" do
|
|
79
|
+
r.post do
|
|
80
|
+
Routes::MCP::Delete.new(self).call(id)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
r.post do
|
|
85
|
+
Routes::MCP::Update.new(self).call(id)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
r.on "settings" do
|
|
90
|
+
r.is "set-model" do
|
|
91
|
+
Routes::Settings::SetModel.new(self).call
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
r.is "set-context" do
|
|
95
|
+
Routes::Settings::SetContext.new(self).call
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
r.is "new-context" do
|
|
99
|
+
Routes::Settings::NewContext.new(self).call
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
r.is "set-provider" do
|
|
103
|
+
Routes::Settings::SetProvider.new(self).call
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
r.on "api" do
|
|
108
|
+
r.is "ws" do
|
|
109
|
+
throw :halt, Routes::Websocket.new(self).call
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
r.is "models" do
|
|
114
|
+
r.get do
|
|
115
|
+
Routes::ListModels.new(self).call
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
r.is "providers" do
|
|
120
|
+
r.get do
|
|
121
|
+
Routes::ListProviders.new(self).call
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
r.is "controls" do
|
|
126
|
+
r.get do
|
|
127
|
+
Routes::ListControls.new(self).call
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
r.is "chat" do
|
|
132
|
+
r.get do
|
|
133
|
+
Routes::ListChat.new(self).call
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
r.is "contexts" do
|
|
138
|
+
r.get do
|
|
139
|
+
Routes::ListContexts.new(self).call
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
r.is "tools" do
|
|
144
|
+
r.get do
|
|
145
|
+
Routes::ListTools.new(self).call
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
r.is "upload-attachment" do
|
|
150
|
+
r.post do
|
|
151
|
+
Routes::UploadAttachment.new(self).call
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
r.is "clear-attachment" do
|
|
156
|
+
r.post do
|
|
157
|
+
Routes::ClearAttachment.new(self).call
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
end
|
|
164
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Relay::Models
|
|
4
|
+
##
|
|
5
|
+
# The {Relay::Models::Context} model stores the accumulated model
|
|
6
|
+
# context for a user, provider, and model combination. It persists
|
|
7
|
+
# the underlying {LLM::Session} state as JSON in the "data" column
|
|
8
|
+
# so future turns can continue from the same context window.
|
|
9
|
+
class Context < Sequel::Model
|
|
10
|
+
include Relay::Model
|
|
11
|
+
plugin :llm, provider: :set_provider, context: :set_context, tracer: :set_tracer
|
|
12
|
+
|
|
13
|
+
set_dataset :contexts
|
|
14
|
+
many_to_one :user
|
|
15
|
+
|
|
16
|
+
##
|
|
17
|
+
# @return [String, nil]
|
|
18
|
+
# Returns the first persisted user message content.
|
|
19
|
+
def title
|
|
20
|
+
ctx.messages.find(&:user?)&.content
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
##
|
|
24
|
+
# @return [Array<Relay::Models::MCP>]
|
|
25
|
+
# Enabled MCP servers for this context's user.
|
|
26
|
+
def mcps
|
|
27
|
+
user ? user.mcps_dataset.where(enabled: true).all : []
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
##
|
|
31
|
+
# @return [LLM::Compactor]
|
|
32
|
+
# Returns the runtime compactor for the persisted context.
|
|
33
|
+
def compactor
|
|
34
|
+
ctx.compactor
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
##
|
|
38
|
+
# @return [Boolean]
|
|
39
|
+
# Returns true when the underlying llm.rb context is in a
|
|
40
|
+
# post-compaction state.
|
|
41
|
+
def compacted?
|
|
42
|
+
ctx.compacted?
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
##
|
|
46
|
+
# @note
|
|
47
|
+
# This method excludes tool calls and system messages.
|
|
48
|
+
# It is safe to render in the UI.
|
|
49
|
+
# @return [Array<Hash>]
|
|
50
|
+
# Returns persisted user and assistant messages
|
|
51
|
+
def messages
|
|
52
|
+
ctx.messages.filter_map do |message|
|
|
53
|
+
next if message.tool_call? || message.compaction?
|
|
54
|
+
next unless message.user? || message.assistant?
|
|
55
|
+
{role: message.role.to_sym, content: message.content.to_s}
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
##
|
|
60
|
+
# @return [Integer]
|
|
61
|
+
def context_window
|
|
62
|
+
super
|
|
63
|
+
rescue LLM::NoSuchModelError, LLM::NoSuchRegistryError
|
|
64
|
+
0
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def set_provider
|
|
70
|
+
LLM.method(provider).call(key: ENV["#{provider.upcase}_SECRET"], persistent: true)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def set_context
|
|
74
|
+
{ model: self[:model], compactor: { retention_window: 8, token_threshold: "95%" } }
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def set_tracer
|
|
78
|
+
path = File.join(Relay.logs_dir, "#{provider}-#{Date.today.iso8601}.log")
|
|
79
|
+
LLM::Tracer::Logger.new(llm, path:)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Relay::Models
|
|
4
|
+
##
|
|
5
|
+
# The {Relay::Models::MCP::Preset} module defines the preset catalog
|
|
6
|
+
# and compilation rules for Relay MCP servers.
|
|
7
|
+
module MCP::Preset
|
|
8
|
+
extend self
|
|
9
|
+
|
|
10
|
+
PRESETS = {
|
|
11
|
+
"github" => {
|
|
12
|
+
id: "github",
|
|
13
|
+
title: "GitHub",
|
|
14
|
+
summary: "Connect GitHub with a single token.",
|
|
15
|
+
transport: "http",
|
|
16
|
+
data: {"preset" => "github", "url" => "https://api.githubcopilot.com/mcp/", "headers" => {}},
|
|
17
|
+
description: "Uses GitHub's hosted MCP endpoint with a Bearer token."
|
|
18
|
+
},
|
|
19
|
+
"forgejo" => {
|
|
20
|
+
id: "forgejo",
|
|
21
|
+
title: "Forgejo",
|
|
22
|
+
summary: "Connect a Forgejo instance with URL and token.",
|
|
23
|
+
transport: "stdio",
|
|
24
|
+
data: {"preset" => "forgejo", "argv" => ["forgejo-mcp"], "cwd" => "", "env" => {}},
|
|
25
|
+
description: "Requires Forgejo support to be installed on this Relay host."
|
|
26
|
+
}
|
|
27
|
+
}.freeze
|
|
28
|
+
|
|
29
|
+
##
|
|
30
|
+
# @return [Array<Hash>]
|
|
31
|
+
# Returns all visible MCP presets
|
|
32
|
+
def all
|
|
33
|
+
PRESETS.values
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
##
|
|
37
|
+
# @param [String, Symbol] id
|
|
38
|
+
# The MCP preset id
|
|
39
|
+
# @return [Hash, nil]
|
|
40
|
+
# Returns the preset definition for the given id
|
|
41
|
+
def [](id)
|
|
42
|
+
PRESETS[id.to_s]
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
##
|
|
46
|
+
# @param [Relay::Forms::MCP] form
|
|
47
|
+
# The preset-specific MCP form
|
|
48
|
+
# @return [Hash]
|
|
49
|
+
# The persisted MCP model attributes for the preset
|
|
50
|
+
def attributes_for(form)
|
|
51
|
+
preset = self[form.preset]
|
|
52
|
+
{
|
|
53
|
+
name: preset[:title],
|
|
54
|
+
description: "",
|
|
55
|
+
transport: preset[:transport],
|
|
56
|
+
data: preset[:data].merge(form.data)
|
|
57
|
+
}
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
data/app/models/mcp.rb
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Relay::Models
|
|
4
|
+
class MCP < Sequel::Model
|
|
5
|
+
include Relay::Model
|
|
6
|
+
plugin :validation_class_methods
|
|
7
|
+
|
|
8
|
+
set_dataset :mcps
|
|
9
|
+
many_to_one :user
|
|
10
|
+
validates_presence_of :user_id, :name, :transport
|
|
11
|
+
validates_inclusion_of :transport, in: %w[stdio http]
|
|
12
|
+
|
|
13
|
+
SUMMARY_COLUMNS = %i[
|
|
14
|
+
id
|
|
15
|
+
user_id
|
|
16
|
+
name
|
|
17
|
+
description
|
|
18
|
+
transport
|
|
19
|
+
enabled
|
|
20
|
+
created_at
|
|
21
|
+
updated_at
|
|
22
|
+
].freeze
|
|
23
|
+
|
|
24
|
+
def self.dump_data(data)
|
|
25
|
+
JSON.generate(data)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.load_data(data)
|
|
29
|
+
case data
|
|
30
|
+
when String then JSON.parse(data)
|
|
31
|
+
when Hash, Array then data
|
|
32
|
+
else {}
|
|
33
|
+
end
|
|
34
|
+
rescue JSON::ParserError
|
|
35
|
+
{}
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def self.summary_dataset(dataset = self.dataset)
|
|
39
|
+
dataset.select(*SUMMARY_COLUMNS)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def self.normalize_url(value)
|
|
43
|
+
url = value.to_s.strip
|
|
44
|
+
return "" if url.empty?
|
|
45
|
+
uri = URI.parse(url)
|
|
46
|
+
uri.path = "/" if uri.path.to_s.empty?
|
|
47
|
+
uri.to_s
|
|
48
|
+
rescue URI::InvalidURIError
|
|
49
|
+
url
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
##
|
|
53
|
+
# @return [Hash]
|
|
54
|
+
# Returns the parsed MCP transport data
|
|
55
|
+
def data
|
|
56
|
+
self.class.load_data(self[:data])
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
##
|
|
60
|
+
# @group stdio
|
|
61
|
+
|
|
62
|
+
##
|
|
63
|
+
# @return [Array<String>]
|
|
64
|
+
# Returns the stdio command argv
|
|
65
|
+
def argv
|
|
66
|
+
transport == "stdio" ? data["argv"] || [] : []
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
##
|
|
70
|
+
# @return [String]
|
|
71
|
+
# Returns the stdio executable name
|
|
72
|
+
def command
|
|
73
|
+
argv.first.to_s
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
##
|
|
77
|
+
# @return [Array<String>]
|
|
78
|
+
# Returns the stdio command arguments
|
|
79
|
+
def arguments
|
|
80
|
+
argv.drop(1)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
##
|
|
84
|
+
# @return [Hash]
|
|
85
|
+
# Returns the stdio environment variables
|
|
86
|
+
def env
|
|
87
|
+
transport == "stdio" ? data["env"] || {} : {}
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
##
|
|
91
|
+
# @return [String]
|
|
92
|
+
# Returns the stdio working directory
|
|
93
|
+
def cwd
|
|
94
|
+
transport == "stdio" ? data["cwd"].to_s : ""
|
|
95
|
+
end
|
|
96
|
+
# @endgroup
|
|
97
|
+
|
|
98
|
+
##
|
|
99
|
+
# @group HTTP
|
|
100
|
+
|
|
101
|
+
##
|
|
102
|
+
# @return [String]
|
|
103
|
+
# Returns the MCP HTTP endpoint URL
|
|
104
|
+
def url
|
|
105
|
+
self.class.normalize_url(transport == "http" ? data["url"] : nil)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
##
|
|
109
|
+
# @return [Hash]
|
|
110
|
+
# Returns the MCP HTTP headers
|
|
111
|
+
def headers
|
|
112
|
+
transport == "http" ? data["headers"] || {} : {}
|
|
113
|
+
end
|
|
114
|
+
# @endgroup
|
|
115
|
+
|
|
116
|
+
##
|
|
117
|
+
# @return [Array<Class<LLM::Tool>>]
|
|
118
|
+
# Returns the cached MCP tool classes
|
|
119
|
+
def tools
|
|
120
|
+
@tools ||= mcp.tools
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
##
|
|
124
|
+
# @return [void]
|
|
125
|
+
# Starts the MCP client
|
|
126
|
+
def start
|
|
127
|
+
mcp.start
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
##
|
|
131
|
+
# @return [void]
|
|
132
|
+
# Stops the MCP client
|
|
133
|
+
def stop
|
|
134
|
+
mcp.stop
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
private
|
|
138
|
+
|
|
139
|
+
def before_validation
|
|
140
|
+
super
|
|
141
|
+
self[:data] = self.class.dump_data(self[:data]) unless String === self[:data]
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def validate
|
|
145
|
+
super
|
|
146
|
+
Relay::Validators::MCP.new(self).call
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
##
|
|
150
|
+
# Builds an {LLM::MCP} instance from the persisted transport settings.
|
|
151
|
+
#
|
|
152
|
+
# For `http` transports this returns an HTTP-backed MCP client using
|
|
153
|
+
# {#url} and {#headers}. For `stdio` transports it returns a stdio-backed
|
|
154
|
+
# MCP client using {#argv}, {#env}, and {#cwd}.
|
|
155
|
+
#
|
|
156
|
+
# @return [LLM::MCP]
|
|
157
|
+
def mcp
|
|
158
|
+
@mcp ||= if transport == "http"
|
|
159
|
+
LLM::MCP.http(url:, headers:)
|
|
160
|
+
else
|
|
161
|
+
LLM::MCP.stdio(argv:, env:, cwd: cwd.empty? ? nil : cwd)
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Relay::Models
|
|
4
|
+
class ModelRecord < Sequel::Model
|
|
5
|
+
include Relay::Model
|
|
6
|
+
plugin :validation_class_methods
|
|
7
|
+
|
|
8
|
+
set_dataset :model_records
|
|
9
|
+
|
|
10
|
+
validates_presence_of :provider
|
|
11
|
+
validates_presence_of :model_id
|
|
12
|
+
validates_presence_of :name
|
|
13
|
+
validates_presence_of :synced_at
|
|
14
|
+
|
|
15
|
+
##
|
|
16
|
+
# Refreshes model records for every configured provider.
|
|
17
|
+
#
|
|
18
|
+
# Each provider builder is initialized and then passed through
|
|
19
|
+
# {refresh} to replace its persisted model rows.
|
|
20
|
+
#
|
|
21
|
+
# @return [void]
|
|
22
|
+
def self.refresh_all
|
|
23
|
+
Relay.providers.each do |_, provider|
|
|
24
|
+
provider = provider.call
|
|
25
|
+
refresh(provider)
|
|
26
|
+
rescue LLM::Error
|
|
27
|
+
next
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
##
|
|
32
|
+
# Replaces all stored model metadata for a provider in one transaction.
|
|
33
|
+
#
|
|
34
|
+
# Existing rows for the provider are deleted before the new rows are
|
|
35
|
+
# inserted. An empty provider model list clears the provider's stored
|
|
36
|
+
# model metadata.
|
|
37
|
+
#
|
|
38
|
+
# @param [LLM::Provider] provider
|
|
39
|
+
# @return [void]
|
|
40
|
+
def self.refresh(provider)
|
|
41
|
+
now = Time.now.utc
|
|
42
|
+
name = provider.name.to_s
|
|
43
|
+
db.transaction do
|
|
44
|
+
where(provider: name).delete
|
|
45
|
+
models = provider.models.all.filter_map do
|
|
46
|
+
next unless _1.chat?
|
|
47
|
+
{
|
|
48
|
+
provider: name,
|
|
49
|
+
model_id: _1.id,
|
|
50
|
+
name: _1.name.to_s,
|
|
51
|
+
data: JSON.dump(_1.to_h),
|
|
52
|
+
synced_at: now,
|
|
53
|
+
created_at: now,
|
|
54
|
+
updated_at: now
|
|
55
|
+
}
|
|
56
|
+
end
|
|
57
|
+
multi_insert(models) unless models.empty?
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
##
|
|
62
|
+
# @return [Hash]
|
|
63
|
+
# Returns the parsed model metadata payload.
|
|
64
|
+
def data
|
|
65
|
+
@data ||= JSON.parse(self[:data])
|
|
66
|
+
rescue JSON::ParserError
|
|
67
|
+
{}
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
data/app/models/song.rb
ADDED
data/app/models/user.rb
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Relay::Models
|
|
4
|
+
class User < Sequel::Model
|
|
5
|
+
include Relay::Model
|
|
6
|
+
|
|
7
|
+
set_dataset :users
|
|
8
|
+
one_to_many :contexts
|
|
9
|
+
one_to_many :mcps, class: "Relay::Models::MCP"
|
|
10
|
+
|
|
11
|
+
##
|
|
12
|
+
# Hashes and stores the given password.
|
|
13
|
+
# @param [String] value
|
|
14
|
+
def password=(value)
|
|
15
|
+
@password = value
|
|
16
|
+
self.password_digest = BCrypt::Password.create(value)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
##
|
|
20
|
+
# Authenticates the given plaintext password.
|
|
21
|
+
# @param [String] value
|
|
22
|
+
# @return [Relay::Models::User,false]
|
|
23
|
+
def authenticate(value)
|
|
24
|
+
return false if password_digest.to_s.empty?
|
|
25
|
+
|
|
26
|
+
BCrypt::Password.new(password_digest) == value && self
|
|
27
|
+
rescue BCrypt::Errors::InvalidHash
|
|
28
|
+
false
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
data/app/pages/base.rb
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Relay::Pages
|
|
4
|
+
##
|
|
5
|
+
# Base class for full-page renderers.
|
|
6
|
+
class Base
|
|
7
|
+
include Relay::Models
|
|
8
|
+
include Relay::Concerns::Attachment
|
|
9
|
+
include Relay::Concerns::Context
|
|
10
|
+
include Relay::Concerns::Roda
|
|
11
|
+
include Relay::Concerns::View
|
|
12
|
+
|
|
13
|
+
private
|
|
14
|
+
|
|
15
|
+
##
|
|
16
|
+
# Renders a page template with the shared layout.
|
|
17
|
+
# @param [String] name
|
|
18
|
+
# @param [Hash] locals
|
|
19
|
+
# @return [String]
|
|
20
|
+
def page(name, **locals)
|
|
21
|
+
view(File.join("pages", name), locals:, layout_opts: {locals:})
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
end
|
|
25
|
+
end
|
data/app/pages/chat.rb
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Relay::Pages
|
|
4
|
+
##
|
|
5
|
+
# Renders the chat page.
|
|
6
|
+
class Chat < Base
|
|
7
|
+
prepend Relay::Hooks::RequireUser
|
|
8
|
+
|
|
9
|
+
##
|
|
10
|
+
# @return [String]
|
|
11
|
+
def call
|
|
12
|
+
response["content-type"] = "text/html"
|
|
13
|
+
session["provider"] ||= "deepseek"
|
|
14
|
+
session["model"] ||= default_model
|
|
15
|
+
page("chat", title: "Relay", messages: ctx.messages)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
data/app/pages/mcp.rb
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Relay::Pages
|
|
4
|
+
class MCP < Base
|
|
5
|
+
prepend Relay::Hooks::RequireUser
|
|
6
|
+
|
|
7
|
+
def call(form:, selected_id: nil)
|
|
8
|
+
response["content-type"] = "text/html"
|
|
9
|
+
page("mcps", title: "Relay MCP", form:, selected_id:)
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|