ask-rails 0.2.5 → 0.3.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 +4 -4
- data/CHANGELOG.md +28 -0
- data/README.md +94 -10
- data/app/controllers/ask/rails/chat_controller.rb +185 -0
- data/app/views/ask/rails/chat/index.html.erb +6 -0
- data/app/views/layouts/ask/rails/application.html.erb +220 -0
- data/config/routes.rb +15 -0
- data/lib/ask/rails/auth.rb +22 -0
- data/lib/ask/rails/engine.rb +1 -1
- data/lib/ask/rails/version.rb +1 -1
- data/lib/ask/rails.rb +6 -1
- metadata +6 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 958cbbe10b9ff728c6b73776250c422f0218a42d44a9f2608b95c1cd67e9d5a6
|
|
4
|
+
data.tar.gz: db0a490be3201f02c9f71115bea770368ed37f9ce9fe409580c341a92d9dc3bc
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1ab77385fc998e0d433d9875ad791ead151f18a085d2a067e69fca310d0eba999167f3482da09db1a38271a0e58e9235a60e958b414abd7740d026ed4d1c7fd7
|
|
7
|
+
data.tar.gz: 31efdd5a7943822f2a0bc4aae038a1834f9933ecce4664928a11993f2441f30cfa0612dda2edc8729c616106549d13f2b9ea9bdb66757077833dc92b4aa121ad
|
data/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,31 @@
|
|
|
1
|
+
## [0.3.0] — 2026-07-21
|
|
2
|
+
|
|
3
|
+
### Added
|
|
4
|
+
|
|
5
|
+
- **Admin chat UI** — Mount `Ask::Rails::Engine` at `/ask` and get a working chat interface with SSE streaming. No generator needed — just mount and go.
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
GET /ask → Chat UI
|
|
9
|
+
POST /ask/sessions → Create new session
|
|
10
|
+
POST /ask/sessions/:id/messages → Send message (SSE streamed)
|
|
11
|
+
GET /ask/sessions/:id/messages → Message history
|
|
12
|
+
GET /ask/sessions/:id/stream → SSE stream for existing session
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
- **`Ask::Rails::ChatController`** — Full controller with actions: `index`, `create`, `message` (SSE streaming), `stream`, `history`, `destroy`. Supports real-time streaming via `Enumerator` + `text/event-stream`.
|
|
16
|
+
|
|
17
|
+
- **Chat layout and view** — A dark-themed admin chat interface shipped in the gem (not generated). Includes sidebar with session list, message history, SSE streaming, and keyboard navigation.
|
|
18
|
+
|
|
19
|
+
- **`Ask::Rails::Auth`** — Configurable authentication hook. Set `Ask::Rails::Auth.check = -> { ... }` to protect the chat behind your existing auth system. Runs in controller context — `current_user`, `redirect_to`, etc. are available.
|
|
20
|
+
|
|
21
|
+
- **Engine routes** — Routes defined in `config/routes.rb` with proper `Ask::Rails::Engine.routes.draw` isolation.
|
|
22
|
+
|
|
23
|
+
### Changed
|
|
24
|
+
|
|
25
|
+
- **README** — Rewritten with clear positioning: ask-rails is for internal admin agents. ask-agent is for external-facing agents. Includes comparison table, quick start guide, and tool reference.
|
|
26
|
+
- **Engine** — `isolate_namespace Ask::Rails` for proper route isolation.
|
|
27
|
+
- **Gemspec** — Now includes `app/` and `config/` directories for engine views, controllers, and routes.
|
|
28
|
+
|
|
1
29
|
## [0.2.5] - 2026-06-25
|
|
2
30
|
|
|
3
31
|
### Changed
|
data/README.md
CHANGED
|
@@ -1,8 +1,22 @@
|
|
|
1
1
|
# ask-rails
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
An admin AI agent for your Rails app. Mount the engine, get a chat interface at `/ask` that can inspect your code, query your database, read logs, and help you debug — all through an authenticated admin UI.
|
|
4
|
+
|
|
5
|
+
## Who is this for?
|
|
6
|
+
|
|
7
|
+
- **Rails developers** who want an AI co-pilot that understands their app's codebase, schema, routes, and logs
|
|
8
|
+
- **Internal/Admin use only** — the agent has direct access to your database, file system, and shell. Not for external/customer-facing use.
|
|
9
|
+
|
|
10
|
+
For building customer-facing AI agents, use `ask-agent` directly with your own tools and UI.
|
|
11
|
+
|
|
12
|
+
## What it gives you
|
|
13
|
+
|
|
14
|
+
- **7 Rails-aware tools**: `ReadFile`, `QueryDatabase`, `ReadRoutes`, `ReadModel`, `ReadLog`, `RunCommand`, `SearchCodebase`
|
|
15
|
+
- **Admin chat UI**: Mount the engine, get a working chat at `/ask` with SSE streaming
|
|
16
|
+
- **Auth integration**: Protect `/ask` behind your existing Devise/authentication
|
|
17
|
+
- **AR persistence**: Agent sessions survive server restarts
|
|
18
|
+
- **Service discovery**: Auto-detects installed ask-* service gems
|
|
19
|
+
- **Skills**: Built-in guides for Rails debugging, deployment, and database performance
|
|
6
20
|
|
|
7
21
|
## Installation
|
|
8
22
|
|
|
@@ -11,24 +25,94 @@ bundle add ask-rails
|
|
|
11
25
|
rails generate ask_rails:install
|
|
12
26
|
```
|
|
13
27
|
|
|
28
|
+
## Quick Start
|
|
29
|
+
|
|
30
|
+
Add the engine mount and auth protection to `config/routes.rb`:
|
|
31
|
+
|
|
32
|
+
```ruby
|
|
33
|
+
# config/routes.rb
|
|
34
|
+
Rails.application.routes.draw do
|
|
35
|
+
# ... your routes ...
|
|
36
|
+
|
|
37
|
+
authenticate :user, ->(u) { u.admin? } do
|
|
38
|
+
mount Ask::Rails::Engine, at: "/ask"
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Then visit `/ask` in your browser.
|
|
44
|
+
|
|
14
45
|
## Usage
|
|
15
46
|
|
|
47
|
+
### Configuration
|
|
48
|
+
|
|
16
49
|
```ruby
|
|
17
|
-
#
|
|
50
|
+
# config/initializers/ask_rails.rb
|
|
51
|
+
Ask::Rails.configure do |c|
|
|
52
|
+
c.default_model = "claude-sonnet-4"
|
|
53
|
+
c.max_turns = 50
|
|
54
|
+
end
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### Programmatic Access
|
|
58
|
+
|
|
59
|
+
```ruby
|
|
60
|
+
# From any controller, view, or job
|
|
18
61
|
session = Ask::Rails.agent_session
|
|
19
62
|
session.run("Find all open issues labeled 'bug' in our repo")
|
|
20
63
|
```
|
|
21
64
|
|
|
22
|
-
|
|
23
|
-
context (auth info, quick-start snippets, error guides) in the system prompt automatically.
|
|
65
|
+
### Route Helpers
|
|
24
66
|
|
|
25
|
-
|
|
67
|
+
```ruby
|
|
68
|
+
ask_rails.root_path # => /ask
|
|
69
|
+
ask_rails.sessions_path # => /ask/sessions
|
|
70
|
+
ask_rails.session_messages_path(session_id) # => /ask/sessions/:id/messages
|
|
71
|
+
```
|
|
26
72
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
73
|
+
### Auth
|
|
74
|
+
|
|
75
|
+
By default, the admin chat is unprotected. Add auth in your routes (as shown above) or set a custom check:
|
|
76
|
+
|
|
77
|
+
```ruby
|
|
78
|
+
# config/initializers/ask_rails.rb
|
|
79
|
+
Ask::Rails::Auth.check = -> {
|
|
80
|
+
redirect_to main_app.login_path unless current_user&.admin?
|
|
81
|
+
}
|
|
30
82
|
```
|
|
31
83
|
|
|
84
|
+
## Tools
|
|
85
|
+
|
|
86
|
+
| Tool | What it does |
|
|
87
|
+
|---|---|
|
|
88
|
+
| `ReadFile` | Read any file (relative to `Rails.root`) |
|
|
89
|
+
| `QueryDatabase` | Run read-only SQL (rejects non-SELECT in production) |
|
|
90
|
+
| `ReadModel` | Inspect AR model schema, associations, validations |
|
|
91
|
+
| `ReadRoutes` | View `config/routes.rb` |
|
|
92
|
+
| `ReadLog` | Read log files with level/search filtering |
|
|
93
|
+
| `RunCommand` | Run shell commands in the app root |
|
|
94
|
+
| `SearchCodebase` | Grep the codebase for patterns |
|
|
95
|
+
|
|
96
|
+
## Engine Routes
|
|
97
|
+
|
|
98
|
+
```
|
|
99
|
+
GET /ask → Chat UI
|
|
100
|
+
POST /ask/sessions → Create new session
|
|
101
|
+
POST /ask/sessions/:id/messages → Send message (SSE streamed response)
|
|
102
|
+
GET /ask/sessions/:id/messages → Get message history
|
|
103
|
+
GET /ask/sessions/:id/stream → SSE stream for existing session
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## Compared to ask-agent
|
|
107
|
+
|
|
108
|
+
| `ask-agent` | `ask-rails` |
|
|
109
|
+
|---|---|
|
|
110
|
+
| Build external-facing agents | Build an internal admin co-pilot |
|
|
111
|
+
| Bring your own tools | Ships Rails-specific tools |
|
|
112
|
+
| Bring your own UI | Ships an admin chat UI |
|
|
113
|
+
| Any Ruby app | Rails apps only |
|
|
114
|
+
| General purpose | Development, debugging, ops |
|
|
115
|
+
|
|
32
116
|
## License
|
|
33
117
|
|
|
34
118
|
MIT
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Ask
|
|
6
|
+
module Rails
|
|
7
|
+
class ChatController < ::ActionController::Base
|
|
8
|
+
layout "ask/rails/application"
|
|
9
|
+
|
|
10
|
+
before_action :authenticate!, unless: -> { Ask::Rails::Auth.check.nil? }
|
|
11
|
+
before_action :set_session, only: [:message, :stream, :history, :show]
|
|
12
|
+
|
|
13
|
+
# GET /ask — main chat interface
|
|
14
|
+
def index
|
|
15
|
+
@sessions = load_sessions
|
|
16
|
+
@active_session = @sessions.first
|
|
17
|
+
render :index
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# POST /ask/sessions — create a new session
|
|
21
|
+
def create
|
|
22
|
+
session_id = SecureRandom.uuid
|
|
23
|
+
|
|
24
|
+
agent = build_agent_session
|
|
25
|
+
agent.run("Hello") # warm up
|
|
26
|
+
agent.save
|
|
27
|
+
|
|
28
|
+
# Store minimal session metadata
|
|
29
|
+
store_session_metadata(session_id, agent)
|
|
30
|
+
|
|
31
|
+
redirect_to ask_rails.root_path
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# GET /ask/sessions/:id — show a specific session
|
|
35
|
+
def show
|
|
36
|
+
@messages = load_session_messages(@session_id)
|
|
37
|
+
render json: { id: @session_id, messages: @messages }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# GET /ask/sessions — list all sessions
|
|
41
|
+
def index_sessions
|
|
42
|
+
@sessions = load_sessions
|
|
43
|
+
render json: @sessions
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# POST /ask/sessions/:session_id/messages — send a message, get SSE stream
|
|
47
|
+
def message
|
|
48
|
+
prompt = params[:message].to_s.strip
|
|
49
|
+
return head :bad_request if prompt.empty?
|
|
50
|
+
|
|
51
|
+
response.headers["Content-Type"] = "text/event-stream"
|
|
52
|
+
response.headers["Cache-Control"] = "no-cache"
|
|
53
|
+
response.headers["X-Accel-Buffering"] = "no"
|
|
54
|
+
|
|
55
|
+
# Disable buffering for streaming
|
|
56
|
+
response.headers["Last-Modified"] = Time.now.httpdate
|
|
57
|
+
|
|
58
|
+
self.response_body = Enumerator.new do |yielder|
|
|
59
|
+
agent = resume_or_create_session(@session_id)
|
|
60
|
+
|
|
61
|
+
yielder << "data: #{JSON.generate(type: 'start', session_id: @session_id)}\n\n"
|
|
62
|
+
|
|
63
|
+
agent.run(prompt) do |chunk|
|
|
64
|
+
if chunk.content&.length&.> 0
|
|
65
|
+
yielder << "data: #{JSON.generate(type: 'delta', content: chunk.content)}\n\n"
|
|
66
|
+
end
|
|
67
|
+
if chunk.thinking&.length&.> 0
|
|
68
|
+
yielder << "data: #{JSON.generate(type: 'thinking', content: chunk.thinking)}\n\n"
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
agent.save
|
|
73
|
+
@session = agent
|
|
74
|
+
|
|
75
|
+
yielder << "data: #{JSON.generate(type: 'done', session_id: @session_id)}\n\n"
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# GET /ask/sessions/:session_id/stream — SSE stream for an existing session
|
|
80
|
+
def stream
|
|
81
|
+
response.headers["Content-Type"] = "text/event-stream"
|
|
82
|
+
response.headers["Cache-Control"] = "no-cache"
|
|
83
|
+
response.headers["X-Accel-Buffering"] = "no"
|
|
84
|
+
|
|
85
|
+
self.response_body = Enumerator.new do |yielder|
|
|
86
|
+
messages = load_session_messages(@session_id)
|
|
87
|
+
yielder << "data: #{JSON.generate(type: 'history', messages: messages)}\n\n"
|
|
88
|
+
|
|
89
|
+
# Keep connection open briefly for potential updates
|
|
90
|
+
sleep 30
|
|
91
|
+
yielder << "data: #{JSON.generate(type: 'keepalive')}\n\n"
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# GET /ask/sessions/:session_id/messages — get message history as JSON
|
|
96
|
+
def history
|
|
97
|
+
messages = load_session_messages(@session_id)
|
|
98
|
+
render json: messages
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# DELETE /ask/sessions — destroy all sessions
|
|
102
|
+
def destroy
|
|
103
|
+
persistence = Ask::Rails.configuration.persistence_adapter
|
|
104
|
+
if persistence
|
|
105
|
+
persistence.list.each { |id| persistence.delete(id) }
|
|
106
|
+
end
|
|
107
|
+
redirect_to ask_rails.root_path
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
private
|
|
111
|
+
|
|
112
|
+
def authenticate!
|
|
113
|
+
instance_eval(&Ask::Rails::Auth.check) if Ask::Rails::Auth.check
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def set_session
|
|
117
|
+
@session_id = params[:session_id] || params[:id]
|
|
118
|
+
head :not_found unless @session_id
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def build_agent_session(**extra)
|
|
122
|
+
Ask::Rails.agent_session(**extra)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def resume_or_create_session(session_id)
|
|
126
|
+
persistence = Ask::Rails.configuration.persistence_adapter
|
|
127
|
+
|
|
128
|
+
if persistence
|
|
129
|
+
data = persistence.load(session_id)
|
|
130
|
+
if data
|
|
131
|
+
session = Ask::Agent::Session.load(session_id, adapter: persistence)
|
|
132
|
+
return session if session
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
Ask::Rails.agent_session(persistence: persistence).tap do |s|
|
|
137
|
+
s.instance_variable_set(:@id, session_id)
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def load_sessions
|
|
142
|
+
persistence = Ask::Rails.configuration.persistence_adapter
|
|
143
|
+
return [] unless persistence
|
|
144
|
+
|
|
145
|
+
persistence.list.map do |id|
|
|
146
|
+
data = persistence.load(id) || {}
|
|
147
|
+
{ id: id, created_at: data.dig(:metadata, :created_at), message_count: (data[:messages] || []).length }
|
|
148
|
+
end.sort_by { |s| s[:created_at] || "" }.reverse
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def load_session_messages(session_id)
|
|
152
|
+
persistence = Ask::Rails.configuration.persistence_adapter
|
|
153
|
+
return [] unless persistence
|
|
154
|
+
|
|
155
|
+
data = persistence.load(session_id)
|
|
156
|
+
return [] unless data
|
|
157
|
+
|
|
158
|
+
(data[:messages] || []).map { |m|
|
|
159
|
+
{ role: m[:role], content: m[:content].to_s.truncate(200) }
|
|
160
|
+
}
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def store_session_metadata(session_id, agent)
|
|
164
|
+
persistence = Ask::Rails.configuration.persistence_adapter
|
|
165
|
+
return unless persistence
|
|
166
|
+
|
|
167
|
+
persistence.save(session_id, {
|
|
168
|
+
id: session_id,
|
|
169
|
+
messages: agent.messages.map { |m|
|
|
170
|
+
{ role: m.role, content: m.content.to_s }
|
|
171
|
+
},
|
|
172
|
+
metadata: {
|
|
173
|
+
model: ask_rails_model,
|
|
174
|
+
created_at: Time.now.iso8601,
|
|
175
|
+
updated_at: Time.now.iso8601
|
|
176
|
+
}
|
|
177
|
+
})
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def ask_rails_model
|
|
181
|
+
Ask::Rails.configuration.default_model
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
<!-- Chat interface rendered by the layout -->
|
|
2
|
+
<!-- The layout handles the full UI. This view is minimal -->
|
|
3
|
+
<!-- since the layout and JavaScript drive the interface. -->
|
|
4
|
+
|
|
5
|
+
<%= tag.div id: "ask-rails-data",
|
|
6
|
+
data: { sessions: @sessions.to_json, active_session_id: @active_session&.dig(:id) } %>
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<title>Ask — Rails Agent</title>
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<style>
|
|
7
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
8
|
+
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #0d0d0f; color: #e1e1e6; height: 100vh; display: flex; }
|
|
9
|
+
a { color: #8b8bf0; text-decoration: none; }
|
|
10
|
+
a:hover { color: #a0a0ff; }
|
|
11
|
+
|
|
12
|
+
.sidebar { width: 280px; background: #121218; border-right: 1px solid #1e1e28; display: flex; flex-direction: column; flex-shrink: 0; }
|
|
13
|
+
.sidebar-header { padding: 16px; border-bottom: 1px solid #1e1e28; }
|
|
14
|
+
.sidebar-header h1 { font-size: 16px; font-weight: 600; color: #c0c0d0; }
|
|
15
|
+
.sidebar-header p { font-size: 11px; color: #606070; margin-top: 2px; }
|
|
16
|
+
.sidebar-actions { padding: 12px 16px; }
|
|
17
|
+
.sidebar-actions a { display: block; padding: 8px 12px; background: #1a1a24; border-radius: 6px; font-size: 13px; text-align: center; color: #c0c0d0; }
|
|
18
|
+
.sidebar-actions a:hover { background: #222230; }
|
|
19
|
+
.session-list { flex: 1; overflow-y: auto; padding: 8px; }
|
|
20
|
+
.session-item { padding: 10px 12px; border-radius: 6px; font-size: 13px; cursor: pointer; color: #9090a0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
21
|
+
.session-item:hover { background: #1a1a24; color: #c0c0d0; }
|
|
22
|
+
.session-item.active { background: #1e1e30; color: #d0d0e0; }
|
|
23
|
+
.session-item .time { font-size: 11px; color: #505060; margin-top: 2px; }
|
|
24
|
+
.sidebar-footer { padding: 12px 16px; border-top: 1px solid #1e1e28; font-size: 11px; color: #404050; }
|
|
25
|
+
|
|
26
|
+
.main { flex: 1; display: flex; flex-direction: column; min-width: 0; }
|
|
27
|
+
.chat-header { padding: 14px 20px; border-bottom: 1px solid #1e1e28; font-size: 13px; color: #808090; }
|
|
28
|
+
.messages { flex: 1; overflow-y: auto; padding: 20px; display: flex; flex-direction: column; gap: 16px; }
|
|
29
|
+
.message { max-width: 80%; padding: 12px 16px; border-radius: 10px; font-size: 14px; line-height: 1.5; }
|
|
30
|
+
.message.user { background: #1e1e30; align-self: flex-end; border-bottom-right-radius: 4px; }
|
|
31
|
+
.message.assistant { background: #181825; align-self: flex-start; border-bottom-left-radius: 4px; }
|
|
32
|
+
.message.thinking { background: #151520; align-self: flex-start; font-style: italic; color: #707080; font-size: 13px; border-left: 2px solid #404068; }
|
|
33
|
+
.message.system { background: #101018; align-self: center; color: #505060; font-size: 12px; text-align: center; }
|
|
34
|
+
.message .label { font-size: 11px; color: #606070; margin-bottom: 4px; }
|
|
35
|
+
.empty-state { flex: 1; display: flex; align-items: center; justify-content: center; color: #404050; font-size: 14px; text-align: center; padding: 40px; }
|
|
36
|
+
.empty-state p { max-width: 300px; }
|
|
37
|
+
|
|
38
|
+
.input-area { padding: 16px 20px; border-top: 1px solid #1e1e28; }
|
|
39
|
+
.input-row { display: flex; gap: 8px; }
|
|
40
|
+
.input-row input { flex: 1; padding: 10px 14px; background: #181825; border: 1px solid #252535; border-radius: 8px; color: #e1e1e6; font-size: 14px; outline: none; }
|
|
41
|
+
.input-row input:focus { border-color: #404068; }
|
|
42
|
+
.input-row button { padding: 10px 20px; background: #404068; border: none; border-radius: 8px; color: #e1e1e6; font-size: 14px; cursor: pointer; }
|
|
43
|
+
.input-row button:hover { background: #505080; }
|
|
44
|
+
.input-row button:disabled { opacity: 0.5; cursor: default; }
|
|
45
|
+
</style>
|
|
46
|
+
</head>
|
|
47
|
+
<body>
|
|
48
|
+
<div class="sidebar">
|
|
49
|
+
<div class="sidebar-header">
|
|
50
|
+
<h1>Ask</h1>
|
|
51
|
+
<p>Rails Agent</p>
|
|
52
|
+
</div>
|
|
53
|
+
<div class="sidebar-actions">
|
|
54
|
+
<%= button_to "New Session", ask_rails.sessions_path, method: :post, style: "display: none", id: "new-session-form" %>
|
|
55
|
+
<a href="#" onclick="document.getElementById('new-session-form').click(); return false;">+ New Session</a>
|
|
56
|
+
</div>
|
|
57
|
+
<div class="session-list" id="session-list">
|
|
58
|
+
<% @sessions.each do |s| %>
|
|
59
|
+
<div class="session-item<%= s[:id] == (@active_session&.dig(:id) || params[:session_id]) ? ' active' : '' %>" onclick="switchSession('<%= s[:id] %>')">
|
|
60
|
+
Session <%= s[:id].to_s[0..7] %>
|
|
61
|
+
<div class="time"><%= s[:message_count] %> messages</div>
|
|
62
|
+
</div>
|
|
63
|
+
<% end %>
|
|
64
|
+
</div>
|
|
65
|
+
<div class="sidebar-footer">
|
|
66
|
+
Model: <%= Ask::Rails.configuration.default_model %>
|
|
67
|
+
</div>
|
|
68
|
+
</div>
|
|
69
|
+
|
|
70
|
+
<div class="main">
|
|
71
|
+
<div class="chat-header" id="chat-header">
|
|
72
|
+
<% if @active_session %>
|
|
73
|
+
Session <%= @active_session[:id].to_s[0..7] %>
|
|
74
|
+
<% else %>
|
|
75
|
+
Start a new session
|
|
76
|
+
<% end %>
|
|
77
|
+
</div>
|
|
78
|
+
<div class="messages" id="messages">
|
|
79
|
+
<div class="empty-state" id="empty-state">
|
|
80
|
+
<p>Send a message to start talking to the Rails agent.</p>
|
|
81
|
+
</div>
|
|
82
|
+
</div>
|
|
83
|
+
<div class="input-area">
|
|
84
|
+
<div class="input-row">
|
|
85
|
+
<input type="text" id="message-input" placeholder="Ask about your app..." autofocus>
|
|
86
|
+
<button id="send-button" onclick="sendMessage()">Send</button>
|
|
87
|
+
</div>
|
|
88
|
+
</div>
|
|
89
|
+
</div>
|
|
90
|
+
|
|
91
|
+
<script>
|
|
92
|
+
let currentSessionId = '<%= @active_session&.dig(:id) || "" %>';
|
|
93
|
+
let isStreaming = false;
|
|
94
|
+
|
|
95
|
+
function addMessage(role, content) {
|
|
96
|
+
const el = document.getElementById('empty-state');
|
|
97
|
+
if (el) el.remove();
|
|
98
|
+
|
|
99
|
+
const msg = document.createElement('div');
|
|
100
|
+
msg.className = 'message ' + role;
|
|
101
|
+
if (role === 'thinking') {
|
|
102
|
+
msg.innerHTML = '<div class="label">Thinking...</div>' + escapeHtml(content);
|
|
103
|
+
} else {
|
|
104
|
+
msg.innerHTML = (role === 'user' ? '' : '<div class="label">Assistant</div>') + escapeHtml(content);
|
|
105
|
+
}
|
|
106
|
+
document.getElementById('messages').appendChild(msg);
|
|
107
|
+
msg.scrollIntoView({ behavior: 'smooth' });
|
|
108
|
+
return msg;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function updateLastMessage(role, content) {
|
|
112
|
+
const msgs = document.getElementById('messages').querySelectorAll('.message.' + role);
|
|
113
|
+
if (msgs.length === 0) return addMessage(role, content);
|
|
114
|
+
const last = msgs[msgs.length - 1];
|
|
115
|
+
const textEl = last.querySelector('div:last-child');
|
|
116
|
+
if (textEl) textEl.textContent += content;
|
|
117
|
+
else last.innerHTML += escapeHtml(content);
|
|
118
|
+
last.scrollIntoView({ behavior: 'smooth' });
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function escapeHtml(text) {
|
|
122
|
+
const d = document.createElement('div');
|
|
123
|
+
d.textContent = text;
|
|
124
|
+
return d.innerHTML;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function sendMessage() {
|
|
128
|
+
const input = document.getElementById('message-input');
|
|
129
|
+
const prompt = input.value.trim();
|
|
130
|
+
if (!prompt || isStreaming) return;
|
|
131
|
+
|
|
132
|
+
input.value = '';
|
|
133
|
+
isStreaming = true;
|
|
134
|
+
document.getElementById('send-button').disabled = true;
|
|
135
|
+
|
|
136
|
+
addMessage('user', prompt);
|
|
137
|
+
|
|
138
|
+
if (!currentSessionId) {
|
|
139
|
+
// Create session first
|
|
140
|
+
fetch('<%= ask_rails.sessions_path %>', { method: 'POST' })
|
|
141
|
+
.then(r => r.text())
|
|
142
|
+
.then(() => {
|
|
143
|
+
// Reload to get the new session
|
|
144
|
+
window.location.reload();
|
|
145
|
+
});
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const url = '<%= ask_rails.session_message_path("SESSION_ID") %>'.replace('SESSION_ID', currentSessionId);
|
|
150
|
+
|
|
151
|
+
fetch(url, {
|
|
152
|
+
method: 'POST',
|
|
153
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
154
|
+
body: new URLSearchParams({ message: prompt })
|
|
155
|
+
}).then(response => {
|
|
156
|
+
const reader = response.body.getReader();
|
|
157
|
+
const decoder = new TextDecoder();
|
|
158
|
+
let buffer = '';
|
|
159
|
+
|
|
160
|
+
function processChunk() {
|
|
161
|
+
reader.read().then(({ done, value }) => {
|
|
162
|
+
if (done) {
|
|
163
|
+
isStreaming = false;
|
|
164
|
+
document.getElementById('send-button').disabled = false;
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
buffer += decoder.decode(value, { stream: true });
|
|
168
|
+
const lines = buffer.split('\n');
|
|
169
|
+
buffer = lines.pop() || '';
|
|
170
|
+
|
|
171
|
+
lines.forEach(line => {
|
|
172
|
+
if (line.startsWith('data: ')) {
|
|
173
|
+
try {
|
|
174
|
+
const data = JSON.parse(line.slice(6));
|
|
175
|
+
if (data.type === 'delta') {
|
|
176
|
+
updateLastMessage('assistant', data.content);
|
|
177
|
+
} else if (data.type === 'thinking') {
|
|
178
|
+
addMessage('thinking', data.content);
|
|
179
|
+
} else if (data.type === 'done') {
|
|
180
|
+
isStreaming = false;
|
|
181
|
+
document.getElementById('send-button').disabled = false;
|
|
182
|
+
} else if (data.type === 'start') {
|
|
183
|
+
currentSessionId = data.session_id;
|
|
184
|
+
}
|
|
185
|
+
} catch(e) {}
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
processChunk();
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
processChunk();
|
|
192
|
+
}).catch(() => {
|
|
193
|
+
isStreaming = false;
|
|
194
|
+
document.getElementById('send-button').disabled = false;
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
document.getElementById('message-input').addEventListener('keydown', function(e) {
|
|
199
|
+
if (e.key === 'Enter') sendMessage();
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
function switchSession(id) {
|
|
203
|
+
currentSessionId = id;
|
|
204
|
+
document.getElementById('empty-state')?.remove();
|
|
205
|
+
document.getElementById('messages').innerHTML = '';
|
|
206
|
+
document.getElementById('chat-header').textContent = 'Session ' + id.substring(0, 8);
|
|
207
|
+
|
|
208
|
+
fetch('<%= ask_rails.session_history_path("SESSION_ID") %>'.replace('SESSION_ID', id))
|
|
209
|
+
.then(r => r.json())
|
|
210
|
+
.then(messages => {
|
|
211
|
+
messages.forEach(m => {
|
|
212
|
+
if (m.role === 'user' || m.role === 'assistant') {
|
|
213
|
+
addMessage(m.role, m.content);
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
</script>
|
|
219
|
+
</body>
|
|
220
|
+
</html>
|
data/config/routes.rb
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
Ask::Rails::Engine.routes.draw do
|
|
4
|
+
root to: "chat#index"
|
|
5
|
+
|
|
6
|
+
resources :sessions, only: [:index, :create, :show], controller: "chat" do
|
|
7
|
+
collection do
|
|
8
|
+
delete :destroy
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
post "sessions/:session_id/messages" => "chat#message", as: :session_message
|
|
13
|
+
get "sessions/:session_id/stream" => "chat#stream", as: :session_stream
|
|
14
|
+
get "sessions/:session_id/messages" => "chat#history", as: :session_history
|
|
15
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
module Rails
|
|
5
|
+
# Inclusion module for authenticating the admin chat.
|
|
6
|
+
#
|
|
7
|
+
# By default, all ask-rails engine routes are accessible without auth.
|
|
8
|
+
# To protect them, define a +current_user+ method and set the auth
|
|
9
|
+
# check in an initializer:
|
|
10
|
+
#
|
|
11
|
+
# Ask::Rails::Auth.check = -> {
|
|
12
|
+
# redirect_to main_app.login_path unless current_user&.admin?
|
|
13
|
+
# }
|
|
14
|
+
#
|
|
15
|
+
# The proc is evaluated in the controller context, so +redirect_to+,
|
|
16
|
+
# +current_user+, +session+, etc. are all available.
|
|
17
|
+
module Auth
|
|
18
|
+
mattr_accessor :check
|
|
19
|
+
self.check = nil
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
data/lib/ask/rails/engine.rb
CHANGED
data/lib/ask/rails/version.rb
CHANGED
data/lib/ask/rails.rb
CHANGED
|
@@ -67,7 +67,7 @@ end
|
|
|
67
67
|
require_relative "rails/version"
|
|
68
68
|
require_relative "rails/engine"
|
|
69
69
|
require_relative "rails/configuration"
|
|
70
|
-
require_relative "rails/
|
|
70
|
+
require_relative "rails/auth"
|
|
71
71
|
require_relative "rails/persistence"
|
|
72
72
|
require_relative "rails/service_discovery"
|
|
73
73
|
require_relative "rails/tool"
|
|
@@ -78,3 +78,8 @@ require_relative "rails/tools/read_routes"
|
|
|
78
78
|
require_relative "rails/tools/query_database"
|
|
79
79
|
require_relative "rails/tools/read_model"
|
|
80
80
|
require_relative "rails/tools/read_log"
|
|
81
|
+
|
|
82
|
+
# Railtie is loaded only when Rails is fully available
|
|
83
|
+
if defined?(::Rails::Railtie)
|
|
84
|
+
require_relative "rails/railtie"
|
|
85
|
+
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: ask-rails
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Kaka Ruto
|
|
@@ -146,8 +146,13 @@ files:
|
|
|
146
146
|
- CHANGELOG.md
|
|
147
147
|
- LICENSE
|
|
148
148
|
- README.md
|
|
149
|
+
- app/controllers/ask/rails/chat_controller.rb
|
|
150
|
+
- app/views/ask/rails/chat/index.html.erb
|
|
151
|
+
- app/views/layouts/ask/rails/application.html.erb
|
|
152
|
+
- config/routes.rb
|
|
149
153
|
- lib/ask-rails.rb
|
|
150
154
|
- lib/ask/rails.rb
|
|
155
|
+
- lib/ask/rails/auth.rb
|
|
151
156
|
- lib/ask/rails/configuration.rb
|
|
152
157
|
- lib/ask/rails/engine.rb
|
|
153
158
|
- lib/ask/rails/persistence.rb
|