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.
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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">⛶</button>',
|
||||
' <button type="button" data-win="maximize" title="Maximize">□</button>',
|
||||
' <button type="button" data-win="close" title="Close">✕</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></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);
|
||||
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.split(String.fromCharCode(34))
|
||||
.join(""");
|
||||
}
|
||||
|
||||
_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;
|
||||
}
|
||||
}
|
||||
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.split(String.fromCharCode(34))
|
||||
.join(""");
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
After Width: | Height: | Size: 815 KiB |
@@ -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;
|
||||
|
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=",
|
||||
};
|
||||
@@ -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;
|
||||
|
After Width: | Height: | Size: 1.0 MiB |
@@ -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;
|
||||
|
After Width: | Height: | Size: 962 KiB |
@@ -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;
|
||||
|
After Width: | Height: | Size: 1.1 MiB |
@@ -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";
|
||||
@@ -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;
|
||||
|
After Width: | Height: | Size: 800 KiB |
@@ -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;
|
||||
|
After Width: | Height: | Size: 1013 KiB |
@@ -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;
|
||||
|
After Width: | Height: | Size: 1.8 MiB |
@@ -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;
|
||||
|
After Width: | Height: | Size: 1.2 MiB |
@@ -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;
|
||||
|
After Width: | Height: | Size: 682 KiB |
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 = () => {};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||