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,19 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title><%= title %></title>
|
|
7
|
+
<link rel="icon" href="/images/favicon.ico" sizes="any">
|
|
8
|
+
<link rel="icon" type="image/png" sizes="16x16" href="/images/favicon-16x16.png">
|
|
9
|
+
<link rel="icon" type="image/png" sizes="32x32" href="/images/favicon-32x32.png">
|
|
10
|
+
<link rel="icon" type="image/png" sizes="48x48" href="/images/favicon-48x48.png">
|
|
11
|
+
<link rel="icon" type="image/png" sizes="64x64" href="/images/favicon-64x64.png">
|
|
12
|
+
<link rel="apple-pptouch-icon" href="/images/apple-touch-icon.png">
|
|
13
|
+
<link rel="stylesheet" href="/stylesheets/application.css">
|
|
14
|
+
<script src="/js/relay.js"></script>
|
|
15
|
+
</head>
|
|
16
|
+
<body data-theme="<%= Relay::THEME %>" class="font-sans">
|
|
17
|
+
<%== yield %>
|
|
18
|
+
</body>
|
|
19
|
+
</html>
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<main class="app-shell h-dvh overflow-hidden font-sans">
|
|
2
|
+
<div id="htmx-activity" class="activity-indicator" aria-live="polite" aria-busy="false">
|
|
3
|
+
<span class="activity-indicator__dot"></span>
|
|
4
|
+
<span>Loading…</span>
|
|
5
|
+
</div>
|
|
6
|
+
<div id="htmx-activity-bar" class="activity-bar" aria-hidden="true"></div>
|
|
7
|
+
<div class="workspace-frame mx-auto flex h-full min-h-0 w-full max-w-none flex-col gap-4 px-4 py-5 lg:flex-row lg:gap-6 sm:px-5">
|
|
8
|
+
<div id="workspace-main" class="flex min-h-0 min-w-0 flex-1 gap-4 lg:gap-5">
|
|
9
|
+
<%== partial("partials/sidebar", locals: {show_controls: true}) %>
|
|
10
|
+
<%== partial("fragments/chat", locals: {messages:, swap_oob: false}) %>
|
|
11
|
+
</div>
|
|
12
|
+
</div>
|
|
13
|
+
</main>
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
<main class="app-shell h-dvh overflow-hidden font-sans">
|
|
2
|
+
<div class="workspace-frame mx-auto flex h-full min-h-0 w-full max-w-none flex-col gap-4 px-4 py-5 lg:flex-row lg:gap-6 sm:px-5">
|
|
3
|
+
<div class="flex min-h-0 min-w-0 flex-1 gap-4 lg:gap-5">
|
|
4
|
+
<%== partial("partials/sidebar", locals: {show_controls: true}) %>
|
|
5
|
+
<section class="workspace-chat">
|
|
6
|
+
<%== partial("fragments/mcp/workspace", locals: {form:, selected_id:, mcps: mcps}) %>
|
|
7
|
+
</section>
|
|
8
|
+
</div>
|
|
9
|
+
</div>
|
|
10
|
+
</main>
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
<main class="auth-shell app-shell min-h-screen px-4 py-6 sm:px-6">
|
|
2
|
+
<div class="mx-auto flex min-h-[calc(100vh-3rem)] w-full max-w-md items-center justify-center">
|
|
3
|
+
<section class="auth-panel w-full px-6 py-8 sm:px-8">
|
|
4
|
+
<div class="w-full space-y-8">
|
|
5
|
+
<a
|
|
6
|
+
href="https://www.youtube.com/watch?v=CyzdOtyYnng"
|
|
7
|
+
target="_blank"
|
|
8
|
+
rel="noreferrer noopener"
|
|
9
|
+
aria-label="Relay home"
|
|
10
|
+
class="block border-b pb-6 text-center"
|
|
11
|
+
style="border-color: var(--theme-panel-border);"
|
|
12
|
+
>
|
|
13
|
+
<img
|
|
14
|
+
class="mx-auto max-h-14 w-auto max-w-[10rem] rounded-2xl shadow-sm"
|
|
15
|
+
src="/images/relay.png"
|
|
16
|
+
alt="Relay"
|
|
17
|
+
>
|
|
18
|
+
</a>
|
|
19
|
+
|
|
20
|
+
<div class="space-y-2 text-center">
|
|
21
|
+
<p class="label">Relay</p>
|
|
22
|
+
<p class="mx-auto max-w-xs text-sm leading-6 text-[color:var(--theme-muted)]">
|
|
23
|
+
Welcome to relay
|
|
24
|
+
</p>
|
|
25
|
+
</div>
|
|
26
|
+
|
|
27
|
+
<form class="space-y-5" method="post" action="/sign-in">
|
|
28
|
+
<div class="space-y-2.5">
|
|
29
|
+
<label class="label" for="email">Email</label>
|
|
30
|
+
<input class="field auth-field" id="email" name="email" type="email" placeholder="you@example.com" autocomplete="email">
|
|
31
|
+
</div>
|
|
32
|
+
|
|
33
|
+
<div class="space-y-2.5">
|
|
34
|
+
<label class="label" for="password">Password</label>
|
|
35
|
+
<input class="field auth-field" id="password" name="password" type="password" placeholder="Enter your password" autocomplete="current-password">
|
|
36
|
+
</div>
|
|
37
|
+
|
|
38
|
+
<button class="auth-button" type="submit">
|
|
39
|
+
Sign in
|
|
40
|
+
</button>
|
|
41
|
+
</form>
|
|
42
|
+
</div>
|
|
43
|
+
</section>
|
|
44
|
+
</div>
|
|
45
|
+
</main>
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
<aside class="hidden shrink-0 self-start lg:sticky lg:top-5 lg:flex lg:w-[17rem] lg:flex-col">
|
|
2
|
+
<div class="workspace-rail flex h-[calc(100vh-2.5rem)] min-h-0 flex-col px-4 py-4">
|
|
3
|
+
<div class="sidebar-header flex w-full justify-center">
|
|
4
|
+
<a
|
|
5
|
+
href="https://www.youtube.com/watch?v=CyzdOtyYnng"
|
|
6
|
+
target="_blank"
|
|
7
|
+
rel="noreferrer noopener"
|
|
8
|
+
aria-label="Relay home"
|
|
9
|
+
class="flex items-center justify-center"
|
|
10
|
+
>
|
|
11
|
+
<img
|
|
12
|
+
class="h-10 w-10 rounded-2xl shadow-sm"
|
|
13
|
+
src="/images/relay.png"
|
|
14
|
+
alt="Relay"
|
|
15
|
+
>
|
|
16
|
+
</a>
|
|
17
|
+
</div>
|
|
18
|
+
<% if show_controls %>
|
|
19
|
+
<div class="sidebar-body mt-5 min-h-0 flex-1">
|
|
20
|
+
<%== partial("fragments/controls") %>
|
|
21
|
+
</div>
|
|
22
|
+
<% end %>
|
|
23
|
+
</div>
|
|
24
|
+
</aside>
|
data/bin/relay
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
def wait
|
|
5
|
+
Process.wait
|
|
6
|
+
rescue Interrupt
|
|
7
|
+
retry
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def libexec
|
|
11
|
+
File.realpath File.join(__dir__, "..", "libexec", "relay")
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def main(argv)
|
|
15
|
+
case argv[0]
|
|
16
|
+
when "setup"
|
|
17
|
+
Process.spawn File.join(libexec, "setup"), *argv[1..]
|
|
18
|
+
Process.wait
|
|
19
|
+
when "migrate"
|
|
20
|
+
Process.spawn File.join(libexec, "migrate"), *argv[1..]
|
|
21
|
+
Process.wait
|
|
22
|
+
when "start"
|
|
23
|
+
Process.spawn File.join(libexec, "start"), *argv[1..]
|
|
24
|
+
Process.wait
|
|
25
|
+
else
|
|
26
|
+
require "relay"
|
|
27
|
+
warn Relay.banner
|
|
28
|
+
warn "Usage: relay [COMMAND] [OPTIONS]\n\n" \
|
|
29
|
+
"Commands:\n" \
|
|
30
|
+
" setup Setup Relay for the first time\n" \
|
|
31
|
+
" start Start Relay\n" \
|
|
32
|
+
" migrate Run database migrations\n"
|
|
33
|
+
end
|
|
34
|
+
rescue Interrupt
|
|
35
|
+
wait
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
main(ARGV)
|
data/config.ru
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
ENV["RACK_MULTIPART_BUFFERED_UPLOAD_BYTESIZE_LIMIT"] ||= (64 * 1024 * 1024).to_s
|
|
4
|
+
|
|
5
|
+
require_relative "app/init"
|
|
6
|
+
|
|
7
|
+
use Rack::Static, urls: ["/g"], root: Relay.home
|
|
8
|
+
use Rack::Static, urls: ["/images", "/stylesheets", "/js"], root: Relay.public_dir
|
|
9
|
+
case Relay.environment
|
|
10
|
+
when "development"
|
|
11
|
+
use Rack::Reloader
|
|
12
|
+
use Rack::Lint
|
|
13
|
+
use Rack::TempfileReaper
|
|
14
|
+
use Rack::ContentLength
|
|
15
|
+
use Rack::ETag
|
|
16
|
+
use Rack::ConditionalGet
|
|
17
|
+
use Rack::Head
|
|
18
|
+
use Relay::Reloader
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
run Relay::Router
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
Sequel.migration do
|
|
2
|
+
change do
|
|
3
|
+
create_table(:contexts) do
|
|
4
|
+
primary_key :id
|
|
5
|
+
foreign_key :user_id, :users, null: false, on_delete: :cascade
|
|
6
|
+
String :model, null: false
|
|
7
|
+
String :provider, null: false
|
|
8
|
+
Integer :input_tokens, default: 0
|
|
9
|
+
Integer :output_tokens, default: 0
|
|
10
|
+
Integer :total_tokens, default: 0
|
|
11
|
+
column :data, :json, null: false, default: Sequel.lit("'{}'")
|
|
12
|
+
|
|
13
|
+
DateTime :created_at, null: false
|
|
14
|
+
DateTime :updated_at, null: false
|
|
15
|
+
|
|
16
|
+
index :user_id
|
|
17
|
+
index :created_at
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
Sequel.migration do
|
|
2
|
+
change do
|
|
3
|
+
create_table(:mcps) do
|
|
4
|
+
primary_key :id
|
|
5
|
+
foreign_key :user_id, :users, null: false, on_delete: :cascade
|
|
6
|
+
String :name, null: false
|
|
7
|
+
String :description, text: true
|
|
8
|
+
String :transport, null: false, default: "stdio"
|
|
9
|
+
TrueClass :enabled, null: false, default: true
|
|
10
|
+
column :data, :json, null: false, default: Sequel.lit("'{}'")
|
|
11
|
+
|
|
12
|
+
DateTime :created_at, null: false
|
|
13
|
+
DateTime :updated_at, null: false
|
|
14
|
+
|
|
15
|
+
index :user_id
|
|
16
|
+
index [:user_id, :enabled]
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
Sequel.migration do
|
|
2
|
+
change do
|
|
3
|
+
create_table(:model_infos) do
|
|
4
|
+
primary_key :id
|
|
5
|
+
String :provider, null: false
|
|
6
|
+
String :model_id, null: false
|
|
7
|
+
String :name, null: false
|
|
8
|
+
TrueClass :chat, null: false, default: false
|
|
9
|
+
column :data, :json, null: false, default: Sequel.lit("'{}'")
|
|
10
|
+
|
|
11
|
+
DateTime :synced_at, null: false
|
|
12
|
+
DateTime :created_at, null: false
|
|
13
|
+
DateTime :updated_at, null: false
|
|
14
|
+
|
|
15
|
+
index :provider
|
|
16
|
+
index [:provider, :chat]
|
|
17
|
+
index [:provider, :model_id], unique: true
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
Sequel.migration do
|
|
2
|
+
change do
|
|
3
|
+
create_table(:songs) do
|
|
4
|
+
primary_key :id
|
|
5
|
+
String :name, null: false
|
|
6
|
+
String :title, null: false
|
|
7
|
+
String :track, null: false
|
|
8
|
+
|
|
9
|
+
DateTime :created_at, null: false
|
|
10
|
+
DateTime :updated_at, null: false
|
|
11
|
+
|
|
12
|
+
index :name
|
|
13
|
+
index :title
|
|
14
|
+
index :track, unique: true
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
data/db/seeds.rb
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
require_relative "../app/init"
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
if Relay::Models::Song.count.zero?
|
|
6
|
+
YAML.safe_load_file(File.join(Relay.resources_dir, "jukebox.yml"), permitted_classes: [], aliases: false).each do |entry|
|
|
7
|
+
Relay::Models::Song.create(
|
|
8
|
+
name: entry["name"],
|
|
9
|
+
title: entry["title"],
|
|
10
|
+
track: entry["track"]
|
|
11
|
+
)
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Relay::Attachment
|
|
4
|
+
class Session
|
|
5
|
+
require "fileutils"
|
|
6
|
+
require "securerandom"
|
|
7
|
+
require "stringio"
|
|
8
|
+
|
|
9
|
+
IMAGE_EXTENSIONS = %w[.png .jpg .jpeg .gif .webp .bmp .tiff .tif .heic .heif].freeze
|
|
10
|
+
|
|
11
|
+
##
|
|
12
|
+
# @param [Hash] session
|
|
13
|
+
# @param [String] root
|
|
14
|
+
# @param [Object, nil] user
|
|
15
|
+
# @param [String, nil] provider
|
|
16
|
+
def initialize(session:, root:, user: nil, provider: nil)
|
|
17
|
+
@session = session
|
|
18
|
+
@root = root
|
|
19
|
+
@user = user
|
|
20
|
+
@provider = provider
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
##
|
|
24
|
+
# @return [Relay::Attachment]
|
|
25
|
+
def file
|
|
26
|
+
@attachment ||= begin
|
|
27
|
+
value = @session["attachment"]
|
|
28
|
+
attachment = Relay::Attachment.new(
|
|
29
|
+
name: value&.[]("name"),
|
|
30
|
+
path: value&.[]("path"),
|
|
31
|
+
type: value&.[]("type")
|
|
32
|
+
)
|
|
33
|
+
clear unless attachment.file?
|
|
34
|
+
attachment
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
##
|
|
39
|
+
# @return [String, nil]
|
|
40
|
+
def error
|
|
41
|
+
@session["attachment_error"]
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
##
|
|
45
|
+
# @param [String] message
|
|
46
|
+
# @return [String]
|
|
47
|
+
def error=(message)
|
|
48
|
+
@session["attachment_error"] = message.to_s
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
##
|
|
52
|
+
# @return [void]
|
|
53
|
+
def clear_error
|
|
54
|
+
@session.delete("attachment_error")
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
##
|
|
58
|
+
# @return [String]
|
|
59
|
+
def accept
|
|
60
|
+
case provider
|
|
61
|
+
when "anthropic" then "image/*,application/pdf,.pdf"
|
|
62
|
+
when "openai", "google" then "*/*"
|
|
63
|
+
else ""
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
##
|
|
68
|
+
# @return [Boolean]
|
|
69
|
+
def supported?
|
|
70
|
+
%w[openai anthropic google].include?(provider)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
##
|
|
74
|
+
# @param [String] filename
|
|
75
|
+
# @param [String, nil] type
|
|
76
|
+
# @return [Boolean]
|
|
77
|
+
def type_supported?(filename:, type:)
|
|
78
|
+
return false unless supported?
|
|
79
|
+
return image_upload?(filename, type) || pdf_upload?(filename, type) if provider == "anthropic"
|
|
80
|
+
true
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
##
|
|
84
|
+
# @return [String]
|
|
85
|
+
def unsupported_message
|
|
86
|
+
return "#{provider} does not support attachments in Relay yet" unless supported?
|
|
87
|
+
"#{provider} attachments must be images or PDFs"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
##
|
|
91
|
+
# @param [#read] io
|
|
92
|
+
# @param [String] filename
|
|
93
|
+
# @param [String, nil] type
|
|
94
|
+
# @return [Relay::Attachment]
|
|
95
|
+
def attach(io:, filename:, type:)
|
|
96
|
+
raise ArgumentError, "a file is required" unless io && filename
|
|
97
|
+
FileUtils.mkdir_p(attachments_dir)
|
|
98
|
+
file = Relay::Attachment.new(
|
|
99
|
+
name: sanitize_filename(filename),
|
|
100
|
+
path: File.join(attachments_dir, "#{SecureRandom.hex(8)}-#{sanitize_filename(filename)}"),
|
|
101
|
+
type: type.to_s
|
|
102
|
+
)
|
|
103
|
+
io.rewind if io.respond_to?(:rewind)
|
|
104
|
+
IO.copy_stream(io, file.path)
|
|
105
|
+
@session["attachment"] = file.to_h
|
|
106
|
+
clear_error
|
|
107
|
+
@attachment = file
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
##
|
|
111
|
+
# @return [Relay::Attachment, nil]
|
|
112
|
+
def consume
|
|
113
|
+
value = @session.delete("attachment")
|
|
114
|
+
return unless Hash === value
|
|
115
|
+
@attachment = Relay::Attachment.new(
|
|
116
|
+
name: value["name"],
|
|
117
|
+
path: value["path"],
|
|
118
|
+
type: value["type"]
|
|
119
|
+
)
|
|
120
|
+
@attachment.file? ? @attachment : nil
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
##
|
|
124
|
+
# @return [void]
|
|
125
|
+
def clear
|
|
126
|
+
@session.delete("attachment")
|
|
127
|
+
clear_error
|
|
128
|
+
@attachment = Relay::Attachment.new
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
private
|
|
132
|
+
|
|
133
|
+
def attachments_dir
|
|
134
|
+
user_id = @user&.id || "anonymous"
|
|
135
|
+
File.join(@root, "tmp", "uploads", user_id.to_s)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def provider
|
|
139
|
+
@provider || "deepseek"
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def pdf_upload?(filename, type)
|
|
143
|
+
File.extname(filename.to_s).downcase == ".pdf" || type.to_s == "application/pdf"
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def image_upload?(filename, type)
|
|
147
|
+
type.to_s.start_with?("image/") || IMAGE_EXTENSIONS.include?(File.extname(filename.to_s).downcase)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def sanitize_filename(filename)
|
|
151
|
+
File.basename(filename.to_s).gsub(/[^A-Za-z0-9._-]/, "_")
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Relay::Attachment
|
|
4
|
+
require_relative "attachment/session"
|
|
5
|
+
|
|
6
|
+
##
|
|
7
|
+
# @param [Hash] session
|
|
8
|
+
# @param [String] root
|
|
9
|
+
# @param [Object, nil] user
|
|
10
|
+
# @param [String, nil] provider
|
|
11
|
+
# @return [Relay::Attachment::Session]
|
|
12
|
+
def self.session(session:, root:, user: nil, provider: nil)
|
|
13
|
+
Session.new(session:, root:, user:, provider:)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
##
|
|
17
|
+
# @param [String, nil] name
|
|
18
|
+
# @param [String, nil] path
|
|
19
|
+
# @param [String, nil] type
|
|
20
|
+
def initialize(name: nil, path: nil, type: nil)
|
|
21
|
+
@name = name.to_s
|
|
22
|
+
@path = path.to_s
|
|
23
|
+
@type = type.to_s
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
##
|
|
27
|
+
# @return [String]
|
|
28
|
+
attr_reader :name
|
|
29
|
+
|
|
30
|
+
##
|
|
31
|
+
# @return [String]
|
|
32
|
+
attr_reader :path
|
|
33
|
+
|
|
34
|
+
##
|
|
35
|
+
# @return [String]
|
|
36
|
+
attr_reader :type
|
|
37
|
+
|
|
38
|
+
##
|
|
39
|
+
# @return [Boolean]
|
|
40
|
+
def file?
|
|
41
|
+
!path.empty? && File.file?(path)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
##
|
|
45
|
+
# @return [Boolean]
|
|
46
|
+
def attached?
|
|
47
|
+
file?
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
##
|
|
51
|
+
# @return [Hash<String, String>]
|
|
52
|
+
def to_h
|
|
53
|
+
{"name" => name, "path" => path, "type" => type}
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Relay::Cache
|
|
4
|
+
##
|
|
5
|
+
# A small in-process cache that supports dynamic attribute-style access.
|
|
6
|
+
#
|
|
7
|
+
# Missing keys are initialized as nested InMemoryCache instances, which
|
|
8
|
+
# makes it convenient for lightweight grouped state such as cached model
|
|
9
|
+
# lists or provider-specific values.
|
|
10
|
+
class InMemoryCache
|
|
11
|
+
##
|
|
12
|
+
# @return [Relay::Cache::InMemoryCache]
|
|
13
|
+
def initialize
|
|
14
|
+
@cache = {}
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
##
|
|
18
|
+
# Handles dynamic cache reads and writes.
|
|
19
|
+
#
|
|
20
|
+
# Setter calls like `cache.models = value` store the value under the
|
|
21
|
+
# corresponding string key. Getter calls return the stored value when
|
|
22
|
+
# present, or initialize a nested InMemoryCache when missing.
|
|
23
|
+
#
|
|
24
|
+
# @param [Symbol] m
|
|
25
|
+
# @param [Array] args
|
|
26
|
+
# @return [Object]
|
|
27
|
+
def method_missing(m, *args, &block)
|
|
28
|
+
if m.to_s.end_with?("=")
|
|
29
|
+
self[m.to_s[0..-2]] = args[0]
|
|
30
|
+
elsif @cache.key?(m.to_s)
|
|
31
|
+
@cache[m.to_s]
|
|
32
|
+
else
|
|
33
|
+
@cache[m.to_s] = InMemoryCache.new
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
##
|
|
38
|
+
# Respond to missing methods that are handled by method_missing.
|
|
39
|
+
def respond_to_missing?(m, include_private = false)
|
|
40
|
+
true
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
##
|
|
44
|
+
# Stores a value by key.
|
|
45
|
+
# @param [String,Symbol] k
|
|
46
|
+
# @param [Object] v
|
|
47
|
+
# @return [Object]
|
|
48
|
+
def []=(k, v)
|
|
49
|
+
@cache[k.to_s] = v
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
##
|
|
53
|
+
# Fetches a value by key.
|
|
54
|
+
# @param [String,Symbol] k
|
|
55
|
+
# @return [Object,nil]
|
|
56
|
+
def [](k)
|
|
57
|
+
@cache[k.to_s]
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
data/lib/relay/cache.rb
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "cgi"
|
|
4
|
+
require "uri"
|
|
5
|
+
|
|
6
|
+
module Relay
|
|
7
|
+
class Jukebox
|
|
8
|
+
def load
|
|
9
|
+
Relay::Models::Song.order(:id).map { entry(_1) }
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def normalize_track(url)
|
|
13
|
+
uri = URI.parse(url.to_s.strip)
|
|
14
|
+
host = uri.host.to_s.downcase
|
|
15
|
+
video_id =
|
|
16
|
+
case host
|
|
17
|
+
when "youtu.be"
|
|
18
|
+
uri.path.split("/").reject(&:empty?).first
|
|
19
|
+
when "youtube.com", "www.youtube.com", "m.youtube.com",
|
|
20
|
+
"youtube-nocookie.com", "www.youtube-nocookie.com"
|
|
21
|
+
extract_youtube_id(uri)
|
|
22
|
+
end
|
|
23
|
+
raise ArgumentError, "unsupported YouTube URL" if video_id.to_s.empty?
|
|
24
|
+
"https://www.youtube-nocookie.com/embed/#{video_id}"
|
|
25
|
+
rescue URI::InvalidURIError
|
|
26
|
+
raise ArgumentError, "invalid YouTube URL"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def remove(name: nil, title: nil, track: nil)
|
|
30
|
+
normalized_name = name && normalize_text(name)
|
|
31
|
+
normalized_title = title && normalize_text(title)
|
|
32
|
+
normalized_track = track && normalize_track(track)
|
|
33
|
+
raise ArgumentError, "name, title, or track is required" unless [normalized_name, normalized_title, normalized_track].any?
|
|
34
|
+
removed = []
|
|
35
|
+
songs.each do |song|
|
|
36
|
+
entry = entry(song)
|
|
37
|
+
matched = true
|
|
38
|
+
matched &&= normalize_text(entry["name"]) == normalized_name if normalized_name
|
|
39
|
+
matched &&= normalize_text(entry["title"]) == normalized_title if normalized_title
|
|
40
|
+
matched &&= normalize_track(entry["track"]) == normalized_track if normalized_track
|
|
41
|
+
next unless matched
|
|
42
|
+
removed << entry
|
|
43
|
+
song.delete
|
|
44
|
+
end
|
|
45
|
+
{removed: removed.length, entries: removed}
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def add(name:, title:, track:)
|
|
49
|
+
normalized_track = normalize_track(track)
|
|
50
|
+
entry = {"name" => scrub_text(name), "title" => scrub_text(title), "track" => normalized_track}
|
|
51
|
+
raise ArgumentError, "name is required" if entry["name"].empty?
|
|
52
|
+
raise ArgumentError, "title is required" if entry["title"].empty?
|
|
53
|
+
songs.each do |song|
|
|
54
|
+
existing = entry(song)
|
|
55
|
+
song.delete if same_track?(existing, entry) || same_song?(existing, entry)
|
|
56
|
+
end
|
|
57
|
+
Relay::Models::Song.create(**entry.transform_keys(&:to_sym))
|
|
58
|
+
entry
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def extract_youtube_id(uri)
|
|
64
|
+
path = uri.path.to_s
|
|
65
|
+
return path.split("/").reject(&:empty?).last if path.start_with?("/embed/", "/shorts/")
|
|
66
|
+
CGI.parse(uri.query.to_s).fetch("v", []).first
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def songs
|
|
70
|
+
Relay::Models::Song.order(:id).all
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def same_track?(left, right)
|
|
74
|
+
normalize_track(left["track"]) == normalize_track(right["track"])
|
|
75
|
+
rescue ArgumentError
|
|
76
|
+
false
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def same_song?(left, right)
|
|
80
|
+
normalize_text(left["name"]) == normalize_text(right["name"]) &&
|
|
81
|
+
normalize_text(left["title"]) == normalize_text(right["title"])
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def scrub_text(value)
|
|
85
|
+
value.to_s.strip.gsub(/\s+/, " ")
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def normalize_text(value)
|
|
89
|
+
scrub_text(value).downcase
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def entry(song)
|
|
93
|
+
{"name" => song.name, "title" => song.title, "track" => normalize_track(song.track)}
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|