studio-engine 0.115.4.rc1 → 0.116.0.rc1
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 +31 -0
- data/README.md +9 -0
- data/app/assets/tailwind/studio_engine/engine-motion.css +153 -0
- data/app/assets/tailwind/studio_engine/engine.css +48 -2
- data/app/controllers/studio/agent_chats_controller.rb +178 -0
- data/app/javascript/studio/agent_chat.js +286 -0
- data/app/javascript/studio/controllers/agent_chat_controller.js +20 -0
- data/app/javascript/studio/stimulus.js +5 -2
- data/app/views/studio/agent_chat/_chat.html.erb +52 -0
- data/app/views/studio/agent_chat/_form.html.erb +23 -0
- data/app/views/studio/agent_chat/_header.html.erb +14 -0
- data/app/views/studio/agent_chat/_turn.html.erb +19 -0
- data/app/views/studio/agent_chat/_typing.html.erb +18 -0
- data/lib/studio/agent_chat.rb +216 -0
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +39 -0
- metadata +10 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2c4110fb2193a6a381c74f20802d33e407c06192bd454f5b3e11b8f681cb9728
|
|
4
|
+
data.tar.gz: acc4906d77b7a8e286c5c601c86113964e3b7fab1073fe1332b77f0e54e78a61
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5bb8eb6f9ef719a032a0a1ec05394222a70753e107a406fb82c7c2adf0cea769fc673395448e65fe3d356542450edb933f9eb9e8402b9a7dc14df86c5991d47d
|
|
7
|
+
data.tar.gz: 76a7b9f0d7a60022406cdfa66b0b02e5245ef7bba95cbdcc997c6c9277b00c103cb85547fb44ce89996844c3f39c9b412736321b32bffa0eb34b923b6e0a3fcf
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,37 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
|
|
|
4
4
|
|
|
5
5
|
## Unreleased
|
|
6
6
|
|
|
7
|
+
## 0.116.0 — 2026-10-28
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **The shared agent chat.** `render "studio/agent_chat/chat", soul:, name:, portrait:`
|
|
12
|
+
puts one chat with a hub agent on any page: the portrait header, a card slot
|
|
13
|
+
(`cards:`), the thread, the thinking/typing line, the composer and a friendly
|
|
14
|
+
retry when the hub is down, lifted from the hub's /build chat. The hub holds the
|
|
15
|
+
thread; `Studio::AgentChatsController` (`POST /agent_chat/messages`,
|
|
16
|
+
`GET /agent_chat/turns`) proxies to it with the signed-in user's email or a
|
|
17
|
+
signed anonymous cookie token, and `Studio::AgentChat` is the bounded hub
|
|
18
|
+
client. Only a verified email (`email_verified_at`, or the app's
|
|
19
|
+
`Studio.agent_chat_verified_email` hook) names a person to the hub; anyone
|
|
20
|
+
else chats anonymously. Opt in with `Studio.agent_chat_souls` and
|
|
21
|
+
`AGENT_CHAT_TOKEN`; no soul, no route. [`docs/AGENT_CHAT.md`](docs/AGENT_CHAT.md).
|
|
22
|
+
|
|
23
|
+
### Changed
|
|
24
|
+
|
|
25
|
+
- **The gear, the name and the avatar light together.** Where the account opens
|
|
26
|
+
the link sidebar, as the gear does, pointing at or focusing any of the three
|
|
27
|
+
turns the gear and the name the theme's primary, turns the gear an eighth
|
|
28
|
+
(not under reduced motion) and puts a soft primary glow on the avatar; the
|
|
29
|
+
gear's own hover pill is dropped inside the group. CSS only (`engine.css`,
|
|
30
|
+
*THE ACCOUNT GROUP*), keyed on the row's existing hooks, so the navbar
|
|
31
|
+
baseline's bytes are unchanged. Two shapes group: the engine's link sidebar
|
|
32
|
+
gear with an account that opens it, and a host's gear in the menu trigger
|
|
33
|
+
seat (`Studio.navbar_menu_trigger`) with an account that opens the host's
|
|
34
|
+
sidebar (`Studio.navbar_account_sidebar`), as Turf Monster's gear sidebar
|
|
35
|
+
does. An account link is never grouped. Guards: `test/views/navbar_account_group_css_test.rb`,
|
|
36
|
+
`test/views/user_nav_test.rb`, `e2e/navbar_account_group.spec.js`.
|
|
37
|
+
|
|
7
38
|
## 0.115.4 — 2026-10-27
|
|
8
39
|
|
|
9
40
|
### Changed
|
data/README.md
CHANGED
|
@@ -245,6 +245,8 @@ Set `Studio.draw_survey_routes = true` to add the public survey pages (`/surveys
|
|
|
245
245
|
|
|
246
246
|
**Magic links need the `studio_links` table.** Install it with `bin/rails studio_engine:install:migrations && bin/rails db:migrate` (install all of them) before enabling `:magic_link` — never by hand-copying the migration, which collides with the task's own copy on `class CreateStudioLinks`. Without the table, the first sign-in raises `Studio::Link::MissingTable`.
|
|
247
247
|
|
|
248
|
+
Set `Studio.agent_chat_souls = %w[alex]` to add the shared agent chat's proxy (`POST /agent_chat/messages`, `GET /agent_chat/turns`), which forwards to the McRitchie Studio hub with the app's `AGENT_CHAT_TOKEN`; then `render "studio/agent_chat/chat", soul: "alex", name: "Alex", portrait: ...` on any page ([`docs/AGENT_CHAT.md`](docs/AGENT_CHAT.md)).
|
|
249
|
+
|
|
248
250
|
In non-production local requests, this also draws `/_studio/local_emails`, a local email inbox for agent/worktree proof flows. Set `LOCAL_EMAIL_CAPTURE=1` or run with `AGENT_WORKTREE=1` to record outbox rows without sending real email.
|
|
249
251
|
|
|
250
252
|
In every environment, production included, it also draws `/_studio/health_report`: the daily health report as counts-only JSON, which the McRitchie Studio hub pulls at 07:00 and posts to Discord. It answers 404 until the app sets `STUDIO_HEALTH_TOKEN` (or `config.health_report_token`), and 401 to a request without that bearer token ([`docs/HEALTH_REPORT.md`](docs/HEALTH_REPORT.md#the-hub-endpoint)).
|
|
@@ -1097,6 +1099,13 @@ helper `studio_account_opens_sidebar?` to decide otherwise; with no sidebar
|
|
|
1097
1099
|
the account stays a link to the account page. Log out lives in the sidebar,
|
|
1098
1100
|
never the bar.
|
|
1099
1101
|
|
|
1102
|
+
Since the gear and the account open the same sidebar, they read as one control:
|
|
1103
|
+
pointing at or focusing any of the gear, the name or the avatar lights all three
|
|
1104
|
+
(the primary colour, a turn of the gear, a primary glow on the avatar). The
|
|
1105
|
+
same holds for a host sidebar: a host that sets both `navbar_menu_trigger` and
|
|
1106
|
+
`navbar_account_sidebar` gets its gear and account lit as one menu. An account
|
|
1107
|
+
link is never grouped (`engine.css`, *THE ACCOUNT GROUP*).
|
|
1108
|
+
|
|
1100
1109
|
## Transactional emails
|
|
1101
1110
|
|
|
1102
1111
|
Every consuming app can render the same admin page — **`/admin/emails`** —
|
|
@@ -2010,3 +2010,156 @@ body.modal-open { overflow: visible; }
|
|
|
2010
2010
|
.email-reject { animation-name: email-reject-fade; }
|
|
2011
2011
|
}
|
|
2012
2012
|
}
|
|
2013
|
+
|
|
2014
|
+
/* -- Agent chat: a pending answer's indicator --------------------------------
|
|
2015
|
+
studio/agent_chat/_typing, lifted from the hub's agent_typing.css. Two looks
|
|
2016
|
+
share one grid cell, so the swap from thinking to typing never moves the page:
|
|
2017
|
+
|
|
2018
|
+
THINKING the agent's portrait in a soft turning ring with a glow, and
|
|
2019
|
+
"<Agent> is thinking" with a slow shimmer across it.
|
|
2020
|
+
TYPING a chat bubble on the agent's side with three wave dots.
|
|
2021
|
+
|
|
2022
|
+
data-phase picks the look (studio/agent_chat sets it); the other fades out
|
|
2023
|
+
and is hidden, so it is not read aloud. Theme tokens only, so dark mode
|
|
2024
|
+
follows the theme. Under prefers-reduced-motion nothing moves and the swap
|
|
2025
|
+
is instant. In the components layer, like every rule here; needs this
|
|
2026
|
+
layer, which host.css imports (a host that skips it sees both labels). */
|
|
2027
|
+
@layer components {
|
|
2028
|
+
.studio-agent-typing {
|
|
2029
|
+
--studio-agent-typing-beat: 2.4s;
|
|
2030
|
+
--studio-agent-typing-fade: 280ms;
|
|
2031
|
+
display: grid;
|
|
2032
|
+
align-items: center;
|
|
2033
|
+
padding-block: 0.375rem;
|
|
2034
|
+
font-size: 0.75rem;
|
|
2035
|
+
line-height: 1rem;
|
|
2036
|
+
color: var(--color-text-muted);
|
|
2037
|
+
}
|
|
2038
|
+
|
|
2039
|
+
.studio-agent-typing > .studio-agent-typing-layer {
|
|
2040
|
+
grid-area: 1 / 1;
|
|
2041
|
+
display: flex;
|
|
2042
|
+
align-items: center;
|
|
2043
|
+
gap: 0.625rem;
|
|
2044
|
+
min-width: 0;
|
|
2045
|
+
transition: opacity var(--studio-agent-typing-fade) ease, transform var(--studio-agent-typing-fade) ease,
|
|
2046
|
+
visibility 0s linear 0s;
|
|
2047
|
+
}
|
|
2048
|
+
|
|
2049
|
+
.studio-agent-typing[data-phase="thinking"] > .studio-agent-typing-types,
|
|
2050
|
+
.studio-agent-typing[data-phase="typing"] > .studio-agent-typing-thinks {
|
|
2051
|
+
opacity: 0;
|
|
2052
|
+
transform: translateY(3px);
|
|
2053
|
+
visibility: hidden;
|
|
2054
|
+
pointer-events: none;
|
|
2055
|
+
transition: opacity var(--studio-agent-typing-fade) ease, transform var(--studio-agent-typing-fade) ease,
|
|
2056
|
+
visibility 0s linear var(--studio-agent-typing-fade);
|
|
2057
|
+
}
|
|
2058
|
+
|
|
2059
|
+
.studio-agent-typing-label {
|
|
2060
|
+
min-width: 0;
|
|
2061
|
+
overflow: hidden;
|
|
2062
|
+
text-overflow: ellipsis;
|
|
2063
|
+
white-space: nowrap;
|
|
2064
|
+
}
|
|
2065
|
+
|
|
2066
|
+
.studio-agent-typing-orbit {
|
|
2067
|
+
position: relative;
|
|
2068
|
+
display: inline-flex;
|
|
2069
|
+
flex: none;
|
|
2070
|
+
border-radius: 9999px;
|
|
2071
|
+
padding: 3px;
|
|
2072
|
+
}
|
|
2073
|
+
|
|
2074
|
+
.studio-agent-typing-orbit::before,
|
|
2075
|
+
.studio-agent-typing-orbit::after {
|
|
2076
|
+
content: "";
|
|
2077
|
+
position: absolute;
|
|
2078
|
+
inset: 0;
|
|
2079
|
+
border-radius: inherit;
|
|
2080
|
+
pointer-events: none;
|
|
2081
|
+
}
|
|
2082
|
+
|
|
2083
|
+
.studio-agent-typing-orbit::before {
|
|
2084
|
+
background: conic-gradient(
|
|
2085
|
+
from 0deg,
|
|
2086
|
+
rgb(var(--color-primary-rgb) / 0) 0deg,
|
|
2087
|
+
rgb(var(--color-primary-rgb) / 0.25) 90deg,
|
|
2088
|
+
rgb(var(--color-primary-rgb) / 0.95) 200deg,
|
|
2089
|
+
rgb(var(--color-primary-rgb) / 0) 300deg
|
|
2090
|
+
);
|
|
2091
|
+
-webkit-mask: radial-gradient(farthest-side, transparent calc(100% - 2px), #000 calc(100% - 1.5px));
|
|
2092
|
+
mask: radial-gradient(farthest-side, transparent calc(100% - 2px), #000 calc(100% - 1.5px));
|
|
2093
|
+
animation: studio-agent-typing-spin var(--studio-agent-typing-beat) linear infinite;
|
|
2094
|
+
}
|
|
2095
|
+
|
|
2096
|
+
.studio-agent-typing-orbit::after {
|
|
2097
|
+
box-shadow: 0 0 0.9rem 0.05rem rgb(var(--color-primary-rgb) / 0.35);
|
|
2098
|
+
animation: studio-agent-typing-breathe var(--studio-agent-typing-beat) ease-in-out infinite;
|
|
2099
|
+
}
|
|
2100
|
+
|
|
2101
|
+
.studio-agent-typing-orbit > * {
|
|
2102
|
+
position: relative;
|
|
2103
|
+
z-index: 1;
|
|
2104
|
+
}
|
|
2105
|
+
|
|
2106
|
+
.studio-agent-typing-shimmer {
|
|
2107
|
+
background-image: linear-gradient(100deg, var(--color-text-muted) 0%, var(--color-text-muted) 38%,
|
|
2108
|
+
var(--color-text) 50%, var(--color-text-muted) 62%, var(--color-text-muted) 100%);
|
|
2109
|
+
background-size: 250% 100%;
|
|
2110
|
+
-webkit-background-clip: text;
|
|
2111
|
+
background-clip: text;
|
|
2112
|
+
-webkit-text-fill-color: transparent;
|
|
2113
|
+
color: transparent;
|
|
2114
|
+
animation: studio-agent-typing-shimmer var(--studio-agent-typing-beat) ease-in-out infinite;
|
|
2115
|
+
}
|
|
2116
|
+
|
|
2117
|
+
.studio-agent-typing-bubble {
|
|
2118
|
+
display: inline-flex;
|
|
2119
|
+
align-items: center;
|
|
2120
|
+
gap: 0.25rem;
|
|
2121
|
+
flex: none;
|
|
2122
|
+
padding: 0.625rem 0.75rem;
|
|
2123
|
+
border-radius: 1rem 1rem 1rem 0.25rem;
|
|
2124
|
+
background: var(--color-inset);
|
|
2125
|
+
border: 1px solid var(--color-border);
|
|
2126
|
+
}
|
|
2127
|
+
|
|
2128
|
+
.studio-agent-typing-dot {
|
|
2129
|
+
width: 0.4rem;
|
|
2130
|
+
height: 0.4rem;
|
|
2131
|
+
border-radius: 9999px;
|
|
2132
|
+
background: var(--color-text-muted);
|
|
2133
|
+
animation: studio-agent-typing-wave calc(var(--studio-agent-typing-beat) / 2) ease-in-out infinite;
|
|
2134
|
+
}
|
|
2135
|
+
|
|
2136
|
+
.studio-agent-typing-dot:nth-child(2) { animation-delay: 0.15s; }
|
|
2137
|
+
.studio-agent-typing-dot:nth-child(3) { animation-delay: 0.3s; }
|
|
2138
|
+
|
|
2139
|
+
@media (prefers-reduced-motion: reduce) {
|
|
2140
|
+
.studio-agent-typing-orbit::before,
|
|
2141
|
+
.studio-agent-typing-orbit::after,
|
|
2142
|
+
.studio-agent-typing-shimmer,
|
|
2143
|
+
.studio-agent-typing-dot { animation: none; }
|
|
2144
|
+
.studio-agent-typing > .studio-agent-typing-layer { transition: none; }
|
|
2145
|
+
}
|
|
2146
|
+
|
|
2147
|
+
@keyframes studio-agent-typing-spin {
|
|
2148
|
+
to { transform: rotate(360deg); }
|
|
2149
|
+
}
|
|
2150
|
+
|
|
2151
|
+
@keyframes studio-agent-typing-breathe {
|
|
2152
|
+
0%, 100% { opacity: 0.45; }
|
|
2153
|
+
50% { opacity: 1; }
|
|
2154
|
+
}
|
|
2155
|
+
|
|
2156
|
+
@keyframes studio-agent-typing-shimmer {
|
|
2157
|
+
0% { background-position: 100% 0; }
|
|
2158
|
+
100% { background-position: 0% 0; }
|
|
2159
|
+
}
|
|
2160
|
+
|
|
2161
|
+
@keyframes studio-agent-typing-wave {
|
|
2162
|
+
0%, 60%, 100% { transform: translateY(0); opacity: 0.5; }
|
|
2163
|
+
30% { transform: translateY(-3px); opacity: 1; }
|
|
2164
|
+
}
|
|
2165
|
+
}
|
|
@@ -723,9 +723,55 @@ body.modal-open .studio-app-banner {
|
|
|
723
723
|
top: -0.25rem;
|
|
724
724
|
pointer-events: auto;
|
|
725
725
|
}
|
|
726
|
+
/* THE ACCOUNT GROUP (components/_user_nav). Where the gear and the account
|
|
727
|
+
(name and avatar) open the same sidebar, the three are one control to
|
|
728
|
+
the eye: pointing at or focusing any of them lights all three. The gear
|
|
729
|
+
and the name take the primary colour, the gear turns an eighth, and the
|
|
730
|
+
avatar glows in the brand's primary. The gear drops its own hover pill,
|
|
731
|
+
which would mark it out as a separate control.
|
|
732
|
+
|
|
733
|
+
The group is the user nav's row, found by what it holds, so no markup
|
|
734
|
+
changes (the navbar baseline holds its bytes). Two shapes group:
|
|
735
|
+
THE LINK SIDEBAR: the engine's gear ([data-link-sidebar-trigger]) and
|
|
736
|
+
an account whose aria-controls names studio-link-sidebar.
|
|
737
|
+
A HOST SIDEBAR: the host's gear in the menu trigger seat
|
|
738
|
+
(Studio.navbar_menu_trigger) and an account that opens the host's
|
|
739
|
+
sidebar (Studio.navbar_account_sidebar). A host that sets both is
|
|
740
|
+
saying they are its one menu, as Turf Monster's gear sidebar is.
|
|
741
|
+
An account link is never grouped. */
|
|
742
|
+
:has(> [data-nav-tools]):has(> [data-nav-account][aria-controls~="studio-link-sidebar"]) [data-link-sidebar-trigger] svg,
|
|
743
|
+
:has(> [data-nav-tools]):has(> [data-nav-account][aria-controls~="studio-link-sidebar"]) [data-nav-account] > [aria-hidden] > * {
|
|
744
|
+
transition: transform 200ms ease, box-shadow 200ms ease;
|
|
745
|
+
}
|
|
746
|
+
:has(> [data-nav-tools]):has(> [data-nav-account][aria-controls~="studio-link-sidebar"]) [data-link-sidebar-trigger]:hover { background-color: transparent; }
|
|
747
|
+
:has(> [data-nav-tools]):has(> [data-nav-account][aria-controls~="studio-link-sidebar"]):has([data-link-sidebar-trigger]:hover, [data-nav-account]:hover, [data-link-sidebar-trigger]:focus-visible, [data-nav-account]:focus-visible) [data-link-sidebar-trigger],
|
|
748
|
+
:has(> [data-nav-tools]):has(> [data-nav-account][aria-controls~="studio-link-sidebar"]):has([data-link-sidebar-trigger]:hover, [data-nav-account]:hover, [data-link-sidebar-trigger]:focus-visible, [data-nav-account]:focus-visible) [data-nav-name] { color: var(--color-primary); }
|
|
749
|
+
:has(> [data-nav-tools]):has(> [data-nav-account][aria-controls~="studio-link-sidebar"]):has([data-link-sidebar-trigger]:hover, [data-nav-account]:hover, [data-link-sidebar-trigger]:focus-visible, [data-nav-account]:focus-visible) [data-nav-account] > [aria-hidden] > * {
|
|
750
|
+
box-shadow:
|
|
751
|
+
0 0 0 2px rgb(var(--color-primary-rgb) / 0.85),
|
|
752
|
+
0 0 14px 2px rgb(var(--color-primary-rgb) / 0.45);
|
|
753
|
+
}
|
|
754
|
+
@media (prefers-reduced-motion: no-preference) {
|
|
755
|
+
:has(> [data-nav-tools]):has(> [data-nav-account][aria-controls~="studio-link-sidebar"]):has([data-link-sidebar-trigger]:hover, [data-nav-account]:hover, [data-link-sidebar-trigger]:focus-visible, [data-nav-account]:focus-visible) [data-link-sidebar-trigger] svg { transform: rotate(45deg); }
|
|
756
|
+
}
|
|
757
|
+
:has(> [data-nav-tools] [data-nav-menu-trigger]):has(> [data-nav-account][data-profile-image-toggle]:not([aria-controls~="studio-link-sidebar"])) [data-nav-menu-trigger] [aria-controls] svg,
|
|
758
|
+
:has(> [data-nav-tools] [data-nav-menu-trigger]):has(> [data-nav-account][data-profile-image-toggle]:not([aria-controls~="studio-link-sidebar"])) [data-nav-account] > [aria-hidden] > * {
|
|
759
|
+
transition: transform 200ms ease, box-shadow 200ms ease;
|
|
760
|
+
}
|
|
761
|
+
:has(> [data-nav-tools] [data-nav-menu-trigger]):has(> [data-nav-account][data-profile-image-toggle]:not([aria-controls~="studio-link-sidebar"])) [data-nav-menu-trigger] [aria-controls]:hover { background-color: transparent; }
|
|
762
|
+
:has(> [data-nav-tools] [data-nav-menu-trigger]):has(> [data-nav-account][data-profile-image-toggle]:not([aria-controls~="studio-link-sidebar"])):has([data-nav-menu-trigger] [aria-controls]:hover, [data-nav-account]:hover, [data-nav-menu-trigger] [aria-controls]:focus-visible, [data-nav-account]:focus-visible) [data-nav-menu-trigger] [aria-controls],
|
|
763
|
+
:has(> [data-nav-tools] [data-nav-menu-trigger]):has(> [data-nav-account][data-profile-image-toggle]:not([aria-controls~="studio-link-sidebar"])):has([data-nav-menu-trigger] [aria-controls]:hover, [data-nav-account]:hover, [data-nav-menu-trigger] [aria-controls]:focus-visible, [data-nav-account]:focus-visible) [data-nav-name] { color: var(--color-primary); }
|
|
764
|
+
:has(> [data-nav-tools] [data-nav-menu-trigger]):has(> [data-nav-account][data-profile-image-toggle]:not([aria-controls~="studio-link-sidebar"])):has([data-nav-menu-trigger] [aria-controls]:hover, [data-nav-account]:hover, [data-nav-menu-trigger] [aria-controls]:focus-visible, [data-nav-account]:focus-visible) [data-nav-account] > [aria-hidden] > * {
|
|
765
|
+
box-shadow:
|
|
766
|
+
0 0 0 2px rgb(var(--color-primary-rgb) / 0.85),
|
|
767
|
+
0 0 14px 2px rgb(var(--color-primary-rgb) / 0.45);
|
|
768
|
+
}
|
|
769
|
+
@media (prefers-reduced-motion: no-preference) {
|
|
770
|
+
:has(> [data-nav-tools] [data-nav-menu-trigger]):has(> [data-nav-account][data-profile-image-toggle]:not([aria-controls~="studio-link-sidebar"])):has([data-nav-menu-trigger] [aria-controls]:hover, [data-nav-account]:hover, [data-nav-menu-trigger] [aria-controls]:focus-visible, [data-nav-account]:focus-visible) [data-nav-menu-trigger] [aria-controls] svg { transform: rotate(45deg); }
|
|
771
|
+
}
|
|
726
772
|
/* THE ACCOUNT ROW (components/_user_nav), from 768px. Every app whose
|
|
727
|
-
navbar renders the engine's user nav gets this
|
|
728
|
-
|
|
773
|
+
navbar renders the engine's user nav gets this, Turf Monster included:
|
|
774
|
+
it renders the engine's partial, its gear in the menu trigger seat.
|
|
729
775
|
|
|
730
776
|
ICONS. The theme toggle leads the rail (the partial renders it first) and
|
|
731
777
|
both icons sit a size down: the moon at 14px, the gear's 16px glyph in a
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Studio
|
|
4
|
+
# The shared agent chat's proxy (docs/AGENT_CHAT.md). The browser talks only
|
|
5
|
+
# to its own app; this controller forwards its two calls to the hub through
|
|
6
|
+
# Studio::AgentChat:
|
|
7
|
+
#
|
|
8
|
+
# POST /agent_chat/messages soul, text -> 201 {turn}
|
|
9
|
+
# GET /agent_chat/turns soul, after, limit -> 200 {turns, pending}
|
|
10
|
+
#
|
|
11
|
+
# Drawn by Studio.routes only for an app that names a soul
|
|
12
|
+
# (Studio.agent_chat_souls).
|
|
13
|
+
#
|
|
14
|
+
# WHAT THE BROWSER MAY CHOOSE, AND WHAT IT MAY NOT:
|
|
15
|
+
#
|
|
16
|
+
# soul only from the app's allow-list (Studio.agent_chat_souls); off the
|
|
17
|
+
# list, or absent when the list holds more than one, is a 404. The
|
|
18
|
+
# hub re-checks that this app may address it.
|
|
19
|
+
# person never. A signed-in user with a VERIFIED email (email_verified_at
|
|
20
|
+
# present, or the app's Studio.agent_chat_verified_email hook) is
|
|
21
|
+
# that email. Everyone else, signed out or signed in with an address
|
|
22
|
+
# they only claimed (the profile page and password signup save any
|
|
23
|
+
# address), is a random token in a SIGNED, http-only cookie
|
|
24
|
+
# (ANON_COOKIE), minted on the first message: a claimed address
|
|
25
|
+
# would open its owner's hub thread on every app. A browser cannot
|
|
26
|
+
# name another person's email or forge another's token. A verified
|
|
27
|
+
# POST that still holds the cookie sends both, so the hub folds the
|
|
28
|
+
# anonymous thread into the person's; the cookie is then deleted.
|
|
29
|
+
# ip never: request.remote_ip, which the hub caps on.
|
|
30
|
+
#
|
|
31
|
+
# The host's ApplicationController is the base, so its session, current_user
|
|
32
|
+
# and CSRF protection hold: the POST must carry the page's authenticity token
|
|
33
|
+
# (the composer's form does; the script sends it as X-CSRF-Token). Sign-in is
|
|
34
|
+
# skipped: a signed-out visitor may chat, as on /build.
|
|
35
|
+
#
|
|
36
|
+
# Every answer is JSON and no-store. A hub that is down, slow or misconfigured
|
|
37
|
+
# is a 503 {error, retry: true}, which the page shows as a friendly retry, and
|
|
38
|
+
# a failed send or a refused app credential is an ErrorLog row (#capture).
|
|
39
|
+
class AgentChatsController < ::ApplicationController
|
|
40
|
+
skip_before_action :require_authentication, raise: false
|
|
41
|
+
protect_from_forgery with: :exception
|
|
42
|
+
|
|
43
|
+
ANON_COOKIE = :studio_agent_chat_anon
|
|
44
|
+
ANON_TTL = 1.year
|
|
45
|
+
UNAVAILABLE = "Can't reach the chat right now. Try again in a moment."
|
|
46
|
+
|
|
47
|
+
before_action :no_store
|
|
48
|
+
before_action :resolve_soul
|
|
49
|
+
|
|
50
|
+
def create
|
|
51
|
+
text = params[:text].to_s.strip
|
|
52
|
+
return render_error("Write a message first.", :unprocessable_entity) if text.empty?
|
|
53
|
+
if text.length > Studio::AgentChat::MAX_TEXT_LENGTH
|
|
54
|
+
return render_error("That message is too long (#{Studio::AgentChat::MAX_TEXT_LENGTH} characters at most).",
|
|
55
|
+
:unprocessable_entity)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
who = person(mint: true, fold: true)
|
|
59
|
+
turn = Studio::AgentChat.post_message(soul: @soul, text: text, person: who, ip: request.remote_ip)
|
|
60
|
+
# A signed-in post that carried the anon token has folded that thread into
|
|
61
|
+
# the person's: the token is spent. Kept on any failure, for the retry.
|
|
62
|
+
cookies.delete(ANON_COOKIE) if who.key?(:email) && who.key?(:anon_token)
|
|
63
|
+
render json: { turn: turn }, status: :created
|
|
64
|
+
rescue Studio::AgentChat::Error => e
|
|
65
|
+
render_hub_error(e)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def turns
|
|
69
|
+
who = person(mint: false)
|
|
70
|
+
# A visitor who has never sent a message has no thread; the hub need not be asked.
|
|
71
|
+
return render(json: { turns: [], pending: false }) unless who
|
|
72
|
+
|
|
73
|
+
after = params[:after].to_s.presence
|
|
74
|
+
after = nil if after && !after.match?(/\A[\w-]{1,64}\z/)
|
|
75
|
+
limit = params[:limit].presence&.to_i || Studio::AgentChat::MAX_LIMIT
|
|
76
|
+
render json: Studio::AgentChat.turns(soul: @soul, person: who, after: after, limit: limit)
|
|
77
|
+
rescue Studio::AgentChat::Error => e
|
|
78
|
+
render_hub_error(e)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
private
|
|
82
|
+
|
|
83
|
+
def no_store
|
|
84
|
+
response.headers["Cache-Control"] = "no-store"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def resolve_soul
|
|
88
|
+
souls = Studio.agent_chat_soul_list
|
|
89
|
+
asked = params[:soul].to_s.strip
|
|
90
|
+
@soul = if asked.empty? then (souls.one? ? souls.first : nil)
|
|
91
|
+
else souls.find { |soul| soul == asked }
|
|
92
|
+
end
|
|
93
|
+
render_error("No chat here.", :not_found) unless @soul
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# {email:} for a user with a VERIFIED email (#verified_email), else
|
|
97
|
+
# {anon_token:} from the signed cookie, as for a signed-out visitor. With
|
|
98
|
+
# mint: false a visitor with no cookie is nil.
|
|
99
|
+
#
|
|
100
|
+
# THE FOLD. With fold: true (the POST) a verified user who still holds a
|
|
101
|
+
# valid anon cookie is {email:, anon_token:}: the hub folds the anonymous
|
|
102
|
+
# thread into the person's, and #create then deletes the cookie. A verified
|
|
103
|
+
# user is never minted a token, and GET /turns sends the email alone (the
|
|
104
|
+
# fold is the hub's job on a post).
|
|
105
|
+
def person(mint:, fold: false)
|
|
106
|
+
email = verified_email
|
|
107
|
+
token = anon_token
|
|
108
|
+
if email
|
|
109
|
+
return fold && token ? { email: email, anon_token: token } : { email: email }
|
|
110
|
+
end
|
|
111
|
+
return { anon_token: token } if token
|
|
112
|
+
return nil unless mint
|
|
113
|
+
|
|
114
|
+
token = SecureRandom.urlsafe_base64(32)
|
|
115
|
+
cookies.signed[ANON_COOKIE] = {
|
|
116
|
+
value: token, expires: ANON_TTL.from_now, httponly: true, same_site: :lax,
|
|
117
|
+
secure: request.ssl?
|
|
118
|
+
}
|
|
119
|
+
{ anon_token: token }
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# The email the hub may know this user by, or nil. The hub keys a person's
|
|
123
|
+
# threads on it across every app, so a CLAIMED address is never enough: the
|
|
124
|
+
# profile page saves a new email directly and clears email_verified_at, and
|
|
125
|
+
# password signup saves any address. The app's Studio.agent_chat_verified_email
|
|
126
|
+
# hook decides when set; otherwise only a user whose email_verified_at is
|
|
127
|
+
# present (the magic link stamps it; so does the host from_omniauth pattern
|
|
128
|
+
# in docs/GOOGLE_AUTH_SETUP.md) counts. Anyone else chats as an
|
|
129
|
+
# anonymous visitor.
|
|
130
|
+
def verified_email
|
|
131
|
+
user = respond_to?(:current_user, true) ? current_user : nil
|
|
132
|
+
return nil unless user
|
|
133
|
+
|
|
134
|
+
hook = Studio.agent_chat_verified_email
|
|
135
|
+
email = if hook.respond_to?(:call) then hook.call(user)
|
|
136
|
+
elsif user.respond_to?(:email_verified_at) && user.email_verified_at.present? && user.respond_to?(:email)
|
|
137
|
+
user.email
|
|
138
|
+
end
|
|
139
|
+
email.to_s.strip.presence
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# The token in the signed anon cookie, or nil when there is none or it is
|
|
143
|
+
# not one this app minted.
|
|
144
|
+
def anon_token
|
|
145
|
+
token = cookies.signed[ANON_COOKIE].to_s
|
|
146
|
+
token.match?(/\A[A-Za-z0-9_-]{32,64}\z/) ? token : nil
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def render_hub_error(error)
|
|
150
|
+
case error
|
|
151
|
+
when Studio::AgentChat::Invalid then render_error(error.message, :unprocessable_entity)
|
|
152
|
+
when Studio::AgentChat::Capped
|
|
153
|
+
response.headers["Retry-After"] = error.retry_after.to_s if error.retry_after
|
|
154
|
+
render_error("You've sent a lot of messages. Wait a little, then try again.", :too_many_requests,
|
|
155
|
+
retry_after: error.retry_after)
|
|
156
|
+
when Studio::AgentChat::UnknownSoul then render_error("No chat here.", :not_found)
|
|
157
|
+
else
|
|
158
|
+
Rails.logger.warn("[studio] agent chat: #{error.class}: #{error.message}")
|
|
159
|
+
capture(error) if error.is_a?(Studio::AgentChat::Unauthorized) || action_name == "create"
|
|
160
|
+
render_error(UNAVAILABLE, :service_unavailable, retry: true)
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# An ErrorLog row for a message that could not be sent, and for every
|
|
165
|
+
# refusal of the app's own credential (a configuration fault). A poll that
|
|
166
|
+
# fails is only logged: an open page polls every 15 seconds while the hub is
|
|
167
|
+
# down, and a row per poll would bury the outage it reports. Never raises.
|
|
168
|
+
def capture(error)
|
|
169
|
+
::ErrorLog.capture!(error) if defined?(::ErrorLog) && ::ErrorLog.respond_to?(:capture!)
|
|
170
|
+
rescue StandardError
|
|
171
|
+
nil
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def render_error(message, status, **extra)
|
|
175
|
+
render json: { error: message, **extra }, status: status
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
// studio/agent_chat: the shared agent chat's thread (studio/agent_chat/_chat;
|
|
2
|
+
// docs/AGENT_CHAT.md). The hub holds the conversation; this page asks its own
|
|
3
|
+
// app for it (GET /agent_chat/turns, POST /agent_chat/messages), which proxies.
|
|
4
|
+
//
|
|
5
|
+
// The look is the ERB: each turn is a clone of a <template data-agent-chat-template>
|
|
6
|
+
// (studio/agent_chat/_turn), filled with textContent, never HTML.
|
|
7
|
+
//
|
|
8
|
+
// LOAD on connect, the thread's latest turns; a failure shows the offline
|
|
9
|
+
// notice with Try again (and retries itself, slowly).
|
|
10
|
+
// POLL answers arrive by polling, as on the hub's /build: every POLL_MS
|
|
11
|
+
// while an answer is pending, slowing to SLOW_MS once it has waited
|
|
12
|
+
// SLOW_AFTER_MS (a hub that is down is not hammered); every IDLE_MS
|
|
13
|
+
// otherwise, and not at all while the tab is hidden (it asks at once
|
|
14
|
+
// when the tab comes back). Each poll asks
|
|
15
|
+
// for turns AFTER the last settled one (afterCursor), so a turn still
|
|
16
|
+
// pending is asked about again until it settles.
|
|
17
|
+
// SEND Enter sends, Shift+Enter is a new line. The words stay in the box
|
|
18
|
+
// until the app answers 201; a refusal shows its reason above them.
|
|
19
|
+
// FOLLOW the thread stays at its end while the reader is within FOLLOW_PX of
|
|
20
|
+
// it, and always after their own send (the hub's chat_follow rule).
|
|
21
|
+
// TYPING the typing line shows while the hub says an answer is pending:
|
|
22
|
+
// "thinking", then "typing" once a turn's status is "typing".
|
|
23
|
+
//
|
|
24
|
+
// It imports nothing, so test/javascript/agent_chat.test.mjs loads it as a
|
|
25
|
+
// data: module; studio/controllers/agent_chat_controller mounts it.
|
|
26
|
+
|
|
27
|
+
export const POLL_MS = 2500
|
|
28
|
+
export const SLOW_MS = 10000
|
|
29
|
+
export const SLOW_AFTER_MS = 120000
|
|
30
|
+
export const IDLE_MS = 30000
|
|
31
|
+
export const OFFLINE_MS = 15000
|
|
32
|
+
export const FOLLOW_PX = 20
|
|
33
|
+
export const LIMIT = 50
|
|
34
|
+
const UNSETTLED = new Set(["pending", "typing"])
|
|
35
|
+
|
|
36
|
+
export function settled(turn) {
|
|
37
|
+
return !UNSETTLED.has(String(turn && turn.status))
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// A turn's identity is its id AND its role: the hub answers one message row as
|
|
41
|
+
// two turns with the row's id, the visitor's words ("user") and, once answered,
|
|
42
|
+
// the agent's ("agent"). Keyed by id alone, the answer would replace the words.
|
|
43
|
+
export function turnKey(turn) {
|
|
44
|
+
return `${turn.id}:${turn.role === "agent" ? "agent" : "user"}`
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Merge `incoming` turns into `turns` (both oldest first): a known (id, role) is
|
|
48
|
+
// replaced in place. A new agent turn goes right after its own user turn (same
|
|
49
|
+
// id), so when the visitor sends A then B before A is answered, A's answer sits
|
|
50
|
+
// under A, not under B. Any other new turn is appended. Returns a new array.
|
|
51
|
+
export function mergeTurns(turns, incoming) {
|
|
52
|
+
const merged = turns.slice()
|
|
53
|
+
for (const turn of incoming || []) {
|
|
54
|
+
if (!turn || turn.id == null) continue
|
|
55
|
+
const key = turnKey(turn)
|
|
56
|
+
const at = merged.findIndex((known) => turnKey(known) === key)
|
|
57
|
+
if (at !== -1) { merged[at] = turn; continue }
|
|
58
|
+
const own = turn.role === "agent" ? merged.findIndex((known) => turnKey(known) === `${turn.id}:user`) : -1
|
|
59
|
+
if (own !== -1) merged.splice(own + 1, 0, turn)
|
|
60
|
+
else merged.push(turn)
|
|
61
|
+
}
|
|
62
|
+
return merged
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// The `after` a poll sends: the id of the last turn before the first one still
|
|
66
|
+
// unsettled (so that one is asked about again), or of the last turn when all
|
|
67
|
+
// are settled. null asks for the latest turns. `after` is a hub ROW id (rows
|
|
68
|
+
// with id > after): an unsettled row's user turn is itself unsettled, so the
|
|
69
|
+
// cursor lands on an earlier row and the pending row is asked about again.
|
|
70
|
+
export function afterCursor(turns) {
|
|
71
|
+
const first = turns.findIndex((turn) => !settled(turn))
|
|
72
|
+
if (first === -1) return turns.length ? String(turns[turns.length - 1].id) : null
|
|
73
|
+
return first === 0 ? null : String(turns[first - 1].id)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Milliseconds to the next poll.
|
|
77
|
+
export function pollDelay({ waiting, waitingSince, now, offline }) {
|
|
78
|
+
if (offline) return OFFLINE_MS
|
|
79
|
+
if (!waiting) return IDLE_MS
|
|
80
|
+
return now - (waitingSince ?? now) > SLOW_AFTER_MS ? SLOW_MS : POLL_MS
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function typingPhase(turns) {
|
|
84
|
+
return turns.some((turn) => turn.status === "typing") ? "typing" : "thinking"
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function isFollowing(log, px = FOLLOW_PX) {
|
|
88
|
+
return log.scrollHeight - log.clientHeight - log.scrollTop <= px
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// The page's authenticity token: the form's own field, else the csrf meta.
|
|
92
|
+
export function csrfToken(form, doc = globalThis.document) {
|
|
93
|
+
const field = form && form.querySelector("input[name='authenticity_token']")
|
|
94
|
+
if (field && field.value) return field.value
|
|
95
|
+
const meta = doc && doc.querySelector("meta[name='csrf-token']")
|
|
96
|
+
return meta ? meta.getAttribute("content") : null
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function turnsUrl(base, { soul, after, limit = LIMIT }) {
|
|
100
|
+
const params = new URLSearchParams({ soul, limit: String(limit) })
|
|
101
|
+
if (after != null) params.set("after", after)
|
|
102
|
+
return `${base}${base.includes("?") ? "&" : "?"}${params}`
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function mountAgentChat(root, {
|
|
106
|
+
fetch: fetcher = globalThis.fetch.bind(globalThis),
|
|
107
|
+
doc = globalThis.document,
|
|
108
|
+
now = () => Date.now(),
|
|
109
|
+
setTimer = (fn, ms) => globalThis.setTimeout(fn, ms),
|
|
110
|
+
clearTimer = (id) => globalThis.clearTimeout(id)
|
|
111
|
+
} = {}) {
|
|
112
|
+
const $ = (selector) => root.querySelector(selector)
|
|
113
|
+
const log = $("[data-agent-chat-log]")
|
|
114
|
+
const typing = $("[data-agent-chat-typing]")
|
|
115
|
+
const phase = $("[data-agent-chat-phase]")
|
|
116
|
+
const offlineNote = $("[data-agent-chat-offline]")
|
|
117
|
+
const retry = $("[data-agent-chat-retry]")
|
|
118
|
+
const form = $("[data-agent-chat-form]")
|
|
119
|
+
const input = $("[data-agent-chat-input]")
|
|
120
|
+
const send = $("[data-agent-chat-send]")
|
|
121
|
+
const error = $("[data-agent-chat-error]")
|
|
122
|
+
const status = $("[data-agent-chat-status]")
|
|
123
|
+
const soul = root.dataset.agentChatSoul
|
|
124
|
+
const name = root.dataset.agentChatName || "The agent"
|
|
125
|
+
|
|
126
|
+
const state = { turns: [], pending: false, waitingSince: null, offline: false, inFlight: false, sending: false, timer: null, alive: true, following: true }
|
|
127
|
+
|
|
128
|
+
const onScroll = () => { state.following = isFollowing(log) }
|
|
129
|
+
log.addEventListener("scroll", onScroll, { passive: true })
|
|
130
|
+
|
|
131
|
+
function toEnd() { log.scrollTop = log.scrollHeight }
|
|
132
|
+
|
|
133
|
+
function setOffline(offline) {
|
|
134
|
+
state.offline = offline
|
|
135
|
+
if (offlineNote) offlineNote.hidden = !offline
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function showError(text) {
|
|
139
|
+
if (!error) return
|
|
140
|
+
error.textContent = text || ""
|
|
141
|
+
error.hidden = !text
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function render() {
|
|
145
|
+
const follow = state.following
|
|
146
|
+
for (const turn of state.turns) {
|
|
147
|
+
const key = turnKey(turn)
|
|
148
|
+
let node = Array.from(log.querySelectorAll("[data-turn-key]")).find((el) => el.dataset.turnKey === key)
|
|
149
|
+
if (!node) {
|
|
150
|
+
const template = root.querySelector(`template[data-agent-chat-template="${turn.role === "agent" ? "agent" : "user"}"]`)
|
|
151
|
+
if (!template) continue
|
|
152
|
+
node = template.content.firstElementChild.cloneNode(true)
|
|
153
|
+
node.dataset.turnId = String(turn.id)
|
|
154
|
+
node.dataset.turnKey = key
|
|
155
|
+
// Placed where mergeTurns put it: after the previous turn's element.
|
|
156
|
+
const index = state.turns.indexOf(turn)
|
|
157
|
+
const prevKey = index > 0 ? turnKey(state.turns[index - 1]) : null
|
|
158
|
+
const prev = prevKey && Array.from(log.querySelectorAll("[data-turn-key]")).find((el) => el.dataset.turnKey === prevKey)
|
|
159
|
+
if (prev) prev.after(node)
|
|
160
|
+
else log.appendChild(node)
|
|
161
|
+
}
|
|
162
|
+
node.dataset.status = turn.status || ""
|
|
163
|
+
const text = node.querySelector("[data-agent-chat-text]")
|
|
164
|
+
if (text && text.textContent !== String(turn.text ?? "")) text.textContent = String(turn.text ?? "")
|
|
165
|
+
const failed = node.querySelector("[data-agent-chat-failed]")
|
|
166
|
+
if (failed) failed.hidden = turn.status !== "failed"
|
|
167
|
+
}
|
|
168
|
+
const waiting = state.pending || state.turns.some((turn) => !settled(turn))
|
|
169
|
+
if (typing) typing.hidden = !waiting
|
|
170
|
+
if (phase) phase.dataset.phase = typingPhase(state.turns)
|
|
171
|
+
if (follow) toEnd()
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function schedule() {
|
|
175
|
+
if (!state.alive) return
|
|
176
|
+
clearTimer(state.timer)
|
|
177
|
+
const waiting = state.pending || state.turns.some((turn) => !settled(turn))
|
|
178
|
+
if (waiting) state.waitingSince = state.waitingSince || now()
|
|
179
|
+
else state.waitingSince = null
|
|
180
|
+
state.timer = setTimer(poll, pollDelay({ waiting, waitingSince: state.waitingSince, now: now(), offline: state.offline }))
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function poll() {
|
|
184
|
+
if (!state.alive) return
|
|
185
|
+
const hidden = doc && doc.visibilityState === "hidden"
|
|
186
|
+
if (hidden || state.inFlight) { schedule(); return }
|
|
187
|
+
state.inFlight = true
|
|
188
|
+
try {
|
|
189
|
+
const response = await fetcher(turnsUrl(root.dataset.agentChatTurnsUrl, { soul, after: afterCursor(state.turns) }), {
|
|
190
|
+
headers: { Accept: "application/json" }, credentials: "same-origin"
|
|
191
|
+
})
|
|
192
|
+
if (!response.ok) throw new Error(`turns ${response.status}`)
|
|
193
|
+
const data = await response.json()
|
|
194
|
+
state.turns = mergeTurns(state.turns, data.turns)
|
|
195
|
+
state.pending = data.pending === true
|
|
196
|
+
setOffline(false)
|
|
197
|
+
render()
|
|
198
|
+
} catch (_error) {
|
|
199
|
+
setOffline(true)
|
|
200
|
+
} finally {
|
|
201
|
+
state.inFlight = false
|
|
202
|
+
schedule()
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async function submit(event) {
|
|
207
|
+
if (event) event.preventDefault()
|
|
208
|
+
const text = (input.value || "").trim()
|
|
209
|
+
if (!text || state.sending) return
|
|
210
|
+
state.sending = true
|
|
211
|
+
if (send) send.disabled = true
|
|
212
|
+
showError("")
|
|
213
|
+
if (status) status.textContent = `Sending to ${name}…`
|
|
214
|
+
try {
|
|
215
|
+
const headers = { Accept: "application/json", "Content-Type": "application/json" }
|
|
216
|
+
const token = csrfToken(form, doc)
|
|
217
|
+
if (token) headers["X-CSRF-Token"] = token
|
|
218
|
+
const response = await fetcher(form.getAttribute("action"), {
|
|
219
|
+
method: "POST", headers, credentials: "same-origin", body: JSON.stringify({ soul, text })
|
|
220
|
+
})
|
|
221
|
+
let data = {}
|
|
222
|
+
try { data = await response.json() } catch (_e) { data = {} }
|
|
223
|
+
if (response.status === 201 && data.turn) {
|
|
224
|
+
input.value = ""
|
|
225
|
+
state.turns = mergeTurns(state.turns, [data.turn])
|
|
226
|
+
state.pending = true
|
|
227
|
+
state.following = true
|
|
228
|
+
setOffline(false)
|
|
229
|
+
render()
|
|
230
|
+
toEnd()
|
|
231
|
+
if (status) status.textContent = "Sent."
|
|
232
|
+
state.waitingSince = now()
|
|
233
|
+
clearTimer(state.timer)
|
|
234
|
+
state.timer = setTimer(poll, POLL_MS)
|
|
235
|
+
} else {
|
|
236
|
+
showError(data.error || `Couldn't send that to ${name}. Try again.`)
|
|
237
|
+
if (status) status.textContent = ""
|
|
238
|
+
}
|
|
239
|
+
} catch (_error) {
|
|
240
|
+
showError(`Can't reach ${name} right now. Your message is still here; try again in a moment.`)
|
|
241
|
+
if (status) status.textContent = ""
|
|
242
|
+
} finally {
|
|
243
|
+
state.sending = false
|
|
244
|
+
if (send) send.disabled = false
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function onKeydown(event) {
|
|
249
|
+
if (event.key !== "Enter" || event.shiftKey || event.isComposing) return
|
|
250
|
+
event.preventDefault()
|
|
251
|
+
submit()
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function onRetry() {
|
|
255
|
+
clearTimer(state.timer)
|
|
256
|
+
poll()
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// A tab that comes back asks at once rather than waiting out an idle poll.
|
|
260
|
+
function onVisible() {
|
|
261
|
+
if (doc && doc.visibilityState === "visible" && !state.inFlight) { clearTimer(state.timer); poll() }
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
form && form.addEventListener("submit", submit)
|
|
265
|
+
input && input.addEventListener("keydown", onKeydown)
|
|
266
|
+
retry && retry.addEventListener("click", onRetry)
|
|
267
|
+
doc && doc.addEventListener && doc.addEventListener("visibilitychange", onVisible)
|
|
268
|
+
root.dataset.agentChatReady = "true"
|
|
269
|
+
poll()
|
|
270
|
+
|
|
271
|
+
return {
|
|
272
|
+
state,
|
|
273
|
+
poll,
|
|
274
|
+
submit,
|
|
275
|
+
destroy() {
|
|
276
|
+
state.alive = false
|
|
277
|
+
clearTimer(state.timer)
|
|
278
|
+
log.removeEventListener("scroll", onScroll)
|
|
279
|
+
form && form.removeEventListener("submit", submit)
|
|
280
|
+
input && input.removeEventListener("keydown", onKeydown)
|
|
281
|
+
retry && retry.removeEventListener("click", onRetry)
|
|
282
|
+
doc && doc.removeEventListener && doc.removeEventListener("visibilitychange", onVisible)
|
|
283
|
+
delete root.dataset.agentChatReady
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// agent-chat: the shared agent chat (studio/agent_chat/_chat), on the engine's
|
|
2
|
+
// Stimulus application (studio/stimulus registers it lazily, the first time a
|
|
3
|
+
// page renders a chat). It sits on the chat's section:
|
|
4
|
+
//
|
|
5
|
+
// <section data-studio-controller="agent-chat" data-agent-chat-soul="alex" ...>
|
|
6
|
+
//
|
|
7
|
+
// The loading, polling, sending and following are studio/agent_chat.
|
|
8
|
+
import { Controller } from "@hotwired/stimulus"
|
|
9
|
+
import { mountAgentChat } from "studio/agent_chat"
|
|
10
|
+
|
|
11
|
+
export default class extends Controller {
|
|
12
|
+
connect() {
|
|
13
|
+
this.chat = mountAgentChat(this.element)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
disconnect() {
|
|
17
|
+
if (this.chat) this.chat.destroy()
|
|
18
|
+
this.chat = null
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -55,7 +55,9 @@ export const schema = {
|
|
|
55
55
|
// board whose controller failed still renders, counts and toasts, and says that
|
|
56
56
|
// its cards do not drag (data-studio-controller-failed="board"). So is the
|
|
57
57
|
// knowledge transcript: a recording whose controller failed still plays from
|
|
58
|
-
// its own controls, and its page says that click-to-play did not load.
|
|
58
|
+
// its own controls, and its page says that click-to-play did not load. So is the
|
|
59
|
+
// agent chat: a chat whose controller failed still renders, and its section
|
|
60
|
+
// carries data-studio-controller-failed="agent-chat".
|
|
59
61
|
export const LAZY = {
|
|
60
62
|
"board": () => import("studio/controllers/board_controller"),
|
|
61
63
|
"geo-settings": () => import("studio/controllers/geo_settings_controller"),
|
|
@@ -64,7 +66,8 @@ export const LAZY = {
|
|
|
64
66
|
"booking": () => import("studio/controllers/booking_controller"),
|
|
65
67
|
"footer-map": () => import("studio/controllers/footer_map_controller"),
|
|
66
68
|
"survey": () => import("studio/controllers/survey_controller"),
|
|
67
|
-
"knowledge-transcript": () => import("studio/controllers/knowledge_transcript_controller")
|
|
69
|
+
"knowledge-transcript": () => import("studio/controllers/knowledge_transcript_controller"),
|
|
70
|
+
"agent-chat": () => import("studio/controllers/agent_chat_controller")
|
|
68
71
|
}
|
|
69
72
|
|
|
70
73
|
export const application = Application.start(document.documentElement, schema)
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
<%# locals: (soul:, name:, portrait: nil, role: nil, empty: nil, cards: nil) %>
|
|
2
|
+
<%#
|
|
3
|
+
The shared agent chat (docs/AGENT_CHAT.md): one conversation with one agent,
|
|
4
|
+
held by the hub, on any app's page.
|
|
5
|
+
|
|
6
|
+
render "studio/agent_chat/chat", soul: "alex", name: "Alex", portrait: image_tag("alex.png", class: "w-24 h-24 rounded-full")
|
|
7
|
+
|
|
8
|
+
soul the agent's slug; must be on the app's Studio.agent_chat_souls.
|
|
9
|
+
name the agent's display name (header, composer, typing line).
|
|
10
|
+
portrait ready markup for the big round portrait; nil shows the initial.
|
|
11
|
+
role ready markup or text under the name.
|
|
12
|
+
empty the line an empty thread shows (default "Say hello to <name>.").
|
|
13
|
+
cards THE CARD SLOT: ready markup shown between the header and the
|
|
14
|
+
thread (/build's intake cards). A local, never a bare yield: a
|
|
15
|
+
partial rendered without a block yields the LAYOUT's body.
|
|
16
|
+
|
|
17
|
+
Top to bottom, as the hub's /build: the portrait and name, the card slot, the
|
|
18
|
+
thread, the typing line, the offline notice and the composer. The thread is
|
|
19
|
+
drawn by studio/agent_chat (the "agent-chat" controller, loaded lazily) from
|
|
20
|
+
the two <template>s below, which are studio/agent_chat/_turn: the look lives
|
|
21
|
+
here, in ERB, and the script only fills it. ONE PAGE HOLDS ONE CHAT.
|
|
22
|
+
%>
|
|
23
|
+
<% souls = Studio.agent_chat_soul_list %>
|
|
24
|
+
<% raise ArgumentError, "studio/agent_chat/chat: soul #{soul.inspect} is not in Studio.agent_chat_souls (#{souls.inspect})" unless souls.include?(soul.to_s) %>
|
|
25
|
+
<section class="w-full max-w-3xl mx-auto px-4 pt-4 sm:pt-8 flex flex-col gap-6 min-w-0"
|
|
26
|
+
data-studio-controller="agent-chat" data-test="agent-chat"
|
|
27
|
+
data-agent-chat-soul="<%= soul %>" data-agent-chat-name="<%= name %>"
|
|
28
|
+
data-agent-chat-messages-url="<%= studio_agent_chat_messages_path %>"
|
|
29
|
+
data-agent-chat-turns-url="<%= studio_agent_chat_turns_path %>">
|
|
30
|
+
<%= render "studio/agent_chat/header", name: name, portrait: portrait, role: role %>
|
|
31
|
+
<% if cards.present? %>
|
|
32
|
+
<div class="flex flex-col gap-3 min-w-0" data-agent-chat-cards data-test="agent-chat-cards"><%= cards %></div>
|
|
33
|
+
<% end %>
|
|
34
|
+
<div class="flex flex-col gap-3 min-w-0">
|
|
35
|
+
<div role="log" aria-live="polite" aria-label="Conversation with <%= name %>"
|
|
36
|
+
class="flex flex-col gap-4 px-1 py-2 overflow-y-auto overscroll-contain max-h-[60vh] min-w-0"
|
|
37
|
+
data-agent-chat-log data-test="agent-chat-messages">
|
|
38
|
+
<p class="hidden only:block text-center text-secondary text-sm py-6" data-test="agent-chat-empty"><%= empty.presence || "Say hello to #{name}." %></p>
|
|
39
|
+
</div>
|
|
40
|
+
<div class="px-1" data-agent-chat-typing hidden data-test="agent-chat-typing-slot">
|
|
41
|
+
<%= render "studio/agent_chat/typing", name: name, portrait: portrait %>
|
|
42
|
+
</div>
|
|
43
|
+
<div class="rounded-2xl border border-subtle bg-inset px-4 py-3 flex flex-wrap items-center justify-between gap-3"
|
|
44
|
+
role="alert" data-agent-chat-offline hidden data-test="agent-chat-offline">
|
|
45
|
+
<p class="text-sm text-body min-w-0">Can't reach <%= name %> right now. Your messages are safe; try again in a moment.</p>
|
|
46
|
+
<button type="button" class="btn btn-secondary btn-sm shrink-0" data-agent-chat-retry data-test="agent-chat-retry">Try again</button>
|
|
47
|
+
</div>
|
|
48
|
+
<%= render "studio/agent_chat/form", soul: soul, name: name %>
|
|
49
|
+
</div>
|
|
50
|
+
<template data-agent-chat-template="user"><%= render "studio/agent_chat/turn", turn: { "role" => "user" }, name: name %></template>
|
|
51
|
+
<template data-agent-chat-template="agent"><%= render "studio/agent_chat/turn", turn: { "role" => "agent" }, name: name %></template>
|
|
52
|
+
</section>
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
<%# locals: (soul:, name:) %>
|
|
2
|
+
<%# The chat's composer (studio/agent_chat/_chat), as the hub's agent_chats/_form: the text over a round send button at its bottom right, sticky at the window's bottom. Enter sends; Shift+Enter is a new line (studio/agent_chat). It posts to POST /agent_chat/messages with the page's authenticity token (form_with adds it; the script sends it as X-CSRF-Token). A send that fails keeps the words in the box and says why above them. %>
|
|
3
|
+
<%= form_with url: studio_agent_chat_messages_path, method: :post, local: true,
|
|
4
|
+
html: { style: "view-transition-name: agent-composer;" },
|
|
5
|
+
class: "sticky bottom-3 z-10 rounded-3xl border border-subtle bg-surface shadow-lg focus-within:border-primary transition-colors p-2 flex flex-col gap-2",
|
|
6
|
+
data: { agent_chat_form: true, turbo: false, test: "agent-chat-form" } do |f| %>
|
|
7
|
+
<%= hidden_field_tag :soul, soul, id: nil %>
|
|
8
|
+
<p class="px-3 pt-1 text-xs text-danger-ink" role="alert" data-agent-chat-error hidden data-test="agent-chat-error"></p>
|
|
9
|
+
<p class="sr-only" role="status" aria-live="polite" data-agent-chat-status data-test="agent-chat-status"></p>
|
|
10
|
+
<div class="relative">
|
|
11
|
+
<%= label_tag :studio_agent_chat_text, "Message #{name}", class: "sr-only" %>
|
|
12
|
+
<%= text_area_tag :text, nil, id: "studio_agent_chat_text", rows: 2, maxlength: Studio::AgentChat::MAX_TEXT_LENGTH, required: true,
|
|
13
|
+
placeholder: "Message #{name}…", autocomplete: "off",
|
|
14
|
+
class: "block w-full min-w-0 resize-none bg-transparent pl-3 pr-14 py-2 text-base text-heading placeholder:text-muted focus:outline-none",
|
|
15
|
+
data: { agent_chat_input: true, test: "agent-chat-input" } %>
|
|
16
|
+
<button type="submit" class="absolute bottom-1 right-1 inline-flex h-10 w-10 items-center justify-center rounded-full bg-primary text-white disabled:opacity-40"
|
|
17
|
+
aria-label="Send to <%= name %>" data-agent-chat-send data-test="agent-chat-send">
|
|
18
|
+
<svg viewBox="0 0 24 24" class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
19
|
+
<path d="M12 19V5M5 12l7-7 7 7"/>
|
|
20
|
+
</svg>
|
|
21
|
+
</button>
|
|
22
|
+
</div>
|
|
23
|
+
<% end %>
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
<%# locals: (name:, portrait: nil, role: nil) %>
|
|
2
|
+
<%# The chat's head (studio/agent_chat/_chat): the agent's portrait, a big circle centered on top, its name and role under it. `portrait` is ready markup; with none, the name's initial on a plain circle. The portrait keeps one view-transition name, as on the hub's agent pages. %>
|
|
3
|
+
<div class="flex flex-col items-center gap-2 text-center min-w-0" data-test="agent-chat-header" data-agent-name="<%= name %>">
|
|
4
|
+
<div class="rounded-full shadow-md overflow-hidden" style="view-transition-name: agent-portrait;" data-test="agent-chat-portrait">
|
|
5
|
+
<% if portrait.present? %>
|
|
6
|
+
<%= portrait %>
|
|
7
|
+
<% else %>
|
|
8
|
+
<span class="w-24 h-24 text-3xl rounded-full flex items-center justify-center bg-inset border border-subtle text-heading font-bold" aria-hidden="true"><%= name.to_s.first.to_s.upcase %></span>
|
|
9
|
+
<% end %>
|
|
10
|
+
</div>
|
|
11
|
+
<p class="text-sm text-secondary break-words" data-test="agent-chat-caption">
|
|
12
|
+
<span class="font-semibold text-heading"><%= name %></span><% if role.present? %> · <%= role %><% end %>
|
|
13
|
+
</p>
|
|
14
|
+
</div>
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
<%# locals: (turn:, name:) %>
|
|
2
|
+
<%# One turn of the chat (studio/agent_chat/_chat), from a hub turn {id, role, text, status}, identified by its id AND role (data-turn-key "<id>:<role>"): the hub answers one message row as a user turn and an agent turn sharing the row's id. The page renders it twice, blank, as the <template>s studio/agent_chat clones; a host may also render real turns with it. The visitor's words sit in a right-hand bubble; the agent's are plain text across the thread (as the hub's agent-reply), its name kept for a screen reader. Text only, never HTML: the script sets textContent. A failed turn says so. %>
|
|
3
|
+
<% agent = turn["role"].to_s == "agent" %>
|
|
4
|
+
<% status = turn["status"].to_s %>
|
|
5
|
+
<div class="flex flex-col gap-2 min-w-0" data-agent-chat-turn data-role="<%= agent ? "agent" : "user" %>"
|
|
6
|
+
data-status="<%= status %>" data-turn-id="<%= turn["id"] %>"
|
|
7
|
+
data-turn-key="<%= "#{turn["id"]}:#{agent ? "agent" : "user"}" if turn["id"].present? %>" data-test="agent-chat-turn">
|
|
8
|
+
<% if agent %>
|
|
9
|
+
<div class="self-stretch min-w-0" data-test="agent-chat-reply">
|
|
10
|
+
<p class="sr-only"><%= name %>:</p>
|
|
11
|
+
<div class="text-body text-[0.9375rem] leading-relaxed whitespace-pre-wrap break-words" data-agent-chat-text><%= turn["text"] %></div>
|
|
12
|
+
</div>
|
|
13
|
+
<% else %>
|
|
14
|
+
<div class="self-end max-w-[85%] rounded-2xl rounded-br-sm px-3 py-2 bg-primary/15 text-primary-ink text-sm whitespace-pre-wrap break-words"
|
|
15
|
+
data-agent-chat-text data-test="agent-chat-sent"><%= turn["text"] %></div>
|
|
16
|
+
<% end %>
|
|
17
|
+
<p class="<%= agent ? "self-start" : "self-end" %> text-xs text-danger-ink" data-agent-chat-failed data-test="agent-chat-failed"
|
|
18
|
+
<%= "hidden" unless status == "failed" %>><%= name %> could not answer this one. Send it again.</p>
|
|
19
|
+
</div>
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
<%# locals: (name:, portrait: nil) %>
|
|
2
|
+
<%# A pending answer (studio/agent_chat/_chat), as the hub's agent_chats/_typing: THINKING (a small portrait in a turning ring, "<name> is thinking" shimmering) until the hub says the answer has begun, then TYPING (a bubble of wave dots). Both are drawn in one grid cell so the swap never moves the page; data-phase picks one (studio/agent_chat sets it). The motion is engine-motion.css (.studio-agent-typing) and holds still under reduced motion. %>
|
|
3
|
+
<div class="studio-agent-typing" data-phase="thinking" data-agent-chat-phase data-test="agent-chat-typing">
|
|
4
|
+
<div class="studio-agent-typing-layer studio-agent-typing-thinks" data-test="agent-chat-thinking">
|
|
5
|
+
<span class="studio-agent-typing-orbit" aria-hidden="true">
|
|
6
|
+
<span class="w-10 h-10 text-sm rounded-full flex items-center justify-center bg-inset border border-subtle text-heading font-bold shrink-0 overflow-hidden [&>img]:w-full [&>img]:h-full [&>img]:object-cover [&>svg]:w-full [&>svg]:h-full">
|
|
7
|
+
<% if portrait.present? %><%= portrait %><% else %><%= name.to_s.first.to_s.upcase %><% end %>
|
|
8
|
+
</span>
|
|
9
|
+
</span>
|
|
10
|
+
<span class="studio-agent-typing-label studio-agent-typing-shimmer"><%= name %> is thinking</span>
|
|
11
|
+
</div>
|
|
12
|
+
<div class="studio-agent-typing-layer studio-agent-typing-types" data-test="agent-chat-typing-bubble">
|
|
13
|
+
<span class="studio-agent-typing-bubble" aria-hidden="true">
|
|
14
|
+
<span class="studio-agent-typing-dot"></span><span class="studio-agent-typing-dot"></span><span class="studio-agent-typing-dot"></span>
|
|
15
|
+
</span>
|
|
16
|
+
<span class="studio-agent-typing-label"><%= name %> is typing</span>
|
|
17
|
+
</div>
|
|
18
|
+
</div>
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "openssl"
|
|
6
|
+
require "uri"
|
|
7
|
+
require "zlib"
|
|
8
|
+
require "active_support/core_ext/object/blank"
|
|
9
|
+
require "active_support/core_ext/object/to_query"
|
|
10
|
+
|
|
11
|
+
module Studio
|
|
12
|
+
# The hub client behind the shared agent chat (docs/AGENT_CHAT.md). An app
|
|
13
|
+
# never stores a chat message: the McRitchie Studio hub holds every thread,
|
|
14
|
+
# one per (person, soul), and this client is the app's server talking to it.
|
|
15
|
+
# The browser only ever talks to its own app (Studio::AgentChatsController).
|
|
16
|
+
#
|
|
17
|
+
# THE CONTRACT (.agents/epics/agent-chat-primitive.md, "Contract"):
|
|
18
|
+
#
|
|
19
|
+
# POST /api/v1/agent_chat/messages soul, text, person, ip
|
|
20
|
+
# 201 {turn} · 422 {error} · 429 capped · 404 soul has no web chat
|
|
21
|
+
# GET /api/v1/agent_chat/turns soul, person, after, limit (<= 50)
|
|
22
|
+
# 200 {turns: [...], pending: bool}
|
|
23
|
+
#
|
|
24
|
+
# `person` is {email:} for a signed-in user, else {anon_token:}; a signed-in
|
|
25
|
+
# POST that still holds the visitor's anon token sends {email:, anon_token:},
|
|
26
|
+
# and the hub folds the anonymous thread into the person's. A turn is
|
|
27
|
+
# {id, role: "user"|"agent", text, created_at, status} with status
|
|
28
|
+
# pending|typing|answered|failed.
|
|
29
|
+
#
|
|
30
|
+
# AUTHENTICATION is the APP's own bearer credential (Studio.agent_chat_token,
|
|
31
|
+
# else ENV["AGENT_CHAT_TOKEN"]), never a user's. It is sent only over TLS: a
|
|
32
|
+
# plain-http hub URL is refused unless its host is this machine (a developer
|
|
33
|
+
# pointing at a local hub).
|
|
34
|
+
#
|
|
35
|
+
# EVERY CALL IS BOUNDED: OPEN_TIMEOUT to connect and finish TLS, then
|
|
36
|
+
# READ_TIMEOUT (and WRITE_TIMEOUT) per socket operation; Net::HTTP's silent
|
|
37
|
+
# retry of an idempotent request is off, so a GET costs at most one wait.
|
|
38
|
+
#
|
|
39
|
+
# ERRORS. Every failure is a Studio::AgentChat::Error, so a caller rescues one
|
|
40
|
+
# class; the subclasses say what the visitor is told:
|
|
41
|
+
#
|
|
42
|
+
# Unavailable the hub could not be reached or answered 5xx / garbage, or
|
|
43
|
+
# the app is not configured (no token, a bad URL). "Try again."
|
|
44
|
+
# Unauthorized 401/403: the hub refused THIS APP's credential. A
|
|
45
|
+
# configuration fault, shown to the visitor as Unavailable.
|
|
46
|
+
# UnknownSoul 404: the hub has no web chat for that soul for this app.
|
|
47
|
+
# Invalid 422: the hub refused the message; #message is its reason.
|
|
48
|
+
# Capped 429: too many messages; #retry_after is seconds, or nil.
|
|
49
|
+
module AgentChat
|
|
50
|
+
class Error < StandardError
|
|
51
|
+
attr_reader :status
|
|
52
|
+
|
|
53
|
+
def initialize(message = nil, status: nil)
|
|
54
|
+
super(message)
|
|
55
|
+
@status = status
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
class Unavailable < Error; end
|
|
60
|
+
class Unauthorized < Error; end
|
|
61
|
+
class UnknownSoul < Error; end
|
|
62
|
+
class Invalid < Error; end
|
|
63
|
+
|
|
64
|
+
class Capped < Error
|
|
65
|
+
attr_reader :retry_after
|
|
66
|
+
|
|
67
|
+
def initialize(message = nil, status: 429, retry_after: nil)
|
|
68
|
+
super(message, status: status)
|
|
69
|
+
@retry_after = retry_after
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
MESSAGES_PATH = "/api/v1/agent_chat/messages"
|
|
74
|
+
TURNS_PATH = "/api/v1/agent_chat/turns"
|
|
75
|
+
# Seconds to open the connection and finish TLS.
|
|
76
|
+
OPEN_TIMEOUT = 3
|
|
77
|
+
# Seconds for any one read or write once connected. A post returns as soon
|
|
78
|
+
# as the hub has stored the message; the agent's answer arrives by polling.
|
|
79
|
+
READ_TIMEOUT = 8
|
|
80
|
+
WRITE_TIMEOUT = 8
|
|
81
|
+
MAX_LIMIT = 50
|
|
82
|
+
# The longest message the app forwards; the hub may refuse a shorter one.
|
|
83
|
+
MAX_TEXT_LENGTH = 4_000
|
|
84
|
+
# A hub answer larger than this is not a chat answer.
|
|
85
|
+
MAX_BODY_BYTES = 1_048_576
|
|
86
|
+
LOOPBACK_HOSTS = %w[localhost 127.0.0.1 ::1 [::1]].freeze
|
|
87
|
+
NETWORK_ERRORS = [
|
|
88
|
+
Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout, Timeout::Error,
|
|
89
|
+
SocketError, SystemCallError, IOError, EOFError, OpenSSL::SSL::SSLError,
|
|
90
|
+
Net::HTTPBadResponse, Net::ProtocolError, Zlib::Error
|
|
91
|
+
].freeze
|
|
92
|
+
|
|
93
|
+
class << self
|
|
94
|
+
# Stores one message from `person` to `soul` and answers its turn (a Hash
|
|
95
|
+
# with string keys). Raises a Studio::AgentChat::Error.
|
|
96
|
+
def post_message(soul:, text:, person:, ip: nil)
|
|
97
|
+
body = { soul: soul.to_s, text: text.to_s, person: person_param(person), ip: ip.to_s.presence }.compact
|
|
98
|
+
payload = request(:post, MESSAGES_PATH, body: body)
|
|
99
|
+
turn = payload.is_a?(Hash) && payload["turn"].is_a?(Hash) ? payload["turn"] : payload
|
|
100
|
+
raise Unavailable.new("the hub answered with no turn", status: 201) unless turn.is_a?(Hash)
|
|
101
|
+
|
|
102
|
+
turn
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# The thread's turns after `after` (a turn id; nil for the latest), and
|
|
106
|
+
# whether an answer is still pending: {"turns" => [...], "pending" => bool}.
|
|
107
|
+
def turns(soul:, person:, after: nil, limit: MAX_LIMIT)
|
|
108
|
+
query = { soul: soul.to_s, person: person_param(person), limit: limit.to_i.clamp(1, MAX_LIMIT) }
|
|
109
|
+
query[:after] = after.to_s if after.present?
|
|
110
|
+
payload = request(:get, TURNS_PATH, query: query)
|
|
111
|
+
raise Unavailable.new("the hub answered with no turns", status: 200) unless payload.is_a?(Hash) && payload["turns"].is_a?(Array)
|
|
112
|
+
|
|
113
|
+
{ "turns" => payload["turns"].select { |turn| turn.is_a?(Hash) }, "pending" => payload["pending"] == true }
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def hub_url = Studio.agent_chat_hub_url.to_s
|
|
117
|
+
def token = Studio.agent_chat_token.presence || ENV["AGENT_CHAT_TOKEN"].presence
|
|
118
|
+
|
|
119
|
+
private
|
|
120
|
+
|
|
121
|
+
# {email:}, {anon_token:}, or both: a signed-in post that still holds the
|
|
122
|
+
# visitor's anon token sends both, and the hub folds that anonymous thread
|
|
123
|
+
# into the person's.
|
|
124
|
+
def person_param(person)
|
|
125
|
+
person = person.to_h.transform_keys(&:to_s)
|
|
126
|
+
email = person["email"].to_s.strip
|
|
127
|
+
token = person["anon_token"].to_s.strip
|
|
128
|
+
raise ArgumentError, "person needs an email or an anon_token" if email.empty? && token.empty?
|
|
129
|
+
|
|
130
|
+
{ email: email.presence, anon_token: token.presence }.compact
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def request(verb, path, body: nil, query: nil)
|
|
134
|
+
uri = endpoint(path)
|
|
135
|
+
uri.query = query.to_query if query
|
|
136
|
+
credential = token || raise(Unavailable, "no AGENT_CHAT_TOKEN: the app has no agent chat credential")
|
|
137
|
+
|
|
138
|
+
req = verb == :post ? Net::HTTP::Post.new(uri) : Net::HTTP::Get.new(uri)
|
|
139
|
+
req["Authorization"] = "Bearer #{credential}"
|
|
140
|
+
req["Accept"] = "application/json"
|
|
141
|
+
req["User-Agent"] = "studio-engine/#{Studio::VERSION} agent-chat"
|
|
142
|
+
if body
|
|
143
|
+
req["Content-Type"] = "application/json"
|
|
144
|
+
req.body = JSON.generate(body)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
response = http_for(uri).start { |http| http.request(req) }
|
|
148
|
+
interpret(response)
|
|
149
|
+
rescue *NETWORK_ERRORS => e
|
|
150
|
+
raise Unavailable, "the hub could not be reached (#{e.class})"
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def endpoint(path)
|
|
154
|
+
base = URI.parse(hub_url)
|
|
155
|
+
raise Unavailable, "agent_chat_hub_url is not an http(s) URL" unless base.is_a?(URI::HTTP) && base.host.present?
|
|
156
|
+
if base.scheme != "https" && !LOOPBACK_HOSTS.include?(base.host)
|
|
157
|
+
raise Unavailable, "agent_chat_hub_url must be https (plain http only to this machine)"
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
URI.join(base.to_s.end_with?("/") ? base.to_s : "#{base}/", path.delete_prefix("/"))
|
|
161
|
+
rescue URI::InvalidURIError
|
|
162
|
+
raise Unavailable, "agent_chat_hub_url is not a URL"
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def http_for(uri)
|
|
166
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
167
|
+
http.use_ssl = uri.scheme == "https"
|
|
168
|
+
http.verify_mode = OpenSSL::SSL::VERIFY_PEER if http.use_ssl?
|
|
169
|
+
http.open_timeout = OPEN_TIMEOUT
|
|
170
|
+
http.read_timeout = READ_TIMEOUT
|
|
171
|
+
http.write_timeout = WRITE_TIMEOUT
|
|
172
|
+
http.ssl_timeout = OPEN_TIMEOUT if http.use_ssl?
|
|
173
|
+
http.max_retries = 0
|
|
174
|
+
http
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def interpret(response)
|
|
178
|
+
status = response.code.to_i
|
|
179
|
+
success = status.between?(200, 299)
|
|
180
|
+
payload = success ? parse(response.body) : parse_reason(response.body)
|
|
181
|
+
reason = payload.is_a?(Hash) ? payload["error"].presence : nil
|
|
182
|
+
|
|
183
|
+
case status
|
|
184
|
+
when 200..299 then payload
|
|
185
|
+
when 401, 403 then raise Unauthorized.new("the hub refused this app's credential (#{status})", status: status)
|
|
186
|
+
when 404 then raise UnknownSoul.new(reason || "no web chat for that soul", status: status)
|
|
187
|
+
when 422 then raise Invalid.new(reason || "the hub refused that message", status: status)
|
|
188
|
+
when 429 then raise Capped.new(reason || "too many messages", retry_after: retry_after(response))
|
|
189
|
+
else raise Unavailable.new("the hub answered #{status}", status: status)
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def parse(body)
|
|
194
|
+
return nil if body.nil? || body.empty?
|
|
195
|
+
raise Unavailable, "the hub's answer is too large" if body.bytesize > MAX_BODY_BYTES
|
|
196
|
+
|
|
197
|
+
JSON.parse(body)
|
|
198
|
+
rescue JSON::ParserError
|
|
199
|
+
raise Unavailable, "the hub's answer is not JSON"
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# A refusal's body, read only for its {"error"} reason; a body that is not
|
|
203
|
+
# JSON (a proxy's HTML error page) is no reason.
|
|
204
|
+
def parse_reason(body)
|
|
205
|
+
parse(body)
|
|
206
|
+
rescue Unavailable
|
|
207
|
+
nil
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def retry_after(response)
|
|
211
|
+
value = response["Retry-After"].to_s
|
|
212
|
+
value.match?(/\A\d+\z/) ? value.to_i : nil
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
end
|
data/lib/studio/version.rb
CHANGED
data/lib/studio.rb
CHANGED
|
@@ -47,6 +47,7 @@ require "studio/profile_image"
|
|
|
47
47
|
require "studio/oauth_identity"
|
|
48
48
|
require "studio/newsletter"
|
|
49
49
|
require "studio/health_report"
|
|
50
|
+
require "studio/agent_chat"
|
|
50
51
|
require "studio/username_generator"
|
|
51
52
|
require "studio/public_user"
|
|
52
53
|
require "studio/name_parts"
|
|
@@ -1313,6 +1314,34 @@ module Studio
|
|
|
1313
1314
|
mattr_accessor :draw_knowledge_routes, default: false
|
|
1314
1315
|
mattr_accessor :knowledge_agents, default: []
|
|
1315
1316
|
|
|
1317
|
+
# The shared agent chat (docs/AGENT_CHAT.md): `render "studio/agent_chat/chat",
|
|
1318
|
+
# soul:, name:, portrait:` on any page, answered by the hub's agents. The hub
|
|
1319
|
+
# holds every thread; the app's server proxies the browser's two calls
|
|
1320
|
+
# (Studio::AgentChatsController, Studio::AgentChat).
|
|
1321
|
+
#
|
|
1322
|
+
# agent_chat_souls is the app's ALLOW-LIST of agent slugs a page may chat
|
|
1323
|
+
# with (e.g. %w[alex]). It is the gate: an app that names no soul draws no
|
|
1324
|
+
# /agent_chat routes, and a request naming a soul off the list is a 404. The
|
|
1325
|
+
# browser's `soul` is never trusted beyond it, and the hub re-checks that this
|
|
1326
|
+
# app may address that soul.
|
|
1327
|
+
mattr_accessor :agent_chat_souls, default: []
|
|
1328
|
+
mattr_accessor :agent_chat_hub_url, default: "https://mcritchie.studio"
|
|
1329
|
+
# The app's bearer credential for the hub. Leave nil to read
|
|
1330
|
+
# ENV["AGENT_CHAT_TOKEN"] (1Password apps.<slug>.agent-chat).
|
|
1331
|
+
mattr_accessor :agent_chat_token, default: nil
|
|
1332
|
+
# Which signed-in users the hub may know by email. An email the user only
|
|
1333
|
+
# CLAIMS (the profile page saves a new address and clears email_verified_at;
|
|
1334
|
+
# password signup saves any address) would open that person's hub thread on
|
|
1335
|
+
# every app, so by default only a user whose email_verified_at is present is
|
|
1336
|
+
# sent by email; everyone else chats as an anonymous visitor (the anon
|
|
1337
|
+
# cookie). Set a callable ->(user) { "proven@address" or nil } to use another
|
|
1338
|
+
# proof; nil keeps the default. Never answer a claimed address.
|
|
1339
|
+
mattr_accessor :agent_chat_verified_email, default: nil
|
|
1340
|
+
|
|
1341
|
+
def self.agent_chat_soul_list
|
|
1342
|
+
Array(agent_chat_souls).map { |soul| soul.to_s.strip }.reject(&:empty?).uniq
|
|
1343
|
+
end
|
|
1344
|
+
|
|
1316
1345
|
class S3ConfigError < StandardError; end
|
|
1317
1346
|
|
|
1318
1347
|
# Whether to validate the host app's User model at boot. See docs/USER_CONTRACT.md.
|
|
@@ -1702,6 +1731,16 @@ module Studio
|
|
|
1702
1731
|
constraints: { token: %r{[^/]+} }
|
|
1703
1732
|
end
|
|
1704
1733
|
|
|
1734
|
+
# The shared agent chat's proxy (Studio::AgentChatsController): the
|
|
1735
|
+
# browser's two calls, which the app's server forwards to the hub. Drawn
|
|
1736
|
+
# only for an app that names a soul (Studio.agent_chat_souls).
|
|
1737
|
+
if Studio.agent_chat_soul_list.any?
|
|
1738
|
+
post "agent_chat/messages", to: "studio/agent_chats#create", as: :studio_agent_chat_messages,
|
|
1739
|
+
defaults: { format: :json }
|
|
1740
|
+
get "agent_chat/turns", to: "studio/agent_chats#turns", as: :studio_agent_chat_turns,
|
|
1741
|
+
defaults: { format: :json }
|
|
1742
|
+
end
|
|
1743
|
+
|
|
1705
1744
|
# The session-drift rehydrate endpoint (Studio::SessionStatesController).
|
|
1706
1745
|
# OPT-IN — see Studio.draw_session_routes. JSON only: the browser store is
|
|
1707
1746
|
# its one caller, and an HTML answer would only ever be a mistake.
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: studio-engine
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.116.0.rc1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Alex McRitchie
|
|
@@ -395,6 +395,7 @@ files:
|
|
|
395
395
|
- app/controllers/sessions_controller.rb
|
|
396
396
|
- app/controllers/solana_sessions_controller.rb
|
|
397
397
|
- app/controllers/studio/admin_surveys_controller.rb
|
|
398
|
+
- app/controllers/studio/agent_chats_controller.rb
|
|
398
399
|
- app/controllers/studio/bookings_controller.rb
|
|
399
400
|
- app/controllers/studio/component_previews_controller.rb
|
|
400
401
|
- app/controllers/studio/emails_controller.rb
|
|
@@ -424,6 +425,7 @@ files:
|
|
|
424
425
|
- app/helpers/studio_navbar_helper.rb
|
|
425
426
|
- app/helpers/studio_sidebar_helper.rb
|
|
426
427
|
- app/helpers/studio_theme_helper.rb
|
|
428
|
+
- app/javascript/studio/agent_chat.js
|
|
427
429
|
- app/javascript/studio/alpine_scopes.js
|
|
428
430
|
- app/javascript/studio/alpine_shims.js
|
|
429
431
|
- app/javascript/studio/alpine_stores.js
|
|
@@ -432,6 +434,7 @@ files:
|
|
|
432
434
|
- app/javascript/studio/board.js
|
|
433
435
|
- app/javascript/studio/booking.js
|
|
434
436
|
- app/javascript/studio/confirm_interstitial.js
|
|
437
|
+
- app/javascript/studio/controllers/agent_chat_controller.js
|
|
435
438
|
- app/javascript/studio/controllers/board_controller.js
|
|
436
439
|
- app/javascript/studio/controllers/booking_controller.js
|
|
437
440
|
- app/javascript/studio/controllers/email_banner_scale_controller.js
|
|
@@ -559,6 +562,11 @@ files:
|
|
|
559
562
|
- app/views/studio/admin_models/show.html.erb
|
|
560
563
|
- app/views/studio/admin_surveys/index.html.erb
|
|
561
564
|
- app/views/studio/admin_surveys/show.html.erb
|
|
565
|
+
- app/views/studio/agent_chat/_chat.html.erb
|
|
566
|
+
- app/views/studio/agent_chat/_form.html.erb
|
|
567
|
+
- app/views/studio/agent_chat/_header.html.erb
|
|
568
|
+
- app/views/studio/agent_chat/_turn.html.erb
|
|
569
|
+
- app/views/studio/agent_chat/_typing.html.erb
|
|
562
570
|
- app/views/studio/banners/_app_banner.html.erb
|
|
563
571
|
- app/views/studio/banners/_button.html.erb
|
|
564
572
|
- app/views/studio/banners/_dev_mode_button.html.erb
|
|
@@ -733,6 +741,7 @@ files:
|
|
|
733
741
|
- lib/generators/studio/site_identity/site_identity_generator.rb
|
|
734
742
|
- lib/studio-engine.rb
|
|
735
743
|
- lib/studio.rb
|
|
744
|
+
- lib/studio/agent_chat.rb
|
|
736
745
|
- lib/studio/auth_labels.rb
|
|
737
746
|
- lib/studio/booking.rb
|
|
738
747
|
- lib/studio/cable.rb
|