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.
- checksums.yaml +7 -0
- data/Gemfile +4 -0
- data/LICENSE +13 -0
- data/README.md +127 -0
- data/app/assets/images/llm.png +0 -0
- data/app/assets/javascripts/rails_llm/application.js +188 -0
- data/app/controllers/rails_llm/agents_controller.rb +63 -0
- data/app/views/layouts/rails_llm/application.html.erb +410 -0
- data/app/views/rails_llm/agents/_message.html.erb +10 -0
- data/app/views/rails_llm/agents/index.html.erb +21 -0
- data/app/views/rails_llm/agents/show.html.erb +30 -0
- data/config/routes.rb +11 -0
- data/lib/generators/rails_llm/install_generator.rb +43 -0
- data/lib/generators/rails_llm/templates/agent_model.rb.tt +25 -0
- data/lib/generators/rails_llm/templates/initializer.rb.tt +8 -0
- data/lib/generators/rails_llm/templates/knowledge_tool.rb.tt +61 -0
- data/lib/generators/rails_llm/templates/migration.rb.tt +9 -0
- data/lib/generators/rails_llm/ui_generator.rb +28 -0
- data/lib/rails-llm.rb +47 -0
- data/lib/rails_llm/engine.rb +27 -0
- data/lib/rails_llm/stream.rb +69 -0
- data/lib/rails_llm/version.rb +5 -0
- data/lib/rails_llm.rb +3 -0
- data/rails-llm.gemspec +47 -0
- metadata +183 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 39eedacae9a3c2f4d9a66b014aa94311596fff997382d8bfc82ef078e181abd0
|
|
4
|
+
data.tar.gz: 0dee69794bf8e1100ea3a708844e1563b366249f5d156f46a678abdb50a54124
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 93d1d518fdb7fdb1ba226db963bf0c5bb818e35cdfff114e8a08312ccbf618e9f2e27bf5a29c16fb7a51114d25f75d9681e3b395d86819853d5165f1acaa3f2d
|
|
7
|
+
data.tar.gz: c4f18a1b658f776822558e135822f837f19a9977d6ac7a5e5e6db535dcf34cecc4952febc62222a04ad57019bd8335b38e733edf5771c3793d160fc99638b991
|
data/Gemfile
ADDED
data/LICENSE
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
Copyright (C) 2026
|
|
2
|
+
0x1eef <0x1eef@hardenedbsd.org>
|
|
3
|
+
|
|
4
|
+
Permission to use, copy, modify, and/or distribute this software for any
|
|
5
|
+
purpose with or without fee is hereby granted.
|
|
6
|
+
|
|
7
|
+
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
|
8
|
+
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
|
9
|
+
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
|
|
10
|
+
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
|
11
|
+
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
|
12
|
+
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
|
|
13
|
+
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<a href="https://github.com/llmrb/rails-llm">
|
|
3
|
+
<img src="https://github.com/llmrb/llm.rb/raw/main/llm.png" width="200"
|
|
4
|
+
height="200" border="0" alt="rails-llm">
|
|
5
|
+
</a>
|
|
6
|
+
</p>
|
|
7
|
+
|
|
8
|
+
<p align="center">
|
|
9
|
+
<a href="https://github.com/llmrb/rails-llm">
|
|
10
|
+
<img src="https://img.shields.io/badge/version-0.1.0-green.svg"
|
|
11
|
+
alt="Version">
|
|
12
|
+
</a>
|
|
13
|
+
<a href="LICENSE">
|
|
14
|
+
<img src="https://img.shields.io/badge/License-0BSD-orange.svg"
|
|
15
|
+
alt="License">
|
|
16
|
+
</a>
|
|
17
|
+
<a href="https://github.com/llmrb/llm.rb">
|
|
18
|
+
<img src="https://img.shields.io/badge/powered%20by-llm.rb-blue.svg"
|
|
19
|
+
alt="Powered by llm.rb">
|
|
20
|
+
</a>
|
|
21
|
+
</p>
|
|
22
|
+
|
|
23
|
+
## About
|
|
24
|
+
|
|
25
|
+
This project integrates the [llm.rb](https://github.com/llmrb/llm.rb#readme)
|
|
26
|
+
runtime and its features into Rails.
|
|
27
|
+
|
|
28
|
+
The project extends the builtin ActiveRecord support available to the
|
|
29
|
+
[llm.rb](https://github.com/llmrb/llm.rb#readme)
|
|
30
|
+
runtime with a Rails integration that includes generators for getting
|
|
31
|
+
set up quickly, and an engine for a stream-capable chat interface
|
|
32
|
+
that can be extended with your own tools.
|
|
33
|
+
|
|
34
|
+
The [llm.rb](https://github.com/llmrb/llm.rb#readme) runtime runs on Ruby's
|
|
35
|
+
standard library by default. loads optional pieces only when needed, and
|
|
36
|
+
offers a single runtime for providers, agents, tools, skills, MCP, A2A (Agent2Agent),
|
|
37
|
+
RAG (vector stores & embeddings), streaming, files, and persisted state.
|
|
38
|
+
|
|
39
|
+
## Quick start
|
|
40
|
+
|
|
41
|
+
**1. Add to Gemfile**
|
|
42
|
+
|
|
43
|
+
Add `rails-llm`:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
bundle add rails-llm
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
**2. Run generator**
|
|
50
|
+
|
|
51
|
+
Generate the model, migration, and routes:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
rails generate rails_llm:install
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
**3. Run migrations**
|
|
58
|
+
|
|
59
|
+
Migrate the database:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
rails db:migrate
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
**4. Configure your API key**
|
|
66
|
+
|
|
67
|
+
Set your API key. If you want to use a different provider,
|
|
68
|
+
edit `set_provider` in `app/models/rails_llm/agent.rb`.
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
export DEEPSEEK_API_KEY=...
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
**5. Profit**
|
|
75
|
+
|
|
76
|
+
Open your browser:
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
open http://localhost:3000/ai/agents
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Example
|
|
83
|
+
|
|
84
|
+
#### acts_as_agent
|
|
85
|
+
|
|
86
|
+
```ruby
|
|
87
|
+
class Agent < ApplicationRecord
|
|
88
|
+
acts_as_agent provider: :set_provider, context: :set_context
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
def set_provider
|
|
93
|
+
LLM.deepseek(key: ENV["DEEPSEEK_API_KEY"])
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def set_context
|
|
97
|
+
{model: "deepseek-v4-flash"}
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
agent = Agent.create!
|
|
102
|
+
agent.ask("What is the capital of France?").content # => "Paris"
|
|
103
|
+
agent.ask("Summarize this", with: "report.pdf").content # with a file
|
|
104
|
+
agent.ask("Tell me a story") { |chunk| print chunk } # streaming
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## Engine
|
|
108
|
+
|
|
109
|
+
#### Generators
|
|
110
|
+
|
|
111
|
+
| Generator | What it creates |
|
|
112
|
+
|---|---|
|
|
113
|
+
| `rails_llm:install` | `RailsLLM::Agent` model, `RailsLLM::KnowledgeTool`, migration (`rails_llm_agents`), initializer, engine routes |
|
|
114
|
+
| `rails_llm:model` | `RailsLLM::Agent` model with `acts_as_agent` |
|
|
115
|
+
|
|
116
|
+
#### Engine routes
|
|
117
|
+
|
|
118
|
+
| Method | Path | Action |
|
|
119
|
+
|---|---|---|
|
|
120
|
+
| GET | `/ai/agents` | List agents |
|
|
121
|
+
| GET | `/ai/agents/:id` | View an agent |
|
|
122
|
+
| POST | `/ai/agents` | Create a new agent |
|
|
123
|
+
| POST | `/ai/agents/:id/ask` | Send a message |)
|
|
124
|
+
|
|
125
|
+
## License
|
|
126
|
+
|
|
127
|
+
[BSD Zero Clause](LICENSE)
|
|
Binary file
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
;(function() {
|
|
2
|
+
const View = (messages) => {
|
|
3
|
+
const append = (role, content) => {
|
|
4
|
+
const node = document.createElement("div")
|
|
5
|
+
node.className = `message ${role}`
|
|
6
|
+
node.textContent = content
|
|
7
|
+
messages.appendChild(node)
|
|
8
|
+
messages.scrollTop = messages.scrollHeight
|
|
9
|
+
return node
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const appendToolCall = (name) => {
|
|
13
|
+
const node = document.createElement("details")
|
|
14
|
+
const summary = document.createElement("summary")
|
|
15
|
+
const label = document.createElement("span")
|
|
16
|
+
node.className = "tool-call live"
|
|
17
|
+
node.open = true
|
|
18
|
+
label.className = "tool-name"
|
|
19
|
+
label.textContent = name
|
|
20
|
+
summary.append("Running ", label)
|
|
21
|
+
node.appendChild(summary)
|
|
22
|
+
messages.appendChild(node)
|
|
23
|
+
messages.scrollTop = messages.scrollHeight
|
|
24
|
+
return node
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return {
|
|
28
|
+
messages,
|
|
29
|
+
appendToolCall,
|
|
30
|
+
appendAssistant() {
|
|
31
|
+
const node = append("assistant", "")
|
|
32
|
+
return node
|
|
33
|
+
},
|
|
34
|
+
appendUser(content) {
|
|
35
|
+
return append("user", content)
|
|
36
|
+
},
|
|
37
|
+
clearEmptyState() {
|
|
38
|
+
const emptyState = messages.querySelector(".empty-state")
|
|
39
|
+
if (emptyState) emptyState.remove()
|
|
40
|
+
},
|
|
41
|
+
focusBottom() {
|
|
42
|
+
messages.scrollTop = messages.scrollHeight
|
|
43
|
+
},
|
|
44
|
+
showCursor(node) {
|
|
45
|
+
node.classList.add("streaming-cursor")
|
|
46
|
+
},
|
|
47
|
+
hideCursor(node) {
|
|
48
|
+
node.classList.remove("streaming-cursor")
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const JSONStream = (response, onEvent) => {
|
|
54
|
+
const reader = response.body.getReader()
|
|
55
|
+
const decoder = new TextDecoder()
|
|
56
|
+
let buffer = ""
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
async read() {
|
|
60
|
+
while (true) {
|
|
61
|
+
const {value, done} = await reader.read()
|
|
62
|
+
buffer += decoder.decode(value || new Uint8Array(), {stream: !done})
|
|
63
|
+
const lines = buffer.split("\n")
|
|
64
|
+
buffer = lines.pop()
|
|
65
|
+
lines.filter(Boolean).forEach((line) => onEvent(JSON.parse(line)))
|
|
66
|
+
if (done) break
|
|
67
|
+
}
|
|
68
|
+
if (buffer.trim()) onEvent(JSON.parse(buffer))
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const Turn = (form, prompt = null) => {
|
|
74
|
+
const view = View(document.getElementById("messages"))
|
|
75
|
+
const input = form.querySelector('input[name="prompt"]')
|
|
76
|
+
const submit = form.querySelector('input[type="submit"], button[type="submit"]')
|
|
77
|
+
const content = (prompt || input.value).trim()
|
|
78
|
+
const state = {
|
|
79
|
+
assistantNode: null,
|
|
80
|
+
done: false
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const ensureAssistantNode = () => {
|
|
84
|
+
state.assistantNode ||= view.appendAssistant()
|
|
85
|
+
view.showCursor(state.assistantNode)
|
|
86
|
+
return state.assistantNode
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const onEvent = (event) => {
|
|
90
|
+
if (event.type == "done") {
|
|
91
|
+
state.done = true
|
|
92
|
+
return
|
|
93
|
+
}
|
|
94
|
+
if (event.type == "content") {
|
|
95
|
+
ensureAssistantNode().innerHTML = event.content || ""
|
|
96
|
+
view.focusBottom()
|
|
97
|
+
return
|
|
98
|
+
}
|
|
99
|
+
if (event.type == "tool_call") {
|
|
100
|
+
view.appendToolCall(event.name || "tool")
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
async submit() {
|
|
106
|
+
if (!content) return
|
|
107
|
+
const formData = new FormData(form)
|
|
108
|
+
view.clearEmptyState()
|
|
109
|
+
formData.set("prompt", content)
|
|
110
|
+
view.appendUser(content)
|
|
111
|
+
input.value = ""
|
|
112
|
+
input.disabled = true
|
|
113
|
+
submit.disabled = true
|
|
114
|
+
try {
|
|
115
|
+
const response = await fetch(form.action, {
|
|
116
|
+
method: form.method || "POST",
|
|
117
|
+
body: formData,
|
|
118
|
+
headers: {
|
|
119
|
+
"Accept": "application/x-ndjson",
|
|
120
|
+
"X-CSRF-Token": document.querySelector('meta[name="csrf-token"]').content
|
|
121
|
+
}
|
|
122
|
+
})
|
|
123
|
+
if (!response.ok || !response.body) throw new Error(`Request failed: ${response.status}`)
|
|
124
|
+
await JSONStream(response, onEvent).read()
|
|
125
|
+
} catch (error) {
|
|
126
|
+
ensureAssistantNode().textContent = "Streaming failed."
|
|
127
|
+
console.error(error)
|
|
128
|
+
} finally {
|
|
129
|
+
if (state.assistantNode) view.hideCursor(state.assistantNode)
|
|
130
|
+
input.disabled = false
|
|
131
|
+
submit.disabled = false
|
|
132
|
+
input.focus()
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const App = () => {
|
|
139
|
+
const onCreate = async (event) => {
|
|
140
|
+
event.preventDefault()
|
|
141
|
+
const form = event.currentTarget
|
|
142
|
+
const input = form.querySelector('input[name="prompt"]')
|
|
143
|
+
const prompt = input.value.trim()
|
|
144
|
+
const response = await fetch(form.action, {
|
|
145
|
+
method: form.method || "POST",
|
|
146
|
+
body: new FormData(form),
|
|
147
|
+
headers: {
|
|
148
|
+
"Accept": "application/json",
|
|
149
|
+
"X-CSRF-Token": document.querySelector('meta[name="csrf-token"]').content
|
|
150
|
+
}
|
|
151
|
+
})
|
|
152
|
+
if (!response.ok) return
|
|
153
|
+
const data = await response.json()
|
|
154
|
+
const url = new URL(data.location, window.location.origin)
|
|
155
|
+
url.searchParams.set("prompt", prompt)
|
|
156
|
+
window.location.assign(url)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const onAsk = async (event) => {
|
|
160
|
+
event.preventDefault()
|
|
161
|
+
await Turn(event.currentTarget).submit()
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return {
|
|
165
|
+
async onDOMContentLoaded() {
|
|
166
|
+
switch(window.location.pathname) {
|
|
167
|
+
case "/ai/agents": {
|
|
168
|
+
const form = document.getElementById("new-agent-form")
|
|
169
|
+
form.addEventListener("submit", onCreate)
|
|
170
|
+
break
|
|
171
|
+
}
|
|
172
|
+
default: {
|
|
173
|
+
const askForm = document.getElementById("ask-form")
|
|
174
|
+
const prompt = new URLSearchParams(window.location.search).get("prompt")
|
|
175
|
+
askForm.addEventListener("submit", onAsk)
|
|
176
|
+
if (!prompt) return
|
|
177
|
+
window.history.replaceState({}, "", window.location.pathname)
|
|
178
|
+
await Turn(askForm, prompt).submit()
|
|
179
|
+
break
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const app = App()
|
|
187
|
+
document.addEventListener("DOMContentLoaded", app.onDOMContentLoaded)
|
|
188
|
+
})();
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsLLM
|
|
4
|
+
class AgentsController < ::ApplicationController
|
|
5
|
+
include ActionController::Live
|
|
6
|
+
|
|
7
|
+
layout "rails_llm/application"
|
|
8
|
+
before_action :set_agents
|
|
9
|
+
before_action :set_agent, only: %i[show ask]
|
|
10
|
+
|
|
11
|
+
def index
|
|
12
|
+
@agent = Agent.new
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def show
|
|
16
|
+
@messages = messages
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def create
|
|
20
|
+
@agent = Agent.create!(create_params)
|
|
21
|
+
respond_to do |format|
|
|
22
|
+
format.html { redirect_to agent_path(@agent) }
|
|
23
|
+
format.json { render json: {location: agent_path(@agent)} }
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def ask
|
|
28
|
+
prompt = params[:prompt]
|
|
29
|
+
return head(:unprocessable_entity) if prompt.blank?
|
|
30
|
+
response.headers["Content-Type"] = "application/x-ndjson; charset=utf-8"
|
|
31
|
+
response.headers["Cache-Control"] = "no-cache"
|
|
32
|
+
response.headers["X-Accel-Buffering"] = "no"
|
|
33
|
+
stream = Stream.new(response.stream)
|
|
34
|
+
@agent.ask(prompt, stream:)
|
|
35
|
+
stream.finish
|
|
36
|
+
ensure
|
|
37
|
+
response.stream.close
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def set_agent
|
|
43
|
+
@agent = Agent.find(params[:id])
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def set_agents
|
|
47
|
+
@agents = Agent.ordered
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def create_params
|
|
51
|
+
allowed = {}
|
|
52
|
+
prompt = params[:prompt].to_s.strip
|
|
53
|
+
allowed.merge!(title: prompt.tr("\n", " ")[0, 80])
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def messages
|
|
57
|
+
@agent
|
|
58
|
+
.messages
|
|
59
|
+
.select { _1.user? || _1.assistant? }
|
|
60
|
+
.reject { _1.tool_call? || _1.tool_return? }
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|