rails-llm 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.
@@ -0,0 +1,410 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title>llm.rb Agents</title>
5
+ <meta name="viewport" content="width=device-width,initial-scale=1">
6
+ <%= csrf_meta_tags %>
7
+ <%= csp_meta_tag %>
8
+ <%= javascript_include_tag "rails_llm/application", defer: true %>
9
+ <%= yield :head %>
10
+ <style>
11
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
12
+ body {
13
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI",
14
+ Roboto, "Helvetica Neue", Arial, sans-serif;
15
+ background: #f8f9fa;
16
+ color: #1a1a2e;
17
+ height: 100vh;
18
+ }
19
+ .layout { display: flex; height: 100vh; }
20
+ .sidebar {
21
+ width: 260px;
22
+ background: #1a1a2e;
23
+ color: #c8d6e5;
24
+ padding: 20px 16px;
25
+ display: flex;
26
+ flex-direction: column;
27
+ gap: 6px;
28
+ }
29
+ .sidebar .brand {
30
+ display: flex;
31
+ align-items: center;
32
+ gap: 10px;
33
+ margin-bottom: 20px;
34
+ padding-bottom: 16px;
35
+ border-bottom: 1px solid #2d2d4a;
36
+ }
37
+ .sidebar .brand img {
38
+ width: 32px; height: 32px;
39
+ border-radius: 6px;
40
+ }
41
+ .sidebar .brand h1 {
42
+ font-size: 16px;
43
+ font-weight: 600;
44
+ color: #fff;
45
+ }
46
+ .sidebar .brand span {
47
+ font-size: 11px;
48
+ color: #00d68f;
49
+ font-weight: 500;
50
+ text-transform: uppercase;
51
+ letter-spacing: 0.5px;
52
+ }
53
+ .sidebar .new-agent {
54
+ background: transparent;
55
+ color: #c8d6e5;
56
+ border: 1px dashed #2d2d4a;
57
+ border-radius: 8px;
58
+ padding: 10px;
59
+ text-align: center;
60
+ font-size: 13px;
61
+ font-weight: 500;
62
+ text-decoration: none;
63
+ transition: all 0.15s;
64
+ margin-bottom: 12px;
65
+ }
66
+ .sidebar .new-agent:hover {
67
+ background: #2d2d4a;
68
+ border-color: #00d68f;
69
+ color: #fff;
70
+ }
71
+ .sidebar .agent-link {
72
+ display: block;
73
+ padding: 8px 12px;
74
+ border-radius: 6px;
75
+ color: #8899b4;
76
+ text-decoration: none;
77
+ font-size: 13px;
78
+ transition: all 0.12s;
79
+ overflow: hidden;
80
+ text-overflow: ellipsis;
81
+ white-space: nowrap;
82
+ }
83
+ .sidebar .agent-link:hover {
84
+ background: #2d2d4a;
85
+ color: #e0e6ed;
86
+ }
87
+ .sidebar .agent-link.active {
88
+ background: #2d2d4a;
89
+ color: #00d68f;
90
+ font-weight: 500;
91
+ border-left: 3px solid #00d68f;
92
+ }
93
+ .main {
94
+ flex: 1;
95
+ display: flex;
96
+ flex-direction: column;
97
+ min-width: 0;
98
+ }
99
+ .messages {
100
+ flex: 1;
101
+ overflow-y: auto;
102
+ padding: 32px 24px;
103
+ display: flex;
104
+ flex-direction: column;
105
+ gap: 20px;
106
+ scroll-behavior: smooth;
107
+ }
108
+ .message {
109
+ max-width: 760px;
110
+ padding: 14px 18px;
111
+ border-radius: 12px;
112
+ line-height: 1.6;
113
+ font-size: 15px;
114
+ animation: fadeIn 0.2s ease;
115
+ }
116
+ @keyframes fadeIn {
117
+ from { opacity: 0; transform: translateY(8px); }
118
+ to { opacity: 1; transform: translateY(0); }
119
+ }
120
+ .message.user {
121
+ background: #1a1a2e;
122
+ color: #fff;
123
+ align-self: flex-end;
124
+ border-bottom-right-radius: 4px;
125
+ }
126
+ .message.assistant {
127
+ background: #fff;
128
+ border: 1px solid #e8ecf1;
129
+ align-self: flex-start;
130
+ border-bottom-left-radius: 4px;
131
+ box-shadow: 0 1px 3px rgba(0,0,0,0.04);
132
+ }
133
+ .message.assistant > :first-child { margin-top: 0; }
134
+ .message.assistant > :last-child { margin-bottom: 0; }
135
+ .message.assistant p + p,
136
+ .message.assistant ul,
137
+ .message.assistant ol,
138
+ .message.assistant pre,
139
+ .message.assistant blockquote,
140
+ .message.assistant table { margin-top: 12px; }
141
+ .message.assistant h1,
142
+ .message.assistant h2,
143
+ .message.assistant h3,
144
+ .message.assistant h4,
145
+ .message.assistant h5,
146
+ .message.assistant h6 {
147
+ line-height: 1.25;
148
+ color: #101828;
149
+ margin: 14px 0 8px;
150
+ }
151
+ .message.assistant h1,
152
+ .message.assistant h2 { font-size: 17px; }
153
+ .message.assistant h3 { font-size: 16px; }
154
+ .message.assistant h4,
155
+ .message.assistant h5,
156
+ .message.assistant h6 { font-size: 15px; }
157
+ .message.assistant ul,
158
+ .message.assistant ol {
159
+ padding-left: 22px;
160
+ }
161
+ .message.assistant li + li {
162
+ margin-top: 4px;
163
+ }
164
+ .message.assistant a {
165
+ color: #0f766e;
166
+ text-decoration: underline;
167
+ text-underline-offset: 2px;
168
+ word-break: break-word;
169
+ }
170
+ .message.assistant code {
171
+ font-family: "SF Mono", "Fira Code", monospace;
172
+ font-size: 13px;
173
+ background: #f3f5f7;
174
+ color: #0f172a;
175
+ padding: 2px 5px;
176
+ border-radius: 5px;
177
+ }
178
+ .message.assistant pre {
179
+ overflow-x: auto;
180
+ padding: 12px 14px;
181
+ border-radius: 8px;
182
+ background: #0f172a;
183
+ color: #e2e8f0;
184
+ font-size: 13px;
185
+ line-height: 1.55;
186
+ }
187
+ .message.assistant pre code {
188
+ display: block;
189
+ background: transparent;
190
+ color: inherit;
191
+ padding: 0;
192
+ border-radius: 0;
193
+ white-space: pre;
194
+ tab-size: 2;
195
+ }
196
+ .message.assistant pre code[class*="language-"] .c,
197
+ .message.assistant pre code[class*="language-"] .cm,
198
+ .message.assistant pre code[class*="language-"] .c1 {
199
+ color: #94a3b8;
200
+ }
201
+ .message.assistant pre code[class*="language-"] .k,
202
+ .message.assistant pre code[class*="language-"] .kd,
203
+ .message.assistant pre code[class*="language-"] .kn {
204
+ color: #f472b6;
205
+ }
206
+ .message.assistant pre code[class*="language-"] .s,
207
+ .message.assistant pre code[class*="language-"] .s1,
208
+ .message.assistant pre code[class*="language-"] .s2 {
209
+ color: #86efac;
210
+ }
211
+ .message.assistant pre code[class*="language-"] .nf,
212
+ .message.assistant pre code[class*="language-"] .nc {
213
+ color: #7dd3fc;
214
+ }
215
+ .message.assistant pre code[class*="language-"] .mi,
216
+ .message.assistant pre code[class*="language-"] .mf {
217
+ color: #fdba74;
218
+ }
219
+ .message.assistant blockquote {
220
+ border-left: 3px solid #00d68f;
221
+ padding-left: 12px;
222
+ color: #52606d;
223
+ }
224
+ .message.assistant table {
225
+ width: 100%;
226
+ border-collapse: collapse;
227
+ font-size: 14px;
228
+ }
229
+ .message.assistant th,
230
+ .message.assistant td {
231
+ border: 1px solid #e5e7eb;
232
+ padding: 8px 10px;
233
+ text-align: left;
234
+ vertical-align: top;
235
+ }
236
+ .message.assistant th {
237
+ background: #f8fafc;
238
+ font-weight: 600;
239
+ }
240
+ .message.assistant img {
241
+ max-width: 100%;
242
+ height: auto;
243
+ border-radius: 8px;
244
+ }
245
+ .reasoning {
246
+ max-width: 760px;
247
+ align-self: flex-start;
248
+ font-size: 13px;
249
+ color: #6b7a8f;
250
+ background: #f0f2f5;
251
+ border-radius: 8px;
252
+ padding: 10px 14px;
253
+ margin-top: -12px;
254
+ border-left: 3px solid #00d68f;
255
+ }
256
+ .reasoning summary {
257
+ cursor: pointer;
258
+ font-weight: 500;
259
+ color: #4a5a6f;
260
+ user-select: none;
261
+ }
262
+ .reasoning .content {
263
+ margin-top: 8px;
264
+ font-size: 13px;
265
+ line-height: 1.5;
266
+ color: #5a6a7f;
267
+ white-space: pre-wrap;
268
+ }
269
+ .tool-call {
270
+ max-width: 760px;
271
+ align-self: flex-start;
272
+ font-size: 13px;
273
+ background: #f8f9fc;
274
+ border: 1px solid #e8ecf1;
275
+ border-radius: 8px;
276
+ padding: 10px 14px;
277
+ margin-top: -12px;
278
+ }
279
+ .tool-call summary {
280
+ cursor: pointer;
281
+ font-weight: 500;
282
+ color: #4a5a6f;
283
+ user-select: none;
284
+ }
285
+ .tool-call .tool-name {
286
+ color: #00d68f;
287
+ font-weight: 600;
288
+ font-family: "SF Mono", "Fira Code", "Fira Mono", monospace;
289
+ font-size: 12px;
290
+ }
291
+ .tool-call pre {
292
+ margin-top: 8px;
293
+ background: #1a1a2e;
294
+ color: #e0e6ed;
295
+ padding: 10px 12px;
296
+ border-radius: 6px;
297
+ font-size: 12px;
298
+ overflow-x: auto;
299
+ font-family: "SF Mono", "Fira Code", monospace;
300
+ }
301
+ .input-area {
302
+ border-top: 1px solid #e8ecf1;
303
+ padding: 16px 24px;
304
+ background: #fff;
305
+ }
306
+ .input-area form {
307
+ display: flex;
308
+ gap: 8px;
309
+ max-width: 800px;
310
+ margin: 0 auto;
311
+ }
312
+ .input-area input[type="text"] {
313
+ flex: 1;
314
+ padding: 12px 18px;
315
+ border: 1px solid #e0e4e8;
316
+ border-radius: 10px;
317
+ font-size: 15px;
318
+ outline: none;
319
+ transition: border 0.15s, box-shadow 0.15s;
320
+ }
321
+ .input-area input[type="text"]:focus {
322
+ border-color: #00d68f;
323
+ box-shadow: 0 0 0 3px rgba(0,214,143,0.12);
324
+ }
325
+ .input-area button {
326
+ background: #1a1a2e;
327
+ color: #fff;
328
+ border: none;
329
+ border-radius: 10px;
330
+ padding: 12px 24px;
331
+ font-size: 15px;
332
+ font-weight: 500;
333
+ cursor: pointer;
334
+ transition: background 0.15s;
335
+ }
336
+ .input-area button:hover { background: #2d2d4a; }
337
+ .input-area button:disabled { opacity: 0.5; cursor: not-allowed; }
338
+ .empty-state {
339
+ flex: 1;
340
+ display: flex;
341
+ flex-direction: column;
342
+ align-items: center;
343
+ justify-content: center;
344
+ gap: 12px;
345
+ color: #8899b4;
346
+ }
347
+ .empty-state .icon { font-size: 48px; opacity: 0.4; }
348
+ .empty-state p { font-size: 16px; }
349
+ .runtime-bar {
350
+ display: flex;
351
+ align-items: center;
352
+ gap: 16px;
353
+ padding: 6px 24px;
354
+ background: #f0f2f5;
355
+ border-top: 1px solid #e0e4e8;
356
+ font-size: 11px;
357
+ color: #8899b4;
358
+ font-family: "SF Mono", "Fira Code", monospace;
359
+ }
360
+ .runtime-bar .dot {
361
+ width: 6px; height: 6px;
362
+ border-radius: 50%;
363
+ background: #00d68f;
364
+ display: inline-block;
365
+ }
366
+ .runtime-bar .dot.idle { background: #8899b4; }
367
+ .runtime-bar span { white-space: nowrap; }
368
+ .streaming-cursor::after {
369
+ content: "▊";
370
+ animation: blink 0.8s step-end infinite;
371
+ color: #1a1a2e;
372
+ margin-left: 2px;
373
+ }
374
+ @keyframes blink {
375
+ 50% { opacity: 0; }
376
+ }
377
+ @media (max-width: 768px) {
378
+ .sidebar { display: none; }
379
+ .messages { padding: 16px; }
380
+ .input-area { padding: 12px 16px; }
381
+ }
382
+ </style>
383
+ </head>
384
+ <body>
385
+ <div class="layout">
386
+ <div class="sidebar">
387
+ <div class="brand">
388
+ <%= image_tag "llm.png", alt: "llm.rb" %>
389
+ <div>
390
+ <h1>llm.rb</h1>
391
+ <span>Agents</span>
392
+ </div>
393
+ </div>
394
+ <%= link_to "+ New Agent", agents_path, method: :post,
395
+ class: "new-agent" %>
396
+ <nav>
397
+ <% if @agents %>
398
+ <% @agents.each do |agent| %>
399
+ <%= link_to agent.title_or_default, agent_path(agent),
400
+ class: "agent-link #{'active' if agent == @agent}" %>
401
+ <% end %>
402
+ <% end %>
403
+ </nav>
404
+ </div>
405
+ <div class="main">
406
+ <%= yield %>
407
+ </div>
408
+ </div>
409
+ </body>
410
+ </html>
@@ -0,0 +1,10 @@
1
+ <div class="message <%= msg.role %>">
2
+ <%= RailsLLM.markdown(msg.content).html_safe %>
3
+ </div>
4
+
5
+ <% if msg.reasoning_content.present? %>
6
+ <details class="reasoning">
7
+ <summary>Reasoning</summary>
8
+ <div class="content"><%= msg.reasoning_content %></div>
9
+ </details>
10
+ <% end %>
@@ -0,0 +1,21 @@
1
+ <div class="empty-state">
2
+ <div class="icon">⚡</div>
3
+ <p>Start a conversation below.</p>
4
+ </div>
5
+
6
+ <div class="input-area">
7
+ <%= form_tag agents_path, method: :post, id: "new-agent-form" do %>
8
+ <%= text_field_tag :prompt, nil,
9
+ placeholder: "Ask anything...",
10
+ autofocus: true,
11
+ autocomplete: "off" %>
12
+ <%= submit_tag "Start Chat", data: { disable_with: "..." } %>
13
+ <% end %>
14
+ </div>
15
+
16
+ <div class="runtime-bar">
17
+ <span class="dot idle"></span>
18
+ <span>llm.rb</span>
19
+ <span>0 tokens</span>
20
+ <span>0 messages</span>
21
+ </div>
@@ -0,0 +1,30 @@
1
+ <div class="messages" id="messages">
2
+ <% if @messages.any? %>
3
+ <% @messages.each do |msg| %>
4
+ <%= render partial: "message", locals: { msg: } %>
5
+ <% end %>
6
+ <% else %>
7
+ <div class="empty-state">
8
+ <div class="icon">⚡</div>
9
+ <p>Start a conversation by typing below.</p>
10
+ </div>
11
+ <% end %>
12
+ </div>
13
+
14
+ <div class="input-area">
15
+ <%= form_tag ask_agent_path(@agent), method: :post, id: "ask-form", data: { turbo: false } do %>
16
+ <%= text_field_tag :prompt, nil,
17
+ placeholder: "Ask anything...",
18
+ autofocus: true,
19
+ required: true,
20
+ autocomplete: "off" %>
21
+ <%= submit_tag "Send", data: { disable_with: "..." } %>
22
+ <% end %>
23
+ </div>
24
+
25
+ <div class="runtime-bar">
26
+ <span class="dot idle"></span>
27
+ <span>llm.rb</span>
28
+ <span><%= @agent.usage.total_tokens || 0 %> tokens</span>
29
+ <span><%= @messages.size %> messages</span>
30
+ </div>
data/config/routes.rb ADDED
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ RailsLLM::Engine.routes.draw do
4
+ resources :agents, only: %i[index show create] do
5
+ member do
6
+ post :ask
7
+ end
8
+ end
9
+
10
+ root to: "agents#index"
11
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/active_record"
5
+
6
+ module RailsLLM
7
+ ##
8
+ # Install generator for rails-llm.rb.
9
+ # Creates the Agent model, migration, initializer, and engine route.
10
+ # @usage rails generate rails_llm:install
11
+ class InstallGenerator < ::Rails::Generators::Base
12
+ include ::ActiveRecord::Generators::Migration
13
+ namespace "rails_llm:install"
14
+ source_root File.expand_path("templates", __dir__)
15
+ desc "Install rails-llm.rb — creates the model, migration, initializer, and engine route."
16
+
17
+ def create_agent_model
18
+ template "agent_model.rb.tt", "app/models/rails_llm/agent.rb"
19
+ end
20
+
21
+ def create_knowledge_tool
22
+ template "knowledge_tool.rb.tt", "app/tools/rails_llm/knowledge_tool.rb"
23
+ end
24
+
25
+ def create_install_migration
26
+ migration_template "migration.rb.tt", "db/migrate/create_rails_llm_agents.rb"
27
+ end
28
+
29
+ def create_initializer
30
+ template "initializer.rb.tt", "config/initializers/rails_llm.rb"
31
+ end
32
+
33
+ def mount_engine
34
+ route %(mount RailsLLM::Engine => "/ai")
35
+ end
36
+
37
+ private
38
+
39
+ def migration_version
40
+ "[#{ActiveRecord::Migration.current_version}]"
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsLLM
4
+ class Agent < ApplicationRecord
5
+ self.table_name = "rails_llm_agents"
6
+
7
+ acts_as_agent provider: :set_provider, context: :set_context
8
+ tools { [RailsLLM::KnowledgeTool] }
9
+ scope :ordered, -> { order(updated_at: :desc) }
10
+
11
+ def title_or_default
12
+ title.presence || "Agent ##{id}"
13
+ end
14
+
15
+ private
16
+
17
+ def set_provider
18
+ LLM.deepseek(key: ENV["DEEPSEEK_API_KEY"])
19
+ end
20
+
21
+ def set_context
22
+ {model: "deepseek-v4-flash"}
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ ##
4
+ # rails-llm.rb configuration.
5
+ #
6
+ # Set your API key via environment variables:
7
+ #
8
+ # DEEPSEEK_API_KEY=...
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+
6
+ module RailsLLM
7
+ ##
8
+ # The {RailsLLM::KnowledgeTool} tool provides the LLM with
9
+ # documentation about rails-llm, llm.rb, and mruby-llm.
10
+ class KnowledgeTool < LLM::Tool
11
+ name "rails-llm-knowledge"
12
+ description "Returns rails-llm, llm.rb or mruby-llm documentation"
13
+ parameter :topic, Enum["rails-llm", "llm.rb", "mruby-llm"], "The knowledge topic"
14
+ required %i[topic]
15
+
16
+ ##
17
+ # Provides project documentation
18
+ # @return [Hash]
19
+ def call(topic:)
20
+ case topic
21
+ when "rails-llm" then {directions:, documentation: fetch(rails_llm_resources)}
22
+ when "llm.rb" then {directions:, documentation: fetch(llmrb_resources)}
23
+ when "mruby-llm" then {directions:, documentation: fetch(mruby_llm_resources)}
24
+ else {error: "unknown topic: #{topic}"}
25
+ end
26
+ end
27
+
28
+ private
29
+
30
+ def fetch(resources)
31
+ resources.each_with_object({}) do |(key, url), docs|
32
+ res = Net::HTTP.get_response(URI.parse(url))
33
+ docs[key] = res.body
34
+ end
35
+ end
36
+
37
+ def rails_llm_resources
38
+ {
39
+ "readme" => "https://raw.githubusercontent.com/llmrb/rails-llm/main/README.md"
40
+ }
41
+ end
42
+
43
+ def llmrb_resources
44
+ {
45
+ "readme" => "https://raw.githubusercontent.com/llmrb/llm.rb/main/README.md",
46
+ "deepdive" => "https://raw.githubusercontent.com/llmrb/llm.rb/main/resources/deepdive.md",
47
+ "changelog" => "https://raw.githubusercontent.com/llmrb/llm.rb/main/CHANGELOG.md"
48
+ }
49
+ end
50
+
51
+ def mruby_llm_resources
52
+ {
53
+ "readme" => "https://raw.githubusercontent.com/llmrb/mruby-llm/main/README.md"
54
+ }
55
+ end
56
+
57
+ def directions
58
+ "Reference links from the associated document in your response"
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,9 @@
1
+ class CreateRailsLLMAgents < ActiveRecord::Migration<%= migration_version %>
2
+ def change
3
+ create_table :rails_llm_agents do |t|
4
+ t.string :title, default: "New Agent"
5
+ t.text :data
6
+ t.timestamps
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+
5
+ module RailsLLM
6
+ ##
7
+ # Model generator for rails-llm.
8
+ # Creates an Agent model with acts_as_agent.
9
+ # @usage rails generate rails_llm:model
10
+ class ModelGenerator < ::Rails::Generators::Base
11
+ namespace "rails_llm:model"
12
+ source_root File.expand_path("templates", __dir__)
13
+ desc "Generate an Agent model with acts_as_agent."
14
+
15
+ def create_agent_model
16
+ template "agent_model.rb.tt", "app/models/rails_llm/agent.rb"
17
+ end
18
+
19
+ def show_instructions
20
+ say ""
21
+ say "Done! Next steps:", :green
22
+ say " 1. Run `rails db:migrate`", :bold
23
+ say " 2. Visit http://localhost:3000/ai/agents", :bold
24
+ say " 3. Set DEEPSEEK_API_KEY in your environment", :bold
25
+ say ""
26
+ end
27
+ end
28
+ end