layered-assistant-rails 0.4.1 → 0.6.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 (74) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/layered-assistant-rails/SKILL.md +207 -0
  3. data/AGENTS.md +1 -1
  4. data/NOTICE +20 -0
  5. data/README.md +18 -0
  6. data/app/assets/images/layered_assistant/icon_assistants.svg +3 -0
  7. data/app/assets/images/layered_assistant/icon_conversations.svg +3 -0
  8. data/app/assets/images/layered_assistant/icon_personas.svg +3 -0
  9. data/app/assets/images/layered_assistant/icon_providers.svg +3 -0
  10. data/app/assets/images/layered_assistant/icon_skills.svg +3 -0
  11. data/app/assets/images/layered_assistant/icon_spanner.svg +3 -0
  12. data/app/controllers/layered/assistant/assistants_controller.rb +4 -4
  13. data/app/controllers/layered/assistant/conversations_controller.rb +4 -4
  14. data/app/controllers/layered/assistant/messages_controller.rb +1 -1
  15. data/app/controllers/layered/assistant/models_controller.rb +1 -1
  16. data/app/controllers/layered/assistant/panel/conversations_controller.rb +3 -3
  17. data/app/controllers/layered/assistant/personas_controller.rb +1 -1
  18. data/app/controllers/layered/assistant/providers_controller.rb +1 -1
  19. data/app/controllers/layered/assistant/public/assistants_controller.rb +1 -1
  20. data/app/controllers/layered/assistant/public/conversations_controller.rb +2 -2
  21. data/app/controllers/layered/assistant/public/panel/conversations_controller.rb +3 -3
  22. data/app/controllers/layered/assistant/skills_controller.rb +1 -1
  23. data/app/helpers/layered/assistant/messages_helper.rb +0 -88
  24. data/app/javascript/layered_assistant/composer_controller.js +0 -1
  25. data/app/javascript/layered_assistant/index.js +2 -0
  26. data/app/javascript/layered_assistant/markdown_controller.js +19 -0
  27. data/app/javascript/layered_assistant/marked_setup.js +20 -0
  28. data/app/javascript/layered_assistant/message_streaming.js +35 -53
  29. data/app/javascript/layered_assistant/messages_controller.js +1 -1
  30. data/app/javascript/layered_assistant/vendor/dompurify.LICENSE +568 -0
  31. data/app/javascript/layered_assistant/vendor/dompurify.js +1471 -0
  32. data/app/javascript/layered_assistant/vendor/marked.LICENSE +44 -0
  33. data/app/javascript/layered_assistant/vendor/marked.js +8 -0
  34. data/app/jobs/layered/assistant/messages/response_job.rb +1 -1
  35. data/app/models/layered/assistant/message.rb +2 -22
  36. data/app/services/layered/assistant/messages_service.rb +2 -2
  37. data/app/views/layered/assistant/assistants/_form.html.erb +7 -7
  38. data/app/views/layered/assistant/assistants/index.html.erb +7 -7
  39. data/app/views/layered/assistant/conversations/_form.html.erb +3 -3
  40. data/app/views/layered/assistant/conversations/index.html.erb +7 -7
  41. data/app/views/layered/assistant/conversations/show.html.erb +2 -2
  42. data/app/views/layered/assistant/messages/_composer_fields.html.erb +3 -3
  43. data/app/views/layered/assistant/messages/_message.html.erb +5 -6
  44. data/app/views/layered/assistant/messages/create.turbo_stream.erb +1 -1
  45. data/app/views/layered/assistant/messages/index.html.erb +6 -6
  46. data/app/views/layered/assistant/models/_form.html.erb +4 -4
  47. data/app/views/layered/assistant/models/index.html.erb +7 -7
  48. data/app/views/layered/assistant/panel/conversations/_header.html.erb +3 -3
  49. data/app/views/layered/assistant/panel/conversations/index.html.erb +6 -6
  50. data/app/views/layered/assistant/panel/conversations/new.html.erb +4 -4
  51. data/app/views/layered/assistant/panel/messages/create.turbo_stream.erb +1 -1
  52. data/app/views/layered/assistant/personas/_form.html.erb +3 -3
  53. data/app/views/layered/assistant/personas/index.html.erb +7 -7
  54. data/app/views/layered/assistant/providers/_form.html.erb +5 -5
  55. data/app/views/layered/assistant/providers/index.html.erb +7 -7
  56. data/app/views/layered/assistant/public/assistants/index.html.erb +3 -3
  57. data/app/views/layered/assistant/public/conversations/show.html.erb +3 -3
  58. data/app/views/layered/assistant/public/messages/create.turbo_stream.erb +1 -1
  59. data/app/views/layered/assistant/public/panel/conversations/_header.html.erb +3 -3
  60. data/app/views/layered/assistant/public/panel/conversations/index.html.erb +5 -5
  61. data/app/views/layered/assistant/public/panel/conversations/new.html.erb +2 -2
  62. data/app/views/layered/assistant/public/panel/messages/create.turbo_stream.erb +1 -1
  63. data/app/views/layered/assistant/setup/_setup.html.erb +54 -54
  64. data/app/views/layered/assistant/skills/_form.html.erb +3 -3
  65. data/app/views/layered/assistant/skills/index.html.erb +7 -7
  66. data/app/views/layouts/layered/assistant/application.html.erb +11 -7
  67. data/config/importmap.rb +4 -0
  68. data/config/routes.rb +15 -15
  69. data/data/models.json +17 -17
  70. data/lib/generators/layered/assistant/install_agent_skill_generator.rb +26 -0
  71. data/lib/layered/assistant/engine.rb +1 -0
  72. data/lib/layered/assistant/version.rb +1 -1
  73. data/lib/layered/assistant.rb +0 -2
  74. metadata +36 -33
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: '07081dd0ae8adfb43bf4bbfcdb5ae6925faa1d31a196762bec9d52efd5a28d11'
4
- data.tar.gz: 305c23332a9a6e41aa5b8e3814565a4be3c0ea9c25a44aa4103c35b2d8e79d42
3
+ metadata.gz: e20ccd66f325c50ccfa813d277572f2d6c4a4460145fa21697adfef42c2a4e25
4
+ data.tar.gz: 109f84519abfcc651e7b0b2c89386cddb6a6c92ee51dddca8b2b43b3ebd1ecae
5
5
  SHA512:
6
- metadata.gz: b148483c047879c248db3d9275c4f8033ca294cc0d5b575b3ac2960c56b78a854ca444894b4d4e59b03279e4fee10712656fc758ef29fadc66f85cfd58254311
7
- data.tar.gz: eae3bd16f81cb0542c7e32e7ce332f08027fb24bf5c7241258f5f6c7034e7ef60886dd16871f5de15986c371a9b38d949ad23fc3709de2397df71a1987c6efc8
6
+ metadata.gz: e68add80616b9619fc97bad66081be5f40013c5354360c06247a9ea05e44985b639ed683e8794edbbe0abf791d7f53cb0887c442483b28b0b2b617586ea80dd3
7
+ data.tar.gz: 1f22e1f958687aa943a0623316fde162afc517c3c3ca6f299ee1c30e72a36c4b676c00bf7f0af977200489612eaaa009662c332fbd000abf8bd6e587dc051b4e
@@ -0,0 +1,207 @@
1
+ ---
2
+ name: layered-assistant-rails
3
+ description: Installs, configures, and builds with the layered-assistant-rails gem - a Rails 8+ engine providing AI assistant UI, conversations, providers, models, personas and skills, with a side-panel chat. Use when adding layered-assistant-rails to a Rails app, mounting the engine, configuring authorization and scoping, embedding the assistant panel, or troubleshooting setup.
4
+ license: Apache-2.0
5
+ compatibility: Requires Ruby on Rails >= 8.0, Ruby >= 3.2, layered-ui-rails >= 0.4, importmap-rails >= 2.0, stimulus-rails >= 1.0, turbo-rails
6
+ metadata:
7
+ author: layered.ai
8
+ version: "1.0"
9
+ source: https://github.com/layered-ai-public/layered-assistant-rails
10
+ ---
11
+
12
+ # layered-assistant-rails
13
+
14
+ A Rails 8+ engine providing an AI assistant: conversations, messages with streaming, configurable providers (Anthropic, OpenAI), models, personas, skills, and a side-panel chat that drops into any layered-ui-rails layout.
15
+
16
+ It builds on `layered-ui-rails` for layout, components, and theming. Install that gem first (or let this generator install it for you).
17
+
18
+ ## Installation
19
+
20
+ ```bash
21
+ bundle add layered-assistant-rails
22
+ bin/rails generate layered:assistant:install
23
+ bin/rails db:migrate
24
+ ```
25
+
26
+ The install generator:
27
+
28
+ 1. Verifies `layered-ui-rails` is installed - invokes its installer if not.
29
+ 2. Adds `import "layered_assistant"` to `app/javascript/application.js` (after the `layered_ui` import).
30
+ 3. Creates `config/initializers/layered_assistant.rb` with example `authorize` and `scope` blocks.
31
+ 4. Mounts the engine at `/layered/assistant` in `config/routes.rb`.
32
+ 5. Copies migrations into the host app via the `layered:assistant:migrations` generator.
33
+
34
+ After installation, configure the initializer (see below) - **routes return 403 until an `authorize` block is set**. Then visit `/layered/assistant` to set up providers, models, personas, skills and assistants.
35
+
36
+ ## Authorization
37
+
38
+ All non-public engine routes are gated by an `authorize` block. The block runs in controller context, so you have access to `current_user`, `request`, `redirect_to`, `head`, `main_app`, etc.
39
+
40
+ ```ruby
41
+ # config/initializers/layered_assistant.rb
42
+
43
+ # Require sign-in (Devise):
44
+ Layered::Assistant.authorize do
45
+ redirect_to main_app.new_user_session_path unless user_signed_in?
46
+ end
47
+
48
+ # Restrict to admins:
49
+ Layered::Assistant.authorize do
50
+ head :forbidden unless current_user&.admin?
51
+ end
52
+
53
+ # Allow all (no-op):
54
+ Layered::Assistant.authorize do
55
+ end
56
+ ```
57
+
58
+ Until configured, every request returns 403. Public routes under `/layered/assistant/public/...` are exempt - use these to expose specific assistants to anonymous visitors.
59
+
60
+ ## Scoping (multi-tenant ownership)
61
+
62
+ Engine models with a polymorphic `owner` association (assistants, personas, providers, models, skills, conversations) can be scoped per request. The block receives the model class and returns an `ActiveRecord::Relation`:
63
+
64
+ ```ruby
65
+ Layered::Assistant.scope do |model_class|
66
+ model_class.where(owner: current_user)
67
+ end
68
+ ```
69
+
70
+ Scope only conversations, leave the rest unscoped:
71
+
72
+ ```ruby
73
+ Layered::Assistant.scope do |model_class|
74
+ if model_class == Layered::Assistant::Conversation
75
+ model_class.where(owner: current_user)
76
+ else
77
+ model_class.all
78
+ end
79
+ end
80
+ ```
81
+
82
+ Ownership is enforced **at the controller layer via `scoped()`**, not via model validations. Out-of-scope IDs return 404.
83
+
84
+ ## Optional settings
85
+
86
+ ```ruby
87
+ Layered::Assistant.log_errors = true # log API errors to stdout
88
+ Layered::Assistant.api_request_timeout = 210 # total streaming API timeout (seconds)
89
+ Layered::Assistant.skip_db_encryption = true # dev/test only - skip encryption on Provider#secret
90
+ ```
91
+
92
+ `Provider#secret` is encrypted with Rails encrypted attributes, so the host app must have `bin/rails db:encryption:init` keys configured (or set `skip_db_encryption = true` for dev/test).
93
+
94
+ ## Mounting the assistant panel
95
+
96
+ The engine ships a side-panel chat that plugs into the `layered-ui-rails` panel slot. Use `PanelHelper` inside your layout's `content_for` blocks (always **above** the layout render call):
97
+
98
+ ```erb
99
+ <% content_for :l_ui_panel_heading do %>
100
+ <%= layered_assistant_panel_header %>
101
+ <% end %>
102
+
103
+ <% content_for :l_ui_panel_body do %>
104
+ <%= layered_assistant_panel_body %>
105
+ <% end %>
106
+
107
+ <%= render template: "layouts/layered_ui/application" %>
108
+ ```
109
+
110
+ The body lazy-loads the conversation list from `panel_conversations_path` via a Turbo Frame.
111
+
112
+ For a public (unauthenticated) panel scoped to one assistant:
113
+
114
+ ```erb
115
+ <% content_for :l_ui_panel_body do %>
116
+ <%= layered_assistant_public_panel_body(assistant: @assistant) %>
117
+ <% end %>
118
+ ```
119
+
120
+ ## View helpers
121
+
122
+ | Helper | Purpose |
123
+ |---|---|
124
+ | `layered_assistant_panel_header(**opts, &block)` | Turbo frame for the panel header |
125
+ | `layered_assistant_panel_body(**opts)` | Turbo frame that loads the authenticated panel conversations |
126
+ | `layered_assistant_public_panel_body(assistant:, **opts)` | Turbo frame for an unauthenticated single-assistant panel |
127
+ | `l_assistant_accessible?` | Returns true if the current request would pass the `authorize` block - useful for hiding the panel from unauthorised users |
128
+
129
+ Example - only render the panel for users who can access it:
130
+
131
+ ```erb
132
+ <% if l_assistant_accessible? %>
133
+ <% content_for :l_ui_panel_heading do %>
134
+ <%= layered_assistant_panel_header %>
135
+ <% end %>
136
+ <% content_for :l_ui_panel_body do %>
137
+ <%= layered_assistant_panel_body %>
138
+ <% end %>
139
+ <% end %>
140
+ ```
141
+
142
+ ## Models
143
+
144
+ All under `Layered::Assistant::*`, tables prefixed `layered_assistant_`. Inherit from `Layered::Assistant::ApplicationRecord`.
145
+
146
+ | Model | Purpose |
147
+ |---|---|
148
+ | `Provider` | API provider config (Anthropic, OpenAI). Holds the encrypted `secret` (API key) |
149
+ | `Model` | A specific model offered by a `Provider` (e.g. `claude-opus-4-7`) |
150
+ | `Persona` | Reusable system prompt / character |
151
+ | `Skill` | A capability that can be attached to assistants |
152
+ | `Assistant` | A configured assistant: model + persona + skills |
153
+ | `AssistantSkill` | Join between assistant and skill |
154
+ | `Conversation` | A chat session with an assistant, owned polymorphically |
155
+ | `Message` | A single message in a conversation; supports streaming |
156
+
157
+ Enums are stored as **strings**, not integers.
158
+
159
+ ## Routes
160
+
161
+ Mounted at `/layered/assistant` by default. Top-level resources: `personas`, `skills`, `assistants` (with nested `conversations`), `providers` (with nested `models`), `conversations` (with nested `messages` and a `stop` member route).
162
+
163
+ The engine also exposes:
164
+
165
+ - `panel/conversations` and `panel/conversations/:id/messages` - the authenticated side-panel API
166
+ - `public/assistants`, `public/conversations`, `public/panel/conversations` - unauthenticated entry points for embedding a single assistant on a public page
167
+
168
+ Use `layered_assistant.<route>_path` from the host app, or `main_app.<route>_path` from inside engine views.
169
+
170
+ ## JavaScript
171
+
172
+ Stimulus controllers are registered automatically via importmap once `import "layered_assistant"` is in `application.js`:
173
+
174
+ | Identifier | Purpose |
175
+ |---|---|
176
+ | `composer` | Message composer (textarea autosize, submit-on-enter) |
177
+ | `messages` | Message list rendering and scroll behaviour |
178
+ | `panel` | Assistant side-panel state |
179
+ | `panel-nav` | Conversation list navigation inside the panel |
180
+ | `conversation-select` | Conversation picker |
181
+ | `provider-template` | Provider form prefill from a template |
182
+
183
+ `message_streaming.js` wires SSE streaming for assistant replies.
184
+
185
+ ## Styling
186
+
187
+ The engine renders inside `layered-ui-rails` layouts and uses only `l-ui-` classes - no host-app Tailwind utilities are required, and **no engine-only Tailwind classes leak into views** (the host's Tailwind build does not scan engine view files). If you customise the look, theme via `layered-ui-rails` CSS custom properties.
188
+
189
+ ## Conventions
190
+
191
+ - **Locale**: en-GB unless a technical standard dictates otherwise.
192
+ - **Ownership**: enforce via `scoped()` in controllers, not model validations.
193
+ - **No bundler**: importmap only.
194
+ - **Accessibility**: WCAG 2.2 AA - tables include `<caption>`, `scope="col"`/`scope="row"`, etc.
195
+
196
+ ## Common issues
197
+
198
+ - **All routes return 403** - no `authorize` block is configured. Edit `config/initializers/layered_assistant.rb`.
199
+ - **Provider creation fails with encryption error** - run `bin/rails db:encryption:init` and add the keys to credentials, or set `Layered::Assistant.skip_db_encryption = true` for dev/test.
200
+ - **Panel body never loads** - `turbo-rails` must be installed and `layered-ui-rails` must be mounted in the layout. Check `import "@hotwired/turbo-rails"` is present.
201
+ - **`layered_assistant` JS controllers missing** - ensure `import "layered_assistant"` is in `app/javascript/application.js` (added by the install generator, after the `layered_ui` import).
202
+ - **Cross-tenant records visible** - configure `Layered::Assistant.scope` to filter by `owner`.
203
+
204
+ ## Further reference
205
+
206
+ - Repository: https://github.com/layered-ai-public/layered-assistant-rails
207
+ - Companion gem: `layered-ui-rails` - layout, components, helpers (skill `layered-ui-rails`)
data/AGENTS.md CHANGED
@@ -6,7 +6,7 @@ This file provides guidance to AI agents when working with code in this reposito
6
6
 
7
7
  **layered-assistant-rails** is a Rails 8+ engine gem (`Layered::Assistant`) providing AI assistant UI components. It uses `isolate_namespace Layered::Assistant` and distributes JS (importmap) to host applications via a generator.
8
8
 
9
- Requires Rails >= 8.0.0, Ruby >= 3.2.0. Depends on sibling gem `layered-ui-rails` (path dependency at `../layered-ui-rails`).
9
+ Requires Rails >= 8.0.0, Ruby >= 3.3.0. Depends on sibling gem `layered-ui-rails` (path dependency at `../layered-ui-rails`).
10
10
 
11
11
  ## Architecture
12
12
 
data/NOTICE CHANGED
@@ -5,3 +5,23 @@ Copyright 2026 LAYERED AI LIMITED (UK company number: 17056830).
5
5
  This product includes software developed by LAYERED AI LIMITED and contributors.
6
6
 
7
7
  Licensed under the Apache License, Version 2.0.
8
+
9
+ ------------------------------------------------------------------------
10
+ Third-party software
11
+ ------------------------------------------------------------------------
12
+
13
+ This product bundles the following third-party JavaScript libraries
14
+ under app/javascript/layered_assistant/vendor/. Their full license
15
+ texts are included alongside the source files.
16
+
17
+ marked (https://github.com/markedjs/marked)
18
+ Copyright (c) 2018+ MarkedJS
19
+ Copyright (c) 2011-2018 Christopher Jeffrey
20
+ Licensed under the MIT License.
21
+ See: app/javascript/layered_assistant/vendor/marked.LICENSE
22
+
23
+ DOMPurify (https://github.com/cure53/DOMPurify)
24
+ Copyright Dr.-Ing. Mario Heiderich, Cure53
25
+ Dual-licensed under the Apache License 2.0 or the
26
+ Mozilla Public License 2.0; used here under Apache-2.0.
27
+ See: app/javascript/layered_assistant/vendor/dompurify.LICENSE
data/README.md CHANGED
@@ -15,6 +15,24 @@ An open source Rails 8+ engine built on [layered-ui-rails](https://github.com/la
15
15
  - Ruby on Rails >= 8.0
16
16
  - [layered-ui-rails](https://github.com/layered-ai-public/layered-ui-rails) installed in the host app
17
17
 
18
+ ## Agent skill
19
+
20
+ An [agent skill](https://agentskills.io) is included so AI coding agents can work with `layered-assistant-rails` in your project. Once installed, the agent can handle the full setup - just ask it to add `layered-assistant-rails` to your app and it will install the gem, run the generator, and configure your layout.
21
+
22
+ **Project install** - scoped to a single repo, available to all contributors:
23
+
24
+ ```bash
25
+ bin/rails generate layered:assistant:install_agent_skill
26
+ ```
27
+
28
+ **Global install** - available across all your projects:
29
+
30
+ ```bash
31
+ ./install-skill.sh
32
+ # or install remotely without cloning the repo:
33
+ curl -fsSL https://raw.githubusercontent.com/layered-ai-public/layered-assistant-rails/main/install-skill.sh | sh
34
+ ```
35
+
18
36
  ## Installation
19
37
 
20
38
  Add to your Gemfile:
@@ -0,0 +1,3 @@
1
+ <svg fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
2
+ <path stroke-linecap="round" stroke-linejoin="round" d="M19.5 12a7.5 7.5 0 0 0-15 0v5.25a1.5 1.5 0 0 0 1.5 1.5h1.5a1.5 1.5 0 0 0 1.5-1.5V13.5a1.5 1.5 0 0 0-1.5-1.5H4.5m15 0h-3a1.5 1.5 0 0 0-1.5 1.5v3.75a1.5 1.5 0 0 0 1.5 1.5h.75m2.25-5.25v6a3 3 0 0 1-3 3h-3" />
3
+ </svg>
@@ -0,0 +1,3 @@
1
+ <svg fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
2
+ <path stroke-linecap="round" stroke-linejoin="round" d="M8.625 9.75a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0H8.25m4.125 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0H12m4.125 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0h-.375m-13.5 3.01c0 1.6 1.123 2.994 2.707 3.227 1.087.16 2.185.283 3.293.369V21l4.184-4.183a1.14 1.14 0 0 1 .778-.332 48.294 48.294 0 0 0 5.83-.498c1.585-.233 2.708-1.626 2.708-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0 0 12 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018Z" />
3
+ </svg>
@@ -0,0 +1,3 @@
1
+ <svg fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
2
+ <path stroke-linecap="round" stroke-linejoin="round" d="M17.982 18.725A7.488 7.488 0 0 0 12 15.75a7.488 7.488 0 0 0-5.982 2.975m11.963 0a9 9 0 1 0-11.963 0m11.963 0A8.966 8.966 0 0 1 12 21a8.966 8.966 0 0 1-5.982-2.275M15 9.75a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
3
+ </svg>
@@ -0,0 +1,3 @@
1
+ <svg fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
2
+ <path stroke-linecap="round" stroke-linejoin="round" d="M5.25 14.25h13.5m-13.5 0a3 3 0 0 1-3-3m3 3a3 3 0 1 0 0 6h13.5a3 3 0 1 0 0-6m-16.5-3a3 3 0 0 1 3-3h13.5a3 3 0 0 1 3 3m-19.5 0a4.5 4.5 0 0 1 .9-2.7L5.737 5.1a3.375 3.375 0 0 1 2.7-1.35h7.126c1.062 0 2.062.5 2.7 1.35l2.587 3.45a4.5 4.5 0 0 1 .9 2.7m0 0a3 3 0 0 1-3 3m0 3h.008v.008h-.008v-.008Zm0-6h.008v.008h-.008v-.008Zm-3 6h.008v.008h-.008v-.008Zm0-6h.008v.008h-.008v-.008Z" />
3
+ </svg>
@@ -0,0 +1,3 @@
1
+ <svg fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
2
+ <path stroke-linecap="round" stroke-linejoin="round" d="M9.813 15.904 9 18.75l-.813-2.846a4.5 4.5 0 0 0-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 0 0 3.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 0 0 3.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 0 0-3.09 3.09ZM18.259 8.715 18 9.75l-.259-1.035a3.375 3.375 0 0 0-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 0 0 2.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 0 0 2.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 0 0-2.456 2.456ZM16.894 20.567 16.5 21.75l-.394-1.183a2.25 2.25 0 0 0-1.423-1.423L13.5 18.75l1.183-.394a2.25 2.25 0 0 0 1.423-1.423l.394-1.183.394 1.183a2.25 2.25 0 0 0 1.423 1.423l1.183.394-1.183.394a2.25 2.25 0 0 0-1.423 1.423Z" />
3
+ </svg>
@@ -0,0 +1,3 @@
1
+ <svg fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
2
+ <path stroke-linecap="round" stroke-linejoin="round" d="M11.42 15.17 17.25 21A2.652 2.652 0 0 0 21 17.25l-5.877-5.877M11.42 15.17l2.496-3.03c.317-.384.74-.626 1.208-.766M11.42 15.17l-4.655 5.653a2.548 2.548 0 1 1-3.586-3.586l6.837-5.63m5.108-.233c.55-.164 1.163-.188 1.743-.14a4.5 4.5 0 0 0 4.486-6.336l-3.276 3.277a3.004 3.004 0 0 1-2.25-2.25l3.276-3.276a4.5 4.5 0 0 0-6.336 4.486c.091 1.076-.071 2.264-.904 2.95l-.102.085m-1.745 1.437L5.909 7.5H4.5L2.25 3.75l1.5-1.5L7.5 4.5v1.409l4.26 4.26m-1.745 1.437 1.745-1.437m6.615 8.206L15.75 15.75M4.867 19.125h.008v.008h-.008v-.008Z" />
3
+ </svg>
@@ -1,10 +1,10 @@
1
1
  module Layered
2
2
  module Assistant
3
3
  class AssistantsController < ApplicationController
4
- before_action :set_assistant, only: [:edit, :update, :destroy]
5
- before_action :set_models, only: [:new, :create, :edit, :update]
6
- before_action :set_personas, only: [:new, :create, :edit, :update]
7
- before_action :set_skills, only: [:new, :create, :edit, :update]
4
+ before_action :set_assistant, only: [ :edit, :update, :destroy ]
5
+ before_action :set_models, only: [ :new, :create, :edit, :update ]
6
+ before_action :set_personas, only: [ :new, :create, :edit, :update ]
7
+ before_action :set_skills, only: [ :new, :create, :edit, :update ]
8
8
 
9
9
  def index
10
10
  @page_title = "Assistants"
@@ -3,8 +3,8 @@ module Layered
3
3
  class ConversationsController < ApplicationController
4
4
  include StoppableResponse
5
5
 
6
- before_action :set_conversation, only: [:show, :edit, :update, :destroy, :stop]
7
- before_action :set_assistants, only: [:new, :create]
6
+ before_action :set_conversation, only: [ :show, :edit, :update, :destroy, :stop ]
7
+ before_action :set_assistants, only: [ :new, :create ]
8
8
 
9
9
  def index
10
10
  if params[:assistant_id]
@@ -26,7 +26,7 @@ module Layered
26
26
 
27
27
  def new
28
28
  @page_title = "New conversation"
29
- @conversation = Conversation.new(params.permit(conversation: [:assistant_id])[:conversation])
29
+ @conversation = Conversation.new(params.permit(conversation: [ :assistant_id ])[:conversation])
30
30
  end
31
31
 
32
32
  def create
@@ -35,7 +35,7 @@ module Layered
35
35
  @conversation.assistant = scoped(Assistant).find(conversation_params[:assistant_id]) if conversation_params[:assistant_id].present?
36
36
  @conversation.name = Conversation.default_name if @conversation.name.blank?
37
37
  if @conversation.save
38
- redirect_to layered_assistant.conversation_path(@conversation), notice: "Conversation was successfully created."
38
+ redirect_to layered_assistant.conversation_path(@conversation)
39
39
  else
40
40
  render :new, status: :unprocessable_entity
41
41
  end
@@ -4,7 +4,7 @@ module Layered
4
4
  include MessageCreation
5
5
 
6
6
  before_action :set_conversation
7
- before_action :set_message, only: [:destroy]
7
+ before_action :set_message, only: [ :destroy ]
8
8
 
9
9
  def index
10
10
  @page_title = "Messages"
@@ -2,7 +2,7 @@ module Layered
2
2
  module Assistant
3
3
  class ModelsController < ApplicationController
4
4
  before_action :set_provider
5
- before_action :set_model, only: [:edit, :update, :destroy]
5
+ before_action :set_model, only: [ :edit, :update, :destroy ]
6
6
 
7
7
  def index
8
8
  @pagy, @models = pagy(@provider.models.sorted)
@@ -5,9 +5,9 @@ module Layered
5
5
  include StoppableResponse
6
6
  layout false
7
7
 
8
- before_action :set_conversation, only: [:show, :destroy, :stop]
9
- before_action :set_conversations, only: [:index, :show]
10
- before_action :set_assistants, only: [:index, :show, :new, :create]
8
+ before_action :set_conversation, only: [ :show, :destroy, :stop ]
9
+ before_action :set_conversations, only: [ :index, :show ]
10
+ before_action :set_assistants, only: [ :index, :show, :new, :create ]
11
11
 
12
12
  def index
13
13
  end
@@ -1,7 +1,7 @@
1
1
  module Layered
2
2
  module Assistant
3
3
  class PersonasController < ApplicationController
4
- before_action :set_persona, only: [:edit, :update, :destroy]
4
+ before_action :set_persona, only: [ :edit, :update, :destroy ]
5
5
 
6
6
  def index
7
7
  @page_title = "Personas"
@@ -1,7 +1,7 @@
1
1
  module Layered
2
2
  module Assistant
3
3
  class ProvidersController < ApplicationController
4
- before_action :set_provider, only: [:edit, :update, :destroy]
4
+ before_action :set_provider, only: [ :edit, :update, :destroy ]
5
5
 
6
6
  def index
7
7
  @page_title = "Providers"
@@ -2,7 +2,7 @@ module Layered
2
2
  module Assistant
3
3
  module Public
4
4
  class AssistantsController < ApplicationController
5
- before_action :set_public_assistant, only: [:show]
5
+ before_action :set_public_assistant, only: [ :show ]
6
6
 
7
7
  def index
8
8
  @pagy, @assistants = pagy(Assistant.publicly_available.by_name)
@@ -4,8 +4,8 @@ module Layered
4
4
  class ConversationsController < ApplicationController
5
5
  include StoppableResponse
6
6
 
7
- before_action :set_public_assistant, only: [:create]
8
- before_action :set_conversation, only: [:show, :stop]
7
+ before_action :set_public_assistant, only: [ :create ]
8
+ before_action :set_conversation, only: [ :show, :stop ]
9
9
 
10
10
  def create
11
11
  @conversation = @assistant.conversations.new(name: Conversation.default_name)
@@ -6,9 +6,9 @@ module Layered
6
6
  include StoppableResponse
7
7
  layout false
8
8
 
9
- before_action :set_public_assistant, only: [:index, :new, :create]
10
- before_action :set_conversation, only: [:show, :stop]
11
- before_action :set_session_conversations, only: [:index, :show]
9
+ before_action :set_public_assistant, only: [ :index, :new, :create ]
10
+ before_action :set_conversation, only: [ :show, :stop ]
11
+ before_action :set_session_conversations, only: [ :index, :show ]
12
12
 
13
13
  def index
14
14
  end
@@ -1,7 +1,7 @@
1
1
  module Layered
2
2
  module Assistant
3
3
  class SkillsController < ApplicationController
4
- before_action :set_skill, only: [:edit, :update, :destroy]
4
+ before_action :set_skill, only: [ :edit, :update, :destroy ]
5
5
 
6
6
  def index
7
7
  @page_title = "Skills"
@@ -1,20 +1,6 @@
1
1
  module Layered
2
2
  module Assistant
3
3
  module MessagesHelper
4
- ALLOWED_TAGS = %w[
5
- p br
6
- h1 h2 h3 h4 h5 h6
7
- strong em s del
8
- ul ol li
9
- pre code
10
- a
11
- blockquote
12
- table thead tbody tr th td
13
- hr
14
- ].freeze
15
-
16
- ALLOWED_ATTRIBUTES = %w[href title class].freeze
17
-
18
4
  MIN_RESPONSE_MS_FOR_TPS = 100
19
5
 
20
6
  def message_metadata_title(message)
@@ -31,80 +17,6 @@ module Layered
31
17
  parts << "#{message.ttft_ms}ms TTFT" if message.ttft_ms
32
18
  parts.join(" · ")
33
19
  end
34
-
35
- def render_message_content(message)
36
- return if message.content.blank?
37
-
38
- render_markdown(message.content)
39
- end
40
-
41
- # Renders accumulated content for streaming, holding back any
42
- # trailing unclosed code fence that Kramdown can't parse correctly.
43
- # Returns { html:, has_unclosed_fence: } so the caller can decide
44
- # whether to show a typing indicator.
45
- def render_streaming_markdown(content)
46
- return { html: "", has_unclosed_fence: false } if content.blank?
47
-
48
- safe = strip_unclosed_fence(content)
49
- {
50
- html: safe.present? ? render_markdown(safe) : "",
51
- has_unclosed_fence: safe.length < content.length
52
- }
53
- end
54
-
55
- private
56
-
57
- def render_markdown(content)
58
- markdown = unwrap_markdown_fence(content)
59
- markdown = ensure_blank_line_before_tables(markdown)
60
-
61
- html = Kramdown::Document.new(
62
- markdown,
63
- input: "GFM",
64
- syntax_highlighter: nil
65
- ).to_html
66
-
67
- sanitize(html, tags: ALLOWED_TAGS, attributes: ALLOWED_ATTRIBUTES)
68
- end
69
-
70
- # Some LLMs wrap their entire response in a ```markdown fence.
71
- # Strip it so Kramdown processes the inner content directly.
72
- def unwrap_markdown_fence(content)
73
- if content.start_with?("```markdown\n") && content.end_with?("\n```")
74
- content.delete_prefix("```markdown\n").delete_suffix("\n```")
75
- else
76
- content
77
- end
78
- end
79
-
80
- # Kramdown GFM requires a blank line before a table, but LLMs often
81
- # place tables directly after headings or paragraphs. Insert one
82
- # where missing so that Kramdown recognises the table syntax.
83
- def ensure_blank_line_before_tables(text)
84
- text.gsub(/([^\n])\n(\|[^\n]+\|\s*\n\|[\s:|-]+\|\s*\n)/, "\\1\n\n\\2")
85
- end
86
-
87
- # Returns text up to (but not including) any trailing unclosed code
88
- # fence. Kramdown can't produce valid output for a half-open fence,
89
- # so we hold it back until the closing marker arrives.
90
- def strip_unclosed_fence(text)
91
- fence_marker = nil
92
- fence_start = 0
93
- pos = 0
94
-
95
- text.each_line do |line|
96
- trimmed = line.lstrip
97
- if fence_marker.nil? && (match = trimmed.match(/\A(`{3,}|~{3,})/))
98
- fence_marker = match[1]
99
- fence_start = pos
100
- elsif fence_marker && trimmed.match?(/\A#{Regexp.escape(fence_marker[0])}{#{fence_marker.length},}\s*\z/)
101
- fence_marker = nil
102
- end
103
- pos += line.length
104
- end
105
-
106
- fence_marker ? text[0, fence_start] : text
107
- end
108
20
  end
109
21
  end
110
22
  end
@@ -78,7 +78,6 @@ export default class extends Controller {
78
78
 
79
79
  const empty = this.inputTarget.value.trim() === ""
80
80
  this.sendButtonTarget.disabled = empty
81
- this.sendButtonTarget.classList.toggle("l-ui-button--disabled", empty)
82
81
  }
83
82
 
84
83
  // Toggle visibility of the Send and Stop buttons. While responding a
@@ -1,6 +1,7 @@
1
1
  import { application } from "controllers/application"
2
2
  import ComposerController from "layered_assistant/composer_controller"
3
3
  import ConversationSelectController from "layered_assistant/conversation_select_controller"
4
+ import MarkdownController from "layered_assistant/markdown_controller"
4
5
  import MessagesController from "layered_assistant/messages_controller"
5
6
  import PanelController from "layered_assistant/panel_controller"
6
7
  import PanelNavController from "layered_assistant/panel_nav_controller"
@@ -8,6 +9,7 @@ import ProviderTemplateController from "layered_assistant/provider_template_cont
8
9
 
9
10
  application.register("composer", ComposerController)
10
11
  application.register("conversation-select", ConversationSelectController)
12
+ application.register("markdown", MarkdownController)
11
13
  application.register("messages", MessagesController)
12
14
  application.register("panel", PanelController)
13
15
  application.register("panel-nav", PanelNavController)
@@ -0,0 +1,19 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+ import { renderMarkdown } from "layered_assistant/marked_setup"
3
+
4
+ // Renders raw markdown (placed in the element by the server) as HTML
5
+ // on connect. Streaming chunks bypass this and write innerHTML
6
+ // directly via Turbo stream actions.
7
+
8
+ export default class extends Controller {
9
+ connect() {
10
+ // Skip if the element already contains rendered HTML - e.g. Turbo
11
+ // Drive cache restore, or streaming output appended by the message
12
+ // stream actions. textContent would flatten rendered HTML back to
13
+ // plain text and marked would silently drop all formatting.
14
+ if (this.element.firstElementChild) return
15
+ const text = this.element.textContent.trim()
16
+ if (!text) return
17
+ this.element.innerHTML = renderMarkdown(text)
18
+ }
19
+ }
@@ -0,0 +1,20 @@
1
+ // Markdown rendering pipeline.
2
+ //
3
+ // Marked parses LLM output (including any raw HTML it emits), then
4
+ // DOMPurify sanitises the result: it strips <script> and other unsafe
5
+ // tags, validates URL schemes (blocking javascript:, data:, vbscript:
6
+ // in href/src), and removes unsafe attributes. DOMPurify is the sole
7
+ // security boundary here.
8
+
9
+ import { marked } from "marked"
10
+ import DOMPurify from "dompurify"
11
+
12
+ marked.use({
13
+ gfm: true,
14
+ breaks: false
15
+ })
16
+
17
+ export function renderMarkdown(text) {
18
+ if (!text) return ""
19
+ return DOMPurify.sanitize(marked.parse(text))
20
+ }