layered-assistant-rails 0.5.0 → 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.
- checksums.yaml +4 -4
- data/NOTICE +20 -0
- data/app/controllers/layered/assistant/assistants_controller.rb +4 -4
- data/app/controllers/layered/assistant/conversations_controller.rb +4 -4
- data/app/controllers/layered/assistant/messages_controller.rb +1 -1
- data/app/controllers/layered/assistant/models_controller.rb +1 -1
- data/app/controllers/layered/assistant/panel/conversations_controller.rb +3 -3
- data/app/controllers/layered/assistant/personas_controller.rb +1 -1
- data/app/controllers/layered/assistant/providers_controller.rb +1 -1
- data/app/controllers/layered/assistant/public/assistants_controller.rb +1 -1
- data/app/controllers/layered/assistant/public/conversations_controller.rb +2 -2
- data/app/controllers/layered/assistant/public/panel/conversations_controller.rb +3 -3
- data/app/controllers/layered/assistant/skills_controller.rb +1 -1
- data/app/helpers/layered/assistant/messages_helper.rb +0 -88
- data/app/javascript/layered_assistant/composer_controller.js +0 -1
- data/app/javascript/layered_assistant/index.js +2 -0
- data/app/javascript/layered_assistant/markdown_controller.js +19 -0
- data/app/javascript/layered_assistant/marked_setup.js +20 -0
- data/app/javascript/layered_assistant/message_streaming.js +35 -53
- data/app/javascript/layered_assistant/messages_controller.js +1 -1
- data/app/javascript/layered_assistant/vendor/dompurify.LICENSE +568 -0
- data/app/javascript/layered_assistant/vendor/dompurify.js +1471 -0
- data/app/javascript/layered_assistant/vendor/marked.LICENSE +44 -0
- data/app/javascript/layered_assistant/vendor/marked.js +8 -0
- data/app/jobs/layered/assistant/messages/response_job.rb +1 -1
- data/app/models/layered/assistant/message.rb +2 -22
- data/app/services/layered/assistant/messages_service.rb +2 -2
- data/app/views/layered/assistant/assistants/_form.html.erb +7 -7
- data/app/views/layered/assistant/assistants/index.html.erb +7 -7
- data/app/views/layered/assistant/conversations/_form.html.erb +3 -3
- data/app/views/layered/assistant/conversations/index.html.erb +7 -7
- data/app/views/layered/assistant/conversations/show.html.erb +2 -2
- data/app/views/layered/assistant/messages/_composer_fields.html.erb +3 -3
- data/app/views/layered/assistant/messages/_message.html.erb +5 -6
- data/app/views/layered/assistant/messages/create.turbo_stream.erb +1 -1
- data/app/views/layered/assistant/messages/index.html.erb +6 -6
- data/app/views/layered/assistant/models/_form.html.erb +4 -4
- data/app/views/layered/assistant/models/index.html.erb +7 -7
- data/app/views/layered/assistant/panel/conversations/_header.html.erb +3 -3
- data/app/views/layered/assistant/panel/conversations/index.html.erb +6 -6
- data/app/views/layered/assistant/panel/conversations/new.html.erb +4 -4
- data/app/views/layered/assistant/panel/messages/create.turbo_stream.erb +1 -1
- data/app/views/layered/assistant/personas/_form.html.erb +3 -3
- data/app/views/layered/assistant/personas/index.html.erb +7 -7
- data/app/views/layered/assistant/providers/_form.html.erb +5 -5
- data/app/views/layered/assistant/providers/index.html.erb +7 -7
- data/app/views/layered/assistant/public/assistants/index.html.erb +3 -3
- data/app/views/layered/assistant/public/conversations/show.html.erb +3 -3
- data/app/views/layered/assistant/public/messages/create.turbo_stream.erb +1 -1
- data/app/views/layered/assistant/public/panel/conversations/_header.html.erb +3 -3
- data/app/views/layered/assistant/public/panel/conversations/index.html.erb +5 -5
- data/app/views/layered/assistant/public/panel/conversations/new.html.erb +2 -2
- data/app/views/layered/assistant/public/panel/messages/create.turbo_stream.erb +1 -1
- data/app/views/layered/assistant/setup/_setup.html.erb +54 -54
- data/app/views/layered/assistant/skills/_form.html.erb +3 -3
- data/app/views/layered/assistant/skills/index.html.erb +7 -7
- data/config/importmap.rb +4 -0
- data/config/routes.rb +15 -15
- data/data/models.json +17 -17
- data/lib/layered/assistant/version.rb +1 -1
- data/lib/layered/assistant.rb +0 -2
- metadata +23 -31
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e20ccd66f325c50ccfa813d277572f2d6c4a4460145fa21697adfef42c2a4e25
|
|
4
|
+
data.tar.gz: 109f84519abfcc651e7b0b2c89386cddb6a6c92ee51dddca8b2b43b3ebd1ecae
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e68add80616b9619fc97bad66081be5f40013c5354360c06247a9ea05e44985b639ed683e8794edbbe0abf791d7f53cb0887c442483b28b0b2b617586ea80dd3
|
|
7
|
+
data.tar.gz: 1f22e1f958687aa943a0623316fde162afc517c3c3ca6f299ee1c30e72a36c4b676c00bf7f0af977200489612eaaa009662c332fbd000abf8bd6e587dc051b4e
|
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
|
|
@@ -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)
|
|
38
|
+
redirect_to layered_assistant.conversation_path(@conversation)
|
|
39
39
|
else
|
|
40
40
|
render :new, status: :unprocessable_entity
|
|
41
41
|
end
|
|
@@ -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
|
|
@@ -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,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
|
+
}
|
|
@@ -1,74 +1,56 @@
|
|
|
1
|
-
//
|
|
1
|
+
// Streaming render for assistant messages.
|
|
2
2
|
//
|
|
3
|
-
// The server broadcasts
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
3
|
+
// The server broadcasts the raw markdown content as it grows; the
|
|
4
|
+
// client parses it with marked and appends newly-closed top-level
|
|
5
|
+
// blocks to the message content element with a fade. The trailing
|
|
6
|
+
// block is always held back - even when it's already complete - so
|
|
7
|
+
// we don't fade-restart it on every chunk; the typing indicator
|
|
8
|
+
// sibling covers for it visually. Trade-off: single-block responses
|
|
9
|
+
// stay hidden until broadcast_response_complete swaps in the final
|
|
10
|
+
// partial. Acceptable because the swap is near-instant and the
|
|
11
|
+
// indicator masks the gap.
|
|
8
12
|
|
|
9
13
|
import "@hotwired/turbo-rails"
|
|
14
|
+
import { renderMarkdown } from "layered_assistant/marked_setup"
|
|
10
15
|
|
|
11
|
-
const
|
|
16
|
+
const pendingMarkdown = new WeakMap()
|
|
12
17
|
const pendingRender = new WeakMap()
|
|
13
18
|
|
|
14
|
-
function reconcile(target, html) {
|
|
15
|
-
const temp = document.createElement("div")
|
|
16
|
-
temp.innerHTML = html
|
|
17
|
-
|
|
18
|
-
// Snapshot temp.children since moves will mutate the live HTMLCollection
|
|
19
|
-
const newBlocks = [...temp.children]
|
|
20
|
-
|
|
21
|
-
// Trim excess blocks from previous render
|
|
22
|
-
while (target.children.length > newBlocks.length) {
|
|
23
|
-
target.lastElementChild.remove()
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
// Reconcile block by block - preserves existing DOM nodes (and their
|
|
27
|
-
// running fade-in animations) wherever possible
|
|
28
|
-
for (let i = 0; i < newBlocks.length; i++) {
|
|
29
|
-
if (i < target.children.length && target.children[i].tagName === newBlocks[i].tagName) {
|
|
30
|
-
// Same tag - patch content in place
|
|
31
|
-
if (target.children[i].innerHTML !== newBlocks[i].innerHTML) {
|
|
32
|
-
target.children[i].innerHTML = newBlocks[i].innerHTML
|
|
33
|
-
}
|
|
34
|
-
} else if (i < target.children.length) {
|
|
35
|
-
// Tag changed (e.g. <p> became <table>) - replace node
|
|
36
|
-
target.children[i].replaceWith(newBlocks[i])
|
|
37
|
-
} else {
|
|
38
|
-
// New block - append with fade
|
|
39
|
-
newBlocks[i].classList.add("l-ui-token-fade")
|
|
40
|
-
target.appendChild(newBlocks[i])
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
|
|
45
19
|
Turbo.StreamActions.enable_composer = function () {
|
|
46
20
|
this.targetElements.forEach((form) => {
|
|
47
21
|
form.setAttribute("data-composer-responding-value", "false")
|
|
48
22
|
})
|
|
49
23
|
}
|
|
50
24
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
25
|
+
function syncStreamingContent(target, markdown) {
|
|
26
|
+
const tmp = document.createElement("div")
|
|
27
|
+
tmp.innerHTML = renderMarkdown(markdown)
|
|
28
|
+
const incoming = [...tmp.children]
|
|
29
|
+
// Hold back the trailing in-progress block; only append new closed ones.
|
|
30
|
+
const closedCount = Math.max(0, incoming.length - 1)
|
|
31
|
+
for (let i = target.children.length; i < closedCount; i++) {
|
|
32
|
+
incoming[i].classList.add("l-ui-stream-fade")
|
|
33
|
+
target.appendChild(incoming[i])
|
|
34
|
+
}
|
|
35
|
+
if (target.children.length > 0) {
|
|
36
|
+
const indicator = target.parentElement?.querySelector(".l-ui-typing-indicator")
|
|
37
|
+
indicator?.classList.add("l-ui-mt-4")
|
|
38
|
+
}
|
|
39
|
+
}
|
|
56
40
|
|
|
57
|
-
|
|
41
|
+
Turbo.StreamActions.render_content = function () {
|
|
42
|
+
const markdown = this.templateContent.textContent
|
|
58
43
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
const div = document.createElement("div")
|
|
62
|
-
div.append(html.cloneNode(true))
|
|
63
|
-
pendingHtml.set(target, div.innerHTML)
|
|
44
|
+
this.targetElements.forEach((target) => {
|
|
45
|
+
pendingMarkdown.set(target, markdown)
|
|
64
46
|
|
|
65
47
|
if (!pendingRender.has(target)) {
|
|
66
48
|
pendingRender.set(target, requestAnimationFrame(() => {
|
|
67
49
|
pendingRender.delete(target)
|
|
68
|
-
const
|
|
69
|
-
if (
|
|
70
|
-
|
|
71
|
-
|
|
50
|
+
const md = pendingMarkdown.get(target)
|
|
51
|
+
if (md != null) {
|
|
52
|
+
syncStreamingContent(target, md)
|
|
53
|
+
pendingMarkdown.delete(target)
|
|
72
54
|
}
|
|
73
55
|
}))
|
|
74
56
|
}
|
|
@@ -14,7 +14,7 @@ export default class extends Controller {
|
|
|
14
14
|
const body = el.closest(".l-ui-message__body")
|
|
15
15
|
el.remove()
|
|
16
16
|
if (body && body.children.length === 0) {
|
|
17
|
-
body.insertAdjacentHTML("beforeend", '<div class="l-ui-notice--error" role="status">The response could not be completed.</div>')
|
|
17
|
+
body.insertAdjacentHTML("beforeend", '<div class="l-ui-notice l-ui-notice--error" role="status">The response could not be completed.</div>')
|
|
18
18
|
}
|
|
19
19
|
})
|
|
20
20
|
}
|