rails_console_ai 0.28.0 → 0.30.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 +45 -0
- data/README.md +48 -0
- data/app/controllers/rails_console_ai/agent_versions_controller.rb +36 -0
- data/app/controllers/rails_console_ai/agents_controller.rb +199 -0
- data/app/controllers/rails_console_ai/application_controller.rb +5 -0
- data/app/controllers/rails_console_ai/memories_controller.rb +159 -0
- data/app/controllers/rails_console_ai/memory_versions_controller.rb +33 -0
- data/app/controllers/rails_console_ai/skill_versions_controller.rb +35 -0
- data/app/controllers/rails_console_ai/skills_controller.rb +200 -0
- data/app/helpers/rails_console_ai/diff_helper.rb +114 -0
- data/app/models/rails_console_ai/agent.rb +175 -0
- data/app/models/rails_console_ai/agent_version.rb +46 -0
- data/app/models/rails_console_ai/memory.rb +98 -0
- data/app/models/rails_console_ai/memory_version.rb +46 -0
- data/app/models/rails_console_ai/session.rb +1 -1
- data/app/models/rails_console_ai/skill.rb +198 -0
- data/app/models/rails_console_ai/skill_version.rb +54 -0
- data/app/views/layouts/rails_console_ai/application.html.erb +78 -1
- data/app/views/rails_console_ai/agent_versions/index.html.erb +28 -0
- data/app/views/rails_console_ai/agent_versions/show.html.erb +25 -0
- data/app/views/rails_console_ai/agents/_form.html.erb +65 -0
- data/app/views/rails_console_ai/agents/diff.html.erb +19 -0
- data/app/views/rails_console_ai/agents/edit.html.erb +7 -0
- data/app/views/rails_console_ai/agents/index.html.erb +80 -0
- data/app/views/rails_console_ai/agents/new.html.erb +24 -0
- data/app/views/rails_console_ai/agents/show.html.erb +108 -0
- data/app/views/rails_console_ai/memories/_form.html.erb +36 -0
- data/app/views/rails_console_ai/memories/diff.html.erb +19 -0
- data/app/views/rails_console_ai/memories/edit.html.erb +7 -0
- data/app/views/rails_console_ai/memories/index.html.erb +67 -0
- data/app/views/rails_console_ai/memories/new.html.erb +23 -0
- data/app/views/rails_console_ai/memories/show.html.erb +65 -0
- data/app/views/rails_console_ai/memory_versions/index.html.erb +26 -0
- data/app/views/rails_console_ai/memory_versions/show.html.erb +21 -0
- data/app/views/rails_console_ai/skill_versions/index.html.erb +28 -0
- data/app/views/rails_console_ai/skill_versions/show.html.erb +23 -0
- data/app/views/rails_console_ai/skills/_form.html.erb +65 -0
- data/app/views/rails_console_ai/skills/diff.html.erb +22 -0
- data/app/views/rails_console_ai/skills/edit.html.erb +7 -0
- data/app/views/rails_console_ai/skills/index.html.erb +79 -0
- data/app/views/rails_console_ai/skills/new.html.erb +25 -0
- data/app/views/rails_console_ai/skills/show.html.erb +94 -0
- data/config/routes.rb +42 -0
- data/lib/rails_console_ai/agent_loader.rb +131 -43
- data/lib/rails_console_ai/agent_runner.rb +158 -0
- data/lib/rails_console_ai/channel/api.rb +139 -0
- data/lib/rails_console_ai/channel/slack.rb +33 -0
- data/lib/rails_console_ai/channel/sub_agent.rb +12 -0
- data/lib/rails_console_ai/conversation_engine.rb +50 -13
- data/lib/rails_console_ai/session_logger.rb +6 -0
- data/lib/rails_console_ai/skill_loader.rb +119 -27
- data/lib/rails_console_ai/slack_bot.rb +8 -0
- data/lib/rails_console_ai/storage/database_storage.rb +201 -0
- data/lib/rails_console_ai/sub_agent.rb +25 -0
- data/lib/rails_console_ai/tools/memory_tools.rb +102 -32
- data/lib/rails_console_ai/tools/registry.rb +99 -8
- data/lib/rails_console_ai/version.rb +1 -1
- data/lib/rails_console_ai.rb +256 -0
- data/lib/tasks/rails_console_ai.rake +7 -0
- metadata +55 -1
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
require 'json'
|
|
2
|
+
|
|
3
|
+
module RailsConsoleAi
|
|
4
|
+
class Skill < ActiveRecord::Base
|
|
5
|
+
self.table_name = 'rails_console_ai_skills'
|
|
6
|
+
|
|
7
|
+
STATUS_PROPOSED = 'proposed'.freeze
|
|
8
|
+
STATUS_APPROVED = 'approved'.freeze
|
|
9
|
+
STATUSES = [STATUS_PROPOSED, STATUS_APPROVED].freeze
|
|
10
|
+
|
|
11
|
+
# Attributes that, if changed, invalidate the current approval and revert
|
|
12
|
+
# the skill back to "proposed". Status / approver columns are excluded so
|
|
13
|
+
# that an explicit approve! call doesn't reset its own approval.
|
|
14
|
+
CONTENT_ATTRIBUTES = %w[name description body tags bypass_guards_for_methods].freeze
|
|
15
|
+
|
|
16
|
+
has_many :versions,
|
|
17
|
+
-> { order(created_at: :desc) },
|
|
18
|
+
class_name: 'RailsConsoleAi::SkillVersion',
|
|
19
|
+
foreign_key: :skill_id,
|
|
20
|
+
dependent: :nullify
|
|
21
|
+
|
|
22
|
+
validates :name, presence: true, uniqueness: { case_sensitive: false }
|
|
23
|
+
validates :status, inclusion: { in: STATUSES }, if: :has_attribute_status?
|
|
24
|
+
|
|
25
|
+
scope :alphabetical, -> { order(Arel.sql('LOWER(name)')) }
|
|
26
|
+
scope :approved, -> { where(status: STATUS_APPROVED) }
|
|
27
|
+
scope :proposed, -> { where(status: STATUS_PROPOSED) }
|
|
28
|
+
|
|
29
|
+
def self.connection
|
|
30
|
+
klass = RailsConsoleAi.configuration.connection_class
|
|
31
|
+
if klass
|
|
32
|
+
klass = Object.const_get(klass) if klass.is_a?(String)
|
|
33
|
+
klass.connection
|
|
34
|
+
else
|
|
35
|
+
super
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Manual JSON accessors keep us off Rails-version-specific `serialize` syntax
|
|
40
|
+
# (positional coder in Rails 5–6, keyword coder in Rails 7+).
|
|
41
|
+
def tags
|
|
42
|
+
decode_json_array(read_attribute(:tags))
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def tags=(value)
|
|
46
|
+
write_attribute(:tags, encode_json_array(value))
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def bypass_guards_for_methods
|
|
50
|
+
decode_json_array(read_attribute(:bypass_guards_for_methods))
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def bypass_guards_for_methods=(value)
|
|
54
|
+
write_attribute(:bypass_guards_for_methods, encode_json_array(value))
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Defensive accessors — if `ai_db_migrate` hasn't been run yet, the status
|
|
58
|
+
# / approval columns may be missing on an older table. Return safe defaults
|
|
59
|
+
# instead of blowing up with NameError.
|
|
60
|
+
def status
|
|
61
|
+
has_attribute_status? ? read_attribute(:status) : STATUS_PROPOSED
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def approved_by
|
|
65
|
+
has_attribute?(:approved_by) ? read_attribute(:approved_by) : nil
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def approved_at
|
|
69
|
+
has_attribute?(:approved_at) ? read_attribute(:approved_at) : nil
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def proposed?; status.to_s == STATUS_PROPOSED; end
|
|
73
|
+
def approved?; status.to_s == STATUS_APPROVED; end
|
|
74
|
+
|
|
75
|
+
def to_hash
|
|
76
|
+
{
|
|
77
|
+
'id' => id,
|
|
78
|
+
'name' => name,
|
|
79
|
+
'description' => description,
|
|
80
|
+
'body' => body,
|
|
81
|
+
'tags' => tags,
|
|
82
|
+
'bypass_guards_for_methods' => bypass_guards_for_methods,
|
|
83
|
+
'status' => status,
|
|
84
|
+
'approved_by' => approved_by,
|
|
85
|
+
'approved_at' => approved_at,
|
|
86
|
+
'use_count' => use_count,
|
|
87
|
+
'last_used_at' => last_used_at,
|
|
88
|
+
'source' => :db,
|
|
89
|
+
'updated_at' => updated_at
|
|
90
|
+
}
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def has_attribute_status?
|
|
94
|
+
has_attribute?(:status)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Atomically bump use_count + last_used_at without firing callbacks /
|
|
98
|
+
# validations / updated_at. Safe to call from concurrent AI tool calls.
|
|
99
|
+
# No-op (returns false) if the table doesn't have the columns yet — that
|
|
100
|
+
# keeps older installs working until they run ai_db_migrate.
|
|
101
|
+
def self.record_use!(id)
|
|
102
|
+
return false unless connection.column_exists?(table_name, :use_count)
|
|
103
|
+
where(id: id).update_all([
|
|
104
|
+
'use_count = COALESCE(use_count, 0) + 1, last_used_at = ?',
|
|
105
|
+
Time.now.utc
|
|
106
|
+
])
|
|
107
|
+
true
|
|
108
|
+
rescue ::ActiveRecord::ActiveRecordError => e
|
|
109
|
+
RailsConsoleAi.logger.warn("RailsConsoleAi::Skill.record_use!(#{id.inspect}) failed: #{e.message}")
|
|
110
|
+
false
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def use_count
|
|
114
|
+
has_attribute?(:use_count) ? (read_attribute(:use_count) || 0) : 0
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def last_used_at
|
|
118
|
+
has_attribute?(:last_used_at) ? read_attribute(:last_used_at) : nil
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def self.decode_json_array(raw)
|
|
122
|
+
return [] if raw.nil? || (raw.respond_to?(:empty?) && raw.empty?)
|
|
123
|
+
return raw if raw.is_a?(Array)
|
|
124
|
+
JSON.parse(raw)
|
|
125
|
+
rescue JSON::ParserError
|
|
126
|
+
[]
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def self.encode_json_array(value)
|
|
130
|
+
JSON.dump(Array(value))
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def decode_json_array(raw)
|
|
134
|
+
self.class.decode_json_array(raw)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def encode_json_array(value)
|
|
138
|
+
self.class.encode_json_array(value)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Assigns attrs, saves, and records one SkillVersion snapshot of the post-save state.
|
|
142
|
+
# Every save produces exactly one version row, so the version log is a complete history
|
|
143
|
+
# including the current state (the most recent version mirrors `self`).
|
|
144
|
+
#
|
|
145
|
+
# If `preserve_approval` is false (the default), any change to a content attribute
|
|
146
|
+
# reverts the skill back to "proposed" and clears the approver. Pass true from the
|
|
147
|
+
# approve! flow so approval doesn't reset itself.
|
|
148
|
+
def update_with_version!(attrs, edited_by: nil, change_note: nil, preserve_approval: false)
|
|
149
|
+
transaction do
|
|
150
|
+
assign_attributes(attrs)
|
|
151
|
+
|
|
152
|
+
if !preserve_approval && approved? && content_dirty?
|
|
153
|
+
self.status = STATUS_PROPOSED
|
|
154
|
+
self.approved_by = nil
|
|
155
|
+
self.approved_at = nil
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
save!
|
|
159
|
+
RailsConsoleAi::SkillVersion.create!(
|
|
160
|
+
skill_id: id,
|
|
161
|
+
name: name,
|
|
162
|
+
description: description,
|
|
163
|
+
body: body,
|
|
164
|
+
tags: tags,
|
|
165
|
+
bypass_guards_for_methods: bypass_guards_for_methods,
|
|
166
|
+
status: status,
|
|
167
|
+
edited_by: edited_by,
|
|
168
|
+
change_note: change_note
|
|
169
|
+
)
|
|
170
|
+
end
|
|
171
|
+
self
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Marks the current head as approved. Logs a version row with the approver name
|
|
175
|
+
# so the audit trail captures the approval moment.
|
|
176
|
+
def approve!(approved_by:)
|
|
177
|
+
raise ArgumentError, 'approved_by is required' if approved_by.to_s.strip.empty?
|
|
178
|
+
|
|
179
|
+
update_with_version!(
|
|
180
|
+
{
|
|
181
|
+
status: STATUS_APPROVED,
|
|
182
|
+
approved_by: approved_by,
|
|
183
|
+
approved_at: Time.now.utc
|
|
184
|
+
},
|
|
185
|
+
edited_by: approved_by,
|
|
186
|
+
change_note: "Approved by #{approved_by}",
|
|
187
|
+
preserve_approval: true
|
|
188
|
+
)
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
private
|
|
192
|
+
|
|
193
|
+
# Did any content-bearing attribute change in this assign_attributes pass?
|
|
194
|
+
def content_dirty?
|
|
195
|
+
CONTENT_ATTRIBUTES.any? { |a| changes.key?(a) }
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
require 'json'
|
|
2
|
+
|
|
3
|
+
module RailsConsoleAi
|
|
4
|
+
class SkillVersion < ActiveRecord::Base
|
|
5
|
+
self.table_name = 'rails_console_ai_skill_versions'
|
|
6
|
+
|
|
7
|
+
belongs_to :skill,
|
|
8
|
+
class_name: 'RailsConsoleAi::Skill',
|
|
9
|
+
foreign_key: :skill_id,
|
|
10
|
+
optional: true
|
|
11
|
+
|
|
12
|
+
scope :recent, -> { order(created_at: :desc) }
|
|
13
|
+
|
|
14
|
+
def self.connection
|
|
15
|
+
klass = RailsConsoleAi.configuration.connection_class
|
|
16
|
+
if klass
|
|
17
|
+
klass = Object.const_get(klass) if klass.is_a?(String)
|
|
18
|
+
klass.connection
|
|
19
|
+
else
|
|
20
|
+
super
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def tags
|
|
25
|
+
decode_json_array(read_attribute(:tags))
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def tags=(value)
|
|
29
|
+
write_attribute(:tags, encode_json_array(value))
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def bypass_guards_for_methods
|
|
33
|
+
decode_json_array(read_attribute(:bypass_guards_for_methods))
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def bypass_guards_for_methods=(value)
|
|
37
|
+
write_attribute(:bypass_guards_for_methods, encode_json_array(value))
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def decode_json_array(raw)
|
|
43
|
+
return [] if raw.nil? || (raw.respond_to?(:empty?) && raw.empty?)
|
|
44
|
+
return raw if raw.is_a?(Array)
|
|
45
|
+
JSON.parse(raw)
|
|
46
|
+
rescue JSON::ParserError
|
|
47
|
+
[]
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def encode_json_array(value)
|
|
51
|
+
JSON.dump(Array(value))
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -70,14 +70,91 @@
|
|
|
70
70
|
.pagination a:hover { background: #f8f9fa; text-decoration: none; }
|
|
71
71
|
.pagination .disabled { padding: 6px 14px; font-size: 13px; color: #ccc; }
|
|
72
72
|
.pagination .page-info { font-size: 13px; color: #888; }
|
|
73
|
+
.nav-links a { margin-left: 16px; }
|
|
74
|
+
.btn {
|
|
75
|
+
display: inline-block; padding: 7px 14px; border-radius: 4px;
|
|
76
|
+
background: #4a6fa5; color: #fff !important; border: 0;
|
|
77
|
+
font-size: 13px; cursor: pointer; text-decoration: none;
|
|
78
|
+
}
|
|
79
|
+
.btn:hover { background: #3a5a85; text-decoration: none; }
|
|
80
|
+
.btn-secondary { background: #6c757d; }
|
|
81
|
+
.btn-secondary:hover { background: #5a6268; }
|
|
82
|
+
.btn-danger { background: #dc3545; }
|
|
83
|
+
.btn-danger:hover { background: #c82333; }
|
|
84
|
+
.btn-bar { display: flex; gap: 8px; margin: 16px 0; flex-wrap: wrap; }
|
|
85
|
+
.source-db { background: #cce5ff; color: #004085; }
|
|
86
|
+
.source-file { background: #e2e3e5; color: #383d41; }
|
|
87
|
+
.source-builtin { background: #e0d4ff; color: #4a2d8c; }
|
|
88
|
+
.status-approved { background: #d4edda; color: #155724; }
|
|
89
|
+
.status-proposed { background: #fff3cd; color: #856404; }
|
|
90
|
+
.form-row { margin-bottom: 14px; }
|
|
91
|
+
.form-row label {
|
|
92
|
+
display: block; font-size: 12px; font-weight: 600;
|
|
93
|
+
color: #555; text-transform: uppercase; margin-bottom: 4px;
|
|
94
|
+
}
|
|
95
|
+
.form-row input[type="text"], .form-row textarea {
|
|
96
|
+
width: 100%; padding: 8px 10px; font-size: 14px;
|
|
97
|
+
border: 1px solid #ccc; border-radius: 4px;
|
|
98
|
+
font-family: -apple-system, BlinkMacSystemFont, sans-serif;
|
|
99
|
+
}
|
|
100
|
+
.form-row textarea {
|
|
101
|
+
font-family: "SF Mono", "Monaco", "Menlo", "Consolas", monospace;
|
|
102
|
+
font-size: 13px;
|
|
103
|
+
}
|
|
104
|
+
.form-row .hint { font-size: 12px; color: #888; margin-top: 4px; }
|
|
105
|
+
.form-card {
|
|
106
|
+
background: #fff; border-radius: 8px; padding: 24px;
|
|
107
|
+
box-shadow: 0 1px 3px rgba(0,0,0,0.1); margin-bottom: 24px;
|
|
108
|
+
}
|
|
109
|
+
.flash { padding: 12px 16px; border-radius: 4px; margin-bottom: 16px; font-size: 14px; }
|
|
110
|
+
.flash-notice { background: #d4edda; color: #155724; }
|
|
111
|
+
.flash-alert { background: #f8d7da; color: #721c24; }
|
|
112
|
+
.markdown-body {
|
|
113
|
+
background: #fff; border-radius: 8px; padding: 20px;
|
|
114
|
+
box-shadow: 0 1px 3px rgba(0,0,0,0.1); margin-bottom: 16px;
|
|
115
|
+
white-space: pre-wrap; font-family: "SF Mono", monospace; font-size: 13px;
|
|
116
|
+
}
|
|
117
|
+
.versions-list { background: #fff; border-radius: 8px; padding: 0; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
|
|
118
|
+
.versions-list .ver-row {
|
|
119
|
+
display: flex; align-items: center; justify-content: space-between;
|
|
120
|
+
padding: 10px 16px; border-bottom: 1px solid #f0f0f0; font-size: 13px;
|
|
121
|
+
}
|
|
122
|
+
.versions-list .ver-row:last-child { border-bottom: 0; }
|
|
123
|
+
.versions-list .ver-meta { color: #888; font-size: 12px; }
|
|
124
|
+
.diff-table {
|
|
125
|
+
width: 100%; border-collapse: collapse; background: #fff;
|
|
126
|
+
border-radius: 8px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
|
127
|
+
}
|
|
128
|
+
.diff-table th { background: #f8f9fa; padding: 8px 12px; text-align: left; font-size: 12px; }
|
|
129
|
+
.diff-table td {
|
|
130
|
+
vertical-align: top; padding: 0; width: 50%; border-bottom: 1px solid #f0f0f0;
|
|
131
|
+
}
|
|
132
|
+
.diff-table pre {
|
|
133
|
+
margin: 0; padding: 4px 12px; font-family: "SF Mono", monospace;
|
|
134
|
+
font-size: 12px; white-space: pre-wrap; word-break: break-word;
|
|
135
|
+
}
|
|
136
|
+
.diff-add { background: #d4edda; }
|
|
137
|
+
.diff-del { background: #f8d7da; }
|
|
138
|
+
.tag {
|
|
139
|
+
display: inline-block; padding: 2px 8px; margin-right: 4px;
|
|
140
|
+
background: #e9ecef; color: #495057; border-radius: 10px; font-size: 11px;
|
|
141
|
+
}
|
|
142
|
+
.inline-form { display: inline; }
|
|
73
143
|
</style>
|
|
74
144
|
</head>
|
|
75
145
|
<body>
|
|
76
146
|
<div class="header">
|
|
77
147
|
<h1>RailsConsoleAi Admin</h1>
|
|
78
|
-
<
|
|
148
|
+
<div class="nav-links">
|
|
149
|
+
<a href="<%= rails_console_ai.root_path %>">Sessions</a>
|
|
150
|
+
<a href="<%= rails_console_ai.skills_path %>">Skills</a>
|
|
151
|
+
<a href="<%= rails_console_ai.memories_path %>">Memories</a>
|
|
152
|
+
<a href="<%= rails_console_ai.agents_path %>">Agents</a>
|
|
153
|
+
</div>
|
|
79
154
|
</div>
|
|
80
155
|
<div class="container">
|
|
156
|
+
<% if flash[:notice] %><div class="flash flash-notice"><%= flash[:notice] %></div><% end %>
|
|
157
|
+
<% if flash[:alert] %><div class="flash flash-alert"><%= flash[:alert] %></div><% end %>
|
|
81
158
|
<%= yield %>
|
|
82
159
|
</div>
|
|
83
160
|
</body>
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
<%= link_to '← Back to agent', agent_path(@agent), class: 'back-link' %>
|
|
2
|
+
|
|
3
|
+
<h2 style="margin-bottom:16px;">Versions — <%= @agent.name %></h2>
|
|
4
|
+
|
|
5
|
+
<% if @versions.empty? %>
|
|
6
|
+
<div class="meta-card"><p class="text-muted">No versions recorded yet.</p></div>
|
|
7
|
+
<% else %>
|
|
8
|
+
<div class="versions-list">
|
|
9
|
+
<% @versions.each do |v| %>
|
|
10
|
+
<div class="ver-row">
|
|
11
|
+
<div>
|
|
12
|
+
<strong>v<%= v.id %></strong>
|
|
13
|
+
<span class="ver-meta">— <%= v.created_at.strftime('%Y-%m-%d %H:%M:%S') %> by <%= v.edited_by || 'unknown' %></span>
|
|
14
|
+
<% if v.status == 'approved' %><span class="badge status-approved">APPROVED</span>
|
|
15
|
+
<% elsif v.status == 'proposed' %><span class="badge status-proposed">PROPOSED</span><% end %>
|
|
16
|
+
<% if v.change_note.present? %><div class="ver-meta"><%= v.change_note %></div><% end %>
|
|
17
|
+
</div>
|
|
18
|
+
<div>
|
|
19
|
+
<%= link_to 'View', agent_version_path(@agent, v) %>
|
|
20
|
+
·
|
|
21
|
+
<%= link_to 'Diff vs current', diff_agents_path(agent_id: @agent.id, from: v.id) %>
|
|
22
|
+
·
|
|
23
|
+
<%= button_to 'Restore', restore_agent_version_path(@agent, v), method: :post, class: 'btn btn-secondary', form: { style: 'display:inline' }, data: { confirm: "Restore version ##{v.id}? Restoring reverts the agent to PROPOSED and a new version row will be created." } %>
|
|
24
|
+
</div>
|
|
25
|
+
</div>
|
|
26
|
+
<% end %>
|
|
27
|
+
</div>
|
|
28
|
+
<% end %>
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
<%= link_to '← All versions', agent_versions_path(@agent), class: 'back-link' %>
|
|
2
|
+
|
|
3
|
+
<div class="meta-card">
|
|
4
|
+
<h2 style="margin-bottom:8px;"><%= @version.name %> <span class="badge source-db">v<%= @version.id %></span></h2>
|
|
5
|
+
<p class="text-muted"><%= @version.description %></p>
|
|
6
|
+
<div class="meta-grid" style="margin-top:16px;">
|
|
7
|
+
<div class="meta-item"><label>Edited by</label><span><%= @version.edited_by || '—' %></span></div>
|
|
8
|
+
<div class="meta-item"><label>At</label><span><%= @version.created_at.strftime('%Y-%m-%d %H:%M:%S') %></span></div>
|
|
9
|
+
<div class="meta-item"><label>Status</label><span><%= @version.status || '—' %></span></div>
|
|
10
|
+
<div class="meta-item"><label>Model</label><span><%= @version.model || '(default)' %></span></div>
|
|
11
|
+
<div class="meta-item"><label>Max rounds</label><span><%= @version.max_rounds || '(default)' %></span></div>
|
|
12
|
+
<div class="meta-item"><label>Tools</label><span><%= Array(@version.tools).join(', ').presence || '(default set)' %></span></div>
|
|
13
|
+
</div>
|
|
14
|
+
<% if @version.change_note.present? %>
|
|
15
|
+
<p style="margin-top:12px;"><strong>Change note:</strong> <%= @version.change_note %></p>
|
|
16
|
+
<% end %>
|
|
17
|
+
</div>
|
|
18
|
+
|
|
19
|
+
<div class="btn-bar">
|
|
20
|
+
<%= button_to "Restore this version", restore_agent_version_path(@agent, @version), method: :post, class: 'btn btn-danger', data: { confirm: "Restore version ##{@version.id}? Current agent will be overwritten and status reverted to PROPOSED." } %>
|
|
21
|
+
<%= link_to 'Diff vs current', diff_agents_path(agent_id: @agent.id, from: @version.id), class: 'btn btn-secondary' %>
|
|
22
|
+
</div>
|
|
23
|
+
|
|
24
|
+
<h3 style="margin-top:24px; margin-bottom:8px;">Body / instructions</h3>
|
|
25
|
+
<div class="markdown-body"><%= @version.body %></div>
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
<% if @agent.persisted? && @agent.respond_to?(:approved?) && @agent.approved? %>
|
|
2
|
+
<div class="flash flash-alert" style="margin-bottom:16px;">
|
|
3
|
+
Heads up: this agent is currently <strong>approved</strong>. Editing it will revert its status to <strong>proposed</strong> and the AI won't be able to invoke it again until someone re-approves it.
|
|
4
|
+
</div>
|
|
5
|
+
<% elsif !@agent.persisted? %>
|
|
6
|
+
<div class="flash flash-notice" style="margin-bottom:16px;">
|
|
7
|
+
New DB agents start as <strong>proposed</strong>. After saving, an approver (likely you) needs to click the Approve button on the show page before the AI can invoke this agent via delegate_task.
|
|
8
|
+
</div>
|
|
9
|
+
<% end %>
|
|
10
|
+
|
|
11
|
+
<%= form_with model: @agent, url: form_url, method: form_method, local: true do |f| %>
|
|
12
|
+
<div class="form-card">
|
|
13
|
+
<div class="form-row">
|
|
14
|
+
<label>Name</label>
|
|
15
|
+
<%= f.text_field :name, value: @agent.name, required: true %>
|
|
16
|
+
</div>
|
|
17
|
+
|
|
18
|
+
<div class="form-row">
|
|
19
|
+
<label>Description</label>
|
|
20
|
+
<%= f.text_field :description, value: @agent.description, placeholder: 'One-line description of what this agent specializes in' %>
|
|
21
|
+
</div>
|
|
22
|
+
|
|
23
|
+
<div class="form-row">
|
|
24
|
+
<label>Body / system instructions (markdown)</label>
|
|
25
|
+
<%= f.text_area :body, value: @agent.body, rows: 18 %>
|
|
26
|
+
<div class="hint">Persona + strategy + rules. Be specific about what tools/approaches the sub-agent should use and what format its summary should take.</div>
|
|
27
|
+
</div>
|
|
28
|
+
|
|
29
|
+
<div class="form-row">
|
|
30
|
+
<label>Max rounds (optional)</label>
|
|
31
|
+
<%= f.number_field :max_rounds, value: @agent.max_rounds, min: 1, max: 100 %>
|
|
32
|
+
<div class="hint">Maximum tool-loop iterations for the sub-agent. Defaults to the global <code>config.sub_agent_max_rounds</code>. Smaller = tighter scope, fewer tokens.</div>
|
|
33
|
+
</div>
|
|
34
|
+
|
|
35
|
+
<div class="form-row">
|
|
36
|
+
<label>Model (optional)</label>
|
|
37
|
+
<%= f.text_field :model, value: @agent.model, placeholder: 'e.g. claude-haiku-4-5' %>
|
|
38
|
+
<div class="hint">Model override. Leave blank to use the global model. Useful for cheap/fast agents.</div>
|
|
39
|
+
</div>
|
|
40
|
+
|
|
41
|
+
<div class="form-row">
|
|
42
|
+
<label>Tool whitelist (one per line, optional)</label>
|
|
43
|
+
<%= f.text_area :tools, value: Array(@agent.tools).join("\n"), rows: 4 %>
|
|
44
|
+
<div class="hint">Tool names the sub-agent is allowed to call (e.g. <code>execute_code</code>, <code>search_code</code>, <code>read_file</code>). Leave blank to use the default tool set. Adding tools here can broaden what the sub-agent can do — this is why DB agents need approval.</div>
|
|
45
|
+
</div>
|
|
46
|
+
|
|
47
|
+
<hr style="margin: 20px 0; border: 0; border-top: 1px solid #eee;">
|
|
48
|
+
|
|
49
|
+
<div class="form-row">
|
|
50
|
+
<label>Edited by</label>
|
|
51
|
+
<input type="text" name="edited_by" value="<%= params[:edited_by] %>" placeholder="Your name or handle">
|
|
52
|
+
<div class="hint">Recorded with this version. Free text — the gem does not assume authentication beyond the admin login.</div>
|
|
53
|
+
</div>
|
|
54
|
+
|
|
55
|
+
<div class="form-row">
|
|
56
|
+
<label>Change note (optional)</label>
|
|
57
|
+
<input type="text" name="change_note" value="" placeholder="What did you change and why?">
|
|
58
|
+
</div>
|
|
59
|
+
</div>
|
|
60
|
+
|
|
61
|
+
<div class="btn-bar">
|
|
62
|
+
<%= f.submit submit_label, class: 'btn' %>
|
|
63
|
+
<%= link_to 'Cancel', cancel_path, class: 'btn btn-secondary' %>
|
|
64
|
+
</div>
|
|
65
|
+
<% end %>
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
<%= link_to '← Back to agent', agent_path(@agent), class: 'back-link' %>
|
|
2
|
+
|
|
3
|
+
<h2 style="margin-bottom:8px;">Diff — <%= @agent.name %></h2>
|
|
4
|
+
<p class="text-muted" style="margin-bottom:16px;">
|
|
5
|
+
Version #<%= @from.id %> (<%= @from.created_at.strftime('%Y-%m-%d %H:%M') %>) → <%= @to_label %>
|
|
6
|
+
</p>
|
|
7
|
+
|
|
8
|
+
<h3 style="margin:16px 0 6px;">Body</h3>
|
|
9
|
+
<%= render_text_diff(@from.body, @to_body, left_label: "v##{@from.id}", right_label: @to_label) %>
|
|
10
|
+
|
|
11
|
+
<h3 style="margin:24px 0 6px;">Tool whitelist</h3>
|
|
12
|
+
<%= render_json_diff(Array(@from.tools), @to_tools, left_label: "v##{@from.id}", right_label: @to_label) %>
|
|
13
|
+
|
|
14
|
+
<div class="btn-bar" style="margin-top:24px;">
|
|
15
|
+
<% if @to.nil? %>
|
|
16
|
+
<%= button_to "Restore v##{@from.id}", restore_agent_version_path(@agent, @from), method: :post, class: 'btn btn-danger', data: { confirm: 'Restoring will overwrite the current agent (a new version row is created) and revert status to PROPOSED.' } %>
|
|
17
|
+
<% end %>
|
|
18
|
+
<%= link_to 'Back', agent_path(@agent), class: 'btn btn-secondary' %>
|
|
19
|
+
</div>
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
<div class="btn-bar">
|
|
2
|
+
<%= link_to 'New agent', new_agent_path, class: 'btn' %>
|
|
3
|
+
<% if @sort == 'used' %>
|
|
4
|
+
<%= link_to 'Sort: alphabetical', agents_path(q: @q), class: 'btn btn-secondary' %>
|
|
5
|
+
<% else %>
|
|
6
|
+
<%= link_to 'Sort: most used', agents_path(q: @q, sort: 'used'), class: 'btn btn-secondary' %>
|
|
7
|
+
<% end %>
|
|
8
|
+
<form method="get" action="<%= agents_path %>" style="margin-left:auto;">
|
|
9
|
+
<input type="text" name="q" value="<%= @q %>" placeholder="Search agents…" style="padding:6px 10px; border:1px solid #ccc; border-radius:4px;">
|
|
10
|
+
<% if @sort.present? %><input type="hidden" name="sort" value="<%= @sort %>"><% end %>
|
|
11
|
+
</form>
|
|
12
|
+
</div>
|
|
13
|
+
|
|
14
|
+
<% proposed_count = @agents.count { |a| a['source'] == :db && a['status'] != 'approved' } %>
|
|
15
|
+
<% if proposed_count > 0 %>
|
|
16
|
+
<div class="flash flash-alert" style="margin-bottom:16px;">
|
|
17
|
+
<strong><%= proposed_count %></strong> agent<%= proposed_count == 1 ? '' : 's' %> awaiting approval. The AI cannot invoke them via delegate_task until a human approves.
|
|
18
|
+
</div>
|
|
19
|
+
<% end %>
|
|
20
|
+
|
|
21
|
+
<% if @agents.empty? %>
|
|
22
|
+
<div class="meta-card">
|
|
23
|
+
<p class="text-muted">No agents found. <%= link_to 'Create one', new_agent_path %> or drop a Markdown file in <code>.rails_console_ai/agents/</code>.</p>
|
|
24
|
+
</div>
|
|
25
|
+
<% else %>
|
|
26
|
+
<table>
|
|
27
|
+
<thead>
|
|
28
|
+
<tr>
|
|
29
|
+
<th>Name</th>
|
|
30
|
+
<th>Description</th>
|
|
31
|
+
<th>Model</th>
|
|
32
|
+
<th>Source / Status</th>
|
|
33
|
+
<th title="Number of times delegate_task invoked this agent (DB only)">Uses</th>
|
|
34
|
+
<th>Last used</th>
|
|
35
|
+
<th></th>
|
|
36
|
+
</tr>
|
|
37
|
+
</thead>
|
|
38
|
+
<tbody>
|
|
39
|
+
<% @agents.each do |a| %>
|
|
40
|
+
<% link_id = a['id'] || a['name'] %>
|
|
41
|
+
<tr>
|
|
42
|
+
<td><strong><%= link_to a['name'], agent_path(link_id) %></strong></td>
|
|
43
|
+
<td class="query-cell"><%= a['description'] %></td>
|
|
44
|
+
<td class="mono"><%= a['model'] || '—' %></td>
|
|
45
|
+
<td>
|
|
46
|
+
<% case a['source'] %>
|
|
47
|
+
<% when :db %>
|
|
48
|
+
<span class="badge source-db">DB</span>
|
|
49
|
+
<% if a['status'] == 'approved' %>
|
|
50
|
+
<span class="badge status-approved">APPROVED</span>
|
|
51
|
+
<% else %>
|
|
52
|
+
<span class="badge status-proposed">PROPOSED</span>
|
|
53
|
+
<% end %>
|
|
54
|
+
<% when :file %>
|
|
55
|
+
<span class="badge source-file">FILE</span>
|
|
56
|
+
<% when :builtin %>
|
|
57
|
+
<span class="badge source-builtin">BUILTIN</span>
|
|
58
|
+
<% end %>
|
|
59
|
+
</td>
|
|
60
|
+
<td>
|
|
61
|
+
<% if a['source'] == :db %>
|
|
62
|
+
<%= a['use_count'] || 0 %>
|
|
63
|
+
<% else %>
|
|
64
|
+
<span class="text-muted">—</span>
|
|
65
|
+
<% end %>
|
|
66
|
+
</td>
|
|
67
|
+
<td class="text-muted" style="font-size:12px;">
|
|
68
|
+
<%= a['last_used_at']&.strftime('%Y-%m-%d %H:%M') || '—' %>
|
|
69
|
+
</td>
|
|
70
|
+
<td>
|
|
71
|
+
<%= link_to 'View', agent_path(link_id) %>
|
|
72
|
+
<% if a['source'] == :db %>
|
|
73
|
+
· <%= link_to 'Edit', edit_agent_path(a['id']) %>
|
|
74
|
+
<% end %>
|
|
75
|
+
</td>
|
|
76
|
+
</tr>
|
|
77
|
+
<% end %>
|
|
78
|
+
</tbody>
|
|
79
|
+
</table>
|
|
80
|
+
<% end %>
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
<%= link_to '← All agents', agents_path, class: 'back-link' %>
|
|
2
|
+
<h2 style="margin-bottom:16px;">New agent</h2>
|
|
3
|
+
|
|
4
|
+
<%= form_with url: import_agents_path, method: :post, local: true do %>
|
|
5
|
+
<div class="form-card" style="background:#f8f9fa;">
|
|
6
|
+
<div class="form-row" style="margin-bottom:8px;">
|
|
7
|
+
<label>Paste a .md file (optional)</label>
|
|
8
|
+
<textarea name="content" rows="14" placeholder="--- name: My agent description: What it specializes in max_rounds: 10 model: claude-haiku-4-5 tools: [execute_code, search_code] --- Persona, strategy, rules …"></textarea>
|
|
9
|
+
<div class="hint">
|
|
10
|
+
Paste the full contents of a Markdown file (YAML frontmatter + body) — same
|
|
11
|
+
format as <code>.rails_console_ai/agents/*.md</code> or the built-in agents
|
|
12
|
+
shipped with the gem. Useful for moving an existing on-disk agent into the
|
|
13
|
+
versioned DB store.
|
|
14
|
+
</div>
|
|
15
|
+
</div>
|
|
16
|
+
<button type="submit" class="btn btn-secondary">Parse pasted content ↓</button>
|
|
17
|
+
</div>
|
|
18
|
+
<% end %>
|
|
19
|
+
|
|
20
|
+
<%= render 'form',
|
|
21
|
+
form_url: agents_path,
|
|
22
|
+
form_method: :post,
|
|
23
|
+
submit_label: 'Create agent',
|
|
24
|
+
cancel_path: agents_path %>
|