envoy_ai 0.0.1 → 0.0.3

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.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +181 -2
  3. data/app/assets/stylesheets/envoy/chat.css +236 -0
  4. data/app/assets/stylesheets/envoy/console.css +50 -176
  5. data/app/controllers/envoy/conversations_controller.rb +1 -4
  6. data/app/controllers/envoy/panels_controller.rb +30 -0
  7. data/app/javascript/envoy/controllers/autoscroll_controller.js +26 -32
  8. data/app/jobs/envoy/run_job.rb +25 -4
  9. data/app/models/envoy/conversation.rb +33 -6
  10. data/app/views/envoy/conversations/_composer_input.html.erb +1 -1
  11. data/app/views/envoy/conversations/index.html.erb +0 -2
  12. data/app/views/envoy/conversations/new.html.erb +1 -7
  13. data/app/views/envoy/conversations/show.html.erb +1 -17
  14. data/app/views/envoy/messages/_error.html.erb +6 -0
  15. data/app/views/envoy/messages/_streaming.html.erb +1 -1
  16. data/app/views/envoy/messages/create.turbo_stream.erb +5 -5
  17. data/app/views/envoy/panels/_panel.html.erb +3 -0
  18. data/app/views/envoy/panels/_transcript.html.erb +21 -0
  19. data/config/routes.rb +1 -1
  20. data/db/migrate/20260716000001_drop_envoy_system_prompts.rb +12 -0
  21. data/db/migrate/20260716000002_add_surface_to_envoy_conversations.rb +15 -0
  22. data/lib/envoy/engine.rb +6 -0
  23. data/lib/envoy/errors.rb +4 -0
  24. data/lib/envoy/guard.rb +13 -1
  25. data/lib/envoy/library.rb +82 -0
  26. data/lib/envoy/llm.rb +1 -1
  27. data/lib/envoy/page_surface.rb +34 -0
  28. data/lib/envoy/prompt.rb +55 -0
  29. data/lib/envoy/runner.rb +17 -3
  30. data/lib/envoy/surface.rb +90 -0
  31. data/lib/envoy/tool_definition.rb +12 -2
  32. data/lib/envoy/version.rb +1 -1
  33. data/lib/envoy.rb +59 -0
  34. metadata +12 -12
  35. data/app/controllers/envoy/system_prompts_controller.rb +0 -52
  36. data/app/models/envoy/system_prompt.rb +0 -24
  37. data/app/models/envoy/system_prompt_version.rb +0 -14
  38. data/app/views/envoy/system_prompts/_form.html.erb +0 -14
  39. data/app/views/envoy/system_prompts/edit.html.erb +0 -2
  40. data/app/views/envoy/system_prompts/index.html.erb +0 -14
  41. data/app/views/envoy/system_prompts/new.html.erb +0 -2
  42. data/app/views/envoy/system_prompts/show.html.erb +0 -15
  43. data/db/migrate/20260711000001_create_envoy_system_prompts.rb +0 -10
  44. data/db/migrate/20260711000002_create_envoy_system_prompt_versions.rb +0 -13
  45. data/db/migrate/20260711000003_add_system_prompt_version_to_envoy_conversations.rb +0 -6
data/lib/envoy/engine.rb CHANGED
@@ -15,5 +15,11 @@ module Envoy
15
15
  app.config.assets.paths << root.join("app/javascript") if app.config.respond_to?(:assets)
16
16
  app.config.assets.paths << root.join("app/assets/stylesheets") if app.config.respond_to?(:assets)
17
17
  end
18
+
19
+ initializer "envoy.page_surface" do
20
+ ActiveSupport.on_load(:action_controller_base) do
21
+ include Envoy::PageSurface
22
+ end
23
+ end
18
24
  end
19
25
  end
data/lib/envoy/errors.rb CHANGED
@@ -3,4 +3,8 @@ module Envoy
3
3
  class Forbidden < Error; end
4
4
  class UnknownToolset < Error; end
5
5
  class ToolsetCycle < Error; end
6
+ class UnknownPrompt < Error; end
7
+ class PromptCycle < Error; end
8
+ class UnknownSurface < Error; end
9
+ class UnknownLibrary < Error; end
6
10
  end
data/lib/envoy/guard.rb CHANGED
@@ -5,7 +5,9 @@ module Envoy
5
5
  # Runs a tool definition's perform block, translating exceptions into
6
6
  # model-legible results. Never raises for expected failures.
7
7
  def run(definition, actor:, args:)
8
- value = definition.perform_block.call(actor: actor, **args.symbolize_keys)
8
+ args = args.symbolize_keys
9
+ validate_enums!(definition, args)
10
+ value = definition.perform_block.call(actor: actor, **args)
9
11
  if value.is_a?(Hash) && value[:error]
10
12
  { value: value, status: :failed }
11
13
  else
@@ -24,5 +26,15 @@ module Envoy
24
26
  def failure(type, message, status)
25
27
  { value: { error: message, type: type }, status: status }
26
28
  end
29
+
30
+ # A closed vocabulary is a contract: reject an unknown value with the valid
31
+ # list rather than letting the perform block improvise on a hallucinated key.
32
+ def validate_enums!(definition, args)
33
+ args.each do |name, value|
34
+ allowed = definition.enum_for(name)
35
+ next if allowed.nil? || allowed.include?(value.to_s)
36
+ raise ArgumentError, "#{name} must be one of: #{allowed.join(', ')} (got #{value.inspect})"
37
+ end
38
+ end
27
39
  end
28
40
  end
@@ -0,0 +1,82 @@
1
+ module Envoy
2
+ # A set of reference documents the model can pull on demand.
3
+ #
4
+ # Progressive disclosure: preloading every reference doc costs thousands of
5
+ # tokens on turns that never need them. A library puts a short index in the
6
+ # instructions and hands the model one tool to fetch a document when the task
7
+ # actually calls for it.
8
+ #
9
+ # Compiles to an ordinary Toolset, so it composes via `use`, merges its
10
+ # description into instructions, and honours read_only — all for free.
11
+ class Library
12
+ attr_reader :key
13
+
14
+ def initialize(key)
15
+ @key = key.to_s
16
+ @description = ""
17
+ @documents = {} # slug => { summary:, body: callable }
18
+ end
19
+
20
+ def description(text = nil)
21
+ return rendered_description if text.nil?
22
+ @description = text
23
+ end
24
+
25
+ # Register one document. The block is the lazy body.
26
+ def document(slug, summary = nil, &block)
27
+ @documents[slug.to_s] = { summary: summary, body: block }
28
+ end
29
+
30
+ # Ingest a directory of markdown: README.md becomes the description, every
31
+ # other *.md becomes a document keyed by its slug. Flat, non-recursive.
32
+ #
33
+ # No markdown parsing: a README that documents its own directory already
34
+ # carries the human-written index, and the tool's enum carries the
35
+ # machine-readable key list. Explicit `document` calls win over files.
36
+ def directory(path)
37
+ path = Pathname(path)
38
+ readme = path.join("README.md")
39
+ @description = readme.read if readme.exist?
40
+
41
+ path.glob("*.md").sort.each do |file|
42
+ slug = file.basename(".md").to_s
43
+ next if slug.casecmp("README").zero?
44
+ @documents[slug] ||= { summary: nil, body: -> { file.read } }
45
+ end
46
+ end
47
+
48
+ def document_keys
49
+ @documents.keys.sort
50
+ end
51
+
52
+ def read(slug)
53
+ entry = @documents[slug.to_s]
54
+ raise ArgumentError, "no document #{slug.inspect}" unless entry
55
+ entry[:body].call
56
+ end
57
+
58
+ # The description plus an index of any documents that carry their own
59
+ # summary. Directory-ingested docs are indexed by the README itself, so
60
+ # listing them again would just duplicate it.
61
+ def rendered_description
62
+ summarised = @documents.select { |_, entry| entry[:summary].present? }
63
+ return @description if summarised.empty?
64
+
65
+ index = summarised.map { |slug, entry| " #{slug} — #{entry[:summary]}" }
66
+ [ @description, "Also available:", *index ].compact_blank.join("\n")
67
+ end
68
+
69
+ def to_toolset
70
+ library = self
71
+ toolset = Envoy::Toolset.new(key)
72
+ toolset.description(rendered_description)
73
+ toolset.tool("read_#{key}") do
74
+ description "Read one #{library.key} reference document in full."
75
+ access :read
76
+ param :key, "Which document to read.", enum: library.document_keys
77
+ perform { |actor:, key:| library.read(key) }
78
+ end
79
+ toolset
80
+ end
81
+ end
82
+ end
data/lib/envoy/llm.rb CHANGED
@@ -11,7 +11,7 @@ module Envoy
11
11
  # (lib/ruby_llm/active_record/chat_methods.rb and lib/ruby_llm/chat.rb).
12
12
  def run(content:, tools:, instructions:)
13
13
  chat = @conversation
14
- .with_model(@conversation.model_id,
14
+ .with_model(@conversation.resolved_model_id,
15
15
  provider: Envoy.config.provider,
16
16
  assume_exists: true)
17
17
  .with_instructions(instructions)
@@ -0,0 +1,34 @@
1
+ module Envoy
2
+ # Declares which surface a page opens a chat against.
3
+ #
4
+ # class TodayController < ApplicationController
5
+ # envoy_surface :today, context_key: -> { "day:#{@date.to_fs(:iso8601)}" }
6
+ # end
7
+ #
8
+ # The context_key lambda is instance_exec'd in the controller, so instance
9
+ # variables set by the action are in scope. Pages that do not declare a
10
+ # surface get no bubble — opt-in by default.
11
+ module PageSurface
12
+ extend ActiveSupport::Concern
13
+
14
+ included do
15
+ class_attribute :envoy_surface_config, instance_writer: false, default: nil
16
+ helper_method :envoy_page_surface
17
+ end
18
+
19
+ class_methods do
20
+ def envoy_surface(key, context_key: nil)
21
+ self.envoy_surface_config = { key: key.to_s, context_key: context_key }
22
+ end
23
+ end
24
+
25
+ # Nil when the controller never declared one — the layout renders nothing.
26
+ def envoy_page_surface
27
+ config = envoy_surface_config
28
+ return nil if config.nil?
29
+
30
+ { surface: config[:key],
31
+ context_key: config[:context_key] && instance_exec(&config[:context_key]) }
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,55 @@
1
+ module Envoy
2
+ # A named block of system-prompt text, composable like a Toolset.
3
+ #
4
+ # Prompts are code, not data: git is the version store. Bodies may be a String
5
+ # or a block; a block stays lazy so a host reading from disk picks up edits on
6
+ # `to_prepare` reload without a boot.
7
+ class Prompt
8
+ attr_reader :key
9
+
10
+ def initialize(key)
11
+ @key = key.to_s
12
+ @body = nil
13
+ @composed_keys = []
14
+ end
15
+
16
+ # Setter with text or a block; reader (no args) returns this prompt's own
17
+ # resolved body only. Use #full_body for the composed result.
18
+ def body(text = nil, &block)
19
+ return own_body if text.nil? && block.nil?
20
+ @body = block || text
21
+ end
22
+
23
+ # Compose other prompts into this one. Keys resolve lazily (at full_body
24
+ # time), so definition load order does not matter.
25
+ def use(*keys)
26
+ @composed_keys.concat(keys.map(&:to_s))
27
+ end
28
+
29
+ # Composed bodies first, then this prompt's own — so shared framing (safety
30
+ # rules, draft status) precedes the specialisation that builds on it.
31
+ def full_body
32
+ collect_bodies.compact_blank.join("\n\n").presence
33
+ end
34
+
35
+ protected
36
+
37
+ def collect_bodies(seen = [])
38
+ guard_cycle!(seen)
39
+ bodies = @composed_keys.flat_map { |k| Envoy.prompt(k).collect_bodies(seen + [ key ]) }
40
+ bodies << own_body
41
+ bodies
42
+ end
43
+
44
+ private
45
+
46
+ def own_body
47
+ @body.respond_to?(:call) ? @body.call : @body
48
+ end
49
+
50
+ def guard_cycle!(seen)
51
+ return unless seen.include?(key)
52
+ raise Envoy::PromptCycle, "prompt composition cycle: #{(seen + [ key ]).join(' -> ')}"
53
+ end
54
+ end
55
+ end
data/lib/envoy/runner.rb CHANGED
@@ -22,7 +22,7 @@ module Envoy
22
22
 
23
23
  def toolset = @conversation.toolset
24
24
 
25
- def read_only? = @conversation.status == "read_only"
25
+ def read_only? = @conversation.read_only?
26
26
 
27
27
  def compiled_tools
28
28
  toolset.tools_for(read_only: read_only?).map do |definition|
@@ -30,9 +30,23 @@ module Envoy
30
30
  end
31
31
  end
32
32
 
33
+ # Ordered stable-prefix-first: the leading segments are identical across
34
+ # turns and so are cacheable, while context is re-resolved every turn and
35
+ # never is. A resumed conversation outlives the page state it started with,
36
+ # which is exactly why context cannot be a snapshot.
33
37
  def instructions
34
- [ Envoy.config.system_preamble, toolset.description, ERROR_CONVENTION,
35
- @conversation.effective_system_prompt ].compact_blank.join("\n\n")
38
+ [ Envoy.config.system_preamble,
39
+ toolset.description,
40
+ ERROR_CONVENTION,
41
+ @conversation.prompt_body,
42
+ surface_context,
43
+ @conversation.effective_system_prompt ].compact_blank.join("\n\n")
44
+ end
45
+
46
+ def surface_context
47
+ surface = @conversation.surface
48
+ return nil unless surface
49
+ surface.context_for(actor: @conversation.actor, key: @conversation.context_key)
36
50
  end
37
51
 
38
52
  def backfill_statuses!
@@ -0,0 +1,90 @@
1
+ module Envoy
2
+ # Binds a page to an agent: which tools, which prompt, which model, and how to
3
+ # describe what the user is looking at.
4
+ #
5
+ # A surface is the source of truth for a conversation opened against it —
6
+ # toolset, prompt and model are read here on every turn, so editing a surface
7
+ # reaches conversations that already exist.
8
+ #
9
+ # References are stored as keys and resolved lazily (Envoy.validate! checks
10
+ # them in one pass at boot), so definition load order does not matter.
11
+ class Surface
12
+ attr_reader :key
13
+
14
+ def initialize(key)
15
+ @key = key.to_s
16
+ @toolset_key = nil
17
+ @model_id = nil
18
+ @read_only = false
19
+ @prompt = nil
20
+ @context_block = nil
21
+ end
22
+
23
+ # --- DSL ---
24
+ def toolset(name = nil)
25
+ return @toolset_key if name.nil?
26
+ @toolset_key = name.to_s
27
+ end
28
+
29
+ def model(id = nil)
30
+ return @model_id if id.nil?
31
+ @model_id = id
32
+ end
33
+
34
+ def read_only(value = nil)
35
+ return @read_only if value.nil?
36
+ @read_only = value
37
+ end
38
+
39
+ # A registry key (Symbol) or a literal String.
40
+ def system_prompt(ref = nil)
41
+ return @prompt if ref.nil?
42
+ @prompt = ref
43
+ end
44
+
45
+ # Runs on every turn, in the job — so it gets actor and key and nothing else.
46
+ # No request, no params, no session. This is also where a host enforces that
47
+ # this actor may see this subject: raise Envoy::Forbidden and Guard handles it.
48
+ def context(&block)
49
+ @context_block = block
50
+ end
51
+
52
+ # --- introspection ---
53
+ def toolset_key = @toolset_key
54
+ def model_id = @model_id
55
+ def read_only? = !!@read_only
56
+
57
+ def prompt_body
58
+ case @prompt
59
+ when nil then nil
60
+ when String then @prompt
61
+ else Envoy.prompt(@prompt).full_body
62
+ end
63
+ end
64
+
65
+ def context_for(actor:, key:)
66
+ return nil unless @context_block
67
+ @context_block.call(actor: actor, key: key)
68
+ end
69
+
70
+ # Resolve every lazy reference, naming this surface in any failure.
71
+ def validate!
72
+ raise Envoy::Error, "surface #{key.inspect} has no toolset" if @toolset_key.blank?
73
+
74
+ begin
75
+ # tools_for walks the whole `use` graph, not just this toolset's own
76
+ # tools — so a typo in a composed toolset's `use` list fails here,
77
+ # at boot, instead of surfacing mid-conversation inside RunJob.
78
+ Envoy.toolset(@toolset_key).tools_for
79
+ rescue Envoy::UnknownToolset, Envoy::ToolsetCycle => e
80
+ raise e.class, "surface #{key.inspect}: #{e.message}"
81
+ end
82
+
83
+ begin
84
+ prompt_body
85
+ rescue Envoy::UnknownPrompt => e
86
+ raise Envoy::UnknownPrompt, "surface #{key.inspect}: #{e.message}"
87
+ end
88
+ end
89
+ end
90
+ end
@@ -23,8 +23,18 @@ module Envoy
23
23
  @access = level
24
24
  end
25
25
 
26
- def param(name, desc, type: :string, required: true)
27
- @params << { name: name.to_sym, desc: desc, type: type, required: required }
26
+ # enum declares a closed vocabulary for a param. RubyLLM::Parameter has no
27
+ # enum support (its initializer would raise on the keyword), so the values
28
+ # are folded into the description the model reads, and Guard enforces them —
29
+ # yielding the same {"error":..,"type":"invalid"} shape as any other bad argument.
30
+ def param(name, desc, type: :string, required: true, enum: nil)
31
+ enum = enum&.map(&:to_s)
32
+ desc = "#{desc} One of: #{enum.join(', ')}." if enum
33
+ @params << { name: name.to_sym, desc: desc, type: type, required: required, enum: enum }
34
+ end
35
+
36
+ def enum_for(param_name)
37
+ @params.find { |p| p[:name] == param_name.to_sym }&.fetch(:enum)
28
38
  end
29
39
 
30
40
  def perform(&block)
data/lib/envoy/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Envoy
2
- VERSION = "0.0.1"
2
+ VERSION = "0.0.3"
3
3
  end
data/lib/envoy.rb CHANGED
@@ -8,6 +8,10 @@ require "envoy/engine"
8
8
  require "envoy/errors"
9
9
  require "envoy/tool_definition"
10
10
  require "envoy/toolset"
11
+ require "envoy/prompt"
12
+ require "envoy/library"
13
+ require "envoy/surface"
14
+ require "envoy/page_surface"
11
15
  require "envoy/guard"
12
16
  require "envoy/compiled_tool"
13
17
  require "envoy/llm"
@@ -41,5 +45,60 @@ module Envoy
41
45
  def toolset(key)
42
46
  toolsets.fetch(key.to_s) { raise UnknownToolset, "no toolset #{key.inspect}" }
43
47
  end
48
+
49
+ def prompts
50
+ @prompts ||= {}
51
+ end
52
+
53
+ def define_prompt(key, &block)
54
+ prompt = Prompt.new(key)
55
+ prompt.instance_eval(&block)
56
+ prompts[prompt.key] = prompt
57
+ end
58
+
59
+ def prompt(key)
60
+ prompts.fetch(key.to_s) { raise UnknownPrompt, "no prompt #{key.inspect}" }
61
+ end
62
+
63
+ def libraries
64
+ @libraries ||= {}
65
+ end
66
+
67
+ # Registers both the Library (for introspection) and its compiled Toolset
68
+ # under the same key, so `use :principles` works like any other toolset.
69
+ def define_library(key, &block)
70
+ library = Library.new(key)
71
+ library.instance_eval(&block)
72
+ libraries[library.key] = library
73
+ toolsets[library.key] = library.to_toolset
74
+ library
75
+ end
76
+
77
+ def library(key)
78
+ libraries.fetch(key.to_s) { raise UnknownLibrary, "no library #{key.inspect}" }
79
+ end
80
+
81
+ def surfaces
82
+ @surfaces ||= {}
83
+ end
84
+
85
+ def define_surface(key, &block)
86
+ surface = Surface.new(key)
87
+ surface.instance_eval(&block)
88
+ surfaces[surface.key] = surface
89
+ end
90
+
91
+ def surface(key)
92
+ surfaces.fetch(key.to_s) { raise UnknownSurface, "no surface #{key.inspect}" }
93
+ end
94
+
95
+ # Resolve every surface's lazy references in one pass. Hosts call this at the
96
+ # end of the to_prepare block that loads their definitions, so a typo fails
97
+ # on boot rather than inside a job — where the failure is invisible and the
98
+ # user watches "working…" forever.
99
+ def validate!
100
+ surfaces.each_value(&:validate!)
101
+ true
102
+ end
44
103
  end
45
104
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: envoy_ai
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.1
4
+ version: 0.0.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Travis Petticrew
@@ -62,11 +62,12 @@ extra_rdoc_files: []
62
62
  files:
63
63
  - MIT-LICENSE
64
64
  - README.md
65
+ - app/assets/stylesheets/envoy/chat.css
65
66
  - app/assets/stylesheets/envoy/console.css
66
67
  - app/controllers/envoy/application_controller.rb
67
68
  - app/controllers/envoy/conversations_controller.rb
68
69
  - app/controllers/envoy/messages_controller.rb
69
- - app/controllers/envoy/system_prompts_controller.rb
70
+ - app/controllers/envoy/panels_controller.rb
70
71
  - app/javascript/envoy/controllers/autoscroll_controller.js
71
72
  - app/javascript/envoy/controllers/composer_controller.js
72
73
  - app/jobs/envoy/application_job.rb
@@ -74,30 +75,25 @@ files:
74
75
  - app/models/envoy/application_record.rb
75
76
  - app/models/envoy/conversation.rb
76
77
  - app/models/envoy/message.rb
77
- - app/models/envoy/system_prompt.rb
78
- - app/models/envoy/system_prompt_version.rb
79
78
  - app/models/envoy/tool_call.rb
80
79
  - app/views/envoy/conversations/_composer_input.html.erb
81
80
  - app/views/envoy/conversations/index.html.erb
82
81
  - app/views/envoy/conversations/new.html.erb
83
82
  - app/views/envoy/conversations/show.html.erb
83
+ - app/views/envoy/messages/_error.html.erb
84
84
  - app/views/envoy/messages/_message.html.erb
85
85
  - app/views/envoy/messages/_streaming.html.erb
86
86
  - app/views/envoy/messages/_tool_call.html.erb
87
87
  - app/views/envoy/messages/create.turbo_stream.erb
88
- - app/views/envoy/system_prompts/_form.html.erb
89
- - app/views/envoy/system_prompts/edit.html.erb
90
- - app/views/envoy/system_prompts/index.html.erb
91
- - app/views/envoy/system_prompts/new.html.erb
92
- - app/views/envoy/system_prompts/show.html.erb
88
+ - app/views/envoy/panels/_panel.html.erb
89
+ - app/views/envoy/panels/_transcript.html.erb
93
90
  - app/views/layouts/envoy/application.html.erb
94
91
  - config/routes.rb
95
92
  - db/migrate/20260710000001_create_envoy_conversations.rb
96
93
  - db/migrate/20260710000002_create_envoy_messages.rb
97
94
  - db/migrate/20260710000003_create_envoy_tool_calls.rb
98
- - db/migrate/20260711000001_create_envoy_system_prompts.rb
99
- - db/migrate/20260711000002_create_envoy_system_prompt_versions.rb
100
- - db/migrate/20260711000003_add_system_prompt_version_to_envoy_conversations.rb
95
+ - db/migrate/20260716000001_drop_envoy_system_prompts.rb
96
+ - db/migrate/20260716000002_add_surface_to_envoy_conversations.rb
101
97
  - lib/envoy.rb
102
98
  - lib/envoy/badges.rb
103
99
  - lib/envoy/compiled_tool.rb
@@ -105,9 +101,13 @@ files:
105
101
  - lib/envoy/engine.rb
106
102
  - lib/envoy/errors.rb
107
103
  - lib/envoy/guard.rb
104
+ - lib/envoy/library.rb
108
105
  - lib/envoy/llm.rb
109
106
  - lib/envoy/markdown.rb
107
+ - lib/envoy/page_surface.rb
108
+ - lib/envoy/prompt.rb
110
109
  - lib/envoy/runner.rb
110
+ - lib/envoy/surface.rb
111
111
  - lib/envoy/testing/fake_llm.rb
112
112
  - lib/envoy/tool_definition.rb
113
113
  - lib/envoy/toolset.rb
@@ -1,52 +0,0 @@
1
- module Envoy
2
- class SystemPromptsController < ApplicationController
3
- def index
4
- @system_prompts = SystemPrompt.order(:name)
5
- end
6
-
7
- def show
8
- @system_prompt = SystemPrompt.find(params[:id])
9
- end
10
-
11
- def new
12
- @system_prompt = SystemPrompt.new
13
- end
14
-
15
- def create
16
- @system_prompt = SystemPrompt.new(prompt_params.except(:body))
17
- body = prompt_params[:body].to_s
18
- if body.blank?
19
- @system_prompt.errors.add(:base, "Body can't be blank")
20
- return render :new, status: :unprocessable_entity
21
- end
22
- SystemPrompt.transaction do
23
- @system_prompt.save!
24
- @system_prompt.add_version!(body)
25
- end
26
- redirect_to system_prompt_path(@system_prompt), notice: "Prompt created."
27
- rescue ActiveRecord::RecordInvalid
28
- render :new, status: :unprocessable_entity
29
- end
30
-
31
- def edit
32
- @system_prompt = SystemPrompt.find(params[:id])
33
- end
34
-
35
- def update
36
- @system_prompt = SystemPrompt.find(params[:id])
37
- if @system_prompt.update(prompt_params.except(:body))
38
- body = prompt_params[:body].to_s
39
- @system_prompt.add_version!(body) if body.present? && body != @system_prompt.current_body
40
- redirect_to system_prompt_path(@system_prompt), notice: "Prompt updated."
41
- else
42
- render :edit, status: :unprocessable_entity
43
- end
44
- end
45
-
46
- private
47
-
48
- def prompt_params
49
- params.require(:system_prompt).permit(:name, :description, :body)
50
- end
51
- end
52
- end
@@ -1,24 +0,0 @@
1
- module Envoy
2
- class SystemPrompt < ApplicationRecord
3
- has_many :versions, -> { order(:version_number) },
4
- class_name: "Envoy::SystemPromptVersion",
5
- foreign_key: "system_prompt_id",
6
- inverse_of: :system_prompt,
7
- dependent: :destroy
8
-
9
- validates :name, presence: true, uniqueness: true
10
-
11
- def latest_version
12
- versions.order(:version_number).last
13
- end
14
-
15
- def current_body
16
- latest_version&.body
17
- end
18
-
19
- def add_version!(body)
20
- next_number = (versions.maximum(:version_number) || 0) + 1
21
- versions.create!(version_number: next_number, body: body)
22
- end
23
- end
24
- end
@@ -1,14 +0,0 @@
1
- module Envoy
2
- class SystemPromptVersion < ApplicationRecord
3
- belongs_to :system_prompt, class_name: "Envoy::SystemPrompt", inverse_of: :versions
4
-
5
- validates :version_number, presence: true, uniqueness: { scope: :system_prompt_id }
6
- validates :body, presence: true
7
-
8
- attr_readonly :body, :version_number, :system_prompt_id
9
-
10
- before_update do
11
- raise ActiveRecord::ReadOnlyRecord, "Envoy::SystemPromptVersion is immutable"
12
- end
13
- end
14
- end
@@ -1,14 +0,0 @@
1
- <%= form_with model: system_prompt, url: url do |f| %>
2
- <% if system_prompt.errors.any? %>
3
- <div class="envoy-error">
4
- <% system_prompt.errors.full_messages.each do |m| %><div><%= m %></div><% end %>
5
- </div>
6
- <% end %>
7
- <div class="envoy-form">
8
- <%= f.text_field :name, placeholder: "Name", class: "envoy-field" %>
9
- <%= f.text_field :description, placeholder: "Description (optional)", class: "envoy-field" %>
10
- <%= text_area_tag "system_prompt[body]", system_prompt.current_body, rows: 12,
11
- placeholder: "System prompt body", class: "envoy-field envoy-field--mono" %>
12
- <%= f.submit submit_label, class: "envoy-btn" %>
13
- </div>
14
- <% end %>
@@ -1,2 +0,0 @@
1
- <h1 class="envoy-title envoy-mb-4">Edit system prompt</h1>
2
- <%= render "form", system_prompt: @system_prompt, url: system_prompt_path(@system_prompt), submit_label: "Save" %>
@@ -1,14 +0,0 @@
1
- <div class="envoy-header-row envoy-mb-4">
2
- <h1 class="envoy-title">System prompts</h1>
3
- <%= link_to "New prompt", new_system_prompt_path, class: "envoy-btn" %>
4
- </div>
5
- <ul class="envoy-list">
6
- <% @system_prompts.each do |p| %>
7
- <li class="envoy-list__item">
8
- <%= link_to p.name, system_prompt_path(p), class: "envoy-link" %>
9
- <span class="envoy-muted">· v<%= p.latest_version&.version_number || 0 %></span>
10
- <% if p.description.present? %><div class="envoy-muted"><%= p.description %></div><% end %>
11
- </li>
12
- <% end %>
13
- </ul>
14
- <div class="envoy-mt-4"><%= link_to "← Chats", conversations_path, class: "envoy-link" %></div>
@@ -1,2 +0,0 @@
1
- <h1 class="envoy-title envoy-mb-4">New system prompt</h1>
2
- <%= render "form", system_prompt: @system_prompt, url: system_prompts_path, submit_label: "Create" %>