chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,220 @@
|
||||
.built-in-commands-modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 120;
|
||||
display: block;
|
||||
opacity: 0;
|
||||
visibility: visible;
|
||||
pointer-events: none;
|
||||
transition:
|
||||
opacity 0.18s ease,
|
||||
visibility 0.18s ease;
|
||||
}
|
||||
|
||||
.built-in-commands-modal--hidden {
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.built-in-commands-modal--open {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.built-in-commands-modal__mask {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.56);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.built-in-commands-modal__panel {
|
||||
position: absolute;
|
||||
top: 33%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -33%) scale(0.985);
|
||||
width: min(864px, 100%);
|
||||
min-height: 400px;
|
||||
max-height: min(78vh, 756px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 24px;
|
||||
background:
|
||||
linear-gradient(
|
||||
var(--a-color-background-surface),
|
||||
var(--a-color-background-surface)
|
||||
)
|
||||
padding-box,
|
||||
linear-gradient(135deg, rgba(28, 117, 219, 0.8) 0%, rgba(237, 41, 122, 0.4) 100%)
|
||||
border-box;
|
||||
box-shadow:
|
||||
0 28px 60px rgba(0, 0, 0, 0.45),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.03) inset;
|
||||
overflow: hidden;
|
||||
opacity: 0;
|
||||
transition:
|
||||
transform 0.18s cubic-bezier(0.18, 0.89, 0.32, 1.08),
|
||||
opacity 0.18s ease;
|
||||
}
|
||||
|
||||
.built-in-commands-modal--open .built-in-commands-modal__panel {
|
||||
transform: translate(-50%, -33%) scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.built-in-commands-modal__section {
|
||||
padding: 20px 22px;
|
||||
}
|
||||
|
||||
.built-in-commands-modal__section--top {
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.built-in-commands-modal__section--bottom {
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.built-in-commands-modal__section--middle {
|
||||
flex: 0 0 512px;
|
||||
height: 512px;
|
||||
min-height: 512px;
|
||||
padding: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.built-in-commands-modal__scroll-area {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.built-in-commands-modal__input-field {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.built-in-commands-modal__input-field .aurora-input-container {
|
||||
--a-icon-container-width: 72px;
|
||||
}
|
||||
|
||||
.built-in-commands-modal__input-field .aurora-input {
|
||||
height: 72px;
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.built-in-commands-modal__input-field .aurora-input-icon-container {
|
||||
height: 72px;
|
||||
}
|
||||
|
||||
.built-in-commands-modal__return-button .aurora-button {
|
||||
min-height: 44px;
|
||||
min-width: 44px;
|
||||
padding: 0 14px;
|
||||
font-size: var(--a-font-size-md);
|
||||
}
|
||||
|
||||
.built-in-commands-modal__return-button .aurora-button .aurora-icon {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.built-in-commands-modal__suggestion-copy {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.built-in-commands-modal .aurora-list-item > .aurora-flexbox .aurora-icon {
|
||||
font-size: 28px;
|
||||
padding: 23px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.built-in-commands-modal
|
||||
.aurora-list-item--selected
|
||||
.aurora-list-item-clickable-icon
|
||||
.aurora-icon {
|
||||
color: var(--a-color-accent);
|
||||
}
|
||||
|
||||
.built-in-commands-modal__result-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.built-in-commands-modal__result-copy,
|
||||
.built-in-commands-modal__result-value {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.built-in-commands-modal__result-value {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.built-in-commands-modal .aurora-scroll-container-scrollview {
|
||||
color-scheme: dark;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(255, 255, 255, 0.2) rgba(10, 14, 18, 0.9);
|
||||
}
|
||||
|
||||
.built-in-commands-modal .aurora-scroll-container-scrollview::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
}
|
||||
|
||||
.built-in-commands-modal
|
||||
.aurora-scroll-container-scrollview::-webkit-scrollbar-track {
|
||||
background: rgba(10, 14, 18, 0.9);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.built-in-commands-modal
|
||||
.aurora-scroll-container-scrollview::-webkit-scrollbar-thumb {
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(54, 65, 76, 0.95) 0%,
|
||||
rgba(30, 38, 46, 0.95) 100%
|
||||
);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.built-in-commands-modal
|
||||
.aurora-scroll-container-scrollview::-webkit-scrollbar-thumb:hover {
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(70, 82, 95, 0.98) 0%,
|
||||
rgba(40, 49, 58, 0.98) 100%
|
||||
);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.built-in-commands-modal__panel {
|
||||
width: auto;
|
||||
min-height: 360px;
|
||||
max-height: calc(100vh - 24px);
|
||||
left: 12px;
|
||||
right: 12px;
|
||||
top: 18%;
|
||||
transform: translate(0, 0) scale(0.985);
|
||||
}
|
||||
|
||||
.built-in-commands-modal--open .built-in-commands-modal__panel {
|
||||
transform: translate(0, 0) scale(1);
|
||||
}
|
||||
|
||||
.built-in-commands-modal__section {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.built-in-commands-modal__section--middle {
|
||||
flex-basis: 280px;
|
||||
height: 280px;
|
||||
min-height: 280px;
|
||||
}
|
||||
|
||||
.built-in-commands-modal__result-item {
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
.file-system-autocomplete {
|
||||
position: fixed;
|
||||
z-index: 150;
|
||||
overflow-y: auto;
|
||||
padding: 6px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 8px;
|
||||
background: #111315;
|
||||
box-shadow:
|
||||
0 18px 42px rgba(0, 0, 0, 0.42),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.03) inset;
|
||||
color: var(--white-color);
|
||||
}
|
||||
|
||||
.file-system-autocomplete--hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.file-system-autocomplete__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
min-height: 42px;
|
||||
padding: 9px 10px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
line-height: 1.35;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.file-system-autocomplete__item:hover,
|
||||
.file-system-autocomplete__item--selected {
|
||||
background: rgba(28, 117, 219, 0.22);
|
||||
}
|
||||
|
||||
.file-system-autocomplete__item i {
|
||||
flex: 0 0 auto;
|
||||
width: 18px;
|
||||
color: var(--blue-color);
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.file-system-autocomplete__label {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
line-height: 1.35;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.file-system-autocomplete__empty {
|
||||
padding: 10px;
|
||||
color: rgba(255, 255, 255, 0.58);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.file-system-autocomplete::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.file-system-autocomplete::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 12px;
|
||||
}
|
||||
@@ -0,0 +1,863 @@
|
||||
@use 'sass:meta';
|
||||
|
||||
@import '@fontsource/source-sans-pro/200.css';
|
||||
@import '@fontsource/source-sans-pro/300.css';
|
||||
@import '@fontsource/source-sans-pro/400.css';
|
||||
@import '@fontsource/source-sans-pro/600.css';
|
||||
@import '@fontsource/source-sans-pro/700.css';
|
||||
@import '@fontsource/source-sans-pro/900.css';
|
||||
@import 'remixicon/fonts/remixicon.css';
|
||||
|
||||
@include meta.load-css('tool-ui');
|
||||
@include meta.load-css('voice-energy/main');
|
||||
@include meta.load-css('file-system-autocomplete');
|
||||
|
||||
html,
|
||||
body,
|
||||
div,
|
||||
span,
|
||||
applet,
|
||||
object,
|
||||
iframes,
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6,
|
||||
p,
|
||||
blockquote,
|
||||
pre,
|
||||
a,
|
||||
abbr,
|
||||
acronym,
|
||||
address,
|
||||
big,
|
||||
cite,
|
||||
code,
|
||||
del,
|
||||
dfn,
|
||||
em,
|
||||
img,
|
||||
ins,
|
||||
kbd,
|
||||
q,
|
||||
s,
|
||||
samp,
|
||||
small,
|
||||
strike,
|
||||
sub,
|
||||
sup,
|
||||
tt,
|
||||
var,
|
||||
u,
|
||||
i,
|
||||
center,
|
||||
dl,
|
||||
dt,
|
||||
dd,
|
||||
ol,
|
||||
ul,
|
||||
li,
|
||||
fieldset,
|
||||
form,
|
||||
label,
|
||||
legend,
|
||||
table,
|
||||
caption,
|
||||
tbody,
|
||||
tfoot,
|
||||
thead,
|
||||
tr,
|
||||
th,
|
||||
td,
|
||||
article,
|
||||
aside,
|
||||
canvas,
|
||||
details,
|
||||
embed,
|
||||
figure,
|
||||
figcaption,
|
||||
footer,
|
||||
header,
|
||||
hgroup,
|
||||
menu,
|
||||
nav,
|
||||
output,
|
||||
ruby,
|
||||
section,
|
||||
summary,
|
||||
time,
|
||||
mark,
|
||||
audio,
|
||||
video {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
font-size: 100%;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
article,
|
||||
aside,
|
||||
details,
|
||||
figcaption,
|
||||
figure,
|
||||
footer,
|
||||
header,
|
||||
hgroup,
|
||||
menu,
|
||||
nav,
|
||||
section {
|
||||
display: block;
|
||||
}
|
||||
|
||||
blockquote,
|
||||
q {
|
||||
quotes: none;
|
||||
}
|
||||
|
||||
blockquote:before,
|
||||
blockquote:after,
|
||||
q:before,
|
||||
q:after {
|
||||
content: '';
|
||||
content: none;
|
||||
}
|
||||
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
border-spacing: 0;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
|
||||
--black-color: #000;
|
||||
--light-black-color: #222426;
|
||||
--white-color: #fff;
|
||||
--grey-color: #323739;
|
||||
--blue-color: #1c75db;
|
||||
--pink-color: #ed297a;
|
||||
--sessions-panel-width: 292px;
|
||||
|
||||
--a-loader-size-md: 20px !important;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
#feed ul li:not(.aurora-list-item) {
|
||||
margin-left: 20px;
|
||||
}
|
||||
|
||||
body {
|
||||
color: var(--white-color);
|
||||
background-color: var(--black-color);
|
||||
font-family:
|
||||
'Source Sans Pro',
|
||||
system-ui,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
'Segoe UI',
|
||||
Roboto,
|
||||
'Helvetica Neue',
|
||||
Arial,
|
||||
'Noto Sans',
|
||||
sans-serif,
|
||||
'Apple Color Emoji',
|
||||
'Segoe UI Emoji',
|
||||
'Segoe UI Symbol',
|
||||
'Noto Color Emoji';
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
|
||||
@keyframes plan-tool-status-cozy-shine {
|
||||
0% {
|
||||
opacity: 0.4;
|
||||
filter: drop-shadow(0 0 0 rgba(255, 255, 255, 0));
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 1;
|
||||
filter: drop-shadow(0 0 5px rgba(255, 255, 255, 0.22));
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0.4;
|
||||
filter: drop-shadow(0 0 0 rgba(255, 255, 255, 0));
|
||||
}
|
||||
}
|
||||
|
||||
#feed
|
||||
.bubble-container[data-message-id^='plan-']
|
||||
.aurora-list
|
||||
.aurora-list-item--center:last-child
|
||||
.aurora-status
|
||||
.aurora-icon {
|
||||
animation: plan-tool-status-cozy-shine 2.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
body > * {
|
||||
transition: opacity 0.5s;
|
||||
}
|
||||
body.settingup > *:not(#init) {
|
||||
opacity: 0;
|
||||
}
|
||||
#init .not-initialized {
|
||||
visibility: hidden;
|
||||
}
|
||||
#init .initialized {
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
kbd {
|
||||
font-family: 'Source Sans Pro', monospace;
|
||||
display: inline-block;
|
||||
background-color: var(--light-black-color);
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
border-radius: 4px;
|
||||
text-align: center;
|
||||
min-width: 16px;
|
||||
min-height: 16px;
|
||||
line-height: 16px !important;
|
||||
padding: 2px 6px !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
#sessions-panel {
|
||||
position: absolute;
|
||||
left: 2%;
|
||||
top: 2%;
|
||||
bottom: 8%;
|
||||
width: var(--sessions-panel-width);
|
||||
border-right: 1px solid rgba(255, 255, 255, 0.12);
|
||||
color: var(--white-color);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#sessions-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 14px 14px 0;
|
||||
}
|
||||
|
||||
#sessions-eyebrow {
|
||||
color: rgba(255, 255, 255, 0.42);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
#new-session,
|
||||
.session-action {
|
||||
border: 0;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: var(--white-color);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color 0.18s,
|
||||
color 0.18s,
|
||||
transform 0.18s;
|
||||
}
|
||||
|
||||
#new-session {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 8px;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
#new-session:hover,
|
||||
.session-action:hover {
|
||||
background: rgba(28, 117, 219, 0.22);
|
||||
color: #86bdff;
|
||||
}
|
||||
|
||||
#sessions-list {
|
||||
height: calc(100% - 48px);
|
||||
padding-right: 14px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
#sessions-list::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
#sessions-list::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(255, 255, 255, 0.16);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.session-group {
|
||||
position: relative;
|
||||
padding: 0 0 12px 14px;
|
||||
}
|
||||
|
||||
.session-group::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 3px;
|
||||
top: 22px;
|
||||
bottom: 0;
|
||||
width: 1px;
|
||||
background: linear-gradient(
|
||||
rgba(28, 117, 219, 0.7),
|
||||
rgba(237, 41, 122, 0.22)
|
||||
);
|
||||
}
|
||||
|
||||
.session-group-title {
|
||||
position: relative;
|
||||
margin-bottom: 8px;
|
||||
color: rgba(255, 255, 255, 0.44);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.session-group-title::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: -14px;
|
||||
top: 7px;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--blue-color);
|
||||
box-shadow: 0 0 12px rgba(28, 117, 219, 0.8);
|
||||
}
|
||||
|
||||
.session-item {
|
||||
position: relative;
|
||||
padding: 10px 8px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
animation: sessionTimelineIn 0.24s ease-out both;
|
||||
}
|
||||
|
||||
.session-item:hover,
|
||||
.session-item--active {
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
}
|
||||
|
||||
.session-item--active::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: -11px;
|
||||
top: 18px;
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background: var(--pink-color);
|
||||
box-shadow: 0 0 10px rgba(237, 41, 122, 0.9);
|
||||
}
|
||||
|
||||
.session-title {
|
||||
overflow: hidden;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
font-size: 0.98rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.2rem;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.session-meta {
|
||||
overflow: hidden;
|
||||
margin-top: 2px;
|
||||
color: rgba(255, 255, 255, 0.36);
|
||||
font-size: 0.8rem;
|
||||
line-height: 1rem;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.session-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-top: 8px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.16s;
|
||||
}
|
||||
|
||||
.session-item:hover .session-actions,
|
||||
.session-item--active .session-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.session-action {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.session-action[aria-pressed='true'] {
|
||||
color: #ff7caf;
|
||||
}
|
||||
|
||||
@keyframes sessionTimelineIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
main {
|
||||
position: absolute;
|
||||
width: calc(94% - var(--sessions-panel-width));
|
||||
top: 0;
|
||||
left: calc(4% + var(--sessions-panel-width));
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
#footer {
|
||||
display: flex;
|
||||
position: absolute;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
left: calc(4% + var(--sessions-panel-width));
|
||||
bottom: 2%;
|
||||
width: calc(94% - var(--sessions-panel-width));
|
||||
line-height: 18px;
|
||||
}
|
||||
#logo-container {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
#version {
|
||||
color: var(--grey-color);
|
||||
}
|
||||
|
||||
textarea {
|
||||
font-family: inherit;
|
||||
text-align: center;
|
||||
color: var(--white-color);
|
||||
width: 100%;
|
||||
border: none;
|
||||
border-bottom: 2px solid var(--grey-color);
|
||||
background: none;
|
||||
font-weight: 600;
|
||||
font-size: 4em;
|
||||
padding-right: 39px;
|
||||
height: 140px;
|
||||
resize: none;
|
||||
overflow-y: auto;
|
||||
}
|
||||
textarea::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
textarea::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
#tip {
|
||||
display: inline-flex;
|
||||
margin-top: 2px;
|
||||
color: var(--white-color);
|
||||
line-height: 22px;
|
||||
font-size: 0.9em;
|
||||
gap: 20px;
|
||||
li {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.hide {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#logo {
|
||||
background: no-repeat url(../img/logo.svg);
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
}
|
||||
|
||||
#top-container {
|
||||
position: absolute;
|
||||
top: 2%;
|
||||
color: var(--grey-color);
|
||||
display: flex;
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
#mood {
|
||||
position: relative;
|
||||
font-size: 16px;
|
||||
}
|
||||
#info {
|
||||
position: relative;
|
||||
text-decoration: underline;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--grey-color);
|
||||
cursor: pointer;
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
#feed {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
top: 6%;
|
||||
height: 58%;
|
||||
overflow-y: auto;
|
||||
border: 2px solid var(--grey-color);
|
||||
border-radius: 12px;
|
||||
}
|
||||
#feed::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
#feed::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
#no-bubble {
|
||||
margin-top: 64px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#is-typing {
|
||||
position: absolute;
|
||||
top: 65%;
|
||||
padding: 0 8px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
#is-typing.on {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
#is-typing .circle {
|
||||
display: inline-block;
|
||||
border-radius: 50%;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background-color: var(--white-color);
|
||||
transform: scale(1);
|
||||
}
|
||||
#is-typing .circle:nth-child(1) {
|
||||
animation: typing 0.2s linear infinite alternate;
|
||||
background-color: #0071f0;
|
||||
}
|
||||
#is-typing .circle:nth-child(2) {
|
||||
animation: typing 0.2s 0.2s linear infinite alternate;
|
||||
background-color: var(--white-color);
|
||||
}
|
||||
#is-typing .circle:nth-child(3) {
|
||||
animation: typing 0.2s linear infinite alternate;
|
||||
background-color: #ec297a;
|
||||
}
|
||||
@keyframes typing {
|
||||
100% {
|
||||
transform: scale(1.5);
|
||||
}
|
||||
}
|
||||
|
||||
.llm-token {
|
||||
opacity: 0;
|
||||
transition: opacity 2.5s;
|
||||
}
|
||||
|
||||
.llm-token.fade-in {
|
||||
animation: fadeIn 2.5s forwards;
|
||||
}
|
||||
|
||||
.reasoning-block-container {
|
||||
padding: 6px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.reasoning-block {
|
||||
max-width: 72%;
|
||||
border: 1px solid rgba(255, 255, 255, 0.16);
|
||||
border-radius: 12px;
|
||||
background-color: #111315;
|
||||
overflow: hidden;
|
||||
animation: fadeIn 0.2s ease-in forwards;
|
||||
}
|
||||
|
||||
.reasoning-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background-color: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.reasoning-icon {
|
||||
color: var(--blue-color);
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.reasoning-title {
|
||||
font-size: 0.85rem;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.reasoning-content {
|
||||
padding: 10px 12px;
|
||||
font-family: 'Courier New', Consolas, monospace;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.45;
|
||||
color: rgba(255, 255, 255, 0.82);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
max-height: 296px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.reasoning-content::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.reasoning-content::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.reasoning-token {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.reasoning-token.fade-in {
|
||||
animation: fadeIn 0.35s forwards;
|
||||
}
|
||||
|
||||
.bubble-container {
|
||||
padding: 6px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: fit-content;
|
||||
max-width: 85%;
|
||||
}
|
||||
.bubble-container.me {
|
||||
margin-left: auto;
|
||||
align-items: flex-end;
|
||||
text-align: right;
|
||||
}
|
||||
.bubble-container.leon {
|
||||
margin-right: auto;
|
||||
align-items: flex-start;
|
||||
text-align: left;
|
||||
}
|
||||
.show-more {
|
||||
margin: 3px;
|
||||
text-decoration: underline;
|
||||
}
|
||||
.show-more:hover {
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
.show-all {
|
||||
max-height: 100% !important;
|
||||
}
|
||||
|
||||
.bubble {
|
||||
padding: 10px 16px;
|
||||
border-radius: 16px;
|
||||
display: inline-block;
|
||||
max-width: 100%;
|
||||
word-break: break-word;
|
||||
text-align: left;
|
||||
opacity: 0;
|
||||
animation: fadeIn 0.2s ease-in forwards;
|
||||
overflow: hidden;
|
||||
font-size: 1.8rem;
|
||||
line-height: 2.4rem;
|
||||
}
|
||||
.bubble-metrics {
|
||||
cursor: default;
|
||||
margin-top: 2px;
|
||||
padding: 0 4px;
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.2rem;
|
||||
color: var(--grey-color);
|
||||
align-self: flex-end;
|
||||
text-align: right;
|
||||
}
|
||||
.bubble-metric-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.bubble-metric-item i {
|
||||
font-size: 1rem;
|
||||
}
|
||||
#feed .me .bubble-metrics {
|
||||
text-align: right;
|
||||
}
|
||||
#feed .me .bubble {
|
||||
background-color: #1c75db;
|
||||
color: var(--white-color);
|
||||
right: 0;
|
||||
}
|
||||
#feed .leon .bubble {
|
||||
background-color: var(--light-black-color);
|
||||
color: var(--white-color);
|
||||
}
|
||||
@keyframes fadeIn {
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
#input-container {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
bottom: 13%;
|
||||
}
|
||||
|
||||
#mic-container {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
margin-top: 38px;
|
||||
}
|
||||
|
||||
.italic {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
#mic-button {
|
||||
position: absolute;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
height: 26px;
|
||||
width: 26px;
|
||||
border-radius: 50%;
|
||||
background-color: #888;
|
||||
-webkit-mask-image: url(../img/mic.svg);
|
||||
mask-image: url(../img/mic.svg);
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
#mic-button:not(.enabled) {
|
||||
margin-left: -26px;
|
||||
}
|
||||
#mic-button:hover {
|
||||
background-color: var(--white-color);
|
||||
}
|
||||
#mic-button.enabled {
|
||||
background-color: #00e676;
|
||||
}
|
||||
#mic-button.enabled + #sonar {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 50%;
|
||||
opacity: 0.3;
|
||||
background-color: #575757;
|
||||
pointer-events: none;
|
||||
animation: sonar 1.3s linear infinite;
|
||||
}
|
||||
@keyframes sonar {
|
||||
25% {
|
||||
transform: scale(1.5);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
60% {
|
||||
transform: scale(1.5);
|
||||
}
|
||||
75% {
|
||||
transform: scale(2);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* Clickable URL styles */
|
||||
.clickable-url {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Clickable file path styles */
|
||||
.clickable-path {
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
@media (max-width: 940px) {
|
||||
:root {
|
||||
--sessions-panel-width: 100%;
|
||||
}
|
||||
|
||||
#sessions-panel {
|
||||
left: 4%;
|
||||
right: 4%;
|
||||
top: 1%;
|
||||
bottom: auto;
|
||||
width: auto;
|
||||
height: 168px;
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
#sessions-list {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
height: 112px;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
.session-group {
|
||||
min-width: 220px;
|
||||
}
|
||||
|
||||
main {
|
||||
top: 176px;
|
||||
left: 4%;
|
||||
width: 92%;
|
||||
height: calc(100% - 176px);
|
||||
}
|
||||
|
||||
#footer {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#top-container {
|
||||
top: 1%;
|
||||
}
|
||||
|
||||
#feed {
|
||||
top: 7%;
|
||||
height: 50%;
|
||||
}
|
||||
|
||||
#is-typing {
|
||||
top: 58%;
|
||||
}
|
||||
|
||||
textarea {
|
||||
font-size: 2.4em;
|
||||
}
|
||||
|
||||
#tip {
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,418 @@
|
||||
/* Tool Output Container Styles */
|
||||
.tool-group-container {
|
||||
margin: 10px 6px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 14px;
|
||||
background:
|
||||
linear-gradient(
|
||||
180deg,
|
||||
rgba(255, 255, 255, 0.04) 0%,
|
||||
rgba(255, 255, 255, 0.02) 100%
|
||||
),
|
||||
#15181a;
|
||||
box-shadow:
|
||||
0 10px 30px rgba(0, 0, 0, 0.24),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.03);
|
||||
opacity: 0;
|
||||
animation: fadeIn 0.3s ease-in forwards;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tool-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
padding: 14px 16px;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.tool-header:hover {
|
||||
background-color: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.tool-icon {
|
||||
color: var(--blue-color);
|
||||
font-size: 18px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tool-name {
|
||||
flex: 1;
|
||||
font-weight: 600;
|
||||
color: var(--white-color);
|
||||
font-size: 0.95em;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.expand-icon {
|
||||
color: rgba(255, 255, 255, 0.48);
|
||||
font-size: 18px;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.expand-icon.rotated {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.tool-content {
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
transition: max-height 0.3s ease, border-top-color 0.2s ease;
|
||||
border-top: 1px solid transparent;
|
||||
}
|
||||
|
||||
.tool-content.expanded {
|
||||
max-height: 900px;
|
||||
overflow-y: auto;
|
||||
border-top-color: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.tool-content::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
.tool-content::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.shell-output {
|
||||
padding: 12px;
|
||||
background-color: #0d1117;
|
||||
border-radius: 0 0 14px 14px;
|
||||
min-height: 40px;
|
||||
font-family: 'Courier New', Consolas, monospace;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.shell-message {
|
||||
margin: 2px 0;
|
||||
line-height: 1.4;
|
||||
color: #e6edf3;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.shell-prompt {
|
||||
color: var(--pink-color);
|
||||
font-weight: bold;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.shell-message .clickable-path {
|
||||
color: var(--blue-color);
|
||||
background-color: rgba(28, 117, 219, 0.1);
|
||||
padding: 1px 4px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid rgba(28, 117, 219, 0.3);
|
||||
}
|
||||
|
||||
.shell-message .clickable-url {
|
||||
color: var(--blue-color);
|
||||
}
|
||||
|
||||
.tool-activity-card {
|
||||
background:
|
||||
radial-gradient(
|
||||
circle at top right,
|
||||
rgba(28, 117, 219, 0.14),
|
||||
transparent 32%
|
||||
),
|
||||
linear-gradient(
|
||||
180deg,
|
||||
rgba(255, 255, 255, 0.04) 0%,
|
||||
rgba(255, 255, 255, 0.02) 100%
|
||||
),
|
||||
#15181a;
|
||||
}
|
||||
|
||||
.agent-skill-activity-card {
|
||||
background:
|
||||
radial-gradient(
|
||||
circle at top right,
|
||||
rgba(237, 41, 122, 0.14),
|
||||
transparent 32%
|
||||
),
|
||||
linear-gradient(
|
||||
180deg,
|
||||
rgba(255, 255, 255, 0.04) 0%,
|
||||
rgba(255, 255, 255, 0.02) 100%
|
||||
),
|
||||
#15181a;
|
||||
}
|
||||
|
||||
.agent-skill-activity-card .tool-icon {
|
||||
color: #ed297a;
|
||||
}
|
||||
|
||||
.tool-activity-header {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.tool-activity-heading {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.tool-activity-title-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.tool-title {
|
||||
color: var(--white-color);
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.tool-subtitle {
|
||||
color: rgba(255, 255, 255, 0.58);
|
||||
font-size: 0.86rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.tool-activity-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tool-status-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 72px;
|
||||
padding: 5px 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid transparent;
|
||||
font-size: 0.76rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.tool-status-chip.running {
|
||||
color: #9fd2ff;
|
||||
background-color: rgba(28, 117, 219, 0.14);
|
||||
border-color: rgba(28, 117, 219, 0.28);
|
||||
}
|
||||
|
||||
.tool-status-chip.success {
|
||||
color: #93f0c1;
|
||||
background-color: rgba(36, 178, 101, 0.15);
|
||||
border-color: rgba(36, 178, 101, 0.28);
|
||||
}
|
||||
|
||||
.tool-status-chip.selected {
|
||||
color: #93f0c1;
|
||||
background-color: rgba(36, 178, 101, 0.15);
|
||||
border-color: rgba(36, 178, 101, 0.28);
|
||||
}
|
||||
|
||||
.tool-status-chip.observed {
|
||||
color: #f5d48a;
|
||||
background-color: rgba(210, 157, 38, 0.15);
|
||||
border-color: rgba(210, 157, 38, 0.3);
|
||||
}
|
||||
|
||||
.tool-status-chip.error {
|
||||
color: #ffb0b0;
|
||||
background-color: rgba(237, 41, 122, 0.14);
|
||||
border-color: rgba(237, 41, 122, 0.28);
|
||||
}
|
||||
|
||||
.tool-activity-summary {
|
||||
padding: 16px 16px 4px;
|
||||
color: rgba(255, 255, 255, 0.82);
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.tool-activity-sections {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
padding: 12px 16px 16px;
|
||||
}
|
||||
|
||||
.tool-activity-sections.single {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.tool-activity-panel {
|
||||
border: 1px solid rgba(255, 255, 255, 0.07);
|
||||
border-radius: 12px;
|
||||
background-color: rgba(255, 255, 255, 0.025);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tool-panel-heading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 12px 14px 10px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.tool-panel-title {
|
||||
color: var(--white-color);
|
||||
font-size: 0.84rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.tool-panel-description {
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.tool-panel-body {
|
||||
padding: 12px 14px 14px;
|
||||
min-height: 72px;
|
||||
}
|
||||
|
||||
.tool-output-summary {
|
||||
margin-bottom: 10px;
|
||||
color: rgba(255, 255, 255, 0.86);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.tool-empty-state {
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
font-size: 0.88rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.tool-kv-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.tool-kv-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.tool-kv-key {
|
||||
color: rgba(255, 255, 255, 0.52);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.03em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.tool-kv-value {
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tool-rich-text {
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
line-height: 1.45;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.tool-command-output {
|
||||
margin: 0;
|
||||
max-height: 320px;
|
||||
overflow: auto;
|
||||
color: rgba(255, 255, 255, 0.86);
|
||||
font-family: 'Courier New', Consolas, monospace;
|
||||
font-size: 0.84rem;
|
||||
line-height: 1.45;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.tool-rich-text .clickable-path {
|
||||
color: var(--blue-color);
|
||||
background-color: rgba(28, 117, 219, 0.1);
|
||||
padding: 1px 4px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid rgba(28, 117, 219, 0.3);
|
||||
}
|
||||
|
||||
.tool-rich-text .clickable-url {
|
||||
color: var(--blue-color);
|
||||
}
|
||||
|
||||
.tool-value-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-left: 18px;
|
||||
}
|
||||
|
||||
.tool-value-list li {
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.tool-raw-details {
|
||||
margin: 0 16px 16px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
.tool-raw-details summary {
|
||||
cursor: pointer;
|
||||
color: rgba(255, 255, 255, 0.62);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.03em;
|
||||
text-transform: uppercase;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.tool-raw-details summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tool-raw-content {
|
||||
margin-top: 12px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 10px;
|
||||
background-color: rgba(0, 0, 0, 0.3);
|
||||
color: rgba(255, 255, 255, 0.78);
|
||||
font-family: 'Courier New', Consolas, monospace;
|
||||
font-size: 0.84rem;
|
||||
line-height: 1.45;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 700px) {
|
||||
.tool-activity-sections {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.tool-activity-header {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.tool-activity-meta {
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.tool-status-chip {
|
||||
min-width: 64px;
|
||||
padding: 5px 8px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
@use 'sass:color';
|
||||
|
||||
$voice-neon-pink-color: #ed297a;
|
||||
$voice-neon-blue-color: #1c75db;
|
||||
$voice-neon-shadow-color: color.mix(
|
||||
$voice-neon-pink-color,
|
||||
$voice-neon-blue-color
|
||||
);
|
||||
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* Overlay and containers
|
||||
*/
|
||||
|
||||
body.voice-mode-enabled {
|
||||
#voice-overlay-transitor,
|
||||
#voice-overlay-bg {
|
||||
visibility: visible;
|
||||
}
|
||||
#voice-overlay-bg {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
#voice-overlay-transitor {
|
||||
position: fixed;
|
||||
background-color: var(--black-color);
|
||||
z-index: 10;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
will-change: transform;
|
||||
animation: scaleIn 1s;
|
||||
}
|
||||
@keyframes scaleIn {
|
||||
0% {
|
||||
transform: scale3d(0, 0, 1);
|
||||
}
|
||||
100% {
|
||||
transform: scale3d(172, 172, 1);
|
||||
}
|
||||
}
|
||||
|
||||
#voice-status,
|
||||
#voice-tips {
|
||||
color: var(--grey-color);
|
||||
text-align: center;
|
||||
}
|
||||
#voice-status {
|
||||
font-size: 17px;
|
||||
font-style: italic;
|
||||
}
|
||||
#voice-tips {
|
||||
margin-top: 32px;
|
||||
line-height: 18px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
#voice-overlay-bg {
|
||||
visibility: hidden;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
position: fixed;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
z-index: 100;
|
||||
will-change: opacity;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
// backdrop-filter: saturate(140%) blur(5px);
|
||||
/*background-color: rgba(0, 0, 0, .9);*/
|
||||
background-color: var(--black-color);
|
||||
}
|
||||
@keyframes skipFadeIn {
|
||||
0% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
#voice-container {
|
||||
opacity: 0;
|
||||
position: relative;
|
||||
top: 64px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 1024px;
|
||||
height: 756px;
|
||||
align-items: center;
|
||||
gap: 64px;
|
||||
animation: fadeIn 1s 3s both;
|
||||
}
|
||||
|
||||
#voice-energy-container {
|
||||
--neon-size: 228px;
|
||||
|
||||
// animation: fadeIn 1s 1.5s both;
|
||||
//opacity: 0;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
height: 400px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
p#voice-speech {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
font-size: 3rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/**
|
||||
* Neons
|
||||
*/
|
||||
|
||||
.voice-neon {
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
width: var(--neon-size);
|
||||
height: var(--neon-size);
|
||||
}
|
||||
|
||||
#purple-neon-blur {
|
||||
--neon-blur: calc(var(--neon-size) + 96px);
|
||||
|
||||
position: absolute;
|
||||
z-index: 0;
|
||||
opacity: 0.7;
|
||||
width: var(--neon-blur);
|
||||
height: var(--neon-blur);
|
||||
}
|
||||
|
||||
#blue-neon-1 {
|
||||
margin-top: -8px;
|
||||
margin-left: 12px;
|
||||
}
|
||||
|
||||
#blue-neon-2 {
|
||||
margin-top: 8px;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
/**
|
||||
* Particles
|
||||
*/
|
||||
|
||||
.voice-particle {
|
||||
position: absolute;
|
||||
width: 3px;
|
||||
height: 3px;
|
||||
border-radius: 50%;
|
||||
opacity: 1;
|
||||
will-change: transform, opacity;
|
||||
animation-duration: 1s;
|
||||
animation-iteration-count: infinite;
|
||||
}
|
||||
.voice-particle.blue {
|
||||
background-color: #c4e0ff;
|
||||
box-shadow: 0 0 2px 2px var(--blue-color);
|
||||
}
|
||||
.voice-particle.pink {
|
||||
background-color: #ffb9d7;
|
||||
box-shadow: 0 0 2px 2px var(--pink-color);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
@use 'variables' as *;
|
||||
|
||||
/**
|
||||
* IDLE status
|
||||
*/
|
||||
|
||||
#voice-energy-container.idle {
|
||||
.voice-particle {
|
||||
visibility: hidden;
|
||||
animation-play-state: paused;
|
||||
opacity: 0;
|
||||
}
|
||||
.voice-neon {
|
||||
margin: 0;
|
||||
}
|
||||
#purple-neon-blur {
|
||||
transform: scale(1);
|
||||
}
|
||||
#purple-neon-blur circle {
|
||||
filter: drop-shadow(0px 0px 64px $voice-neon-shadow-color);
|
||||
animation: idleNeonBlurBreath 2.2s infinite alternate;
|
||||
}
|
||||
#pink-neon-1 {
|
||||
transform: scale(1);
|
||||
animation: idleBouncePinkNeon1 1.8s 1s infinite alternate;
|
||||
}
|
||||
#blue-neon-1 {
|
||||
transform: scale(0.8);
|
||||
animation: idleMoveBlueNeon1 1.8s infinite alternate;
|
||||
}
|
||||
#blue-neon-2 {
|
||||
transform: scale(0.9);
|
||||
animation: idleMoveBlueNeon2 1.8s 0.5s infinite alternate;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes idleNeonBlurBreath {
|
||||
100% {
|
||||
filter: drop-shadow(0px 0px 0px $voice-neon-shadow-color);
|
||||
}
|
||||
}
|
||||
@keyframes idleBouncePurpleNeonBlur {
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
@keyframes idleBouncePinkNeon1 {
|
||||
100% {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
}
|
||||
@keyframes idleMoveBlueNeon1 {
|
||||
100% {
|
||||
transform: scale(0.9);
|
||||
}
|
||||
}
|
||||
@keyframes idleMoveBlueNeon2 {
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
@use 'variables' as *;
|
||||
|
||||
/**
|
||||
* Listening status
|
||||
*/
|
||||
|
||||
#voice-energy-container.listening {
|
||||
.voice-particle {
|
||||
visibility: hidden;
|
||||
animation-play-state: paused;
|
||||
opacity: 0;
|
||||
}
|
||||
.voice-neon {
|
||||
margin: 0;
|
||||
}
|
||||
#purple-neon-blur {
|
||||
transform: scale(1);
|
||||
}
|
||||
#purple-neon-blur circle {
|
||||
filter: drop-shadow(0px 0px 64px $voice-neon-shadow-color);
|
||||
animation: listeningNeonBlurBreath 0.7s infinite alternate;
|
||||
}
|
||||
#pink-neon-1 {
|
||||
transform: scale(1);
|
||||
animation: listeningBouncePinkNeon1 0.3s 1s infinite alternate;
|
||||
}
|
||||
#blue-neon-1 {
|
||||
transform: scale(0.8);
|
||||
animation: listeningMoveBlueNeon1 0.3s infinite alternate;
|
||||
}
|
||||
#blue-neon-2 {
|
||||
transform: scale(0.9);
|
||||
animation: listeningMoveBlueNeon2 0.3s 0.5s infinite alternate;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes listeningNeonBlurBreath {
|
||||
100% {
|
||||
filter: drop-shadow(0px 0px 0px $voice-neon-shadow-color);
|
||||
}
|
||||
}
|
||||
@keyframes listeningBouncePurpleNeonBlur {
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
@keyframes listeningBouncePinkNeon1 {
|
||||
100% {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
}
|
||||
@keyframes listeningMoveBlueNeon1 {
|
||||
100% {
|
||||
transform: scale(0.9);
|
||||
}
|
||||
}
|
||||
@keyframes listeningMoveBlueNeon2 {
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
@use 'base';
|
||||
@use 'listening';
|
||||
@use 'idle';
|
||||
@use 'processing';
|
||||
@use 'talking';
|
||||
@@ -0,0 +1,88 @@
|
||||
@use 'sass:math';
|
||||
|
||||
/**
|
||||
* Processing status
|
||||
*/
|
||||
|
||||
#voice-energy-container.processing {
|
||||
#purple-neon-blur {
|
||||
animation: processingBouncePurpleNeonBlur 1s infinite alternate;
|
||||
}
|
||||
#pink-neon-1 {
|
||||
animation: processingBouncePinkNeon1 0.5s infinite alternate;
|
||||
}
|
||||
#blue-neon-1 {
|
||||
animation: processingMoveBlueNeon1 0.5s infinite alternate;
|
||||
}
|
||||
#blue-neon-2 {
|
||||
animation: processingMoveBlueNeon2 0.5s infinite alternate;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes processingBouncePurpleNeonBlur {
|
||||
50% {
|
||||
transform: scale(1.07);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
@keyframes processingBouncePinkNeon1 {
|
||||
50% {
|
||||
transform: scale(1.02);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
@keyframes processingMoveBlueNeon1 {
|
||||
0% {
|
||||
transform: translateX(0) translateY(0);
|
||||
}
|
||||
33% {
|
||||
transform: translateY(-3px) translateX(-2px);
|
||||
}
|
||||
66% {
|
||||
transform: translateY(-3px) translateX(3px);
|
||||
}
|
||||
100% {
|
||||
transform: translateY(-3px) translateX(1px);
|
||||
}
|
||||
}
|
||||
@keyframes processingMoveBlueNeon2 {
|
||||
0% {
|
||||
transform: translateX(0) translateY(0);
|
||||
}
|
||||
33% {
|
||||
transform: translateY(3px) translateX(2px);
|
||||
}
|
||||
66% {
|
||||
transform: translateY(3px) translateX(3px);
|
||||
}
|
||||
100% {
|
||||
transform: translateY(3px) translateX(-1px);
|
||||
}
|
||||
}
|
||||
|
||||
@for $i from 0 through 31 {
|
||||
.processing .voice-particle[data-particle='#{$i}'] {
|
||||
animation-delay: #{$i * 0.1}s;
|
||||
}
|
||||
|
||||
.processing .voice-particle[data-particle='#{$i}'] {
|
||||
animation-name: processingMoveParticle#{$i};
|
||||
}
|
||||
|
||||
#voice-energy-container.processing {
|
||||
@keyframes processingMoveParticle#{$i} {
|
||||
75% {
|
||||
opacity: 0.1;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateX(math.cos(11.25deg * $i) * 110px)
|
||||
translateY(math.sin(11.25deg * $i) * 110px);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
@use 'sass:math';
|
||||
|
||||
/**
|
||||
* Talking status
|
||||
*/
|
||||
|
||||
#voice-energy-container.talking {
|
||||
.voice-neon {
|
||||
margin: 0;
|
||||
}
|
||||
#purple-neon-blur {
|
||||
animation: talkingBouncePurpleNeonBlur 1s infinite alternate;
|
||||
}
|
||||
#pink-neon-1 {
|
||||
transform: scale(1);
|
||||
animation: talkingBouncePinkNeon1 0.5s 1s infinite alternate;
|
||||
}
|
||||
#blue-neon-1 {
|
||||
transform: scale(0.8);
|
||||
animation: talkingMoveBlueNeon1 0.5s infinite alternate;
|
||||
}
|
||||
#blue-neon-2 {
|
||||
transform: scale(0.9);
|
||||
animation: talkingMoveBlueNeon2 0.5s 0.3s infinite alternate;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes talkingBouncePurpleNeonBlur {
|
||||
50% {
|
||||
transform: scale(1.07);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
@keyframes talkingBouncePinkNeon1 {
|
||||
100% {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
}
|
||||
@keyframes talkingMoveBlueNeon1 {
|
||||
100% {
|
||||
transform: scale(0.9);
|
||||
}
|
||||
}
|
||||
@keyframes talkingMoveBlueNeon2 {
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.talking .voice-particle {
|
||||
opacity: 0;
|
||||
animation-duration: 2s;
|
||||
}
|
||||
@for $i from 0 through 31 {
|
||||
.talking .voice-particle[data-particle='#{$i}'] {
|
||||
animation-delay: #{$i * 0.2}s;
|
||||
// animation-duration: #{$i * 0.5}s;
|
||||
}
|
||||
|
||||
.talking .voice-particle[data-particle='#{$i}'] {
|
||||
animation-name: talkingMoveParticle#{$i};
|
||||
}
|
||||
|
||||
#voice-energy-container.talking {
|
||||
@keyframes talkingMoveParticle#{$i} {
|
||||
0% {
|
||||
opacity: 1;
|
||||
transform: translate(0);
|
||||
}
|
||||
50% {
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translateX(math.cos(math.random() * 360deg))
|
||||
translateY(math.sin(math.random() * 360deg));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './timer'
|
||||
@@ -0,0 +1 @@
|
||||
export * from './timer'
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
import { CircularProgress, Flexbox, Text } from '@aurora'
|
||||
|
||||
interface TimerProps {
|
||||
initialTime: number
|
||||
interval: number
|
||||
totalTimeContent: string
|
||||
initialProgress?: number
|
||||
onEnd?: () => void
|
||||
}
|
||||
|
||||
function formatTime(seconds: number): string {
|
||||
const minutes = seconds >= 60 ? Math.floor(seconds / 60) : 0
|
||||
const remainingSeconds = seconds % 60
|
||||
const formattedMinutes = minutes < 10 ? `0${minutes}` : minutes
|
||||
const formattedSeconds =
|
||||
remainingSeconds < 10 ? `0${remainingSeconds}` : remainingSeconds
|
||||
|
||||
return `${formattedMinutes}:${formattedSeconds}`
|
||||
}
|
||||
|
||||
export function Timer({
|
||||
initialTime,
|
||||
initialProgress,
|
||||
interval,
|
||||
totalTimeContent,
|
||||
onEnd
|
||||
}: TimerProps) {
|
||||
const [progress, setProgress] = useState(initialProgress || 0)
|
||||
const [timeLeft, setTimeLeft] = useState(initialTime)
|
||||
|
||||
useEffect(() => {
|
||||
setTimeLeft(initialTime)
|
||||
setProgress(progress)
|
||||
}, [initialTime])
|
||||
|
||||
useEffect(() => {
|
||||
if (timeLeft <= 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const timer = setInterval(() => {
|
||||
setTimeLeft((prevTime) => {
|
||||
const newTime = prevTime - 1
|
||||
|
||||
if (newTime <= 0 && onEnd) {
|
||||
onEnd()
|
||||
}
|
||||
|
||||
return newTime
|
||||
})
|
||||
setProgress((prevProgress) => prevProgress + 100 / initialTime)
|
||||
}, interval)
|
||||
|
||||
return () => clearInterval(timer)
|
||||
}, [initialTime, interval, timeLeft])
|
||||
|
||||
return (
|
||||
<CircularProgress value={progress} size="lg">
|
||||
<Flexbox gap="xs" alignItems="center" justifyContent="center">
|
||||
<Text fontSize="lg" fontWeight="semi-bold">
|
||||
{formatTime(timeLeft)}
|
||||
</Text>
|
||||
<Text fontSize="xs" secondary>
|
||||
{totalTimeContent}
|
||||
</Text>
|
||||
</Flexbox>
|
||||
</CircularProgress>
|
||||
)
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg viewBox="0 0 331 344" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<!-- Generator: Sketch 52.5 (67469) - http://www.bohemiancoding.com/sketch -->
|
||||
<title>logo</title>
|
||||
<desc>Created with Sketch.</desc>
|
||||
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<g id="Logo">
|
||||
<path d="M250.64,41 C270.304004,42.1610882 288.156044,52.8690755 298.438662,69.6705316 C308.721281,86.4719876 310.13355,107.241239 302.22,125.28 C301.7,126.45 301.15,127.61 300.56,128.75 C319.366788,139.878564 330.669003,160.322066 330.091728,182.167133 C329.514454,204.0122 317.148293,223.830245 297.78,233.95 C297.532922,261.641745 278.432026,285.595234 251.49,292 C250.416906,312.172645 238.230063,330.080529 219.858344,338.480949 C201.486625,346.881369 179.969731,344.38443 164.01,332 L160.44,329.23 L160.44,19.93 L162.73,17.32 C175.471771,2.76363972 195.386563,-3.2731374 214.066047,1.75848783 C232.74553,6.79011305 246.933663,22.0130721 250.64,41" id="Path" fill="#C9C9C9"></path>
|
||||
<path d="M169.67,59.35 L169.67,324.76 C183.967722,335.845903 203.509545,337.305349 219.295336,328.466179 C235.081126,319.627008 244.049498,302.203429 242.07,284.22 C268.377478,281.884446 288.549541,259.850947 288.56,233.44 C288.56,231.98 288.47,230.44 288.32,228.72 L288.26,228.14 C308.113863,220.465385 321.113357,201.267433 320.868328,179.983271 C320.623298,158.699109 307.185283,139.805514 287.16,132.59 C299.201053,117.242299 301.432492,96.3705182 292.908203,78.8241398 C284.383914,61.2777614 266.597395,50.1309998 247.09,50.11 C245.54,50.11 243.96,50.19 242.34,50.34 C242.194471,33.1294454 231.430913,17.7984963 215.292982,11.815892 C199.155051,5.83328766 180.999054,10.4432677 169.67,23.4 L169.67,59.4 L169.67,59.35 Z" id="Path" fill="#ED297A"></path>
|
||||
<path d="M311.36,180.57 C311.365994,162.074603 299.120439,145.812103 281.343516,140.707126 C263.566594,135.602149 244.558255,142.889506 234.75,158.57 L226.75,153.57 C237.365806,136.609776 256.918463,127.423938 276.75,130.08 C284.339746,122.340056 288.572378,111.920165 288.53,101.08 C288.53,78.1877971 269.972203,59.63 247.08,59.63 C245.586971,59.6271616 244.094853,59.7039372 242.61,59.86 L241.27,60.02 C237.069339,78.1206974 221.351105,91.2246721 202.79,92.1 L202.38,82.61 C216.541344,82.0019927 228.615323,72.150244 232.052357,58.3988753 C235.48939,44.6475065 229.470406,30.2735808 217.260427,23.0742928 C205.050447,15.8750049 189.55921,17.5660037 179.19,27.23 L179.19,59.35 L169.67,59.35 L169.67,23.4 C180.999054,10.4432677 199.155051,5.83328766 215.292982,11.815892 C231.430913,17.7984963 242.194471,33.1294454 242.34,50.34 C243.96,50.19 245.54,50.11 247.09,50.11 C266.597395,50.1309998 284.383914,61.2777614 292.908203,78.8241398 C301.432492,96.3705182 299.201053,117.242299 287.16,132.59 C307.185283,139.805514 320.623298,158.699109 320.868328,179.983271 C321.113357,201.267433 308.113863,220.465385 288.26,228.14 L288.32,228.72 C288.47,230.4 288.56,231.98 288.56,233.44 C288.549541,259.850947 268.377478,281.884446 242.07,284.22 C244.059426,302.206931 235.092757,319.638427 219.30298,328.47983 C203.513202,337.321234 183.96552,335.856198 169.67,324.76 L169.67,292.46 L179.19,292.46 L179.19,319.77 C190.187352,326.24397 203.809618,326.332461 214.890148,320.00191 C225.970679,313.671359 232.812253,301.891425 232.82,289.13 C232.824107,287.712866 232.740615,286.296832 232.57,284.89 L232.48,284.16 C219.501568,282.845584 207.520172,276.607958 199,266.73 L206.18,260.52 C217.502416,273.612717 235.767179,278.293513 251.991106,272.260226 C268.215032,266.226939 278.983405,250.749421 279,233.44 C279,232.44 279,231.57 278.93,230.74 C251.386749,235.675319 225.010632,217.49628 219.82,190 L229.16,188.25 C233.129854,209.273133 252.444519,223.823215 273.746894,221.838103 C295.049269,219.852991 311.342948,201.984662 311.36,180.59" id="Path" fill="#FFFFFF"></path>
|
||||
<path d="M79.47,41.29 C59.8066341,42.447746 41.9539463,53.1534482 31.6708751,69.95365 C21.3878039,86.7538518 19.9756696,107.522492 27.89,125.56 C28.41,126.74 28.96,127.9 29.56,129.04 C10.7637431,140.17517 -0.530524844,160.614207 0.044524719,182.453634 C0.619574282,204.293062 12.9737988,224.109415 32.33,234.24 C32.5705294,261.940431 51.6712753,285.906111 78.62,292.32 C79.6930938,312.492645 91.8799368,330.400529 110.251656,338.800949 C128.623375,347.201369 150.140269,344.70443 166.1,332.32 L169.67,329.55 L169.67,20.23 L167.38,17.62 C154.639626,3.06383185 134.726538,-2.9741018 116.047549,2.05526536 C97.3685601,7.08463252 83.1792521,22.3047265 79.47,41.29" id="Path" fill="#C9C9C9"></path>
|
||||
<path d="M160.44,59.64 L160.44,325.06 C146.143061,336.173463 126.582248,337.648732 110.780169,328.805327 C94.9780899,319.961922 86.0050706,302.518105 88,284.52 C61.696479,282.179578 41.5299972,260.147436 41.52,233.74 C41.52,232.28 41.6,230.74 41.75,229.02 L41.81,228.44 C21.9555682,220.750788 8.96708703,201.536622 9.23220597,180.246902 C9.49732492,158.957183 22.9602486,140.072414 43,132.88 C30.9727627,117.542153 28.7393251,96.690502 37.2452913,79.1533261 C45.7512576,61.6161502 63.5089397,50.4606896 83,50.41 C84.55,50.41 86.14,50.49 87.75,50.64 C87.8752789,33.414146 98.6417018,18.0612756 114.794365,12.0748572 C130.947029,6.08843887 149.118659,10.7164345 160.44,23.7 L160.44,59.64 Z" id="Path" fill="#1C75DB"></path>
|
||||
<path d="M18.75,180.87 C18.7440057,162.374603 30.9895614,146.112103 48.7664839,141.007126 C66.5434063,135.902149 85.5517453,143.189506 95.36,158.87 L103.36,153.87 C92.7362795,136.89172 73.1584352,127.703237 53.31,130.38 C45.7202536,122.640056 41.4876221,112.220165 41.53,101.38 C41.5326513,90.3850432 45.9036085,79.8416108 53.68103,72.0698157 C61.4584514,64.2980206 72.0050441,59.9346945 83,59.94 C84.496169,59.9342385 85.9915942,60.0076746 87.48,60.16 L88.81,60.33 C93.0175747,78.4370421 108.750476,91.5389411 127.32,92.4 L127.73,82.91 C113.568656,82.3019927 101.494677,72.450244 98.0576434,58.6988753 C94.6206096,44.9475065 100.639594,30.5735808 112.849573,23.3742928 C125.059553,16.1750049 140.55079,17.8660037 150.92,27.53 L150.92,59.64 L160.44,59.64 L160.44,23.7 C149.110946,10.7432677 130.954949,6.13328766 114.817018,12.115892 C98.6790869,18.0984963 87.9155294,33.4294454 87.77,50.64 C86.16,50.49 84.57,50.41 83.02,50.41 C63.5073553,50.4137874 45.7107042,61.5614533 37.1919008,79.1163189 C28.6730973,96.6711845 30.928585,117.549499 43,132.88 C22.966945,140.089556 9.52045448,158.985255 9.27316203,180.274685 C9.02586957,201.564115 22.0298162,220.767055 41.89,228.44 L41.83,229.02 C41.68,230.7 41.6,232.28 41.6,233.74 C41.6062718,260.119384 61.7281371,282.140684 88,284.52 C86.010574,302.506931 94.9772425,319.938427 110.76702,328.77983 C126.556798,337.621234 146.10448,336.156198 160.4,325.06 L160.4,292.76 L150.88,292.76 L150.88,320.07 C139.882648,326.54397 126.260382,326.632461 115.179852,320.30191 C104.099321,313.971359 97.2577469,302.191425 97.25,289.43 C97.2505745,288.013001 97.3340504,286.597248 97.5,285.19 L97.59,284.46 C110.587095,283.147703 122.585122,276.898134 131.11,267 L123.93,260.78 C112.61845,273.906654 94.3352005,278.611779 78.0923104,272.576158 C61.8494202,266.540536 51.0750377,251.038017 51.08,233.71 C51.08,232.71 51.08,231.84 51.15,231.01 C78.7102292,235.962739 105.109178,217.768266 110.29,190.25 L101,188.53 C97.0537654,209.57847 77.7264203,224.158026 56.4035784,222.171249 C35.0807365,220.184472 18.7796392,202.285199 18.79,180.87" id="Path" fill="#FFFFFF"></path>
|
||||
<rect id="Rectangle" fill="#FFFFFF" x="150.92" y="51.12" width="9.53" height="249.42"></rect>
|
||||
<rect id="Rectangle" fill="#FFFFFF" x="169.67" y="42.87" width="9.53" height="257.67"></rect>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 7.6 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:a="http://ns.adobe.com/AdobeSVGViewerExtensions/3.0/" version="1.1" x="0px" y="0px" viewBox="-20.794 -3 100 100" overflow="visible" enable-background="new -20.794 -3 100 100" xml:space="preserve"><path fill="#FFFFFF" d="M29.206,62.703c8.638,0,15.643-7.002,15.643-15.642V15.642C44.848,7.005,37.843,0,29.206,0 c-8.64,0-15.642,7.005-15.642,15.642v31.419C13.563,55.701,20.565,62.703,29.206,62.703z"></path><path fill="#FFFFFF" d="M52.14,40.702v6.38c0,12.648-10.287,22.935-22.935,22.935c-12.648,0-22.935-10.287-22.935-22.935v-6.38H0 v6.38c0,15.045,11.435,27.465,26.07,29.035v11.612H8.821V94h40.77v-6.271H32.341V76.117c14.635-1.569,26.07-13.989,26.07-29.035 v-6.38H52.14z"></path></svg>
|
||||
|
After Width: | Height: | Size: 772 B |
@@ -0,0 +1,366 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Leon</title>
|
||||
<style>
|
||||
@import './css/style.scss';
|
||||
</style>
|
||||
</head>
|
||||
<body class="settingup">
|
||||
<div id="init"></div>
|
||||
<div id="voice-overlay-bg">
|
||||
<div id="voice-container">
|
||||
<div>
|
||||
<div id="voice-energy-container">
|
||||
<svg
|
||||
class="voice-neon"
|
||||
id="pink-neon-1"
|
||||
viewBox="0 0 237 237"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g filter="url(#filter0_i_6_16)">
|
||||
<circle
|
||||
cx="118.5"
|
||||
cy="118.5"
|
||||
r="113.5"
|
||||
stroke="#FFF"
|
||||
stroke-width="10"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<filter
|
||||
id="filter0_i_6_16"
|
||||
x="0"
|
||||
y="0"
|
||||
width="237"
|
||||
height="237"
|
||||
filterUnits="userSpaceOnUse"
|
||||
color-interpolation-filters="sRGB"
|
||||
>
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix" />
|
||||
<feBlend
|
||||
mode="normal"
|
||||
in="SourceGraphic"
|
||||
in2="BackgroundImageFix"
|
||||
result="shape"
|
||||
/>
|
||||
<feColorMatrix
|
||||
in="SourceAlpha"
|
||||
type="matrix"
|
||||
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
|
||||
result="hardAlpha"
|
||||
/>
|
||||
<feMorphology
|
||||
radius="1"
|
||||
operator="erode"
|
||||
in="SourceAlpha"
|
||||
result="effect1_innerShadow_6_16"
|
||||
/>
|
||||
<feOffset />
|
||||
<feGaussianBlur stdDeviation="1" />
|
||||
<feComposite
|
||||
in2="hardAlpha"
|
||||
operator="arithmetic"
|
||||
k2="-1"
|
||||
k3="1"
|
||||
/>
|
||||
<feColorMatrix
|
||||
type="matrix"
|
||||
values="0 0 0 0 0.929412 0 0 0 0 0.160784 0 0 0 0 0.478431 0 0 0 1 0"
|
||||
/>
|
||||
<feBlend
|
||||
mode="normal"
|
||||
in2="shape"
|
||||
result="effect1_innerShadow_6_16"
|
||||
/>
|
||||
</filter>
|
||||
</defs>
|
||||
</svg>
|
||||
|
||||
<svg
|
||||
class="voice-neon"
|
||||
id="blue-neon-1"
|
||||
viewBox="0 0 237 237"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g filter="url(#filter0_i_6_24)">
|
||||
<circle
|
||||
cx="118.5"
|
||||
cy="118.5"
|
||||
r="116.5"
|
||||
stroke="#FFF"
|
||||
stroke-width="4"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<filter
|
||||
id="filter0_i_6_24"
|
||||
x="0"
|
||||
y="0"
|
||||
width="237"
|
||||
height="237"
|
||||
filterUnits="userSpaceOnUse"
|
||||
color-interpolation-filters="sRGB"
|
||||
>
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix" />
|
||||
<feBlend
|
||||
mode="normal"
|
||||
in="SourceGraphic"
|
||||
in2="BackgroundImageFix"
|
||||
result="shape"
|
||||
/>
|
||||
<feColorMatrix
|
||||
in="SourceAlpha"
|
||||
type="matrix"
|
||||
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
|
||||
result="hardAlpha"
|
||||
/>
|
||||
<feMorphology
|
||||
radius="1"
|
||||
operator="erode"
|
||||
in="SourceAlpha"
|
||||
result="effect1_innerShadow_6_24"
|
||||
/>
|
||||
<feOffset />
|
||||
<feGaussianBlur stdDeviation="1" />
|
||||
<feComposite
|
||||
in2="hardAlpha"
|
||||
operator="arithmetic"
|
||||
k2="-1"
|
||||
k3="1"
|
||||
/>
|
||||
<feColorMatrix
|
||||
type="matrix"
|
||||
values="0 0 0 0 0.109804 0 0 0 0 0.458824 0 0 0 0 0.858824 0 0 0 1 0"
|
||||
/>
|
||||
<feBlend
|
||||
mode="normal"
|
||||
in2="shape"
|
||||
result="effect1_innerShadow_6_24"
|
||||
/>
|
||||
</filter>
|
||||
</defs>
|
||||
</svg>
|
||||
|
||||
<svg
|
||||
class="voice-neon"
|
||||
id="blue-neon-2"
|
||||
viewBox="0 0 237 237"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g filter="url(#filter0_i_6_24)">
|
||||
<circle
|
||||
cx="118.5"
|
||||
cy="118.5"
|
||||
r="116.5"
|
||||
stroke="#FFF"
|
||||
stroke-width="4"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<filter
|
||||
id="filter0_i_6_24"
|
||||
x="0"
|
||||
y="0"
|
||||
width="237"
|
||||
height="237"
|
||||
filterUnits="userSpaceOnUse"
|
||||
color-interpolation-filters="sRGB"
|
||||
>
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix" />
|
||||
<feBlend
|
||||
mode="normal"
|
||||
in="SourceGraphic"
|
||||
in2="BackgroundImageFix"
|
||||
result="shape"
|
||||
/>
|
||||
<feColorMatrix
|
||||
in="SourceAlpha"
|
||||
type="matrix"
|
||||
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
|
||||
result="hardAlpha"
|
||||
/>
|
||||
<feMorphology
|
||||
radius="5"
|
||||
operator="erode"
|
||||
in="SourceAlpha"
|
||||
result="effect1_innerShadow_6_24"
|
||||
/>
|
||||
<feOffset />
|
||||
<feGaussianBlur stdDeviation="1" />
|
||||
<feComposite
|
||||
in2="hardAlpha"
|
||||
operator="arithmetic"
|
||||
k2="-1"
|
||||
k3="1"
|
||||
/>
|
||||
<feColorMatrix
|
||||
type="matrix"
|
||||
values="0 0 0 0 0.109804 0 0 0 0 0.458824 0 0 0 0 0.858824 0 0 0 1 0"
|
||||
/>
|
||||
<feBlend
|
||||
mode="normal"
|
||||
in2="shape"
|
||||
result="effect1_innerShadow_6_24"
|
||||
/>
|
||||
</filter>
|
||||
</defs>
|
||||
</svg>
|
||||
|
||||
<svg
|
||||
id="purple-neon-blur"
|
||||
viewBox="0 0 341 341"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g opacity="0.72" filter="url(#filter0_f_18_61)">
|
||||
<circle
|
||||
cx="170.5"
|
||||
cy="170.5"
|
||||
r="116.5"
|
||||
stroke="#ED297A"
|
||||
stroke-width="24"
|
||||
/>
|
||||
</g>
|
||||
<g opacity="0.72" filter="url(#filter1_f_18_61)">
|
||||
<circle
|
||||
cx="170.5"
|
||||
cy="170.5"
|
||||
r="116.5"
|
||||
stroke="#1C75DB"
|
||||
stroke-width="24"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<filter
|
||||
id="filter0_f_18_61"
|
||||
x="0"
|
||||
y="0"
|
||||
width="341"
|
||||
height="341"
|
||||
filterUnits="userSpaceOnUse"
|
||||
color-interpolation-filters="sRGB"
|
||||
>
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix" />
|
||||
<feBlend
|
||||
mode="normal"
|
||||
in="SourceGraphic"
|
||||
in2="BackgroundImageFix"
|
||||
result="shape"
|
||||
/>
|
||||
<feGaussianBlur
|
||||
stdDeviation="21"
|
||||
result="effect1_foregroundBlur_18_61"
|
||||
/>
|
||||
</filter>
|
||||
<filter
|
||||
id="filter1_f_18_61"
|
||||
x="0"
|
||||
y="0"
|
||||
width="341"
|
||||
height="341"
|
||||
filterUnits="userSpaceOnUse"
|
||||
color-interpolation-filters="sRGB"
|
||||
>
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix" />
|
||||
<feBlend
|
||||
mode="normal"
|
||||
in="SourceGraphic"
|
||||
in2="BackgroundImageFix"
|
||||
result="shape"
|
||||
/>
|
||||
<feGaussianBlur
|
||||
stdDeviation="21"
|
||||
result="effect1_foregroundBlur_18_61"
|
||||
/>
|
||||
</filter>
|
||||
</defs>
|
||||
</svg>
|
||||
</div>
|
||||
<div id="voice-status"></div>
|
||||
<div id="voice-tips">
|
||||
It is recommended to use a headset for a better voice experience.
|
||||
<br />
|
||||
Otherwise, if your microphone is too sensitive or speakers are too
|
||||
loud,
|
||||
<br />
|
||||
Leon may hear his own voice and get confused.
|
||||
</div>
|
||||
</div>
|
||||
<p id="voice-speech"></p>
|
||||
</div>
|
||||
</div>
|
||||
<aside id="sessions-panel" aria-label="Sessions">
|
||||
<div id="sessions-header">
|
||||
<div>
|
||||
<span id="sessions-eyebrow">Sessions</span>
|
||||
</div>
|
||||
<button id="new-session" type="button" aria-label="New session">
|
||||
<i class="ri-add-line"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div id="sessions-list"></div>
|
||||
</aside>
|
||||
<main>
|
||||
<div id="top-container">
|
||||
<div id="mood"></div>
|
||||
<button id="info">Info</button>
|
||||
</div>
|
||||
<div id="feed">
|
||||
<p id="no-bubble" class="hide">
|
||||
You can start to interact with me, don't be shy.
|
||||
</p>
|
||||
</div>
|
||||
<div id="is-typing">
|
||||
<div class="circle"></div>
|
||||
<div class="circle"></div>
|
||||
<div class="circle"></div>
|
||||
</div>
|
||||
<div id="input-container">
|
||||
<div id="mic-container">
|
||||
<button id="mic-button"></button>
|
||||
<div id="sonar"></div>
|
||||
</div>
|
||||
<label for="utterance"></label>
|
||||
<textarea id="utterance" autocomplete="off" autofocus></textarea>
|
||||
<ul id="tip">
|
||||
<li><kbd>enter</kbd> to submit.</li>
|
||||
<li><kbd>shift</kbd> + <kbd>enter</kbd> for new line.</li>
|
||||
<li>
|
||||
<kbd>shift</kbd> + <kbd>↑</kbd> / <kbd>↓</kbd> to browse history.
|
||||
</li>
|
||||
<li><kbd>alt</kbd> / <kbd>cmd</kbd> + <kbd>c</kbd> to listen.</li>
|
||||
<li><kbd>/</kbd> for commands.</li>
|
||||
<li><kbd>@</kbd> for files.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</main>
|
||||
<footer id="footer">
|
||||
<div id="logo-container">
|
||||
<div id="logo"></div>
|
||||
<div>
|
||||
<small>Leon AI</small>
|
||||
<div id="version">
|
||||
<small>v</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="discord-container">
|
||||
<div>
|
||||
<small>
|
||||
<a href="https://discord.gg/MNQqqKg" target="_blank"
|
||||
>Join us on Discord</a
|
||||
>
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
<script type="module" src="/js/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,7 @@
|
||||
import axios from 'axios'
|
||||
|
||||
export async function requestBuiltInCommand(serverUrl, payload) {
|
||||
const response = await axios.post(`${serverUrl}/api/v1/command`, payload)
|
||||
|
||||
return response.data
|
||||
}
|
||||
@@ -0,0 +1,750 @@
|
||||
import { createRef } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
|
||||
import { requestBuiltInCommand } from './api'
|
||||
import { BuiltInCommandsModal } from './modal'
|
||||
import FileSystemAutocomplete from '../file-system-autocomplete'
|
||||
|
||||
const AUTOCOMPLETE_DELAY_MS = 90
|
||||
const CLOSE_ANIMATION_DURATION_MS = 180
|
||||
const ROOT_COMMAND_INPUT = '/'
|
||||
const EDITABLE_ACTION_KEYS = new Set(['Backspace', 'Delete'])
|
||||
const NAVIGATION_DIRECTIONS = {
|
||||
next: 1,
|
||||
previous: -1
|
||||
}
|
||||
|
||||
function isEditableElement(element) {
|
||||
if (!element) {
|
||||
return false
|
||||
}
|
||||
|
||||
const tagName = element.tagName?.toLowerCase()
|
||||
|
||||
return (
|
||||
tagName === 'input' ||
|
||||
tagName === 'textarea' ||
|
||||
element.isContentEditable === true
|
||||
)
|
||||
}
|
||||
|
||||
export default class BuiltInCommands {
|
||||
constructor({
|
||||
serverUrl,
|
||||
input,
|
||||
onSubmitToChat,
|
||||
getActiveSessionId,
|
||||
onCommandExecuted
|
||||
}) {
|
||||
this.serverUrl = serverUrl
|
||||
this.input = input
|
||||
this.onSubmitToChat = onSubmitToChat
|
||||
this.getActiveSessionId = getActiveSessionId
|
||||
this.onCommandExecuted = onCommandExecuted
|
||||
this.sessionId = null
|
||||
this.origin = 'shortcut'
|
||||
this.commandValue = ''
|
||||
this.selectedSuggestionIndex = -1
|
||||
this.suggestions = []
|
||||
this.recentSuggestions = []
|
||||
this.result = null
|
||||
this.loadingMessage = null
|
||||
this.pendingInput = null
|
||||
this.isOpen = false
|
||||
this.isClosing = false
|
||||
this.isLoading = false
|
||||
this.hasSubmitted = false
|
||||
this.autocompleteTimeout = null
|
||||
this.closeTimeout = null
|
||||
this.shouldFocusInput = false
|
||||
this.modalInputRef = createRef()
|
||||
this.fileSystemAutocomplete = new FileSystemAutocomplete({
|
||||
serverUrl,
|
||||
onValueChange: (value) => {
|
||||
this.handleCommandChange(value)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
init() {
|
||||
this.mount()
|
||||
this.attachEvents()
|
||||
this.render()
|
||||
}
|
||||
|
||||
mount() {
|
||||
this.container = document.createElement('div')
|
||||
document.body.appendChild(this.container)
|
||||
this.root = createRoot(this.container)
|
||||
}
|
||||
|
||||
attachEvents() {
|
||||
document.addEventListener('keydown', (event) => {
|
||||
this.handleDocumentKeyDown(event)
|
||||
})
|
||||
|
||||
this.input.addEventListener('input', () => {
|
||||
this.handleMainInput()
|
||||
})
|
||||
|
||||
this.input.addEventListener('focus', () => {
|
||||
this.handleMainInput()
|
||||
})
|
||||
}
|
||||
|
||||
render() {
|
||||
const focusedInputElement = this.modalInputRef.current
|
||||
const shouldRestoreCommandInputFocus =
|
||||
focusedInputElement && document.activeElement === focusedInputElement
|
||||
const selectionStart = shouldRestoreCommandInputFocus
|
||||
? focusedInputElement.selectionStart
|
||||
: null
|
||||
const selectionEnd = shouldRestoreCommandInputFocus
|
||||
? focusedInputElement.selectionEnd
|
||||
: null
|
||||
const { recentSelectedSuggestionIndex, suggestionSelectedSuggestionIndex } =
|
||||
this.getSelectedSuggestionIndices()
|
||||
|
||||
this.root.render(
|
||||
<BuiltInCommandsModal
|
||||
isOpen={this.isOpen}
|
||||
isVisible={this.isOpen || this.isClosing}
|
||||
isLoading={this.isLoading}
|
||||
loadingMessage={this.loadingMessage}
|
||||
commandValue={this.commandValue}
|
||||
suggestions={this.suggestions}
|
||||
recentSuggestions={this.recentSuggestions}
|
||||
recentSelectedSuggestionIndex={recentSelectedSuggestionIndex}
|
||||
suggestionSelectedSuggestionIndex={suggestionSelectedSuggestionIndex}
|
||||
result={this.result}
|
||||
pendingInput={this.pendingInput}
|
||||
hasSubmitted={this.hasSubmitted}
|
||||
inputRef={this.modalInputRef}
|
||||
onCommandChange={(value) => {
|
||||
this.handleCommandChange(value)
|
||||
}}
|
||||
onMaskClick={() => {
|
||||
this.close()
|
||||
}}
|
||||
onSuggestionSelect={(suggestion) => {
|
||||
this.handleSuggestionSelect(suggestion)
|
||||
}}
|
||||
onReturn={() => {
|
||||
this.returnToSuggestions()
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
if (this.shouldFocusInput) {
|
||||
this.shouldFocusInput = false
|
||||
|
||||
window.requestAnimationFrame(() => {
|
||||
this.focusCommandInput()
|
||||
})
|
||||
} else if (shouldRestoreCommandInputFocus) {
|
||||
window.requestAnimationFrame(() => {
|
||||
this.restoreCommandInputFocus(selectionStart, selectionEnd)
|
||||
})
|
||||
}
|
||||
|
||||
if (
|
||||
this.isOpen &&
|
||||
!this.hasSubmitted &&
|
||||
!this.result &&
|
||||
this.selectedSuggestionIndex >= 0
|
||||
) {
|
||||
window.requestAnimationFrame(() => {
|
||||
this.scrollSelectedSuggestionIntoView()
|
||||
})
|
||||
}
|
||||
|
||||
window.requestAnimationFrame(() => {
|
||||
this.attachFileSystemAutocomplete()
|
||||
})
|
||||
}
|
||||
|
||||
attachFileSystemAutocomplete() {
|
||||
const inputElement = this.modalInputRef.current
|
||||
|
||||
if (!inputElement) {
|
||||
return
|
||||
}
|
||||
|
||||
this.fileSystemAutocomplete.attach(inputElement)
|
||||
}
|
||||
|
||||
handleDocumentKeyDown(event) {
|
||||
if (this.isOpen) {
|
||||
if (event.metaKey || event.ctrlKey || event.altKey) {
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
this.close()
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault()
|
||||
this.blurCommandInput()
|
||||
this.moveSelection(NAVIGATION_DIRECTIONS.next)
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowUp') {
|
||||
event.preventDefault()
|
||||
this.blurCommandInput()
|
||||
this.moveSelection(NAVIGATION_DIRECTIONS.previous)
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'Tab') {
|
||||
const suggestion =
|
||||
this.selectedSuggestionIndex >= 0
|
||||
? this.getVisibleSuggestions()[this.selectedSuggestionIndex]
|
||||
: null
|
||||
|
||||
if (suggestion) {
|
||||
event.preventDefault()
|
||||
this.applySuggestion(suggestion, {
|
||||
appendTrailingSpace: true
|
||||
})
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault()
|
||||
|
||||
if (this.selectedSuggestionIndex >= 0) {
|
||||
const suggestion =
|
||||
this.getVisibleSuggestions()[this.selectedSuggestionIndex]
|
||||
|
||||
if (suggestion) {
|
||||
this.applySuggestion(suggestion)
|
||||
void this.submit()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
this.submit()
|
||||
return
|
||||
}
|
||||
|
||||
if (this.shouldHandleDetachedInputKey(event)) {
|
||||
event.preventDefault()
|
||||
this.handleDetachedInputKey(event)
|
||||
return
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
event.key !== '/' ||
|
||||
event.metaKey ||
|
||||
event.ctrlKey ||
|
||||
event.altKey ||
|
||||
event.shiftKey
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const activeElement = document.activeElement
|
||||
|
||||
if (
|
||||
activeElement === this.input ||
|
||||
(activeElement && isEditableElement(activeElement))
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
this.open({
|
||||
origin: 'shortcut',
|
||||
rawInput: '/'
|
||||
})
|
||||
}
|
||||
|
||||
handleMainInput() {
|
||||
if (document.activeElement !== this.input) {
|
||||
return
|
||||
}
|
||||
|
||||
const rawValue = this.input.value || ''
|
||||
|
||||
if (!rawValue.startsWith('/')) {
|
||||
return
|
||||
}
|
||||
|
||||
this.open({
|
||||
origin: 'input',
|
||||
rawInput: rawValue
|
||||
})
|
||||
}
|
||||
|
||||
handleCommandChange(value) {
|
||||
this.commandValue = value
|
||||
this.hasSubmitted = false
|
||||
if (!this.pendingInput) {
|
||||
this.result = null
|
||||
this.loadingMessage = null
|
||||
this.queueAutocomplete()
|
||||
}
|
||||
this.render()
|
||||
}
|
||||
|
||||
open({ origin, rawInput }) {
|
||||
window.clearTimeout(this.closeTimeout)
|
||||
|
||||
if (this.isOpen) {
|
||||
this.origin = origin
|
||||
this.commandValue = this.normalizeCommandValue(rawInput)
|
||||
this.result = null
|
||||
this.loadingMessage = null
|
||||
this.pendingInput = null
|
||||
this.hasSubmitted = false
|
||||
this.shouldFocusInput = true
|
||||
this.input.value = ''
|
||||
this.queueAutocomplete()
|
||||
this.render()
|
||||
return
|
||||
}
|
||||
|
||||
this.origin = origin
|
||||
this.sessionId = null
|
||||
this.commandValue = this.normalizeCommandValue(rawInput)
|
||||
this.suggestions = []
|
||||
this.result = null
|
||||
this.loadingMessage = null
|
||||
this.pendingInput = null
|
||||
this.selectedSuggestionIndex = -1
|
||||
this.hasSubmitted = false
|
||||
this.isOpen = true
|
||||
this.isClosing = false
|
||||
this.shouldFocusInput = true
|
||||
this.input.value = ''
|
||||
|
||||
if (this.origin === 'input') {
|
||||
this.input.blur()
|
||||
}
|
||||
|
||||
this.queueAutocomplete()
|
||||
this.render()
|
||||
}
|
||||
|
||||
close() {
|
||||
if (!this.isOpen || this.isClosing) {
|
||||
return
|
||||
}
|
||||
|
||||
this.isClosing = true
|
||||
this.isOpen = false
|
||||
this.fileSystemAutocomplete.close()
|
||||
this.render()
|
||||
|
||||
this.closeTimeout = window.setTimeout(() => {
|
||||
this.resetState()
|
||||
this.render()
|
||||
this.focusMainInput()
|
||||
}, CLOSE_ANIMATION_DURATION_MS)
|
||||
}
|
||||
|
||||
queueAutocomplete() {
|
||||
window.clearTimeout(this.autocompleteTimeout)
|
||||
this.isLoading = true
|
||||
|
||||
this.autocompleteTimeout = window.setTimeout(() => {
|
||||
void this.fetchAutocomplete()
|
||||
}, AUTOCOMPLETE_DELAY_MS)
|
||||
}
|
||||
|
||||
async fetchAutocomplete() {
|
||||
try {
|
||||
const data = await requestBuiltInCommand(this.serverUrl, {
|
||||
mode: 'autocomplete',
|
||||
input: this.buildCommandInput(),
|
||||
session_id: this.sessionId,
|
||||
conversation_session_id: this.getActiveSessionId?.()
|
||||
})
|
||||
|
||||
if (!this.isOpen || this.hasSubmitted) {
|
||||
return
|
||||
}
|
||||
|
||||
this.sessionId = data.session.id
|
||||
this.suggestions = data.suggestions || []
|
||||
this.recentSuggestions = data.recent_suggestions || []
|
||||
this.loadingMessage = data.session.loading_message || null
|
||||
this.pendingInput = data.session.pending_input || null
|
||||
this.selectedSuggestionIndex =
|
||||
this.getVisibleSuggestions().length > 0 ? 0 : -1
|
||||
this.isLoading = false
|
||||
this.render()
|
||||
} catch (error) {
|
||||
if (!this.isOpen || this.hasSubmitted) {
|
||||
return
|
||||
}
|
||||
|
||||
this.isLoading = false
|
||||
this.result = {
|
||||
title: 'Command Error',
|
||||
tone: 'error',
|
||||
blocks: [
|
||||
{
|
||||
type: 'list',
|
||||
items: [
|
||||
{
|
||||
label: error.message || 'Failed to load built-in commands.',
|
||||
tone: 'error'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
this.loadingMessage = null
|
||||
this.render()
|
||||
}
|
||||
}
|
||||
|
||||
async submit() {
|
||||
const commandInput = this.buildCommandInput()
|
||||
|
||||
if (commandInput === ROOT_COMMAND_INPUT) {
|
||||
return
|
||||
}
|
||||
|
||||
window.clearTimeout(this.autocompleteTimeout)
|
||||
this.autocompleteTimeout = null
|
||||
|
||||
await this.resolveLoadingMessageForSubmission(commandInput)
|
||||
|
||||
this.isLoading = true
|
||||
this.hasSubmitted = true
|
||||
this.result = null
|
||||
this.commandValue = ''
|
||||
this.suggestions = []
|
||||
this.selectedSuggestionIndex = -1
|
||||
this.render()
|
||||
|
||||
try {
|
||||
const data = await requestBuiltInCommand(this.serverUrl, {
|
||||
mode: 'execute',
|
||||
input: commandInput,
|
||||
session_id: this.sessionId,
|
||||
conversation_session_id: this.getActiveSessionId?.()
|
||||
})
|
||||
|
||||
if (data.client_action?.type === 'submit_to_chat') {
|
||||
const wasSubmitted = this.onSubmitToChat?.(data.client_action)
|
||||
|
||||
if (!wasSubmitted) {
|
||||
throw new Error('Failed to submit the built-in command to chat.')
|
||||
}
|
||||
|
||||
this.isLoading = false
|
||||
this.close()
|
||||
return
|
||||
}
|
||||
|
||||
this.sessionId = data.session.id
|
||||
this.isLoading = false
|
||||
this.result = data.result
|
||||
this.onCommandExecuted?.(commandInput, data)
|
||||
this.loadingMessage = data.session.loading_message || null
|
||||
this.recentSuggestions = data.recent_suggestions || []
|
||||
this.pendingInput = data.session.pending_input || null
|
||||
this.commandValue = ''
|
||||
this.render()
|
||||
} catch (error) {
|
||||
this.isLoading = false
|
||||
this.result = {
|
||||
title: 'Command Error',
|
||||
tone: 'error',
|
||||
blocks: [
|
||||
{
|
||||
type: 'list',
|
||||
items: [
|
||||
{
|
||||
label:
|
||||
error.message || 'Failed to execute the built-in command.',
|
||||
tone: 'error'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
this.loadingMessage = null
|
||||
this.render()
|
||||
}
|
||||
}
|
||||
|
||||
async resolveLoadingMessageForSubmission(commandInput) {
|
||||
try {
|
||||
const data = await requestBuiltInCommand(this.serverUrl, {
|
||||
mode: 'autocomplete',
|
||||
input: commandInput,
|
||||
session_id: this.sessionId,
|
||||
conversation_session_id: this.getActiveSessionId?.()
|
||||
})
|
||||
|
||||
this.sessionId = data.session.id
|
||||
this.loadingMessage = data.session.loading_message || null
|
||||
} catch {
|
||||
this.loadingMessage = null
|
||||
}
|
||||
}
|
||||
|
||||
moveSelection(direction) {
|
||||
if (this.hasSubmitted || this.result) {
|
||||
return
|
||||
}
|
||||
|
||||
const visibleSuggestions = this.getVisibleSuggestions()
|
||||
|
||||
if (visibleSuggestions.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
if (this.selectedSuggestionIndex === -1) {
|
||||
this.selectedSuggestionIndex = 0
|
||||
} else {
|
||||
this.selectedSuggestionIndex =
|
||||
(this.selectedSuggestionIndex +
|
||||
direction +
|
||||
visibleSuggestions.length) %
|
||||
visibleSuggestions.length
|
||||
}
|
||||
|
||||
this.render()
|
||||
}
|
||||
|
||||
handleSuggestionSelect(suggestion) {
|
||||
const normalizedSuggestionValue = this.normalizeCommandValue(suggestion.value)
|
||||
|
||||
if (normalizedSuggestionValue.trim() === this.commandValue.trim()) {
|
||||
void this.submit()
|
||||
return
|
||||
}
|
||||
|
||||
this.applySuggestion(suggestion, {
|
||||
appendTrailingSpace: true
|
||||
})
|
||||
}
|
||||
|
||||
applySuggestion(suggestion, options = {}) {
|
||||
this.commandValue = `${this.normalizeCommandValue(suggestion.value)}${
|
||||
options.appendTrailingSpace ? ' ' : ''
|
||||
}`
|
||||
this.loadingMessage = null
|
||||
this.queueAutocomplete()
|
||||
this.shouldFocusInput = true
|
||||
this.render()
|
||||
}
|
||||
|
||||
returnToSuggestions() {
|
||||
this.sessionId = null
|
||||
this.hasSubmitted = false
|
||||
this.result = null
|
||||
this.loadingMessage = null
|
||||
this.pendingInput = null
|
||||
this.commandValue = ''
|
||||
this.suggestions = []
|
||||
this.selectedSuggestionIndex = -1
|
||||
this.shouldFocusInput = true
|
||||
this.queueAutocomplete()
|
||||
this.render()
|
||||
}
|
||||
|
||||
focusCommandInput() {
|
||||
const inputElement = this.modalInputRef.current
|
||||
|
||||
if (!inputElement) {
|
||||
return
|
||||
}
|
||||
|
||||
inputElement.focus()
|
||||
|
||||
if (typeof inputElement.setSelectionRange === 'function') {
|
||||
inputElement.setSelectionRange(
|
||||
inputElement.value.length,
|
||||
inputElement.value.length
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
restoreCommandInputFocus(selectionStart, selectionEnd) {
|
||||
const inputElement = this.modalInputRef.current
|
||||
|
||||
if (!inputElement) {
|
||||
return
|
||||
}
|
||||
|
||||
if (document.activeElement !== inputElement) {
|
||||
try {
|
||||
inputElement.focus({ preventScroll: true })
|
||||
} catch {
|
||||
inputElement.focus()
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
typeof inputElement.setSelectionRange === 'function' &&
|
||||
selectionStart !== null &&
|
||||
selectionEnd !== null
|
||||
) {
|
||||
inputElement.setSelectionRange(selectionStart, selectionEnd)
|
||||
}
|
||||
}
|
||||
|
||||
blurCommandInput() {
|
||||
const inputElement = this.modalInputRef.current
|
||||
|
||||
if (!inputElement || document.activeElement !== inputElement) {
|
||||
return
|
||||
}
|
||||
|
||||
inputElement.blur()
|
||||
}
|
||||
|
||||
focusMainInput() {
|
||||
if (!this.input) {
|
||||
return
|
||||
}
|
||||
|
||||
window.requestAnimationFrame(() => {
|
||||
try {
|
||||
this.input.focus({ preventScroll: true })
|
||||
} catch {
|
||||
this.input.focus()
|
||||
}
|
||||
|
||||
if (typeof this.input.setSelectionRange === 'function') {
|
||||
this.input.setSelectionRange(
|
||||
this.input.value.length,
|
||||
this.input.value.length
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
scrollSelectedSuggestionIntoView() {
|
||||
const selectedSuggestionElement = this.container?.querySelector(
|
||||
'.aurora-list-item--selected'
|
||||
)
|
||||
|
||||
if (!selectedSuggestionElement) {
|
||||
return
|
||||
}
|
||||
|
||||
selectedSuggestionElement.scrollIntoView({
|
||||
block: 'nearest'
|
||||
})
|
||||
}
|
||||
|
||||
shouldHandleDetachedInputKey(event) {
|
||||
const inputElement = this.modalInputRef.current
|
||||
|
||||
if (!inputElement || document.activeElement === inputElement) {
|
||||
return false
|
||||
}
|
||||
|
||||
return (
|
||||
event.key.length === 1 ||
|
||||
EDITABLE_ACTION_KEYS.has(event.key)
|
||||
)
|
||||
}
|
||||
|
||||
handleDetachedInputKey(event) {
|
||||
if (event.key.length === 1) {
|
||||
this.commandValue += event.key
|
||||
} else if (event.key === 'Backspace') {
|
||||
this.commandValue = this.commandValue.slice(0, -1)
|
||||
} else if (event.key === 'Delete') {
|
||||
this.commandValue = ''
|
||||
}
|
||||
|
||||
this.hasSubmitted = false
|
||||
this.result = null
|
||||
this.loadingMessage = null
|
||||
this.queueAutocomplete()
|
||||
this.shouldFocusInput = true
|
||||
this.render()
|
||||
}
|
||||
|
||||
normalizeCommandValue(rawInput) {
|
||||
const normalizedValue = String(rawInput || '').trim()
|
||||
|
||||
if (!normalizedValue.startsWith('/')) {
|
||||
return normalizedValue
|
||||
}
|
||||
|
||||
return normalizedValue.slice(1)
|
||||
}
|
||||
|
||||
buildCommandInput() {
|
||||
if (this.pendingInput) {
|
||||
return this.commandValue
|
||||
}
|
||||
|
||||
return `/${this.commandValue}`.trim()
|
||||
}
|
||||
|
||||
getVisibleSuggestions() {
|
||||
if (this.commandValue.trim() === '') {
|
||||
return [...this.recentSuggestions, ...this.suggestions]
|
||||
}
|
||||
|
||||
return this.suggestions
|
||||
}
|
||||
|
||||
getSelectedSuggestionIndices() {
|
||||
if (this.commandValue.trim() !== '') {
|
||||
return {
|
||||
recentSelectedSuggestionIndex: -1,
|
||||
suggestionSelectedSuggestionIndex: this.selectedSuggestionIndex
|
||||
}
|
||||
}
|
||||
|
||||
const recentSuggestionsLength = this.recentSuggestions.length
|
||||
|
||||
if (this.selectedSuggestionIndex < 0) {
|
||||
return {
|
||||
recentSelectedSuggestionIndex: -1,
|
||||
suggestionSelectedSuggestionIndex: -1
|
||||
}
|
||||
}
|
||||
|
||||
if (this.selectedSuggestionIndex < recentSuggestionsLength) {
|
||||
return {
|
||||
recentSelectedSuggestionIndex: this.selectedSuggestionIndex,
|
||||
suggestionSelectedSuggestionIndex: -1
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
recentSelectedSuggestionIndex: -1,
|
||||
suggestionSelectedSuggestionIndex:
|
||||
this.selectedSuggestionIndex - recentSuggestionsLength
|
||||
}
|
||||
}
|
||||
|
||||
resetState() {
|
||||
window.clearTimeout(this.autocompleteTimeout)
|
||||
this.isClosing = false
|
||||
this.isLoading = false
|
||||
this.sessionId = null
|
||||
this.origin = 'shortcut'
|
||||
this.commandValue = ''
|
||||
this.suggestions = []
|
||||
this.result = null
|
||||
this.loadingMessage = null
|
||||
this.pendingInput = null
|
||||
this.selectedSuggestionIndex = -1
|
||||
this.hasSubmitted = false
|
||||
this.shouldFocusInput = false
|
||||
this.autocompleteTimeout = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import '../../css/built-in-commands.scss'
|
||||
|
||||
export { default as BuiltInCommands } from './built-in-commands.jsx'
|
||||
@@ -0,0 +1,263 @@
|
||||
import {
|
||||
Button,
|
||||
Flexbox,
|
||||
Icon,
|
||||
Input,
|
||||
List,
|
||||
ListHeader,
|
||||
ListItem,
|
||||
Loader,
|
||||
ScrollContainer,
|
||||
Text
|
||||
} from '@aurora'
|
||||
|
||||
import { BuiltInCommandResultRenderer } from './result-renderer'
|
||||
|
||||
const SLASH_COMMAND_ICON_SVG = (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M9.78839 21H7.66003L14.2115 3H16.3398L9.78839 21Z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
function parseRemixIcon(rawIconName, rawIconType) {
|
||||
const fallbackIcon = {
|
||||
iconName: 'terminal-box',
|
||||
type: 'line'
|
||||
}
|
||||
const normalizedIconName = String(rawIconName || '').trim()
|
||||
const normalizedIconType = ['line', 'fill', 'notype'].includes(rawIconType)
|
||||
? rawIconType
|
||||
: null
|
||||
|
||||
if (!normalizedIconName) {
|
||||
return fallbackIcon
|
||||
}
|
||||
|
||||
const iconWithoutPrefix = normalizedIconName.replace(/^ri-/, '')
|
||||
|
||||
if (iconWithoutPrefix.endsWith('-line')) {
|
||||
return {
|
||||
iconName: iconWithoutPrefix.slice(0, -5),
|
||||
type: 'line'
|
||||
}
|
||||
}
|
||||
|
||||
if (iconWithoutPrefix.endsWith('-fill')) {
|
||||
return {
|
||||
iconName: iconWithoutPrefix.slice(0, -5),
|
||||
type: 'fill'
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
iconName: iconWithoutPrefix,
|
||||
type: normalizedIconType || 'notype'
|
||||
}
|
||||
}
|
||||
|
||||
function SuggestionList({
|
||||
headerTitle,
|
||||
suggestions,
|
||||
selectedSuggestionIndex,
|
||||
onSuggestionSelect
|
||||
}) {
|
||||
return (
|
||||
<List>
|
||||
<ListHeader>{headerTitle}</ListHeader>
|
||||
{suggestions.map((suggestion, index) => {
|
||||
const parsedIcon = parseRemixIcon(
|
||||
suggestion.icon_name,
|
||||
suggestion.icon_type
|
||||
)
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
key={`suggestion-${suggestion.value}-${index}`}
|
||||
name="built-in-command-suggestion"
|
||||
value={suggestion.value}
|
||||
selected={index === selectedSuggestionIndex}
|
||||
onClick={() => {
|
||||
onSuggestionSelect(suggestion)
|
||||
}}
|
||||
>
|
||||
<Flexbox flexDirection="row" alignItems="center" gap="md">
|
||||
<Icon
|
||||
iconName={parsedIcon.iconName}
|
||||
type={parsedIcon.type}
|
||||
size="md"
|
||||
bgShape="square"
|
||||
color="blue"
|
||||
bgColor="transparent-blue"
|
||||
/>
|
||||
<div className="built-in-commands-modal__suggestion-copy">
|
||||
<Text fontWeight="semi-bold">{suggestion.usage}</Text>
|
||||
<Text secondary>{suggestion.description}</Text>
|
||||
</div>
|
||||
</Flexbox>
|
||||
</ListItem>
|
||||
)
|
||||
})}
|
||||
</List>
|
||||
)
|
||||
}
|
||||
|
||||
function LoadingState({ loadingMessage }) {
|
||||
return (
|
||||
<List>
|
||||
<ListHeader>Working</ListHeader>
|
||||
<ListItem>
|
||||
<Flexbox flexDirection="row" alignItems="center" gap="sm">
|
||||
<Loader size="sm" />
|
||||
<Text>{loadingMessage || 'Working on it...'}</Text>
|
||||
</Flexbox>
|
||||
</ListItem>
|
||||
</List>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyState() {
|
||||
return (
|
||||
<List>
|
||||
<ListHeader>Suggestions</ListHeader>
|
||||
<ListItem>
|
||||
<Text secondary>No matching built-in command.</Text>
|
||||
</ListItem>
|
||||
</List>
|
||||
)
|
||||
}
|
||||
|
||||
export function BuiltInCommandsModal({
|
||||
isOpen,
|
||||
isVisible,
|
||||
isLoading,
|
||||
loadingMessage,
|
||||
commandValue,
|
||||
suggestions,
|
||||
recentSuggestions,
|
||||
recentSelectedSuggestionIndex,
|
||||
suggestionSelectedSuggestionIndex,
|
||||
result,
|
||||
pendingInput,
|
||||
hasSubmitted,
|
||||
inputRef,
|
||||
onCommandChange,
|
||||
onMaskClick,
|
||||
onSuggestionSelect,
|
||||
onReturn
|
||||
}) {
|
||||
const isCommandInputEmpty = commandValue.trim() === ''
|
||||
const pendingInputIcon = pendingInput?.icon_name
|
||||
? parseRemixIcon(pendingInput.icon_name, pendingInput.icon_type)
|
||||
: null
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`built-in-commands-modal ${
|
||||
isOpen
|
||||
? 'built-in-commands-modal--open'
|
||||
: isVisible
|
||||
? ''
|
||||
: 'built-in-commands-modal--hidden'
|
||||
}`}
|
||||
aria-hidden={!isVisible}
|
||||
>
|
||||
<div className="built-in-commands-modal__mask" onClick={onMaskClick} />
|
||||
<div
|
||||
className="built-in-commands-modal__panel"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Built-in commands"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
}}
|
||||
>
|
||||
<div className="built-in-commands-modal__section built-in-commands-modal__section--top">
|
||||
<Flexbox flexDirection="row" alignItems="center" gap="md">
|
||||
{hasSubmitted && result ? (
|
||||
<div className="built-in-commands-modal__return-button">
|
||||
<Button
|
||||
light
|
||||
iconName="arrow-left-double"
|
||||
iconSize="lg"
|
||||
onClick={() => {
|
||||
onReturn()
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="built-in-commands-modal__input-field">
|
||||
<Input
|
||||
name="built-in-commands"
|
||||
type={pendingInput?.type || 'text'}
|
||||
placeholder={
|
||||
pendingInput?.placeholder || 'Type a built-in command'
|
||||
}
|
||||
maxLength={2_048}
|
||||
iconName={pendingInputIcon?.iconName}
|
||||
iconType={pendingInputIcon ? pendingInputIcon.type : undefined}
|
||||
iconSVG={pendingInput ? undefined : SLASH_COMMAND_ICON_SVG}
|
||||
iconSize="lg"
|
||||
value={commandValue}
|
||||
autofocus={isOpen}
|
||||
inputRef={inputRef}
|
||||
onChange={onCommandChange}
|
||||
/>
|
||||
</div>
|
||||
</Flexbox>
|
||||
</div>
|
||||
<div className="built-in-commands-modal__section built-in-commands-modal__section--middle">
|
||||
<div className="built-in-commands-modal__scroll-area">
|
||||
<ScrollContainer orientation="vertical" height="100%">
|
||||
{isLoading ? (
|
||||
<LoadingState loadingMessage={loadingMessage} />
|
||||
) : result ? (
|
||||
<BuiltInCommandResultRenderer result={result} />
|
||||
) : suggestions.length > 0 ? (
|
||||
<Flexbox gap="md">
|
||||
{isCommandInputEmpty && recentSuggestions.length > 0 ? (
|
||||
<SuggestionList
|
||||
headerTitle="Recently Used Commands"
|
||||
suggestions={recentSuggestions}
|
||||
selectedSuggestionIndex={recentSelectedSuggestionIndex}
|
||||
onSuggestionSelect={onSuggestionSelect}
|
||||
/>
|
||||
) : null}
|
||||
<SuggestionList
|
||||
headerTitle={
|
||||
isCommandInputEmpty ? 'All Commands' : 'Suggested Commands'
|
||||
}
|
||||
suggestions={suggestions}
|
||||
selectedSuggestionIndex={suggestionSelectedSuggestionIndex}
|
||||
onSuggestionSelect={onSuggestionSelect}
|
||||
/>
|
||||
</Flexbox>
|
||||
) : (
|
||||
<EmptyState />
|
||||
)}
|
||||
</ScrollContainer>
|
||||
</div>
|
||||
</div>
|
||||
<div className="built-in-commands-modal__section built-in-commands-modal__section--bottom">
|
||||
<Flexbox flexDirection="row" gap="md">
|
||||
<Text fontSize="xs" secondary>
|
||||
<kbd>↑</kbd> <kbd>↓</kbd> navigate
|
||||
</Text>
|
||||
<Text fontSize="xs" secondary>
|
||||
<kbd>tab</kbd> complete
|
||||
</Text>
|
||||
<Text fontSize="xs" secondary>
|
||||
<kbd>enter</kbd> submit
|
||||
</Text>
|
||||
<Text fontSize="xs" secondary>
|
||||
<kbd>esc</kbd> close
|
||||
</Text>
|
||||
</Flexbox>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import {
|
||||
Flexbox,
|
||||
Icon,
|
||||
Link,
|
||||
List,
|
||||
ListHeader,
|
||||
ListItem,
|
||||
Text
|
||||
} from '@aurora'
|
||||
|
||||
function InlineLabel({ item }) {
|
||||
if (item.inline_link_label && item.inline_link_href) {
|
||||
return (
|
||||
<Text>
|
||||
{item.label}{' '}
|
||||
<Link href={item.inline_link_href} fontSize="md">
|
||||
{item.inline_link_label}
|
||||
</Link>.
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
return <Text>{item.label}</Text>
|
||||
}
|
||||
|
||||
function ToneListItem({ item }) {
|
||||
if (item.tone === 'success') {
|
||||
return (
|
||||
<ListItem>
|
||||
<Flexbox flexDirection="row" alignItems="center" gap="sm">
|
||||
<Icon
|
||||
iconName="check"
|
||||
size="sm"
|
||||
type="fill"
|
||||
bgShape="circle"
|
||||
color="green"
|
||||
bgColor="transparent-green"
|
||||
/>
|
||||
<InlineLabel item={item} />
|
||||
</Flexbox>
|
||||
</ListItem>
|
||||
)
|
||||
}
|
||||
|
||||
if (item.tone === 'warning') {
|
||||
return (
|
||||
<ListItem>
|
||||
<Flexbox flexDirection="row" alignItems="center" gap="sm">
|
||||
<Icon
|
||||
iconName="alert"
|
||||
size="sm"
|
||||
type="fill"
|
||||
bgShape="circle"
|
||||
color="yellow"
|
||||
bgColor="transparent-yellow"
|
||||
/>
|
||||
<InlineLabel item={item} />
|
||||
</Flexbox>
|
||||
</ListItem>
|
||||
)
|
||||
}
|
||||
|
||||
if (item.tone === 'error') {
|
||||
return (
|
||||
<ListItem>
|
||||
<Flexbox flexDirection="row" alignItems="center" gap="sm">
|
||||
<Icon
|
||||
iconName="close"
|
||||
size="sm"
|
||||
type="fill"
|
||||
bgShape="circle"
|
||||
color="red"
|
||||
bgColor="transparent-red"
|
||||
/>
|
||||
<InlineLabel item={item} />
|
||||
</Flexbox>
|
||||
</ListItem>
|
||||
)
|
||||
}
|
||||
|
||||
if (item.value || item.description) {
|
||||
return (
|
||||
<ListItem>
|
||||
<div className="built-in-commands-modal__result-item">
|
||||
<div className="built-in-commands-modal__result-copy">
|
||||
<Text fontWeight="semi-bold">{item.label}</Text>
|
||||
{item.description ? <Text secondary>{item.description}</Text> : null}
|
||||
</div>
|
||||
{item.value ? (
|
||||
<div className="built-in-commands-modal__result-value">
|
||||
{item.href ? (
|
||||
<Link href={item.href} fontSize="md">
|
||||
{item.value}
|
||||
</Link>
|
||||
) : (
|
||||
<Text secondary textAlign="right">
|
||||
{item.value}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</ListItem>
|
||||
)
|
||||
}
|
||||
|
||||
if (item.inline_link_label && item.inline_link_href) {
|
||||
return (
|
||||
<ListItem>
|
||||
<InlineLabel item={item} />
|
||||
</ListItem>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ListItem>
|
||||
<InlineLabel item={item} />
|
||||
</ListItem>
|
||||
)
|
||||
}
|
||||
|
||||
export function BuiltInCommandResultRenderer({ result }) {
|
||||
const blocks = Array.isArray(result?.blocks) ? result.blocks : []
|
||||
|
||||
return (
|
||||
<Flexbox flexDirection="column" gap="sm">
|
||||
{blocks.map((block, index) => {
|
||||
if (block.type !== 'list') {
|
||||
return null
|
||||
}
|
||||
|
||||
const header = block.header || (index === 0 ? result.title : '')
|
||||
|
||||
return (
|
||||
<List key={`result-block-${index}`}>
|
||||
{header ? <ListHeader>{header}</ListHeader> : null}
|
||||
{block.items.map((item, itemIndex) => (
|
||||
<ToneListItem item={item} key={`result-item-${itemIndex}`} />
|
||||
))}
|
||||
</List>
|
||||
)
|
||||
})}
|
||||
</Flexbox>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,957 @@
|
||||
import { createElement } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import axios from 'axios'
|
||||
|
||||
import { WidgetWrapper, Flexbox, Loader, Text } from '@aurora'
|
||||
|
||||
import renderAuroraComponent from './render-aurora-component'
|
||||
import ToolUIHandler from './tool-ui-handler'
|
||||
|
||||
const WIDGETS_TO_FETCH = []
|
||||
const WIDGETS_FETCH_CACHE = new Map()
|
||||
const REPLACED_MESSAGES = new Set()
|
||||
const AUTO_SCROLL_BOTTOM_THRESHOLD_PX = 24
|
||||
const MAXIMUM_BUBBLES_IN_MEMORY = 62
|
||||
const MAXIMUM_WIDGET_FETCH_CONCURRENCY = 4
|
||||
const SECONDS_PER_MINUTE = 60
|
||||
|
||||
function escapeHTML(value) {
|
||||
return String(value || '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
export default class Chatbot {
|
||||
constructor(socket, serverURL, sessionId = null) {
|
||||
this.socket = socket
|
||||
this.serverURL = serverURL
|
||||
this.sessionId = sessionId
|
||||
this.et = new EventTarget()
|
||||
this.feed = document.querySelector('#feed')
|
||||
this.typing = document.querySelector('#is-typing')
|
||||
this.noBubbleMessage = document.querySelector('#no-bubble')
|
||||
this.parsedBubbles = []
|
||||
this.reasoningBlocks = new Map()
|
||||
this.feedAutoScrollEnabled = true
|
||||
this.isProgrammaticFeedScroll = false
|
||||
this.widgetHydrationPromise = null
|
||||
|
||||
// Initialize tool UI handler
|
||||
this.toolUIHandler = new ToolUIHandler(
|
||||
this.feed,
|
||||
this.scrollDown.bind(this),
|
||||
this.formatMessage.bind(this)
|
||||
)
|
||||
}
|
||||
|
||||
async init() {
|
||||
await this.loadFeed()
|
||||
this.scrollDown()
|
||||
|
||||
this.et.addEventListener('to-leon', (event) => {
|
||||
this.createBubble({
|
||||
who: 'me',
|
||||
string: event.detail.string,
|
||||
sentAt: event.detail.sentAt
|
||||
})
|
||||
})
|
||||
|
||||
this.et.addEventListener('me-received', (event) => {
|
||||
this.createBubble({
|
||||
who: 'leon',
|
||||
string: event.detail
|
||||
})
|
||||
})
|
||||
|
||||
// Add event delegation for clickable paths
|
||||
this.feed.addEventListener('click', (event) => {
|
||||
if (event.target.classList.contains('clickable-path')) {
|
||||
const path = event.target.getAttribute('data-path')
|
||||
if (path) {
|
||||
this.openPath(path)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
this.feed.addEventListener(
|
||||
'scroll',
|
||||
() => {
|
||||
this.handleFeedScroll()
|
||||
},
|
||||
{ passive: true }
|
||||
)
|
||||
}
|
||||
|
||||
sendTo(who, string, sentAt = Date.now()) {
|
||||
if (who === 'leon') {
|
||||
this.et.dispatchEvent(
|
||||
new CustomEvent('to-leon', {
|
||||
detail: {
|
||||
string,
|
||||
sentAt
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
receivedFrom(who, string) {
|
||||
if (who === 'leon') {
|
||||
this.et.dispatchEvent(new CustomEvent('me-received', { detail: string }))
|
||||
}
|
||||
}
|
||||
|
||||
isTyping(who, value) {
|
||||
if (who === 'leon') {
|
||||
if (value) {
|
||||
this.enableTyping()
|
||||
} else if (value === false) {
|
||||
this.disableTyping()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enableTyping() {
|
||||
if (!this.typing.classList.contains('on')) {
|
||||
this.typing.classList.add('on')
|
||||
}
|
||||
}
|
||||
|
||||
disableTyping() {
|
||||
if (this.typing.classList.contains('on')) {
|
||||
this.typing.classList.remove('on')
|
||||
}
|
||||
}
|
||||
|
||||
isElementNearBottom(element) {
|
||||
if (!element) {
|
||||
return true
|
||||
}
|
||||
|
||||
const remainingScrollableDistance =
|
||||
element.scrollHeight - (element.scrollTop + element.clientHeight)
|
||||
|
||||
return remainingScrollableDistance <= AUTO_SCROLL_BOTTOM_THRESHOLD_PX
|
||||
}
|
||||
|
||||
handleFeedScroll() {
|
||||
if (!this.feed || this.isProgrammaticFeedScroll) {
|
||||
return
|
||||
}
|
||||
|
||||
this.feedAutoScrollEnabled = this.isElementNearBottom(this.feed)
|
||||
}
|
||||
|
||||
scrollDown(options = {}) {
|
||||
if (!this.feed) {
|
||||
return
|
||||
}
|
||||
|
||||
const { force = false } = options
|
||||
|
||||
if (!force && !this.feedAutoScrollEnabled) {
|
||||
return
|
||||
}
|
||||
|
||||
this.isProgrammaticFeedScroll = true
|
||||
this.feed.scrollTo(0, this.feed.scrollHeight)
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
this.isProgrammaticFeedScroll = false
|
||||
this.feedAutoScrollEnabled = this.isElementNearBottom(this.feed)
|
||||
})
|
||||
}
|
||||
|
||||
scrollReasoningContentToBottom(reasoningBlock) {
|
||||
if (!reasoningBlock?.content || !reasoningBlock.isAutoScrollEnabled) {
|
||||
return
|
||||
}
|
||||
|
||||
reasoningBlock.isProgrammaticScroll = true
|
||||
reasoningBlock.content.scrollTop = reasoningBlock.content.scrollHeight
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
reasoningBlock.isProgrammaticScroll = false
|
||||
reasoningBlock.isAutoScrollEnabled = this.isElementNearBottom(
|
||||
reasoningBlock.content
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
getWidgetPayload(formattedString) {
|
||||
if (
|
||||
typeof formattedString !== 'string' ||
|
||||
!formattedString.includes('"component":"WidgetWrapper"')
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(formattedString)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
getPlanWidgetInsertionPoint(widgetPayload) {
|
||||
if (!widgetPayload || widgetPayload.widget !== 'PlanWidget') {
|
||||
return null
|
||||
}
|
||||
|
||||
// Always append new plan widgets as new bubbles.
|
||||
// Widget updates are handled via replaceMessageId targeting the same
|
||||
// messageId, so insertion-point heuristics are unnecessary and can cause
|
||||
// visual reuse across turns.
|
||||
return null
|
||||
}
|
||||
|
||||
isPlanWidgetData(data) {
|
||||
return Boolean(data && typeof data === 'object' && data.widget === 'PlanWidget')
|
||||
}
|
||||
|
||||
isSystemWidgetData(data) {
|
||||
return Boolean(
|
||||
data &&
|
||||
typeof data === 'object' &&
|
||||
data.historyMode &&
|
||||
data.historyMode === 'system_widget'
|
||||
)
|
||||
}
|
||||
|
||||
getTimelineItemWeight(item) {
|
||||
if (item.who === 'owner') {
|
||||
return 0
|
||||
}
|
||||
|
||||
if (item.source === 'system_widget') {
|
||||
return 1
|
||||
}
|
||||
|
||||
return 2
|
||||
}
|
||||
|
||||
async hydrateFetchedWidgets() {
|
||||
if (this.widgetHydrationPromise) {
|
||||
return this.widgetHydrationPromise
|
||||
}
|
||||
|
||||
const widgetContainers = [...WIDGETS_TO_FETCH].reverse()
|
||||
WIDGETS_TO_FETCH.length = 0
|
||||
|
||||
if (widgetContainers.length === 0) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
const hydrateWidgetContainer = async (widgetContainer) => {
|
||||
const hasWidgetBeenFetched = WIDGETS_FETCH_CACHE.has(
|
||||
widgetContainer.widgetId
|
||||
)
|
||||
|
||||
if (hasWidgetBeenFetched) {
|
||||
const fetchedWidget = WIDGETS_FETCH_CACHE.get(widgetContainer.widgetId)
|
||||
widgetContainer.reactRootNode.render(fetchedWidget.reactNode)
|
||||
|
||||
setTimeout(() => {
|
||||
this.scrollDown()
|
||||
}, 100)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const data = await axios.get(
|
||||
`${this.serverURL}/api/v1/fetch-widget?skill_action=${widgetContainer.onFetch.actionName}&widget_id=${widgetContainer.widgetId}${this.sessionId ? `&session_id=${encodeURIComponent(this.sessionId)}` : ''}`
|
||||
)
|
||||
const fetchedWidget = data.data.widget
|
||||
const reactNode = fetchedWidget
|
||||
? renderAuroraComponent(
|
||||
this.socket,
|
||||
fetchedWidget.componentTree,
|
||||
fetchedWidget.supportedEvents
|
||||
)
|
||||
: createElement(WidgetWrapper, {
|
||||
children: createElement(Flexbox, {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
children: createElement(Text, {
|
||||
secondary: true,
|
||||
children: 'This widget has been deleted.'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
widgetContainer.reactRootNode.render(reactNode)
|
||||
WIDGETS_FETCH_CACHE.set(widgetContainer.widgetId, {
|
||||
...fetchedWidget,
|
||||
reactNode
|
||||
})
|
||||
setTimeout(() => {
|
||||
this.scrollDown()
|
||||
}, 100)
|
||||
}
|
||||
|
||||
const workerCount = Math.min(
|
||||
MAXIMUM_WIDGET_FETCH_CONCURRENCY,
|
||||
widgetContainers.length
|
||||
)
|
||||
let currentIndex = 0
|
||||
|
||||
this.widgetHydrationPromise = Promise.all(
|
||||
Array.from({ length: workerCount }, async () => {
|
||||
while (currentIndex < widgetContainers.length) {
|
||||
const widgetContainer = widgetContainers[currentIndex]
|
||||
currentIndex += 1
|
||||
|
||||
if (!widgetContainer) {
|
||||
continue
|
||||
}
|
||||
|
||||
await hydrateWidgetContainer(widgetContainer)
|
||||
}
|
||||
})
|
||||
)
|
||||
.catch((error) => {
|
||||
console.error('Failed to hydrate fetched widgets:', error)
|
||||
})
|
||||
.finally(() => {
|
||||
this.widgetHydrationPromise = null
|
||||
})
|
||||
|
||||
return this.widgetHydrationPromise
|
||||
}
|
||||
|
||||
setSessionId(sessionId) {
|
||||
this.sessionId = sessionId
|
||||
}
|
||||
|
||||
resetFeed() {
|
||||
WIDGETS_TO_FETCH.length = 0
|
||||
this.feed.innerHTML = ''
|
||||
this.noBubbleMessage = document.createElement('p')
|
||||
this.noBubbleMessage.id = 'no-bubble'
|
||||
this.noBubbleMessage.className = 'hide'
|
||||
this.noBubbleMessage.textContent =
|
||||
'You can start to interact with me, don\'t be shy.'
|
||||
this.feed.appendChild(this.noBubbleMessage)
|
||||
this.parsedBubbles = []
|
||||
this.reasoningBlocks.clear()
|
||||
this.feedAutoScrollEnabled = true
|
||||
}
|
||||
|
||||
async loadFeed() {
|
||||
WIDGETS_TO_FETCH.length = 0
|
||||
this.resetFeed()
|
||||
const sessionQuery = this.sessionId
|
||||
? `&session_id=${encodeURIComponent(this.sessionId)}`
|
||||
: ''
|
||||
|
||||
const [historyResponse, systemWidgetsResponse] = await Promise.all([
|
||||
axios.get(
|
||||
`${this.serverURL}/api/v1/conversation-history?supports_widgets=true${sessionQuery}`
|
||||
),
|
||||
axios.get(
|
||||
`${this.serverURL}/api/v1/system-widgets?supports_widgets=true${sessionQuery}`
|
||||
)
|
||||
])
|
||||
const history = Array.isArray(historyResponse.data?.history)
|
||||
? historyResponse.data.history
|
||||
: []
|
||||
const systemWidgets = Array.isArray(systemWidgetsResponse.data?.widgets)
|
||||
? systemWidgetsResponse.data.widgets
|
||||
: []
|
||||
|
||||
const timelineItems = [...history, ...systemWidgets]
|
||||
.map((item, index) => ({
|
||||
...item,
|
||||
sortIndex: index
|
||||
}))
|
||||
.sort((left, right) => {
|
||||
if (left.sentAt !== right.sentAt) {
|
||||
return left.sentAt - right.sentAt
|
||||
}
|
||||
|
||||
const leftWeight = this.getTimelineItemWeight(left)
|
||||
const rightWeight = this.getTimelineItemWeight(right)
|
||||
|
||||
if (leftWeight !== rightWeight) {
|
||||
return leftWeight - rightWeight
|
||||
}
|
||||
|
||||
return left.sortIndex - right.sortIndex
|
||||
})
|
||||
|
||||
this.parsedBubbles = history
|
||||
|
||||
if (timelineItems.length === 0) {
|
||||
this.noBubbleMessage.classList.remove('hide')
|
||||
return
|
||||
}
|
||||
|
||||
for (let i = 0; i < timelineItems.length; i += 1) {
|
||||
const bubble = timelineItems[i]
|
||||
|
||||
if (
|
||||
bubble.originalString &&
|
||||
ToolUIHandler.isToolOutputMarker(bubble.originalString)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
|
||||
this.createBubble({
|
||||
who: bubble.who === 'owner' ? 'me' : bubble.who,
|
||||
string: bubble.originalString ? bubble.originalString : bubble.string,
|
||||
metrics: bubble.llmMetrics || null,
|
||||
sentAt: bubble.sentAt,
|
||||
save: false,
|
||||
isCreatingFromLoadingFeed: true,
|
||||
messageId: bubble.messageId
|
||||
})
|
||||
}
|
||||
|
||||
void this.hydrateFetchedWidgets()
|
||||
}
|
||||
|
||||
createBubble(params) {
|
||||
const {
|
||||
who,
|
||||
string,
|
||||
metrics = null,
|
||||
save = true,
|
||||
bubbleId,
|
||||
isCreatingFromLoadingFeed = false,
|
||||
messageId,
|
||||
sentAt = null,
|
||||
beforeElement = null
|
||||
} = params
|
||||
const container = document.createElement('div')
|
||||
const bubble = document.createElement('p')
|
||||
|
||||
if (!this.noBubbleMessage.classList.contains('hide')) {
|
||||
this.noBubbleMessage.classList.add('hide')
|
||||
}
|
||||
|
||||
container.className = `bubble-container ${who}`
|
||||
bubble.className = 'bubble'
|
||||
|
||||
if (messageId) {
|
||||
container.setAttribute('data-message-id', messageId)
|
||||
}
|
||||
|
||||
// Store original string before formatting
|
||||
const originalString = string
|
||||
const formattedString = this.formatMessage(string)
|
||||
const widgetPayload = this.getWidgetPayload(formattedString)
|
||||
const autoPlanInsertionPoint = this.getPlanWidgetInsertionPoint(widgetPayload)
|
||||
const resolvedBeforeElement = beforeElement || autoPlanInsertionPoint
|
||||
|
||||
bubble.innerHTML = formattedString
|
||||
|
||||
if (bubbleId) {
|
||||
container.classList.add(bubbleId)
|
||||
}
|
||||
|
||||
if (resolvedBeforeElement && resolvedBeforeElement.parentNode === this.feed) {
|
||||
this.feed.insertBefore(container, resolvedBeforeElement)
|
||||
} else {
|
||||
this.feed.appendChild(container)
|
||||
}
|
||||
container.appendChild(bubble)
|
||||
|
||||
if (who === 'leon' && metrics) {
|
||||
container.appendChild(this.createMetricsElement(metrics, sentAt))
|
||||
} else if (who === 'me') {
|
||||
const timestampElement = this.createTimestampElement(sentAt)
|
||||
|
||||
if (timestampElement) {
|
||||
container.appendChild(timestampElement)
|
||||
}
|
||||
}
|
||||
|
||||
let widgetComponentTree = null
|
||||
let widgetSupportedEvents = null
|
||||
|
||||
/**
|
||||
* Widget rendering
|
||||
*/
|
||||
if (
|
||||
formattedString.includes &&
|
||||
formattedString.includes('"component":"WidgetWrapper"')
|
||||
) {
|
||||
const parsedWidget = widgetPayload || JSON.parse(formattedString)
|
||||
container.setAttribute('data-widget-id', parsedWidget.id)
|
||||
|
||||
/**
|
||||
* On widget fetching, render the loader
|
||||
*/
|
||||
if (isCreatingFromLoadingFeed && parsedWidget.onFetch) {
|
||||
const root = createRoot(container)
|
||||
|
||||
root.render(
|
||||
createElement(WidgetWrapper, {
|
||||
children: createElement(Flexbox, {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
children: createElement(Loader)
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
WIDGETS_TO_FETCH.push({
|
||||
reactRootNode: root,
|
||||
widgetId: parsedWidget.id,
|
||||
onFetch: parsedWidget.onFetch
|
||||
})
|
||||
|
||||
return container
|
||||
}
|
||||
|
||||
widgetComponentTree = parsedWidget.componentTree
|
||||
widgetSupportedEvents = parsedWidget.supportedEvents
|
||||
|
||||
/**
|
||||
* On widget creation
|
||||
*/
|
||||
const root = createRoot(container)
|
||||
|
||||
const reactNode = renderAuroraComponent(
|
||||
this.socket,
|
||||
widgetComponentTree,
|
||||
widgetSupportedEvents
|
||||
)
|
||||
|
||||
root.render(reactNode)
|
||||
}
|
||||
|
||||
if (save) {
|
||||
this.saveBubble(who, originalString, formattedString, messageId, metrics, sentAt)
|
||||
}
|
||||
|
||||
return container
|
||||
}
|
||||
|
||||
formatReasoningPhaseTitle(phase) {
|
||||
const normalizedPhase =
|
||||
typeof phase === 'string' && phase.trim()
|
||||
? phase.replaceAll('_', ' ').toUpperCase()
|
||||
: 'EXECUTION'
|
||||
|
||||
return `REASONING - ${normalizedPhase}`
|
||||
}
|
||||
|
||||
createOrUpdateReasoningBlock(generationId, token, phase) {
|
||||
if (!generationId || !token) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!this.noBubbleMessage.classList.contains('hide')) {
|
||||
this.noBubbleMessage.classList.add('hide')
|
||||
}
|
||||
|
||||
let reasoningBlock = this.reasoningBlocks.get(generationId)
|
||||
|
||||
if (!reasoningBlock) {
|
||||
const container = document.createElement('div')
|
||||
const block = document.createElement('div')
|
||||
const header = document.createElement('div')
|
||||
const icon = document.createElement('i')
|
||||
const title = document.createElement('span')
|
||||
const content = document.createElement('div')
|
||||
|
||||
container.className = 'reasoning-block-container leon'
|
||||
container.setAttribute('data-reasoning-id', generationId)
|
||||
block.className = 'reasoning-block'
|
||||
header.className = 'reasoning-header'
|
||||
icon.className = 'ri-brain-ai-3-line reasoning-icon'
|
||||
title.className = 'reasoning-title'
|
||||
title.textContent = this.formatReasoningPhaseTitle(phase)
|
||||
content.className = 'reasoning-content'
|
||||
|
||||
header.appendChild(icon)
|
||||
header.appendChild(title)
|
||||
block.appendChild(header)
|
||||
block.appendChild(content)
|
||||
container.appendChild(block)
|
||||
this.feed.appendChild(container)
|
||||
|
||||
reasoningBlock = {
|
||||
container,
|
||||
content,
|
||||
text: '',
|
||||
isAutoScrollEnabled: true,
|
||||
isProgrammaticScroll: false
|
||||
}
|
||||
content.addEventListener(
|
||||
'scroll',
|
||||
() => {
|
||||
if (reasoningBlock.isProgrammaticScroll) {
|
||||
return
|
||||
}
|
||||
|
||||
reasoningBlock.isAutoScrollEnabled = this.isElementNearBottom(content)
|
||||
},
|
||||
{ passive: true }
|
||||
)
|
||||
this.reasoningBlocks.set(generationId, reasoningBlock)
|
||||
}
|
||||
|
||||
reasoningBlock.text += token
|
||||
reasoningBlock.content.textContent = reasoningBlock.text
|
||||
this.scrollReasoningContentToBottom(reasoningBlock)
|
||||
|
||||
return reasoningBlock.container
|
||||
}
|
||||
|
||||
handleToolOutput(data) {
|
||||
const result = this.toolUIHandler.handleToolOutput(data)
|
||||
|
||||
// Save in memory if it's a new group
|
||||
if (result && result.isNewGroup) {
|
||||
const { toolkitName, toolName, answer } = data
|
||||
const toolInfo = this.toolUIHandler.getToolGroupInfo(
|
||||
result.groupId,
|
||||
toolkitName,
|
||||
toolName,
|
||||
answer
|
||||
)
|
||||
|
||||
this.saveBubble(
|
||||
'leon',
|
||||
toolInfo.originalString,
|
||||
toolInfo.formattedMessage,
|
||||
toolInfo.messageId
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
saveBubble(
|
||||
who,
|
||||
originalString,
|
||||
string,
|
||||
messageId,
|
||||
metrics = null,
|
||||
sentAt = null
|
||||
) {
|
||||
if (!this.noBubbleMessage.classList.contains('hide')) {
|
||||
this.noBubbleMessage.classList.add('hide')
|
||||
}
|
||||
|
||||
if (this.parsedBubbles.length === MAXIMUM_BUBBLES_IN_MEMORY) {
|
||||
this.parsedBubbles.shift()
|
||||
}
|
||||
|
||||
// Store both original and formatted strings
|
||||
this.parsedBubbles.push({
|
||||
who,
|
||||
sentAt,
|
||||
string,
|
||||
originalString,
|
||||
messageId,
|
||||
llmMetrics: metrics
|
||||
})
|
||||
this.scrollDown()
|
||||
}
|
||||
|
||||
formatMessage(message) {
|
||||
const isWidget =
|
||||
message.includes && message.includes('"component":"WidgetWrapper"')
|
||||
|
||||
if (typeof message === 'string' && !isWidget) {
|
||||
message = escapeHTML(message)
|
||||
message = message.replace(/\n/g, '<br />')
|
||||
|
||||
// Handle HTTP/HTTPS URLs with simple regex
|
||||
message = message.replace(/https?:\/\/[^\s<>"{}|\\^`[\]]+/gi, (match) => {
|
||||
return `<a href="${match}" target="_blank" rel="noopener noreferrer" class="clickable-url" title="Open URL in browser">${match}</a>`
|
||||
})
|
||||
|
||||
// Handle file paths with delimiters for exact matching
|
||||
message = message.replace(
|
||||
/\[FILE_PATH\](.*?)\[\/FILE_PATH\]/g,
|
||||
(match, filePath) => {
|
||||
return `<span class="clickable-path" data-path="${filePath}" title="Open in file explorer">${filePath}</span>`
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
return message
|
||||
}
|
||||
|
||||
formatMetricTimestamp(sentAt) {
|
||||
if (typeof sentAt !== 'number' || !Number.isFinite(sentAt)) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const date = new Date(sentAt)
|
||||
const now = new Date()
|
||||
|
||||
if (Number.isNaN(date.getTime()) || Number.isNaN(now.getTime())) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const timeFormatter = new Intl.DateTimeFormat(undefined, {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false
|
||||
})
|
||||
const formattedTime = timeFormatter.format(date)
|
||||
const startOfToday = new Date(
|
||||
now.getFullYear(),
|
||||
now.getMonth(),
|
||||
now.getDate()
|
||||
)
|
||||
const startOfTargetDay = new Date(
|
||||
date.getFullYear(),
|
||||
date.getMonth(),
|
||||
date.getDate()
|
||||
)
|
||||
const dayDifference = Math.round(
|
||||
(startOfToday.getTime() - startOfTargetDay.getTime()) / 86_400_000
|
||||
)
|
||||
|
||||
if (dayDifference === 0) {
|
||||
return `Today, ${formattedTime}`
|
||||
}
|
||||
|
||||
if (dayDifference === 1) {
|
||||
return `Yesterday, ${formattedTime}`
|
||||
}
|
||||
|
||||
if (dayDifference > 1 && dayDifference < 7) {
|
||||
const weekdayFormatter = new Intl.DateTimeFormat(undefined, {
|
||||
weekday: 'long'
|
||||
})
|
||||
|
||||
return `${weekdayFormatter.format(date)}, ${formattedTime}`
|
||||
}
|
||||
|
||||
const monthDayFormatter = new Intl.DateTimeFormat(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
})
|
||||
|
||||
return `${monthDayFormatter.format(date)}, ${formattedTime}`
|
||||
}
|
||||
|
||||
formatFullMetricTimestamp(sentAt) {
|
||||
if (typeof sentAt !== 'number' || !Number.isFinite(sentAt)) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const date = new Date(sentAt)
|
||||
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
const hours = String(date.getHours()).padStart(2, '0')
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0')
|
||||
const seconds = String(date.getSeconds()).padStart(2, '0')
|
||||
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
|
||||
}
|
||||
|
||||
formatTimestampMarkup(sentAt) {
|
||||
const formattedTimestamp = this.formatMetricTimestamp(sentAt)
|
||||
const fullFormattedTimestamp = this.formatFullMetricTimestamp(sentAt)
|
||||
|
||||
if (!formattedTimestamp) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return `
|
||||
<span class="bubble-metric-item">
|
||||
<i class="ri-time-line" aria-hidden="true"></i>
|
||||
<span title="${fullFormattedTimestamp || formattedTimestamp}">${formattedTimestamp}</span>
|
||||
</span>
|
||||
`.trim()
|
||||
}
|
||||
|
||||
formatTurnDuration(durationMs) {
|
||||
const durationSeconds = Number(durationMs || 0) / 1_000
|
||||
const roundedDurationSeconds = Math.max(0, Number(durationSeconds.toFixed(1)))
|
||||
|
||||
if (roundedDurationSeconds < SECONDS_PER_MINUTE) {
|
||||
return `${roundedDurationSeconds.toFixed(1)}s`
|
||||
}
|
||||
|
||||
const minutes = Math.floor(roundedDurationSeconds / SECONDS_PER_MINUTE)
|
||||
const seconds = roundedDurationSeconds % SECONDS_PER_MINUTE
|
||||
|
||||
return `${minutes}m${seconds.toFixed(1)}s`
|
||||
}
|
||||
|
||||
formatMetrics(metrics, sentAt = null) {
|
||||
if (!metrics) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const inputTokens = Number(metrics.inputTokens || 0)
|
||||
const outputTokens = Number(metrics.outputTokens || 0)
|
||||
const totalTokens = Number(metrics.totalTokens || inputTokens + outputTokens)
|
||||
const turnDuration = this.formatTurnDuration(metrics.durationMs)
|
||||
const tokensPerSecond = Number(
|
||||
metrics.tokensPerSecond || metrics.averagedPhaseTokensPerSecond || 0
|
||||
)
|
||||
const tokenFormatter = new Intl.NumberFormat()
|
||||
const timestampMarkup = this.formatTimestampMarkup(sentAt)
|
||||
|
||||
return `
|
||||
<span class="bubble-metric-item">
|
||||
<i class="ri-copper-coin-line" aria-hidden="true"></i>
|
||||
<span>${tokenFormatter.format(totalTokens)} (i:${tokenFormatter.format(inputTokens)}/o:${tokenFormatter.format(outputTokens)}) tok</span>
|
||||
</span>
|
||||
<span class="bubble-metric-item">
|
||||
<i class="ri-timer-flash-line" aria-hidden="true"></i>
|
||||
<span>${turnDuration}</span>
|
||||
</span>
|
||||
<span class="bubble-metric-item">
|
||||
<i class="ri-flashlight-line" aria-hidden="true"></i>
|
||||
<span>${tokensPerSecond.toFixed(2)} t/s</span>
|
||||
</span>
|
||||
${timestampMarkup}
|
||||
`.trim()
|
||||
}
|
||||
|
||||
createMetricsElement(metrics, sentAt = null) {
|
||||
const metricsElement = document.createElement('div')
|
||||
|
||||
metricsElement.className = 'bubble-metrics'
|
||||
metricsElement.innerHTML = this.formatMetrics(metrics, sentAt)
|
||||
|
||||
return metricsElement
|
||||
}
|
||||
|
||||
createTimestampElement(sentAt) {
|
||||
const timestampMarkup = this.formatTimestampMarkup(sentAt)
|
||||
|
||||
if (!timestampMarkup) {
|
||||
return null
|
||||
}
|
||||
|
||||
const timestampElement = document.createElement('div')
|
||||
|
||||
timestampElement.className = 'bubble-metrics'
|
||||
timestampElement.innerHTML = timestampMarkup
|
||||
|
||||
return timestampElement
|
||||
}
|
||||
|
||||
updateBubbleMetrics(container, metrics, sentAt = null) {
|
||||
if (!container) {
|
||||
return
|
||||
}
|
||||
|
||||
const existingMetricsElement = container.querySelector('.bubble-metrics')
|
||||
|
||||
if (!metrics) {
|
||||
if (existingMetricsElement) {
|
||||
existingMetricsElement.remove()
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (existingMetricsElement) {
|
||||
existingMetricsElement.innerHTML = this.formatMetrics(metrics, sentAt)
|
||||
return
|
||||
}
|
||||
|
||||
container.appendChild(this.createMetricsElement(metrics, sentAt))
|
||||
}
|
||||
|
||||
getLatestReasoningContainer() {
|
||||
const reasoningContainers = this.feed.querySelectorAll(
|
||||
'.reasoning-block-container'
|
||||
)
|
||||
|
||||
if (reasoningContainers.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return reasoningContainers[reasoningContainers.length - 1] || null
|
||||
}
|
||||
|
||||
replaceMessage(replaceMessageId, newData) {
|
||||
const existingBubble = document.querySelector(
|
||||
`[data-message-id="${replaceMessageId}"]`
|
||||
)
|
||||
const isPlanWidget = this.isPlanWidgetData(newData)
|
||||
const nextSibling = existingBubble ? existingBubble.nextSibling : null
|
||||
|
||||
if (existingBubble) {
|
||||
existingBubble.remove()
|
||||
|
||||
const bubbleIndex = this.parsedBubbles.findIndex(
|
||||
(bubble) => bubble.messageId === replaceMessageId
|
||||
)
|
||||
if (bubbleIndex !== -1) {
|
||||
this.parsedBubbles.splice(bubbleIndex, 1)
|
||||
}
|
||||
}
|
||||
|
||||
const isTextAnswerPayload = Boolean(
|
||||
newData &&
|
||||
typeof newData === 'object' &&
|
||||
typeof newData.answer === 'string' &&
|
||||
!newData.widget &&
|
||||
!newData.componentTree
|
||||
)
|
||||
const bubbleString = isTextAnswerPayload
|
||||
? newData.answer
|
||||
: typeof newData === 'string'
|
||||
? newData
|
||||
: JSON.stringify(newData)
|
||||
const metrics =
|
||||
isTextAnswerPayload && newData.llmMetrics ? newData.llmMetrics : null
|
||||
|
||||
const shouldSaveMessage = !this.isSystemWidgetData(newData)
|
||||
const beforeElement = isPlanWidget ? null : nextSibling
|
||||
|
||||
this.createBubble({
|
||||
who: 'leon',
|
||||
string: bubbleString,
|
||||
save: shouldSaveMessage,
|
||||
messageId: replaceMessageId,
|
||||
beforeElement,
|
||||
metrics,
|
||||
sentAt:
|
||||
newData && typeof newData === 'object' && typeof newData.sentAt === 'number'
|
||||
? newData.sentAt
|
||||
: Date.now()
|
||||
})
|
||||
|
||||
/**
|
||||
* Only scroll down on the first replacement of this message
|
||||
* to avoid repeating scrolling for every message replacement
|
||||
*/
|
||||
if (!REPLACED_MESSAGES.has(replaceMessageId)) {
|
||||
REPLACED_MESSAGES.add(replaceMessageId)
|
||||
this.scrollDown()
|
||||
}
|
||||
}
|
||||
|
||||
openPath(filePath) {
|
||||
// Send request to server to open the file path in system file explorer
|
||||
fetch(`${this.serverURL}/api/v1/open-path`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ path: filePath })
|
||||
})
|
||||
.then((response) => response.json())
|
||||
.then((data) => {
|
||||
if (!data.success) {
|
||||
console.error('Failed to open path:', data.error)
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Error opening path:', error)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,651 @@
|
||||
import { io } from 'socket.io-client'
|
||||
|
||||
import Chatbot from './chatbot'
|
||||
import VoiceEnergy from './voice-energy'
|
||||
import { ASR_DISABLED_MESSAGE, INIT_MESSAGES } from './constants'
|
||||
import handleSuggestions from './suggestion-handler.js'
|
||||
|
||||
const LEON_CLIENT_INTERFACE_PROTOCOL_VERSION = 1
|
||||
const LEON_EVENTS = {
|
||||
init: 'leon:init',
|
||||
utterance: 'leon:utterance',
|
||||
ready: 'leon:ready',
|
||||
answer: 'leon:answer',
|
||||
isTyping: 'leon:is-typing',
|
||||
suggest: 'leon:suggest',
|
||||
llmToken: 'leon:llm-token',
|
||||
llmReasoningToken: 'leon:llm-reasoning-token',
|
||||
ownerUtterance: 'leon:owner-utterance',
|
||||
error: 'leon:error'
|
||||
}
|
||||
|
||||
export default class Client {
|
||||
constructor(client, serverUrl, input, options = {}) {
|
||||
this.client = client
|
||||
this._input = input
|
||||
this.voiceSpeechElement = document.querySelector('#voice-speech')
|
||||
this.serverUrl = serverUrl
|
||||
this.socket = io(this.serverUrl)
|
||||
this.activeSessionId = options.activeSessionId || null
|
||||
this.history = localStorage.getItem('history')
|
||||
this.parsedHistory = []
|
||||
this.chatbot = new Chatbot(this.socket, this.serverUrl, this.activeSessionId)
|
||||
this.voiceEnergy = new VoiceEnergy(this)
|
||||
this._recorder = {}
|
||||
this._suggestions = []
|
||||
this._answerGenerationId = 'xxx'
|
||||
this._activeStreamGenerationId = null
|
||||
this._ttsAudioContext = null
|
||||
this._isLeonGeneratingAnswer = false
|
||||
this._isVoiceModeEnabled = false
|
||||
this._hasSentInitMessages = false
|
||||
this._chatbotInitPromise = null
|
||||
// this._ttsAudioContextes = {}
|
||||
}
|
||||
|
||||
set input(newInput) {
|
||||
if (typeof newInput !== 'undefined') {
|
||||
this._input.value = newInput
|
||||
}
|
||||
}
|
||||
|
||||
get input() {
|
||||
return this._input
|
||||
}
|
||||
|
||||
set recorder(recorder) {
|
||||
this._recorder = recorder
|
||||
}
|
||||
|
||||
get recorder() {
|
||||
return this._recorder
|
||||
}
|
||||
|
||||
updateMood(mood) {
|
||||
const moodContainer = document.querySelector('#mood')
|
||||
|
||||
if (!moodContainer || !mood?.emoji || !mood?.type) {
|
||||
return
|
||||
}
|
||||
|
||||
moodContainer.textContent = `Leon's mood: ${mood.emoji}`
|
||||
moodContainer.setAttribute('title', mood.type)
|
||||
}
|
||||
|
||||
setSessionPanel(sessionPanel) {
|
||||
this.sessionPanel = sessionPanel
|
||||
}
|
||||
|
||||
async setActiveSession(sessionId) {
|
||||
if (!sessionId || sessionId === this.activeSessionId) {
|
||||
return
|
||||
}
|
||||
|
||||
this.activeSessionId = sessionId
|
||||
this.chatbot.setSessionId(sessionId)
|
||||
this.socket.emit('session-change', sessionId)
|
||||
await this.chatbot.loadFeed()
|
||||
this.chatbot.scrollDown({ force: true })
|
||||
}
|
||||
|
||||
async sendInitMessages() {
|
||||
for (let i = 0; i < INIT_MESSAGES.length; i++) {
|
||||
const messages = INIT_MESSAGES[i]
|
||||
const message = messages[Math.floor(Math.random() * messages.length)]
|
||||
const sendingDelay = Math.floor(Math.random() * 2000) + 1000
|
||||
const typingFactorDelay = Math.floor(Math.random() * 4) + 2
|
||||
|
||||
setTimeout(() => {
|
||||
this.chatbot.isTyping('leon', true)
|
||||
}, sendingDelay / typingFactorDelay)
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, sendingDelay))
|
||||
|
||||
this.chatbot.receivedFrom('leon', message)
|
||||
this.chatbot.isTyping('leon', false)
|
||||
}
|
||||
}
|
||||
|
||||
setInitStatus(statusName, statusType) {
|
||||
window.leonInitStatusEvent.dispatchEvent(
|
||||
new CustomEvent('initStatusChange', {
|
||||
detail: {
|
||||
statusName,
|
||||
statusType
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
waitForInitUICompletion() {
|
||||
if (this._hasSentInitMessages || this.chatbot.parsedBubbles?.length > 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const trySendInitMessages = () => {
|
||||
const initializedInitElement = document.querySelector('#init .initialized')
|
||||
|
||||
if (!initializedInitElement) {
|
||||
return false
|
||||
}
|
||||
|
||||
this._hasSentInitMessages = true
|
||||
this.sendInitMessages()
|
||||
return true
|
||||
}
|
||||
|
||||
if (trySendInitMessages()) {
|
||||
return
|
||||
}
|
||||
|
||||
const interval = setInterval(() => {
|
||||
if (trySendInitMessages()) {
|
||||
clearInterval(interval)
|
||||
}
|
||||
}, 100)
|
||||
}
|
||||
|
||||
asrStartRecording() {
|
||||
if (!window.leonConfigInfo.asr.enabled) {
|
||||
alert(ASR_DISABLED_MESSAGE)
|
||||
return
|
||||
}
|
||||
|
||||
if (!this._isVoiceModeEnabled) {
|
||||
this.enableVoiceMode()
|
||||
|
||||
this.voiceEnergy.status = 'listening'
|
||||
|
||||
this.socket.emit('asr-start-record')
|
||||
}
|
||||
}
|
||||
|
||||
init() {
|
||||
this._chatbotInitPromise = this.chatbot.init()
|
||||
this.voiceEnergy.init()
|
||||
|
||||
if (window.leonConfigInfo?.tcpServer?.enabled === false) {
|
||||
this.setInitStatus('tcpServerBoot', 'success')
|
||||
}
|
||||
|
||||
this.socket.on('connect', () => {
|
||||
this.socket.emit(LEON_EVENTS.init, {
|
||||
protocolVersion: LEON_CLIENT_INTERFACE_PROTOCOL_VERSION,
|
||||
client: {
|
||||
id: this.client,
|
||||
type: 'web_app',
|
||||
name: 'Leon Web App'
|
||||
},
|
||||
sessionId: this.activeSessionId,
|
||||
capabilities: {
|
||||
supportsWidgets: true,
|
||||
supportsTokenStreaming: true,
|
||||
supportsVoice: true
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Init status listeners
|
||||
*/
|
||||
this.socket.on('init-llama-server-boot', (status) => {
|
||||
this.setInitStatus('llamaServerBoot', status)
|
||||
})
|
||||
|
||||
this.socket.on(LEON_EVENTS.ready, () => {
|
||||
this.setInitStatus('clientCoreServerHandshake', 'success')
|
||||
this.setInitStatus('tcpServerBoot', 'success')
|
||||
|
||||
void this._chatbotInitPromise?.then(() => {
|
||||
setTimeout(() => {
|
||||
const body = document.querySelector('body')
|
||||
body.classList.remove('settingup')
|
||||
}, 250)
|
||||
|
||||
this.waitForInitUICompletion()
|
||||
})
|
||||
})
|
||||
|
||||
this.socket.on(LEON_EVENTS.answer, (data) => {
|
||||
/*if (this._isVoiceModeEnabled) {
|
||||
this.voiceEnergy.status = 'listening'
|
||||
}*/
|
||||
|
||||
// Leon has finished to answer
|
||||
this._isLeonGeneratingAnswer = false
|
||||
|
||||
const isPlanWidget =
|
||||
data && typeof data === 'object' && data.widget === 'PlanWidget'
|
||||
|
||||
if (isPlanWidget) {
|
||||
this.chatbot.isTyping('leon', false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle message replacement if replaceMessageId is provided
|
||||
*/
|
||||
if (data.replaceMessageId) {
|
||||
this.chatbot.replaceMessage(data.replaceMessageId, data)
|
||||
return
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle tool output messages
|
||||
*/
|
||||
if (data.isToolOutput) {
|
||||
this.chatbot.handleToolOutput(data)
|
||||
return
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle widget data directly
|
||||
*/
|
||||
if (data.widget || data.componentTree) {
|
||||
const isSystemWidget = this.chatbot.isSystemWidgetData(data)
|
||||
// Pass the entire widget data as JSON string for chatbot.js to handle
|
||||
const widgetString =
|
||||
typeof data === 'string' ? data : JSON.stringify(data)
|
||||
|
||||
this.chatbot.createBubble({
|
||||
who: 'leon',
|
||||
string: widgetString,
|
||||
save: !isSystemWidget,
|
||||
messageId: data.widget?.id || data.id || `msg-${Date.now()}`
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const answerText = typeof data === 'string' ? data : data.answer
|
||||
const llmMetrics =
|
||||
data && typeof data === 'object' && data.llmMetrics
|
||||
? data.llmMetrics
|
||||
: null
|
||||
|
||||
/**
|
||||
* Just save the bubble if the newest bubble is from the streaming.
|
||||
* Otherwise, create a new bubble
|
||||
*/
|
||||
const streamGenerationId =
|
||||
this._activeStreamGenerationId || this._answerGenerationId
|
||||
const streamedBubbleContainerElement = streamGenerationId
|
||||
? document.querySelector(
|
||||
`.bubble-container.leon.${streamGenerationId}`
|
||||
)
|
||||
: null
|
||||
const isBubbleFromStreaming = Boolean(streamedBubbleContainerElement)
|
||||
|
||||
if (isBubbleFromStreaming && streamedBubbleContainerElement) {
|
||||
this.chatbot.saveBubble(
|
||||
'leon',
|
||||
answerText,
|
||||
answerText,
|
||||
null,
|
||||
llmMetrics,
|
||||
data && typeof data === 'object' && typeof data.sentAt === 'number'
|
||||
? data.sentAt
|
||||
: Date.now()
|
||||
)
|
||||
|
||||
// Slightly delay the update to avoid the stream animation to be interrupted
|
||||
setTimeout(() => {
|
||||
// Update the text of the bubble (quick emoji fix)
|
||||
streamedBubbleContainerElement.querySelector('p.bubble').innerHTML =
|
||||
this.chatbot.formatMessage(answerText)
|
||||
this.chatbot.updateBubbleMetrics(
|
||||
streamedBubbleContainerElement,
|
||||
llmMetrics,
|
||||
data && typeof data === 'object' && typeof data.sentAt === 'number'
|
||||
? data.sentAt
|
||||
: Date.now()
|
||||
)
|
||||
}, 2_500)
|
||||
} else {
|
||||
this.chatbot.createBubble({
|
||||
who: 'leon',
|
||||
string: answerText,
|
||||
save:
|
||||
!(
|
||||
data &&
|
||||
typeof data === 'object' &&
|
||||
data.historyMode === 'system_widget'
|
||||
),
|
||||
metrics: llmMetrics,
|
||||
messageId: data && typeof data === 'object' ? data.messageId : null,
|
||||
sentAt:
|
||||
data && typeof data === 'object' && typeof data.sentAt === 'number'
|
||||
? data.sentAt
|
||||
: Date.now()
|
||||
})
|
||||
}
|
||||
this.chatbot.scrollDown({ force: true })
|
||||
|
||||
this._activeStreamGenerationId = null
|
||||
this._answerGenerationId = 'xxx'
|
||||
void this.sessionPanel?.refresh()
|
||||
})
|
||||
|
||||
this.socket.on(LEON_EVENTS.suggest, (data) => {
|
||||
setTimeout(() => {
|
||||
handleSuggestions(data, this.chatbot, this)
|
||||
}, 400)
|
||||
setTimeout(() => {
|
||||
this.chatbot.scrollDown()
|
||||
}, 450)
|
||||
/*data?.forEach((suggestionText) => {
|
||||
this.addSuggestion(suggestionText)
|
||||
})*/
|
||||
})
|
||||
|
||||
this.socket.on(LEON_EVENTS.isTyping, (data) => {
|
||||
this.chatbot.isTyping('leon', data)
|
||||
})
|
||||
|
||||
this.socket.on(LEON_EVENTS.ownerUtterance, (data) => {
|
||||
if (!data?.utterance) {
|
||||
return
|
||||
}
|
||||
|
||||
this.chatbot.createBubble({
|
||||
who: 'me',
|
||||
string: data.utterance,
|
||||
messageId: data.messageId,
|
||||
sentAt: typeof data.sentAt === 'number' ? data.sentAt : Date.now()
|
||||
})
|
||||
})
|
||||
|
||||
this.socket.on('recognized', (data, cb) => {
|
||||
this._input.value = data
|
||||
this.send('utterance')
|
||||
|
||||
cb('string-received')
|
||||
})
|
||||
|
||||
this.socket.on('widget-send-utterance', (utterance) => {
|
||||
this._input.value = utterance
|
||||
this.send('utterance')
|
||||
})
|
||||
|
||||
this.socket.on('new-mood', (mood) => {
|
||||
this.updateMood(mood)
|
||||
})
|
||||
|
||||
this.socket.on(LEON_EVENTS.llmToken, (data) => {
|
||||
if (this._isVoiceModeEnabled) {
|
||||
this.voiceEnergy.status = 'processing'
|
||||
}
|
||||
|
||||
this._isLeonGeneratingAnswer = true
|
||||
const previousGenerationId = this._answerGenerationId
|
||||
const newGenerationId = data.generationId
|
||||
this._answerGenerationId = newGenerationId
|
||||
this._activeStreamGenerationId = newGenerationId
|
||||
const isSameGeneration = previousGenerationId === newGenerationId
|
||||
let bubbleContainerElement = null
|
||||
|
||||
if (!isSameGeneration) {
|
||||
bubbleContainerElement = this.chatbot.createBubble({
|
||||
who: 'leon',
|
||||
string: data.token,
|
||||
save: false,
|
||||
bubbleId: newGenerationId
|
||||
})
|
||||
} else {
|
||||
bubbleContainerElement = document.querySelector(
|
||||
`.${previousGenerationId}`
|
||||
)
|
||||
}
|
||||
|
||||
const bubbleElement = bubbleContainerElement.querySelector('p.bubble')
|
||||
|
||||
// Token is already appened when it's a new generation
|
||||
if (isSameGeneration) {
|
||||
// bubbleElement.textContent += data.token
|
||||
|
||||
const tokenSpan = document.createElement('span')
|
||||
tokenSpan.className = 'llm-token fade-in'
|
||||
tokenSpan.textContent = data.token
|
||||
|
||||
bubbleElement.appendChild(tokenSpan)
|
||||
}
|
||||
|
||||
this.chatbot.scrollDown()
|
||||
})
|
||||
|
||||
this.socket.on(LEON_EVENTS.llmReasoningToken, (data) => {
|
||||
if (!data?.generationId || !data?.token) {
|
||||
return
|
||||
}
|
||||
|
||||
if (this._isVoiceModeEnabled) {
|
||||
this.voiceEnergy.status = 'processing'
|
||||
}
|
||||
|
||||
this._isLeonGeneratingAnswer = true
|
||||
this.chatbot.createOrUpdateReasoningBlock(
|
||||
data.generationId,
|
||||
data.token,
|
||||
data.phase
|
||||
)
|
||||
this.chatbot.scrollDown()
|
||||
})
|
||||
|
||||
this.socket.on('asr-speech', (text) => {
|
||||
if (!this._isVoiceModeEnabled) {
|
||||
this.enableVoiceMode()
|
||||
}
|
||||
|
||||
this.voiceEnergy.status = 'listening'
|
||||
this._input.value = text
|
||||
|
||||
if (this.voiceSpeechElement) {
|
||||
this.voiceSpeechElement.textContent = text
|
||||
}
|
||||
})
|
||||
|
||||
this.socket.on('asr-end-of-owner-speech', () => {
|
||||
this.voiceEnergy.status = 'processing'
|
||||
|
||||
setTimeout(() => {
|
||||
this.send('utterance')
|
||||
}, 200)
|
||||
})
|
||||
|
||||
this.socket.on('asr-active-listening-disabled', () => {
|
||||
this.voiceEnergy.status = 'idle'
|
||||
})
|
||||
|
||||
/**
|
||||
* Only used for "local" TTS provider as a PoC for now.
|
||||
* Target to do a better implementation in the future
|
||||
* with streaming support
|
||||
*/
|
||||
this.socket.on('tts-stream', (data) => {
|
||||
this.voiceEnergy.status = 'talking'
|
||||
|
||||
// const { audioId, chunk } = data
|
||||
const { chunk } = data
|
||||
this._ttsAudioContext = new AudioContext()
|
||||
// this._ttsAudioContextes[audioId] = ctx
|
||||
|
||||
const source = this._ttsAudioContext.createBufferSource()
|
||||
this._ttsAudioContext.decodeAudioData(chunk, (buffer) => {
|
||||
source.buffer = buffer
|
||||
|
||||
source.connect(this._ttsAudioContext.destination)
|
||||
source.start(0)
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* When Leon got interrupted by the owner voice
|
||||
* while he is speaking
|
||||
*/
|
||||
this.socket.on('tts-interruption', async () => {
|
||||
if (this._ttsAudioContext) {
|
||||
await this._ttsAudioContext.close()
|
||||
}
|
||||
})
|
||||
|
||||
this.socket.on('tts-end-of-speech', async () => {
|
||||
this.voiceEnergy.status = 'listening'
|
||||
|
||||
if (window.leonConfigInfo?.asr?.enabled) {
|
||||
this.socket.emit('asr-start-record')
|
||||
}
|
||||
})
|
||||
|
||||
this.socket.on('audio-forwarded', (data, cb) => {
|
||||
const ctx = new AudioContext()
|
||||
const source = ctx.createBufferSource()
|
||||
|
||||
ctx.decodeAudioData(data.buffer, (buffer) => {
|
||||
source.buffer = buffer
|
||||
|
||||
source.connect(ctx.destination)
|
||||
source.start(0)
|
||||
|
||||
/**
|
||||
* When the after speech option is enabled and
|
||||
* the answer is a final one
|
||||
*/
|
||||
if (window.leonConfigInfo.after_speech && data.is_final_answer) {
|
||||
// Enable recording after the speech + 500ms
|
||||
setTimeout(() => {
|
||||
this._recorder.start()
|
||||
this._recorder.enabled = true
|
||||
|
||||
// Check every second if the recorder is enabled to stop it
|
||||
const id = setInterval(() => {
|
||||
if (this._recorder.enabled) {
|
||||
if (this._recorder.countSilenceAfterTalk <= 8) {
|
||||
// Stop recording if there was no noise for 8 seconds
|
||||
if (this._recorder.countSilenceAfterTalk === 8) {
|
||||
this._recorder.stop()
|
||||
this._recorder.enabled = false
|
||||
this._recorder.countSilenceAfterTalk = 0
|
||||
clearInterval(id)
|
||||
} else if (!this._recorder.noiseDetected) {
|
||||
this._recorder.countSilenceAfterTalk += 1
|
||||
} else {
|
||||
clearInterval(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}, 1_000)
|
||||
}, data.duration + 500)
|
||||
}
|
||||
})
|
||||
|
||||
cb('audio-received')
|
||||
})
|
||||
|
||||
this.socket.on(LEON_EVENTS.error, (data) => {
|
||||
console.error('Leon client interface error:', data)
|
||||
})
|
||||
|
||||
if (this.history !== null) {
|
||||
this.parsedHistory = JSON.parse(this.history)
|
||||
}
|
||||
}
|
||||
|
||||
send(keyword) {
|
||||
if (keyword === 'utterance') {
|
||||
return this.sendUtterance(this._input.value)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
sendUtterance(value, options = {}) {
|
||||
if (this._isLeonGeneratingAnswer) {
|
||||
return false
|
||||
}
|
||||
|
||||
const trimmedValue = String(value || '').trim()
|
||||
|
||||
if (trimmedValue === '') {
|
||||
return false
|
||||
}
|
||||
|
||||
const sentAt =
|
||||
typeof options.sentAt === 'number' ? options.sentAt : Date.now()
|
||||
|
||||
this.socket.emit(LEON_EVENTS.utterance, {
|
||||
value: trimmedValue,
|
||||
sentAt,
|
||||
sessionId: this.activeSessionId,
|
||||
...(options.commandContext
|
||||
? { commandContext: options.commandContext }
|
||||
: {})
|
||||
})
|
||||
this.chatbot.sendTo('leon', trimmedValue, sentAt)
|
||||
this.chatbot.scrollDown({ force: true })
|
||||
|
||||
this.save(trimmedValue)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
save(value = this._input.value) {
|
||||
let val = value
|
||||
|
||||
if (localStorage.getItem('history') === null) {
|
||||
localStorage.setItem('history', JSON.stringify([]))
|
||||
this.parsedHistory = JSON.parse(localStorage.getItem('history'))
|
||||
} else if (this.parsedHistory.length >= 32) {
|
||||
this.parsedHistory.shift()
|
||||
}
|
||||
|
||||
if (val[0] === ' ') {
|
||||
val = val.substr(1, val.length - 1)
|
||||
}
|
||||
|
||||
if (this.parsedHistory[this.parsedHistory.length - 1] !== val) {
|
||||
this.parsedHistory.push(val)
|
||||
localStorage.setItem('history', JSON.stringify(this.parsedHistory))
|
||||
}
|
||||
|
||||
this._input.value = ''
|
||||
setTimeout(() => {
|
||||
// Remove the last character to avoid the space
|
||||
this._input.value = this._input.value.slice(0, -1)
|
||||
}, 0)
|
||||
}
|
||||
|
||||
enableVoiceMode() {
|
||||
if (!this._isVoiceModeEnabled) {
|
||||
this._isVoiceModeEnabled = true
|
||||
|
||||
const body = document.querySelector('body')
|
||||
if (!body.classList.contains('voice-mode-enabled')) {
|
||||
body.classList.add('voice-mode-enabled')
|
||||
|
||||
const voiceOverlayTransitor = document.createElement('div')
|
||||
voiceOverlayTransitor.id = 'voice-overlay-transitor'
|
||||
body.appendChild(voiceOverlayTransitor)
|
||||
voiceOverlayTransitor.addEventListener('animationend', () => {
|
||||
voiceOverlayTransitor.removeEventListener('animationend', () => {})
|
||||
voiceOverlayTransitor.remove()
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
disableVoiceMode() {
|
||||
if (this._isVoiceModeEnabled) {
|
||||
this._isVoiceModeEnabled = false
|
||||
|
||||
const body = document.querySelector('body')
|
||||
|
||||
const voiceContainer = document.querySelector('#voice-container')
|
||||
if (voiceContainer) {
|
||||
voiceContainer.style.animation = 'none'
|
||||
voiceContainer.style.animation = null
|
||||
}
|
||||
|
||||
if (body.classList.contains('voice-mode-enabled')) {
|
||||
body.classList.remove('voice-mode-enabled')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
export const INIT_MESSAGES = [
|
||||
[
|
||||
'Hi, I\'m Leon, your open-source personal assistant. I\'m still evolving, but I\'m here to help.'
|
||||
],
|
||||
[
|
||||
`Want to follow the project?
|
||||
|
||||
- Regular progress updates: https://x.com/grenlouis
|
||||
- Blog posts: https://blog.getleon.ai/
|
||||
- Email updates: https://leonai.substack.com/subscribe`
|
||||
],
|
||||
[
|
||||
`Come hang out on Discord: https://discord.gg/MNQqqKg
|
||||
That's where people will help shape the ecosystem and build what comes next.`
|
||||
],
|
||||
[
|
||||
'Leon is built independently in spare time. If you want to help accelerate development, you can support the project here: http://sponsor.getleon.ai/'
|
||||
]
|
||||
]
|
||||
|
||||
export const ASR_DISABLED_MESSAGE =
|
||||
'Voice mode is not enabled yet. Run /voice setup from the chat, wait for the resources to install, then restart Leon by using /restart.'
|
||||
@@ -0,0 +1,386 @@
|
||||
import axios from 'axios'
|
||||
|
||||
const AUTOCOMPLETE_DELAY_MS = 80
|
||||
const CLOSE_DELAY_MS = 120
|
||||
const DROPDOWN_MARGIN_PX = 8
|
||||
const MINIMUM_DROPDOWN_HEIGHT_PX = 196
|
||||
const MAXIMUM_DROPDOWN_HEIGHT_PX = 280
|
||||
const FILE_SYSTEM_TRIGGER = '@'
|
||||
const FOLDER_TYPE = 'folder'
|
||||
const DEFAULT_FILE_ICON_NAME = 'file-line'
|
||||
const DEFAULT_FOLDER_ICON_NAME = 'folder-3-line'
|
||||
const NAVIGATION_DIRECTIONS = {
|
||||
next: 1,
|
||||
previous: -1
|
||||
}
|
||||
|
||||
function isWhitespace(character) {
|
||||
return character.trim() === ''
|
||||
}
|
||||
|
||||
function findPathToken(input) {
|
||||
const selectionStart = input.selectionStart ?? input.value.length
|
||||
let tokenStart = selectionStart
|
||||
|
||||
while (
|
||||
tokenStart > 0 &&
|
||||
!isWhitespace(input.value.charAt(tokenStart - 1))
|
||||
) {
|
||||
tokenStart -= 1
|
||||
}
|
||||
|
||||
if (input.value.charAt(tokenStart) !== FILE_SYSTEM_TRIGGER) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
start: tokenStart,
|
||||
end: selectionStart,
|
||||
value: input.value.slice(tokenStart, selectionStart)
|
||||
}
|
||||
}
|
||||
|
||||
export default class FileSystemAutocomplete {
|
||||
constructor({ serverUrl, input = null, onValueChange = null }) {
|
||||
this.serverUrl = serverUrl
|
||||
this.input = null
|
||||
this.onValueChange = onValueChange
|
||||
this.dropdown = document.createElement('div')
|
||||
this.dropdown.className = 'file-system-autocomplete file-system-autocomplete--hidden'
|
||||
this.entries = []
|
||||
this.selectedEntryIndex = -1
|
||||
this.token = null
|
||||
this.isOpen = false
|
||||
this.requestCounter = 0
|
||||
this.autocompleteTimeout = null
|
||||
this.closeTimeout = null
|
||||
this.shouldScrollSelectedEntry = false
|
||||
|
||||
this.handleInput = this.handleInput.bind(this)
|
||||
this.handleKeyDown = this.handleKeyDown.bind(this)
|
||||
this.handleFocus = this.handleFocus.bind(this)
|
||||
this.handleClick = this.handleClick.bind(this)
|
||||
this.handleBlur = this.handleBlur.bind(this)
|
||||
this.handleWindowChange = this.handleWindowChange.bind(this)
|
||||
|
||||
document.body.appendChild(this.dropdown)
|
||||
|
||||
if (input) {
|
||||
this.attach(input)
|
||||
}
|
||||
}
|
||||
|
||||
attach(input) {
|
||||
if (this.input === input) {
|
||||
return
|
||||
}
|
||||
|
||||
window.clearTimeout(this.closeTimeout)
|
||||
|
||||
if (this.input) {
|
||||
this.removeInputListeners()
|
||||
}
|
||||
|
||||
this.input = input
|
||||
this.input.addEventListener('input', this.handleInput)
|
||||
this.input.addEventListener('keydown', this.handleKeyDown)
|
||||
this.input.addEventListener('focus', this.handleFocus)
|
||||
this.input.addEventListener('click', this.handleClick)
|
||||
this.input.addEventListener('blur', this.handleBlur)
|
||||
window.addEventListener('resize', this.handleWindowChange)
|
||||
window.addEventListener('scroll', this.handleWindowChange, true)
|
||||
}
|
||||
|
||||
removeInputListeners() {
|
||||
if (!this.input) {
|
||||
return
|
||||
}
|
||||
|
||||
this.input.removeEventListener('input', this.handleInput)
|
||||
this.input.removeEventListener('keydown', this.handleKeyDown)
|
||||
this.input.removeEventListener('focus', this.handleFocus)
|
||||
this.input.removeEventListener('click', this.handleClick)
|
||||
this.input.removeEventListener('blur', this.handleBlur)
|
||||
window.removeEventListener('resize', this.handleWindowChange)
|
||||
window.removeEventListener('scroll', this.handleWindowChange, true)
|
||||
this.input = null
|
||||
}
|
||||
|
||||
detach() {
|
||||
this.removeInputListeners()
|
||||
this.close()
|
||||
}
|
||||
|
||||
handleInput() {
|
||||
window.clearTimeout(this.closeTimeout)
|
||||
this.queueAutocomplete()
|
||||
}
|
||||
|
||||
handleFocus() {
|
||||
window.clearTimeout(this.closeTimeout)
|
||||
this.queueAutocomplete()
|
||||
}
|
||||
|
||||
handleClick() {
|
||||
this.queueAutocomplete()
|
||||
}
|
||||
|
||||
handleBlur() {
|
||||
this.queueClose()
|
||||
}
|
||||
|
||||
queueClose() {
|
||||
window.clearTimeout(this.closeTimeout)
|
||||
this.closeTimeout = window.setTimeout(() => {
|
||||
this.close()
|
||||
}, CLOSE_DELAY_MS)
|
||||
}
|
||||
|
||||
handleWindowChange() {
|
||||
if (this.isOpen) {
|
||||
this.positionDropdown()
|
||||
}
|
||||
}
|
||||
|
||||
handleKeyDown(event) {
|
||||
if (!this.isOpen) {
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
event.stopImmediatePropagation()
|
||||
this.close()
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault()
|
||||
event.stopImmediatePropagation()
|
||||
this.moveSelection(NAVIGATION_DIRECTIONS.next)
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowUp') {
|
||||
event.preventDefault()
|
||||
event.stopImmediatePropagation()
|
||||
this.moveSelection(NAVIGATION_DIRECTIONS.previous)
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'Tab' || event.key === 'Enter') {
|
||||
const selectedEntry = this.entries[this.selectedEntryIndex]
|
||||
|
||||
if (selectedEntry) {
|
||||
event.preventDefault()
|
||||
event.stopImmediatePropagation()
|
||||
this.applyEntry(selectedEntry)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
queueAutocomplete() {
|
||||
window.clearTimeout(this.autocompleteTimeout)
|
||||
this.autocompleteTimeout = window.setTimeout(() => {
|
||||
void this.fetchAutocomplete()
|
||||
}, AUTOCOMPLETE_DELAY_MS)
|
||||
}
|
||||
|
||||
async fetchAutocomplete() {
|
||||
if (!this.input) {
|
||||
this.close()
|
||||
return
|
||||
}
|
||||
|
||||
if (document.activeElement !== this.input) {
|
||||
this.queueClose()
|
||||
return
|
||||
}
|
||||
|
||||
const token = findPathToken(this.input)
|
||||
|
||||
if (!token) {
|
||||
this.close()
|
||||
return
|
||||
}
|
||||
|
||||
const requestId = this.requestCounter + 1
|
||||
this.requestCounter = requestId
|
||||
this.token = token
|
||||
|
||||
try {
|
||||
const response = await axios.post(
|
||||
`${this.serverUrl}/api/v1/file-system/list`,
|
||||
{
|
||||
value: token.value
|
||||
}
|
||||
)
|
||||
|
||||
if (requestId !== this.requestCounter) {
|
||||
return
|
||||
}
|
||||
|
||||
this.entries = response.data.entries || []
|
||||
this.selectedEntryIndex = this.entries.length > 0 ? 0 : -1
|
||||
this.render()
|
||||
} catch {
|
||||
if (requestId !== this.requestCounter) {
|
||||
return
|
||||
}
|
||||
|
||||
this.entries = []
|
||||
this.selectedEntryIndex = -1
|
||||
this.render()
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.input || !this.token) {
|
||||
this.close()
|
||||
return
|
||||
}
|
||||
|
||||
this.dropdown.innerHTML = ''
|
||||
|
||||
if (this.entries.length === 0) {
|
||||
const emptyElement = document.createElement('div')
|
||||
emptyElement.className = 'file-system-autocomplete__empty'
|
||||
emptyElement.textContent = 'No matching file system entry.'
|
||||
this.dropdown.appendChild(emptyElement)
|
||||
} else {
|
||||
this.entries.forEach((entry, index) => {
|
||||
const item = document.createElement('button')
|
||||
const icon = document.createElement('i')
|
||||
const label = document.createElement('span')
|
||||
|
||||
item.type = 'button'
|
||||
item.className = `file-system-autocomplete__item${
|
||||
index === this.selectedEntryIndex
|
||||
? ' file-system-autocomplete__item--selected'
|
||||
: ''
|
||||
}`
|
||||
icon.className = `ri-${
|
||||
entry.iconName ||
|
||||
(entry.type === FOLDER_TYPE
|
||||
? DEFAULT_FOLDER_ICON_NAME
|
||||
: DEFAULT_FILE_ICON_NAME)
|
||||
}`
|
||||
label.className = 'file-system-autocomplete__label'
|
||||
label.textContent = entry.value
|
||||
|
||||
item.addEventListener('mousedown', (event) => {
|
||||
event.preventDefault()
|
||||
})
|
||||
item.addEventListener('click', () => {
|
||||
this.applyEntry(entry)
|
||||
})
|
||||
|
||||
item.appendChild(icon)
|
||||
item.appendChild(label)
|
||||
this.dropdown.appendChild(item)
|
||||
})
|
||||
}
|
||||
|
||||
this.isOpen = true
|
||||
this.dropdown.classList.remove('file-system-autocomplete--hidden')
|
||||
this.positionDropdown()
|
||||
|
||||
if (this.shouldScrollSelectedEntry) {
|
||||
this.shouldScrollSelectedEntry = false
|
||||
this.scrollSelectedEntryIntoView()
|
||||
}
|
||||
}
|
||||
|
||||
positionDropdown() {
|
||||
const inputRect = this.input.getBoundingClientRect()
|
||||
const spaceBelow = window.innerHeight - inputRect.bottom
|
||||
const spaceAbove = inputRect.top
|
||||
const shouldOpenAbove =
|
||||
spaceBelow < MINIMUM_DROPDOWN_HEIGHT_PX && spaceAbove > spaceBelow
|
||||
const availableHeight =
|
||||
(shouldOpenAbove ? spaceAbove : spaceBelow) - DROPDOWN_MARGIN_PX * 2
|
||||
const maxHeight = Math.min(
|
||||
MAXIMUM_DROPDOWN_HEIGHT_PX,
|
||||
Math.max(MINIMUM_DROPDOWN_HEIGHT_PX, availableHeight)
|
||||
)
|
||||
|
||||
this.dropdown.style.left = `${inputRect.left}px`
|
||||
this.dropdown.style.width = `${inputRect.width}px`
|
||||
this.dropdown.style.maxHeight = `${maxHeight}px`
|
||||
|
||||
if (shouldOpenAbove) {
|
||||
this.dropdown.style.top = ''
|
||||
this.dropdown.style.bottom = `${
|
||||
window.innerHeight - inputRect.top + DROPDOWN_MARGIN_PX
|
||||
}px`
|
||||
} else {
|
||||
this.dropdown.style.top = `${inputRect.bottom + DROPDOWN_MARGIN_PX}px`
|
||||
this.dropdown.style.bottom = ''
|
||||
}
|
||||
}
|
||||
|
||||
moveSelection(direction) {
|
||||
if (this.entries.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
this.selectedEntryIndex =
|
||||
(this.selectedEntryIndex + direction + this.entries.length) %
|
||||
this.entries.length
|
||||
this.shouldScrollSelectedEntry = true
|
||||
this.render()
|
||||
}
|
||||
|
||||
scrollSelectedEntryIntoView() {
|
||||
const selectedEntryElement = this.dropdown.querySelector(
|
||||
'.file-system-autocomplete__item--selected'
|
||||
)
|
||||
|
||||
if (!selectedEntryElement) {
|
||||
return
|
||||
}
|
||||
|
||||
selectedEntryElement.scrollIntoView({
|
||||
block: 'nearest'
|
||||
})
|
||||
}
|
||||
|
||||
applyEntry(entry) {
|
||||
if (!this.input || !this.token) {
|
||||
return
|
||||
}
|
||||
|
||||
const completedValue = entry.absolutePath || entry.value
|
||||
const nextCharacter = this.input.value.charAt(this.token.end)
|
||||
const shouldAddTrailingWhitespace =
|
||||
nextCharacter === '' || !isWhitespace(nextCharacter)
|
||||
const insertedValue = `${completedValue}${
|
||||
shouldAddTrailingWhitespace ? ' ' : ''
|
||||
}`
|
||||
const nextValue = `${this.input.value.slice(0, this.token.start)}${
|
||||
insertedValue
|
||||
}${this.input.value.slice(this.token.end)}`
|
||||
const nextCursorPosition = this.token.start + insertedValue.length
|
||||
|
||||
this.input.value = nextValue
|
||||
this.input.setSelectionRange(nextCursorPosition, nextCursorPosition)
|
||||
this.input.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
|
||||
if (this.onValueChange) {
|
||||
this.onValueChange(nextValue)
|
||||
window.requestAnimationFrame(() => {
|
||||
this.input?.setSelectionRange(nextCursorPosition, nextCursorPosition)
|
||||
})
|
||||
}
|
||||
|
||||
this.close()
|
||||
}
|
||||
|
||||
close() {
|
||||
this.isOpen = false
|
||||
this.entries = []
|
||||
this.selectedEntryIndex = -1
|
||||
this.token = null
|
||||
this.dropdown.classList.add('file-system-autocomplete--hidden')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
import { useEffect, useState, useRef } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import {
|
||||
WidgetWrapper,
|
||||
Text,
|
||||
Icon,
|
||||
Flexbox,
|
||||
List,
|
||||
ListHeader,
|
||||
ListItem,
|
||||
Loader
|
||||
} from '@aurora'
|
||||
|
||||
const container = document.querySelector('#init')
|
||||
const root = createRoot(container)
|
||||
const LLAMA_SERVER_BOOT_STATUS = 'llamaServerBoot'
|
||||
const INIT_ERROR_STATUS = 'error'
|
||||
const INIT_ERROR_DISMISS_SECONDS = 10
|
||||
const INIT_ERROR_DISMISS_INTERVAL_MS = 1_000
|
||||
|
||||
function Item({ children, status }) {
|
||||
if (status === 'error') {
|
||||
return <ErrorListItem>{children}</ErrorListItem>
|
||||
}
|
||||
if (status === 'warning') {
|
||||
return <WarningListItem>{children}</WarningListItem>
|
||||
}
|
||||
if (status === 'success') {
|
||||
return <SuccessListItem>{children}</SuccessListItem>
|
||||
}
|
||||
if (status === 'loading') {
|
||||
return <LoadingListItem>{children}</LoadingListItem>
|
||||
}
|
||||
|
||||
return <ListItem>{children}</ListItem>
|
||||
}
|
||||
|
||||
function LoadingListItem({ children }) {
|
||||
return (
|
||||
<ListItem>
|
||||
<Flexbox flexDirection="row" alignItems="center" gap="sm">
|
||||
<Loader size="sm" />
|
||||
<Text>{children}</Text>
|
||||
</Flexbox>
|
||||
</ListItem>
|
||||
)
|
||||
}
|
||||
function ErrorListItem({ children }) {
|
||||
return (
|
||||
<ListItem>
|
||||
<Flexbox flexDirection="row" alignItems="center" gap="sm">
|
||||
<Icon
|
||||
iconName="close"
|
||||
size="sm"
|
||||
type="fill"
|
||||
bgShape="circle"
|
||||
color="red"
|
||||
bgColor="transparent-red"
|
||||
/>
|
||||
<Text>{children}</Text>
|
||||
</Flexbox>
|
||||
</ListItem>
|
||||
)
|
||||
}
|
||||
function WarningListItem({ children }) {
|
||||
return (
|
||||
<ListItem>
|
||||
<Flexbox flexDirection="row" alignItems="center" gap="sm">
|
||||
<Icon
|
||||
iconName="alert"
|
||||
size="sm"
|
||||
type="fill"
|
||||
bgShape="circle"
|
||||
color="yellow"
|
||||
bgColor="transparent-yellow"
|
||||
/>
|
||||
<Text>{children}</Text>
|
||||
</Flexbox>
|
||||
</ListItem>
|
||||
)
|
||||
}
|
||||
function SuccessListItem({ children }) {
|
||||
return (
|
||||
<ListItem>
|
||||
<Flexbox flexDirection="row" alignItems="center" gap="sm">
|
||||
<Icon
|
||||
iconName="check"
|
||||
size="sm"
|
||||
type="fill"
|
||||
bgShape="circle"
|
||||
color="green"
|
||||
bgColor="transparent-green"
|
||||
/>
|
||||
<Text>{children}</Text>
|
||||
</Flexbox>
|
||||
</ListItem>
|
||||
)
|
||||
}
|
||||
|
||||
function Init() {
|
||||
const parentRef = useRef(null)
|
||||
const [config, setConfig] = useState(() => ({ ...window.leonConfigInfo }))
|
||||
const usesLlamaCPP =
|
||||
config.llm?.workflowProvider === 'llamacpp' ||
|
||||
config.llm?.agentProvider === 'llamacpp'
|
||||
const [initErrorCountdown, setInitErrorCountdown] = useState(null)
|
||||
const [areInitErrorsDismissed, setAreInitErrorsDismissed] = useState(false)
|
||||
const [statusMap, setStatusMap] = useState({
|
||||
clientCoreServerHandshake: 'loading',
|
||||
tcpServerBoot:
|
||||
window.leonConfigInfo?.tcpServer?.enabled === false ? 'success' : 'loading',
|
||||
[LLAMA_SERVER_BOOT_STATUS]:
|
||||
window.leonConfigInfo?.llm?.workflowProvider === 'llamacpp' ||
|
||||
window.leonConfigInfo?.llm?.agentProvider === 'llamacpp'
|
||||
? 'loading'
|
||||
: 'success'
|
||||
})
|
||||
const hasInitError = Object.values(statusMap).some(
|
||||
(status) => status === INIT_ERROR_STATUS
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
setTimeout(() => {
|
||||
if (parentRef.current) {
|
||||
parentRef.current.classList.remove('not-initialized')
|
||||
}
|
||||
}, 250)
|
||||
|
||||
function handleStatusChange(event) {
|
||||
const { statusName, statusType } = event.detail
|
||||
|
||||
if (statusType === INIT_ERROR_STATUS) {
|
||||
setAreInitErrorsDismissed(false)
|
||||
}
|
||||
|
||||
setStatusMap((prev) => ({ ...prev, [statusName]: statusType }))
|
||||
}
|
||||
|
||||
window.leonInitStatusEvent.addEventListener(
|
||||
'initStatusChange',
|
||||
handleStatusChange
|
||||
)
|
||||
return () =>
|
||||
window.leonInitStatusEvent.removeEventListener(
|
||||
'initStatusChange',
|
||||
handleStatusChange
|
||||
)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasInitError || areInitErrorsDismissed) {
|
||||
setInitErrorCountdown(null)
|
||||
return
|
||||
}
|
||||
|
||||
let secondsLeft = INIT_ERROR_DISMISS_SECONDS
|
||||
setInitErrorCountdown(secondsLeft)
|
||||
|
||||
const interval = setInterval(() => {
|
||||
secondsLeft -= 1
|
||||
setInitErrorCountdown(secondsLeft)
|
||||
|
||||
if (secondsLeft <= 0) {
|
||||
clearInterval(interval)
|
||||
setAreInitErrorsDismissed(true)
|
||||
}
|
||||
}, INIT_ERROR_DISMISS_INTERVAL_MS)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [hasInitError, areInitErrorsDismissed])
|
||||
|
||||
const statuses = []
|
||||
for (let key of Object.keys(statusMap)) {
|
||||
if (key === 'tcpServerBoot' && config.tcpServer?.enabled === false) {
|
||||
statuses.push('success')
|
||||
} else if (statusMap[key] === INIT_ERROR_STATUS && areInitErrorsDismissed) {
|
||||
statuses.push('success')
|
||||
} else if (
|
||||
key === LLAMA_SERVER_BOOT_STATUS &&
|
||||
!usesLlamaCPP
|
||||
) {
|
||||
statuses.push('success')
|
||||
} else {
|
||||
statuses.push(statusMap[key])
|
||||
}
|
||||
}
|
||||
|
||||
const areAllStatusesSuccess = statuses.every((status) => status === 'success')
|
||||
const getInitMessage = (status, defaultMessage) => {
|
||||
if (status === INIT_ERROR_STATUS && initErrorCountdown !== null) {
|
||||
return `An error occurred during the initialization. This message will disappear in ${initErrorCountdown} seconds`
|
||||
}
|
||||
|
||||
return defaultMessage
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (window.leonConfigInfo) {
|
||||
setConfig({ ...window.leonConfigInfo })
|
||||
}
|
||||
}, [window.leonConfigInfo])
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
width: '100vw',
|
||||
height: '100vh',
|
||||
zIndex: 9999,
|
||||
backgroundColor: 'var(--black-color)'
|
||||
}}
|
||||
ref={parentRef}
|
||||
className={areAllStatusesSuccess ? 'initialized' : 'not-initialized'}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '33%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)'
|
||||
}}
|
||||
>
|
||||
<WidgetWrapper noPadding>
|
||||
<List>
|
||||
<ListHeader>Leon is getting ready...</ListHeader>
|
||||
<Item status={statusMap.clientCoreServerHandshake}>
|
||||
{getInitMessage(
|
||||
statusMap.clientCoreServerHandshake,
|
||||
'Client and core server handshaked'
|
||||
)}
|
||||
</Item>
|
||||
{config.tcpServer?.enabled !== false && (
|
||||
<Item status={statusMap.tcpServerBoot}>
|
||||
{getInitMessage(statusMap.tcpServerBoot, 'TCP server booted')}
|
||||
</Item>
|
||||
)}
|
||||
{usesLlamaCPP && (
|
||||
<Item status={statusMap[LLAMA_SERVER_BOOT_STATUS]}>
|
||||
{getInitMessage(
|
||||
statusMap[LLAMA_SERVER_BOOT_STATUS],
|
||||
'llama-server booted'
|
||||
)}
|
||||
</Item>
|
||||
)}
|
||||
</List>
|
||||
</WidgetWrapper>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
root.render(<Init />)
|
||||
@@ -0,0 +1,44 @@
|
||||
const listener = {}
|
||||
|
||||
listener.listening = (
|
||||
stream,
|
||||
minDecibels,
|
||||
maxBlankTime,
|
||||
cbOnStart,
|
||||
cbOnEnd
|
||||
) => {
|
||||
const ctx = new AudioContext()
|
||||
const analyser = ctx.createAnalyser()
|
||||
const streamNode = ctx.createMediaStreamSource(stream)
|
||||
streamNode.connect(analyser)
|
||||
analyser.minDecibels = minDecibels
|
||||
|
||||
const data = new Uint8Array(analyser.frequencyBinCount)
|
||||
let silenceStart = performance.now()
|
||||
let triggered = false
|
||||
|
||||
const loop = (time) => {
|
||||
requestAnimationFrame(loop)
|
||||
|
||||
analyser.getByteFrequencyData(data)
|
||||
|
||||
if (data.some((v) => v)) {
|
||||
if (triggered) {
|
||||
triggered = false
|
||||
|
||||
cbOnStart()
|
||||
}
|
||||
silenceStart = time
|
||||
}
|
||||
|
||||
if (!triggered && time - silenceStart > maxBlankTime) {
|
||||
cbOnEnd()
|
||||
|
||||
triggered = true
|
||||
}
|
||||
}
|
||||
|
||||
loop()
|
||||
}
|
||||
|
||||
export default listener
|
||||
@@ -0,0 +1,213 @@
|
||||
import axios from 'axios'
|
||||
import '@aurora/style.css'
|
||||
|
||||
window.leonInitStatusEvent = new EventTarget()
|
||||
|
||||
import './init'
|
||||
import Client from './client'
|
||||
import { BuiltInCommands } from './built-in-commands'
|
||||
import FileSystemAutocomplete from './file-system-autocomplete'
|
||||
import SessionsPanel from './sessions'
|
||||
// import Recorder from './recorder'
|
||||
// import listener from './listener'
|
||||
import { onkeydownstartrecording, onkeydowninput } from './onkeydown'
|
||||
|
||||
const config = {
|
||||
app: 'webapp',
|
||||
server_host: import.meta.env.VITE_LEON_HOST,
|
||||
server_port: import.meta.env.VITE_LEON_PORT,
|
||||
min_decibels: -40, // Noise detection sensitivity
|
||||
max_blank_time: 1_000 // Maximum time to consider a blank (ms)
|
||||
}
|
||||
const serverUrl =
|
||||
import.meta.env.VITE_LEON_NODE_ENV === 'production'
|
||||
? ''
|
||||
: `${config.server_host}:${config.server_port}`
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
try {
|
||||
const [response, sessionsResponse] = await Promise.all([
|
||||
axios.get(`${serverUrl}/api/v1/info`),
|
||||
axios.get(`${serverUrl}/api/v1/sessions`)
|
||||
])
|
||||
const input = document.querySelector('#utterance')
|
||||
const mic = document.querySelector('#mic-button')
|
||||
const v = document.querySelector('#version small')
|
||||
const infoButton = document.querySelector('#info')
|
||||
const client = new Client(config.app, serverUrl, input, {
|
||||
activeSessionId: sessionsResponse.data.active_session_id
|
||||
})
|
||||
const sessionsPanel = new SessionsPanel({
|
||||
serverUrl,
|
||||
socket: client.socket,
|
||||
activeSessionId: sessionsResponse.data.active_session_id,
|
||||
initialPayload: sessionsResponse.data,
|
||||
onSelect: (sessionId) => client.setActiveSession(sessionId)
|
||||
})
|
||||
const fileSystemAutocomplete = new FileSystemAutocomplete({
|
||||
serverUrl,
|
||||
input
|
||||
})
|
||||
const builtInCommands = new BuiltInCommands({
|
||||
serverUrl,
|
||||
input,
|
||||
getActiveSessionId: () => sessionsPanel.getActiveSessionId(),
|
||||
onCommandExecuted: (commandInput) => {
|
||||
if (commandInput.trim().startsWith('/session')) {
|
||||
void sessionsPanel.refresh()
|
||||
}
|
||||
},
|
||||
onSubmitToChat: (clientAction) => {
|
||||
return client.sendUtterance(clientAction.utterance, {
|
||||
commandContext: {
|
||||
forcedRoutingMode: clientAction.command_context?.forced_routing_mode,
|
||||
forcedSkillName: clientAction.command_context?.forced_skill_name,
|
||||
forcedToolName: clientAction.command_context?.forced_tool_name
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
// let rec = {}
|
||||
// let chunks = []
|
||||
|
||||
window.leonConfigInfo = response.data
|
||||
const infoKeys = [
|
||||
'timeZone',
|
||||
'telemetry',
|
||||
'gpu',
|
||||
'graphicsComputeAPI',
|
||||
'totalVRAM',
|
||||
'freeVRAM',
|
||||
'usedVRAM',
|
||||
'llm',
|
||||
'asr',
|
||||
'tts',
|
||||
'mood',
|
||||
'version'
|
||||
]
|
||||
const infoToDisplay = {}
|
||||
infoKeys.forEach((key) => {
|
||||
infoToDisplay[key] = window.leonConfigInfo[key]
|
||||
})
|
||||
|
||||
v.textContent += window.leonConfigInfo.version
|
||||
|
||||
client.updateMood(window.leonConfigInfo.mood)
|
||||
client.setSessionPanel(sessionsPanel)
|
||||
sessionsPanel.init()
|
||||
client.init()
|
||||
fileSystemAutocomplete.attach(input)
|
||||
builtInCommands.init()
|
||||
|
||||
infoButton.addEventListener('click', () => {
|
||||
alert(JSON.stringify(infoToDisplay, null, 2))
|
||||
})
|
||||
|
||||
/*if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) {
|
||||
navigator.mediaDevices
|
||||
.getUserMedia({ audio: true })
|
||||
.then((stream) => {
|
||||
if (MediaRecorder) {
|
||||
rec = new Recorder(stream, mic, window.leonConfigInfo)
|
||||
client.recorder = rec
|
||||
|
||||
rec.ondataavailable((e) => {
|
||||
chunks.push(e.data)
|
||||
})
|
||||
|
||||
rec.onstart(() => {
|
||||
/!* *!/
|
||||
})
|
||||
|
||||
rec.onstop(() => {
|
||||
const blob = new Blob(chunks)
|
||||
chunks = []
|
||||
rec.enabled = false
|
||||
|
||||
// Ensure there are some data
|
||||
if (blob.size >= 1_000) {
|
||||
client.socket.emit('recognize', blob)
|
||||
}
|
||||
})
|
||||
|
||||
listener.listening(
|
||||
stream,
|
||||
config.min_decibels,
|
||||
config.max_blank_time,
|
||||
() => {
|
||||
// Noise detected
|
||||
rec.noiseDetected = true
|
||||
},
|
||||
() => {
|
||||
// Noise ended
|
||||
|
||||
rec.noiseDetected = false
|
||||
if (rec.enabled && !rec.hotwordTriggered) {
|
||||
rec.stop()
|
||||
rec.enabled = false
|
||||
rec.hotwordTriggered = false
|
||||
rec.countSilenceAfterTalk = 0
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
client.socket.on('enable-record', () => {
|
||||
rec.hotwordTriggered = true
|
||||
rec.start()
|
||||
setTimeout(() => {
|
||||
rec.hotwordTriggered = false
|
||||
}, config.max_blank_time)
|
||||
rec.enabled = true
|
||||
})
|
||||
} else {
|
||||
console.error('MediaRecorder is not supported on your browser.')
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(
|
||||
'MediaDevices.getUserMedia() threw the following error:',
|
||||
err
|
||||
)
|
||||
})
|
||||
} else {
|
||||
console.error(
|
||||
'MediaDevices.getUserMedia() is not supported on your browser.'
|
||||
)
|
||||
}*/
|
||||
|
||||
document.addEventListener('keydown', (e) => {
|
||||
onkeydownstartrecording(e, () => {
|
||||
client.asrStartRecording()
|
||||
/*if (rec.enabled === false) {
|
||||
input.value = ''
|
||||
rec.start()
|
||||
rec.enabled = true
|
||||
} else {
|
||||
rec.stop()
|
||||
rec.enabled = false
|
||||
}*/
|
||||
})
|
||||
})
|
||||
|
||||
input.addEventListener('keydown', (e) => {
|
||||
onkeydowninput(e, client)
|
||||
})
|
||||
|
||||
mic.addEventListener('click', (e) => {
|
||||
e.preventDefault()
|
||||
|
||||
client.asrStartRecording()
|
||||
|
||||
/*if (rec.enabled === false) {
|
||||
rec.start()
|
||||
rec.enabled = true
|
||||
} else {
|
||||
rec.stop()
|
||||
rec.enabled = false
|
||||
}*/
|
||||
})
|
||||
} catch (e) {
|
||||
alert(`Error: ${e.message}; ${JSON.stringify(e.response?.data)}`)
|
||||
console.error(e)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,38 @@
|
||||
let index = -1
|
||||
let parsedHistory = null
|
||||
|
||||
const onkeydowninput = (e, client) => {
|
||||
const key = e.which || e.keyCode
|
||||
|
||||
if (localStorage.getItem('history') !== null && (key === 38 || key === 40)) {
|
||||
parsedHistory = JSON.parse(localStorage.getItem('history')).reverse()
|
||||
}
|
||||
|
||||
if (key === 13 && !e.shiftKey) {
|
||||
if (client.send('utterance')) {
|
||||
parsedHistory = JSON.parse(localStorage.getItem('history')).reverse()
|
||||
index = -1
|
||||
}
|
||||
} else if (localStorage.getItem('history') !== null) {
|
||||
if (e.shiftKey) {
|
||||
if (key === 38 && index < parsedHistory.length - 1) {
|
||||
index += 1
|
||||
client.input = parsedHistory[index]
|
||||
} else if (key === 40 && index - 1 >= 0) {
|
||||
index -= 1
|
||||
client.input = parsedHistory[index]
|
||||
} else if (key === 40 && index - 1 < 0) {
|
||||
client.input = ''
|
||||
index = -1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const onkeydownstartrecording = (e, cb) => {
|
||||
if ((e.metaKey || e.altKey) && e.key === 'c') {
|
||||
cb()
|
||||
}
|
||||
}
|
||||
|
||||
export { onkeydowninput, onkeydownstartrecording }
|
||||
@@ -0,0 +1,63 @@
|
||||
import on from '../sounds/on.mp3'
|
||||
import off from '../sounds/off.mp3'
|
||||
|
||||
export default class Recorder {
|
||||
constructor(stream, el, info) {
|
||||
this.recorder = new MediaRecorder(stream, { audioBitsPerSecond: 16000 })
|
||||
this.el = el
|
||||
this.audioOn = new Audio(on)
|
||||
this.audioOff = new Audio(off)
|
||||
this.playSound = true
|
||||
this.info = info
|
||||
this.enabled = false
|
||||
this.hotwordTriggered = false
|
||||
this.noiseDetected = false
|
||||
this.countSilenceAfterTalk = 0
|
||||
}
|
||||
|
||||
start(playSound = true) {
|
||||
if (this.info.asr.enabled === false) {
|
||||
console.warn('ASR disabled')
|
||||
} else {
|
||||
this.playSound = playSound
|
||||
this.recorder.start(playSound)
|
||||
}
|
||||
}
|
||||
|
||||
stop(playSound = true) {
|
||||
if (this.info.asr.enabled === false) {
|
||||
console.warn('ASR disabled')
|
||||
} else {
|
||||
this.playSound = playSound
|
||||
this.recorder.stop(playSound)
|
||||
}
|
||||
}
|
||||
|
||||
onstart(cb) {
|
||||
this.recorder.onstart = (e) => {
|
||||
if (this.playSound) {
|
||||
this.audioOn.play()
|
||||
}
|
||||
this.el.classList.add('enabled')
|
||||
|
||||
cb(e)
|
||||
}
|
||||
}
|
||||
|
||||
onstop(cb) {
|
||||
this.recorder.onstop = (e) => {
|
||||
if (this.playSound) {
|
||||
this.audioOff.play()
|
||||
}
|
||||
this.el.classList.remove('enabled')
|
||||
|
||||
cb(e)
|
||||
}
|
||||
}
|
||||
|
||||
ondataavailable(cb) {
|
||||
this.recorder.ondataavailable = (e) => {
|
||||
cb(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { createElement } from 'react'
|
||||
|
||||
import * as auroraComponents from '@aurora'
|
||||
|
||||
import * as customAuroraComponents from '../custom-aurora-components'
|
||||
|
||||
export default function renderAuroraComponent(
|
||||
socket,
|
||||
component,
|
||||
supportedEvents
|
||||
) {
|
||||
if (component) {
|
||||
// `import/namespace` cannot statically validate dynamic component lookups.
|
||||
// eslint-disable-next-line import/namespace
|
||||
let reactComponent = auroraComponents[component.component]
|
||||
/**
|
||||
* Find custom component if a former component is not found
|
||||
*/
|
||||
if (!reactComponent) {
|
||||
// eslint-disable-next-line import/namespace
|
||||
reactComponent = customAuroraComponents[component.component]
|
||||
}
|
||||
|
||||
if (!reactComponent) {
|
||||
console.error(`Component ${component.component} not found`)
|
||||
return null
|
||||
}
|
||||
|
||||
// Check if the browsed component has a supported event and bind it
|
||||
if (reactComponent && Array.isArray(component.events)) {
|
||||
component.events.forEach((event) => {
|
||||
if (supportedEvents.includes(event.type)) {
|
||||
component.props[event.type] = (data) => {
|
||||
const { method } = event
|
||||
|
||||
socket.emit('widget-event', { method, data })
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// When children is a component, then wrap it in an array to render properly
|
||||
const isComponent = !!component.props?.children?.component
|
||||
if (isComponent) {
|
||||
component.props.children = [component.props.children]
|
||||
}
|
||||
|
||||
if (component.props?.children && Array.isArray(component.props.children)) {
|
||||
component.props.children = component.props.children.map((child) => {
|
||||
return renderAuroraComponent(socket, child, supportedEvents)
|
||||
})
|
||||
}
|
||||
|
||||
return createElement(reactComponent, component.props)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
import axios from 'axios'
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1_000
|
||||
const GROUP_DATE_FORMATTER = new Intl.DateTimeFormat(undefined, {
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
})
|
||||
|
||||
function createElement(tagName, className, textContent = '') {
|
||||
const element = document.createElement(tagName)
|
||||
|
||||
if (className) {
|
||||
element.className = className
|
||||
}
|
||||
|
||||
if (textContent) {
|
||||
element.textContent = textContent
|
||||
}
|
||||
|
||||
return element
|
||||
}
|
||||
|
||||
function getDayKey(timestamp) {
|
||||
const date = new Date(timestamp)
|
||||
|
||||
return new Date(
|
||||
date.getFullYear(),
|
||||
date.getMonth(),
|
||||
date.getDate()
|
||||
).getTime()
|
||||
}
|
||||
|
||||
function getGroupLabel(timestamp) {
|
||||
const now = Date.now()
|
||||
const todayKey = getDayKey(now)
|
||||
const yesterdayKey = todayKey - DAY_MS
|
||||
const itemKey = getDayKey(timestamp)
|
||||
|
||||
if (itemKey === todayKey) {
|
||||
return 'Today'
|
||||
}
|
||||
|
||||
if (itemKey === yesterdayKey) {
|
||||
return 'Yesterday'
|
||||
}
|
||||
|
||||
return GROUP_DATE_FORMATTER.format(new Date(timestamp))
|
||||
}
|
||||
|
||||
export default class SessionsPanel {
|
||||
constructor({
|
||||
serverUrl,
|
||||
socket,
|
||||
activeSessionId,
|
||||
initialPayload,
|
||||
onSelect
|
||||
}) {
|
||||
this.serverUrl = serverUrl
|
||||
this.socket = socket
|
||||
this.activeSessionId = activeSessionId
|
||||
this.sessions = initialPayload?.sessions || []
|
||||
this.supportedProviders = initialPayload?.supported_providers || []
|
||||
this.currentModelTarget = initialPayload?.current_model_target || ''
|
||||
this.onSelect = onSelect
|
||||
this.container = document.querySelector('#sessions-panel')
|
||||
this.list = document.querySelector('#sessions-list')
|
||||
this.newButton = document.querySelector('#new-session')
|
||||
}
|
||||
|
||||
init() {
|
||||
this.newButton.addEventListener('click', () => {
|
||||
void this.createSession()
|
||||
})
|
||||
this.list.addEventListener('click', (event) => {
|
||||
void this.handleClick(event)
|
||||
})
|
||||
this.render()
|
||||
}
|
||||
|
||||
getActiveSessionId() {
|
||||
return this.activeSessionId
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
const previousActiveSessionId = this.activeSessionId
|
||||
const response = await axios.get(`${this.serverUrl}/api/v1/sessions`)
|
||||
|
||||
this.applyPayload(response.data)
|
||||
|
||||
if (
|
||||
response.data.active_session_id &&
|
||||
response.data.active_session_id !== previousActiveSessionId
|
||||
) {
|
||||
await this.onSelect?.(response.data.active_session_id)
|
||||
}
|
||||
}
|
||||
|
||||
async createSession() {
|
||||
const response = await axios.post(`${this.serverUrl}/api/v1/sessions`)
|
||||
|
||||
this.applyPayload(response.data)
|
||||
await this.selectSession(response.data.session.id)
|
||||
}
|
||||
|
||||
async selectSession(sessionId) {
|
||||
this.activeSessionId = sessionId
|
||||
this.socket.emit('session-change', sessionId)
|
||||
await this.onSelect?.(sessionId)
|
||||
await axios.patch(`${this.serverUrl}/api/v1/sessions/${sessionId}`, {
|
||||
is_active: true
|
||||
})
|
||||
await this.refresh()
|
||||
}
|
||||
|
||||
async updateSession(sessionId, payload) {
|
||||
const response = await axios.patch(
|
||||
`${this.serverUrl}/api/v1/sessions/${sessionId}`,
|
||||
payload
|
||||
)
|
||||
|
||||
this.applyPayload(response.data)
|
||||
}
|
||||
|
||||
async deleteSession(sessionId) {
|
||||
const wasActiveSession = this.activeSessionId === sessionId
|
||||
const response = await axios.delete(
|
||||
`${this.serverUrl}/api/v1/sessions/${sessionId}`
|
||||
)
|
||||
|
||||
this.applyPayload(response.data)
|
||||
|
||||
if (wasActiveSession) {
|
||||
await this.selectSession(response.data.active_session_id)
|
||||
}
|
||||
}
|
||||
|
||||
async handleClick(event) {
|
||||
const button = event.target.closest('button')
|
||||
const item = event.target.closest('.session-item')
|
||||
|
||||
if (!item) {
|
||||
return
|
||||
}
|
||||
|
||||
const sessionId = item.getAttribute('data-session-id')
|
||||
|
||||
if (!button) {
|
||||
await this.selectSession(sessionId)
|
||||
return
|
||||
}
|
||||
|
||||
const action = button.getAttribute('data-action')
|
||||
|
||||
if (action === 'rename') {
|
||||
const currentTitle = item.getAttribute('data-session-title') || ''
|
||||
const title = window.prompt('Session title', currentTitle)
|
||||
|
||||
if (title && title.trim()) {
|
||||
await this.updateSession(sessionId, { title: title.trim() })
|
||||
}
|
||||
} else if (action === 'pin') {
|
||||
await this.updateSession(sessionId, {
|
||||
is_pinned: button.getAttribute('aria-pressed') !== 'true'
|
||||
})
|
||||
} else if (action === 'delete') {
|
||||
await this.deleteSession(sessionId)
|
||||
} else if (action === 'model') {
|
||||
await this.updateSessionModel(sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
async updateSessionModel(sessionId) {
|
||||
const provider = window.prompt(
|
||||
`Provider (${this.supportedProviders.join(', ')})`
|
||||
)
|
||||
|
||||
if (!provider) {
|
||||
return
|
||||
}
|
||||
|
||||
const model = window.prompt('Model')
|
||||
|
||||
if (!model) {
|
||||
return
|
||||
}
|
||||
|
||||
await this.updateSession(sessionId, {
|
||||
provider: provider.trim(),
|
||||
model: model.trim()
|
||||
})
|
||||
}
|
||||
|
||||
applyPayload(payload) {
|
||||
this.sessions = payload.sessions || []
|
||||
this.supportedProviders = payload.supported_providers || []
|
||||
this.currentModelTarget = payload.current_model_target || ''
|
||||
this.activeSessionId = payload.active_session_id || this.activeSessionId
|
||||
this.render()
|
||||
}
|
||||
|
||||
render() {
|
||||
this.list.innerHTML = ''
|
||||
|
||||
const pinnedSessions = this.sessions.filter((session) => session.isPinned)
|
||||
const unpinnedSessions = this.sessions.filter((session) => !session.isPinned)
|
||||
|
||||
if (pinnedSessions.length > 0) {
|
||||
this.renderGroup('Pinned', pinnedSessions)
|
||||
}
|
||||
|
||||
const groupedSessions = new Map()
|
||||
|
||||
for (const session of unpinnedSessions) {
|
||||
const timestamp = session.lastMessageAt || session.updatedAt
|
||||
const groupLabel = getGroupLabel(timestamp)
|
||||
const group = groupedSessions.get(groupLabel) || []
|
||||
|
||||
group.push(session)
|
||||
groupedSessions.set(groupLabel, group)
|
||||
}
|
||||
|
||||
for (const [label, sessions] of groupedSessions.entries()) {
|
||||
this.renderGroup(label, sessions)
|
||||
}
|
||||
}
|
||||
|
||||
renderGroup(label, sessions) {
|
||||
const group = createElement('section', 'session-group')
|
||||
const heading = createElement('h2', 'session-group-title', label)
|
||||
|
||||
group.appendChild(heading)
|
||||
|
||||
for (const session of sessions) {
|
||||
group.appendChild(this.createSessionItem(session))
|
||||
}
|
||||
|
||||
this.list.appendChild(group)
|
||||
}
|
||||
|
||||
createSessionItem(session) {
|
||||
const item = createElement('article', 'session-item')
|
||||
const title = createElement('div', 'session-title', session.title)
|
||||
const meta = createElement(
|
||||
'div',
|
||||
'session-meta',
|
||||
session.modelTarget || this.currentModelTarget || 'Default model'
|
||||
)
|
||||
const actions = createElement('div', 'session-actions')
|
||||
|
||||
item.setAttribute('data-session-id', session.id)
|
||||
item.setAttribute('data-session-title', session.title)
|
||||
|
||||
if (session.id === this.activeSessionId) {
|
||||
item.classList.add('session-item--active')
|
||||
}
|
||||
|
||||
actions.appendChild(
|
||||
this.createActionButton(
|
||||
'pin',
|
||||
session.isPinned ? 'ri-pushpin-fill' : 'ri-pushpin-line',
|
||||
session.isPinned
|
||||
)
|
||||
)
|
||||
actions.appendChild(this.createActionButton('rename', 'ri-edit-line'))
|
||||
actions.appendChild(this.createActionButton('model', 'ri-brain-line'))
|
||||
actions.appendChild(this.createActionButton('delete', 'ri-delete-bin-line'))
|
||||
|
||||
item.appendChild(title)
|
||||
item.appendChild(meta)
|
||||
item.appendChild(actions)
|
||||
|
||||
return item
|
||||
}
|
||||
|
||||
createActionButton(action, iconClassName, isPressed = false) {
|
||||
const button = createElement('button', 'session-action')
|
||||
const icon = createElement('i', iconClassName)
|
||||
|
||||
button.type = 'button'
|
||||
button.setAttribute('data-action', action)
|
||||
button.setAttribute('aria-label', action)
|
||||
button.setAttribute('aria-pressed', String(isPressed))
|
||||
button.appendChild(icon)
|
||||
|
||||
return button
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { createElement } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
|
||||
import { WidgetWrapper, List, ListHeader, ListItem } from '@aurora'
|
||||
|
||||
export default function handleSuggestions(data, chatbot, client) {
|
||||
const container = document.createElement('div')
|
||||
container.className = 'bubble-container leon'
|
||||
|
||||
chatbot.feed.appendChild(container)
|
||||
|
||||
const root = createRoot(container)
|
||||
|
||||
root.render(
|
||||
createElement(WidgetWrapper, {
|
||||
noPadding: true,
|
||||
children: createElement(List, {
|
||||
children: [
|
||||
createElement(ListHeader, {
|
||||
children: 'Suggestions'
|
||||
}),
|
||||
...data.map((suggestionText) => {
|
||||
return createElement(ListItem, {
|
||||
children: suggestionText,
|
||||
name: 'suggestion',
|
||||
value: suggestionText,
|
||||
onClick: (suggestion) => {
|
||||
const parent = container.parentNode
|
||||
|
||||
if (parent) {
|
||||
parent.removeChild(container)
|
||||
}
|
||||
|
||||
client.input.value = suggestion.value
|
||||
client.send('utterance')
|
||||
}
|
||||
})
|
||||
})
|
||||
]
|
||||
})
|
||||
})
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,74 @@
|
||||
const STATUS = {
|
||||
listening: 'Listening...',
|
||||
processing: 'Processing...',
|
||||
talking: 'Talking...',
|
||||
idle: 'Idle'
|
||||
}
|
||||
|
||||
export default class VoiceEnergy {
|
||||
constructor(client) {
|
||||
this.client = client
|
||||
this.voiceEnergyContainerElement = document.querySelector(
|
||||
'#voice-energy-container'
|
||||
)
|
||||
this.voiceOverlayElement = document.querySelector('#voice-overlay-bg')
|
||||
this.statusElement = document.querySelector('#voice-status')
|
||||
this._status = 'idle'
|
||||
}
|
||||
|
||||
get status() {
|
||||
return this._status
|
||||
}
|
||||
|
||||
set status(newStatus) {
|
||||
if (this._status !== newStatus) {
|
||||
this._status = newStatus
|
||||
|
||||
if (this.statusElement) {
|
||||
this.statusElement.textContent = STATUS[newStatus]
|
||||
}
|
||||
|
||||
// Clean up speech text when listening
|
||||
if (newStatus === 'listening' && this.client.voiceSpeechElement) {
|
||||
this.client.voiceSpeechElement.textContent = ''
|
||||
}
|
||||
|
||||
if (this.voiceEnergyContainerElement) {
|
||||
this.voiceEnergyContainerElement.className = ''
|
||||
this.voiceEnergyContainerElement.classList.add(newStatus)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
init() {
|
||||
if (this.voiceEnergyContainerElement) {
|
||||
if (this.voiceOverlayElement) {
|
||||
this.voiceOverlayElement.addEventListener('click', (e) => {
|
||||
e.preventDefault()
|
||||
this.client.disableVoiceMode()
|
||||
})
|
||||
}
|
||||
|
||||
const particles = new Set()
|
||||
const particleColors = ['blue', 'pink']
|
||||
|
||||
for (let i = 0; i < 32; i += 1) {
|
||||
const particle = document.createElement('div')
|
||||
const randomColor = Math.floor(Math.random() * 2)
|
||||
let random = Math.floor(Math.random() * 32)
|
||||
|
||||
while (particles.has(random)) {
|
||||
random = Math.floor(Math.random() * 32)
|
||||
}
|
||||
|
||||
particles.add(random)
|
||||
particle.setAttribute('data-particle', String(random))
|
||||
particle.classList.add('voice-particle', particleColors[randomColor])
|
||||
particle.style.transform = `rotate(${
|
||||
i * 11.25
|
||||
}deg) translate(110px) rotate(-${i * 11.25}deg)`
|
||||
this.voiceEnergyContainerElement.appendChild(particle)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"jsx": "react-jsx",
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["./src/**/*", "../aurora/src/**/*", "../aurora/global.d.ts"],
|
||||
"exclude": ["./dist", "../node_modules"]
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import dns from 'node:dns'
|
||||
import fs from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import dotenv from 'dotenv'
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import YAML from 'yaml'
|
||||
|
||||
import {
|
||||
PROFILE_CONFIG_PATH,
|
||||
PROFILE_DOT_ENV_PATH
|
||||
} from '../server/src/leon-roots.ts'
|
||||
|
||||
const APP_DEV_SERVER_PORT = 5_173
|
||||
const NODE_MODULES_PATH_SEGMENT = '/node_modules/'
|
||||
const REACT_VENDOR_PACKAGES = ['react', 'react-dom', 'scheduler']
|
||||
const REALTIME_VENDOR_PACKAGES = [
|
||||
'socket.io-client',
|
||||
'socket.io-parser',
|
||||
'engine.io-client',
|
||||
'engine.io-parser'
|
||||
]
|
||||
const UI_VENDOR_PACKAGE_PREFIXES = ['@ark-ui/', '@zag-js/', '@floating-ui/']
|
||||
|
||||
dotenv.config({ path: PROFILE_DOT_ENV_PATH })
|
||||
|
||||
dns.setDefaultResultOrder('verbatim')
|
||||
|
||||
function readAppLeonConfig() {
|
||||
if (!fs.existsSync(PROFILE_CONFIG_PATH)) {
|
||||
throw new Error(`Profile config file not found at "${PROFILE_CONFIG_PATH}".`)
|
||||
}
|
||||
|
||||
const config = YAML.parse(fs.readFileSync(PROFILE_CONFIG_PATH, 'utf8'))
|
||||
const server = config?.server
|
||||
|
||||
if (
|
||||
!server ||
|
||||
typeof server.host !== 'string' ||
|
||||
!Number.isInteger(server.port)
|
||||
) {
|
||||
throw new Error(
|
||||
`Profile config file "${PROFILE_CONFIG_PATH}" must define server.host and server.port.`
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
host: server.host,
|
||||
port: server.port
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeModuleId(moduleId) {
|
||||
return moduleId.replaceAll('\\', '/')
|
||||
}
|
||||
|
||||
function isNodeModule(moduleId) {
|
||||
return normalizeModuleId(moduleId).includes(NODE_MODULES_PATH_SEGMENT)
|
||||
}
|
||||
|
||||
function includesVendorPackage(moduleId, packageName) {
|
||||
return normalizeModuleId(moduleId).includes(
|
||||
`${NODE_MODULES_PATH_SEGMENT}${packageName}/`
|
||||
)
|
||||
}
|
||||
|
||||
function includesVendorPackagePrefix(moduleId, packagePrefix) {
|
||||
return normalizeModuleId(moduleId).includes(
|
||||
`${NODE_MODULES_PATH_SEGMENT}${packagePrefix}`
|
||||
)
|
||||
}
|
||||
|
||||
// Map necessary Leon's env vars as Vite only expose VITE_*
|
||||
const leonConfig = readAppLeonConfig()
|
||||
process.env.VITE_LEON_NODE_ENV = process.env.LEON_NODE_ENV
|
||||
process.env.VITE_LEON_HOST = leonConfig.host
|
||||
process.env.VITE_LEON_PORT = String(leonConfig.port)
|
||||
|
||||
export default defineConfig({
|
||||
root: 'app/src',
|
||||
resolve: {
|
||||
alias: [
|
||||
{
|
||||
find: '@aurora/style.css',
|
||||
replacement: fileURLToPath(
|
||||
new URL('../aurora/style.css', import.meta.url)
|
||||
)
|
||||
},
|
||||
{
|
||||
find: '@aurora',
|
||||
replacement: fileURLToPath(
|
||||
new URL('../aurora/src/index.ts', import.meta.url)
|
||||
)
|
||||
}
|
||||
]
|
||||
},
|
||||
build: {
|
||||
outDir: '../dist',
|
||||
emptyOutDir: true,
|
||||
rolldownOptions: {
|
||||
output: {
|
||||
codeSplitting: {
|
||||
groups: [
|
||||
{
|
||||
name: 'react-vendor',
|
||||
test: (moduleId) =>
|
||||
REACT_VENDOR_PACKAGES.some((packageName) =>
|
||||
includesVendorPackage(moduleId, packageName)
|
||||
),
|
||||
priority: 30
|
||||
},
|
||||
{
|
||||
name: 'realtime-vendor',
|
||||
test: (moduleId) =>
|
||||
REALTIME_VENDOR_PACKAGES.some((packageName) =>
|
||||
includesVendorPackage(moduleId, packageName)
|
||||
),
|
||||
priority: 20
|
||||
},
|
||||
{
|
||||
name: 'ui-vendor',
|
||||
test: (moduleId) =>
|
||||
UI_VENDOR_PACKAGE_PREFIXES.some((packagePrefix) =>
|
||||
includesVendorPackagePrefix(moduleId, packagePrefix)
|
||||
),
|
||||
priority: 10
|
||||
},
|
||||
{
|
||||
name: 'vendor',
|
||||
test: isNodeModule,
|
||||
priority: 0
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
server: {
|
||||
port: APP_DEV_SERVER_PORT
|
||||
},
|
||||
plugins: [react()]
|
||||
})
|
||||
Reference in New Issue
Block a user