contai 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: 1ea8b64ee261ac1e205aec107d9011fcb2a90194856143eb25c73a79af70f982
4
+ data.tar.gz: b91bd9e6138f0b4980ed0f2651f15083de0ae275524d3a749308f6af900c04b3
5
+ SHA512:
6
+ metadata.gz: 596f44c2e09f5ada2af464d9f7f656569bc396a8b3343cf7415e53d89fa1a7c81457907995c263689468a8238df2dce43f871cd13ff7f26bef289ebd84c0ee30
7
+ data.tar.gz: da7f09f708f026f5bb5766d18a1d7e3125bd327fb0b2494a39e8575d6ffb9be51556896967dbec38ba0ce2d3e827efa419c2ac5893f237e6dcef8e11c522f63e
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --format documentation
2
+ --color
3
+ --require spec_helper
data/.rubocop.yml ADDED
@@ -0,0 +1,13 @@
1
+ AllCops:
2
+ TargetRubyVersion: 3.0
3
+
4
+ Style/StringLiterals:
5
+ Enabled: true
6
+ EnforcedStyle: double_quotes
7
+
8
+ Style/StringLiteralsInInterpolation:
9
+ Enabled: true
10
+ EnforcedStyle: double_quotes
11
+
12
+ Layout/LineLength:
13
+ Max: 120
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.1.0] - 2025-06-06
4
+
5
+ - Initial release
data/README.md ADDED
@@ -0,0 +1,159 @@
1
+ # Contai
2
+
3
+ AI content generation for Rails models. Integrate external AI APIs seamlessly into your Rails applications.
4
+
5
+ ## Installation
6
+
7
+ Add this line to your application's Gemfile:
8
+
9
+ ```ruby
10
+ gem 'contai'
11
+ ```
12
+
13
+ And then execute:
14
+
15
+ $ bundle install
16
+
17
+ ## Usage
18
+
19
+ ### Basic Setup
20
+
21
+ Include `Contai::Generatable` in your model and configure it:
22
+
23
+ ```ruby
24
+ class Article < ApplicationRecord
25
+ include Contai::Generatable
26
+
27
+ contai do
28
+ prompt_from :title, :description
29
+ output_to :body
30
+ provider :openai, api_key: ENV["OPENAI_API_KEY"]
31
+ template "Write a blog post about: {{title}} - {{description}}"
32
+ end
33
+ end
34
+ ```
35
+
36
+ ### Generate Content
37
+
38
+ ```ruby
39
+ article = Article.new(title: "Ruby on Rails", description: "Web framework")
40
+ article.generate_ai_content!
41
+ # Content will be generated and saved to article.body
42
+
43
+ # Async generation
44
+ article.generate_ai_content!(async: true)
45
+ ```
46
+ ### Supported Providers
47
+
48
+ #### OpenAI
49
+ ```ruby
50
+ provider :openai,
51
+ api_key: ENV["OPENAI_API_KEY"],
52
+ model: "gpt-4",
53
+ max_tokens: 1000
54
+ ```
55
+
56
+ #### Claude
57
+ ```ruby
58
+ provider :claude,
59
+ api_key: ENV["CLAUDE_API_KEY"],
60
+ model: "claude-3-sonnet-20240229"
61
+ ```
62
+
63
+ #### N8N Webhook
64
+ ```ruby
65
+ provider :n8n,
66
+ webhook_url: "https://your-n8n-instance.com/webhook/your-webhook-id",
67
+ response_path: "output"
68
+ ```
69
+
70
+ #### Custom HTTP
71
+ ```ruby
72
+ provider :http,
73
+ url: "https://api.example.com/generate",
74
+ method: :post,
75
+ headers: { "Authorization" => "Bearer #{ENV['API_KEY']}" },
76
+ body_template: { prompt: "{{prompt}}", model_name: "custom-model" },
77
+ response_path: "result.text"
78
+ ```
79
+
80
+ ### Configuration
81
+
82
+ Configure global defaults:
83
+
84
+ ```ruby
85
+ # config/initializers/contai.rb
86
+ Contai.configure do |config|
87
+ config.default_provider = :openai
88
+ config.default_template = "Generate content based on: {{prompt}}"
89
+ config.timeout = 30
90
+ end
91
+ ```
92
+
93
+ ### Routes
94
+
95
+ Add to your routes file:
96
+
97
+ ```ruby
98
+ # config/routes.rb
99
+ Rails.application.routes.draw do
100
+ post '/contai/generate', to: 'contai/generations#create', as: 'contai_generation'
101
+ end
102
+ ```
103
+ ## Error Handling
104
+
105
+ The gem provides comprehensive error handling:
106
+
107
+ ```ruby
108
+ article = Article.new(title: "Test")
109
+ result = article.generate_ai_content!
110
+
111
+ unless result
112
+ puts article.errors.full_messages
113
+ # => ["AI generation failed: API error"]
114
+ end
115
+ ```
116
+
117
+ ## Testing
118
+
119
+ Test your models with Contai:
120
+
121
+ ```ruby
122
+ # spec/models/article_spec.rb
123
+ RSpec.describe Article do
124
+ describe '#generate_ai_content!' do
125
+ let(:article) { create(:article, title: "Test", description: "Test desc") }
126
+
127
+ before do
128
+ allow_any_instance_of(Contai::Providers::OpenAI).to receive(:generate)
129
+ .and_return(double(success?: true, content: "Generated content"))
130
+ end
131
+
132
+ it 'generates content' do
133
+ expect { article.generate_ai_content! }.to change { article.body }
134
+ .from(nil).to("Generated content")
135
+ end
136
+ end
137
+ end
138
+ ```
139
+
140
+ ## Development
141
+
142
+ After checking out the repo, run:
143
+
144
+ ```bash
145
+ bundle install
146
+ rake spec
147
+ ```
148
+
149
+ ## Contributing
150
+
151
+ 1. Fork it
152
+ 2. Create your feature branch (`git checkout -b my-new-feature`)
153
+ 3. Commit your changes (`git commit -am 'Add some feature'`)
154
+ 4. Push to the branch (`git push origin my-new-feature`)
155
+ 5. Create new Pull Request
156
+
157
+ ## License
158
+
159
+ The gem is available as open source under the terms of the [CAPAA License](https://capaal.com).
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ require "rubocop/rake_task"
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ task default: %i[spec rubocop]
@@ -0,0 +1,25 @@
1
+ module Contai
2
+ class GenerationsController < ApplicationController
3
+ def create
4
+ model_class = params[:model].constantize
5
+ record = model_class.find(params[:id])
6
+
7
+ if record.generate_ai_content!
8
+ render json: {
9
+ success: true,
10
+ content: record.send(record.class.contai_config[:output_field])
11
+ }
12
+ else
13
+ render json: {
14
+ success: false,
15
+ errors: record.errors.full_messages
16
+ }, status: 422
17
+ end
18
+ rescue => e
19
+ render json: {
20
+ success: false,
21
+ error: e.message
22
+ }, status: 500
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,39 @@
1
+ module ContaiHelper
2
+ def contai_button(record, text: "Contai!", css_class: "btn btn-secondary contai-btn")
3
+ return unless record.class.respond_to?(:contai_config) && record.class.contai_config.any?
4
+
5
+ button_to text, contai_generation_path,
6
+ params: { model: record.class.name, id: record.id },
7
+ method: :post,
8
+ remote: true,
9
+ class: css_class,
10
+ data: {
11
+ contai_target: record.class.contai_config[:output_field],
12
+ contai_record_id: record.id,
13
+ contai_model: record.class.name
14
+ }
15
+ end
16
+
17
+ def contai_field_with_button(form, field, options = {})
18
+ record = form.object
19
+ return form.text_area(field, options) unless record.class.respond_to?(:contai_config)
20
+
21
+ config = record.class.contai_config
22
+ return form.text_area(field, options) unless config[:output_field] == field
23
+
24
+ content_tag :div, class: "contai-field-wrapper" do
25
+ form.text_area(field, options.merge(
26
+ data: { contai_target: "output" }
27
+ )) +
28
+ content_tag(:button, "Contai!",
29
+ type: "button",
30
+ class: "btn btn-secondary contai-generate-btn",
31
+ data: {
32
+ controller: "contai",
33
+ contai_model_value: record.class.name,
34
+ contai_record_id_value: record.id,
35
+ contai_output_target: field
36
+ })
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,7 @@
1
+ class ContaiGenerationJob < ApplicationJob
2
+ queue_as :default
3
+
4
+ def perform(record)
5
+ record.send(:perform_generation)
6
+ end
7
+ end
data/config/routes.rb ADDED
@@ -0,0 +1,3 @@
1
+ # Rails.application.routes.draw do
2
+ # post '/contai/generate', to: 'contai/generations#create', as: 'contai_generation'
3
+ # end
data/contai.gemspec ADDED
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/contai/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "contai"
7
+ spec.version = Contai::VERSION
8
+ spec.authors = ["Panasenkov A."]
9
+ spec.email = ["apanasenkov@capaa.ru"]
10
+
11
+ spec.summary = "Gem for generating AI content for rails models"
12
+ spec.description = "A Ruby on Rails gem that provides seamless integration with AI services to automatically generate content for your Rails models. It offers a simple interface to enhance your models with AI-generated content, supporting various content types and customization options."
13
+ spec.homepage = "https://github.com/capaas/contai"
14
+ spec.license = "Unlicense"
15
+ spec.required_ruby_version = ">= 3.0.0"
16
+
17
+ spec.metadata["allowed_push_host"] = "https://rubygems.org"
18
+
19
+ spec.metadata["homepage_uri"] = spec.homepage
20
+ spec.metadata["source_code_uri"] = "https://github.com/capaas/contai"
21
+ spec.metadata["changelog_uri"] = "https://github.com/capaas/contai/blob/main/CHANGELOG.md"
22
+
23
+ spec.files = Dir.chdir(__dir__) do
24
+ `git ls-files -z`.split("\x0").reject do |f|
25
+ (File.expand_path(f) == __FILE__) ||
26
+ f.start_with?(*%w[bin/ test/ spec/ features/ .git appveyor Gemfile])
27
+ end
28
+ end
29
+ spec.bindir = "exe"
30
+ spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
31
+ spec.require_paths = ["lib"]
32
+
33
+ spec.add_dependency "rails", "~> 6.0"
34
+ spec.add_dependency "httparty", "~> 0.20"
35
+
36
+ spec.add_development_dependency "rspec", "~> 3.0"
37
+ spec.add_development_dependency "sqlite3", "~> 1.4"
38
+ end
data/docs/usage.md ADDED
@@ -0,0 +1,70 @@
1
+ # Contai Gem - Полное руководство по использованию
2
+
3
+ ## Быстрый старт
4
+
5
+ ### 1. Установка
6
+
7
+ ```ruby
8
+ # Gemfile
9
+ gem 'contai'
10
+ ```
11
+
12
+ ```bash
13
+ bundle install
14
+ rails generate contai:install
15
+ ```
16
+
17
+ ### 2. Настройка модели
18
+
19
+ ```ruby
20
+ # app/models/article.rb
21
+ class Article < ApplicationRecord
22
+ include Contai::Generatable
23
+
24
+ contai do
25
+ prompt_from :title, :description
26
+ output_to :body
27
+ provider :openai, api_key: ENV['OPENAI_API_KEY']
28
+ template "Напиши подробную статью на тему: {{title}}. Описание: {{description}}"
29
+ end
30
+ end
31
+ ```
32
+
33
+ ### 3. Использование в контроллере
34
+
35
+ ```ruby
36
+ # app/controllers/articles_controller.rb
37
+ class ArticlesController < ApplicationController
38
+ def create
39
+ @article = Article.new(article_params)
40
+
41
+ if @article.save
42
+ # Синхронная генерация
43
+ @article.generate_ai_content!
44
+
45
+ # Или асинхронная
46
+ # @article.generate_ai_content!(async: true)
47
+
48
+ redirect_to @article
49
+ else
50
+ render :new
51
+ end
52
+ end
53
+
54
+ def generate_content
55
+ @article = Article.find(params[:id])
56
+
57
+ if @article.generate_ai_content!
58
+ redirect_to @article, notice: 'Контент успешно сгенерирован!'
59
+ else
60
+ redirect_to @article, alert: 'Ошибка генерации контента'
61
+ end
62
+ end
63
+
64
+ private
65
+
66
+ def article_params
67
+ params.require(:article).permit(:title, :description)
68
+ end
69
+ end
70
+ ```
@@ -0,0 +1,11 @@
1
+ module Contai
2
+ class Configuration
3
+ attr_accessor :default_provider, :default_template, :timeout
4
+
5
+ def initialize
6
+ @default_provider = :http
7
+ @default_template = "Generate content based on: {{prompt}}"
8
+ @timeout = 30
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,9 @@
1
+ module Contai
2
+ class Engine < ::Rails::Engine
3
+ isolate_namespace Contai
4
+
5
+ initializer "contai.assets" do |app|
6
+ app.config.assets.precompile += %w[contai.js contai.css]
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,102 @@
1
+ module Contai
2
+ module Generatable
3
+ extend ActiveSupport::Concern
4
+
5
+ included do
6
+ class_attribute :contai_config, default: {}
7
+ end
8
+
9
+ class_methods do
10
+ def contai(&block)
11
+ config = ContaiConfig.new
12
+ config.instance_eval(&block) if block_given?
13
+ self.contai_config = config.to_h
14
+ end
15
+ end
16
+
17
+ def generate_ai_content!(async: false)
18
+ if async
19
+ ContaiGenerationJob.perform_later(self)
20
+ else
21
+ perform_generation
22
+ end
23
+ end
24
+
25
+ private
26
+
27
+ def perform_generation
28
+ config = self.class.contai_config
29
+ return false if config.empty?
30
+
31
+ prompt = build_prompt(config)
32
+ provider = Providers.build(config[:provider], config[:provider_options])
33
+
34
+ result = provider.generate(prompt)
35
+
36
+ if result.success?
37
+ update!(config[:output_field] => result.content)
38
+ true
39
+ else
40
+ errors.add(:base, "AI generation failed: #{result.error}")
41
+ false
42
+ end
43
+ rescue => e
44
+ errors.add(:base, "AI generation error: #{e.message}")
45
+ false
46
+ end
47
+
48
+ def build_prompt(config)
49
+ template = config[:template] || Contai.configuration.default_template
50
+ data = {}
51
+
52
+ config[:prompt_fields].each do |field|
53
+ data[field] = send(field)
54
+ end
55
+
56
+ # Simple template substitution
57
+ template.gsub(/\{\{(\w+)\}\}/) do |match|
58
+ field = $1.to_sym
59
+ data[field] || match
60
+ end
61
+ end
62
+ end
63
+
64
+ class ContaiConfig
65
+ attr_accessor :prompt_fields, :output_field, :provider_type, :provider_options, :template
66
+
67
+ def initialize
68
+ @prompt_fields = [:prompt]
69
+ @output_field = :body
70
+ @provider_type = :http
71
+ @provider_options = {}
72
+ @template = nil
73
+ end
74
+
75
+ def prompt_from(*fields)
76
+ @prompt_fields = fields
77
+ end
78
+
79
+ def output_to(field)
80
+ @output_field = field
81
+ end
82
+
83
+ def provider(type, options = {})
84
+ @provider_type = type
85
+ @provider_options = options
86
+ end
87
+
88
+ def template(tmpl)
89
+ @template = tmpl
90
+ end
91
+
92
+ def to_h
93
+ {
94
+ prompt_fields: @prompt_fields,
95
+ output_field: @output_field,
96
+ provider: @provider_type,
97
+ provider_options: @provider_options,
98
+ template: @template
99
+ }
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,37 @@
1
+ module Contai
2
+ module Providers
3
+ class Base
4
+ def initialize(options = {})
5
+ @options = options
6
+ end
7
+
8
+ def generate(prompt)
9
+ raise NotImplementedError, "Subclasses must implement #generate"
10
+ end
11
+
12
+ protected
13
+
14
+ def success_result(content)
15
+ Result.new(success: true, content: content)
16
+ end
17
+
18
+ def error_result(error)
19
+ Result.new(success: false, error: error)
20
+ end
21
+
22
+ class Result
23
+ attr_reader :content, :error
24
+
25
+ def initialize(success:, content: nil, error: nil)
26
+ @success = success
27
+ @content = content
28
+ @error = error
29
+ end
30
+
31
+ def success?
32
+ @success
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,38 @@
1
+ require "httparty"
2
+
3
+ module Contai
4
+ module Providers
5
+ class Claude < Base
6
+ include HTTParty
7
+
8
+ base_uri "https://api.anthropic.com/v1"
9
+
10
+ def generate(prompt)
11
+ response = self.class.post("/messages",
12
+ headers: {
13
+ "x-api-key" => @options[:api_key],
14
+ "Content-Type" => "application/json",
15
+ "anthropic-version" => "2023-06-01"
16
+ },
17
+ body: {
18
+ model: @options[:model] || "claude-3-sonnet-20240229",
19
+ max_tokens: @options[:max_tokens] || 1000,
20
+ messages: [
21
+ { role: "user", content: prompt }
22
+ ]
23
+ }.to_json,
24
+ timeout: @options[:timeout] || Contai.configuration.timeout
25
+ )
26
+
27
+ if response.success?
28
+ content = response.parsed_response.dig("content", 0, "text")
29
+ success_result(content)
30
+ else
31
+ error_result("Claude API error: #{response.code} #{response.message}")
32
+ end
33
+ rescue => e
34
+ error_result(e.message)
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,57 @@
1
+ require "httparty"
2
+
3
+ module Contai
4
+ module Providers
5
+ class HTTP < Base
6
+ include HTTParty
7
+
8
+ def generate(prompt)
9
+ url = @options[:url] || raise(ArgumentError, "HTTP provider requires :url option")
10
+ method = (@options[:method] || :post).to_s.downcase
11
+
12
+ request_options = {
13
+ headers: @options[:headers] || { "Content-Type" => "application/json" },
14
+ timeout: @options[:timeout] || Contai.configuration.timeout
15
+ }
16
+
17
+ body_data = @options[:body_template] || { prompt: prompt }
18
+ if body_data.is_a?(Hash)
19
+ body_data = body_data.transform_values { |v| v.is_a?(String) ? v.gsub("{{prompt}}", prompt) : v }
20
+ request_options[:body] = body_data.to_json
21
+ end
22
+
23
+ response = case method
24
+ when "get"
25
+ self.class.get(url, request_options)
26
+ when "post"
27
+ self.class.post(url, request_options)
28
+ when "put"
29
+ self.class.put(url, request_options)
30
+ else
31
+ raise ArgumentError, "Unsupported HTTP method: #{method}"
32
+ end
33
+
34
+ if response.success?
35
+ content = extract_content(response, @options[:response_path])
36
+ success_result(content)
37
+ else
38
+ error_result("HTTP API error: #{response.code} #{response.message}")
39
+ end
40
+ rescue => e
41
+ error_result(e.message)
42
+ end
43
+
44
+ private
45
+
46
+ def extract_content(response, path)
47
+ return response.parsed_response.to_s unless path
48
+
49
+ content = response.parsed_response
50
+ path.split(".").each do |key|
51
+ content = content[key] if content.respond_to?(:[])
52
+ end
53
+ content.to_s
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,17 @@
1
+ module Contai
2
+ module Providers
3
+ class N8N < HTTP
4
+ def initialize(options = {})
5
+ webhook_url = options[:webhook_url] || raise(ArgumentError, "N8N provider requires :webhook_url")
6
+
7
+ super({
8
+ url: webhook_url,
9
+ method: :post,
10
+ headers: { "Content-Type" => "application/json" },
11
+ body_template: { prompt: "{{prompt}}" },
12
+ response_path: options[:response_path] || "output"
13
+ }.merge(options))
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,37 @@
1
+ require "httparty"
2
+
3
+ module Contai
4
+ module Providers
5
+ class OpenAI < Base
6
+ include HTTParty
7
+
8
+ base_uri "https://api.openai.com/v1"
9
+
10
+ def generate(prompt)
11
+ response = self.class.post("/chat/completions",
12
+ headers: {
13
+ "Authorization" => "Bearer #{@options[:api_key]}",
14
+ "Content-Type" => "application/json"
15
+ },
16
+ body: {
17
+ model: @options[:model] || "gpt-3.5-turbo",
18
+ messages: [
19
+ { role: "user", content: prompt }
20
+ ],
21
+ max_tokens: @options[:max_tokens] || 1000
22
+ }.to_json,
23
+ timeout: @options[:timeout] || Contai.configuration.timeout
24
+ )
25
+
26
+ if response.success?
27
+ content = response.parsed_response.dig("choices", 0, "message", "content")
28
+ success_result(content)
29
+ else
30
+ error_result("OpenAI API error: #{response.code} #{response.message}")
31
+ end
32
+ rescue => e
33
+ error_result(e.message)
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,24 @@
1
+ require "contai/providers/base"
2
+ require "contai/providers/openai"
3
+ require "contai/providers/claude"
4
+ require "contai/providers/http"
5
+ require "contai/providers/n8n"
6
+
7
+ module Contai
8
+ module Providers
9
+ def self.build(type, options = {})
10
+ case type.to_sym
11
+ when :openai
12
+ OpenAI.new(options)
13
+ when :claude
14
+ Claude.new(options)
15
+ when :n8n
16
+ N8N.new(options)
17
+ when :http
18
+ HTTP.new(options)
19
+ else
20
+ raise ArgumentError, "Unknown provider: #{type}"
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Contai
4
+ VERSION = "0.1.0"
5
+ end
data/lib/contai.rb ADDED
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "contai/version"
4
+ require "contai/configuration"
5
+ require "contai/generatable"
6
+ require "contai/providers"
7
+ require "contai/engine" if defined?(Rails)
8
+
9
+ module Contai
10
+ class << self
11
+ attr_accessor :configuration
12
+ end
13
+
14
+ def self.configuration
15
+ @configuration ||= Configuration.new
16
+ end
17
+
18
+ def self.configure
19
+ yield(configuration)
20
+ end
21
+ end
@@ -0,0 +1,68 @@
1
+ module Contai
2
+ module Generators
3
+ class InstallGenerator < Rails::Generators::Base
4
+ source_root File.expand_path('templates', __dir__)
5
+
6
+ desc "Install Contai into your Rails application"
7
+
8
+ def create_initializer
9
+ template 'initializer.rb', 'config/initializers/contai.rb'
10
+ end
11
+
12
+ def add_routes
13
+ route 'post "/contai/generate", to: "contai/generations#create", as: "contai_generation"'
14
+ end
15
+
16
+ def create_stimulus_controller
17
+ if stimulus_detected?
18
+ template 'contai_controller.js', 'app/javascript/controllers/contai_controller.js'
19
+
20
+ say "Stimulus controller created. Make sure to register it in your application:", :green
21
+ say "// app/javascript/controllers/application.js"
22
+ say 'import ContaiController from "./contai_controller"'
23
+ say 'application.register("contai", ContaiController)'
24
+ end
25
+ end
26
+
27
+ def copy_assets
28
+ template 'contai.js', 'app/assets/javascripts/contai.js'
29
+ template 'contai.css', 'app/assets/stylesheets/contai.css'
30
+ end
31
+
32
+ def create_job
33
+ template 'contai_generation_job.rb', 'app/jobs/contai_generation_job.rb'
34
+ end
35
+
36
+ def show_instructions
37
+ say "\n" + "="*60
38
+ say "Contai has been installed successfully!", :green
39
+ say "="*60
40
+ say "\nNext steps:"
41
+ say "1. Configure your AI providers in config/initializers/contai.rb"
42
+ say "2. Add 'include Contai::Generatable' to your models"
43
+ say "3. Configure your models with the contai DSL"
44
+ say "4. Use contai_button or contai_field_with_button helpers in your views"
45
+ say "\nExample model configuration:"
46
+ say <<~EXAMPLE
47
+ class Article < ApplicationRecord
48
+ include Contai::Generatable
49
+
50
+ contai do
51
+ prompt_from :title, :description
52
+ output_to :body
53
+ provider :openai, api_key: ENV['OPENAI_API_KEY']
54
+ template "Write about: {{title}} - {{description}}"
55
+ end
56
+ end
57
+ EXAMPLE
58
+ end
59
+
60
+ private
61
+
62
+ def stimulus_detected?
63
+ File.exist?(Rails.root.join('app/javascript/controllers/application.js'))
64
+ end
65
+ end
66
+ end
67
+ end
68
+
@@ -0,0 +1,55 @@
1
+ module Contai
2
+ module Generators
3
+ class ModelGenerator < Rails::Generators::NamedBase
4
+ source_root File.expand_path('templates', __dir__)
5
+
6
+ desc "Add Contai configuration to an existing model"
7
+
8
+ class_option :prompt_fields, type: :array, default: ['title'],
9
+ desc: "Fields to use for prompt generation"
10
+ class_option :output_field, type: :string, default: 'body',
11
+ desc: "Field to store generated content"
12
+ class_option :provider, type: :string, default: 'openai',
13
+ desc: "AI provider to use"
14
+ class_option :template, type: :string,
15
+ desc: "Custom template for prompt generation"
16
+
17
+ def add_contai_to_model
18
+ model_file = "app/models/#{file_name}.rb"
19
+
20
+ unless File.exist?(model_file)
21
+ say "Model #{class_name} not found at #{model_file}", :red
22
+ return
23
+ end
24
+
25
+ inject_into_class model_file, class_name do
26
+ contai_configuration
27
+ end
28
+
29
+ say "Added Contai configuration to #{class_name}", :green
30
+ end
31
+
32
+ private
33
+
34
+ def contai_configuration
35
+ prompt_fields = options[:prompt_fields].map(&:to_sym)
36
+ output_field = options[:output_field].to_sym
37
+ provider = options[:provider].to_sym
38
+ template = options[:template]
39
+
40
+ config = []
41
+ config << " include Contai::Generatable"
42
+ config << ""
43
+ config << " contai do"
44
+ config << " prompt_from #{prompt_fields.map(&:inspect).join(', ')}"
45
+ config << " output_to :#{output_field}"
46
+ config << " provider :#{provider}"
47
+ config << " template \"#{template}\"" if template
48
+ config << " end"
49
+ config << ""
50
+
51
+ config.join("\n")
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,53 @@
1
+ .contai-field-wrapper {
2
+ position: relative;
3
+ }
4
+
5
+ .contai-generate-btn {
6
+ margin-top: 5px;
7
+ font-size: 0.875rem;
8
+ }
9
+
10
+ .contai-btn {
11
+ background-color: #6366f1;
12
+ border-color: #6366f1;
13
+ color: white;
14
+ padding: 0.375rem 0.75rem;
15
+ border-radius: 0.25rem;
16
+ border: 1px solid transparent;
17
+ font-size: 0.875rem;
18
+ line-height: 1.5;
19
+ cursor: pointer;
20
+ text-decoration: none;
21
+ display: inline-block;
22
+ text-align: center;
23
+ vertical-align: middle;
24
+ user-select: none;
25
+ transition: color 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
26
+ }
27
+
28
+ .contai-btn:hover {
29
+ background-color: #4f46e5;
30
+ border-color: #4f46e5;
31
+ color: white;
32
+ }
33
+
34
+ .contai-btn:disabled {
35
+ opacity: 0.6;
36
+ cursor: not-allowed;
37
+ }
38
+
39
+ .contai-notification {
40
+ animation: slideIn 0.3s ease-out;
41
+ box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
42
+ }
43
+
44
+ @keyframes slideIn {
45
+ from {
46
+ transform: translateX(100%);
47
+ opacity: 0;
48
+ }
49
+ to {
50
+ transform: translateX(0);
51
+ opacity: 1;
52
+ }
53
+ }
@@ -0,0 +1,82 @@
1
+ (() => {
2
+ 'use strict';
3
+
4
+ document.addEventListener('click', (e) => {
5
+ const button = e.target.closest('[data-contai-model]');
6
+ if (!button) return;
7
+
8
+ e.preventDefault();
9
+
10
+ const model = button.dataset.contaiModel;
11
+ const recordId = button.dataset.contaiRecordId;
12
+ const outputTarget = button.dataset.contaiOutputTarget;
13
+
14
+ generateContaiContent(button, model, recordId, outputTarget);
15
+ });
16
+
17
+ function generateContaiContent(button, model, recordId, outputTarget) {
18
+ const originalText = button.textContent;
19
+
20
+ button.disabled = true;
21
+ button.textContent = 'Generating...';
22
+
23
+ fetch('/contai/generate', {
24
+ method: 'POST',
25
+ headers: {
26
+ 'Content-Type': 'application/json',
27
+ 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content')
28
+ },
29
+ body: JSON.stringify({
30
+ model: model,
31
+ id: recordId
32
+ })
33
+ })
34
+ .then(response => response.json())
35
+ .then(data => {
36
+ if (data.success) {
37
+ updateOutput(outputTarget, data.content);
38
+ showNotification('Content generated successfully!', 'success');
39
+ } else {
40
+ showNotification('Error: ' + (data.errors || data.error), 'error');
41
+ }
42
+ })
43
+ .catch(() => {
44
+ showNotification('Network error occurred', 'error');
45
+ })
46
+ .finally(() => {
47
+ button.disabled = false;
48
+ button.textContent = originalText;
49
+ });
50
+ }
51
+
52
+ function updateOutput(fieldName, content) {
53
+ const field = document.querySelector(`[name*="${fieldName}"]`);
54
+ if (field) {
55
+ field.value = content;
56
+ field.dispatchEvent(new Event('input'));
57
+ }
58
+ }
59
+
60
+ function showNotification(message, type) {
61
+ const notification = document.createElement('div');
62
+ notification.className = `contai-notification contai-${type}`;
63
+ notification.textContent = message;
64
+
65
+ Object.assign(notification.style, {
66
+ position: 'fixed',
67
+ top: '20px',
68
+ right: '20px',
69
+ padding: '10px 15px',
70
+ borderRadius: '4px',
71
+ color: 'white',
72
+ zIndex: '1000',
73
+ backgroundColor: type === 'success' ? '#28a745' : '#dc3545'
74
+ });
75
+
76
+ document.body.appendChild(notification);
77
+
78
+ setTimeout(() => {
79
+ notification.remove();
80
+ }, 3000);
81
+ }
82
+ })();
@@ -0,0 +1,109 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["output"]
5
+ static values = {
6
+ model: String,
7
+ recordId: Number,
8
+ outputTarget: String
9
+ }
10
+
11
+ connect() {
12
+ this.setupButton()
13
+ }
14
+
15
+ setupButton() {
16
+ const button = this.element.querySelector('.contai-generate-btn')
17
+ if (button) {
18
+ button.addEventListener('click', this.generate.bind(this))
19
+ }
20
+ }
21
+
22
+ async generate(event) {
23
+ event.preventDefault()
24
+
25
+ const button = event.target
26
+ const originalText = button.textContent
27
+
28
+ try {
29
+ button.disabled = true
30
+ button.textContent = 'Generating...'
31
+
32
+ const response = await fetch('/contai/generate', {
33
+ method: 'POST',
34
+ headers: {
35
+ 'Content-Type': 'application/json',
36
+ 'X-CSRF-Token': this.getCSRFToken()
37
+ },
38
+ body: JSON.stringify({
39
+ model: this.modelValue,
40
+ id: this.recordIdValue
41
+ })
42
+ })
43
+
44
+ const data = await response.json()
45
+
46
+ if (data.success) {
47
+ this.updateOutput(data.content)
48
+ this.showSuccess()
49
+ } else {
50
+ this.showError(data.errors || [data.error])
51
+ }
52
+ } catch (error) {
53
+ this.showError(['Network error occurred'])
54
+ } finally {
55
+ button.disabled = false
56
+ button.textContent = originalText
57
+ }
58
+ }
59
+
60
+ updateOutput(content) {
61
+ if (this.hasOutputTarget) {
62
+ this.outputTarget.value = content
63
+ this.outputTarget.dispatchEvent(new Event('input', { bubbles: true }))
64
+ } else {
65
+ const fieldName = this.outputTargetValue || 'body'
66
+ const field = this.element.querySelector(`[name*="${fieldName}"]`)
67
+ if (field) {
68
+ field.value = content
69
+ field.dispatchEvent(new Event('input', { bubbles: true }))
70
+ }
71
+ }
72
+ }
73
+
74
+ showSuccess() {
75
+ this.showNotification('Content generated successfully!', 'success')
76
+ }
77
+
78
+ showError(errors) {
79
+ const message = Array.isArray(errors) ? errors.join(', ') : errors
80
+ this.showNotification(`Error: ${message}`, 'error')
81
+ }
82
+
83
+ showNotification(message, type) {
84
+ const notification = document.createElement('div')
85
+ notification.className = `contai-notification contai-${type}`
86
+ notification.textContent = message
87
+ notification.style.cssText = `
88
+ position: fixed;
89
+ top: 20px;
90
+ right: 20px;
91
+ padding: 10px 15px;
92
+ border-radius: 4px;
93
+ color: white;
94
+ z-index: 1000;
95
+ background-color: ${type === 'success' ? '#28a745' : '#dc3545'};
96
+ `
97
+
98
+ document.body.appendChild(notification)
99
+
100
+ setTimeout(() => {
101
+ notification.remove()
102
+ }, 3000)
103
+ }
104
+
105
+ getCSRFToken() {
106
+ const token = document.querySelector('meta[name="csrf-token"]')
107
+ return token ? token.getAttribute('content') : ''
108
+ }
109
+ }
@@ -0,0 +1,7 @@
1
+ class ContaiGenerationJob < ApplicationJob
2
+ queue_as :default
3
+
4
+ def perform(record)
5
+ record.send(:perform_generation)
6
+ end
7
+ end
@@ -0,0 +1,34 @@
1
+ Contai.configure do |config|
2
+ # Default provider for all models (can be overridden per model)
3
+ config.default_provider = :http
4
+
5
+ # Default template for prompt generation
6
+ config.default_template = "Generate content based on: {{prompt}}"
7
+
8
+ # Request timeout in seconds
9
+ config.timeout = 30
10
+ end
11
+
12
+ # Configure your AI providers here:
13
+
14
+ # OpenAI Configuration
15
+ # Contai::Providers::OpenAI.configure do |config|
16
+ # config.api_key = ENV['OPENAI_API_KEY']
17
+ # config.default_model = 'gpt-3.5-turbo'
18
+ # config.default_max_tokens = 1000
19
+ # end
20
+
21
+ # Claude Configuration
22
+ # Contai::Providers::Claude.configure do |config|
23
+ # config.api_key = ENV['CLAUDE_API_KEY']
24
+ # config.default_model = 'claude-3-sonnet-20240229'
25
+ # end
26
+
27
+ # Custom HTTP API Configuration
28
+ # Contai::Providers::HTTP.configure do |config|
29
+ # config.default_url = ENV['AI_API_URL']
30
+ # config.default_headers = {
31
+ # 'Authorization' => "Bearer #{ENV['AI_API_KEY']}",
32
+ # 'Content-Type' => 'application/json'
33
+ # }
34
+ # end
data/sig/contai.rbs ADDED
@@ -0,0 +1,4 @@
1
+ module Contai
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
metadata ADDED
@@ -0,0 +1,136 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: contai
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Panasenkov A.
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2025-06-06 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: rails
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '6.0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '6.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: httparty
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '0.20'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '0.20'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rspec
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '3.0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '3.0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: sqlite3
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '1.4'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '1.4'
69
+ description: A Ruby on Rails gem that provides seamless integration with AI services
70
+ to automatically generate content for your Rails models. It offers a simple interface
71
+ to enhance your models with AI-generated content, supporting various content types
72
+ and customization options.
73
+ email:
74
+ - apanasenkov@capaa.ru
75
+ executables: []
76
+ extensions: []
77
+ extra_rdoc_files: []
78
+ files:
79
+ - ".rspec"
80
+ - ".rubocop.yml"
81
+ - CHANGELOG.md
82
+ - README.md
83
+ - Rakefile
84
+ - app/controllers/contai/generations_controller.rb
85
+ - app/helpers/contai_helper.rb
86
+ - app/jobs/contai_generation_job.rb
87
+ - config/routes.rb
88
+ - contai.gemspec
89
+ - docs/usage.md
90
+ - lib/contai.rb
91
+ - lib/contai/configuration.rb
92
+ - lib/contai/engine.rb
93
+ - lib/contai/generatabale.rb
94
+ - lib/contai/providers.rb
95
+ - lib/contai/providers/base.rb
96
+ - lib/contai/providers/claude.rb
97
+ - lib/contai/providers/http.rb
98
+ - lib/contai/providers/n8n.rb
99
+ - lib/contai/providers/openai.rb
100
+ - lib/contai/version.rb
101
+ - lib/generators/contai/install_generator.rb
102
+ - lib/generators/contai/model_generator.rb
103
+ - lib/generators/contai/templates/contai.css
104
+ - lib/generators/contai/templates/contai.js
105
+ - lib/generators/contai/templates/contai_controller.js
106
+ - lib/generators/contai/templates/contai_generation_job.rb
107
+ - lib/generators/contai/templates/initializer.rb
108
+ - sig/contai.rbs
109
+ homepage: https://github.com/capaas/contai
110
+ licenses:
111
+ - Unlicense
112
+ metadata:
113
+ allowed_push_host: https://rubygems.org
114
+ homepage_uri: https://github.com/capaas/contai
115
+ source_code_uri: https://github.com/capaas/contai
116
+ changelog_uri: https://github.com/capaas/contai/blob/main/CHANGELOG.md
117
+ post_install_message:
118
+ rdoc_options: []
119
+ require_paths:
120
+ - lib
121
+ required_ruby_version: !ruby/object:Gem::Requirement
122
+ requirements:
123
+ - - ">="
124
+ - !ruby/object:Gem::Version
125
+ version: 3.0.0
126
+ required_rubygems_version: !ruby/object:Gem::Requirement
127
+ requirements:
128
+ - - ">="
129
+ - !ruby/object:Gem::Version
130
+ version: '0'
131
+ requirements: []
132
+ rubygems_version: 3.2.22
133
+ signing_key:
134
+ specification_version: 4
135
+ summary: Gem for generating AI content for rails models
136
+ test_files: []