chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:44:08 +08:00
commit 983960e2dd
1244 changed files with 281996 additions and 0 deletions
+220
View File
@@ -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;
}
}
+69
View File
@@ -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;
}
+863
View File
@@ -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;
}
}
+418
View File
@@ -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;
}
}
+8
View File
@@ -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
);
+162
View File
@@ -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);
}
+61
View File
@@ -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);
}
}
+61
View File
@@ -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);
}
}
+5
View File
@@ -0,0 +1,5 @@
@use 'base';
@use 'listening';
@use 'idle';
@use 'processing';
@use 'talking';
+88
View File
@@ -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);
}
}
}
}
+82
View File
@@ -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

+18
View File
@@ -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

+1
View File
@@ -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

+366
View File
@@ -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>
+7
View File
@@ -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
}
}
+3
View File
@@ -0,0 +1,3 @@
import '../../css/built-in-commands.scss'
export { default as BuiltInCommands } from './built-in-commands.jsx'
+263
View File
@@ -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>
)
}
+957
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
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)
})
}
}
+651
View File
@@ -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')
}
}
}
}
+22
View File
@@ -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.'
+386
View File
@@ -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')
}
}
+252
View File
@@ -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 />)
+44
View File
@@ -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
+213
View File
@@ -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)
}
})
+38
View File
@@ -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 }
+63
View File
@@ -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)
}
}
}
+56
View File
@@ -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)
}
}
+288
View File
@@ -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
}
}
+43
View File
@@ -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
+74
View File
@@ -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.
+9
View File
@@ -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"]
}
+143
View File
@@ -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()]
})