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,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
@@ -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
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Relay::Models
4
+ class Song < Sequel::Model
5
+ include Relay::Model
6
+ plugin :validation_class_methods
7
+
8
+ set_dataset :songs
9
+ validates_presence_of :name, :title, :track
10
+ end
11
+ end
@@ -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