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 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
@@ -0,0 +1,4 @@
1
+ source "https://rubygems.org"
2
+
3
+ gemspec
4
+ gem "llm.rb", github: "llmrb/llm.rb"
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