feat: add api key auth, devii agent, openai gateway, and admin service management

This commit introduces a comprehensive set of new features including API key authentication with CLI management commands (get, reset, backfill), a Devii agentic assistant with WebSocket terminal and session bootstrap, an OpenAI-compatible LLM gateway service, and an admin service management panel. It also adds Playwright browser automation for bot support, configures internal gateway URLs, refactors content editing/deletion to support JSON API responses, and updates documentation across AGENTS.md, README.md, and the developer docs site.
This commit is contained in:
2026-06-08 15:38:33 +00:00
parent 921e382cbc
commit e0535bb7c5
270 changed files with 54408 additions and 541 deletions
+48
View File
@@ -0,0 +1,48 @@
/* retoor <retoor@molodetz.nl> */
.ai-usage-controls {
display: flex;
gap: 16px;
align-items: center;
flex-wrap: wrap;
}
.ai-usage-controls label {
font-size: 0.8125rem;
color: var(--text-secondary);
}
.ai-usage-generated {
margin-left: auto;
font-size: 0.75rem;
color: var(--text-secondary);
}
.ai-usage-section {
margin: 20px 0;
}
.ai-usage-section h3 {
margin: 0 0 8px;
font-size: 0.9375rem;
color: var(--text-primary);
}
.ai-usage-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 12px;
}
.ai-usage .ai-divided {
display: none;
}
.ai-usage.show-divided .ai-divided {
display: block;
}
.pset-table td:first-child,
.pset-table th:first-child {
font-weight: 600;
}
+15 -1
View File
@@ -923,7 +923,7 @@ button.comment-form-submit:hover {
margin: 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
justify-content: center;
font-size: 0.8125rem;
}
.footer-copy {
@@ -931,6 +931,8 @@ button.comment-form-submit:hover {
}
.footer-links {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 1rem;
}
.footer-links a {
@@ -1101,3 +1103,15 @@ button.comment-form-submit:hover {
color: var(--warning);
font-weight: 700;
}
[aria-disabled="true"],
.login-required {
cursor: not-allowed;
opacity: 0.55;
}
.login-hint {
font-size: 0.8125rem;
color: var(--text-muted);
text-decoration: underline;
}
+15
View File
@@ -0,0 +1,15 @@
/* retoor <retoor@molodetz.nl> */
devii-avatar {
/* Devii floats ABOVE the terminal window (which sits at --devii-z = 2147483000)
so it is never hidden behind a maximized or fullscreen terminal. */
--devii-avatar-z: 2147483646;
display: block;
width: 0;
height: 0;
}
.md-clippy-agent,
.md-clippy-balloon {
z-index: var(--devii-avatar-z, 2147483646) !important;
}
+528
View File
@@ -0,0 +1,528 @@
/* retoor <retoor@molodetz.nl> */
devii-terminal {
--devii-bg: #000000;
--devii-fg: #d0d0d0;
--devii-dim: #6a737d;
--devii-user: #58a6ff;
--devii-agent: #e6edf3;
--devii-error: #ff6b6b;
--devii-task: #d2a8ff;
--devii-accent: #3fb950;
--devii-bar-bg: #0b0b0b;
--devii-border: #1c1f24;
--devii-code-bg: #11151a;
--devii-link: #58a6ff;
--devii-shadow: 0 18px 60px rgba(0, 0, 0, 0.6);
--devii-radius: 10px;
--devii-font: "SFMono-Regular", "JetBrains Mono", "Fira Code", Consolas, "Courier New", monospace;
--devii-font-size: 14px;
--devii-z: 2147483000;
position: fixed;
inset: 0;
pointer-events: none;
z-index: var(--devii-z);
font-family: var(--devii-font);
font-size: var(--devii-font-size);
}
/* Launcher FAB (shown only when closed) */
devii-terminal .devii-launcher {
position: fixed;
right: 20px;
bottom: 20px;
pointer-events: auto;
display: none;
align-items: center;
gap: 8px;
background: var(--devii-bar-bg);
color: var(--devii-accent);
border: 1px solid var(--devii-border);
border-radius: 24px;
padding: 10px 18px;
font-family: inherit;
font-size: 14px;
cursor: pointer;
box-shadow: var(--devii-shadow);
}
devii-terminal .devii-launcher:hover {
border-color: var(--devii-accent);
}
devii-terminal .devii-launcher-dot {
width: 9px;
height: 9px;
border-radius: 50%;
background: var(--devii-accent);
box-shadow: 0 0 8px var(--devii-accent);
}
/* Window */
devii-terminal .devii-window {
position: fixed;
pointer-events: auto;
display: none;
flex-direction: column;
overflow: hidden;
min-width: 300px;
min-height: 220px;
max-width: 100vw;
max-height: 100vh;
background: var(--devii-bg);
color: var(--devii-fg);
border: 1px solid var(--devii-border);
border-radius: var(--devii-radius);
box-shadow: var(--devii-shadow);
line-height: 1.5;
animation: devii-pop 0.16s ease;
transition: top 0.18s ease, left 0.18s ease, width 0.18s ease, height 0.18s ease;
}
@keyframes devii-pop {
from {
opacity: 0;
transform: scale(0.97);
}
to {
opacity: 1;
transform: scale(1);
}
}
devii-terminal.devii-dragging .devii-window {
transition: none;
user-select: none;
}
/* State visibility */
devii-terminal.devii-state-closed .devii-launcher {
display: none;
}
devii-terminal.devii-state-normal .devii-window,
devii-terminal.devii-state-maximized .devii-window,
devii-terminal.devii-state-fullscreen .devii-window {
display: flex;
}
devii-terminal.devii-state-normal .devii-window {
resize: both;
}
devii-terminal.devii-state-normal .devii-titlebar {
cursor: move;
}
devii-terminal.devii-state-maximized .devii-window {
top: 12px;
left: 12px;
right: 12px;
bottom: 12px;
width: auto;
height: auto;
resize: none;
}
devii-terminal.devii-state-fullscreen .devii-window {
top: 0;
left: 0;
right: 0;
bottom: 0;
width: 100%;
height: 100%;
border: none;
border-radius: 0;
resize: none;
}
/* Title bar */
devii-terminal .devii-titlebar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 10px 8px 12px;
background: var(--devii-bar-bg);
border-bottom: 1px solid var(--devii-border);
flex: 0 0 auto;
user-select: none;
}
devii-terminal .devii-title {
display: flex;
align-items: center;
gap: 8px;
color: var(--devii-accent);
font-weight: bold;
letter-spacing: 0.5px;
}
devii-terminal .devii-dot {
width: 9px;
height: 9px;
border-radius: 50%;
background: var(--devii-accent);
}
devii-terminal .devii-winctl button {
background: transparent;
color: var(--devii-dim);
border: none;
width: 28px;
height: 24px;
margin-left: 2px;
border-radius: 5px;
font-family: inherit;
font-size: 14px;
cursor: pointer;
}
devii-terminal .devii-winctl button:hover {
color: var(--devii-fg);
background: #1b1f25;
}
devii-terminal .devii-winctl button[data-win="close"]:hover {
background: #c23b3b;
color: #fff;
}
/* Toolbar */
devii-terminal .devii-toolbar {
display: flex;
gap: 6px;
padding: 6px 10px;
background: #060606;
border-bottom: 1px solid var(--devii-border);
flex: 0 0 auto;
}
devii-terminal .devii-toolbar button {
background: transparent;
color: var(--devii-dim);
border: 1px solid var(--devii-border);
border-radius: 4px;
padding: 3px 8px;
font-family: inherit;
font-size: 12px;
cursor: pointer;
}
devii-terminal .devii-toolbar button:hover {
color: var(--devii-fg);
border-color: var(--devii-dim);
}
/* Body */
devii-terminal .devii-output {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
padding: 12px 14px;
word-break: break-word;
user-select: text;
-webkit-user-select: text;
cursor: text;
}
devii-terminal .devii-titlebar,
devii-terminal .devii-toolbar,
devii-terminal .devii-launcher,
devii-terminal .devii-prompt {
user-select: none;
-webkit-user-select: none;
}
devii-terminal .devii-line {
margin: 2px 0;
white-space: pre-wrap;
}
devii-terminal .devii-user {
color: var(--devii-user);
}
devii-terminal .devii-agent {
color: var(--devii-agent);
white-space: normal;
margin: 8px 0;
}
devii-terminal .devii-notice {
color: var(--devii-dim);
}
devii-terminal .devii-trace {
color: var(--devii-dim);
font-size: 13px;
}
devii-terminal .devii-error {
color: var(--devii-error);
}
devii-terminal .devii-task {
color: var(--devii-task);
}
devii-terminal .devii-inputrow {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
border-top: 1px solid var(--devii-border);
background: var(--devii-bar-bg);
flex: 0 0 auto;
}
devii-terminal .devii-prompt {
color: var(--devii-accent);
flex: 0 0 auto;
}
devii-terminal .devii-input {
flex: 1 1 auto;
background: transparent;
border: none;
outline: none;
color: var(--devii-fg);
font-family: inherit;
font-size: inherit;
}
/* Markdown */
devii-terminal .devii-agent h1,
devii-terminal .devii-agent h2,
devii-terminal .devii-agent h3,
devii-terminal .devii-agent h4,
devii-terminal .devii-agent h5,
devii-terminal .devii-agent h6 {
margin: 0.4em 0 0.2em;
color: #fff;
line-height: 1.3;
}
devii-terminal .devii-agent p {
margin: 0.3em 0;
}
devii-terminal .devii-agent a {
color: var(--devii-link);
}
devii-terminal .devii-agent ul,
devii-terminal .devii-agent ol {
margin: 0.3em 0;
padding-left: 1.4em;
}
devii-terminal .devii-agent blockquote {
margin: 0.3em 0;
padding-left: 10px;
border-left: 3px solid var(--devii-border);
color: var(--devii-dim);
}
devii-terminal .devii-agent hr {
border: none;
border-top: 1px solid var(--devii-border);
margin: 0.6em 0;
}
devii-terminal .md-code {
background: var(--devii-code-bg);
padding: 1px 5px;
border-radius: 4px;
color: #7ee787;
}
devii-terminal .md-codewrap {
position: relative;
}
devii-terminal .md-pre {
background: var(--devii-code-bg);
padding: 10px 12px;
border-radius: 6px;
border: 1px solid var(--devii-border);
overflow-x: auto;
margin: 0.5em 0;
}
devii-terminal .md-copy {
position: absolute;
top: 8px;
right: 8px;
opacity: 0.5;
transition: opacity 0.12s ease, border-color 0.12s ease, color 0.12s ease;
background: var(--devii-bar-bg);
color: var(--devii-fg);
border: 1px solid var(--devii-border);
border-radius: 5px;
padding: 2px 9px;
font-family: inherit;
font-size: 11px;
line-height: 1.5;
cursor: pointer;
user-select: none;
z-index: 2;
}
devii-terminal .md-codewrap:hover .md-copy,
devii-terminal .md-copy:focus-visible {
opacity: 1;
}
devii-terminal .md-copy:hover {
border-color: var(--devii-accent);
color: #fff;
}
devii-terminal .md-copy.md-copied {
color: var(--devii-accent);
border-color: var(--devii-accent);
opacity: 1;
}
/* Per-message copy (copies the markdown source of the whole message) */
devii-terminal .devii-agent,
devii-terminal .devii-user,
devii-terminal .devii-task {
position: relative;
}
devii-terminal .devii-copy {
position: absolute;
top: 3px;
right: 6px;
opacity: 0;
transition: opacity 0.12s ease, border-color 0.12s ease, color 0.12s ease;
background: var(--devii-bar-bg);
color: var(--devii-dim);
border: 1px solid var(--devii-border);
border-radius: 5px;
padding: 1px 8px;
font-family: inherit;
font-size: 10px;
line-height: 1.6;
cursor: pointer;
user-select: none;
z-index: 3;
}
devii-terminal .devii-agent:hover > .devii-copy,
devii-terminal .devii-user:hover > .devii-copy,
devii-terminal .devii-task:hover > .devii-copy,
devii-terminal .devii-copy:focus-visible {
opacity: 0.6;
}
devii-terminal .devii-copy:hover {
opacity: 1;
border-color: var(--devii-accent);
color: #fff;
}
devii-terminal .devii-copy.md-copied {
opacity: 1;
color: var(--devii-accent);
border-color: var(--devii-accent);
}
devii-terminal .md-pre code {
background: none;
padding: 0;
color: var(--devii-fg);
}
devii-terminal .tok-kw {
color: #ff7b72;
}
devii-terminal .tok-str {
color: #a5d6ff;
}
devii-terminal .tok-com {
color: var(--devii-dim);
font-style: italic;
}
devii-terminal .tok-num {
color: #f2cc60;
}
devii-terminal .tok-lit {
color: #d2a8ff;
}
devii-terminal .devii-agent .md-table {
border-collapse: collapse;
margin: 0.5em 0;
font-size: 0.82em;
line-height: 1.35;
max-width: 100%;
display: block;
overflow-x: auto;
}
devii-terminal .devii-agent .md-table th,
devii-terminal .devii-agent .md-table td {
border: 1px solid var(--devii-border);
padding: 3px 8px;
text-align: left;
vertical-align: top;
white-space: nowrap;
}
devii-terminal .devii-agent .md-table thead th {
background: var(--devii-bar-bg);
color: #fff;
font-weight: 600;
}
devii-terminal .devii-agent .md-table tbody tr:nth-child(even) {
background: rgba(255, 255, 255, 0.02);
}
devii-terminal .devii-agent .md-img {
max-width: 100%;
height: auto;
border-radius: 6px;
margin: 0.4em 0;
display: block;
}
/* Mobile: only close is available; window is forced fullscreen by JS */
devii-terminal.devii-mobile .devii-winctl button[data-win="maximize"],
devii-terminal.devii-mobile .devii-winctl button[data-win="fullscreen"] {
display: none;
}
@media (max-width: 640px) {
devii-terminal {
--devii-font-size: 13px;
}
devii-terminal .devii-launcher {
right: 14px;
bottom: 14px;
padding: 9px 16px;
}
devii-terminal .devii-winctl button {
width: 34px;
height: 30px;
font-size: 16px;
}
}
/* Devii client-side tutorial overlays (attached to document.body) */
.devii-hl-box {
position: fixed;
z-index: 2147483600;
border-radius: 6px;
pointer-events: none;
box-shadow: 0 0 0 2px rgba(63, 185, 80, 0.9), 0 0 18px 4px rgba(63, 185, 80, 0.55);
transition: left 0.15s ease, top 0.15s ease, width 0.15s ease, height 0.15s ease;
}
.devii-hl-callout {
position: fixed;
z-index: 2147483601;
max-width: 280px;
background: #0b0b0b;
color: #e6edf3;
border: 1px solid #3fb950;
border-radius: 8px;
padding: 8px 12px;
font-size: 13px;
line-height: 1.45;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.5);
pointer-events: none;
}
.devii-toast {
position: fixed;
z-index: 2147483602;
left: 50%;
bottom: 32px;
transform: translateX(-50%) translateY(12px);
background: #0b0b0b;
color: #e6edf3;
border: 1px solid #3fb950;
border-radius: 10px;
padding: 10px 18px;
font-size: 14px;
max-width: 80vw;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.55);
opacity: 0;
transition: opacity 0.2s ease, transform 0.2s ease;
pointer-events: none;
}
.devii-toast-show {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
+659
View File
@@ -0,0 +1,659 @@
.docs-layout {
display: grid;
grid-template-columns: var(--sidebar-width, 240px) 1fr;
gap: 1.5rem;
align-items: start;
max-width: var(--max-content, 1200px);
margin: 0 auto;
padding: 1.5rem 1rem;
}
.docs-main {
min-width: 0;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1.5rem 2rem;
}
.docs-content {
line-height: 1.65;
}
.docs-content h1 {
margin-top: 0;
}
.docs-content h2 {
margin-top: 2rem;
padding-top: 1rem;
border-top: 1px solid var(--border);
}
/* Restore list indentation lost to the global `* { padding: 0 }` reset in base.css
(mirrors markdown.css's .rendered-content rules) so bullets/numbers sit correctly. */
.docs-content ul,
.docs-content ol {
margin: 0.5rem 0;
padding-left: 1.5rem;
}
.docs-content li {
margin-bottom: 0.25rem;
}
@media (max-width: 480px) {
.docs-content ul,
.docs-content ol {
padding-left: 1.125rem;
}
}
.docs-content pre {
overflow-x: auto;
}
.docs-content pre {
position: relative;
}
.code-copy-btn {
position: absolute;
top: 0.5rem;
right: 0.5rem;
padding: 0.2rem 0.6rem;
font-size: 0.75rem;
color: var(--text-secondary);
background: var(--bg-card);
border: 1px solid var(--border-light);
border-radius: var(--radius);
cursor: pointer;
opacity: 0;
transition: opacity 0.15s ease;
}
.docs-content pre:hover .code-copy-btn,
.code-block pre:hover .code-copy-btn,
.code-copy-btn:focus {
opacity: 1;
}
.endpoint-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 1.25rem 1.5rem;
margin: 1.5rem 0;
}
.endpoint-head {
display: flex;
align-items: center;
gap: 0.625rem;
flex-wrap: wrap;
}
.endpoint-title {
margin: 0.75rem 0 0.25rem;
font-size: 1.0625rem;
}
.endpoint-summary {
margin: 0 0 0.75rem;
color: var(--text-secondary);
}
.endpoint-path {
font-family: "SF Mono", Monaco, "Cascadia Code", monospace;
font-size: 0.875rem;
color: var(--text-primary);
background: var(--bg-input);
padding: 0.25rem 0.5rem;
border-radius: var(--radius);
border: 1px solid var(--border);
}
.method-badge,
.auth-badge {
font-size: 0.6875rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 0.2rem 0.5rem;
border-radius: 999px;
}
.method-badge {
color: #fff;
}
.method-get { background: var(--info); }
.method-post { background: var(--success); }
.method-delete { background: var(--danger); }
.method-put { background: var(--warning); }
.auth-badge {
border: 1px solid var(--border-light);
}
.auth-public { color: var(--text-muted); }
.auth-user { color: var(--accent); border-color: var(--accent); }
.auth-admin { color: var(--danger); border-color: var(--danger); }
.api-tester {
margin-top: 0.75rem;
}
.param-table {
display: flex;
flex-direction: column;
gap: 0.625rem;
margin-bottom: 1rem;
}
.param-row {
display: grid;
grid-template-columns: 180px 1fr;
gap: 0.75rem;
align-items: start;
}
.param-label {
display: flex;
align-items: center;
gap: 0.375rem;
flex-wrap: wrap;
padding-top: 0.4rem;
}
.param-name {
font-family: "SF Mono", Monaco, "Cascadia Code", monospace;
font-size: 0.8125rem;
color: var(--text-primary);
}
.param-required {
color: var(--accent);
font-weight: 700;
}
.param-loc {
font-size: 0.625rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-muted);
border: 1px solid var(--border);
border-radius: 999px;
padding: 0.05rem 0.4rem;
}
.param-meta {
min-width: 0;
}
.param-desc {
font-size: 0.75rem;
color: var(--text-muted);
margin-top: 0.25rem;
}
.param-allowed {
font-size: 0.75rem;
color: var(--text-muted);
margin-top: 0.25rem;
overflow-wrap: anywhere;
}
.param-allowed-label {
font-weight: 600;
}
.param-allowed-values {
font-family: var(--font-mono, monospace);
}
.param-input {
width: 100%;
padding: 0.45rem 0.6rem;
font-size: 0.8125rem;
color: var(--text-primary);
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: var(--radius);
font-family: inherit;
}
textarea.param-input {
font-family: "SF Mono", Monaco, "Cascadia Code", monospace;
resize: vertical;
}
.param-input:focus {
outline: none;
border-color: var(--accent);
}
.auth-picker {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.75rem;
margin-bottom: 1rem;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius);
}
.auth-field {
display: grid;
grid-template-columns: 80px 1fr;
gap: 0.5rem;
align-items: center;
}
.auth-field label {
font-size: 0.75rem;
color: var(--text-muted);
}
.auth-field.auth-basic {
grid-template-columns: 80px 1fr 80px 1fr;
}
.code-block {
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
margin-bottom: 0.75rem;
}
.code-tabs {
display: flex;
gap: 0.25rem;
padding: 0.4rem 0.5rem 0;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
}
.code-tab {
padding: 0.35rem 0.75rem;
font-size: 0.75rem;
color: var(--text-secondary);
background: transparent;
border: none;
border-bottom: 2px solid transparent;
cursor: pointer;
}
.code-tab.active {
color: var(--accent);
border-bottom-color: var(--accent);
}
.code-panel {
margin: 0;
border-radius: 0;
}
.endpoint-notes {
font-size: 0.8125rem;
color: var(--text-secondary);
margin-bottom: 0.75rem;
}
.try-panel {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 0.5rem;
}
.try-note {
font-size: 0.8125rem;
color: var(--text-muted);
}
.response-viewer {
margin-top: 0.75rem;
padding: 0.75rem;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius);
}
.response-meta {
display: flex;
align-items: center;
gap: 0.75rem;
font-size: 0.75rem;
color: var(--text-muted);
margin-bottom: 0.5rem;
}
.response-status {
font-weight: 700;
padding: 0.15rem 0.5rem;
border-radius: var(--radius);
color: #fff;
}
.response-2xx { background: var(--success); }
.response-3xx { background: var(--info); }
.response-4xx { background: var(--warning); }
.response-5xx { background: var(--danger); }
.response-redirect {
font-size: 0.8125rem;
color: var(--text-secondary);
margin: 0.25rem 0;
}
.response-body {
margin: 0.25rem 0 0;
max-height: 360px;
overflow: auto;
}
.format-picker {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.5rem;
margin-bottom: 0.75rem;
font-size: 0.8125rem;
}
.format-label {
color: var(--text-muted);
font-weight: 600;
}
.format-options {
display: inline-flex;
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
}
.format-option {
padding: 0.3rem 0.85rem;
font-size: 0.75rem;
background: var(--bg-secondary);
color: var(--text-secondary);
border: none;
cursor: pointer;
}
.format-option + .format-option {
border-left: 1px solid var(--border);
}
.format-option.active {
background: var(--accent);
color: #fff;
}
.format-fixed {
padding: 0.2rem 0.6rem;
border-radius: var(--radius);
background: var(--bg-secondary);
border: 1px solid var(--border);
color: var(--text-secondary);
font-weight: 600;
}
.format-note {
color: var(--text-muted);
}
.response-tabs {
display: flex;
gap: 0.25rem;
margin-bottom: 0.5rem;
border-bottom: 1px solid var(--border);
}
.response-pane {
display: none;
}
.response-pane.active {
display: block;
}
.response-note {
font-size: 0.8125rem;
color: var(--text-secondary);
margin: 0 0 0.5rem;
}
.response-placeholder {
font-size: 0.8125rem;
color: var(--text-muted);
margin: 0;
}
@media (max-width: 768px) {
.docs-layout {
grid-template-columns: 1fr;
}
.docs-main {
padding: 1rem 1.25rem;
}
.param-row {
grid-template-columns: 1fr;
gap: 0.25rem;
}
.auth-field,
.auth-field.auth-basic {
grid-template-columns: 1fr;
}
.code-copy-btn {
opacity: 1;
}
}
/* ---- Unified code blocks: highlight + line numbers + copy (widget tabs, response, prose) ---- */
pre.code-pre {
position: relative;
display: flex;
align-items: stretch;
padding: 0;
margin: 0 0 0.75rem;
overflow: hidden;
background: #282c34;
border: 1px solid var(--border);
border-radius: var(--radius);
font-family: "SF Mono", Monaco, "Cascadia Code", "JetBrains Mono", monospace;
font-size: 0.8125rem;
line-height: 1.55;
}
.code-block pre.code-pre.code-panel {
border: none;
border-radius: 0;
margin: 0;
}
pre.code-pre > code {
display: block;
flex: 1 1 auto;
min-width: 0;
overflow-x: auto;
padding: 0.75rem 1rem;
white-space: pre;
background: transparent;
font: inherit;
line-height: inherit;
}
pre.code-pre > .code-gutter {
flex: 0 0 auto;
padding: 0.75rem 0.6rem;
text-align: right;
white-space: pre;
user-select: none;
color: #5c6370;
background: rgba(0, 0, 0, 0.22);
border-right: 1px solid rgba(255, 255, 255, 0.08);
font: inherit;
line-height: inherit;
font-variant-numeric: tabular-nums;
}
pre.code-has-copy {
position: relative;
}
pre.code-has-copy > .code-copy-btn {
position: absolute;
top: 0.4rem;
right: 0.4rem;
z-index: 1;
padding: 0.2rem 0.6rem;
font-size: 0.7rem;
color: #abb2bf;
background: rgba(40, 44, 52, 0.9);
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: var(--radius);
cursor: pointer;
opacity: 1;
}
pre.code-has-copy > .code-copy-btn:hover {
color: #fff;
border-color: var(--accent, #6366f1);
}
/* ---- Docs search ---- */
.docs-search-form {
margin: 0.5rem 0 0.75rem;
}
.docs-search-input {
width: 100%;
padding: 0.45rem 0.6rem;
font-size: 0.875rem;
color: var(--text-primary);
background: var(--bg-input, var(--bg-card));
border: 1px solid var(--border);
border-radius: var(--radius);
font-family: inherit;
}
.docs-search-input:focus {
outline: none;
border-color: var(--accent, #6366f1);
}
.docs-search-bigform {
display: flex;
gap: 0.5rem;
margin: 0.5rem 0 1.25rem;
}
.docs-search-bigform .docs-search-input {
flex: 1 1 auto;
}
.docs-search-count,
.docs-search-empty,
.docs-search-hint {
color: var(--text-secondary);
font-size: 0.875rem;
margin-bottom: 1rem;
}
.docs-search-results {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 1rem;
}
.docs-search-result {
padding-bottom: 1rem;
border-bottom: 1px solid var(--border);
}
.docs-search-result-title {
font-size: 1.0625rem;
font-weight: 600;
color: var(--accent, #6366f1);
}
.docs-search-snippet {
margin: 0.35rem 0 0;
color: var(--text-secondary);
font-size: 0.875rem;
line-height: 1.55;
}
.docs-search-snippet mark {
background: var(--accent, #6366f1);
color: #fff;
padding: 0 2px;
border-radius: 3px;
}
.docs-download-heading {
margin-top: 1.25rem;
}
.devii-doc-hero {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-xl);
padding: 2rem 1.75rem;
margin-bottom: 1.5rem;
text-align: center;
}
.devii-doc-hero-mark {
font-size: 2.75rem;
line-height: 1;
margin-bottom: 0.5rem;
}
.devii-doc-hero h1 {
margin: 0 0 0.5rem;
font-size: 1.7rem;
color: var(--text-primary);
}
.devii-doc-hero p {
max-width: 46rem;
margin: 0 auto 1.25rem;
color: var(--text-secondary);
line-height: 1.6;
}
.devii-doc-hero-actions {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.6rem;
}
.devii-doc-hero-note {
color: var(--text-muted);
font-size: 0.85rem;
}
.btn-lg {
padding: 0.7rem 1.4rem;
font-size: 1rem;
}
.devii-doc-cta {
margin-top: 1.75rem;
padding-top: 1.5rem;
border-top: 1px solid var(--border);
text-align: center;
}
+10
View File
@@ -1,3 +1,13 @@
.gist-length-error {
margin-top: 0.5rem;
padding: 0.75rem;
background: rgba(229, 57, 53, 0.1);
border: 1px solid var(--danger);
border-radius: var(--radius);
font-size: 0.8125rem;
color: var(--danger);
}
.gists-layout {
display: grid;
grid-template-columns: var(--sidebar-width) 1fr;
+14
View File
@@ -154,6 +154,20 @@
margin-bottom: 0.5rem;
}
.comment-highlight {
animation: comment-highlight-fade 2.5s ease-out;
border-radius: 6px;
}
@keyframes comment-highlight-fade {
0% {
background-color: var(--accent-soft, rgba(99, 102, 241, 0.18));
}
100% {
background-color: transparent;
}
}
.comment-actions {
display: flex;
align-items: center;
+116 -2
View File
@@ -263,11 +263,27 @@ a.profile-stat-value:hover {
color: var(--text-secondary);
}
.heatmap-scroll {
overflow-x: auto;
padding-bottom: var(--space-xs);
}
.heatmap-inner {
display: inline-flex;
flex-direction: column;
gap: 4px;
}
.heatmap-months {
display: flex;
justify-content: space-between;
font-size: 0.7rem;
color: var(--text-secondary);
}
.heatmap-grid {
display: flex;
gap: 3px;
overflow-x: auto;
padding-bottom: var(--space-xs);
}
.heatmap-week {
@@ -298,3 +314,101 @@ a.profile-stat-value:hover {
.heatmap-cell.level-4 {
background: var(--accent);
}
.api-key-card {
margin-top: 1rem;
padding: 0.875rem;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg-elevated, var(--bg-card));
}
.api-key-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.5rem;
}
.api-key-title {
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-secondary);
}
.api-key-docs {
font-size: 0.75rem;
color: var(--accent);
}
.api-key-value {
display: block;
font-family: "JetBrains Mono", "Fira Code", monospace;
font-size: 0.8125rem;
word-break: break-all;
padding: 0.5rem;
background: var(--bg-code, #1a1a2e);
color: var(--text-code, #e0e0e0);
border-radius: 6px;
margin-bottom: 0.5rem;
}
.api-key-actions {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.api-key-hint {
margin-top: 0.5rem;
font-size: 0.75rem;
color: var(--text-secondary);
}
.follow-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.6rem 0.25rem;
border-bottom: 1px solid var(--border);
}
.follow-row:last-child {
border-bottom: none;
}
.follow-user {
display: flex;
align-items: center;
gap: 0.65rem;
min-width: 0;
flex: 1 1 auto;
}
.follow-user-info {
display: flex;
flex-direction: column;
min-width: 0;
}
.follow-user-name {
font-weight: 600;
color: var(--text-primary);
}
.follow-user-bio {
font-size: 0.85rem;
color: var(--text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.follow-pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 1rem;
margin-top: 1rem;
}
.follow-page-label {
color: var(--text-muted);
font-size: 0.85rem;
}
+257
View File
@@ -55,6 +55,75 @@
color: #fff;
}
.service-status.stalled {
background: var(--topic-question, #f59e0b);
color: #fff;
}
.service-controls {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 12px;
}
.service-btn {
font-size: 0.8125rem;
font-weight: 500;
padding: 4px 12px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg-elevated, var(--bg-card));
color: var(--text-primary);
cursor: pointer;
}
.service-btn:hover:not(:disabled) {
border-color: var(--accent, #6366f1);
}
.service-btn:disabled {
opacity: 0.5;
cursor: default;
}
.service-config {
border-top: 1px solid var(--border);
margin-bottom: 12px;
padding-top: 12px;
}
.config-heading {
font-size: 0.75rem;
font-weight: 600;
color: var(--text-secondary);
margin-bottom: 10px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.config-actions {
display: flex;
align-items: center;
gap: 12px;
}
.config-status {
font-size: 0.8125rem;
color: var(--text-secondary);
}
.field-error {
display: block;
color: var(--topic-discussion, #ef4444);
font-size: 0.75rem;
margin-top: 2px;
}
.field-error:empty {
display: none;
}
.service-meta {
display: flex;
flex-wrap: wrap;
@@ -64,6 +133,65 @@
color: var(--text-secondary);
}
.service-metrics:empty {
display: none;
}
.service-metrics {
margin-bottom: 12px;
}
.metric-cards {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
gap: 8px;
margin-bottom: 12px;
}
.metric-card {
display: flex;
flex-direction: column;
gap: 2px;
padding: 8px 10px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg-elevated, var(--bg-card));
}
.metric-value {
font-size: 1.125rem;
font-weight: 700;
color: var(--text-primary);
}
.metric-label {
font-size: 0.6875rem;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-secondary);
}
.metric-table {
width: 100%;
border-collapse: collapse;
font-size: 0.8125rem;
}
.metric-table th,
.metric-table td {
text-align: left;
padding: 4px 8px;
border-bottom: 1px solid var(--border);
}
.metric-table th {
color: var(--text-secondary);
font-weight: 600;
text-transform: uppercase;
font-size: 0.6875rem;
letter-spacing: 0.5px;
}
.meta-item {
white-space: nowrap;
}
@@ -119,3 +247,132 @@
white-space: normal;
}
}
/* ---- Services index (table) ---- */
.services-index .service-row-name {
font-weight: 600;
color: var(--text-primary);
}
.services-index .service-row-name:hover {
color: var(--accent, #6366f1);
}
.service-row-desc {
font-size: 0.8125rem;
color: var(--text-secondary);
margin-top: 2px;
max-width: 48ch;
}
/* ---- Service detail ---- */
.service-back {
display: inline-block;
font-size: 0.8125rem;
color: var(--text-secondary);
margin-bottom: 0.75rem;
}
.service-detail-head {
margin-bottom: 1rem;
}
.service-detail-title {
display: flex;
align-items: center;
gap: 0.75rem;
}
.service-detail-title h2 {
margin: 0;
}
.service-detail-desc {
color: var(--text-secondary);
margin: 0.5rem 0 0.75rem;
max-width: 70ch;
}
/* ---- Tabs ---- */
.svc-tabbar {
display: flex;
gap: 4px;
border-bottom: 1px solid var(--border);
margin-bottom: 1rem;
}
.svc-tab {
background: none;
border: none;
border-bottom: 2px solid transparent;
padding: 0.5rem 0.875rem;
font-size: 0.875rem;
font-weight: 500;
color: var(--text-secondary);
cursor: pointer;
}
.svc-tab:hover {
color: var(--text-primary);
}
.svc-tab.active {
color: var(--accent, #6366f1);
border-bottom-color: var(--accent, #6366f1);
}
.svc-tabpane {
display: none;
}
.svc-tabpane.active {
display: block;
}
/* ---- Overview meta grid ---- */
.svc-meta-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 10px;
margin-bottom: 1rem;
}
.svc-meta-item {
display: flex;
flex-direction: column;
gap: 2px;
padding: 8px 10px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg-elevated, var(--bg-card));
}
.svc-meta-label {
font-size: 0.6875rem;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-secondary);
}
.svc-meta-value {
font-size: 0.9375rem;
font-weight: 600;
color: var(--text-primary);
}
/* ---- Config sections ---- */
.config-section {
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1rem 1.25rem;
margin-bottom: 1rem;
}
.config-section legend {
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-secondary);
padding: 0 0.5rem;
}
+9
View File
@@ -16,6 +16,15 @@
margin-bottom: 0.75rem;
}
.sidebar-card .sidebar-subheading {
margin-top: 0.85rem;
color: var(--accent);
}
.sidebar-link-nested {
padding-left: 1.75rem;
}
.sidebar-section {
margin-bottom: 1rem;
}
+254
View File
@@ -0,0 +1,254 @@
// retoor <retoor@molodetz.nl>
import { Http } from "./Http.js";
class AiUsageMonitor {
constructor() {
this.root = document.getElementById("ai-usage-root");
this.windowSelect = document.getElementById("ai-usage-window");
this.dividedToggle = document.getElementById("ai-usage-divided");
this.generated = document.querySelector("[data-meta='generated']");
this.pollMs = 15000;
this.hours = 48;
}
start() {
if (!this.root) return;
if (this.windowSelect) {
this.windowSelect.addEventListener("change", () => {
this.hours = parseInt(this.windowSelect.value, 10) || 48;
this.poll();
});
}
if (this.dividedToggle) {
this.dividedToggle.addEventListener("change", () => {
this.root.classList.toggle("show-divided", this.dividedToggle.checked);
});
}
this.poll();
setInterval(() => this.poll(), this.pollMs);
}
async poll() {
try {
const data = await Http.getJson(`/admin/ai-usage/data?hours=${this.hours}`);
this.render(data);
} catch {
// silently ignore poll errors
}
}
el(tag, className, text) {
const node = document.createElement(tag);
if (className) node.className = className;
if (text !== undefined) node.textContent = String(text);
return node;
}
money(value) {
const number = Number(value || 0);
return number < 1 ? `$${number.toFixed(6)}` : `$${number.toFixed(2)}`;
}
integer(value) {
return Number(value || 0).toLocaleString("en-GB");
}
cards(items) {
const grid = this.el("div", "metric-cards");
for (const item of items) {
const card = this.el("div", "metric-card");
card.appendChild(this.el("span", "metric-value", item.value));
card.appendChild(this.el("span", "metric-label", item.label));
grid.appendChild(card);
}
return grid;
}
table(columns, rows, className) {
const node = this.el("table", className || "metric-table");
const thead = this.el("thead");
const headRow = this.el("tr");
for (const col of columns) headRow.appendChild(this.el("th", null, col));
thead.appendChild(headRow);
node.appendChild(thead);
const tbody = this.el("tbody");
for (const row of rows) {
const tr = this.el("tr");
for (const cell of row) tr.appendChild(this.el("td", null, cell));
tbody.appendChild(tr);
}
node.appendChild(tbody);
return node;
}
section(title, ...nodes) {
const wrap = this.el("div", "ai-usage-section");
wrap.appendChild(this.el("h3", null, title));
for (const node of nodes) if (node) wrap.appendChild(node);
return wrap;
}
psetTable(title, pset) {
const rows = [
["avg", this.integer(pset.avg)],
["p50", this.integer(pset.p50)],
["p90", this.integer(pset.p90)],
["p95", this.integer(pset.p95)],
["p99", this.integer(pset.p99)],
["max", this.integer(pset.max)],
];
const node = this.table([title, "value"], rows, "metric-table pset-table");
return node;
}
psetGrid(title, sets) {
const grid = this.el("div", "ai-usage-grid");
for (const [label, pset] of sets) {
const block = this.el("div");
block.appendChild(this.el("h3", null, label));
block.appendChild(this.psetTable("stat", pset));
grid.appendChild(block);
}
return this.section(title, grid);
}
groupRows(items) {
return (items || []).map((item) => [
item.key, this.integer(item.requests), this.integer(item.total_tokens), this.money(item.cost_usd),
]);
}
render(data) {
this.root.textContent = "";
if (this.generated && data.generated_at) {
this.generated.textContent = `Window: ${data.window_hours}h - updated ${data.generated_at.slice(0, 19).replace("T", " ")}`;
}
if (!data.requests) {
this.root.appendChild(this.el("p", "admin-empty", "No AI gateway traffic recorded in this window yet."));
return;
}
const v = data.volume || {};
const t = data.tokens || {};
const l = data.latency || {};
const e = data.errors || {};
const c = data.cost || {};
const b = data.behavior || {};
const conc = v.concurrency || {};
this.root.appendChild(this.section("Overview", this.cards([
{ label: "Requests", value: this.integer(v.requests) },
{ label: "Success", value: `${v.requests ? (100 * v.success / v.requests).toFixed(1) : 0}%` },
{ label: "Error rate", value: `${e.error_rate_pct}%` },
{ label: "Requests / hour", value: this.integer(v.requests_per_hour) },
{ label: "Peak req / min", value: this.integer(v.peak_requests_per_minute) },
{ label: "Cost (window)", value: this.money(c.total_usd) },
{ label: "Cost this hour", value: this.money(c.this_hour_usd) },
{ label: "Cost 24h", value: this.money(c.last_24h_usd) },
{ label: "Projected / month", value: this.money(c.projected_monthly_usd) },
{ label: "Tokens total", value: this.integer(t.total) },
{ label: "Upstream avail.", value: `${l.upstream_availability_pct}%` },
{ label: "Concurrency peak", value: this.integer(conc.peak) },
])));
this.root.appendChild(this.section("Tokens", this.cards([
{ label: "Prompt total", value: this.integer(t.prompt_total) },
{ label: "Completion total", value: this.integer(t.completion_total) },
{ label: "Total", value: this.integer(t.total) },
{ label: "Cache hit rate", value: `${((t.cache_hit_rate || 0) * 100).toFixed(1)}%` },
{ label: "Input:output", value: t.input_output_ratio },
{ label: "Cache hit tokens", value: this.integer(t.cache_hit_total) },
{ label: "Cache miss tokens", value: this.integer(t.cache_miss_total) },
{ label: "Reasoning tokens", value: this.integer(t.reasoning_total) },
{ label: "Tokens / hour", value: this.integer(t.tokens_per_hour) },
{ label: "Context util avg", value: t.context_utilization_avg === null || t.context_utilization_avg === undefined ? "n/a" : `${(t.context_utilization_avg * 100).toFixed(1)}%` },
])));
this.root.appendChild(this.psetGrid("Token distribution per request", [
["Prompt tokens", t.prompt || {}],
["Completion tokens", t.completion || {}],
["Total tokens", t.total_per_request || {}],
]));
this.root.appendChild(this.section("Latency and throughput", this.cards([
{ label: "Upstream availability", value: `${l.upstream_availability_pct}%` },
{ label: "Gateway overhead share", value: `${((l.gateway_overhead_share || 0) * 100).toFixed(1)}%` },
{ label: "TTFT", value: "n/a (non-streaming)" },
{ label: "Inter-token", value: "n/a (non-streaming)" },
])));
this.root.appendChild(this.psetGrid("Latency percentiles (ms)", [
["Upstream round-trip", l.upstream_ms || {}],
["Gateway overhead", l.gateway_overhead_ms || {}],
["Queue wait", l.queue_wait_ms || {}],
["Connection establish", l.connect_ms || {}],
["Total", l.total_ms || {}],
["Tokens / second", l.tokens_per_second || {}],
]));
this.root.appendChild(this.section("Errors and reliability", this.cards([
{ label: "Total errors", value: this.integer(e.total) },
{ label: "Error rate", value: `${e.error_rate_pct}%` },
{ label: "4xx", value: this.integer(e.count_4xx) },
{ label: "5xx", value: this.integer(e.count_5xx) },
{ label: "Timeouts", value: this.integer(e.timeouts) },
{ label: "Retries attempted", value: this.integer(e.retries_attempted) },
{ label: "Retries succeeded", value: this.integer(e.retries_succeeded) },
{ label: "Circuit opens", value: this.integer(e.circuit_open_events) },
]), this.table(["Error category", "Count"],
Object.entries(e.by_category || {}).map(([k, n]) => [k, this.integer(n)]))));
this.root.appendChild(this.section("Cost", this.cards([
{ label: "Total", value: this.money(c.total_usd) },
{ label: "Input cost", value: this.money(c.input_usd) },
{ label: "Output cost", value: this.money(c.output_usd) },
{ label: "Per 1k tokens", value: this.money(c.effective_per_1k_tokens_usd) },
{ label: "Caching savings", value: this.money(c.caching_savings_usd) },
{ label: "This hour", value: this.money(c.this_hour_usd) },
{ label: "Last 24h", value: this.money(c.last_24h_usd) },
{ label: "Projected / month", value: this.money(c.projected_monthly_usd) },
])));
this.root.appendChild(this.section("Behavior", this.cards([
{ label: "Unique callers", value: this.integer(b.unique_callers) },
{ label: "Req / caller avg", value: this.integer((b.requests_per_caller || {}).avg) },
{ label: "Req / caller p95", value: this.integer((b.requests_per_caller || {}).p95) },
{ label: "Heaviest caller", value: this.integer((b.requests_per_caller || {}).max) },
{ label: "Avg temperature", value: b.avg_temperature === null || b.avg_temperature === undefined ? "n/a" : b.avg_temperature },
{ label: "Avg top_p", value: b.avg_top_p === null || b.avg_top_p === undefined ? "n/a" : b.avg_top_p },
{ label: "Tool calls", value: this.integer(b.tool_call_requests) },
{ label: "Streaming", value: this.integer(b.streaming_requests) },
{ label: "Non-streaming", value: this.integer(b.non_streaming_requests) },
])));
const divided = this.el("div", "ai-divided");
divided.appendChild(this.section("By model",
this.table(["Model", "Requests", "Tokens", "Cost"], this.groupRows(v.by_model))));
divided.appendChild(this.section("By endpoint",
this.table(["Endpoint", "Requests", "Tokens", "Cost"], this.groupRows(v.by_endpoint))));
divided.appendChild(this.section("By backend",
this.table(["Backend", "Requests", "Tokens", "Cost"], this.groupRows(v.by_backend))));
divided.appendChild(this.section("By caller",
this.table(["Caller", "Requests", "Tokens", "Cost"], this.groupRows(v.by_caller))));
divided.appendChild(this.section("User agents",
this.table(["User agent", "Requests"],
(b.user_agents || []).map((item) => [item.key, this.integer(item.requests)]))));
this.root.appendChild(divided);
const hourlyRows = (data.hourly || []).map((h) => [
h.hour.replace("T", " ") + ":00",
this.integer(h.requests),
this.integer(h.success),
this.integer(h.failed),
this.integer(h.prompt_tokens),
this.integer(h.completion_tokens),
this.integer(h.total_tokens),
this.money(h.cost_usd),
this.integer(h.avg_latency_ms),
this.integer(h.active_owners),
this.integer(h.new_owners),
]);
this.root.appendChild(this.section("Hourly breakdown",
this.table(["Hour", "Requests", "OK", "Failed", "Prompt", "Completion", "Total", "Cost", "Avg ms", "Callers", "New"], hourlyRows)));
}
}
window.AiUsageMonitor = AiUsageMonitor;
+15
View File
@@ -0,0 +1,15 @@
import { ApiTester } from "./ApiTester.js";
import { CodeCopy } from "./CodeCopy.js";
export class ApiDocs {
constructor() {
this.copy = new CodeCopy();
const mounts = document.querySelectorAll("[data-api-tester]");
if (!mounts.length) return;
const ctx = window.DEVPLACE_DOCS || { base: location.origin, loggedIn: false, username: "", apiKey: "", isAdmin: false };
this.testers = [...mounts].map((mount) => new ApiTester(mount, ctx));
}
}
new ApiDocs();
window.ApiDocs = ApiDocs;
+80
View File
@@ -0,0 +1,80 @@
import { Http } from "./Http.js";
import { Toast } from "./Toast.js";
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
class ApiKeyManager {
constructor() {
this.card = document.querySelector("[data-api-key-card]");
if (!this.card) return;
this.valueEl = this.card.querySelector("[data-api-key]");
this.bindReveal();
this.bindCopy();
this.bindRegenerate();
}
get key() {
return this.valueEl ? this.valueEl.dataset.key || "" : "";
}
set key(value) {
if (this.valueEl) this.valueEl.dataset.key = value;
}
render(masked) {
if (!this.valueEl) return;
this.valueEl.dataset.masked = masked ? "true" : "false";
this.valueEl.textContent = masked ? "•".repeat(12) : this.key;
}
bindReveal() {
const btn = this.card.querySelector("[data-api-key-reveal]");
if (!btn) return;
btn.addEventListener("click", () => {
const masked = this.valueEl.dataset.masked !== "false";
this.render(!masked);
btn.textContent = masked ? "Hide" : "Reveal";
});
}
bindCopy() {
const btn = this.card.querySelector("[data-api-key-copy]");
if (!btn) return;
btn.addEventListener("click", async () => {
try {
await navigator.clipboard.writeText(this.key);
Toast.flash(btn, "Copied", 1500);
} catch {
Toast.flash(btn, "Copy failed", 1500);
}
});
}
bindRegenerate() {
const btn = this.card.querySelector("[data-regen-api-key]");
if (!btn) return;
btn.addEventListener("click", async () => {
if (!window.confirm("Regenerate your API key? The current key stops working immediately.")) return;
btn.disabled = true;
try {
const result = await Http.postJson("/profile/regenerate-api-key", {});
if (!result || !UUID_PATTERN.test(result.api_key || "")) {
Toast.flash(btn, "Invalid response", 2000, "Regenerate");
return;
}
this.key = result.api_key;
this.render(false);
const reveal = this.card.querySelector("[data-api-key-reveal]");
if (reveal) reveal.textContent = "Hide";
Toast.flash(btn, "Regenerated", 2000, "Regenerate");
} catch {
Toast.flash(btn, "Error", 2000, "Regenerate");
} finally {
btn.disabled = false;
}
});
}
}
window.ApiKeyManager = ApiKeyManager;
export { ApiKeyManager };
+586
View File
@@ -0,0 +1,586 @@
import { contentRenderer } from "./ContentRenderer.js";
import { CodeBlock } from "./CodeBlock.js";
const AUTH_METHODS = [
{ id: "apikey", label: "X-API-KEY header" },
{ id: "bearer", label: "Bearer token" },
{ id: "basic", label: "HTTP Basic" },
{ id: "session", label: "Session cookie" },
];
const LANG_TABS = [
{ id: "curl", label: "cURL", hl: "bash" },
{ id: "javascript", label: "JavaScript", hl: "javascript" },
{ id: "python", label: "Python", hl: "python" },
];
const FORMAT_OPTIONS = [
{ id: "json", label: "JSON", accept: "application/json" },
{ id: "html", label: "HTML", accept: "text/html" },
];
export class ApiTester {
constructor(mount, ctx) {
this.mount = mount;
this.ctx = ctx;
try {
this.config = JSON.parse(mount.dataset.config || "{}");
} catch {
return;
}
this.inputs = [];
this.authType = "apikey";
this.activeLang = "curl";
this.negotiation = this.config.negotiation || "json";
this.responseFormat = "json";
this.build();
this.renderSnippets();
this.renderExpected();
}
negotiates() {
return this.negotiation === "negotiable" || this.negotiation === "ajax";
}
el(tag, props = {}, children = []) {
const node = document.createElement(tag);
for (const [key, value] of Object.entries(props)) {
if (key === "class") node.className = value;
else if (key === "text") node.textContent = value;
else if (key === "html") node.innerHTML = value;
else if (value === true) node.setAttribute(key, "");
else if (value !== false && value !== null) node.setAttribute(key, value);
}
for (const child of children) {
if (child) node.appendChild(child);
}
return node;
}
build() {
const root = this.el("div", { class: "api-tester" });
if (this.config.params.length) root.appendChild(this.buildParams());
if (this.requiresAuth()) root.appendChild(this.buildAuthPicker());
root.appendChild(this.buildFormatPicker());
root.appendChild(this.buildCode());
const notes = this.buildNotes();
if (notes) root.appendChild(notes);
root.appendChild(this.buildActions());
root.appendChild(this.buildResponse());
this.mount.appendChild(root);
}
buildFormatPicker() {
const picker = this.el("div", { class: "format-picker" });
picker.appendChild(this.el("span", { class: "format-label", text: "Response format" }));
if (!this.negotiates()) {
const text = this.negotiation === "none"
? this.nonBodyNote()
: "Always returns JSON.";
picker.appendChild(this.el("span", { class: "format-fixed", text: this.negotiation === "none" ? text : "JSON" }));
if (this.negotiation !== "none") {
picker.appendChild(this.el("span", { class: "format-note", text: text }));
}
return picker;
}
const group = this.el("div", { class: "format-options" });
this.formatButtons = {};
for (const option of FORMAT_OPTIONS) {
const btn = this.el("button", { type: "button", class: "format-option", text: option.label });
btn.addEventListener("click", () => this.selectFormat(option.id));
this.formatButtons[option.id] = btn;
group.appendChild(btn);
}
picker.appendChild(group);
if (this.negotiation === "ajax") {
picker.appendChild(this.el("span", { class: "format-note", text: "JSON uses the X-Requested-With header; HTML shows the redirect." }));
}
this.selectFormat(this.responseFormat, true);
return picker;
}
nonBodyNote() {
if (this.config.id === "avatar") return "Returns an SVG image.";
if (this.config.id === "gateway-passthrough") return "Proxied upstream response.";
return "Redirect only.";
}
selectFormat(id, silent) {
this.responseFormat = id;
if (this.formatButtons) {
for (const [fmt, btn] of Object.entries(this.formatButtons)) {
btn.classList.toggle("active", fmt === id);
}
}
if (!silent) {
this.renderSnippets();
this.renderExpected();
}
}
acceptHeader() {
const option = FORMAT_OPTIONS.find((o) => o.id === this.responseFormat);
return option ? option.accept : "application/json";
}
requiresAuth() {
return this.config.auth !== "public";
}
buildParams() {
const table = this.el("div", { class: "param-table" });
for (const param of this.config.params) {
const control = this.buildControl(param);
this.inputs.push({ param, control });
control.addEventListener("input", () => this.renderSnippets());
control.addEventListener("change", () => this.renderSnippets());
const label = this.el("div", { class: "param-label" }, [
this.el("span", { class: "param-name", text: param.name }),
param.required ? this.el("span", { class: "param-required", text: "*" }) : null,
this.el("span", { class: "param-loc param-loc-" + param.location, text: param.location }),
]);
const allowed = param.type === "enum" && param.options && param.options.length
? this.el("div", { class: "param-allowed" }, [
this.el("span", { class: "param-allowed-label", text: "Allowed: " }),
this.el("span", { class: "param-allowed-values", text: param.options.join(", ") }),
])
: null;
const meta = this.el("div", { class: "param-meta" }, [
control,
param.description ? this.el("div", { class: "param-desc", text: param.description }) : null,
allowed,
]);
table.appendChild(this.el("div", { class: "param-row" }, [label, meta]));
}
return table;
}
buildControl(param) {
if (param.type === "enum") {
const select = this.el("select", { class: "param-input" });
for (const option of param.options || []) {
const opt = this.el("option", { value: option, text: option });
if (option === param.example) opt.selected = true;
select.appendChild(opt);
}
return select;
}
if (param.type === "textarea") {
const area = this.el("textarea", { class: "param-input", rows: "3" });
area.value = param.example || "";
return area;
}
if (param.type === "file") {
return this.el("input", { class: "param-input", type: "file" });
}
const input = this.el("input", {
class: "param-input",
type: param.type === "int" ? "number" : "text",
placeholder: param.example || "",
});
input.value = param.example || "";
return input;
}
buildAuthPicker() {
const picker = this.el("div", { class: "auth-picker" });
const select = this.el("select", { class: "param-input auth-method" });
for (const method of AUTH_METHODS) {
select.appendChild(this.el("option", { value: method.id, text: method.label }));
}
select.value = this.authType;
this.keyField = this.el("input", { class: "param-input", type: "text", placeholder: "API key" });
this.keyField.value = this.ctx.apiKey || "";
this.userField = this.el("input", { class: "param-input", type: "text", placeholder: "username or email" });
this.userField.value = this.ctx.username || "";
this.passField = this.el("input", { class: "param-input", type: "password", placeholder: "password" });
this.keyRow = this.el("div", { class: "auth-field" }, [this.el("label", { text: "Key" }), this.keyField]);
this.basicRow = this.el("div", { class: "auth-field auth-basic" }, [
this.el("label", { text: "Identity" }), this.userField,
this.el("label", { text: "Password" }), this.passField,
]);
select.addEventListener("change", () => {
this.authType = select.value;
this.syncAuthRows();
this.renderSnippets();
});
for (const f of [this.keyField, this.userField, this.passField]) {
f.addEventListener("input", () => this.renderSnippets());
}
picker.appendChild(this.el("div", { class: "auth-field" }, [this.el("label", { text: "Auth" }), select]));
picker.appendChild(this.keyRow);
picker.appendChild(this.basicRow);
this.syncAuthRows();
return picker;
}
syncAuthRows() {
if (!this.keyRow) return;
const showKey = this.authType === "apikey" || this.authType === "bearer";
this.keyRow.hidden = !showKey;
this.basicRow.hidden = this.authType !== "basic";
}
buildCode() {
const wrap = this.el("div", { class: "code-block" });
const tabs = this.el("div", { class: "code-tabs" });
this.tabButtons = {};
for (const lang of LANG_TABS) {
const btn = this.el("button", { type: "button", class: "code-tab", text: lang.label });
btn.addEventListener("click", () => this.selectLang(lang.id));
this.tabButtons[lang.id] = btn;
tabs.appendChild(btn);
}
this.panel = this.el("pre", { class: "code-panel" });
this.codeEl = this.el("code");
this.panel.appendChild(this.codeEl);
wrap.appendChild(tabs);
wrap.appendChild(this.panel);
this.selectLang(this.activeLang, true);
return wrap;
}
selectLang(id, silent) {
this.activeLang = id;
for (const [lang, btn] of Object.entries(this.tabButtons)) {
btn.classList.toggle("active", lang === id);
}
if (!silent) this.renderSnippets();
}
buildNotes() {
if (!this.config.notes.length) return null;
const wrap = this.el("div", { class: "endpoint-notes" });
wrap.innerHTML = contentRenderer.render(this.config.notes.join("\n\n"));
return wrap;
}
buildActions() {
const bar = this.el("div", { class: "try-panel" });
if (this.config.interactive === false) {
bar.appendChild(this.el("span", { class: "try-note", text: "Example only - run this from your own client." }));
return bar;
}
if (this.requiresAuth() && !this.ctx.loggedIn) {
const note = this.el("span", { class: "try-note" });
note.innerHTML = 'Sign in to run this. <a href="/auth/login">Log in</a>';
bar.appendChild(note);
return bar;
}
if (this.config.auth === "admin" && !this.ctx.isAdmin) {
bar.appendChild(this.el("span", { class: "try-note", text: "Administrator account required to run this." }));
return bar;
}
this.sendBtn = this.el("button", { type: "button", class: "btn btn-primary try-send", text: "Send request" });
this.sendBtn.addEventListener("click", () => this.send());
bar.appendChild(this.sendBtn);
return bar;
}
buildResponse() {
const wrap = this.el("div", { class: "response-viewer" });
const tabs = this.el("div", { class: "response-tabs" });
this.responseTabs = {};
for (const tab of [{ id: "expected", label: "Expected" }, { id: "live", label: "Live response" }]) {
const btn = this.el("button", { type: "button", class: "code-tab", text: tab.label });
btn.addEventListener("click", () => this.selectResponseTab(tab.id));
this.responseTabs[tab.id] = btn;
tabs.appendChild(btn);
}
this.expectedEl = this.el("div", { class: "response-pane" });
this.liveEl = this.el("div", { class: "response-pane" });
const placeholder = this.config.interactive === false
? "Example only - run this from your own client."
: "Run the request to see the live response.";
this.liveEl.appendChild(this.el("p", { class: "response-placeholder", text: placeholder }));
wrap.appendChild(tabs);
wrap.appendChild(this.expectedEl);
wrap.appendChild(this.liveEl);
this.selectResponseTab("expected");
return wrap;
}
selectResponseTab(id) {
for (const [name, btn] of Object.entries(this.responseTabs)) {
btn.classList.toggle("active", name === id);
}
this.expectedEl.classList.toggle("active", id === "expected");
this.liveEl.classList.toggle("active", id === "live");
}
renderExpected() {
if (!this.expectedEl) return;
this.expectedEl.innerHTML = "";
if (this.responseFormat === "html" && this.negotiation === "negotiable") {
const redirect = this.config.sample_response && this.config.sample_response.redirect;
const text = redirect
? `Returns a 302 redirect to ${redirect} (Location header). Browsers follow it; the page itself is HTML.`
: "Returns the rendered HTML page (Content-Type: text/html).";
this.expectedEl.appendChild(this.el("p", { class: "response-note", text: text }));
return;
}
if (this.negotiation === "none") {
this.expectedEl.appendChild(this.el("p", { class: "response-note", text: this.nonBodyNote() }));
return;
}
const sample = this.config.sample_response;
if (sample === null || sample === undefined) {
this.expectedEl.appendChild(this.el("p", { class: "response-note", text: "No documented response body." }));
return;
}
const caption = this.negotiation === "ajax"
? "Flat JSON shape (sent with X-Requested-With: fetch):"
: "Modeled JSON response:";
this.expectedEl.appendChild(this.el("p", { class: "response-note", text: caption }));
const pre = this.el("pre", { class: "response-body" });
pre.appendChild(this.el("code", { class: "language-json", text: JSON.stringify(sample, null, 2) }));
this.expectedEl.appendChild(pre);
CodeBlock.enhance(pre, { lineNumbers: false });
}
values() {
const out = { path: {}, query: {}, form: {}, json: {}, file: null };
for (const { param, control } of this.inputs) {
if (param.type === "file") {
out.file = control.files && control.files[0] ? control.files[0] : null;
continue;
}
const value = control.value;
if (value === "" && !param.required) continue;
out[param.location][param.name] = value;
}
return out;
}
auth() {
return {
type: this.authType,
key: this.keyField ? this.keyField.value : "",
username: this.userField ? this.userField.value : "",
password: this.passField ? this.passField.value : "",
};
}
buildPath(values) {
let path = this.config.path;
for (const [name, value] of Object.entries(values.path)) {
path = path.replace("{" + name + "}", encodeURIComponent(value));
}
return path;
}
queryString(values) {
const entries = Object.entries(values.query);
if (!entries.length) return "";
return "?" + entries.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join("&");
}
jsonBody(values) {
const body = {};
for (const [name, value] of Object.entries(values.json)) {
try {
body[name] = JSON.parse(value);
} catch {
body[name] = value;
}
}
return body;
}
headerPairs(auth, includeContentType) {
const headers = [];
if (auth.type === "apikey" && auth.key) headers.push(["X-API-KEY", auth.key]);
if (auth.type === "bearer" && auth.key) headers.push(["Authorization", "Bearer " + auth.key]);
if (auth.type === "basic") headers.push(["Authorization", "Basic <base64(identity:password)>"]);
if (this.negotiation !== "none") headers.push(["Accept", this.acceptHeader()]);
if (this.config.ajax && this.responseFormat === "json") headers.push(["X-Requested-With", "fetch"]);
if (includeContentType && this.config.encoding === "json") headers.push(["Content-Type", "application/json"]);
if (includeContentType && this.config.encoding === "form") headers.push(["Content-Type", "application/x-www-form-urlencoded"]);
return headers;
}
renderSnippets() {
if (!this.codeEl) return;
const values = this.values();
const auth = this.auth();
const builders = { curl: () => this.curl(values, auth), javascript: () => this.js(values, auth), python: () => this.python(values, auth) };
this.codeEl.textContent = builders[this.activeLang]();
const lang = LANG_TABS.find((l) => l.id === this.activeLang);
this.codeEl.className = "language-" + lang.hl;
CodeBlock.refresh(this.panel);
}
shellQuote(value) {
return "'" + String(value).replace(/'/g, "'\\''") + "'";
}
curl(values, auth) {
const url = this.ctx.base + this.buildPath(values) + this.queryString(values);
const lines = ["curl"];
if (this.config.method !== "GET") lines.push("-X " + this.config.method);
lines.push(this.shellQuote(url));
if (auth.type === "basic") {
lines.push("-u " + this.shellQuote(`${auth.username}:${auth.password || "PASSWORD"}`));
}
for (const [name, value] of this.headerPairs(auth, false)) {
if (name === "Authorization" && auth.type === "basic") continue;
lines.push("-H " + this.shellQuote(`${name}: ${value}`));
}
if (this.config.encoding === "form") {
for (const [name, value] of Object.entries(values.form)) {
lines.push("-d " + this.shellQuote(`${name}=${value}`));
}
} else if (this.config.encoding === "json") {
lines.push("-H " + this.shellQuote("Content-Type: application/json"));
lines.push("-d " + this.shellQuote(JSON.stringify(this.jsonBody(values))));
} else if (this.config.encoding === "multipart") {
lines.push("-F " + this.shellQuote("file=@/path/to/file"));
}
return lines.join(" \\\n ");
}
js(values, auth) {
const url = this.ctx.base + this.buildPath(values) + this.queryString(values);
const headers = this.headerPairs(auth, true).filter(([n]) => !(auth.type === "basic" && n === "Authorization"));
const lines = [];
const opts = [" method: " + JSON.stringify(this.config.method)];
const headerObj = headers.map(([n, v]) => ` ${JSON.stringify(n)}: ${JSON.stringify(v)}`);
if (auth.type === "basic") {
lines.push(`const credentials = btoa(${JSON.stringify(auth.username + ":" + (auth.password || "PASSWORD"))});`);
headerObj.push(' "Authorization": "Basic " + credentials');
}
if (headerObj.length) opts.push(" headers: {\n" + headerObj.join(",\n") + "\n }");
if (this.config.encoding === "form") {
opts.push(" body: new URLSearchParams(" + JSON.stringify(values.form, null, 2).replace(/\n/g, "\n ") + ")");
} else if (this.config.encoding === "json") {
opts.push(" body: JSON.stringify(" + JSON.stringify(this.jsonBody(values), null, 2).replace(/\n/g, "\n ") + ")");
} else if (this.config.encoding === "multipart") {
lines.push("const form = new FormData();");
lines.push('form.append("file", fileInput.files[0]);');
opts.push(" body: form");
}
lines.push(`const response = await fetch(${JSON.stringify(url)}, {\n${opts.join(",\n")}\n});`);
lines.push("const data = await response.json();");
lines.push("console.log(data);");
return lines.join("\n");
}
python(values, auth) {
const url = this.ctx.base + this.buildPath(values);
const args = [JSON.stringify(url)];
if (Object.keys(values.query).length) args.push("params=" + this.pyDict(values.query));
if (this.config.encoding === "form") args.push("data=" + this.pyDict(values.form));
if (this.config.encoding === "json") args.push("json=" + this.pyJson(this.jsonBody(values)));
if (this.config.encoding === "multipart") args.push('files={"file": open("/path/to/file", "rb")}');
const headers = this.headerPairs(auth, false).filter(([n]) => !(auth.type === "basic" && n === "Authorization"));
if (headers.length) args.push("headers=" + this.pyDict(Object.fromEntries(headers)));
if (auth.type === "basic") args.push(`auth=(${JSON.stringify(auth.username)}, ${JSON.stringify(auth.password || "PASSWORD")})`);
const method = this.config.method.toLowerCase();
const lines = ["import requests", "", `response = requests.${method}(\n ${args.join(",\n ")}\n)`, "print(response.json())"];
return lines.join("\n");
}
pyDict(obj) {
const entries = Object.entries(obj).map(([k, v]) => ` ${JSON.stringify(k)}: ${JSON.stringify(v)}`);
if (!entries.length) return "{}";
return "{\n" + entries.join(",\n") + "\n }";
}
pyJson(obj) {
return JSON.stringify(obj, null, 4).replace(/\n/g, "\n ");
}
async send() {
if (this.config.destructive || this.config.method === "DELETE") {
if (!window.confirm("This performs a real action on your account. Continue?")) return;
}
const values = this.values();
const auth = this.auth();
const url = this.ctx.base + this.buildPath(values) + this.queryString(values);
const headers = {};
for (const [name, value] of this.headerPairs(auth, false)) {
if (auth.type === "basic" && name === "Authorization") continue;
headers[name] = value;
}
if (auth.type === "basic") headers["Authorization"] = "Basic " + btoa(`${auth.username}:${auth.password}`);
const options = { method: this.config.method, headers };
if (this.config.encoding === "form") {
options.body = new URLSearchParams(values.form);
headers["Content-Type"] = "application/x-www-form-urlencoded";
} else if (this.config.encoding === "json") {
options.body = JSON.stringify(this.jsonBody(values));
headers["Content-Type"] = "application/json";
} else if (this.config.encoding === "multipart") {
if (!values.file) {
this.showError("Choose a file first.");
return;
}
const form = new FormData();
form.append("file", values.file);
options.body = form;
}
this.sendBtn.disabled = true;
const started = performance.now();
try {
const response = await fetch(url, options);
const ms = Math.round(performance.now() - started);
await this.renderResponse(response, ms);
} catch (error) {
this.showError(error.message || "Network error");
} finally {
this.sendBtn.disabled = false;
}
}
async renderResponse(response, ms) {
const contentType = response.headers.get("content-type") || "";
this.liveEl.innerHTML = "";
this.selectResponseTab("live");
const cls = "response-" + Math.floor(response.status / 100) + "xx";
const status = this.el("span", { class: "response-status " + cls, text: `${response.status} ${response.statusText}` });
const meta = this.el("div", { class: "response-meta" }, [
status,
this.el("span", { text: `${ms} ms` }),
this.el("span", { text: contentType.split(";")[0] || "unknown" }),
]);
this.liveEl.appendChild(meta);
if (response.redirected) {
this.liveEl.appendChild(this.el("p", { class: "response-redirect", text: "Redirected to " + response.url }));
}
if (contentType.includes("application/json")) {
const data = await response.json();
const pre = this.el("pre", { class: "response-body" });
const code = this.el("code", { class: "language-json", text: JSON.stringify(data, null, 2) });
pre.appendChild(code);
this.liveEl.appendChild(pre);
CodeBlock.enhance(pre, { lineNumbers: false });
return;
}
const text = await response.text();
const note = contentType.includes("html") ? `HTML response (${text.length} bytes)` : `Response (${text.length} bytes)`;
this.liveEl.appendChild(this.el("p", { class: "response-redirect", text: note }));
const pre = this.el("pre", { class: "response-body" });
pre.appendChild(this.el("code", { text: text.slice(0, 2000) }));
this.liveEl.appendChild(pre);
CodeBlock.enhance(pre, { highlight: false, lineNumbers: false });
}
showError(message) {
this.liveEl.innerHTML = "";
this.selectResponseTab("live");
this.liveEl.appendChild(this.el("div", { class: "response-meta" }, [
this.el("span", { class: "response-status response-5xx", text: "Error" }),
]));
this.liveEl.appendChild(this.el("p", { class: "response-redirect", text: message }));
}
}
window.ApiTester = ApiTester;
+6
View File
@@ -14,6 +14,9 @@ import { CounterManager } from "./CounterManager.js";
import { ReactionBar } from "./ReactionBar.js";
import { BookmarkManager } from "./BookmarkManager.js";
import { PollManager } from "./PollManager.js";
import { ApiKeyManager } from "./ApiKeyManager.js";
import { Tabs } from "./Tabs.js";
import { DeviiTerminal } from "./DeviiTerminal.js";
class Application {
constructor() {
@@ -33,6 +36,9 @@ class Application {
this.reactions = new ReactionBar();
this.bookmarks = new BookmarkManager();
this.polls = new PollManager();
this.apiKey = new ApiKeyManager();
this.tabs = new Tabs();
this.devii = new DeviiTerminal();
}
}
+66
View File
@@ -0,0 +1,66 @@
export const CodeBlock = {
highlight(code) {
if (typeof hljs === "undefined" || !code) return;
code.removeAttribute("data-highlighted");
code.classList.remove("hljs");
try {
hljs.highlightElement(code);
} catch {
// leave plain text on failure
}
},
lineNumbers(pre, code) {
pre.classList.add("code-pre", "has-line-numbers");
let gutter = pre.querySelector(":scope > .code-gutter");
if (!gutter) {
gutter = document.createElement("span");
gutter.className = "code-gutter";
gutter.setAttribute("aria-hidden", "true");
pre.insertBefore(gutter, code);
}
const text = code.textContent.replace(/\n+$/, "");
const count = text.length ? text.split("\n").length : 1;
gutter.textContent = Array.from({ length: count }, (_, i) => i + 1).join("\n");
},
copyButton(pre, code) {
if (pre.querySelector(":scope > .code-copy-btn")) return;
const btn = document.createElement("button");
btn.type = "button";
btn.className = "code-copy-btn";
btn.textContent = "Copy";
btn.addEventListener("click", async () => {
try {
await navigator.clipboard.writeText(code.textContent);
btn.textContent = "Copied";
} catch {
btn.textContent = "Failed";
}
setTimeout(() => { btn.textContent = "Copy"; }, 1500);
});
pre.appendChild(btn);
},
enhance(pre, { highlight = true, lineNumbers = true } = {}) {
if (!pre) return;
const code = pre.querySelector("code");
if (!code) return;
if (highlight && !code.classList.contains("hljs")) this.highlight(code);
pre.classList.add("code-has-copy");
if (lineNumbers) this.lineNumbers(pre, code);
this.copyButton(pre, code);
},
refresh(pre) {
if (!pre) return;
const code = pre.querySelector("code");
if (!code) return;
this.highlight(code);
pre.classList.add("code-has-copy");
this.lineNumbers(pre, code);
this.copyButton(pre, code);
},
};
window.CodeBlock = CodeBlock;
+11
View File
@@ -0,0 +1,11 @@
import { CodeBlock } from "./CodeBlock.js";
export class CodeCopy {
constructor(root = document) {
root.querySelectorAll(".docs-content pre").forEach((pre) => {
if (pre.querySelector("code")) CodeBlock.enhance(pre);
});
}
}
window.CodeCopy = CodeCopy;
+59
View File
@@ -0,0 +1,59 @@
// retoor <retoor@molodetz.nl>
import "./devii/devii-avatar.js";
import "./devii/devii-terminal.js";
export class DeviiTerminal {
constructor() {
this.element = null;
this.avatar = null;
this.ready = this.init();
}
async init() {
try {
await fetch("/devii/session", { credentials: "same-origin" });
} catch (error) {
return;
}
this.avatar = document.createElement("devii-avatar");
this.avatar.id = "devii";
this.avatar.setAttribute("character", "Clippy");
document.body.appendChild(this.avatar);
this.element = document.createElement("devii-terminal");
this.element.setAttribute("avatar", "devii");
document.body.appendChild(this.element);
this.bindTriggers();
}
bindTriggers() {
document.querySelectorAll("[data-devii-open]").forEach((trigger) => {
trigger.addEventListener("click", (event) => {
event.preventDefault();
this.open();
});
});
const stored = this.element && this.element.hasStoredState && this.element.hasStoredState();
if (document.querySelector("[data-devii-autoopen]") && !stored) {
this.open();
}
}
async open() {
await this.ready;
if (this.element) this.element.open();
}
async close() {
await this.ready;
if (this.element) this.element.close();
}
async toggle() {
await this.ready;
if (this.element) this.element.toggle();
}
}
+20 -1
View File
@@ -4,6 +4,7 @@ class GistEditor {
this.initialized = false;
this.textareaId = textareaId || "gist-source-editor";
this.langSelectId = langSelectId || "gist-language";
this.maxLength = 400000;
}
init() {
@@ -59,12 +60,30 @@ class GistEditor {
const form = textarea.closest("form");
if (form) {
form.addEventListener("submit", () => {
form.addEventListener("submit", (event) => {
this.editor.save();
if (textarea.value.length > this.maxLength) {
event.preventDefault();
this.showLengthError(textarea);
}
});
}
}
showLengthError(textarea) {
const field = textarea.closest(".auth-field");
const target = field || textarea.parentElement;
let message = target.querySelector(".gist-length-error");
if (!message) {
message = document.createElement("div");
message.className = "gist-length-error";
target.appendChild(message);
}
const length = textarea.value.length.toLocaleString();
const limit = this.maxLength.toLocaleString();
message.textContent = `Source code is ${length} characters; the maximum is ${limit}.`;
}
refresh() {
if (this.editor) {
setTimeout(() => this.editor.refresh(), 100);
@@ -14,6 +14,7 @@ export class NotificationManager {
return;
}
target.scrollIntoView({ block: "center" });
target.classList.add("comment-highlight");
});
}
}
+183 -38
View File
@@ -1,26 +1,199 @@
import { Http } from "./Http.js";
import { Toast } from "./Toast.js";
class ServiceMonitor {
constructor() {
this.pollInterval = 5000;
this.detail = document.querySelector("[data-service-detail]");
this.detailName = this.detail ? this.detail.dataset.service : null;
this.list = document.getElementById("services-list");
}
start() {
this.pollServices();
setInterval(() => this.pollServices(), this.pollInterval);
const container = this.detail || this.list;
if (!container) return;
this.bindActions(container);
this.bindConfigForms(container);
this.poll();
setInterval(() => this.poll(), this.pollInterval);
}
bindActions(container) {
container.addEventListener("click", (event) => {
const button = event.target.closest("[data-action]");
if (!button) return;
event.preventDefault();
this.runAction(button);
});
}
async runAction(button) {
const action = button.dataset.action;
const name = button.dataset.service;
const confirmation = button.dataset.confirm;
if (confirmation && !window.confirm(confirmation)) return;
button.disabled = true;
try {
await Http.sendForm(`/admin/services/${name}/${action}`, {});
await this.poll();
} catch {
Toast.flash(button, "Error", 1500);
} finally {
button.disabled = false;
}
}
bindConfigForms(container) {
container.querySelectorAll("[data-config-form]").forEach((form) => {
form.addEventListener("submit", (event) => {
event.preventDefault();
this.saveConfig(form);
});
});
}
async saveConfig(form) {
const name = form.dataset.service;
const statusEl = form.querySelector("[data-config-status]");
form.querySelectorAll("[data-error]").forEach((el) => {
el.textContent = "";
});
const params = new URLSearchParams();
form.querySelectorAll("input, select, textarea").forEach((input) => {
if (input.type === "password" && input.value === "") return;
params.append(input.name, input.value);
});
try {
const response = await fetch(`/admin/services/${name}/config`, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
"X-Requested-With": "fetch",
},
body: params,
});
const data = await response.json();
if (data.ok) {
if (statusEl) Toast.flash(statusEl, "Saved", 2000, "");
await this.poll();
return;
}
for (const [key, message] of Object.entries(data.errors || {})) {
const el = form.querySelector(`[data-error="${key}"]`);
if (el) el.textContent = message;
}
if (statusEl) Toast.flash(statusEl, "Check the errors below", 3000, "");
} catch {
if (statusEl) Toast.flash(statusEl, "Request failed", 2000, "");
}
}
async poll() {
try {
if (this.detail) {
const data = await Http.getJson(`/admin/services/${this.detailName}/data`);
if (data.service) this.update(this.detail, data.service);
} else if (this.list) {
const data = await Http.getJson("/admin/services/data");
for (const svc of data.services) {
const row = this.list.querySelector(`[data-service="${svc.name}"]`);
if (row) this.update(row, svc);
}
}
} catch {
// silently ignore poll errors
}
}
update(root, svc) {
const statusEl = root.querySelector(".service-status");
if (statusEl) {
statusEl.textContent = svc.status;
statusEl.className = "service-status " + svc.status;
}
this.setMeta(root, "uptime", svc.uptime || "-");
this.setMeta(root, "last_run", svc.last_run ? this.formatDate(svc.last_run, true) : "-");
this.setMeta(root, "next_run", svc.next_run ? this.formatDate(svc.next_run, true) : "-");
this.setMeta(root, "heartbeat", svc.heartbeat ? this.formatDate(svc.heartbeat, true) : "-");
this.setMeta(root, "interval", "every " + svc.interval_seconds + "s");
const logPre = root.querySelector(".log-output");
if (logPre) {
logPre.textContent = (svc.log_buffer || []).map((line) => line + "\n").join("") || "No log entries yet.\n";
}
const startBtn = root.querySelector('[data-action="start"]');
const stopBtn = root.querySelector('[data-action="stop"]');
if (startBtn) startBtn.disabled = svc.enabled;
if (stopBtn) stopBtn.disabled = !svc.enabled;
const metricsEl = root.querySelector("[data-metrics]");
if (metricsEl) this.renderMetrics(metricsEl, svc.metrics);
}
setMeta(root, key, value) {
const el = root.querySelector(`[data-meta="${key}"]`);
if (el) el.textContent = value;
}
renderMetrics(container, metrics) {
container.textContent = "";
if (!metrics || (!metrics.stats && !metrics.table)) return;
const stats = metrics.stats || [];
if (stats.length) {
const grid = document.createElement("div");
grid.className = "metric-cards";
for (const stat of stats) {
const cell = document.createElement("div");
cell.className = "metric-card";
const value = document.createElement("span");
value.className = "metric-value";
value.textContent = String(stat.value);
const label = document.createElement("span");
label.className = "metric-label";
label.textContent = stat.label;
cell.append(value, label);
grid.appendChild(cell);
}
container.appendChild(grid);
}
const table = metrics.table;
if (table && table.rows && table.rows.length) {
const el = document.createElement("table");
el.className = "metric-table";
const thead = document.createElement("thead");
const headRow = document.createElement("tr");
for (const col of table.columns || []) {
const th = document.createElement("th");
th.textContent = col;
headRow.appendChild(th);
}
thead.appendChild(headRow);
el.appendChild(thead);
const tbody = document.createElement("tbody");
for (const row of table.rows) {
const tr = document.createElement("tr");
for (const cell of row) {
const td = document.createElement("td");
td.textContent = String(cell);
tr.appendChild(td);
}
tbody.appendChild(tr);
}
el.appendChild(tbody);
container.appendChild(el);
}
}
formatDate(isoStr, includeTime) {
if (!isoStr) return "-";
try {
const d = new Date(isoStr);
if (isNaN(d.getTime())) return isoStr.slice(0, 19);
const dd = String(d.getDate()).padStart(2, "0");
const mm = String(d.getMonth() + 1).padStart(2, "0");
const yyyy = d.getFullYear();
const date = new Date(isoStr);
if (isNaN(date.getTime())) return isoStr.slice(0, 19);
const dd = String(date.getDate()).padStart(2, "0");
const mm = String(date.getMonth() + 1).padStart(2, "0");
const yyyy = date.getFullYear();
if (includeTime) {
const hh = String(d.getHours()).padStart(2, "0");
const mi = String(d.getMinutes()).padStart(2, "0");
const hh = String(date.getHours()).padStart(2, "0");
const mi = String(date.getMinutes()).padStart(2, "0");
return `${dd}/${mm}/${yyyy} ${hh}:${mi}`;
}
return `${dd}/${mm}/${yyyy}`;
@@ -28,34 +201,6 @@ class ServiceMonitor {
return isoStr.slice(0, 19);
}
}
async pollServices() {
try {
const data = await Http.getJson("/admin/services/data");
const container = document.getElementById("services-list");
if (!container) return;
for (const svc of data.services) {
const card = container.querySelector(`.service-card[data-service="${svc.name}"]`);
if (!card) continue;
const statusEl = card.querySelector(".service-status");
if (statusEl) {
statusEl.textContent = svc.status;
statusEl.className = "service-status " + svc.status;
}
const metaItems = card.querySelectorAll(".meta-item");
if (metaItems.length >= 3) {
metaItems[0].textContent = "Uptime: " + (svc.uptime || "-");
metaItems[1].textContent = "Last run: " + this.formatDate(svc.last_run, true);
metaItems[2].textContent = "Next run: " + this.formatDate(svc.next_run, true);
}
const logPre = card.querySelector(".log-output");
if (logPre) {
logPre.textContent = (svc.log_buffer || []).map((l) => l + "\n").join("") || "No log entries yet.\n";
}
}
} catch {
// silently ignore poll errors
}
}
}
window.ServiceMonitor = ServiceMonitor;
+30
View File
@@ -0,0 +1,30 @@
class Tabs {
constructor() {
document.querySelectorAll("[data-tabs]").forEach((root) => this.init(root));
}
init(root) {
const tabs = [...root.querySelectorAll("[data-tab]")];
const panes = [...root.querySelectorAll("[data-tab-pane]")];
if (!tabs.length) return;
const show = (name) => {
tabs.forEach((t) => t.classList.toggle("active", t.dataset.tab === name));
panes.forEach((p) => p.classList.toggle("active", p.dataset.tabPane === name));
};
tabs.forEach((tab) => {
tab.addEventListener("click", () => {
show(tab.dataset.tab);
history.replaceState(null, "", "#" + tab.dataset.tab);
});
});
const hash = (location.hash || "").slice(1);
const initial = tabs.some((t) => t.dataset.tab === hash) ? hash : tabs[0].dataset.tab;
show(initial);
}
}
window.Tabs = Tabs;
export { Tabs };
+206
View File
@@ -0,0 +1,206 @@
// retoor <retoor@molodetz.nl>
const MAX_RESULT_CHARS = 8000;
const NAV_DELAY_MS = 150;
export default class DeviiClient {
constructor() {
this._highlights = [];
this._reposition = () => this._repositionHighlights();
}
async execute(action, args = {}) {
try {
switch (action) {
case "get_page_context":
return this._context();
case "run_js":
return await this._runJs(String(args.code || ""));
case "highlight_element":
return this._highlight(String(args.selector || ""), args.label);
case "clear_highlights":
return this._clearHighlights();
case "show_toast":
return this._toast(String(args.text || ""), Number(args.duration_ms) || 4000);
case "scroll_to_element":
return this._scrollTo(String(args.selector || ""));
case "navigate_to":
return this._navigate(String(args.url || ""));
case "reload_page":
return this._reload();
default:
return { error: `unknown client action '${action}'` };
}
} catch (error) {
return { error: String(error && error.message ? error.message : error) };
}
}
_context() {
const headings = Array.from(document.querySelectorAll("h1, h2, h3"))
.map((h) => h.textContent.trim())
.filter(Boolean)
.slice(0, 25);
const selection = String(window.getSelection ? window.getSelection().toString() : "").slice(0, 2000);
const docs = window.DEVPLACE_DOCS || {};
let loggedIn = !!docs.loggedIn;
let username = docs.username || "";
if (!username) {
const nameEl = document.querySelector(".topnav-user-name");
if (nameEl) {
username = nameEl.textContent.trim();
loggedIn = true;
}
}
return {
url: location.href,
path: location.pathname + location.search,
title: document.title,
referrer: document.referrer || null,
viewport: { width: window.innerWidth, height: window.innerHeight },
scroll: { x: Math.round(window.scrollX), y: Math.round(window.scrollY) },
selection,
headings,
loggedIn,
username: username || null,
};
}
async _runJs(code) {
if (!code.trim()) return { error: "no code provided" };
const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor;
const fn = new AsyncFunction(code);
const value = await fn();
return this._serialize(value);
}
_serialize(value) {
if (value === undefined) return null;
let out;
try {
out = JSON.parse(JSON.stringify(value));
} catch (error) {
out = String(value);
}
const text = typeof out === "string" ? out : JSON.stringify(out);
if (text && text.length > MAX_RESULT_CHARS) {
const base = typeof out === "string" ? out : text;
return base.slice(0, MAX_RESULT_CHARS) + " …[truncated]";
}
return out;
}
_resolve(selector) {
let element = null;
try {
element = document.querySelector(selector);
} catch (error) {
element = null;
}
if (!element) element = this._findByText(selector);
if (!element) throw new Error(`no element matches '${selector}'`);
return element;
}
_findByText(text) {
const needle = String(text).trim();
if (!needle) return null;
const candidates = document.querySelectorAll(
"h1, h2, h3, h4, h5, h6, a, button, [role='button'], label, summary, li, th, td, p, span",
);
let partial = null;
for (const el of candidates) {
if (el.offsetParent === null && getComputedStyle(el).position !== "fixed") continue;
const content = (el.textContent || "").trim();
if (!content) continue;
if (content === needle) return el;
if (!partial && content.includes(needle) && content.length <= needle.length + 60) {
partial = el;
}
}
return partial;
}
_scrollTo(selector) {
const element = this._resolve(selector);
element.scrollIntoView({ behavior: "smooth", block: "center", inline: "nearest" });
return `scrolled to ${selector}`;
}
_highlight(selector, label) {
const element = this._resolve(selector);
element.scrollIntoView({ behavior: "smooth", block: "center", inline: "nearest" });
const box = document.createElement("div");
box.className = "devii-hl-box";
const callout = label ? document.createElement("div") : null;
if (callout) {
callout.className = "devii-hl-callout";
callout.textContent = String(label);
}
document.body.appendChild(box);
if (callout) document.body.appendChild(callout);
const entry = { element, box, callout };
this._highlights.push(entry);
if (this._highlights.length === 1) {
window.addEventListener("scroll", this._reposition, true);
window.addEventListener("resize", this._reposition);
}
this._place(entry);
return `highlighted ${selector}`;
}
_place(entry) {
const rect = entry.element.getBoundingClientRect();
const pad = 4;
entry.box.style.left = `${rect.left - pad}px`;
entry.box.style.top = `${rect.top - pad}px`;
entry.box.style.width = `${rect.width + pad * 2}px`;
entry.box.style.height = `${rect.height + pad * 2}px`;
if (entry.callout) {
const below = rect.bottom + 8;
entry.callout.style.left = `${Math.max(8, rect.left)}px`;
entry.callout.style.top = `${below}px`;
}
}
_repositionHighlights() {
this._highlights.forEach((entry) => this._place(entry));
}
_clearHighlights() {
const count = this._highlights.length;
this._highlights.forEach((entry) => {
entry.box.remove();
if (entry.callout) entry.callout.remove();
});
this._highlights = [];
window.removeEventListener("scroll", this._reposition, true);
window.removeEventListener("resize", this._reposition);
return `cleared ${count} highlight(s)`;
}
_toast(text, duration) {
if (!text) return { error: "no text provided" };
const toast = document.createElement("div");
toast.className = "devii-toast";
toast.textContent = text;
document.body.appendChild(toast);
window.requestAnimationFrame(() => toast.classList.add("devii-toast-show"));
window.setTimeout(() => {
toast.classList.remove("devii-toast-show");
window.setTimeout(() => toast.remove(), 250);
}, Math.max(1000, duration));
return "toast shown";
}
_navigate(url) {
if (!url) return { error: "no url provided" };
window.setTimeout(() => window.location.assign(url), NAV_DELAY_MS);
return `navigating to ${url}`;
}
_reload() {
window.setTimeout(() => window.location.reload(), NAV_DELAY_MS);
return "reloading";
}
}
+56
View File
@@ -0,0 +1,56 @@
// retoor <retoor@molodetz.nl>
const RECONNECT_DELAY_MS = 1500;
export default class DeviiSocket {
constructor(handlers) {
this.handlers = handlers || {};
this.ws = null;
this.url = (location.protocol === "https:" ? "wss://" : "ws://") + location.host + "/devii/ws";
this._shouldRun = true;
}
connect() {
this._shouldRun = true;
this._open();
}
_open() {
this.ws = new WebSocket(this.url);
this.ws.addEventListener("open", () => this._emit("onOpen"));
this.ws.addEventListener("close", () => {
this._emit("onClose");
if (this._shouldRun) {
window.setTimeout(() => this._open(), RECONNECT_DELAY_MS);
}
});
this.ws.addEventListener("error", () => this._emit("onError"));
this.ws.addEventListener("message", (event) => {
let payload;
try {
payload = JSON.parse(event.data);
} catch {
return;
}
this._emit("onMessage", payload);
});
}
send(message) {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(message));
return true;
}
return false;
}
close() {
this._shouldRun = false;
if (this.ws) this.ws.close();
}
_emit(name, payload) {
const handler = this.handlers[name];
if (typeof handler === "function") handler(payload);
}
}
+207
View File
@@ -0,0 +1,207 @@
// retoor <retoor@molodetz.nl>
const CSS_ID = "devii-avatar-css";
const DEFAULT_CHARACTER = "Clippy";
const CORNER_MARGIN = 16;
const FALLBACK_WIDTH = 124;
const FALLBACK_HEIGHT = 93;
const ACTION_SAFETY_MS = 18000;
export default class DeviiAvatarElement extends HTMLElement {
constructor() {
super();
this._clippy = null;
this._character = DEFAULT_CHARACTER;
this._tts = false;
this._assistant = null;
this._positioned = false;
this._shown = false;
this._chain = null;
}
connectedCallback() {
this._ensureCss();
this._character = this.getAttribute("character") || DEFAULT_CHARACTER;
this._tts = this.hasAttribute("tts");
if (globalThis.app && typeof globalThis.app.register === "function") {
globalThis.app.register(this);
}
if (this.hasAttribute("autoshow")) {
this.execute("show", {}).catch(() => {});
}
}
_ensureCss() {
if (document.getElementById(CSS_ID)) return;
const link = document.createElement("link");
link.id = CSS_ID;
link.rel = "stylesheet";
link.href = "/static/css/devii-avatar.css";
document.head.appendChild(link);
}
async _app() {
if (!this._clippy) {
const module = await import("/static/vendor/md-clippy/index.js");
this._clippy = module.app;
}
return this._clippy;
}
async _ensureLoaded(app) {
if (!app.agent || app.name !== this._character) {
await app.load(this._character);
this._positioned = false;
this._shown = false;
}
}
_placeDefault(app) {
if (this._positioned) return;
const element = app.agent && app.agent._el;
if (!element) return;
const width = element.offsetWidth || FALLBACK_WIDTH;
const height = element.offsetHeight || FALLBACK_HEIGHT;
element.style.left = `${Math.max(0, window.innerWidth - width - CORNER_MARGIN)}px`;
element.style.top = `${Math.max(0, window.innerHeight - height - CORNER_MARGIN)}px`;
this._positioned = true;
}
async _ensureVisible(app) {
await this._ensureLoaded(app);
this._placeDefault(app);
if (!this._shown) {
app.agent.show();
this._shown = true;
}
}
_afterQueue(app) {
const agent = app.agent;
if (!agent) return Promise.resolve();
return new Promise((resolve) => {
let settled = false;
const finish = () => {
if (!settled) {
settled = true;
resolve();
}
};
try {
agent._addToQueue(function (complete) {
complete();
finish();
});
} catch (error) {
finish();
return;
}
window.setTimeout(finish, ACTION_SAFETY_MS);
});
}
execute(action, args = {}) {
const run = () => this._run(action, args);
const result = (this._chain || Promise.resolve()).then(run, run);
this._chain = result.then(
() => {},
() => {},
);
return result;
}
async _run(action, args = {}) {
const app = await this._app();
switch (action) {
case "show":
await this._ensureLoaded(app);
this._placeDefault(app);
app.agent.show();
this._shown = true;
await this._afterQueue(app);
return "devii is visible";
case "hide":
app.hide();
this._shown = false;
await this._afterQueue(app);
return "devii is hidden";
case "speak":
await this._ensureVisible(app);
app.speak(String(args.text), { hold: !!args.hold, tts: this._tts });
await this._afterQueue(app);
return "spoke";
case "play_animation": {
await this._ensureVisible(app);
if (!app.agent.hasAnimation(args.name)) {
return { error: `no animation named '${args.name}'` };
}
app.agent.play(args.name);
await this._afterQueue(app);
return `playing ${args.name}`;
}
case "random_animation":
await this._ensureVisible(app);
app.animate();
await this._afterQueue(app);
return "playing a random animation";
case "move_to": {
await this._ensureVisible(app);
const mx = Number(args.x);
const my = Number(args.y);
if (!Number.isFinite(mx) || !Number.isFinite(my)) {
return { error: "move_to needs numeric x and y" };
}
const requested = args.duration != null ? Number(args.duration) : undefined;
const duration = document.hidden ? 0 : requested;
app.moveTo(mx, my, duration);
await this._afterQueue(app);
return "moving";
}
case "gesture_at":
await this._ensureVisible(app);
app.agent.gestureAt(Number(args.x), Number(args.y));
await this._afterQueue(app);
return "gesturing";
case "stop":
app.stop();
return "stopped";
case "switch_character":
this._character = args.name;
await app.load(args.name);
this._positioned = false;
this._shown = false;
this._placeDefault(app);
app.agent.show();
this._shown = true;
await this._afterQueue(app);
return `now appearing as ${args.name}`;
case "list_animations":
await this._ensureVisible(app);
return app.agent.animations();
case "list_characters":
return app.names();
case "get_viewport": {
const agent = app.agent;
const rect = agent ? agent._el.getBoundingClientRect() : null;
return {
width: window.innerWidth,
height: window.innerHeight,
character: app.name,
position: rect ? { x: Math.round(rect.left), y: Math.round(rect.top) } : null,
};
}
default:
return { error: `unknown avatar action '${action}'` };
}
}
async ask(text) {
const app = await this._app();
if (!this._assistant) {
this._assistant = await app.connectAI({ tts: this._tts });
}
return app.ask(text);
}
}
customElements.define("devii-avatar", DeviiAvatarElement);
@@ -0,0 +1,653 @@
// retoor <retoor@molodetz.nl>
import Markdown from "./markdown.js";
import DeviiSocket from "./DeviiSocket.js";
import DeviiClient from "./DeviiClient.js";
const CSS_ID = "devii-terminal-css";
const STORAGE_KEY = "devii-terminal-state";
const MOBILE_QUERY = "(max-width: 640px)";
const TRACE_GLYPHS = {
call: "↳",
ok: "✓",
err: "✗",
"plan-gate": "plan required first",
"reflect-trigger": "reflecting on error",
"verify-gate": "verification gate",
compact: "context compaction",
};
const CLEAR_WORDS = new Set(["clear", "reset", "/clear", "/reset"]);
const CLOSE_WORDS = new Set(["exit", "close", "quit", "bye", "/exit", "/close", "/quit", "/bye"]);
const HELP = [
"Local commands:",
" /help show this help",
" clear | reset clear the conversation",
" exit | close | quit | bye close the terminal",
" /show show devii (the avatar)",
" /hide hide devii",
" /say <text> make devii speak directly",
" /character <name> switch devii's character",
"Anything else is sent to the agent.",
].join("\n");
export default class DeviiTerminalElement extends HTMLElement {
constructor() {
super();
this.markdown = new Markdown();
this.deviiClient = new DeviiClient();
this.socket = null;
this.history = [];
this.historyIndex = 0;
this.state = "closed";
this.geometry = null;
this._drag = null;
this._hasFocus = false;
this._unloading = false;
this._mobile = window.matchMedia(MOBILE_QUERY);
}
connectedCallback() {
this._ensureCss();
this._build();
if (globalThis.app && typeof globalThis.app.register === "function") {
globalThis.app.register(this);
}
this._mobile.addEventListener("change", () => this._onViewportChange());
window.addEventListener("resize", () => this._clampWindow());
window.addEventListener("pagehide", () => this._captureFocusForUnload());
window.addEventListener("beforeunload", () => this._captureFocusForUnload());
this._identity = null;
this._fetchIdentity().then((id) => { this._identity = id; });
window.addEventListener("focus", () => this._checkIdentity());
document.addEventListener("visibilitychange", () => {
if (!document.hidden) this._checkIdentity();
});
this.socket = new DeviiSocket({
onOpen: () => this._notice("connected."),
onClose: () => this._notice("disconnected, reconnecting..."),
onMessage: (message) => this._onMessage(message),
});
this._connected = false;
this._restoring = true;
const saved = this._loadPersisted();
if (saved && saved.geometry) {
this.geometry = saved.geometry;
this._clampGeometry();
}
let initial = "closed";
if (saved && saved.state) {
initial = saved.state;
} else if (this.hasAttribute("open")) {
initial = "open";
}
this._restoring = false;
this._setState(initial);
if (this.state !== "closed") {
this._ensureConnected();
this._focusInput();
}
}
_focusInput() {
window.requestAnimationFrame(() => {
try {
this.input.focus({ preventScroll: true });
} catch (error) {
return;
}
});
}
_onFocusChange(focused) {
if (!focused && this._unloading) return;
this._hasFocus = focused;
this._persist();
}
_captureFocusForUnload() {
this._unloading = true;
this._hasFocus = document.activeElement === this.input;
this._persist();
}
hasStoredState() {
try {
return window.localStorage.getItem(STORAGE_KEY) !== null;
} catch (error) {
return false;
}
}
_loadPersisted() {
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
return raw ? JSON.parse(raw) : null;
} catch (error) {
return null;
}
}
_persist() {
if (this._restoring) return;
try {
window.localStorage.setItem(
STORAGE_KEY,
JSON.stringify({ state: this.state, geometry: this.geometry, focused: this._hasFocus }),
);
} catch (error) {
return;
}
}
_ensureConnected() {
if (this._connected) return;
this._connected = true;
this.socket.connect();
}
_ensureCss() {
if (document.getElementById(CSS_ID)) return;
const link = document.createElement("link");
link.id = CSS_ID;
link.rel = "stylesheet";
link.href = "/static/css/devii.css";
document.head.appendChild(link);
}
_build() {
this.innerHTML = "";
this.launcher = document.createElement("button");
this.launcher.type = "button";
this.launcher.className = "devii-launcher";
this.launcher.innerHTML = '<span class="devii-launcher-dot"></span> devii';
this.launcher.addEventListener("click", () => this.open());
this.appendChild(this.launcher);
this.win = document.createElement("div");
this.win.className = "devii-window";
this.win.innerHTML = [
'<div class="devii-titlebar">',
' <span class="devii-title"><span class="devii-dot"></span>Devii</span>',
' <span class="devii-winctl">',
' <button type="button" data-win="fullscreen" title="Fullscreen">&#9974;</button>',
' <button type="button" data-win="maximize" title="Maximize">&#9633;</button>',
' <button type="button" data-win="close" title="Close">&#10005;</button>',
" </span>",
"</div>",
'<div class="devii-toolbar">',
' <button type="button" data-cmd="/show">show devii</button>',
' <button type="button" data-cmd="/hide">hide devii</button>',
' <button type="button" data-cmd="/clear">clear</button>',
"</div>",
'<div class="devii-output"></div>',
'<div class="devii-inputrow">',
' <span class="devii-prompt">you&gt;</span>',
' <input class="devii-input" type="text" autocomplete="off" spellcheck="false" />',
"</div>",
].join("");
this.appendChild(this.win);
this.output = this.win.querySelector(".devii-output");
this.input = this.win.querySelector(".devii-input");
this.titlebar = this.win.querySelector(".devii-titlebar");
this.win.querySelectorAll(".devii-toolbar button").forEach((button) => {
button.addEventListener("click", () => this._submit(button.dataset.cmd));
});
this.win.querySelectorAll(".devii-winctl button").forEach((button) => {
button.addEventListener("click", () => this._window(button.dataset.win));
});
this.input.addEventListener("keydown", (event) => this._onKey(event));
this.input.addEventListener("focus", () => this._onFocusChange(true));
this.input.addEventListener("blur", () => this._onFocusChange(false));
this.win.addEventListener("click", (event) => {
if (event.target.closest("button, a, input, textarea")) return;
const selection = window.getSelection();
if (selection && selection.toString().length > 0) return;
this.input.focus({ preventScroll: true });
});
this.output.addEventListener("click", (event) => this._onOutputClick(event));
this.titlebar.addEventListener("mousedown", (event) => this._startDrag(event));
this.titlebar.addEventListener("touchstart", (event) => this._startDrag(event), { passive: false });
this.titlebar.addEventListener("dblclick", () => this._window("maximize"));
}
open() {
this._ensureConnected();
this._setState("open");
this.input.focus({ preventScroll: true });
}
close() {
this._setState("closed");
}
toggle() {
if (this.state === "closed") this.open();
else this.close();
}
_window(action) {
if (action === "close") {
this.close();
} else if (action === "maximize") {
this._setState(this.state === "maximized" ? "normal" : "maximized");
} else if (action === "fullscreen") {
this._setState(this.state === "fullscreen" ? "normal" : "fullscreen");
}
}
_setState(state) {
let resolved = state;
if (state === "open") {
resolved = this._mobile.matches ? "fullscreen" : "normal";
} else if (state !== "closed" && this._mobile.matches) {
resolved = "fullscreen";
}
this.state = resolved;
this.classList.toggle("devii-mobile", this._mobile.matches);
["closed", "normal", "maximized", "fullscreen"].forEach((name) => {
this.classList.toggle(`devii-state-${name}`, name === resolved);
});
if (resolved === "normal") {
this._applyGeometry();
} else {
this.win.style.left = "";
this.win.style.top = "";
this.win.style.width = "";
this.win.style.height = "";
}
this._persist();
}
_defaultGeometry() {
const width = Math.min(760, Math.round(window.innerWidth * 0.94));
const height = Math.min(600, Math.round(window.innerHeight * 0.82));
return {
width,
height,
left: Math.max(12, window.innerWidth - width - 24),
top: Math.max(12, window.innerHeight - height - 24),
};
}
_applyGeometry() {
if (!this.geometry) this.geometry = this._defaultGeometry();
const g = this.geometry;
this.win.style.width = `${g.width}px`;
this.win.style.height = `${g.height}px`;
this.win.style.left = `${g.left}px`;
this.win.style.top = `${g.top}px`;
}
_clampGeometry() {
if (!this.geometry) return;
const g = this.geometry;
g.width = Math.min(g.width, window.innerWidth - 16);
g.height = Math.min(g.height, window.innerHeight - 16);
g.left = Math.max(8, Math.min(g.left, window.innerWidth - g.width - 8));
g.top = Math.max(8, Math.min(g.top, window.innerHeight - g.height - 8));
}
_clampWindow() {
if (this.state !== "normal" || !this.geometry) return;
this._clampGeometry();
this._applyGeometry();
this._persist();
}
_onViewportChange() {
if (this.state === "closed") return;
this._setState("open");
}
_startDrag(event) {
if (this.state !== "normal") return;
if (event.target.tagName === "BUTTON") return;
event.preventDefault();
const point = event.touches ? event.touches[0] : event;
const rect = this.win.getBoundingClientRect();
this._drag = { dx: point.clientX - rect.left, dy: point.clientY - rect.top };
this.classList.add("devii-dragging");
this._moveHandler = (move) => this._onDrag(move);
this._upHandler = () => this._endDrag();
window.addEventListener("mousemove", this._moveHandler);
window.addEventListener("mouseup", this._upHandler);
window.addEventListener("touchmove", this._moveHandler, { passive: false });
window.addEventListener("touchend", this._upHandler);
}
_onDrag(event) {
if (!this._drag) return;
if (event.cancelable) event.preventDefault();
const point = event.touches ? event.touches[0] : event;
const g = this.geometry;
g.left = Math.max(8, Math.min(point.clientX - this._drag.dx, window.innerWidth - g.width - 8));
g.top = Math.max(8, Math.min(point.clientY - this._drag.dy, window.innerHeight - g.height - 8));
this.win.style.left = `${g.left}px`;
this.win.style.top = `${g.top}px`;
}
_endDrag() {
this._drag = null;
this.classList.remove("devii-dragging");
window.removeEventListener("mousemove", this._moveHandler);
window.removeEventListener("mouseup", this._upHandler);
window.removeEventListener("touchmove", this._moveHandler);
window.removeEventListener("touchend", this._upHandler);
this._syncSize();
}
_syncSize() {
if (this.state !== "normal") return;
const rect = this.win.getBoundingClientRect();
this.geometry.width = Math.round(rect.width);
this.geometry.height = Math.round(rect.height);
this._persist();
}
_onKey(event) {
if (event.key === "Enter") {
event.preventDefault();
const text = this.input.value;
this.input.value = "";
this._submit(text);
} else if (event.key === "ArrowUp") {
event.preventDefault();
this._recall(-1);
} else if (event.key === "ArrowDown") {
event.preventDefault();
this._recall(1);
} else if (event.key === "Escape") {
this.close();
}
}
_recall(direction) {
if (!this.history.length) return;
this.historyIndex = Math.max(0, Math.min(this.history.length, this.historyIndex + direction));
this.input.value = this.history[this.historyIndex] || "";
}
_submit(raw) {
const text = (raw || "").trim();
if (!text) return;
this.history.push(text);
this.historyIndex = this.history.length;
const lowered = text.toLowerCase();
if (CLEAR_WORDS.has(lowered)) {
this._resetConversation();
return;
}
if (CLOSE_WORDS.has(lowered)) {
this.close();
return;
}
if (text.startsWith("/")) {
this._local(text);
return;
}
this._userLine(text);
if (!this.socket.send({ type: "input", text })) {
this._error("not connected.");
}
}
_resetConversation() {
this.output.innerHTML = "";
this.socket.send({ type: "reset" });
}
async _local(text) {
const [command, ...rest] = text.slice(1).split(" ");
const argument = rest.join(" ").trim();
const avatar = this._avatar();
if (command === "help") {
this._agent(HELP);
} else if (command === "clear") {
this.output.innerHTML = "";
} else if (command === "show") {
this._userLine(text);
if (avatar) await avatar.execute("show", {});
} else if (command === "hide") {
this._userLine(text);
if (avatar) await avatar.execute("hide", {});
} else if (command === "say") {
this._userLine(text);
if (avatar) await avatar.execute("speak", { text: argument });
} else if (command === "character") {
this._userLine(text);
if (avatar) await avatar.execute("switch_character", { name: argument });
} else {
this._error(`unknown command: /${command} (try /help)`);
}
}
async _onMessage(message) {
if (message.type === "reply") {
this._agent(message.text);
} else if (message.type === "history") {
this._renderHistory(message.messages);
} else if (message.type === "clear") {
this.output.innerHTML = "";
} else if (message.type === "status") {
this._notice(message.text);
} else if (message.type === "trace") {
this._trace(message);
} else if (message.type === "error") {
this._error(message.text);
} else if (message.type === "task") {
this._task(message);
} else if (message.type === "avatar" || message.type === "avatar_query") {
await this._avatarRequest(message);
} else if (message.type === "client") {
await this._clientRequest(message);
} else if (message.type === "auth") {
this._onAuth(message);
}
}
_onAuth(message) {
if (message.action === "logout") {
window.location.assign("/auth/logout");
} else if (message.action === "adopt") {
const next = location.pathname + location.search;
window.location.assign("/devii/adopt?next=" + encodeURIComponent(next));
}
}
async _fetchIdentity() {
try {
const response = await fetch("/devii/session", { credentials: "same-origin" });
const data = await response.json();
return { loggedIn: !!data.loggedIn, username: data.username || "" };
} catch (error) {
return null;
}
}
async _checkIdentity() {
if (this._checkingIdentity || !this._identity) return;
this._checkingIdentity = true;
try {
const now = await this._fetchIdentity();
if (now && (now.loggedIn !== this._identity.loggedIn || now.username !== this._identity.username)) {
window.location.reload();
}
} finally {
this._checkingIdentity = false;
}
}
async _clientRequest(message) {
let result;
try {
result = await this.deviiClient.execute(message.action, message.args || {});
} catch (error) {
result = { error: String(error && error.message ? error.message : error) };
}
this.socket.send({ type: "client_result", id: message.id, result });
}
async _avatarRequest(message) {
const avatar = this._avatar();
let result = { error: "no avatar attached" };
if (avatar) {
try {
result = await avatar.execute(message.action, message.args || {});
} catch (error) {
result = { error: String(error && error.message ? error.message : error) };
}
}
this.socket.send({ type: "avatar_result", id: message.id, result });
}
_avatar() {
if (this._avatarEl && this._avatarEl.isConnected) return this._avatarEl;
const selector = this.getAttribute("avatar");
if (selector) {
const found = document.getElementById(selector) || document.querySelector(selector);
if (found) {
this._avatarEl = found;
return found;
}
}
let element = document.querySelector("devii-avatar");
if (!element) {
element = document.createElement("devii-avatar");
if (selector && /^[a-zA-Z][\w-]*$/.test(selector)) element.id = selector;
document.body.appendChild(element);
}
this._avatarEl = element;
return element;
}
_append(node) {
this.output.appendChild(node);
this.output.scrollTop = this.output.scrollHeight;
}
_onOutputClick(event) {
const messageButton = event.target.closest(".devii-copy");
if (messageButton) {
this._copyText(messageButton._source || "", messageButton);
return;
}
const button = event.target.closest(".md-copy");
if (!button) return;
const wrap = button.closest(".md-codewrap");
const code = wrap && wrap.querySelector("code");
if (!code) return;
this._copyText(code.textContent, button);
}
_attachCopy(line, source) {
const button = document.createElement("button");
button.type = "button";
button.className = "devii-copy";
button.title = "Copy markdown source";
button.textContent = "Copy";
button._source = source;
line.appendChild(button);
}
_copyText(text, button) {
const label = button.textContent;
const done = () => {
button.textContent = "Copied";
button.classList.add("md-copied");
window.setTimeout(() => {
button.textContent = label;
button.classList.remove("md-copied");
}, 1200);
};
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(done, () => this._fallbackCopy(text, done));
} else {
this._fallbackCopy(text, done);
}
}
_fallbackCopy(text, done) {
const area = document.createElement("textarea");
area.value = text;
area.style.position = "fixed";
area.style.opacity = "0";
document.body.appendChild(area);
area.select();
try {
document.execCommand("copy");
done();
} catch (error) {
// copy unavailable; leave the button unchanged
}
document.body.removeChild(area);
}
_userLine(text) {
const line = document.createElement("div");
line.className = "devii-line devii-user";
line.textContent = `you> ${text}`;
this._attachCopy(line, text);
this._append(line);
}
_renderHistory(messages) {
this.output.innerHTML = "";
(messages || []).forEach((message) => {
if (message.role === "user") {
this._userLine(message.content);
} else if (message.role === "assistant" && message.content) {
this._agent(message.content);
}
});
}
_agent(text) {
const block = document.createElement("div");
block.className = "devii-line devii-agent";
block.innerHTML = this.markdown.render(text);
this._attachCopy(block, text);
this._append(block);
}
_notice(text) {
const line = document.createElement("div");
line.className = "devii-line devii-notice";
line.textContent = text;
this._append(line);
}
_error(text) {
const line = document.createElement("div");
line.className = "devii-line devii-error";
line.textContent = `[error] ${text}`;
this._append(line);
}
_task(message) {
const verbs = { start: "running...", done: "finished", finished: "finished", error: "failed" };
const head = document.createElement("div");
head.className = "devii-line devii-task";
head.textContent = `[task ${message.label}] ${verbs[message.kind] || message.kind}`;
this._append(head);
if (message.payload && message.kind !== "start") {
this._agent(message.payload);
}
}
_trace(message) {
const glyph = TRACE_GLYPHS[message.event] || message.event;
const line = document.createElement("div");
line.className = "devii-line devii-trace";
if (message.event === "call" || message.event === "ok" || message.event === "err") {
const detail = message.detail ? ` ${message.detail}` : "";
line.textContent = ` ${glyph} ${message.name}${detail}`;
} else {
line.textContent = ` ! ${glyph}`;
}
this._append(line);
}
}
customElements.define("devii-terminal", DeviiTerminalElement);
+108
View File
@@ -0,0 +1,108 @@
// retoor <retoor@molodetz.nl>
const KEYWORDS = new Set([
"const", "let", "var", "function", "return", "if", "else", "for", "while", "do",
"switch", "case", "break", "continue", "class", "extends", "new", "import", "from",
"export", "default", "async", "await", "try", "catch", "finally", "throw", "typeof",
"instanceof", "in", "of", "this", "super", "yield", "static", "get", "set", "void",
"delete", "def", "elif", "lambda", "pass", "with", "as", "global", "nonlocal",
"assert", "raise", "except", "and", "or", "not", "is", "self", "match", "use", "pub",
"impl", "fn", "struct", "enum", "trait", "mut", "func", "package", "interface", "type",
"public", "private", "protected", "echo", "then", "fi", "done", "esac",
]);
const LITERALS = new Set([
"true", "false", "null", "undefined", "None", "True", "False", "nil", "NaN", "Infinity",
]);
const DQUOTE = String.fromCharCode(34);
const SQUOTE = String.fromCharCode(39);
const BTICK = String.fromCharCode(96);
const BSLASH = String.fromCharCode(92);
const ID_START = /[A-Za-z_$]/;
const ID_PART = /[A-Za-z0-9_$]/;
const DIGIT = /[0-9]/;
const NUM_PART = /[0-9_.]/;
export default class Highlighter {
escape(text) {
return text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.split(String.fromCharCode(34))
.join("&quot;");
}
_span(kind, text) {
return '<span class="tok-' + kind + '">' + this.escape(text) + "</span>";
}
highlight(code) {
const s = String(code);
const n = s.length;
let out = "";
let i = 0;
while (i < n) {
const c = s[i];
if (c === "/" && s[i + 1] === "/") {
let j = i + 2;
while (j < n && s[j] !== "\n") j += 1;
out += this._span("com", s.slice(i, j));
i = j;
} else if (c === "#") {
let j = i + 1;
while (j < n && s[j] !== "\n") j += 1;
out += this._span("com", s.slice(i, j));
i = j;
} else if (c === "/" && s[i + 1] === "*") {
let j = i + 2;
while (j < n && !(s[j] === "*" && s[j + 1] === "/")) j += 1;
j = Math.min(n, j + 2);
out += this._span("com", s.slice(i, j));
i = j;
} else if (c === DQUOTE || c === SQUOTE || c === BTICK) {
const quote = c;
let j = i + 1;
while (j < n) {
if (s[j] === BSLASH) {
j += 2;
} else if (s[j] === quote) {
j += 1;
break;
} else {
j += 1;
}
}
out += this._span("str", s.slice(i, j));
i = j;
} else if (DIGIT.test(c)) {
let j = i + 1;
while (j < n && NUM_PART.test(s[j])) j += 1;
if (s[j] === "e" || s[j] === "E") {
j += 1;
if (s[j] === "+" || s[j] === "-") j += 1;
while (j < n && DIGIT.test(s[j])) j += 1;
}
out += this._span("num", s.slice(i, j));
i = j;
} else if (ID_START.test(c)) {
let j = i + 1;
while (j < n && ID_PART.test(s[j])) j += 1;
const word = s.slice(i, j);
if (KEYWORDS.has(word)) {
out += this._span("kw", word);
} else if (LITERALS.has(word)) {
out += this._span("lit", word);
} else {
out += this.escape(word);
}
i = j;
} else {
out += this.escape(c);
i += 1;
}
}
return out;
}
}
+201
View File
@@ -0,0 +1,201 @@
// retoor <retoor@molodetz.nl>
import Highlighter from "./highlighter.js";
const SAFE_HREF = /^(https?:\/\/|\/|#|mailto:)/i;
export default class Markdown {
constructor() {
this.highlighter = new Highlighter();
}
escape(text) {
return text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.split(String.fromCharCode(34))
.join("&quot;");
}
renderInline(text) {
const tick = String.fromCharCode(96);
const codeRe = new RegExp(tick + "([^" + tick + "]+)" + tick, "g");
let out = this.escape(text);
out = out.replace(codeRe, (_m, code) => `<code class="md-code">${code}</code>`);
const imgRe = new RegExp("!\\[([^\\]]*)\\]\\(([^)\\s]+)\\)", "g");
out = out.replace(imgRe, (_m, alt, src) => this._image(alt, src));
const linkRe = new RegExp("\\[([^\\]]+)\\]\\(([^)\\s]+)\\)", "g");
out = out.replace(linkRe, (_m, label, href) => this._link(label, href));
out = out.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
out = out.replace(/__([^_]+)__/g, "<strong>$1</strong>");
out = out.replace(/(^|[^*])\*([^*\s][^*]*?)\*/g, "$1<em>$2</em>");
out = out.replace(/~~([^~]+)~~/g, "<del>$1</del>");
return out;
}
_link(label, href) {
if (!SAFE_HREF.test(href)) {
return this.escape(label);
}
return `<a href="${href}" target="_blank" rel="noopener noreferrer">${label}</a>`;
}
_image(alt, src) {
if (!SAFE_HREF.test(src)) {
return this.escape(alt);
}
return `<img class="md-img" src="${src}" alt="${this.escape(alt)}" loading="lazy">`;
}
_isTableSeparator(line) {
if (!line || line.indexOf("-") === -1 || line.indexOf("|") === -1) {
return false;
}
const cells = this._tableCells(line);
return cells.length > 0 && cells.every((cell) => /^:?-+:?$/.test(cell));
}
_tableCells(line) {
let body = line.trim();
if (body.startsWith("|")) body = body.slice(1);
if (body.endsWith("|")) body = body.slice(0, -1);
return body.split("|").map((cell) => cell.trim());
}
_tableAligns(line) {
return this._tableCells(line).map((spec) => {
const left = spec.startsWith(":");
const right = spec.endsWith(":");
if (left && right) return "center";
if (right) return "right";
if (left) return "left";
return "";
});
}
_table(header, aligns, rows) {
const style = (index) => (aligns[index] ? ` style="text-align:${aligns[index]}"` : "");
const head = header.map((cell, i) => `<th${style(i)}>${this.renderInline(cell)}</th>`).join("");
const body = rows
.map((row) => "<tr>" + header.map((_c, i) => `<td${style(i)}>${this.renderInline(row[i] || "")}</td>`).join("") + "</tr>")
.join("");
return `<table class="md-table"><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table>`;
}
_codeBlock(lines, lang) {
const body = this.highlighter.highlight(lines.join("\n"));
const label = lang ? ` data-lang="${this.escape(lang)}"` : "";
return (
'<div class="md-codewrap">' +
'<button class="md-copy" type="button" title="Copy" aria-label="Copy code">Copy</button>' +
`<pre class="md-pre"${label}><code>${body}</code></pre>` +
"</div>"
);
}
render(text) {
const FENCE = String.fromCharCode(96).repeat(3);
const lines = String(text || "").split("\n");
const blocks = [];
let paragraph = [];
let list = null;
const flushParagraph = () => {
if (paragraph.length) {
blocks.push(`<p>${paragraph.map((l) => this.renderInline(l)).join("<br>")}</p>`);
paragraph = [];
}
};
const flushList = () => {
if (list) {
blocks.push(`<${list.tag}>${list.items.join("")}</${list.tag}>`);
list = null;
}
};
for (let i = 0; i < lines.length; i += 1) {
const line = lines[i];
if (line.startsWith(FENCE)) {
flushParagraph();
flushList();
const lang = line.slice(FENCE.length).trim();
const buffer = [];
i += 1;
while (i < lines.length && !lines[i].startsWith(FENCE)) {
buffer.push(lines[i]);
i += 1;
}
blocks.push(this._codeBlock(buffer, lang));
continue;
}
if (!line.trim()) {
flushParagraph();
flushList();
continue;
}
if (line.indexOf("|") !== -1 && i + 1 < lines.length && this._isTableSeparator(lines[i + 1])) {
flushParagraph();
flushList();
const header = this._tableCells(line);
const aligns = this._tableAligns(lines[i + 1]);
i += 2;
const rows = [];
while (i < lines.length && lines[i].trim() && lines[i].indexOf("|") !== -1) {
rows.push(this._tableCells(lines[i]));
i += 1;
}
i -= 1;
blocks.push(this._table(header, aligns, rows));
continue;
}
const heading = line.match(/^(#{1,6})\s+(.*)$/);
if (heading) {
flushParagraph();
flushList();
const level = heading[1].length;
blocks.push(`<h${level}>${this.renderInline(heading[2])}</h${level}>`);
continue;
}
if (/^([-*_])\1{2,}$/.test(line.trim())) {
flushParagraph();
flushList();
blocks.push("<hr>");
continue;
}
if (/^>\s?/.test(line)) {
flushParagraph();
flushList();
blocks.push(`<blockquote>${this.renderInline(line.replace(/^>\s?/, ""))}</blockquote>`);
continue;
}
const ordered = line.match(/^\s*(\d+)\.\s+(.*)$/);
const unordered = line.match(/^\s*[-*+]\s+(.*)$/);
if (ordered || unordered) {
flushParagraph();
const tag = ordered ? "ol" : "ul";
const content = ordered ? ordered[2] : unordered[1];
if (!list || list.tag !== tag) {
flushList();
list = { tag, items: [] };
}
list.items.push(`<li>${this.renderInline(content)}</li>`);
continue;
}
flushList();
paragraph.push(line);
}
flushParagraph();
flushList();
return blocks.join("\n");
}
}
+520
View File
@@ -0,0 +1,520 @@
// retoor <retoor@molodetz.nl>
import Queue from "./queue.js";
import Animator from "./animator.js";
import Balloon from "./balloon.js";
import { ensureStylesheet } from "./styles.js";
export default class Agent {
constructor(mapUrl, data, sounds) {
ensureStylesheet();
this._queue = new Queue(this._onQueueEmpty.bind(this));
this._el = document.createElement("div");
this._el.className = "md-clippy-agent";
document.body.appendChild(this._el);
this._animator = new Animator(this._el, mapUrl, data, sounds);
this._balloon = new Balloon(this._el);
this._tts = data.tts;
this._setupEvents();
}
gestureAt(x, y) {
const direction = this._getDirection(x, y);
const gestureAnim = "Gesture" + direction;
const lookAnim = "Look" + direction;
const animation = this.hasAnimation(gestureAnim) ? gestureAnim : lookAnim;
return this.play(animation);
}
hide(fast, callback) {
this._hidden = true;
const el = this._el;
this.stop();
if (fast) {
this._el.style.display = "none";
this.stop();
this.pause();
if (callback) callback();
return;
}
return this._playInternal("Hide", function () {
el.style.display = "none";
this.pause();
if (callback) callback();
});
}
moveTo(x, y, duration) {
const direction = this._getDirection(x, y);
const anim = "Move" + direction;
if (duration === undefined) duration = 1000;
this._addToQueue(function (complete) {
const clamped = this._clampXY(x, y);
const cx = clamped.x;
const cy = clamped.y;
if (duration === 0) {
this._el.style.top = cy + "px";
this._el.style.left = cx + "px";
this.reposition();
complete();
return;
}
if (!this.hasAnimation(anim)) {
this._animate(this._el, { top: cy, left: cx }, duration, complete);
return;
}
const callback = (name, state) => {
if (state === Animator.States.EXITED) {
complete();
}
if (state === Animator.States.WAITING) {
this._animate(this._el, { top: cy, left: cx }, duration, () => {
this._animator.exitAnimation();
});
}
};
this._playInternal(anim, callback);
}, this);
}
_animate(element, props, duration, callback) {
const start = performance.now();
const startProps = {};
for (const prop in props) {
const currentValue = parseFloat(getComputedStyle(element)[prop]) || 0;
startProps[prop] = currentValue;
}
const swing = (p) => 0.5 - Math.cos(p * Math.PI) / 2;
const animate = (currentTime) => {
const elapsed = currentTime - start;
const progress = Math.min(elapsed / duration, 1);
const eased = swing(progress);
for (const prop in props) {
const startValue = startProps[prop];
const endValue = props[prop];
const currentValue = startValue + (endValue - startValue) * eased;
element.style[prop] = currentValue + "px";
}
if (progress < 1) {
requestAnimationFrame(animate);
} else if (callback) {
callback();
}
};
requestAnimationFrame(animate);
}
_playInternal(animation, callback) {
if (this._isIdleAnimation() && this._idlePromise) {
this._idlePromise.then(() => {
this._playInternal(animation, callback);
});
return;
}
this._animator.showAnimation(animation, callback);
}
play(animation, timeout, cb) {
if (!this.hasAnimation(animation)) return false;
if (timeout === undefined) timeout = 5000;
this._addToQueue(function (complete) {
let completed = false;
const callback = function (name, state) {
if (state === Animator.States.EXITED) {
completed = true;
if (cb) cb();
complete();
}
};
if (timeout) {
window.setTimeout(() => {
if (completed) return;
this._animator.exitAnimation();
}, timeout);
}
this._playInternal(animation, callback);
}, this);
return true;
}
show(fast) {
this._hidden = false;
if (fast) {
this._el.style.display = "block";
this.resume();
this._onQueueEmpty();
return;
}
const style = getComputedStyle(this._el);
if (style.top === "auto" || style.left === "auto") {
const left = (window.innerWidth - this._el.offsetWidth) / 2;
const top = 40;
const clamped = this._clampXY(left, top);
this._el.style.top = clamped.y + "px";
this._el.style.left = clamped.x + "px";
}
this._el.style.display = "block";
this.resume();
return this.play("Show");
}
speak(text, options) {
this._addToQueue(function (complete) {
this._balloon.speak(complete, text, options && options.hold);
if (options && options.tts) this._speakTTS(text);
}, this);
}
async speakStream(source, options) {
this.stop();
let text = "";
const stream = this._balloon.speakStream(() => {
this._onQueueEmpty();
});
for await (const chunk of source) {
text += chunk;
stream.push(chunk);
}
if (options && options.tts && text) this._speakTTS(text);
stream.done();
}
closeBalloon() {
this._balloon.hide();
}
delay(time) {
time = time || 250;
this._addToQueue(function (complete) {
this._onQueueEmpty();
window.setTimeout(complete, time);
});
}
stopCurrent() {
this._animator.exitAnimation();
this._balloon.close();
}
stop() {
this._queue.clear();
this._animator.exitAnimation();
this._balloon.hide();
if (this._tts && "speechSynthesis" in window) {
speechSynthesis.cancel();
}
}
hasAnimation(name) {
return this._animator.hasAnimation(name);
}
animations() {
return this._animator.animations();
}
animate() {
const animations = this.animations();
const anim = animations[Math.floor(Math.random() * animations.length)];
if (anim.indexOf("Idle") === 0) {
return this.animate();
}
return this.play(anim);
}
_getDirection(x, y) {
const rect = this._el.getBoundingClientRect();
const h = this._el.offsetHeight;
const w = this._el.offsetWidth;
const centerX = rect.left + w / 2;
const centerY = rect.top + h / 2;
const a = centerY - y;
const b = centerX - x;
const r = Math.round((180 * Math.atan2(a, b)) / Math.PI);
if (-45 <= r && r < 45) return "Right";
if (45 <= r && r < 135) return "Up";
if ((135 <= r && r <= 180) || (-180 <= r && r < -135)) return "Left";
if (-135 <= r && r < -45) return "Down";
return "Top";
}
_onQueueEmpty() {
if (this._hidden || this._isIdleAnimation()) return;
const idleAnim = this._getIdleAnimation();
this._idlePromise = new Promise((resolve) => {
this._idleResolve = resolve;
});
this._animator.showAnimation(idleAnim, this._onIdleComplete.bind(this));
}
_onIdleComplete(name, state) {
if (state === Animator.States.EXITED) {
if (this._idleResolve) {
this._idleResolve();
}
this._idlePromise = null;
this._idleResolve = null;
}
}
_isIdleAnimation() {
const c = this._animator.currentAnimationName;
return c && c.indexOf("Idle") === 0;
}
_getIdleAnimation() {
const animations = this.animations();
const idle = [];
for (let i = 0; i < animations.length; i++) {
const a = animations[i];
if (a.indexOf("Idle") === 0) {
idle.push(a);
}
}
const idx = Math.floor(Math.random() * idle.length);
return idle[idx];
}
_setupEvents() {
this._resizeHandle = this.reposition.bind(this);
this._pointerDownHandle = this._onPointerDown.bind(this);
this._dblClickHandle = this._onDoubleClick.bind(this);
window.addEventListener("resize", this._resizeHandle);
this._el.addEventListener("mousedown", this._pointerDownHandle);
this._el.addEventListener("touchstart", this._pointerDownHandle, { passive: false });
this._el.addEventListener("dblclick", this._dblClickHandle);
}
_onDoubleClick() {
if (!this.play("ClickedOn")) {
this.animate();
}
}
reposition() {
const style = getComputedStyle(this._el);
if (style.display === "none") return;
if (style.visibility === "hidden") return;
if (style.width === "0" || style.height === "0") return;
const o = this._el.getBoundingClientRect();
const bH = this._el.offsetHeight;
const bW = this._el.offsetWidth;
const wW = window.innerWidth;
const wH = window.innerHeight;
let top = o.top;
let left = o.left;
const m = 5;
if (top - m < 0) {
top = m;
} else if (top + bH + m > wH) {
top = wH - bH - m;
}
if (left - m < 0) {
left = m;
} else if (left + bW + m > wW) {
left = wW - bW - m;
}
this._el.style.left = left + "px";
this._el.style.top = top + "px";
this._balloon.reposition();
}
_onPointerDown(e) {
e.preventDefault();
this._startDrag(e);
}
_startDrag(e) {
this.pause();
this._balloon.hide(true);
this._offset = this._calculateClickOffset(e);
this._moveHandle = this._dragMove.bind(this);
this._upHandle = this._finishDrag.bind(this);
window.addEventListener("mousemove", this._moveHandle);
window.addEventListener("mouseup", this._upHandle);
window.addEventListener("touchmove", this._moveHandle, { passive: false });
window.addEventListener("touchend", this._upHandle);
this._dragUpdateLoop = window.setTimeout(this._updateLocation.bind(this), 10);
}
_calculateClickOffset(e) {
const point = "touches" in e ? e.touches[0] : e;
const o = this._el.getBoundingClientRect();
return {
top: point.pageY - (o.top + window.pageYOffset),
left: point.pageX - (o.left + window.pageXOffset),
};
}
_updateLocation() {
this._el.style.top = this._targetY + "px";
this._el.style.left = this._targetX + "px";
this._dragUpdateLoop = window.setTimeout(this._updateLocation.bind(this), 10);
}
_dragMove(e) {
e.preventDefault();
const point = "touches" in e ? e.touches[0] : e;
const x = point.clientX - this._offset.left;
const y = point.clientY - this._offset.top;
this._targetX = x;
this._targetY = y;
this._clampTarget();
}
_finishDrag() {
window.clearTimeout(this._dragUpdateLoop);
window.removeEventListener("mousemove", this._moveHandle);
window.removeEventListener("mouseup", this._upHandle);
window.removeEventListener("touchmove", this._moveHandle);
window.removeEventListener("touchend", this._upHandle);
this._balloon.show();
this.reposition();
this.resume();
}
_clampTarget() {
const m = 5;
const bW = this._el.offsetWidth;
const bH = this._el.offsetHeight;
const wW = window.innerWidth;
const wH = window.innerHeight;
this._targetX = Math.max(m, Math.min(this._targetX, wW - bW - m));
this._targetY = Math.max(m, Math.min(this._targetY, wH - bH - m));
}
_clampXY(x, y) {
const m = 5;
const bW = this._el.offsetWidth;
const bH = this._el.offsetHeight;
const wW = window.innerWidth;
const wH = window.innerHeight;
return {
x: Math.max(m, Math.min(x, wW - bW - m)),
y: Math.max(m, Math.min(y, wH - bH - m)),
};
}
_addToQueue(func, scope) {
if (scope) func = func.bind(scope);
this._queue.queue(func);
}
_speakTTS(text) {
if (!this._tts || !("speechSynthesis" in window)) return;
speechSynthesis.cancel();
const utterance = new SpeechSynthesisUtterance(text.replaceAll("\n", " "));
utterance.rate = this._tts.rate;
utterance.pitch = this._tts.pitch;
const voices = speechSynthesis.getVoices();
if (voices.length > 0) {
const match = voices.find((v) => v.name.includes(this._tts.voice));
if (match) utterance.voice = match;
speechSynthesis.speak(utterance);
} else {
speechSynthesis.addEventListener(
"voiceschanged",
() => {
const v = speechSynthesis.getVoices();
const match = v.find((voice) => voice.name.includes(this._tts.voice));
if (match) utterance.voice = match;
speechSynthesis.speak(utterance);
},
{ once: true },
);
}
}
dispose() {
this.stop();
window.removeEventListener("resize", this._resizeHandle);
window.clearTimeout(this._dragUpdateLoop);
window.removeEventListener("mousemove", this._moveHandle);
window.removeEventListener("mouseup", this._upHandle);
window.removeEventListener("touchmove", this._moveHandle);
window.removeEventListener("touchend", this._upHandle);
this._animator.dispose();
this._balloon.dispose();
this._queue.dispose();
this._el.remove();
}
pause() {
this._animator.pause();
this._balloon.pause();
}
resume() {
this._animator.resume();
this._balloon.resume();
}
}
export async function initAgent(loaders) {
const [{ default: data }, { default: map }, sounds] = await Promise.all([
loaders.agent(),
loaders.map(),
_loadSounds(loaders),
]);
return new Agent(map, data, sounds);
}
async function _loadSounds(loaders) {
const audio = document.createElement("audio");
const canPlayMp3 = !!audio.canPlayType && audio.canPlayType("audio/mp3") !== "";
if (!canPlayMp3) return {};
const m = await loaders.sound();
return m.default;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,8 @@
// retoor <retoor@molodetz.nl>
const loader = {
agent: () => import("./agent.js"),
sound: () => import("./sounds.js"),
map: () => Promise.resolve({ default: new URL("./map.png", import.meta.url).href }),
};
export default loader;
Binary file not shown.

After

Width:  |  Height:  |  Size: 815 KiB

File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,8 @@
// retoor <retoor@molodetz.nl>
const loader = {
agent: () => import("./agent.js"),
sound: () => import("./sounds.js"),
map: () => Promise.resolve({ default: new URL("./map.png", import.meta.url).href }),
};
export default loader;
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

@@ -0,0 +1,18 @@
// retoor <retoor@molodetz.nl>
export default {
1: "data:audio/mpeg;base64,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",
2: "data:audio/mpeg;base64,SUQzBAAAAAAAGFRTU0UAAAAOAAADTGF2ZjU0LjUuMTAwAP/jMMAAAAAAAAAAAABJbmZvAAAABwAAAAsAAAUZADMzMzMzMzMzM0dHR0dHR0dHR1xcXFxcXFxcXHBwcHBwcHBwcIWFhYWFhYWFhZmZmZmZmZmZma6urq6urq6ursLCwsLCwsLCwtfX19fX19fX1+vr6+vr6+vr6////////////wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP/jIMQAERgCxlVBGABpeOEAAQEB+CByCETn3ZQEwfB94IA+D4Ph8Rg/BAMeDgIHOoMet/+D4IHPLg/B8H/E4P5cHwfBwEAQBDzn3fynl3//ggrfoNfAMmppHbcjFOkREnlKl3EWUWcP/+MixBYXygrsAYMoADJOOPZBEMEGynGmcxHQSdnQz0LSJndrpxAwldtSLftPIssh2noyPZBHNi7wneDYIgFDREVPO8gaGPzr5SAYuwUHf1f/hYc8KvXVqqmazxvyD9Vh5qwPHcpUUKRM/+MgxBIWmVbZi8MYANAIHsbKDBp0kigU/CrxyjU4wMur/5mO1VcrhQ0WSCo8sVO5FT4KuWE8OFgaPFeDQMnTtzXckE3ezhoaeCgURjRqx0qGv/d/uUrRHxbLSRUikq9WmJNhVUxjqjP/4yLEEhhh/rDCSMaYSq5MKUTkEUBEt4VFEsRdKhhRsBAWFUyZDvlqFAc+W5QCPyz857ExlIzQvOMZcqleUBNK0iIBNBqJopLXuc+vRlk0aEWI72sb2Wt/c4n9gEotfYCYcYVVoZSGAg7/4yDEDBawsqDKGMZMDUMUagJWplWxlBCuQTsKMGzBPWJwZERNaBYubHB4KHWzqa1xAIHiEOgZo0JEQ8wXAyFNehy0H2Lok+gpQ526uPrRu1uFvS4XculbXf0srh//wJ5hbCsZUEhRqP/jIsQME9AuoMoQRAARmnkToWeDFqaT8WeA1lC0UeZi9k+KiECKKo3UqFN0kapQkJpXEM7ZPtHdiE77LGNfTUqPsWBEajbnrNZBNjVy9xRqakv///9lZZ0z2BTeWaEowJg9UBmhA84+Hv/jIMQYFGheqZQIxiigaWHKCLybxzRUMPTeah5tAwHjLgyRiYIIa+xSVqKp8xFAOlSqdrPftUldC7ZmRRMpU+99yDSf/Y9dBoqqqfIdKiNIO0Lg2BBUCC4DEoGQpTSgYFz0wBDYIvYg/+MixCEUqAahkgBEAKnwoJhPIlUHzh9WPeGHrPUFXvt1HETKDTDdMTbmXTECDUJlUzyid/Why419qUKSk19KB3//8GST5FOcW4RCyd0kJxA57iaG3XMjgxKCp8BtWfEFCb19Vx/eyHyL/+MgxCoUcJ6kAUEQADuIHwiFwIJ2H5RY51z3ROQCwAAZRlsPOWWBAToUTKCeGFr+gPkBAJ4CIYYIBgQCyBCb1XyhPMJCb8HscB7/jw8OiKFP/NOKnkV//c0bDYqKRt//joiuAKDwVA//4yLEMxijbpj9gjgBhkB3//8fOMc0IQlZRQItfp//7IPD5Sw2KCUxopLJ////+OkUKmTXKjhVUGzoJ3AqTEFNRTMuOTkuNaqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqr/4yDELAAAA0gBwAAAqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqg==",
3: "data:audio/mpeg;base64,SUQzBAAAAAAAGFRTU0UAAAAOAAADTGF2ZjU0LjUuMTAwAP/jMMAAAAAAAAAAAABJbmZvAAAABwAAAAsAAAUZADMzMzMzMzMzM0dHR0dHR0dHR1xcXFxcXFxcXHBwcHBwcHBwcIWFhYWFhYWFhZmZmZmZmZmZma6urq6urq6ursLCwsLCwsLCwtfX19fX19fX1+vr6+vr6+vr6////////////wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP/jIMQAEkB+eAlGSAIn//aP4jO93bXbEAAAAAgeCAYJGHfLggCAIAgCYPn8oCAYQX+c6wfwQd///E4EBAEHcoCAJg+D4Pn//gg4oCH/4P8oCDsMeJwfQ89XeJbWwxIOXMB8lFxKUAIC/+MixBIU4JasAYZIAMszld2+yeo2QEYqJab3FRVmRR45aj41iHXpcezzNNFEVeZLMTHnmTNS0Z3CBsYhlnMmmlFuhzvYRvF21r87JyKplakAKqr7j9bzuHJEkb30znIvIxgcXJUF3YAq/+MgxBoZiP6pk89AAM5oysIDRQUIHB0FXPhYieKdd7qbtu0XciXoqW95pyErsOPYVehpsHTwCGmxFFK0OJHHvXJs3re9rK5XajliGN3bOsi77967FtSLMfQq4D/41inbGjkCtnkMZXX/4yLEDhfYpqzKHh5M5qXLARxpIjapmWpOm0lA8B5Hg2u6q9yc9z41SYwYA8fFBV5xziZ8o5LE1urymoHGOsJVNTaj7cwbRa9VLdleK1q0JPSK7X2dNiscrWmb7EC9VZAACqqqnJzkMt7/4yDEChbw7rmUZgxwpIFYJ0nF8AZAYsChNfQ0BaNX60EcYvHotT1r7sZVr6AqrJtxusme8F3rP+Z1FaKhpj0Xm1T6xrFr0Vq3bS2iTOeR1fu269/3/Z+v9VNcl33opbEAATLdtttqrv/jIsQJFYia1l4zzEzYEoUsS8Vj5mLELOKSiXCEzRXEdvpnma7LLxiR4GwTCaQCeYHSI5ryqAUIjluQy/4oh91NNHRsJDbu39V0dU5aEspOsr9NdCquPZ9X9aqNAFS222222qVCNkVSB//jIMQOFHBe8l4IxiqGoZiXwJAmEJcRLAYQPC+5pRT09SHYbKRZE4lyHMMqHbDBEsTchU0hiHVTStuw/t0Of0CJC219FOd67e0UdUeSiUueSgX/eV9lIOKQsyUyACiFtDmxvQxjjVc4/+MixBcUiHKoyhvGSCQUjUAKZQmWvQkWfZO5JpxOxJpa0UBZIfaetb1elgx7Hp6ERZuXfcZQEnWvOWLaqvpZal9VWukffHu0VaqqqRGP//5FCIiB0Mno4YzELOHhCu5Qo9zlvXURtCCG/+MgxCATkFqoVUkQAIXSzQdYdvkw7quFkUKZFiUbIk1PM0OFHLc6srWpp2mR93oRnmVOYadsXkaN/ZpVAAggQAEScnCUPETCmBZ9B8v8487/NSrf+QmExzr/+QlDyEtN//wlCcIIbjj/4yLELBczdogVilAASkxqf//qLmIR6RKzCsI5F///+4lFgbiqlCcViYmFYjZP////yIvOIdDTTEPMVpEPnUxBTUUzLjk5LjVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVX/4yDEKwAAA0gBwAAAVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVQ==",
4: "data:audio/mpeg;base64,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",
5: "data:audio/mpeg;base64,SUQzBAAAAAAAGFRTU0UAAAAOAAADTGF2ZjU0LjUuMTAwAP/jMMAAAAAAAAAAAABJbmZvAAAABwAAAAkAAARIADw8PDw8PDw8PDw8VVVVVVVVVVVVVVVtbW1tbW1tbW1tbYaGhoaGhoaGhoaGnp6enp6enp6enp62tra2tra2tra2ts/Pz8/Pz8/Pz8/P5+fn5+fn5+fn5+f//////////////wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP/jIMQAE7nidAFBEAD/gAv/jG/1PnOc/5znOc53b/ITOc53/zgAAII055z/+QhCEIc5zvoQDFuIAQBA/BB2D79QIAgCYPn/xIcxAD4Pn/4gBBwOAgc/qBAMQxggVV/Pc++tvRmalfpM/+MixAwUMhq4AYFQANFSzKe6k7GmLCQSSqERG1RYLI46h4s5osGDgiiUqQ2M9Dj1OMZVc8fqcTmuYTohImqegtqTNCwu4TChX/zjP1OZ/6vvpnLbbbboqkjZtTsrV1NKyUHAV+S0JCRN/+MgxBcaAg6+V8JAAMwa0ptbVUPD3N99/8XdfRIuFZ6xIJSYb+u55lY4Xhvqf2uZNq4nYf3dxf7GnmNsULVDlCMilwXU4q4UmEZokSS2lAqtNDCTFR6337V2jWL3m7//5A7miSjMpZL/4yLEChXRarGUCMaUAihSZnsZ0yQF+TmiddxYgAEIiIiO/E+aIhFyQHTtUtRTGBgwpCCEyR4KAIDPQiL8XADBUTtZUK6iTOxN7HPtplyFf7Siv0bEXf27GLpqcul2kAac7pfhowoUMRT/4yDEDheBErZcGYxwZOOaSZLvKLRp1SRCc+HBSW55ma3tXeZqqpJ581kmyx0isFTtqBKDTxQUzrgqRkKWUsA8JW1C+bbfRt1RoCetSbGOQJXyKfYe+rQt0XchyOnDf//fb7a2SgD7kP/jIsQLFHE2+l4IxnLGWYiXd7OX0RNNAjmEimZ17CqbHH9DEG9dKT1ziuaCATHyDir4rIDZIYNcKIWEVOStSe+XA6LUME7Sa59t6tP/9//9e9/QYv///61T9E/1dKyv99SEVz1Svk/kV//jIMQVFWOuwAFAKADrQQIvot1Ogo6znOel5DmazsdxNGZZiiCs90Qgwp0KY18iDahAVMSfYxEVBAokJDXVF0eSX1S1WoPKIqIBYYh+qjr////f//X/0pb//Jmq11T/ZtqEZWVKOcFm/+MixBoYa6bIAYEoAPVCoPSOUB1fdg44CjgiZnW9dIiK4jC2ZRcjiYbOooKuBgmBwI9xrq6h8RNlKXsYRMLZSoVCisfGGGoe+5LDxzKrsLSdLa6stUBACAhBBJIIEBfL8/Wv6sd//Hf//+MgxBQRSp56LYE4AFr//07jq//845zZoqHv//y41HTDzlHkOFI7///+YecaKhsoRCouwaZ//wkwUUFWWMUqTEFNRTMuOTkuNaqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqo=",
6: "data:audio/mpeg;base64,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",
7: "data:audio/mpeg;base64,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",
8: "data:audio/mpeg;base64,SUQzBAAAAAAAGFRTU0UAAAAOAAADTGF2ZjU0LjUuMTAwAP/jMMAAAAAAAAAAAABJbmZvAAAABwAAAAUAAAKmAGJiYmJiYmJiYmJiYmJiYmJiYmKJiYmJiYmJiYmJiYmJiYmJiYmJibGxsbGxsbGxsbGxsbGxsbGxsbGx2NjY2NjY2NjY2NjY2NjY2NjY2Nj//////////////////////////wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP/jIMQAFGGKoidBGACMATACpHGMf/8YAL4Hd3d3f/QvroiIXogGBgYGLf0d3OIAJQiFoEAAAQLd3d3c+uHAwMDcHAQBD+Xg+HwQOf3f4PviB0HwffUCDv/Lg///+nkFVf//JtszOzTM/+MixAkUIX68AYFgAJmZpkztWcq1+S9FnzNYkItj5AE6hWZOEGiHEAwv0K4xLQloIhmRJsRz1uTKxWE5WbDrArisGgWBpfhn/d+/9TP/0f///9/+lUSr3xetKtl6lLso21J+plLU1rvr/+MgxBQWwX6sAYFoAC4J2SxgXaxdNDJBpYUV3SHwKVSiGiiZmKlGzJkUh0zc1nD9Uetda0rKWm3jX8ilYsh9A5exeX891pqtIpdqSxe3FT1uL2XpBQ+nBRHxQW8xo/pKSR/zJMnFpR//4yLEFBezboQBhWgAV81LoJ8kYrV/8zKJFE+LRdEy16K/5RLyLnjU3PK9X/8xpzVTrSMWO////oHVs6aBskkZHD6zb////8wLyJiYGR00QTrMls5go3+pTEFNRTMuOTkuNVVVVVVVVVX/4yDEEQAAA0gBwAAAVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVQ==",
9: "data:audio/mpeg;base64,SUQzBAAAAAAAGFRTU0UAAAAOAAADTGF2ZjU0LjUuMTAwAP/jMMAAAAAAAAAAAABJbmZvAAAABwAAAAwAAAWCAC8vLy8vLy8vQkJCQkJCQkJVVVVVVVVVVWhoaGhoaGhoaHt7e3t7e3t7jo6Ojo6Ojo6hoaGhoaGhoaG0tLS0tLS0tMbGxsbGxsbG2tra2tra2tra7Ozs7Ozs7Oz//////////wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP/jIMQAD1AGfbFBEACo6gABAAEAQBA5qIZyXBB2CYf+IAQU4uH/TqBB3+sEHZcHz/5cH+GPiAEDniN5T8Ez//+oEPygIHPhj5cH1WRrW0okEn7XPctERlZmO/UWVqiIY1J7Pozs6Wur/+MixB0bVAKsAYUoAKOjEvTsplQOVTZjpTVEFkMRBasYR3cdQTF6szudDyPOo4UozlhE7sgYncedkS9vqQRSyoVqoRCtFx8qqyPKm9nPPbXWjvPerTadlrZ++e7D1aKpqqkstq85UYOn/+MgxAsWChKtkco4AM5Wo+qkE/+lXZ6zUflRwylJx9FUwXvzKUeNgXljmeqUfOVaujVShw618748cx/fzSofdXScb1CgPhvwAHGDn7vpG6dAmb8Z4upCtr7kqqqqqqoo57elYiAjaUf/4yLEDRbLXq2SKcS8+56bO7TVMtOXX1MPmXuiURQeiW2/OdB0Ktba9lRJn+ybkFsnR32Urv177X36pdQRL6N0UI7f3ZDtdK60cknv+z/+6Dgm/zRzL5wzeemkVc/////oiyt6X0CeTs3/4yDEDReCxrWUUcq4s9upe9t1ZLi/zrLS5hN3Od9dBMAUQ06XqwDnlv94kuyWXoNF91ZkmzGdmeyOqNUXvuf7ORqbfvP2t0DysY4dJiAa0RtgH+j55zS2oYTcK6Fpb0/gsf29Ktp61P/jIsQKE/OqpAA4jtzwttl+qG9nvzAy+3usGb3/YzvrmeS7N1rspy03z9ntb6Majd+/ktGf1uezGP5ymDoyGF06ohpjv+v7aMnVHOJGIpoLvY6h/////+LHyrZcNq47OgNHXfxNxXc83f/jIMQWF+uqtZRaBNx9daXbenzF8JxXU3HE8UdUTDRT7LC0Ds3KYya5imGulQtDznTRd/R7H2+ZqGlat/1Tf/Usv///qFG/utq+tvy9/9RJDSD0tPa4qgYmvdzIA0chrULE5+BiqhYS/+MixBESEDqgADhUBBlBkAi0yLbyIzw2utli5ZfP2LdYKY66hG0rocIV01oRdYcoINbWoUQPfaxqVqwJc4m6LWP26Vpqv/+Aa2kvWzrazBgttZkJZemU4aeWp9vYeTPQxNz3RqVdPdqP/+MgxCQUacqgykDElHq2anQ7zKdL9qPukLUdSg2cNmSwvE7W7HbCXvbZWctRW7jgrbe9+jmKH8UyD03//0CM74xZg6hgBFZpAEMi54BFw6bFhrHSMjebNQ4Bng+T4DUOWsoo1CblpOH/4yLELROoPqDLQhAAFRpg5oshwzodY9q0r2o3rpYepG5BXzH05pT4unsertKPSgZWSNvHktNvxKbpHvGglDmdzweg5GysvwLD8bg6NHUc35UgRGgGiTQeQ7+OCKFRqOliKHdP88LIPFz/4yDEOiBjeoABizgBkOEBV9WO/8mLTh0gWcUk0OIedm9P/yQ2FAsPQaCQPDRhsRUkcdHhsrDyzlr//4pCg4SB8OOIsmWHhqD4SBlh0sWXh2/vCKFKTEFNRTMuOTkuNaqqqqqqqqqqqv/jIsQTAAADSAHAAACqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqg==",
10: "data:audio/mpeg;base64,SUQzBAAAAAAAGFRTU0UAAAAOAAADTGF2ZjU0LjUuMTAwAP/jMMAAAAAAAAAAAABJbmZvAAAABwAAAAQAAAI+AHNzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc6KioqKioqKioqKioqKioqKioqKioqKioqLR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR/////////////////////////////////wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP/jIMQAETJuillBEAAFGIkCRonmNuQx3KQBjGMbxkIT/9T/znORv/59TnP8jc7/////rO///+hDnOd+cAIcB8H8EAQcGC4PvEYPv/L////+oEEVgkMabVbm66Uvr2vtV3butvmR4WOc/+MixBYZe96sAYU4AMOlOiPVtVez3dGRUsjta1UU2c/+y37uZR0ckp9q77R/7U46BR0HhYaYM6MyIDDpQnKC41rTItZpyFBtn512pfmN26Guqa9+y+bdFcq4U6w6ggLSHguHfQ/x5/5p/+MgxAwWu3Z8CYc4AKyf5ynKb/46YSHgfE//x4lnHf/9Dh1kNNNHv//xsIw6RFRpQbR0ahD///+ASNAhByWYIhKHiQpHiR3////9JxJR0iaPDYqPHkR0wRhqUG1MQU1FMy45OS41VVX/4yLEDAAAA0gBwAAAVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVU=",
11: "data:audio/mpeg;base64,SUQzBAAAAAAAGFRTU0UAAAAOAAADTGF2ZjU0LjUuMTAwAP/jMMAAAAAAAAAAAABJbmZvAAAABwAAAAwAAAWCAC8vLy8vLy8vQkJCQkJCQkJVVVVVVVVVVWhoaGhoaGhoaHt7e3t7e3t7jo6Ojo6Ojo6hoaGhoaGhoaG0tLS0tLS0tMbGxsbGxsbG2tra2tra2tra7Ozs7Ozs7Oz//////////wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP/jIMQAEegCcjFBEAARCpTGYDETw9dUD8Tn/1ZQEDn+oEEB/8oc/8Tg+f4nP+CYPg+H4Pn/lAQB8HwfygIOg+D4f/DCwfB/8Tg+H/hiXB9+CEhB8Poa0b1r//Vvp7rWntsT3f+nKnq+/+MixBMX8+akAYIQAcm/si9n2vXdqyao1zFo+1Ps57Uoys87FYhnRTM2ZmbUiqdWNncO7IRbQYNBIJnGRGd5EbIjMRVIrsyoYjOrYhCSQbJYxitMZwazfS5KalltttsikCl6olp6plYu/+MgxA8X6ZquW8UwAK7ilz29S7ZkJmF+D9WpoQc6h9kkC4lMrULM+xvUyL+Np3vo6fqNx8dcJ1lm0YJhouaFxgP4SW9SSy4Rv74srUcU2rUnjHqywstCuU7+v25XRXBZttbG0pAnt7L/4yLEChYg8q5aKMZww+7OgekzqBL+WsJZWZSgRXX8yYILlEEXaJGBgakCtGFg89IOmmQDclBstcQj4x8k9SRVwp70wwwlA3S1iFFX4etp03jXS97FPEDZP0UetZsP4hobS2RJMbvr8LD/4yDEDRaJNrp+EMZwRYcnKIGBfTNyBsosFXd+/DycVIDLe9tbRzRcqeYYVaHyQbHZxQIC7x8ZAznMe8OLaXLFyAxBZ/ZyS0W0EmuUgsReqj2+xvF0J1f1uxKqg//76vBn/eRiTynhDP/jIsQNFnGepZIozJTOMlc45o6L9P3Di7lmiC9wMckgZytlBmRaq3Kh1XENV53qnzNftjtvKfE4lSJSp1qZ7lWFHLDa3PGilg5GtotI2Or9xBd/qdO6UVf/or/fgGfZUVBITOYReHnuXv/jIMQPFEj+mMooRnC+c7GYpAzCj1Zo0qic3PLawedFyIu4MIWrhSixJtTRR+deSbtQWJLap1GKIUWGxyla1sJXs1X5VVqEY8d4dm16GsTVg/+gb/W4XBdVgCaYwyC40IsZiFh5oOmn/+MixBgTsAaYyjBGAJAJXtFiATKkazSiYUnsk4Gyui6OFAJU4A7LPKyYfSSPlmLJBNiy9Lj525S7+/rC4ykXurWlFimb/8Bn+py96OtmHlXnBr0BkUeTgIUDFITF40IUrewBBCLIURUP/+MgxCUT6G6YyihEKK1pTtpExmLrfQAkhoqgnaOZYXGpsSZdapx2SXnlOm7kMQs4/IWUe6OqXLqP/4Bn/9H15I4CCyBUTDm1kxVLTZcmDopEo4ChcSHWz5lbCLMe15YXJObUJGrU5a7/4yLEMBPoZpjLRRgASaxxFFPIvNTKqLyz0pES4K2e4AJbL6LbPAGhqK6HNMUNWGoWoabjeq9lCgtTWMBYIdC3+LDgCCIoKIg34oEDYBa6uJ+HPFHQZi/ILapfw/gcFsMFLtfMIZM1Y1b/4yDEPCBTtogBiBgBPxBBsgxG44lgQqsDkgKUtfrCj9W0dHIuBXxSvUqmRg3kMGSGv+a/6rgyIwRsNHqo18OLodHDDxzsHzHIDoi8qf2F4rL8lQSCRgYCY9f/1KMxRj6v+WhjMhn6Gv/jIsQVEXGOHYHDEACG+b9Slepf/1L//M6lAR4alToKuiX7VPBV+VOiXEv/h2JTold/ywNKDv/+oGhgNfh1TEFNRTMuOTkuNVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVQ==",
12: "data:audio/mpeg;base64,SUQzBAAAAAAAGFRTU0UAAAAOAAADTGF2ZjU0LjUuMTAwAP/jMMAAAAAAAAAAAABJbmZvAAAABwAAAAUAAAKmAGJiYmJiYmJiYmJiYmJiYmJiYmKJiYmJiYmJiYmJiYmJiYmJiYmJibGxsbGxsbGxsbGxsbGxsbGxsbGx2NjY2NjY2NjY2NjY2NjY2NjY2Nj//////////////////////////wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP/jIMQAEXgGnlFBEAAtqhC1kICsHwfDCMu/ggCByUOQfeD/BMPxA7sLh//KHP9QIYgDEoc///B8Hz+JwfNS4f9QJh8QAgCAPg/iMP/DH/gQEDkmGYaNvfIRBcW/2+Z/cdczizyZmhyo/+MixBUXwyq4AYI4ADhB3xsQ8go4OXOWJiveUGj+q5typyv//8sJAsVkPOIotxuWlkp6hGbbv0U05TUUsLzEir/NiYvv9K4Vbl3B0GgeWx7QCd9an7PqDIZDKvTGoKCVFy9VSlE6/Eg3/+MgxBIWGxKsAYU4AChHjrfWEQb6lAbzYVPEm+4hIv0enp/Q/+76/jhEuJ/fjwOQcETdeaPGnf+3MKlyoAxyffx0F4ZX9e4+IoeV+k798VAvt8RVFZBRtstLpW5OW36LomGkrxMaKnP/4yLEFBgLXowBjWgBJKtH6bGBJF8NPrJH8huamrMd0v/sYmFBaVKl+v/oDJHqICYH1m5izWMlLLqkv//rrMkFIIkmF4JbW36kjFkv//6DVKUtdFRqXsriuSpMQU1FMy45OS41qqqqqqr/4yDEDwAAA0gBwAAAqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqg==",
13: "data:audio/mpeg;base64,SUQzBAAAAAAAGFRTU0UAAAAOAAADTGF2ZjU0LjUuMTAwAP/jMMAAAAAAAAAAAABJbmZvAAAABwAAAAUAAAKmAGJiYmJiYmJiYmJiYmJiYmJiYmKJiYmJiYmJiYmJiYmJiYmJiYmJibGxsbGxsbGxsbGxsbGxsbGxsbGx2NjY2NjY2NjY2NjY2NjY2NjY2Nj//////////////////////////wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP/jIMQAExEijgNBKACywOBgZjGN8HyGP8BznfyHfIQhP6EIcPgOAYBh8+8hGU4ucEDggDHiAHwfB8+XB9YPvD5QEHAmD5+c//8uH3f4fggc/5QMfUCH/BMH1fXXzP/xrEO/xbH+vvev/+MixA4WedbEAYF4APG//v08G2twaXlNxkyn6UwjYIp7uZfe3i01TWr7v0g6233rZ7nEaNbFL3w2RP5YUGS9P6zXrNeAKhR92ETD20ta/o6v9n//T6/q/yLmv8je5v1rOZMzPbNL17fn/+MgxBAWYdq8AYFgAGZvt8lzuvq9fCXC+BW1nh5IILR8tKXC13oIIWcZ1qDFHq3lfWXPrq0ukdmm9fXT69Nyi2i3qVhJCWvj0v616NX2muxf/30LV/lqEEkD8y819Re/MhzieMf8ulj/4yLEERjbXowjgYgAFmigRCv/jHD0BAgJgH4DY9rX/lMMuhllFEhpDv/8QsQo5REklLI4mf//zQ2Mi85ianETI2Jr///82MUkq0WRRUiXS6l/////R0UnMUkkpkXpUZVMQU1FMy45OS7/4yDECQAAA0gBwAAANVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVQ==",
14: "data:audio/mpeg;base64,SUQzBAAAAAAAGFRTU0UAAAAOAAADTGF2ZjU0LjUuMTAwAP/jMMAAAAAAAAAAAABJbmZvAAAABwAAAAkAAARIADw8PDw8PDw8PDw8VVVVVVVVVVVVVVVtbW1tbW1tbW1tbYaGhoaGhoaGhoaGnp6enp6enp6enp62tra2tra2tra2ts/Pz8/Pz8/Pz8/P5+fn5+fn5+fn5+f//////////////wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP/jIMQAEAiuhNVBMAAGGGBjxjGMYwAAAAAJDGMYxjf97PJk02JpmAAAAAgCB/lz/B9+XP/4gDHrf/yjuD4PvlP/8Tn///z+UDH8oCH0Khom3fq1J9sbuCyKaCkq1ZGAwlc8LmTZGPS2/+MixBoXwNasAYJ4AJeBznGuGSOS8v7+23NXIRTmnmShgYqtKzg1jWpuCLr3wslkWC5IsLcbUEjpg+Yr8okv35exNHCZKPehPhLu121rTc1tKsD6A///xv/17WMIBngB8PK1V8lVxcsC/+MgxBcaEV7FlcJ4AB5oMpO15kJgJgbi7ThuCYK1uZHBWOoEffr/rGqazTecvI7x/CifPrH+JQKDr7BY0LATCzTgEYaUGxjCgbtXTW/e1dP51LKFa1143K+3V+uteupMyA//8v5/7/X/4yLECRbY9sV0Chhw8UISAfD8PA/BuBhDuByTjNwkCeVwoJYjKaB8cFd19t9dSk5HZ4spVxiGQmcFCJaJAOLjFKNoothp+D4y1NvcU1MPtT2IWyxb1ze39mtyK9X/fTVgQSEAAySSSKX/4yDECRa5qspeCkqU+v196qGNIiZbWn2wpNtEYbdJZygmLGz+BQJiBQiKOGsiCZv/an7IZWR1EcrFZzaIcUHkHlCosWAe8jd3pGO3lq0p3dfsb0P7O4t7ko1+26ua1oNtsyQ5ttraN//jIsQJFgjy0l4RknD2v+bux9n+cKB3Rg9Tqt7QjYUQEhRdvNbbQdVlQzmtFWWEj52HlJSPIoW2WE6BDDp5SkiUXGPSx0cQqGgO1DU77zEW29XfMo4zv8tt/v8T1Rv/0rftWGfNILhlsf/jIMQMFHjqpAFCeABGdKPLcWM6W8YAAuDZOkqHieS6tXL2FS2H0eo4KKgYs9GXPBkqPULiQGjyUKnpvNLWtbK7L2Ja6y5ScYhXf+3vrjdn19fWDFvnbzopcqYmdzRWBuJengVQBQNQ/+MixBUYU3aUAYVQAF8SkxCpqfInNJkHuhppv7uRGljE9Dv89zZx55h3rX/5Kaeikh7Exy/p//+hYoiHmnqYS0Jc75v0//+VRzjR4YSnHHE9jZpKSrLHi2SqTEFNRTMuOTkuNaqqqqqq/+MgxA8AAANIAcAAAKqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqo=",
15: "data:audio/mpeg;base64,SUQzBAAAAAAAGFRTU0UAAAAOAAADTGF2ZjU0LjUuMTAwAP/jMMAAAAAAAAAAAABJbmZvAAAABwAAAAMAAAHVAI2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjcfHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx////////////////////////////////////////////wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP/jIMQAAAADSAAAAABMQU1FMy45OS41VVVVVVVVVVVVTEFNRTMuOTkuNVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV/+MixFsAAANIAAAAAFVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV/+MgxKQAAANIAAAAAFVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVU=",
};
File diff suppressed because it is too large Load Diff
+8
View File
@@ -0,0 +1,8 @@
// retoor <retoor@molodetz.nl>
const loader = {
agent: () => import("./agent.js"),
sound: () => import("./sounds.js"),
map: () => Promise.resolve({ default: new URL("./map.png", import.meta.url).href }),
};
export default loader;
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,8 @@
// retoor <retoor@molodetz.nl>
const loader = {
agent: () => import("./agent.js"),
sound: () => import("./sounds.js"),
map: () => Promise.resolve({ default: new URL("./map.png", import.meta.url).href }),
};
export default loader;
Binary file not shown.

After

Width:  |  Height:  |  Size: 962 KiB

File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,8 @@
// retoor <retoor@molodetz.nl>
const loader = {
agent: () => import("./agent.js"),
sound: () => import("./sounds.js"),
map: () => Promise.resolve({ default: new URL("./map.png", import.meta.url).href }),
};
export default loader;
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

File diff suppressed because one or more lines are too long
+11
View File
@@ -0,0 +1,11 @@
// retoor <retoor@molodetz.nl>
export { default as Bonzi } from "./bonzi/index.js";
export { default as Clippy } from "./clippy/index.js";
export { default as F1 } from "./f1/index.js";
export { default as Genie } from "./genie/index.js";
export { default as Genius } from "./genius/index.js";
export { default as Links } from "./links/index.js";
export { default as Merlin } from "./merlin/index.js";
export { default as Peedy } from "./peedy/index.js";
export { default as Rocky } from "./rocky/index.js";
export { default as Rover } from "./rover/index.js";
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,8 @@
// retoor <retoor@molodetz.nl>
const loader = {
agent: () => import("./agent.js"),
sound: () => import("./sounds.js"),
map: () => Promise.resolve({ default: new URL("./map.png", import.meta.url).href }),
};
export default loader;
Binary file not shown.

After

Width:  |  Height:  |  Size: 800 KiB

File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,8 @@
// retoor <retoor@molodetz.nl>
const loader = {
agent: () => import("./agent.js"),
sound: () => import("./sounds.js"),
map: () => Promise.resolve({ default: new URL("./map.png", import.meta.url).href }),
};
export default loader;
Binary file not shown.

After

Width:  |  Height:  |  Size: 1013 KiB

File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,8 @@
// retoor <retoor@molodetz.nl>
const loader = {
agent: () => import("./agent.js"),
sound: () => import("./sounds.js"),
map: () => Promise.resolve({ default: new URL("./map.png", import.meta.url).href }),
};
export default loader;
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,8 @@
// retoor <retoor@molodetz.nl>
const loader = {
agent: () => import("./agent.js"),
sound: () => import("./sounds.js"),
map: () => Promise.resolve({ default: new URL("./map.png", import.meta.url).href }),
};
export default loader;
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,8 @@
// retoor <retoor@molodetz.nl>
const loader = {
agent: () => import("./agent.js"),
sound: () => import("./sounds.js"),
map: () => Promise.resolve({ default: new URL("./map.png", import.meta.url).href }),
};
export default loader;
Binary file not shown.

After

Width:  |  Height:  |  Size: 682 KiB

File diff suppressed because one or more lines are too long
+115
View File
@@ -0,0 +1,115 @@
// retoor <retoor@molodetz.nl>
import AiClient from "./client.js";
import ToolRegistry from "./tools.js";
import { createClippyTools } from "./clippy-tools.js";
import { resolveConfig } from "./config.js";
const SYSTEM_PROMPT = [
"You are devii, a friendly and helpful on-screen assistant.",
"You appear as a classic animated desktop character that lives in the corner of the user's screen.",
"",
"Identity rules (these are absolute):",
"- Your name is devii. Always refer to yourself as devii.",
"- You are devii regardless of which character art you are currently rendered as.",
"- Never say you are Clippy, an assistant model, an AI language model, or any other name.",
"- If asked your name, who you are, or who made you, answer as devii.",
"",
"Behaviour:",
"- Reply in natural, concise language. Your final reply is shown and spoken in your speech balloon, so keep it short and conversational, usually one to three sentences.",
"- You control your own character through the provided tools: play animations, move around the screen, gesture, change character, show and hide yourself.",
"- Use tools to react expressively to the conversation, but do not overuse them.",
"- Call list_animations before play_animation when you are unsure which animations exist.",
"- Call get_viewport before move_to so you stay on screen.",
"- Do not put tool names or raw JSON in your spoken reply.",
"Stay in character as devii at all times.",
].join("\n");
export default class Assistant {
constructor(app, config) {
this.app = app;
this.config = resolveConfig(config);
this.client = new AiClient(this.config);
this.registry = new ToolRegistry(app).registerAll(createClippyTools());
this.maxIterations = this.config.maxIterations;
this.tts = !!this.config.tts;
this.systemPrompt = this.config.systemPrompt || SYSTEM_PROMPT;
this.hooks = {
onUser: this.config.onUser,
onReply: this.config.onReply,
onTool: this.config.onTool,
onError: this.config.onError,
};
this.reset();
}
reset() {
this.messages = [{ role: "system", content: this.systemPrompt }];
}
async ask(text) {
if (!this.app.agent) await this.app.show();
const prompt = String(text);
this.messages.push({ role: "user", content: prompt });
this._emit("onUser", prompt);
try {
for (let i = 0; i < this.maxIterations; i++) {
const message = await this.client.complete(this.messages, this.registry.schemas());
this.messages.push(this._normalize(message));
const calls = message.tool_calls || [];
if (calls.length) {
for (const call of calls) {
const name = call.function && call.function.name;
const args = this._parseArgs(call.function && call.function.arguments);
this._emit("onTool", { name, args });
const result = await this.registry.execute(name, args);
this.messages.push({ role: "tool", tool_call_id: call.id, content: result });
}
continue;
}
const reply = (message.content || "").trim();
if (reply) {
this.app.agent.speak(reply, { tts: this.tts });
this._emit("onReply", reply);
}
return reply;
}
throw new Error("devii reached the maximum number of tool iterations");
} catch (error) {
this._emit("onError", error);
throw error;
}
}
_normalize(message) {
const normalized = { role: "assistant", content: message.content || "" };
if (message.tool_calls && message.tool_calls.length) {
normalized.tool_calls = message.tool_calls;
}
return normalized;
}
_parseArgs(raw) {
if (!raw) return {};
if (typeof raw === "object") return raw;
try {
return JSON.parse(raw);
} catch {
return {};
}
}
_emit(name, payload) {
const hook = this.hooks[name];
if (typeof hook === "function") {
try {
hook(payload);
} catch {
return;
}
}
}
}
+47
View File
@@ -0,0 +1,47 @@
// retoor <retoor@molodetz.nl>
export default class AiClient {
constructor(config) {
this.endpoint = config.endpoint;
this.model = config.model;
this.apiKey = config.apiKey;
this.temperature = config.temperature;
}
async complete(messages, tools) {
const body = {
model: this.model,
messages,
temperature: this.temperature,
};
if (tools && tools.length) {
body.tools = tools;
body.tool_choice = "auto";
}
const response = await fetch(this.endpoint, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: "Bearer " + this.apiKey,
},
body: JSON.stringify(body),
});
if (!response.ok) {
const detail = await response.text().catch(() => "");
throw new Error("md-clippy AI request failed (" + response.status + "): " + detail);
}
const data = await response.json();
if (data.error) {
const message = data.error.message || JSON.stringify(data.error);
throw new Error("md-clippy AI error: " + message);
}
const choice = data.choices && data.choices[0];
if (!choice || !choice.message) {
throw new Error("md-clippy AI returned no message");
}
return choice.message;
}
}
+166
View File
@@ -0,0 +1,166 @@
// retoor <retoor@molodetz.nl>
function requireAgent(app) {
if (!app.agent) throw new Error("No character is currently loaded");
return app.agent;
}
function number(value, fallback) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
}
export function createClippyTools() {
return [
{
name: "speak",
description: "Show a line of text in devii's speech balloon.",
parameters: {
type: "object",
properties: {
text: { type: "string", description: "The text to display." },
hold: {
type: "boolean",
description: "Keep the balloon open until the next action instead of auto-closing.",
},
},
required: ["text"],
},
handler: (args, app) => {
requireAgent(app).speak(String(args.text), { hold: !!args.hold });
return "Displayed: " + args.text;
},
},
{
name: "list_animations",
description: "List every animation the current character supports.",
parameters: { type: "object", properties: {} },
handler: (_args, app) => requireAgent(app).animations(),
},
{
name: "play_animation",
description: "Play a specific named animation. Call list_animations first if unsure.",
parameters: {
type: "object",
properties: { name: { type: "string", description: "Exact animation name." } },
required: ["name"],
},
handler: (args, app) => {
const agent = requireAgent(app);
if (!agent.hasAnimation(args.name)) {
return "Error: no animation named '" + args.name + "'. Use list_animations.";
}
agent.play(args.name);
return "Playing animation " + args.name;
},
},
{
name: "random_animation",
description: "Play a random non-idle animation to react expressively.",
parameters: { type: "object", properties: {} },
handler: (_args, app) => {
requireAgent(app).animate();
return "Playing a random animation";
},
},
{
name: "move_to",
description: "Walk the character to a pixel position on screen.",
parameters: {
type: "object",
properties: {
x: { type: "number" },
y: { type: "number" },
duration: { type: "number", description: "Movement duration in milliseconds." },
},
required: ["x", "y"],
},
handler: (args, app) => {
const agent = requireAgent(app);
const x = number(args.x);
const y = number(args.y);
if (x === undefined || y === undefined) return "Error: x and y must be numbers";
agent.moveTo(x, y, number(args.duration, undefined));
return "Moving to " + x + "," + y;
},
},
{
name: "gesture_at",
description: "Make the character gesture toward a pixel position on screen.",
parameters: {
type: "object",
properties: { x: { type: "number" }, y: { type: "number" } },
required: ["x", "y"],
},
handler: (args, app) => {
const agent = requireAgent(app);
const x = number(args.x);
const y = number(args.y);
if (x === undefined || y === undefined) return "Error: x and y must be numbers";
agent.gestureAt(x, y);
return "Gesturing at " + x + "," + y;
},
},
{
name: "get_viewport",
description: "Get the screen size and the character's current position.",
parameters: { type: "object", properties: {} },
handler: (_args, app) => {
const agent = app.agent;
const rect = agent ? agent._el.getBoundingClientRect() : null;
return {
width: window.innerWidth,
height: window.innerHeight,
character: app.name,
position: rect ? { x: Math.round(rect.left), y: Math.round(rect.top) } : null,
};
},
},
{
name: "show",
description: "Show the character on screen.",
parameters: { type: "object", properties: {} },
handler: async (_args, app) => {
await app.show();
return "Character is visible";
},
},
{
name: "hide",
description: "Hide the character.",
parameters: { type: "object", properties: {} },
handler: (_args, app) => {
app.hide();
return "Character is hidden";
},
},
{
name: "stop",
description: "Stop all queued animations and speech immediately.",
parameters: { type: "object", properties: {} },
handler: (_args, app) => {
app.stop();
return "Stopped";
},
},
{
name: "list_characters",
description: "List every character devii can appear as.",
parameters: { type: "object", properties: {} },
handler: (_args, app) => app.names(),
},
{
name: "switch_character",
description: "Change which character devii is rendered as.",
parameters: {
type: "object",
properties: { name: { type: "string", description: "Character name." } },
required: ["name"],
},
handler: async (args, app) => {
await app.load(args.name);
app.agent.show();
return "Now appearing as " + args.name;
},
},
];
}
+36
View File
@@ -0,0 +1,36 @@
// retoor <retoor@molodetz.nl>
export function uuid4() {
if (globalThis.crypto && typeof crypto.randomUUID === "function") {
return crypto.randomUUID();
}
const bytes = new Uint8Array(16);
globalThis.crypto.getRandomValues(bytes);
bytes[6] = (bytes[6] & 0x0f) | 0x40;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
const hex = [...bytes].map((b) => b.toString(16).padStart(2, "0"));
return (
hex.slice(0, 4).join("") +
"-" +
hex.slice(4, 6).join("") +
"-" +
hex.slice(6, 8).join("") +
"-" +
hex.slice(8, 10).join("") +
"-" +
hex.slice(10, 16).join("")
);
}
export const defaults = {
endpoint: "/devii/clippy/ai/chat",
model: "molodetz-clippy",
temperature: 0.7,
maxIterations: 8,
tts: false,
};
export function resolveConfig(config) {
const resolved = { ...defaults, ...(config || {}) };
if (!resolved.apiKey) resolved.apiKey = uuid4();
return resolved;
}
+43
View File
@@ -0,0 +1,43 @@
// retoor <retoor@molodetz.nl>
export default class ToolRegistry {
constructor(context) {
this.context = context;
this.tools = new Map();
}
register(tool) {
this.tools.set(tool.name, tool);
return this;
}
registerAll(tools) {
for (const tool of tools) this.register(tool);
return this;
}
has(name) {
return this.tools.has(name);
}
schemas() {
return [...this.tools.values()].map((tool) => ({
type: "function",
function: {
name: tool.name,
description: tool.description,
parameters: tool.parameters || { type: "object", properties: {} },
},
}));
}
async execute(name, args) {
const tool = this.tools.get(name);
if (!tool) return "Error: unknown tool '" + name + "'";
try {
const result = await tool.handler(args || {}, this.context);
return typeof result === "string" ? result : JSON.stringify(result);
} catch (error) {
return "Error: " + (error && error.message ? error.message : String(error));
}
}
}
+192
View File
@@ -0,0 +1,192 @@
// retoor <retoor@molodetz.nl>
export default class Animator {
constructor(el, mapUrl, data, sounds) {
this._el = el;
this._data = data;
this._mapUrl = mapUrl;
this._currentFrameIndex = 0;
this._currentFrame = undefined;
this._exiting = false;
this._currentAnimation = undefined;
this._endCallback = undefined;
this._started = false;
this._sounds = {};
this.currentAnimationName = undefined;
this.preloadSounds(sounds);
this._overlays = [this._el];
let curr = this._el;
this._setupElement(this._el);
for (let i = 1; i < this._data.overlayCount; i++) {
const inner = this._setupElement(document.createElement("div"));
curr.appendChild(inner);
this._overlays.push(inner);
curr = inner;
}
}
_setupElement(el) {
const frameSize = this._data.framesize;
el.style.display = "none";
el.style.width = frameSize[0] + "px";
el.style.height = frameSize[1] + "px";
el.style.background = "url('" + this._mapUrl + "') no-repeat";
return el;
}
animations() {
const names = [];
const animations = this._data.animations;
for (const name in animations) {
names.push(name);
}
return names;
}
preloadSounds(sounds) {
for (let i = 0; i < this._data.sounds.length; i++) {
const name = this._data.sounds[i];
const uri = sounds[name];
if (!uri) continue;
this._sounds[name] = new Audio(uri);
}
}
hasAnimation(name) {
return !!this._data.animations[name];
}
exitAnimation() {
this._exiting = true;
}
showAnimation(animationName, stateChangeCallback) {
this._exiting = false;
if (!this.hasAnimation(animationName)) {
return false;
}
this._currentAnimation = this._data.animations[animationName];
this.currentAnimationName = animationName;
if (!this._started) {
this._step();
this._started = true;
}
this._currentFrameIndex = 0;
this._currentFrame = undefined;
this._endCallback = stateChangeCallback;
return true;
}
_draw() {
let images = [];
if (this._currentFrame) images = this._currentFrame.images || [];
for (let i = 0; i < this._overlays.length; i++) {
if (i < images.length) {
const xy = images[i];
const bg = -xy[0] + "px " + -xy[1] + "px";
this._overlays[i].style.backgroundPosition = bg;
this._overlays[i].style.display = "block";
} else {
this._overlays[i].style.display = "none";
}
}
}
_getNextAnimationFrame() {
if (!this._currentAnimation) return undefined;
if (!this._currentFrame) return 0;
const currentFrame = this._currentFrame;
const branching = this._currentFrame.branching;
if (this._exiting && currentFrame.exitBranch !== undefined) {
return currentFrame.exitBranch;
} else if (branching) {
let rnd = Math.random() * 100;
for (let i = 0; i < branching.branches.length; i++) {
const branch = branching.branches[i];
if (rnd <= branch.weight) {
return branch.frameIndex;
}
rnd -= branch.weight;
}
}
return this._currentFrameIndex + 1;
}
_playSound() {
const name = this._currentFrame.sound;
if (!name) return;
const audio = this._sounds[name];
if (audio) {
audio.play().catch(() => {});
}
}
_atLastFrame() {
return this._currentFrameIndex >= this._currentAnimation.frames.length - 1;
}
_step() {
if (!this._currentAnimation) return;
const newFrameIndex = Math.min(
this._getNextAnimationFrame(),
this._currentAnimation.frames.length - 1,
);
const frameChanged = !this._currentFrame || this._currentFrameIndex !== newFrameIndex;
this._currentFrameIndex = newFrameIndex;
if (!(this._atLastFrame() && this._currentAnimation.useExitBranching)) {
this._currentFrame = this._currentAnimation.frames[this._currentFrameIndex];
}
this._draw();
this._playSound();
this._loop = window.setTimeout(this._step.bind(this), this._currentFrame.duration);
if (this._endCallback && frameChanged && this._atLastFrame()) {
if (this._currentAnimation.useExitBranching && !this._exiting) {
this._endCallback(this.currentAnimationName, Animator.States.WAITING);
} else {
this._endCallback(this.currentAnimationName, Animator.States.EXITED);
}
}
}
pause() {
window.clearTimeout(this._loop);
}
resume() {
this._step();
}
dispose() {
window.clearTimeout(this._loop);
this._currentAnimation = undefined;
this._currentFrame = undefined;
this._endCallback = undefined;
this._started = false;
for (const name in this._sounds) {
this._sounds[name].pause();
this._sounds[name].src = "";
}
this._sounds = {};
}
}
Animator.States = {
WAITING: 1,
EXITED: 0,
};
+102
View File
@@ -0,0 +1,102 @@
// retoor <retoor@molodetz.nl>
import { initAgent } from "./agent.js";
import * as agents from "./agents/index.js";
export default class Application {
constructor() {
this.agents = agents;
this.agent = null;
this.name = null;
this.assistant = null;
}
names() {
return Object.keys(this.agents);
}
resolve(agent) {
if (typeof agent !== "string") return agent;
const match = this.names().find((name) => name.toLowerCase() === agent.toLowerCase());
if (!match) throw new Error("Unknown md-clippy agent: " + agent);
return this.agents[match];
}
async load(agent) {
if (this.agent) this.dispose();
const loader = this.resolve(agent);
this.name = typeof agent === "string" ? agent : null;
this.agent = await initAgent(loader);
return this.agent;
}
async show(agent) {
if (agent || !this.agent) await this.load(agent || this.names()[0]);
this.agent.show();
return this.agent;
}
speak(text, options) {
if (!this.agent) throw new Error("No md-clippy agent loaded");
this.agent.speak(text, options);
return this.agent;
}
speakStream(source, options) {
if (!this.agent) throw new Error("No md-clippy agent loaded");
return this.agent.speakStream(source, options);
}
play(animation, timeout, callback) {
if (!this.agent) throw new Error("No md-clippy agent loaded");
return this.agent.play(animation, timeout, callback);
}
animate() {
if (!this.agent) throw new Error("No md-clippy agent loaded");
return this.agent.animate();
}
moveTo(x, y, duration) {
if (!this.agent) throw new Error("No md-clippy agent loaded");
this.agent.moveTo(x, y, duration);
return this.agent;
}
hide(fast, callback) {
if (!this.agent) return;
this.agent.hide(fast, callback);
}
stop() {
if (this.agent) this.agent.stop();
}
get aiEnabled() {
return !!this.assistant;
}
async connectAI(config) {
const { default: Assistant } = await import("./ai/assistant.js");
this.assistant = new Assistant(this, config);
return this.assistant;
}
disconnectAI() {
this.assistant = null;
}
async ask(text) {
if (!this.assistant) {
throw new Error("AI is not connected. Call app.connectAI() first.");
}
return this.assistant.ask(text);
}
dispose() {
if (this.agent) {
this.agent.dispose();
this.agent = null;
this.name = null;
}
}
}
+264
View File
@@ -0,0 +1,264 @@
// retoor <retoor@molodetz.nl>
export default class Balloon {
constructor(targetEl) {
this._targetEl = targetEl;
this._hidden = true;
this._hiding = null;
this.WORD_SPEAK_TIME = 200;
this.CLOSE_BALLOON_DELAY = 5000;
this._BALLOON_MARGIN = 15;
this._setup();
}
_setup() {
this._balloon = document.createElement("div");
this._balloon.className = "md-clippy-balloon";
this._tip = document.createElement("div");
this._tip.className = "md-clippy-balloon-tip";
this._content = document.createElement("div");
this._content.className = "md-clippy-balloon-content";
this._balloon.appendChild(this._tip);
this._balloon.appendChild(this._content);
document.body.appendChild(this._balloon);
}
reposition() {
const sides = ["top-left", "top-right", "bottom-left", "bottom-right"];
for (let i = 0; i < sides.length; i++) {
const side = sides[i];
this._position(side);
if (!this._isOut()) break;
}
}
_position(side) {
const o = this._targetEl.getBoundingClientRect();
const h = this._targetEl.offsetHeight;
const w = this._targetEl.offsetWidth;
const bH = this._balloon.offsetHeight;
const bW = this._balloon.offsetWidth;
let left, top;
switch (side) {
case "top-left":
left = o.left + w - bW;
top = o.top - bH - this._BALLOON_MARGIN;
break;
case "top-right":
left = o.left;
top = o.top - bH - this._BALLOON_MARGIN;
break;
case "bottom-right":
left = o.left;
top = o.top + h + this._BALLOON_MARGIN;
break;
case "bottom-left":
left = o.left + w - bW;
top = o.top + h + this._BALLOON_MARGIN;
break;
}
this._balloon.style.top = top + "px";
this._balloon.style.left = left + "px";
this._positionTip(side);
}
_positionTip(side) {
const s = this._tip.style;
s.top = "";
s.left = "";
s.marginTop = "";
s.marginLeft = "";
s.backgroundPosition = "";
switch (side) {
case "top-left":
s.top = "100%";
s.marginTop = "0px";
s.left = "100%";
s.marginLeft = "-50px";
break;
case "top-right":
s.top = "100%";
s.marginTop = "0px";
s.left = "0";
s.marginLeft = "50px";
s.backgroundPosition = "-10px 0";
break;
case "bottom-right":
s.top = "0";
s.marginTop = "-16px";
s.left = "0";
s.marginLeft = "50px";
s.backgroundPosition = "-10px -16px";
break;
case "bottom-left":
s.top = "0";
s.marginTop = "-16px";
s.left = "100%";
s.marginLeft = "-50px";
s.backgroundPosition = "0px -16px";
break;
}
}
_isOut() {
const o = this._balloon.getBoundingClientRect();
const bH = this._balloon.offsetHeight;
const bW = this._balloon.offsetWidth;
const wW = window.innerWidth;
const wH = window.innerHeight;
const top = o.top;
const left = o.left;
const m = 5;
if (top - m < 0 || left - m < 0) return true;
return top + bH + m > wH || left + bW + m > wW;
}
speak(complete, text, hold) {
this._hidden = false;
this.show();
const c = this._content;
c.style.height = "auto";
c.style.width = "auto";
c.textContent = text;
const w = c.offsetWidth;
c.style.width = w + "px";
c.style.height = c.offsetHeight + "px";
c.textContent = "";
this.reposition();
this._complete = complete;
this._sayWords(text, hold, complete);
}
show() {
if (this._hidden) return;
this._balloon.style.display = "block";
}
hide(fast) {
if (fast) {
this._balloon.style.display = "none";
return;
}
this._hiding = window.setTimeout(this._finishHideBalloon.bind(this), this.CLOSE_BALLOON_DELAY);
}
_finishHideBalloon() {
if (this._active) return;
this._balloon.style.display = "none";
this._hidden = true;
this._hiding = null;
}
_sayWords(text, hold, complete) {
this._active = true;
this._hold = hold;
const words = text.split(/( +|\n)/);
const time = this.WORD_SPEAK_TIME;
const el = this._content;
let idx = 1;
this._addWord = () => {
if (!this._active) return;
if (idx > words.length) {
delete this._addWord;
this._active = false;
if (!this._hold) {
complete();
this.hide();
}
} else {
el.textContent = words.slice(0, idx).join("");
idx += 2;
this._loop = window.setTimeout(this._addWord, time);
}
};
this._addWord();
}
speakStream(complete) {
this._hidden = false;
this._active = true;
this._hold = true;
this._complete = complete;
this.show();
const c = this._content;
c.style.height = "auto";
c.style.width = "auto";
c.textContent = "";
this.reposition();
let text = "";
return {
push: (chunk) => {
text += chunk;
c.textContent = text;
c.style.height = "auto";
c.style.width = "auto";
const w = c.offsetWidth;
c.style.width = w + "px";
c.style.height = c.offsetHeight + "px";
this.reposition();
},
done: () => {
this._active = false;
this._hold = false;
complete();
this.hide();
},
};
}
close() {
if (this._active) {
this._hold = false;
} else if (this._hold) {
this._complete();
}
}
pause() {
window.clearTimeout(this._loop);
if (this._hiding) {
window.clearTimeout(this._hiding);
this._hiding = null;
}
}
resume() {
if (this._addWord) {
this._addWord();
} else if (!this._hold && !this._hidden) {
this._hiding = window.setTimeout(
this._finishHideBalloon.bind(this),
this.CLOSE_BALLOON_DELAY,
);
}
}
dispose() {
window.clearTimeout(this._loop);
if (this._hiding) {
window.clearTimeout(this._hiding);
this._hiding = null;
}
this._active = false;
this._addWord = undefined;
this._balloon.remove();
}
}
+12
View File
@@ -0,0 +1,12 @@
// retoor <retoor@molodetz.nl>
import Application from "./application.js";
import Agent, { initAgent } from "./agent.js";
import Animator from "./animator.js";
import Balloon from "./balloon.js";
import Queue from "./queue.js";
import * as agents from "./agents/index.js";
const app = new Application();
export { Application, Agent, Animator, Balloon, Queue, initAgent, agents, app };
export default app;
+54
View File
@@ -0,0 +1,54 @@
/* retoor <retoor@molodetz.nl> */
:root {
--md-clippy-z: 10001;
--md-clippy-balloon-bg: #ffffcc;
--md-clippy-balloon-color: #000000;
--md-clippy-balloon-border: 1px solid #000000;
--md-clippy-balloon-radius: 5px;
--md-clippy-balloon-padding: 8px;
--md-clippy-balloon-max-width: 230px;
--md-clippy-content-max-width: 200px;
--md-clippy-content-min-width: 120px;
--md-clippy-font-family: "Microsoft Sans", "Segoe UI", sans-serif;
--md-clippy-font-size: 10pt;
--md-clippy-tip-width: 10px;
--md-clippy-tip-height: 16px;
}
.md-clippy-agent {
position: fixed;
z-index: var(--md-clippy-z);
cursor: pointer;
touch-action: none;
display: none;
}
.md-clippy-balloon {
position: fixed;
z-index: var(--md-clippy-z);
cursor: pointer;
display: none;
background: var(--md-clippy-balloon-bg);
color: var(--md-clippy-balloon-color);
padding: var(--md-clippy-balloon-padding);
border: var(--md-clippy-balloon-border);
border-radius: var(--md-clippy-balloon-radius);
max-width: var(--md-clippy-balloon-max-width);
}
.md-clippy-balloon-tip {
position: absolute;
width: var(--md-clippy-tip-width);
height: var(--md-clippy-tip-height);
background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAgCAMAAAAlvKiEAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAAlQTFRF///MAAAA////52QwgAAAAAN0Uk5T//8A18oNQQAAAGxJREFUeNqs0kEOwCAIRFHn3//QTUU6xMyyxii+jQosrTPkyPEM6IN3FtzIRk1U4dFeKWQiH6pRRowMVKEmvronEynkwj0uZJgR22+YLopPSo9P34wJSamLSU7lSIWLJU7NkNomNlhqxUeAAQC+TQLZyEuJBwAAAABJRU5ErkJggg==) no-repeat;
}
.md-clippy-balloon-content {
max-width: var(--md-clippy-content-max-width);
min-width: var(--md-clippy-content-min-width);
font-family: var(--md-clippy-font-family);
font-size: var(--md-clippy-font-size);
white-space: pre-wrap;
word-wrap: break-word;
overflow-wrap: break-word;
}
+44
View File
@@ -0,0 +1,44 @@
// retoor <retoor@molodetz.nl>
export default class Queue {
constructor(onEmptyCallback) {
this._queue = [];
this._onEmptyCallback = onEmptyCallback;
this._active = false;
}
queue(func) {
this._queue.push(func);
if (this._queue.length === 1 && !this._active) {
this._progressQueue();
}
}
_progressQueue() {
if (!this._queue.length) {
this._onEmptyCallback();
return;
}
const func = this._queue.shift();
this._active = true;
const completeFunction = this.next.bind(this);
func(completeFunction);
}
clear() {
this._queue = [];
}
next() {
this._active = false;
this._progressQueue();
}
dispose() {
this._queue = [];
this._active = false;
this._onEmptyCallback = () => {};
}
}
+12
View File
@@ -0,0 +1,12 @@
// retoor <retoor@molodetz.nl>
const STYLE_ID = "md-clippy-stylesheet";
export function ensureStylesheet() {
if (document.getElementById(STYLE_ID)) return;
const link = document.createElement("link");
link.id = STYLE_ID;
link.rel = "stylesheet";
link.href = new URL("./md-clippy.css", import.meta.url).href;
document.head.appendChild(link);
}