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.
Files changed (64) hide show
  1. checksums.yaml +7 -0
  2. data/AGENTS.md +384 -0
  3. data/LICENSE +115 -0
  4. data/README.md +322 -0
  5. data/Rakefile +6 -0
  6. data/app/assets/stylesheets/fosm/rails/application.css +15 -0
  7. data/app/controllers/fosm/admin/agents_controller.rb +242 -0
  8. data/app/controllers/fosm/admin/apps_controller.rb +34 -0
  9. data/app/controllers/fosm/admin/base_controller.rb +15 -0
  10. data/app/controllers/fosm/admin/dashboard_controller.rb +25 -0
  11. data/app/controllers/fosm/admin/settings_controller.rb +44 -0
  12. data/app/controllers/fosm/admin/transitions_controller.rb +22 -0
  13. data/app/controllers/fosm/admin/webhooks_controller.rb +37 -0
  14. data/app/controllers/fosm/application_controller.rb +23 -0
  15. data/app/controllers/fosm/rails/application_controller.rb +6 -0
  16. data/app/helpers/fosm/application_helper.rb +19 -0
  17. data/app/helpers/fosm/rails/application_helper.rb +11 -0
  18. data/app/jobs/fosm/application_job.rb +6 -0
  19. data/app/jobs/fosm/rails/application_job.rb +6 -0
  20. data/app/jobs/fosm/webhook_delivery_job.rb +52 -0
  21. data/app/models/fosm/application_record.rb +6 -0
  22. data/app/models/fosm/rails/application_record.rb +7 -0
  23. data/app/models/fosm/transition_log.rb +27 -0
  24. data/app/models/fosm/webhook_subscription.rb +15 -0
  25. data/app/views/fosm/admin/agents/chat.html.erb +242 -0
  26. data/app/views/fosm/admin/agents/show.html.erb +166 -0
  27. data/app/views/fosm/admin/apps/show.html.erb +114 -0
  28. data/app/views/fosm/admin/dashboard/index.html.erb +82 -0
  29. data/app/views/fosm/admin/settings/show.html.erb +63 -0
  30. data/app/views/fosm/admin/transitions/index.html.erb +65 -0
  31. data/app/views/fosm/admin/webhooks/index.html.erb +51 -0
  32. data/app/views/fosm/admin/webhooks/new.html.erb +45 -0
  33. data/app/views/layouts/fosm/application.html.erb +41 -0
  34. data/app/views/layouts/fosm/rails/application.html.erb +17 -0
  35. data/config/routes.rb +17 -0
  36. data/db/migrate/20240101000001_create_fosm_transition_logs.rb +23 -0
  37. data/db/migrate/20240101000002_create_fosm_webhook_subscriptions.rb +16 -0
  38. data/lib/fosm/agent.rb +232 -0
  39. data/lib/fosm/configuration.rb +50 -0
  40. data/lib/fosm/engine.rb +133 -0
  41. data/lib/fosm/errors.rb +31 -0
  42. data/lib/fosm/lifecycle/definition.rb +103 -0
  43. data/lib/fosm/lifecycle/event_definition.rb +27 -0
  44. data/lib/fosm/lifecycle/guard_definition.rb +16 -0
  45. data/lib/fosm/lifecycle/side_effect_definition.rb +16 -0
  46. data/lib/fosm/lifecycle/state_definition.rb +18 -0
  47. data/lib/fosm/lifecycle.rb +173 -0
  48. data/lib/fosm/rails/engine.rb +9 -0
  49. data/lib/fosm/rails/version.rb +9 -0
  50. data/lib/fosm/rails.rb +9 -0
  51. data/lib/fosm/registry.rb +29 -0
  52. data/lib/fosm/version.rb +3 -0
  53. data/lib/fosm-rails.rb +40 -0
  54. data/lib/generators/fosm/app/app_generator.rb +106 -0
  55. data/lib/generators/fosm/app/templates/agent.rb.tt +26 -0
  56. data/lib/generators/fosm/app/templates/controller.rb.tt +56 -0
  57. data/lib/generators/fosm/app/templates/migration.rb.tt +14 -0
  58. data/lib/generators/fosm/app/templates/model.rb.tt +31 -0
  59. data/lib/generators/fosm/app/templates/views/_form.html.erb.tt +24 -0
  60. data/lib/generators/fosm/app/templates/views/index.html.erb.tt +37 -0
  61. data/lib/generators/fosm/app/templates/views/new.html.erb.tt +4 -0
  62. data/lib/generators/fosm/app/templates/views/show.html.erb.tt +57 -0
  63. data/lib/tasks/fosm/rails_tasks.rake +4 -0
  64. 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,6 @@
1
+ module Fosm
2
+ module Rails
3
+ class ApplicationController < ActionController::Base
4
+ end
5
+ end
6
+ 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,6 @@
1
+ module Fosm
2
+ class ApplicationJob < ActiveJob::Base
3
+ # The FOSM engine uses the host app's ActiveJob queue adapter.
4
+ # Override queue_name in subclasses if needed.
5
+ end
6
+ end
@@ -0,0 +1,6 @@
1
+ module Fosm
2
+ module Rails
3
+ class ApplicationJob < ActiveJob::Base
4
+ end
5
+ end
6
+ 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,6 @@
1
+ module Fosm
2
+ class ApplicationRecord < ActiveRecord::Base
3
+ self.abstract_class = true
4
+ connects_to database: { writing: :primary } if ActiveRecord::Base.configurations.find_db_config("primary")
5
+ end
6
+ end
@@ -0,0 +1,7 @@
1
+ module Fosm
2
+ module Rails
3
+ class ApplicationRecord < ActiveRecord::Base
4
+ self.abstract_class = true
5
+ end
6
+ end
7
+ 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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
238
+ .replace(/"/g,'&quot;').replace(/'/g,'&#39;');
239
+ }
240
+
241
+ document.addEventListener('DOMContentLoaded', scrollBottom);
242
+ </script>