fosm-rails 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 +7 -0
- data/AGENTS.md +384 -0
- data/LICENSE +115 -0
- data/README.md +322 -0
- data/Rakefile +6 -0
- data/app/assets/stylesheets/fosm/rails/application.css +15 -0
- data/app/controllers/fosm/admin/agents_controller.rb +242 -0
- data/app/controllers/fosm/admin/apps_controller.rb +34 -0
- data/app/controllers/fosm/admin/base_controller.rb +15 -0
- data/app/controllers/fosm/admin/dashboard_controller.rb +25 -0
- data/app/controllers/fosm/admin/settings_controller.rb +44 -0
- data/app/controllers/fosm/admin/transitions_controller.rb +22 -0
- data/app/controllers/fosm/admin/webhooks_controller.rb +37 -0
- data/app/controllers/fosm/application_controller.rb +23 -0
- data/app/controllers/fosm/rails/application_controller.rb +6 -0
- data/app/helpers/fosm/application_helper.rb +19 -0
- data/app/helpers/fosm/rails/application_helper.rb +11 -0
- data/app/jobs/fosm/application_job.rb +6 -0
- data/app/jobs/fosm/rails/application_job.rb +6 -0
- data/app/jobs/fosm/webhook_delivery_job.rb +52 -0
- data/app/models/fosm/application_record.rb +6 -0
- data/app/models/fosm/rails/application_record.rb +7 -0
- data/app/models/fosm/transition_log.rb +27 -0
- data/app/models/fosm/webhook_subscription.rb +15 -0
- data/app/views/fosm/admin/agents/chat.html.erb +242 -0
- data/app/views/fosm/admin/agents/show.html.erb +166 -0
- data/app/views/fosm/admin/apps/show.html.erb +114 -0
- data/app/views/fosm/admin/dashboard/index.html.erb +82 -0
- data/app/views/fosm/admin/settings/show.html.erb +63 -0
- data/app/views/fosm/admin/transitions/index.html.erb +65 -0
- data/app/views/fosm/admin/webhooks/index.html.erb +51 -0
- data/app/views/fosm/admin/webhooks/new.html.erb +45 -0
- data/app/views/layouts/fosm/application.html.erb +41 -0
- data/app/views/layouts/fosm/rails/application.html.erb +17 -0
- data/config/routes.rb +17 -0
- data/db/migrate/20240101000001_create_fosm_transition_logs.rb +23 -0
- data/db/migrate/20240101000002_create_fosm_webhook_subscriptions.rb +16 -0
- data/lib/fosm/agent.rb +232 -0
- data/lib/fosm/configuration.rb +50 -0
- data/lib/fosm/engine.rb +133 -0
- data/lib/fosm/errors.rb +31 -0
- data/lib/fosm/lifecycle/definition.rb +103 -0
- data/lib/fosm/lifecycle/event_definition.rb +27 -0
- data/lib/fosm/lifecycle/guard_definition.rb +16 -0
- data/lib/fosm/lifecycle/side_effect_definition.rb +16 -0
- data/lib/fosm/lifecycle/state_definition.rb +18 -0
- data/lib/fosm/lifecycle.rb +173 -0
- data/lib/fosm/rails/engine.rb +9 -0
- data/lib/fosm/rails/version.rb +9 -0
- data/lib/fosm/rails.rb +9 -0
- data/lib/fosm/registry.rb +29 -0
- data/lib/fosm/version.rb +3 -0
- data/lib/fosm-rails.rb +40 -0
- data/lib/generators/fosm/app/app_generator.rb +106 -0
- data/lib/generators/fosm/app/templates/agent.rb.tt +26 -0
- data/lib/generators/fosm/app/templates/controller.rb.tt +56 -0
- data/lib/generators/fosm/app/templates/migration.rb.tt +14 -0
- data/lib/generators/fosm/app/templates/model.rb.tt +31 -0
- data/lib/generators/fosm/app/templates/views/_form.html.erb.tt +24 -0
- data/lib/generators/fosm/app/templates/views/index.html.erb.tt +37 -0
- data/lib/generators/fosm/app/templates/views/new.html.erb.tt +4 -0
- data/lib/generators/fosm/app/templates/views/show.html.erb.tt +57 -0
- data/lib/tasks/fosm/rails_tasks.rake +4 -0
- metadata +139 -0
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
module Fosm
|
|
2
|
+
module Admin
|
|
3
|
+
class SettingsController < BaseController
|
|
4
|
+
def show
|
|
5
|
+
@llm_providers = detect_llm_providers
|
|
6
|
+
@fosm_config = summarize_fosm_config
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
LLM_PROVIDERS = [
|
|
12
|
+
{ name: "Anthropic (Claude)", env_key: "ANTHROPIC_API_KEY", model_prefix: "anthropic/" },
|
|
13
|
+
{ name: "OpenAI", env_key: "OPENAI_API_KEY", model_prefix: "openai/" },
|
|
14
|
+
{ name: "Google (Gemini)", env_key: "GEMINI_API_KEY", model_prefix: "gemini/" },
|
|
15
|
+
{ name: "Cohere", env_key: "COHERE_API_KEY", model_prefix: "cohere/" },
|
|
16
|
+
{ name: "Mistral", env_key: "MISTRAL_API_KEY", model_prefix: "mistral/" },
|
|
17
|
+
].freeze
|
|
18
|
+
|
|
19
|
+
def detect_llm_providers
|
|
20
|
+
LLM_PROVIDERS.map do |provider|
|
|
21
|
+
value = ENV[provider[:env_key]]
|
|
22
|
+
{
|
|
23
|
+
name: provider[:name],
|
|
24
|
+
env_key: provider[:env_key],
|
|
25
|
+
configured: value.present?,
|
|
26
|
+
hint: value.present? ? "#{value.length} chars, starts with #{value[0..3]}…" : nil
|
|
27
|
+
}
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def summarize_fosm_config
|
|
32
|
+
cfg = Fosm.config
|
|
33
|
+
{
|
|
34
|
+
base_controller: cfg.base_controller,
|
|
35
|
+
admin_layout: cfg.admin_layout,
|
|
36
|
+
app_layout: cfg.app_layout,
|
|
37
|
+
admin_authorize: cfg.admin_authorize.inspect,
|
|
38
|
+
app_authorize: cfg.app_authorize.inspect,
|
|
39
|
+
current_user_method: cfg.current_user_method.inspect
|
|
40
|
+
}
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
module Fosm
|
|
2
|
+
module Admin
|
|
3
|
+
class TransitionsController < BaseController
|
|
4
|
+
def index
|
|
5
|
+
@transitions = Fosm::TransitionLog.recent
|
|
6
|
+
|
|
7
|
+
@transitions = @transitions.where(record_type: params[:model]) if params[:model].present?
|
|
8
|
+
@transitions = @transitions.where(event_name: params[:event]) if params[:event].present?
|
|
9
|
+
@transitions = @transitions.where(actor_type: "symbol", actor_label: "agent") if params[:actor] == "agent"
|
|
10
|
+
@transitions = @transitions.where.not(actor_type: "symbol") if params[:actor] == "human"
|
|
11
|
+
|
|
12
|
+
@per_page = 50
|
|
13
|
+
@current_page = [params[:page].to_i, 1].max
|
|
14
|
+
@total_count = @transitions.count
|
|
15
|
+
@total_pages = (@total_count / @per_page.to_f).ceil
|
|
16
|
+
@transitions = @transitions.limit(@per_page).offset((@current_page - 1) * @per_page)
|
|
17
|
+
|
|
18
|
+
@model_names = Fosm::Registry.model_classes.map(&:name).sort
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
module Fosm
|
|
2
|
+
module Admin
|
|
3
|
+
class WebhooksController < BaseController
|
|
4
|
+
def index
|
|
5
|
+
@webhooks = Fosm::WebhookSubscription.all.order(:model_class_name, :event_name)
|
|
6
|
+
@apps = Fosm::Registry.all
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def new
|
|
10
|
+
@webhook = Fosm::WebhookSubscription.new
|
|
11
|
+
@apps = Fosm::Registry.all
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def create
|
|
15
|
+
@webhook = Fosm::WebhookSubscription.new(webhook_params)
|
|
16
|
+
if @webhook.save
|
|
17
|
+
redirect_to fosm.admin_webhooks_path, notice: "Webhook created successfully."
|
|
18
|
+
else
|
|
19
|
+
@apps = Fosm::Registry.all
|
|
20
|
+
render :new, status: :unprocessable_entity
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def destroy
|
|
25
|
+
@webhook = Fosm::WebhookSubscription.find(params[:id])
|
|
26
|
+
@webhook.destroy
|
|
27
|
+
redirect_to fosm.admin_webhooks_path, notice: "Webhook removed."
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def webhook_params
|
|
33
|
+
params.require(:fosm_webhook_subscription).permit(:model_class_name, :event_name, :url, :active, :secret_token)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
module Fosm
|
|
2
|
+
class ApplicationController < Fosm.config.base_controller.constantize
|
|
3
|
+
protect_from_forgery with: :exception
|
|
4
|
+
|
|
5
|
+
# Call this in generated app controllers to use host app routes instead of
|
|
6
|
+
# the engine's isolated routes. FOSM apps define routes in the host app
|
|
7
|
+
# (config/routes/fosm.rb), so controllers need host app route context.
|
|
8
|
+
#
|
|
9
|
+
# `include url_helpers` triggers the module's `included` hook which calls
|
|
10
|
+
# `redefine_singleton_method(:_routes) { routes }` — this overrides the
|
|
11
|
+
# engine's _routes with the host app's routes for this controller.
|
|
12
|
+
def self.use_host_routes!
|
|
13
|
+
include ::Rails.application.routes.url_helpers
|
|
14
|
+
helper ::Rails.application.routes.url_helpers
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
def fosm_current_user
|
|
20
|
+
instance_exec(&Fosm.config.current_user_method)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
module Fosm
|
|
2
|
+
module ApplicationHelper
|
|
3
|
+
# Returns a human-friendly label for a FOSM state, suitable for display in views.
|
|
4
|
+
def fosm_state_badge(state)
|
|
5
|
+
content_tag(:span, state.to_s.humanize,
|
|
6
|
+
class: "text-xs font-medium px-2 py-0.5 rounded bg-gray-100 text-gray-700")
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
# Returns a human-friendly label for an actor stored in a TransitionLog.
|
|
10
|
+
def fosm_actor_label(transition)
|
|
11
|
+
if transition.by_agent?
|
|
12
|
+
content_tag(:span, "AI Agent",
|
|
13
|
+
class: "text-xs font-medium text-purple-600 bg-purple-50 px-2 py-0.5 rounded")
|
|
14
|
+
else
|
|
15
|
+
transition.actor_label || transition.actor_type || "—"
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# Legacy helper kept for backward compatibility.
|
|
2
|
+
# New code should use Fosm::ApplicationHelper directly.
|
|
3
|
+
require_relative "../application_helper"
|
|
4
|
+
|
|
5
|
+
module Fosm
|
|
6
|
+
module Rails
|
|
7
|
+
module ApplicationHelper
|
|
8
|
+
include Fosm::ApplicationHelper
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
module Fosm
|
|
2
|
+
# Delivers webhook payloads to configured endpoints when FOSM events fire.
|
|
3
|
+
# Runs asynchronously after the transition completes.
|
|
4
|
+
class WebhookDeliveryJob < ApplicationJob
|
|
5
|
+
queue_as :default
|
|
6
|
+
retry_on StandardError, wait: :polynomially_longer, attempts: 5
|
|
7
|
+
|
|
8
|
+
def perform(record_type:, record_id:, event_name:, from_state:, to_state:, metadata: {})
|
|
9
|
+
subscriptions = Fosm::WebhookSubscription.for_event(record_type, event_name)
|
|
10
|
+
return if subscriptions.none?
|
|
11
|
+
|
|
12
|
+
payload = {
|
|
13
|
+
event: event_name,
|
|
14
|
+
record_type: record_type,
|
|
15
|
+
record_id: record_id,
|
|
16
|
+
from_state: from_state,
|
|
17
|
+
to_state: to_state,
|
|
18
|
+
fired_at: Time.current.iso8601,
|
|
19
|
+
metadata: metadata
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
subscriptions.each do |subscription|
|
|
23
|
+
deliver(subscription, payload)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def deliver(subscription, payload)
|
|
30
|
+
uri = URI.parse(subscription.url)
|
|
31
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
32
|
+
http.use_ssl = uri.scheme == "https"
|
|
33
|
+
http.open_timeout = 5
|
|
34
|
+
http.read_timeout = 10
|
|
35
|
+
|
|
36
|
+
request = Net::HTTP::Post.new(uri.request_uri, {
|
|
37
|
+
"Content-Type" => "application/json",
|
|
38
|
+
"X-FOSM-Event" => payload[:event],
|
|
39
|
+
"X-FOSM-Record-Type" => payload[:record_type],
|
|
40
|
+
"User-Agent" => "fosm-rails/#{Fosm::VERSION}"
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
if subscription.secret_token.present?
|
|
44
|
+
signature = OpenSSL::HMAC.hexdigest("SHA256", subscription.secret_token, payload.to_json)
|
|
45
|
+
request["X-FOSM-Signature"] = "sha256=#{signature}"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
request.body = payload.to_json
|
|
49
|
+
http.request(request)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
module Fosm
|
|
2
|
+
# Immutable audit trail of every FOSM state transition.
|
|
3
|
+
# Records are never updated or deleted — this is an append-only log.
|
|
4
|
+
class TransitionLog < ApplicationRecord
|
|
5
|
+
self.table_name = "fosm_transition_logs"
|
|
6
|
+
|
|
7
|
+
# Immutability: prevent any updates or deletions
|
|
8
|
+
before_update { raise ActiveRecord::ReadOnlyRecord, "Fosm::TransitionLog is immutable" }
|
|
9
|
+
before_destroy { raise ActiveRecord::ReadOnlyRecord, "Fosm::TransitionLog records cannot be deleted" }
|
|
10
|
+
|
|
11
|
+
validates :record_type, :record_id, :event_name, :from_state, :to_state, presence: true
|
|
12
|
+
|
|
13
|
+
scope :recent, -> { order(created_at: :desc) }
|
|
14
|
+
scope :for_record, ->(type, id) { where(record_type: type, record_id: id.to_s) }
|
|
15
|
+
scope :for_app, ->(model_class) { where(record_type: model_class.name) }
|
|
16
|
+
scope :by_event, ->(event) { where(event_name: event.to_s) }
|
|
17
|
+
scope :by_actor_type, ->(type) { where(actor_type: type) }
|
|
18
|
+
|
|
19
|
+
def by_agent?
|
|
20
|
+
actor_type == "symbol" && actor_label == "agent"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def by_human?
|
|
24
|
+
!by_agent? && actor_id.present?
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
module Fosm
|
|
2
|
+
# Admin-configured webhooks that fire on specific FOSM transitions.
|
|
3
|
+
class WebhookSubscription < ApplicationRecord
|
|
4
|
+
self.table_name = "fosm_webhook_subscriptions"
|
|
5
|
+
|
|
6
|
+
validates :model_class_name, presence: true
|
|
7
|
+
validates :event_name, presence: true
|
|
8
|
+
validates :url, presence: true, format: { with: URI::DEFAULT_PARSER.make_regexp(%w[http https]) }
|
|
9
|
+
|
|
10
|
+
scope :active, -> { where(active: true) }
|
|
11
|
+
scope :for_event, ->(model_class, event) {
|
|
12
|
+
where(model_class_name: model_class.to_s, event_name: event.to_s).active
|
|
13
|
+
}
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
<div class="flex flex-col h-screen max-h-screen">
|
|
2
|
+
|
|
3
|
+
<!-- Header -->
|
|
4
|
+
<div class="flex items-center justify-between px-5 py-3 bg-white border-b border-gray-200 shrink-0">
|
|
5
|
+
<div class="flex items-center gap-3">
|
|
6
|
+
<%= link_to "←", fosm.agent_admin_app_path(@slug), class: "text-gray-400 hover:text-gray-700 text-lg" %>
|
|
7
|
+
<div>
|
|
8
|
+
<span class="font-semibold text-gray-900 text-sm"><%= @model_class.name.demodulize.humanize %> Agent Chat</span>
|
|
9
|
+
<span class="text-xs text-gray-400 ml-2">FOSM bounded autonomy · tools only</span>
|
|
10
|
+
</div>
|
|
11
|
+
</div>
|
|
12
|
+
<button id="new-chat-btn"
|
|
13
|
+
onclick="resetChat()"
|
|
14
|
+
class="text-xs border border-gray-200 text-gray-500 px-3 py-1.5 rounded hover:bg-gray-50">
|
|
15
|
+
New conversation
|
|
16
|
+
</button>
|
|
17
|
+
</div>
|
|
18
|
+
|
|
19
|
+
<!-- Messages -->
|
|
20
|
+
<div id="messages" class="flex-1 overflow-y-auto px-5 py-4 space-y-4 bg-gray-50">
|
|
21
|
+
|
|
22
|
+
<% if @history.empty? %>
|
|
23
|
+
<div id="empty-state" class="flex flex-col items-center justify-center h-full text-center text-gray-400 space-y-2 py-20">
|
|
24
|
+
<div class="text-4xl">🤖</div>
|
|
25
|
+
<p class="text-sm font-medium text-gray-600">Chat with <%= @model_class.name.demodulize.humanize %> Agent</p>
|
|
26
|
+
<p class="text-xs max-w-sm">
|
|
27
|
+
The agent can only act via the lifecycle tools — it cannot bypass guards or jump to terminal states directly.
|
|
28
|
+
</p>
|
|
29
|
+
<div class="mt-4 space-y-2 text-left">
|
|
30
|
+
<p class="text-xs text-gray-400 font-medium">Try asking:</p>
|
|
31
|
+
<% example_prompts = [
|
|
32
|
+
"List all #{@mn.pluralize} in draft state",
|
|
33
|
+
"What events are available for #{@mn} #1?",
|
|
34
|
+
"Cancel #{@mn} #2"
|
|
35
|
+
] %>
|
|
36
|
+
<% example_prompts.each do |prompt| %>
|
|
37
|
+
<button onclick="sendMessage(<%= prompt.to_json %>)"
|
|
38
|
+
class="block w-full text-left text-xs bg-white border border-gray-200 rounded-lg px-3 py-2 hover:border-gray-300 hover:bg-gray-50 text-gray-600">
|
|
39
|
+
<%= prompt %>
|
|
40
|
+
</button>
|
|
41
|
+
<% end %>
|
|
42
|
+
</div>
|
|
43
|
+
</div>
|
|
44
|
+
<% end %>
|
|
45
|
+
|
|
46
|
+
<% @history.each do |msg| %>
|
|
47
|
+
<% if msg[:role] == "user" || msg["role"] == "user" %>
|
|
48
|
+
<div class="flex justify-end">
|
|
49
|
+
<div class="bg-gray-800 text-white text-sm rounded-2xl rounded-tr-sm px-4 py-2.5 max-w-lg">
|
|
50
|
+
<%= (msg[:content] || msg["content"]) %>
|
|
51
|
+
</div>
|
|
52
|
+
</div>
|
|
53
|
+
<% else %>
|
|
54
|
+
<% content = msg[:content] || msg["content"] %>
|
|
55
|
+
<% steps = msg[:steps] || msg["steps"] || [] %>
|
|
56
|
+
<div class="flex flex-col gap-1 max-w-2xl">
|
|
57
|
+
<div class="bg-white border border-gray-200 rounded-2xl rounded-tl-sm px-4 py-3 text-sm text-gray-800 whitespace-pre-wrap"><%= content %></div>
|
|
58
|
+
<% if steps.any? %>
|
|
59
|
+
<details class="text-xs text-gray-400 cursor-pointer select-none ml-1">
|
|
60
|
+
<summary class="hover:text-gray-600"><%= steps.size %> step<%= steps.size == 1 ? "" : "s" %> · expand reasoning</summary>
|
|
61
|
+
<div class="mt-1 space-y-1 ml-2 border-l-2 border-gray-100 pl-3">
|
|
62
|
+
<% steps.each do |step| %>
|
|
63
|
+
<% step = step.transform_keys(&:to_s) %>
|
|
64
|
+
<div class="py-1">
|
|
65
|
+
<% if step["thought"].present? %>
|
|
66
|
+
<p class="text-gray-500 italic"><%= step["thought"].to_s.truncate(200) %></p>
|
|
67
|
+
<% end %>
|
|
68
|
+
<% if step["tool_calls"].present? %>
|
|
69
|
+
<% Array(step["tool_calls"]).each do |tc| %>
|
|
70
|
+
<% tc = tc.is_a?(Hash) ? tc.transform_keys(&:to_s) : {} %>
|
|
71
|
+
<p class="font-mono text-purple-600">
|
|
72
|
+
🔧 <%= tc["name"] %><% if tc["args"].present? %>(<%= tc["args"].map { |k,v| "#{k}: #{v.inspect}" }.join(", ") %><% end %>)
|
|
73
|
+
</p>
|
|
74
|
+
<% end %>
|
|
75
|
+
<% end %>
|
|
76
|
+
<% if step["observation"].present? %>
|
|
77
|
+
<p class="text-green-600 font-mono text-[10px] truncate"><%= step["observation"].to_s.truncate(150) %></p>
|
|
78
|
+
<% end %>
|
|
79
|
+
</div>
|
|
80
|
+
<% end %>
|
|
81
|
+
</div>
|
|
82
|
+
</details>
|
|
83
|
+
<% end %>
|
|
84
|
+
</div>
|
|
85
|
+
<% end %>
|
|
86
|
+
<% end %>
|
|
87
|
+
|
|
88
|
+
<div id="thinking" class="hidden flex gap-2 items-center text-sm text-gray-400">
|
|
89
|
+
<div class="flex gap-1">
|
|
90
|
+
<span class="animate-bounce" style="animation-delay:0ms">●</span>
|
|
91
|
+
<span class="animate-bounce" style="animation-delay:150ms">●</span>
|
|
92
|
+
<span class="animate-bounce" style="animation-delay:300ms">●</span>
|
|
93
|
+
</div>
|
|
94
|
+
<span>Agent is thinking…</span>
|
|
95
|
+
</div>
|
|
96
|
+
</div>
|
|
97
|
+
|
|
98
|
+
<!-- Input -->
|
|
99
|
+
<div class="shrink-0 bg-white border-t border-gray-200 px-4 py-3">
|
|
100
|
+
<div class="flex gap-2 items-end">
|
|
101
|
+
<textarea id="chat-input"
|
|
102
|
+
rows="1"
|
|
103
|
+
placeholder="Ask the agent something… (Enter to send, Shift+Enter for newline)"
|
|
104
|
+
class="flex-1 resize-none text-sm border border-gray-200 rounded-xl px-3 py-2.5 focus:outline-none focus:border-gray-400 max-h-32 overflow-y-auto"
|
|
105
|
+
onkeydown="handleKey(event)"
|
|
106
|
+
oninput="autoResize(this)"></textarea>
|
|
107
|
+
<button id="send-btn"
|
|
108
|
+
onclick="sendFromInput()"
|
|
109
|
+
class="text-sm bg-gray-900 text-white px-4 py-2.5 rounded-xl hover:bg-gray-700 shrink-0">
|
|
110
|
+
Send
|
|
111
|
+
</button>
|
|
112
|
+
</div>
|
|
113
|
+
<p class="text-[10px] text-gray-300 mt-1 ml-1">
|
|
114
|
+
Agent has access to: list, get, available_events, transition_history, and fire-event tools.
|
|
115
|
+
</p>
|
|
116
|
+
</div>
|
|
117
|
+
</div>
|
|
118
|
+
|
|
119
|
+
<script>
|
|
120
|
+
const CHAT_SEND_URL = '<%= fosm.agent_chat_send_admin_app_path(@slug) %>';
|
|
121
|
+
const CHAT_RESET_URL = '<%= fosm.agent_chat_reset_admin_app_path(@slug) %>';
|
|
122
|
+
const CSRF_TOKEN = document.querySelector('meta[name="csrf-token"]')?.content || '';
|
|
123
|
+
|
|
124
|
+
function scrollBottom() {
|
|
125
|
+
const el = document.getElementById('messages');
|
|
126
|
+
el.scrollTop = el.scrollHeight;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function appendMessage(role, content, steps) {
|
|
130
|
+
const wrap = document.getElementById('messages');
|
|
131
|
+
const empty = document.getElementById('empty-state');
|
|
132
|
+
if (empty) empty.remove();
|
|
133
|
+
|
|
134
|
+
const div = document.createElement('div');
|
|
135
|
+
|
|
136
|
+
if (role === 'user') {
|
|
137
|
+
div.className = 'flex justify-end';
|
|
138
|
+
div.innerHTML = `<div class="bg-gray-800 text-white text-sm rounded-2xl rounded-tr-sm px-4 py-2.5 max-w-lg">${escapeHtml(content)}</div>`;
|
|
139
|
+
} else {
|
|
140
|
+
div.className = 'flex flex-col gap-1 max-w-2xl';
|
|
141
|
+
let stepsHtml = '';
|
|
142
|
+
if (steps && steps.length > 0) {
|
|
143
|
+
const stepItems = steps.map(s => {
|
|
144
|
+
let parts = [];
|
|
145
|
+
if (s.thought) parts.push(`<p class="text-gray-500 italic">${escapeHtml(s.thought.substring(0, 200))}</p>`);
|
|
146
|
+
if (s.tool_calls) {
|
|
147
|
+
s.tool_calls.forEach(tc => {
|
|
148
|
+
const args = tc.args ? Object.entries(tc.args).map(([k,v]) => `${k}: ${JSON.stringify(v)}`).join(', ') : '';
|
|
149
|
+
parts.push(`<p class="font-mono text-purple-600">🔧 ${escapeHtml(tc.name)}(${escapeHtml(args)})</p>`);
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
if (s.observation) parts.push(`<p class="text-green-600 font-mono text-[10px] truncate">${escapeHtml(String(s.observation).substring(0, 150))}</p>`);
|
|
153
|
+
return `<div class="py-1">${parts.join('')}</div>`;
|
|
154
|
+
}).join('');
|
|
155
|
+
stepsHtml = `<details class="text-xs text-gray-400 cursor-pointer select-none ml-1">
|
|
156
|
+
<summary class="hover:text-gray-600">${steps.length} step${steps.length === 1 ? '' : 's'} · expand reasoning</summary>
|
|
157
|
+
<div class="mt-1 space-y-1 ml-2 border-l-2 border-gray-100 pl-3">${stepItems}</div>
|
|
158
|
+
</details>`;
|
|
159
|
+
}
|
|
160
|
+
div.innerHTML = `<div class="bg-white border border-gray-200 rounded-2xl rounded-tl-sm px-4 py-3 text-sm text-gray-800 whitespace-pre-wrap">${escapeHtml(content)}</div>${stepsHtml}`;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
wrap.insertBefore(div, document.getElementById('thinking'));
|
|
164
|
+
scrollBottom();
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function sendMessage(text) {
|
|
168
|
+
if (!text.trim()) return;
|
|
169
|
+
appendMessage('user', text);
|
|
170
|
+
|
|
171
|
+
const thinking = document.getElementById('thinking');
|
|
172
|
+
thinking.classList.remove('hidden');
|
|
173
|
+
document.getElementById('send-btn').disabled = true;
|
|
174
|
+
scrollBottom();
|
|
175
|
+
|
|
176
|
+
try {
|
|
177
|
+
const resp = await fetch(CHAT_SEND_URL, {
|
|
178
|
+
method: 'POST',
|
|
179
|
+
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': CSRF_TOKEN },
|
|
180
|
+
body: JSON.stringify({ message: text })
|
|
181
|
+
});
|
|
182
|
+
const data = await resp.json();
|
|
183
|
+
if (data.error) {
|
|
184
|
+
// Translate technical LLM errors into friendly messages
|
|
185
|
+
let friendly = data.error;
|
|
186
|
+
if (data.error.includes('trailing whitespace')) {
|
|
187
|
+
friendly = "I had trouble continuing from the previous message. I've reset my context — please try again.";
|
|
188
|
+
} else if (data.error.includes('anthropic_api_key') || data.error.includes('Missing configuration')) {
|
|
189
|
+
friendly = "No API key configured. Set ANTHROPIC_API_KEY (or another provider) and restart the server.";
|
|
190
|
+
} else if (data.error.includes('LLM API error')) {
|
|
191
|
+
friendly = "The LLM returned an error: " + data.error.replace(/.*LLM API error:\s*/, '');
|
|
192
|
+
}
|
|
193
|
+
appendMessage('agent', '⚠ ' + friendly, []);
|
|
194
|
+
} else {
|
|
195
|
+
appendMessage('agent', data.output || '(no response)', data.steps || []);
|
|
196
|
+
}
|
|
197
|
+
} catch(e) {
|
|
198
|
+
appendMessage('agent', '⚠ Something went wrong. Please try again.', []);
|
|
199
|
+
} finally {
|
|
200
|
+
thinking.classList.add('hidden');
|
|
201
|
+
document.getElementById('send-btn').disabled = false;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function sendFromInput() {
|
|
206
|
+
const input = document.getElementById('chat-input');
|
|
207
|
+
const text = input.value.trim();
|
|
208
|
+
if (!text) return;
|
|
209
|
+
input.value = '';
|
|
210
|
+
autoResize(input);
|
|
211
|
+
sendMessage(text);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async function resetChat() {
|
|
215
|
+
if (!confirm('Start a new conversation? This will clear the current context.')) return;
|
|
216
|
+
await fetch(CHAT_RESET_URL, {
|
|
217
|
+
method: 'DELETE',
|
|
218
|
+
headers: { 'X-CSRF-Token': CSRF_TOKEN }
|
|
219
|
+
});
|
|
220
|
+
window.location.reload();
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function handleKey(e) {
|
|
224
|
+
if (e.key === 'Enter' && !e.shiftKey) {
|
|
225
|
+
e.preventDefault();
|
|
226
|
+
sendFromInput();
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function autoResize(el) {
|
|
231
|
+
el.style.height = 'auto';
|
|
232
|
+
el.style.height = Math.min(el.scrollHeight, 128) + 'px';
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function escapeHtml(str) {
|
|
236
|
+
return String(str)
|
|
237
|
+
.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>')
|
|
238
|
+
.replace(/"/g,'"').replace(/'/g,''');
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
document.addEventListener('DOMContentLoaded', scrollBottom);
|
|
242
|
+
</script>
|