studio-engine 0.122.0 → 0.124.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/CHANGELOG.md +142 -0
- data/README.md +18 -3
- data/app/assets/tailwind/studio_engine/engine.css +210 -11
- data/app/controllers/concerns/studio/admin_models.rb +24 -16
- data/app/controllers/concerns/studio/error_handling.rb +13 -0
- data/app/controllers/studio/agent_chats_controller.rb +31 -4
- data/app/controllers/studio/marketing_kit_controller.rb +24 -0
- data/app/controllers/studio/voice_takes_controller.rb +11 -0
- data/app/controllers/style_controller.rb +2 -5
- data/app/controllers/theme_settings_controller.rb +8 -8
- data/app/helpers/studio/agent_chat_helper.rb +37 -0
- data/app/helpers/studio/site_footer_helper.rb +15 -2
- data/app/javascript/studio/agent_chat.js +42 -2
- data/app/javascript/studio/agent_chat_voice.js +318 -0
- data/app/javascript/studio/alpine_shims.js +1 -3
- data/app/javascript/studio/controllers/agent_chat_voice_controller.js +17 -0
- data/app/javascript/studio/controllers/copy_text_controller.js +63 -0
- data/app/javascript/studio/controllers/page_header_controller.js +60 -0
- data/app/javascript/studio/controllers/style_section_nav_controller.js +80 -0
- data/app/javascript/studio/microphone_chip.js +1 -0
- data/app/javascript/studio/page_header.js +24 -0
- data/app/javascript/studio/page_transitions.js +181 -0
- data/app/javascript/studio/pinned_stack.js +12 -1
- data/app/javascript/studio/stimulus.js +9 -1
- data/app/javascript/studio/style_guide.js +2 -0
- data/app/javascript/studio/style_section_nav.js +16 -0
- data/app/javascript/studio/theme_editor.js +6 -106
- data/app/javascript/studio/voice_recorder.js +23 -2
- data/app/models/studio/user_session.rb +76 -0
- data/app/models/theme_setting.rb +3 -0
- data/app/views/components/_link_sidebar.html.erb +5 -2
- data/app/views/components/_user_nav.html.erb +5 -4
- data/app/views/layouts/_navbar.html.erb +7 -4
- data/app/views/layouts/studio/_head.html.erb +6 -0
- data/app/views/layouts/studio/_smooth_load.html.erb +11 -2
- data/app/views/studio/_page_header.html.erb +65 -0
- data/app/views/studio/admin_models/_sessions_table.html.erb +28 -0
- data/app/views/studio/admin_models/show.html.erb +15 -12
- data/app/views/studio/admin_surveys/index.html.erb +2 -2
- data/app/views/studio/agent_chat/_chat.html.erb +17 -2
- data/app/views/studio/agent_chat/_form.html.erb +4 -3
- data/app/views/studio/agent_chat/_header.html.erb +1 -1
- data/app/views/studio/agent_chat/_voice.html.erb +54 -0
- data/app/views/studio/marketing_kit/_assets.html.erb +44 -0
- data/app/views/studio/marketing_kit/_kit.html.erb +84 -0
- data/app/views/studio/marketing_kit/show.html.erb +6 -0
- data/app/views/studio/site_footer/_assets.html.erb +1 -0
- data/app/views/studio/site_footer/_footer.html.erb +5 -0
- data/app/views/studio/voice/_microphone_help.html.erb +6 -3
- data/app/views/style/_theme.html.erb +75 -12
- data/app/views/style/_tricks.html.erb +35 -0
- data/app/views/style/index.html.erb +21 -34
- data/db/migrate/20261103120000_create_studio_user_sessions.rb +16 -0
- data/docs/SITE_FOOTER.md +146 -2
- data/lib/studio/agent_chat.rb +83 -5
- data/lib/studio/marketing_kit.rb +304 -0
- data/lib/studio/site_footer.rb +65 -6
- data/lib/studio/ui_primitives.rb +4 -2
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +146 -6
- metadata +21 -4
- data/app/views/studio/admin_models/index.html.erb +0 -31
- data/app/views/theme_settings/edit.html.erb +0 -304
|
@@ -5,12 +5,18 @@ module Studio
|
|
|
5
5
|
# to its own app; this controller forwards its two calls to the hub through
|
|
6
6
|
# Studio::AgentChat:
|
|
7
7
|
#
|
|
8
|
-
# POST /agent_chat/messages soul, text
|
|
8
|
+
# POST /agent_chat/messages soul, text, voice -> 201 {turn, voice}
|
|
9
9
|
# GET /agent_chat/turns soul, after, limit -> 200 {turns, pending}
|
|
10
10
|
#
|
|
11
11
|
# Drawn by Studio.routes only for an app that names a soul
|
|
12
12
|
# (Studio.agent_chat_souls).
|
|
13
13
|
#
|
|
14
|
+
# WHO MAY USE IT: Studio.agent_chat_allowed, asked of the viewer on both calls.
|
|
15
|
+
# A refused viewer is a JSON 403 {error, reason: "not_allowed"} before any hub
|
|
16
|
+
# call and before an anon cookie is minted. These routes skip sign-in, so hiding
|
|
17
|
+
# a link is not the gate; with the app's token set the hub answers whoever gets
|
|
18
|
+
# through here.
|
|
19
|
+
#
|
|
14
20
|
# WHAT THE BROWSER MAY CHOOSE, AND WHAT IT MAY NOT:
|
|
15
21
|
#
|
|
16
22
|
# soul only from the app's allow-list (Studio.agent_chat_souls); off the
|
|
@@ -27,6 +33,10 @@ module Studio
|
|
|
27
33
|
# POST that still holds the cookie sends both, so the hub folds the
|
|
28
34
|
# anonymous thread into the person's; the cookie is then deleted.
|
|
29
35
|
# ip never: request.remote_ip, which the hub caps on.
|
|
36
|
+
# voice only what a take measured, {check, score, duration_ms}
|
|
37
|
+
# (Studio::AgentChat.voice_param), and only from a user
|
|
38
|
+
# Studio.voice_chat_allowed? lets talk; anyone else's words go as
|
|
39
|
+
# typed. Never a name: the hub names the speaker.
|
|
30
40
|
#
|
|
31
41
|
# The host's ApplicationController is the base, so its session, current_user
|
|
32
42
|
# and CSRF protection hold: the POST must carry the page's authenticity token
|
|
@@ -43,8 +53,10 @@ module Studio
|
|
|
43
53
|
ANON_COOKIE = :studio_agent_chat_anon
|
|
44
54
|
ANON_TTL = 1.year
|
|
45
55
|
UNAVAILABLE = "Can't reach the chat right now. Try again in a moment."
|
|
56
|
+
NOT_ALLOWED = "This chat isn't open to you yet."
|
|
46
57
|
|
|
47
58
|
before_action :no_store
|
|
59
|
+
before_action :require_chat_allowed
|
|
48
60
|
before_action :resolve_soul
|
|
49
61
|
|
|
50
62
|
def create
|
|
@@ -56,11 +68,13 @@ module Studio
|
|
|
56
68
|
end
|
|
57
69
|
|
|
58
70
|
who = person(mint: true, fold: true)
|
|
59
|
-
turn = Studio::AgentChat.post_message(soul: @soul, text: text, person: who, ip: request.remote_ip
|
|
71
|
+
turn = Studio::AgentChat.post_message(soul: @soul, text: text, person: who, ip: request.remote_ip,
|
|
72
|
+
voice: spoken_voice)
|
|
60
73
|
# A signed-in post that carried the anon token has folded that thread into
|
|
61
74
|
# the person's: the token is spent. Kept on any failure, for the retry.
|
|
62
75
|
cookies.delete(ANON_COOKIE) if who.key?(:email) && who.key?(:anon_token)
|
|
63
|
-
|
|
76
|
+
# `voice` is what the hub kept, so the page says who the agent was told spoke.
|
|
77
|
+
render json: { turn: turn.except("voice"), voice: turn["voice"] }.compact, status: :created
|
|
64
78
|
rescue Studio::AgentChat::Error => e
|
|
65
79
|
render_hub_error(e)
|
|
66
80
|
end
|
|
@@ -80,6 +94,19 @@ module Studio
|
|
|
80
94
|
|
|
81
95
|
private
|
|
82
96
|
|
|
97
|
+
# The message's `voice`, for a user who may talk; nil otherwise.
|
|
98
|
+
def spoken_voice
|
|
99
|
+
params[:voice] if Studio.voice_chat_allowed?(viewer)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def viewer
|
|
103
|
+
respond_to?(:current_user, true) ? current_user : nil
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def require_chat_allowed
|
|
107
|
+
render_error(NOT_ALLOWED, :forbidden, reason: "not_allowed") unless Studio.agent_chat_allowed?(viewer)
|
|
108
|
+
end
|
|
109
|
+
|
|
83
110
|
def no_store
|
|
84
111
|
response.headers["Cache-Control"] = "no-store"
|
|
85
112
|
end
|
|
@@ -128,7 +155,7 @@ module Studio
|
|
|
128
155
|
# in docs/GOOGLE_AUTH_SETUP.md) counts. Anyone else chats as an
|
|
129
156
|
# anonymous visitor.
|
|
130
157
|
def verified_email
|
|
131
|
-
user =
|
|
158
|
+
user = viewer
|
|
132
159
|
return nil unless user
|
|
133
160
|
|
|
134
161
|
hook = Studio.agent_chat_verified_email
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Studio
|
|
4
|
+
# /marketing-kit: the app's logos, colours and typefaces, for anyone to use.
|
|
5
|
+
# See Studio::MarketingKit and docs/SITE_FOOTER.md.
|
|
6
|
+
#
|
|
7
|
+
# Public by design: a journalist or a partner has no account. It inherits the
|
|
8
|
+
# host's ApplicationController so the page renders in the app's layout, navbar
|
|
9
|
+
# and theme, and it skips each gate a host is known to put there:
|
|
10
|
+
# Studio::ErrorHandling's sign-in gate (every adopter), the hub's admin wall,
|
|
11
|
+
# and turf-monster's profile-completion redirect. A host with a gate of another
|
|
12
|
+
# name skips it in a to_prepare block (docs/SITE_FOOTER.md).
|
|
13
|
+
#
|
|
14
|
+
# It needs no table and no session, so an app with neither can draw it. Where
|
|
15
|
+
# ThemeSetting exists, the colours read it (Studio.marketing_kit_colors).
|
|
16
|
+
class MarketingKitController < ::ApplicationController
|
|
17
|
+
HOST_GATES = %i[require_authentication require_admin_wall require_admin require_login
|
|
18
|
+
authenticate_user! require_profile_completion].freeze
|
|
19
|
+
|
|
20
|
+
HOST_GATES.each { |gate| skip_before_action gate, raise: false }
|
|
21
|
+
|
|
22
|
+
def show; end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -19,6 +19,11 @@ module Studio
|
|
|
19
19
|
# CSRF-protected (send the page's token as X-CSRF-Token), JSON, no-store.
|
|
20
20
|
# The speaker is matched only against the signed-in user's own saved voice.
|
|
21
21
|
#
|
|
22
|
+
# ALLOWED USERS ONLY (Studio.voice_chat_allowed; by default anyone signed in):
|
|
23
|
+
# anyone else is a JSON 403 {error, reason: "not_allowed"}, before the rate
|
|
24
|
+
# limit counts or anything is read. Hiding the microphone is not the gate; each
|
|
25
|
+
# take is a paid call.
|
|
26
|
+
#
|
|
22
27
|
# RATE-LIMITED per user (Studio.voice_takes_rate_limit, 20 takes in 10
|
|
23
28
|
# minutes): over it, a JSON 429 {error, reason: "rate_limited", retry_after}
|
|
24
29
|
# and a Retry-After header, before anything is read or transcribed.
|
|
@@ -31,11 +36,13 @@ module Studio
|
|
|
31
36
|
before_action :require_authentication
|
|
32
37
|
protect_from_forgery with: :exception
|
|
33
38
|
before_action :no_store
|
|
39
|
+
before_action :require_voice_allowed
|
|
34
40
|
before_action :limit_takes
|
|
35
41
|
|
|
36
42
|
CANT_HEAR = "I couldn't hear any words in that. Check your microphone and try again."
|
|
37
43
|
UNAVAILABLE = "I couldn't transcribe that just now. Try again in a moment."
|
|
38
44
|
TOO_MANY = "That's a lot of recordings in a short time. Wait a moment, then try again."
|
|
45
|
+
NOT_ALLOWED = "Voice isn't available on your account yet. Type instead for now."
|
|
39
46
|
|
|
40
47
|
# The counter's fallback when the app's Rails.cache counts nothing (a
|
|
41
48
|
# NullStore, as many apps run in development): per process, so a limit
|
|
@@ -81,6 +88,10 @@ module Studio
|
|
|
81
88
|
|
|
82
89
|
private
|
|
83
90
|
|
|
91
|
+
def require_voice_allowed
|
|
92
|
+
refuse(NOT_ALLOWED, :forbidden, reason: "not_allowed") unless Studio.voice_chat_allowed?(current_user)
|
|
93
|
+
end
|
|
94
|
+
|
|
84
95
|
# The per-user limit, a fixed window: the window's number is in the key, so
|
|
85
96
|
# its end (retry_after) is known exactly. Rails' own rate_limit macro fixes
|
|
86
97
|
# to:/within: when the class loads and counts nothing on a NullStore; this
|
|
@@ -6,14 +6,11 @@
|
|
|
6
6
|
# The view is a bare content wrapper that renders inside each host's
|
|
7
7
|
# application.html.erb, so it inherits that app's navbar + theme automatically.
|
|
8
8
|
#
|
|
9
|
-
# Reached at /admin/style (helper admin_style_path).
|
|
10
|
-
# route redirects here and keeps its admin_design_system_path helper resolving, so a
|
|
11
|
-
# shipped host sidebar link that still points at the old helper keeps working.
|
|
9
|
+
# Reached at /admin/style (helper admin_style_path).
|
|
12
10
|
class StyleController < ApplicationController
|
|
13
11
|
before_action :require_admin
|
|
14
12
|
|
|
15
|
-
# Load the live theme so the Theme section (the landing section
|
|
16
|
-
# /admin/theme) shows this app's saved colors + the CSRF-protected editor.
|
|
13
|
+
# Load the live theme so the Theme section (the landing section) shows this app's saved colors + the CSRF-protected editor.
|
|
17
14
|
# Best-effort: the section renders from Studio.theme_config defaults when no
|
|
18
15
|
# ThemeSetting row exists, so a load hiccup must not take the whole page down.
|
|
19
16
|
def index
|
|
@@ -1,10 +1,11 @@
|
|
|
1
|
+
# The role colours' save endpoints. The editor lives on the style guide
|
|
2
|
+
# (style/_theme), so GET /admin/theme sends an admin there; the route and its
|
|
3
|
+
# name stay, because hosts link and register them.
|
|
1
4
|
class ThemeSettingsController < ApplicationController
|
|
2
5
|
before_action :require_admin
|
|
3
6
|
|
|
4
7
|
def edit
|
|
5
|
-
|
|
6
|
-
@defaults = Studio.theme_config
|
|
7
|
-
@preview_css = Studio::ThemeResolver.new(@theme_setting.resolved_colors).to_css
|
|
8
|
+
redirect_to editor_path
|
|
8
9
|
end
|
|
9
10
|
|
|
10
11
|
def update
|
|
@@ -18,10 +19,7 @@ class ThemeSettingsController < ApplicationController
|
|
|
18
19
|
rescue StandardError => e
|
|
19
20
|
return render(json: { error: e.message }, status: :unprocessable_entity) if request.format.json?
|
|
20
21
|
|
|
21
|
-
|
|
22
|
-
@preview_css = Studio::ThemeResolver.new(@theme_setting.resolved_colors).to_css
|
|
23
|
-
flash.now[:alert] = "Error saving theme: #{e.message}"
|
|
24
|
-
render :edit, status: :unprocessable_entity
|
|
22
|
+
redirect_to editor_path, alert: "Error saving theme: #{e.message}"
|
|
25
23
|
end
|
|
26
24
|
|
|
27
25
|
def regenerate
|
|
@@ -31,12 +29,14 @@ class ThemeSettingsController < ApplicationController
|
|
|
31
29
|
|
|
32
30
|
private
|
|
33
31
|
|
|
32
|
+
def editor_path = admin_style_path(anchor: "theme")
|
|
33
|
+
|
|
34
34
|
# The style guide saves by fetch and asks for JSON: a 204 is its proof of the
|
|
35
35
|
# write. A form post is redirected back to the editor.
|
|
36
36
|
def done(notice)
|
|
37
37
|
return head(:no_content) if request.format.json?
|
|
38
38
|
|
|
39
|
-
redirect_to
|
|
39
|
+
redirect_to editor_path, notice: notice
|
|
40
40
|
end
|
|
41
41
|
|
|
42
42
|
def theme_params
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Studio
|
|
4
|
+
# The agent chat's voice option (docs/AGENT_CHAT.md, "Talking by voice").
|
|
5
|
+
module AgentChatHelper
|
|
6
|
+
# The signed-in user on this page, or nil. A page whose controller has no
|
|
7
|
+
# current_user is a signed-out page.
|
|
8
|
+
def studio_agent_chat_viewer
|
|
9
|
+
respond_to?(:current_user) ? current_user : nil
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# Whether this viewer gets the microphone: the app turned takes on (no route
|
|
13
|
+
# otherwise) and Studio.voice_chat_allowed lets them talk. Never a signed-out
|
|
14
|
+
# visitor: POST /voice/takes is sign-in only.
|
|
15
|
+
def studio_agent_chat_voice?
|
|
16
|
+
return false unless Studio.voice_transcription && respond_to?(:studio_voice_takes_path)
|
|
17
|
+
|
|
18
|
+
Studio.voice_chat_allowed?(studio_agent_chat_viewer)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# The embedder the page runs on a take (Studio::VoiceMatch.browser_config),
|
|
22
|
+
# as JSON, or nil. Only for a user whose saved voice came from the app's own
|
|
23
|
+
# embedder: anything else answers embedder_mismatch or no_profile on the
|
|
24
|
+
# server, so downloading the model would buy nothing.
|
|
25
|
+
def studio_agent_chat_embedder(user)
|
|
26
|
+
return nil unless user.respond_to?(:voice_embedding) && user.respond_to?(:voice_embedding_model)
|
|
27
|
+
return nil if user.voice_embedding.blank?
|
|
28
|
+
|
|
29
|
+
name = Studio.voice_embedder.to_s
|
|
30
|
+
return nil unless user.voice_embedding_model.to_s == Studio::VoiceMatch.model_id(name)
|
|
31
|
+
|
|
32
|
+
Studio::VoiceMatch.browser_config(name).to_json
|
|
33
|
+
rescue ArgumentError, KeyError
|
|
34
|
+
nil
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -11,8 +11,8 @@ module Studio
|
|
|
11
11
|
# <%= studio_footer_map %> the map alone, e.g. on a contact page
|
|
12
12
|
#
|
|
13
13
|
# THE FOOTER IS ON BY DEFAULT: with Studio.site_footer unset, the layout line
|
|
14
|
-
# renders the default footer (the site name, the logo,
|
|
15
|
-
#
|
|
14
|
+
# renders the default footer (the site name, the logo, the legal links the host
|
|
15
|
+
# has routes for, and the Marketing Kit link where the page is drawn), and `config.site_footer = false` turns it off. The
|
|
16
16
|
# booking helpers render NOTHING without Studio.booking_url, and the map
|
|
17
17
|
# nothing without an address, so a layout or a shared view can call any of
|
|
18
18
|
# them before an app has declared anything.
|
|
@@ -70,6 +70,19 @@ module Studio
|
|
|
70
70
|
zoom: options[:zoom], controls: options.fetch(:controls, true)
|
|
71
71
|
end
|
|
72
72
|
|
|
73
|
+
# The kit /marketing-kit prints (Studio.marketing_kit_for). A callable that
|
|
74
|
+
# raises costs the app's own facts, not the page: in production the error is
|
|
75
|
+
# logged once and the page shows what derives from the theme. In development
|
|
76
|
+
# and test it raises.
|
|
77
|
+
def studio_marketing_kit
|
|
78
|
+
Studio.marketing_kit_for(self)
|
|
79
|
+
rescue StandardError => error
|
|
80
|
+
raise if studio_site_footer_raise?
|
|
81
|
+
|
|
82
|
+
studio_site_footer_report(error)
|
|
83
|
+
Studio.marketing_kit_for(self, declared: nil)
|
|
84
|
+
end
|
|
85
|
+
|
|
73
86
|
# ---- booking ------------------------------------------------------------
|
|
74
87
|
|
|
75
88
|
def studio_booking? = Studio.booking_url.present?
|
|
@@ -18,6 +18,12 @@
|
|
|
18
18
|
// until the app answers 201; a refusal shows its reason above them.
|
|
19
19
|
// FOLLOW the thread stays at its end while the reader is within FOLLOW_PX of
|
|
20
20
|
// it, and always after their own send (the hub's chat_follow rule).
|
|
21
|
+
// REFRESH the public hook for code outside the chat (studio/agent_chat_voice, a
|
|
22
|
+
// host's own composer): dispatch studio:agent-chat:refresh on the
|
|
23
|
+
// chat's section, optionally with {detail: {turn}}, a turn the app
|
|
24
|
+
// just answered. The turn shows at once, the chat follows it, and it
|
|
25
|
+
// polls now; a poll already in flight is followed by one more, so a
|
|
26
|
+
// refresh is never swallowed.
|
|
21
27
|
// TYPING the typing line shows while the hub says an answer is pending:
|
|
22
28
|
// "thinking", then "typing" once a turn's status is "typing".
|
|
23
29
|
//
|
|
@@ -31,6 +37,7 @@ export const IDLE_MS = 30000
|
|
|
31
37
|
export const OFFLINE_MS = 15000
|
|
32
38
|
export const FOLLOW_PX = 20
|
|
33
39
|
export const LIMIT = 50
|
|
40
|
+
export const REFRESH_EVENT = "studio:agent-chat:refresh"
|
|
34
41
|
const UNSETTLED = new Set(["pending", "typing"])
|
|
35
42
|
|
|
36
43
|
export function settled(turn) {
|
|
@@ -123,7 +130,7 @@ export function mountAgentChat(root, {
|
|
|
123
130
|
const soul = root.dataset.agentChatSoul
|
|
124
131
|
const name = root.dataset.agentChatName || "The agent"
|
|
125
132
|
|
|
126
|
-
const state = { turns: [], pending: false, waitingSince: null, offline: false, inFlight: false, sending: false, timer: null, alive: true, following: true }
|
|
133
|
+
const state = { turns: [], pending: false, waitingSince: null, offline: false, inFlight: false, pollAgain: false, sending: false, timer: null, alive: true, following: true }
|
|
127
134
|
|
|
128
135
|
const onScroll = () => { state.following = isFollowing(log) }
|
|
129
136
|
log.addEventListener("scroll", onScroll, { passive: true })
|
|
@@ -199,10 +206,40 @@ export function mountAgentChat(root, {
|
|
|
199
206
|
setOffline(true)
|
|
200
207
|
} finally {
|
|
201
208
|
state.inFlight = false
|
|
202
|
-
|
|
209
|
+
if (state.pollAgain) {
|
|
210
|
+
state.pollAgain = false
|
|
211
|
+
if (state.alive) poll()
|
|
212
|
+
} else {
|
|
213
|
+
schedule()
|
|
214
|
+
}
|
|
203
215
|
}
|
|
204
216
|
}
|
|
205
217
|
|
|
218
|
+
// The public refresh hook (REFRESH above). Returns poll's promise, or nothing
|
|
219
|
+
// when the poll in flight will be followed by another.
|
|
220
|
+
function refresh({ turn } = {}) {
|
|
221
|
+
if (!state.alive) return undefined
|
|
222
|
+
if (turn) {
|
|
223
|
+
state.turns = mergeTurns(state.turns, [turn])
|
|
224
|
+
state.pending = true
|
|
225
|
+
state.following = true
|
|
226
|
+
state.waitingSince = now()
|
|
227
|
+
setOffline(false)
|
|
228
|
+
render()
|
|
229
|
+
toEnd()
|
|
230
|
+
}
|
|
231
|
+
if (state.inFlight) {
|
|
232
|
+
state.pollAgain = true
|
|
233
|
+
return undefined
|
|
234
|
+
}
|
|
235
|
+
clearTimer(state.timer)
|
|
236
|
+
return poll()
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function onRefresh(event) {
|
|
240
|
+
refresh(event.detail || {})
|
|
241
|
+
}
|
|
242
|
+
|
|
206
243
|
async function submit(event) {
|
|
207
244
|
if (event) event.preventDefault()
|
|
208
245
|
const text = (input.value || "").trim()
|
|
@@ -264,6 +301,7 @@ export function mountAgentChat(root, {
|
|
|
264
301
|
form && form.addEventListener("submit", submit)
|
|
265
302
|
input && input.addEventListener("keydown", onKeydown)
|
|
266
303
|
retry && retry.addEventListener("click", onRetry)
|
|
304
|
+
root.addEventListener(REFRESH_EVENT, onRefresh)
|
|
267
305
|
doc && doc.addEventListener && doc.addEventListener("visibilitychange", onVisible)
|
|
268
306
|
root.dataset.agentChatReady = "true"
|
|
269
307
|
poll()
|
|
@@ -271,6 +309,7 @@ export function mountAgentChat(root, {
|
|
|
271
309
|
return {
|
|
272
310
|
state,
|
|
273
311
|
poll,
|
|
312
|
+
refresh,
|
|
274
313
|
submit,
|
|
275
314
|
destroy() {
|
|
276
315
|
state.alive = false
|
|
@@ -279,6 +318,7 @@ export function mountAgentChat(root, {
|
|
|
279
318
|
form && form.removeEventListener("submit", submit)
|
|
280
319
|
input && input.removeEventListener("keydown", onKeydown)
|
|
281
320
|
retry && retry.removeEventListener("click", onRetry)
|
|
321
|
+
root.removeEventListener(REFRESH_EVENT, onRefresh)
|
|
282
322
|
doc && doc.removeEventListener && doc.removeEventListener("visibilitychange", onVisible)
|
|
283
323
|
delete root.dataset.agentChatReady
|
|
284
324
|
}
|