This commit is contained in:
2026-07-19 18:57:43 +02:00
parent 48bb6c2ec2
commit c53e2a3319
179 changed files with 9307 additions and 897 deletions
@@ -0,0 +1,60 @@
/* retoor <retoor@molodetz.nl> */
.ai-choice,
.ai-choice-multi {
display: block;
margin: 0.75rem 0;
}
.ai-choice-options {
display: flex;
flex-direction: column;
gap: 0.4rem;
max-height: min(50vh, 22rem);
overflow-y: auto;
padding-right: 0.15rem;
}
.ai-choice-option {
text-align: left;
border: 1px solid var(--devii-border, rgba(255, 255, 255, 0.12));
background: var(--devii-bar-bg, #0b0b0b);
color: inherit;
border-radius: 8px;
padding: 0.5rem 0.75rem;
cursor: pointer;
font: inherit;
}
.ai-choice-option.selected {
border-color: var(--devii-accent, #3fb950);
}
.ai-choice-option-desc,
.ai-choice-check small {
display: block;
color: var(--devii-dim, #6a737d);
font-size: 0.85em;
margin-top: 0.15rem;
}
.ai-choice-select {
width: 100%;
max-width: 28rem;
background: var(--devii-bar-bg, #0b0b0b);
color: inherit;
border: 1px solid var(--devii-border, rgba(255, 255, 255, 0.12));
border-radius: 8px;
padding: 0.45rem 0.6rem;
font: inherit;
}
.ai-choice-check {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: 0.45rem;
border: 1px solid var(--devii-border, rgba(255, 255, 255, 0.12));
border-radius: 8px;
padding: 0.45rem 0.6rem;
}
@@ -0,0 +1,27 @@
/* retoor <retoor@molodetz.nl> */
.ai-confirm {
display: block;
margin: 0.75rem 0;
}
.ai-confirm-options {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.ai-confirm-option {
border: 1px solid var(--devii-border, rgba(255, 255, 255, 0.12));
background: var(--devii-bar-bg, #0b0b0b);
color: inherit;
border-radius: 8px;
padding: 0.45rem 0.85rem;
cursor: pointer;
font: inherit;
}
.ai-confirm-option.selected {
border-color: var(--devii-accent, #3fb950);
box-shadow: 0 0 0 1px var(--devii-accent, #3fb950);
}
@@ -0,0 +1,28 @@
/* retoor <retoor@molodetz.nl> */
.ai-field {
display: block;
margin: 0.75rem 0;
}
.ai-field-wrap {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.ai-field-input {
width: 100%;
max-width: 28rem;
background: var(--devii-bar-bg, #0b0b0b);
color: inherit;
border: 1px solid var(--devii-border, rgba(255, 255, 255, 0.12));
border-radius: 8px;
padding: 0.5rem 0.65rem;
font: inherit;
}
.ai-field-input:focus-visible {
outline: none;
box-shadow: 0 0 0 2px rgba(63, 185, 80, 0.35);
}
@@ -0,0 +1,148 @@
/* retoor <retoor@molodetz.nl> */
.ai-interaction {
--ai-space-1: 0.25rem;
--ai-space-2: 0.5rem;
--ai-space-3: 0.75rem;
--ai-space-4: 1rem;
--ai-radius-md: 10px;
--ai-color-fg: var(--devii-agent, var(--text-primary, #e6edf3));
--ai-color-muted: var(--devii-dim, var(--text-muted, #7a6a90));
--ai-color-accent: var(--devii-accent, var(--accent, #ff6b35));
--ai-color-border: var(--devii-border, var(--border, rgba(255, 255, 255, 0.12)));
--ai-color-surface: var(--devii-code-bg, var(--bg-card, #1a1030));
--ai-color-surface-2: var(--devii-bar-bg, var(--bg-secondary, #120821));
--ai-focus-ring: 0 0 0 2px rgba(63, 185, 80, 0.35);
display: block;
box-sizing: border-box;
width: 100%;
max-width: 100%;
margin: var(--ai-space-3) 0;
padding: var(--ai-space-3);
border: 1px solid var(--ai-color-border);
border-radius: var(--ai-radius-md);
background: var(--ai-color-surface);
color: var(--ai-color-fg);
overflow: visible;
}
.ai-interaction-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--ai-space-2);
margin-bottom: var(--ai-space-3);
}
.ai-interaction-title {
margin: 0;
font-size: 1rem;
font-weight: 600;
}
.ai-status {
font-size: 0.75rem;
color: var(--ai-color-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.ai-status[data-state="submitted"] {
color: var(--ai-color-accent);
}
.ai-status[data-state="error"],
.ai-status[data-state="cancelled"],
.ai-status[data-state="timeout"] {
color: var(--devii-error, var(--danger, #e53935));
}
.ai-widget-label {
display: block;
font-weight: 600;
margin-bottom: var(--ai-space-1);
}
.ai-widget-help,
.ai-help {
display: block;
color: var(--ai-color-muted);
font-size: 0.85rem;
margin-bottom: var(--ai-space-2);
}
.ai-actions {
display: flex;
gap: var(--ai-space-2);
margin-top: var(--ai-space-3);
flex-wrap: wrap;
position: sticky;
bottom: 0;
z-index: 2;
padding: var(--ai-space-2) 0 var(--ai-space-1);
background: linear-gradient(
to bottom,
rgba(0, 0, 0, 0) 0%,
var(--ai-color-surface) 28%,
var(--ai-color-surface) 100%
);
}
.ai-actions.align-end {
justify-content: flex-end;
}
.ai-btn {
border: 1px solid var(--ai-color-border);
border-radius: 8px;
padding: 0.45rem 0.9rem;
background: var(--ai-color-surface-2);
color: var(--ai-color-fg);
cursor: pointer;
font: inherit;
}
.ai-btn:focus-visible {
outline: none;
box-shadow: var(--ai-focus-ring);
}
.ai-btn-primary {
background: var(--ai-color-accent);
border-color: transparent;
color: #041008;
font-weight: 600;
}
.ai-btn-secondary {
opacity: 0.92;
}
.ai-group {
margin: var(--ai-space-3) 0;
padding: var(--ai-space-2);
border-left: 2px solid var(--ai-color-border);
}
.ai-group-label {
font-weight: 600;
margin-bottom: var(--ai-space-2);
}
.ai-markdown-fallback pre {
white-space: pre-wrap;
margin: 0;
font: inherit;
color: var(--ai-color-muted);
}
@media (max-width: 520px) {
.ai-actions {
flex-direction: column;
}
.ai-btn {
width: 100%;
}
}
+15
View File
@@ -256,6 +256,21 @@ devii-terminal .devii-line {
white-space: pre-wrap;
}
devii-terminal .devii-interaction-line {
white-space: normal;
overflow: visible;
max-width: 100%;
padding-bottom: 8px;
}
devii-terminal .devii-interaction-line ai-interaction,
devii-terminal .devii-interaction-line .ai-interaction {
display: block;
width: 100%;
max-width: 100%;
box-sizing: border-box;
}
devii-terminal .devii-user {
color: var(--devii-user);
}
+4
View File
@@ -184,6 +184,10 @@
margin-bottom: 1.5rem;
}
.gist-rendered-content {
margin-bottom: 1.5rem;
}
.gist-code-block {
margin-bottom: 1.5rem;
border: 1px solid var(--border);
+485 -12
View File
@@ -1,5 +1,413 @@
/* retoor <retoor@molodetz.nl> */
.dashboard {
max-width: 1200px;
margin: 0 auto;
padding: 1.5rem 1.25rem 0;
width: 100%;
position: relative;
z-index: 1;
}
.dashboard-top {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 1rem;
padding: 1.25rem 1.5rem;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-xl);
margin-bottom: 1.25rem;
}
.dashboard-actions {
display: flex;
align-items: center;
gap: 1.25rem;
flex-wrap: wrap;
}
.dashboard-welcome {
display: flex;
align-items: center;
gap: 0.75rem;
}
.dashboard-greeting {
font-size: 1.15rem;
color: var(--text-primary);
white-space: nowrap;
}
.dashboard-greeting strong {
color: var(--accent);
}
.dashboard-buttons {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.dashboard-btn {
padding: 0.55rem 1.1rem;
border-radius: var(--radius-lg);
border: 1px solid var(--border);
background: var(--bg-card);
color: var(--text-primary);
font-size: 0.875rem;
font-weight: 600;
white-space: nowrap;
transition: border-color 0.15s ease, transform 0.15s ease;
}
.dashboard-btn:hover {
border-color: var(--accent);
transform: translateY(-1px);
}
.dashboard-btn-primary {
background: var(--accent);
color: var(--white);
border-color: var(--accent);
font-weight: 700;
padding: 0.55rem 1.5rem;
}
.dashboard-btn-primary:hover {
background: var(--accent-hover);
box-shadow: 0 4px 16px rgba(255, 107, 53, 0.25);
}
.dashboard-stats {
display: flex;
gap: 2rem;
}
.dashboard-stat {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.2rem;
}
.dashboard-stat-value {
font-size: 1.5rem;
font-weight: 700;
color: var(--accent);
line-height: 1;
}
.dashboard-stat-label {
font-size: 0.6875rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-secondary);
}
.dashboard-stat-level {
min-width: 140px;
}
.dashboard-xp-bar {
width: 100%;
max-width: 120px;
height: 5px;
background: var(--border);
border-radius: 3px;
overflow: hidden;
margin-top: 0.15rem;
}
.dashboard-xp-fill {
height: 100%;
background: var(--accent-gradient, linear-gradient(90deg, var(--accent), #f0932b));
border-radius: 3px;
transition: width 0.4s ease;
}
.dashboard-xp-label {
font-size: 0.625rem;
color: var(--text-muted);
margin-top: 0.1rem;
}
.dashboard-main {
display: grid;
grid-template-columns: 1fr 280px;
gap: 1.25rem;
align-items: start;
}
.dashboard-feed-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.875rem;
}
.dashboard-feed-header h2 {
font-size: 1.15rem;
font-weight: 700;
color: var(--text-primary);
margin: 0;
}
.dashboard-feed-link {
font-size: 0.8125rem;
font-weight: 600;
color: var(--accent);
}
.dashboard-feed-link:hover {
color: var(--accent-hover);
}
.dashboard-posts-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.875rem;
}
.dashboard-post-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-xl);
padding: 1rem 1.1rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.dashboard-post-card:hover {
transform: translateY(-1px);
box-shadow: var(--shadow-lg);
}
.dashboard-post-header {
display: flex;
align-items: center;
gap: 0.5rem;
}
.dashboard-post-author-wrap {
display: flex;
flex-direction: column;
flex: 1;
min-width: 0;
}
.dashboard-post-author {
font-size: 0.75rem;
font-weight: 600;
color: var(--text-primary);
}
.dashboard-post-time {
font-size: 0.65rem;
color: var(--text-muted);
}
.dashboard-post-title {
font-size: 0.9375rem;
font-weight: 700;
line-height: 1.35;
margin: 0;
}
.dashboard-post-title a {
color: var(--text-primary);
}
.dashboard-post-title a:hover {
color: var(--accent);
text-decoration: none;
}
.dashboard-post-content {
font-size: 0.8125rem;
color: var(--text-secondary);
line-height: 1.45;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.dashboard-post-footer {
display: flex;
align-items: center;
gap: 0.625rem;
padding-top: 0.5rem;
border-top: 1px solid var(--border);
margin-top: auto;
}
.dashboard-post-stat {
font-size: 0.6875rem;
color: var(--text-muted);
font-weight: 600;
}
.dashboard-post-open {
font-size: 0.75rem;
font-weight: 600;
color: var(--accent);
margin-left: auto;
}
.dashboard-post-open:hover {
color: var(--accent-hover);
}
.dashboard-sidebar {
display: flex;
flex-direction: column;
gap: 1rem;
position: sticky;
top: 5rem;
}
.dashboard-sidebar-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-xl);
padding: 1rem 1.1rem;
}
.dashboard-sidebar-heading {
font-size: 0.8125rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-secondary);
margin: 0 0 0.75rem;
}
.dashboard-sidebar-link {
display: inline-block;
margin-top: 0.625rem;
font-size: 0.75rem;
font-weight: 600;
color: var(--accent);
}
.dashboard-sidebar-link:hover {
color: var(--accent-hover);
}
.dashboard-contributor-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.625rem;
}
.dashboard-contributor {
display: flex;
align-items: center;
gap: 0.5rem;
}
.dashboard-contributor-meta {
display: flex;
flex-direction: column;
flex: 1;
min-width: 0;
}
.dashboard-contributor-name {
font-size: 0.8125rem;
font-weight: 600;
color: var(--text-primary);
}
.dashboard-contributor-stars {
font-size: 0.6875rem;
color: var(--text-muted);
}
.dashboard-tag-cloud {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.dashboard-tag {
display: flex;
align-items: center;
gap: 0.35rem;
padding: 0.3rem 0.55rem;
border-radius: var(--radius-lg);
border: 1px solid var(--border);
background: var(--bg-card);
transition: border-color 0.15s ease;
}
.dashboard-tag:hover {
border-color: var(--accent);
}
.dashboard-tag-count {
font-size: 0.6875rem;
font-weight: 600;
color: var(--text-muted);
}
@media (max-width: 900px) {
.dashboard-main {
grid-template-columns: 1fr;
}
.dashboard-sidebar {
position: static;
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.875rem;
}
.dashboard-top {
flex-direction: column;
align-items: flex-start;
}
.dashboard-posts-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 480px) {
.dashboard {
padding: 1rem 0.75rem 0;
}
.dashboard-top {
padding: 1rem;
}
.dashboard-actions {
flex-direction: column;
align-items: flex-start;
width: 100%;
}
.dashboard-buttons {
width: 100%;
}
.dashboard-btn {
flex: 1;
text-align: center;
}
.dashboard-stats {
width: 100%;
justify-content: space-around;
}
.dashboard-sidebar {
grid-template-columns: 1fr;
}
.dashboard-greeting {
white-space: normal;
}
}
.landing {
min-height: 100vh;
background: var(--bg-gradient);
@@ -104,7 +512,7 @@
}
.landing-hero-user {
gap: 1.5rem;
gap: 1.25rem;
}
.landing-welcome {
@@ -119,18 +527,56 @@
.landing-welcome-eyebrow {
display: block;
font-size: 1rem;
color: var(--text-secondary);
font-weight: 500;
}
.landing-welcome-name {
font-size: 2.5rem;
font-size: 1.5rem;
font-weight: 800;
line-height: 1.1;
line-height: 1.2;
letter-spacing: -0.03em;
color: var(--text-primary);
margin: 0;
}
.landing-welcome-context {
display: block;
font-size: 0.875rem;
color: var(--text-secondary);
margin-top: 0.25rem;
}
.landing-actions {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 0.75rem;
}
.landing-action-primary {
padding: 0.7rem 2rem;
border-radius: var(--radius-lg);
background: var(--accent);
color: var(--white);
font-size: 0.9375rem;
font-weight: 700;
}
.landing-action-primary:hover {
background: var(--accent-hover);
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(255, 107, 53, 0.3);
}
.landing-action-secondary {
padding: 0.7rem 1.25rem;
border-radius: var(--radius-lg);
border: 1px solid var(--border);
background: var(--bg-card);
color: var(--text-primary);
font-size: 0.9375rem;
font-weight: 600;
transition: border-color 0.15s ease, transform 0.15s ease;
}
.landing-action-secondary:hover {
border-color: var(--accent);
transform: translateY(-2px);
}
.landing-stats {
@@ -160,6 +606,33 @@
color: var(--text-secondary);
}
.landing-stat-level {
min-width: 160px;
}
.landing-xp-bar {
width: 100%;
max-width: 140px;
height: 6px;
background: var(--border);
border-radius: 3px;
overflow: hidden;
margin-top: 0.25rem;
}
.landing-xp-fill {
height: 100%;
background: var(--accent-gradient, linear-gradient(90deg, var(--accent), #f0932b));
border-radius: 3px;
transition: width 0.4s ease;
}
.landing-xp-label {
font-size: 0.6875rem;
color: var(--text-muted);
margin-top: 0.15rem;
}
.landing-quicklinks {
display: flex;
flex-wrap: wrap;
@@ -184,8 +657,8 @@
}
@media (max-width: 480px) {
.landing-welcome-name {
font-size: 1.75rem;
.landing-welcome-eyebrow {
font-size: 1.25rem;
}
.landing-stats {
gap: 1.5rem;
+23 -3
View File
@@ -303,10 +303,30 @@
background: var(--bg-card);
}
.messages-input-area input[type="text"] {
.messages-input-area textarea {
flex: 1;
min-width: 0;
margin: 0;
resize: none;
overflow-y: auto;
max-height: 160px;
line-height: 1.5;
padding: 0.5rem 0.75rem;
font-family: inherit;
font-size: 0.875rem;
border-radius: var(--radius);
background: var(--bg-secondary);
border: 1px solid var(--border);
color: var(--text-primary);
outline: none;
}
.messages-input-area textarea:focus {
border-color: var(--accent);
}
.messages-input-area textarea::placeholder {
color: var(--text-muted);
}
.messages-input-area dp-upload {
@@ -449,7 +469,7 @@
justify-content: center;
}
.messages-input-area input[type="text"] {
.messages-input-area textarea {
font-size: 16px;
}
}
@@ -467,7 +487,7 @@
min-height: 48px;
}
.messages-input-area input[type="text"] {
.messages-input-area textarea {
font-size: 16px;
}
+17
View File
@@ -23,6 +23,7 @@ import { CustomizationToggle } from "./CustomizationToggle.js";
import { NotificationPrefs } from "./NotificationPrefs.js";
import { AiCorrection } from "./AiCorrection.js";
import { AiModifier } from "./AiModifier.js";
import { InteractionsPref } from "./InteractionsPref.js";
import { TelegramPairing } from "./TelegramPairing.js";
import { AdminNotificationDefaults } from "./AdminNotificationDefaults.js";
import { UserAiUsage } from "./UserAiUsage.js";
@@ -45,10 +46,25 @@ import { ScrollMemory } from "./ScrollMemory.js";
import { GameFarm } from "./GameFarm.js";
import { Accessibility } from "./Accessibility.js";
import { OverflowTabs } from "./OverflowTabs.js";
import { AiAutoload } from "./autoload/AiAutoload.js";
import "./components/AiInteraction.js";
import "./components/AiStatus.js";
import "./components/AiActions.js";
import "./components/AiHelp.js";
import "./components/AiOption.js";
import "./components/AiConfirm.js";
import "./components/AiChoice.js";
import "./components/AiChoiceMulti.js";
import "./components/AiField.js";
import "./components/AiSelect.js";
import "./components/AiGroup.js";
import "./components/AiMarkdownFallback.js";
class Application {
constructor() {
this.accessibility = new Accessibility();
this.aiAutoload = new AiAutoload(document);
this.aiAutoload.start();
this.dialog = document.createElement("dp-dialog");
this.contextMenu = document.createElement("dp-context-menu");
this.toast = document.createElement("dp-toast");
@@ -77,6 +93,7 @@ class Application {
this.notificationPrefs = new NotificationPrefs(this.pubsub);
this.aiCorrection = new AiCorrection();
this.aiModifier = new AiModifier();
this.interactionsPref = new InteractionsPref();
this.telegramPairing = new TelegramPairing(this.pubsub);
this.adminNotificationDefaults = new AdminNotificationDefaults();
this.userAiUsage = new UserAiUsage();
+63 -4
View File
@@ -20,8 +20,32 @@ export class ContentRenderer {
return text.replace(/\u2014/g, "-");
}
render(text) {
render(text, authorAdmin = false) {
if (!text) return "";
let widgets = [];
const widgetRe = /<dp-widget>([\s\S]*?)<\/dp-widget>/gi;
if (authorAdmin && widgetRe.test(text)) {
widgetRe.lastIndex = 0;
let m;
let idx = 0;
let parts = [];
let last = 0;
while ((m = widgetRe.exec(text)) !== null) {
if (m.index > last) {
parts.push(text.substring(last, m.index));
}
parts.push("\x00WIDGET_" + idx + "\x00");
widgets.push(m[1]);
idx++;
last = m.index + m[0].length;
}
if (last < text.length) {
parts.push(text.substring(last));
}
text = parts.join("");
}
text = this.normalizeDashes(text);
text = this.replaceShortcodes(text);
@@ -37,6 +61,10 @@ export class ContentRenderer {
}
html = DOMPurify.sanitize(html);
for (let i = 0; i < widgets.length; i++) {
html = html.replace("\x00WIDGET_" + i + "\x00", widgets[i]);
}
html = this.processMedia(html);
return html;
@@ -67,8 +95,32 @@ export class ContentRenderer {
return div.innerHTML;
}
renderInline(text) {
renderInline(text, authorAdmin = false) {
if (!text) return "";
let widgets = [];
const widgetRe = /<dp-widget>([\s\S]*?)<\/dp-widget>/gi;
if (authorAdmin && widgetRe.test(text)) {
widgetRe.lastIndex = 0;
let m;
let idx = 0;
let parts = [];
let last = 0;
while ((m = widgetRe.exec(text)) !== null) {
if (m.index > last) {
parts.push(text.substring(last, m.index));
}
parts.push("\x00WIDGET_" + idx + "\x00");
widgets.push(m[1]);
idx++;
last = m.index + m[0].length;
}
if (last < text.length) {
parts.push(text.substring(last));
}
text = parts.join("");
}
text = this.normalizeDashes(text);
text = this.replaceShortcodes(text);
@@ -83,10 +135,16 @@ export class ContentRenderer {
throw new Error("DOMPurify not loaded; refusing to render untrusted HTML");
}
return DOMPurify.sanitize(html, {
html = DOMPurify.sanitize(html, {
ALLOWED_TAGS: ["b", "strong", "i", "em", "code", "del", "s", "mark", "sub", "sup", "span", "br"],
ALLOWED_ATTR: [],
});
for (let i = 0; i < widgets.length; i++) {
html = html.replace("\x00WIDGET_" + i + "\x00", widgets[i]);
}
return html;
}
processMedia(html) {
@@ -241,8 +299,9 @@ export class ContentRenderer {
applyTo(element) {
if (!element) return;
const authorAdmin = element.hasAttribute("data-author-admin");
const text = element.textContent || element.innerText || "";
const rendered = this.render(text);
const rendered = this.render(text, authorAdmin);
element.innerHTML = rendered;
}
+67
View File
@@ -0,0 +1,67 @@
// retoor <retoor@molodetz.nl>
import { Http } from "./Http.js";
class InteractionsPref {
constructor() {
this.root = document.querySelector("[data-interactions]");
if (!this.root) return;
this.username = this.root.dataset.username;
this.toggle = this.root.querySelector("[data-interactions-toggle]");
this.status = this.root.querySelector("[data-interactions-status]");
this.save = this.root.querySelector("[data-interactions-save]");
this.reset = this.root.querySelector("[data-interactions-reset]");
if (this.save) this.save.addEventListener("click", () => this.submit(false));
if (this.reset) this.reset.addEventListener("click", () => this.submit(true));
}
async submit(reset) {
if (!this.toggle) return;
if (this.save) this.save.disabled = true;
if (this.reset) this.reset.disabled = true;
this.setStatus("Saving...", "");
try {
const body = reset
? { reset: "true", enabled: "false" }
: {
enabled: this.toggle.checked ? "true" : "false",
reset: "false",
};
const result = await Http.send(
`/profile/${this.username}/interactions`,
body
);
if (result && result.data) {
this.toggle.checked = !!result.data.enabled;
}
this.setStatus(reset ? "Using site default" : "Saved", "success");
this.flash(
reset
? "Interactive widgets follow the site default"
: "Interactive widget preference saved",
"success"
);
} catch (error) {
this.setStatus("Could not save", "error");
this.flash("Could not save interactive widget preference", "error");
} finally {
if (this.save) this.save.disabled = false;
if (this.reset) this.reset.disabled = false;
}
}
setStatus(message, type) {
if (!this.status) return;
this.status.textContent = message;
this.status.dataset.state = type;
}
flash(message, type) {
if (window.app && window.app.toast) {
window.app.toast.show(message, { type });
}
}
}
window.InteractionsPref = InteractionsPref;
export { InteractionsPref };
+115 -29
View File
@@ -5,6 +5,8 @@ import { contentRenderer } from "./ContentRenderer.js";
const TYPING_THROTTLE_MS = 1500;
const TYPING_HIDE_MS = 4000;
const AUTO_SCROLL_MARGIN_PX = 100;
const STABILIZE_MAX_FRAMES = 300;
export class MessagesLayout {
constructor() {
@@ -27,6 +29,12 @@ export class MessagesLayout {
this._lastTypingSent = 0;
this._typingHideTimer = null;
this._socketReady = false;
this._userAtBottom = true;
this._stabilizeFrames = 0;
this._stabilizePending = false;
// Expose for diagnostics
window.__messagesLayout = this;
this.scrollThreadToEnd();
if (this.input) {
@@ -37,9 +45,83 @@ export class MessagesLayout {
this.bindForm();
this.bindTyping();
this.bindViewport();
this.bindAutoScroll();
this._startScrollWatcher();
this.markRead();
}
_startScrollWatcher() {
if (!this.thread) return;
const onAnyChange = () => {
if (!this._userAtBottom) return;
if (this._stabilizePending) return;
this._stabilizePending = true;
requestAnimationFrame(() => {
this._stabilizePending = false;
this._stabilizeScroll();
});
};
this._scrollWatcher = new MutationObserver(onAnyChange);
this._scrollWatcher.observe(this.thread, {
childList: true,
subtree: true,
});
this.thread.addEventListener("load", (e) => {
if (e.target.tagName === "IMG" && this._userAtBottom) {
onAnyChange();
}
}, true);
// Kick initial stabilization
onAnyChange();
}
_stabilizeScroll() {
if (!this.thread) {
this._stabilizeFrames = 0;
return;
}
if (!this._userAtBottom) {
this._stabilizeFrames = 0;
return;
}
if (this._stabilizeFrames >= STABILIZE_MAX_FRAMES) {
this._stabilizeFrames = 0;
return;
}
this._stabilizeFrames++;
this.thread.scrollTop = this.thread.scrollHeight;
const atBottom = this.thread.scrollHeight
- this.thread.scrollTop
- this.thread.clientHeight < 10;
if (!atBottom) {
this._stabilizePending = true;
requestAnimationFrame(() => {
this._stabilizePending = false;
this._stabilizeScroll();
});
} else {
this._stabilizeFrames = 0;
}
}
bindAutoScroll() {
if (!this.thread) return;
this.thread.addEventListener("scroll", () => {
if (this._stabilizePending) return;
const atBottom = this.thread.scrollHeight
- this.thread.scrollTop
- this.thread.clientHeight < AUTO_SCROLL_MARGIN_PX;
this._userAtBottom = atBottom;
if (atBottom) {
this._stabilizeScroll();
}
}, { passive: true });
}
bindViewport() {
const page = document.querySelector(".page-messages");
if (!page || !this.input || !this.form) return;
@@ -55,7 +137,8 @@ export class MessagesLayout {
if (newInset !== currentInset) {
page.style.setProperty("--kb-inset", `${Math.round(newInset)}px`);
requestAnimationFrame(() => this.scrollThreadToEnd());
this._userAtBottom = true;
this.scrollThreadToEnd();
}
};
@@ -83,8 +166,21 @@ export class MessagesLayout {
this._viewportIO = io;
}
this.input.addEventListener("keydown", (event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
this.form.requestSubmit();
}
});
this.input.addEventListener("input", () => {
this.input.style.height = "auto";
this.input.style.height = Math.min(this.input.scrollHeight, 160) + "px";
});
this.input.addEventListener("focus", () => {
requestAnimationFrame(() => {
this._userAtBottom = true;
const doScroll = () => {
this.scrollThreadToEnd();
if (window.innerWidth < 768) {
const retry = (delay) => setTimeout(() => {
@@ -95,7 +191,8 @@ export class MessagesLayout {
retry(350);
retry(600);
}
});
};
requestAnimationFrame(doScroll);
});
this.input.addEventListener("blur", () => {
@@ -240,12 +337,11 @@ export class MessagesLayout {
pending.dataset.msgUid = frame.uid;
const oldBody = pending.querySelector(".rendered-content");
if (oldBody && frame.content !== undefined) {
const body = document.createElement("div");
body.className = "rendered-content";
body.setAttribute("data-render", "");
body.textContent = frame.content || "";
oldBody.replaceWith(body);
this.renderBody(body);
const content = document.createElement("dp-content");
content.setAttribute("no-copy", "");
if (frame.sender_role === "Admin") content.setAttribute("data-author-admin", "");
content.textContent = frame.content || "";
oldBody.replaceWith(content);
}
const time = pending.querySelector(".message-time");
if (time && frame.created_at) {
@@ -277,6 +373,7 @@ export class MessagesLayout {
time: frame.time_ago,
iso: frame.created_at,
uid: frame.uid,
senderRole: frame.sender_role,
attachments: frame.attachments,
});
this.insertBubble(bubble);
@@ -287,17 +384,17 @@ export class MessagesLayout {
this.bumpConversation(frame, frame.sender_uid === this.selfUid);
}
buildBubble({ content, mine, time, iso, uid, clientId, attachments, attachmentCount }) {
buildBubble({ content, mine, time, iso, uid, clientId, senderRole, attachments, attachmentCount }) {
const bubble = document.createElement("div");
bubble.className = "message-bubble " + (mine ? "mine" : "theirs");
if (uid) bubble.dataset.msgUid = uid;
if (clientId) bubble.dataset.clientId = clientId;
const body = document.createElement("div");
body.className = "rendered-content";
body.setAttribute("data-render", "");
body.textContent = content || "";
bubble.appendChild(body);
const el = document.createElement("dp-content");
el.setAttribute("no-copy", "");
if (senderRole === "Admin") el.setAttribute("data-author-admin", "");
el.textContent = content || "";
bubble.appendChild(el);
const gallery = this.renderAttachments(attachments);
if (gallery) {
@@ -332,20 +429,9 @@ export class MessagesLayout {
bubble.appendChild(receipt);
}
this.renderBody(body);
return bubble;
}
renderBody(el) {
contentRenderer.ready.then(() => {
contentRenderer.applyTo(el);
el.querySelectorAll("img:not(.avatar-img)").forEach((img) => {
img.dataset.lightbox = "";
});
contentRenderer.highlightAll();
});
}
renderAttachments(attachments) {
if (!attachments || !attachments.length) return null;
const gallery = document.createElement("div");
@@ -434,8 +520,8 @@ export class MessagesLayout {
}
scrollThreadToEnd() {
if (this.thread) {
this.thread.scrollTop = this.thread.scrollHeight;
}
if (!this.thread) return;
if (!this._userAtBottom) return;
this.thread.scrollTop = this.thread.scrollHeight;
}
}
+114
View File
@@ -0,0 +1,114 @@
// retoor <retoor@molodetz.nl>
import { assetUrl } from "../assetVersion.js";
const TAG_RE = /^ai-[a-z0-9-]+$/;
const ALLOWLIST = {
"ai-interaction": "AiInteraction.js",
"ai-confirm": "AiConfirm.js",
"ai-choice": "AiChoice.js",
"ai-choice-multi": "AiChoiceMulti.js",
"ai-field": "AiField.js",
"ai-select": "AiSelect.js",
"ai-option": "AiOption.js",
"ai-group": "AiGroup.js",
"ai-help": "AiHelp.js",
"ai-actions": "AiActions.js",
"ai-status": "AiStatus.js",
"ai-markdown-fallback": "AiMarkdownFallback.js",
};
export class AiAutoload {
constructor(root = document) {
this.root = root;
this.loading = new Map();
this.failed = new Set();
this._observer = null;
}
start() {
this.scan(this.root);
if (this._observer) return;
this._observer = new MutationObserver((records) => {
for (const record of records) {
for (const node of record.addedNodes) {
if (node.nodeType !== 1) continue;
this.scan(node);
}
}
});
this._observer.observe(this.root === document ? document.documentElement : this.root, {
childList: true,
subtree: true,
});
}
stop() {
if (this._observer) {
this._observer.disconnect();
this._observer = null;
}
}
scan(root) {
if (!root || root.nodeType !== 1) return;
const nodes = [];
if (TAG_RE.test(root.localName || "")) nodes.push(root);
if (root.querySelectorAll) {
nodes.push(...root.querySelectorAll("*"));
}
for (const el of nodes) {
const name = el.localName || "";
if (!TAG_RE.test(name)) continue;
this.ensure(name, el).catch(() => null);
}
}
ensure(tag, el = null) {
if (customElements.get(tag)) return Promise.resolve();
if (this.loading.has(tag)) {
return this.loading.get(tag);
}
const file = ALLOWLIST[tag];
if (!file) {
this.failed.add(tag);
this._markFailed(el, tag);
return Promise.reject(new Error(`ai widget not allowlisted: ${tag}`));
}
const url = assetUrl(`/static/js/components/${file}`);
const task = import(url)
.then(() => {
this.loading.delete(tag);
this.failed.delete(tag);
if (!customElements.get(tag)) {
return customElements.whenDefined(tag);
}
return undefined;
})
.catch((error) => {
this.loading.delete(tag);
this.failed.add(tag);
console.error("AiAutoload failed", tag, url, error);
this._markFailed(el, tag);
window.setTimeout(() => this.failed.delete(tag), 3000);
throw error;
});
this.loading.set(tag, task);
return task;
}
_markFailed(el, tag) {
if (!el || !el.isConnected || !el.replaceWith) return;
if (el.getAttribute("data-ai-load-failed") === tag) return;
el.setAttribute("data-ai-load-failed", tag);
const status = document.createElement("div");
status.className = "ai-status";
status.dataset.state = "error";
status.setAttribute("aria-live", "polite");
status.textContent = `Widget failed to load (${tag})`;
el.replaceWith(status);
}
}
export default AiAutoload;
@@ -0,0 +1,52 @@
// retoor <retoor@molodetz.nl>
import { Component } from "./Component.js";
import { assetUrl } from "../assetVersion.js";
const CSS_ID = "ai-actions-css";
export class AiActions extends Component {
static get observedAttributes() {
return ["align", "sticky", "submit-label", "cancel-label", "cancelable"];
}
_ensureCss() {
if (document.getElementById(CSS_ID)) return;
const link = document.createElement("link");
link.id = CSS_ID;
link.rel = "stylesheet";
link.href = assetUrl("/static/css/components/ai-interaction.css");
document.head.appendChild(link);
}
connectedCallback() {
this._ensureCss();
if (this._built) return;
this._built = true;
this.classList.add("ai-actions");
if (this.attr("align") === "end") this.classList.add("align-end");
this.innerHTML = "";
if (this.hasAttribute("cancelable")) {
const cancel = document.createElement("button");
cancel.type = "button";
cancel.className = "ai-btn ai-btn-secondary";
cancel.dataset.aiCancel = "1";
cancel.textContent = this.attr("cancel-label", "Cancel");
this.appendChild(cancel);
}
if (!this.hasAttribute("submit-hidden")) {
const submit = document.createElement("button");
submit.type = "button";
submit.className = "ai-btn ai-btn-primary";
submit.dataset.aiSubmit = "1";
submit.textContent = this.attr("submit-label", "Confirm");
this.appendChild(submit);
}
}
}
if (!customElements.get("ai-actions")) {
customElements.define("ai-actions", AiActions);
}
export default AiActions;
+137
View File
@@ -0,0 +1,137 @@
// retoor <retoor@molodetz.nl>
import { Component } from "./Component.js";
import { assetUrl } from "../assetVersion.js";
const CSS_ID = "ai-choice-css";
export class AiChoice extends Component {
static get observedAttributes() {
return ["name", "label", "help", "display", "required", "disabled", "value"];
}
constructor() {
super();
this._value = "";
}
_ensureCss() {
if (document.getElementById(CSS_ID)) return;
const link = document.createElement("link");
link.id = CSS_ID;
link.rel = "stylesheet";
link.href = assetUrl("/static/css/components/ai-choice.css");
document.head.appendChild(link);
}
connectedCallback() {
this._ensureCss();
if (this._built) return;
this._built = true;
this.classList.add("ai-choice");
this._value = this.attr("value", "");
const name = this.attr("name", "choice");
const label = this.attr("label", "Choice");
const display = this.attr("display", "auto");
const options = [...this.querySelectorAll("ai-option")].map((el) => ({
value: el.getAttribute("value") || "",
label: el.getAttribute("label") || el.textContent || "",
description: el.getAttribute("description") || "",
disabled: el.hasAttribute("disabled"),
selected: el.hasAttribute("selected"),
}));
this.innerHTML = "";
const title = document.createElement("div");
title.className = "ai-widget-label";
title.textContent = label;
this.appendChild(title);
if (this.attr("help")) {
const help = document.createElement("div");
help.className = "ai-widget-help";
help.textContent = this.attr("help");
this.appendChild(help);
}
if (display === "select" || (display === "auto" && options.length > 6)) {
const select = document.createElement("select");
select.className = "ai-choice-select";
select.name = name;
const blank = document.createElement("option");
blank.value = "";
blank.textContent = "Select…";
select.appendChild(blank);
for (const option of options) {
const el = document.createElement("option");
el.value = option.value;
el.textContent = option.label;
el.disabled = option.disabled;
if (option.selected || option.value === this._value) el.selected = true;
select.appendChild(el);
}
select.addEventListener("change", () => {
this._value = select.value;
this._emit(name);
});
this.appendChild(select);
this._value = select.value;
return;
}
const group = document.createElement("div");
group.className = "ai-choice-options";
group.setAttribute("role", "radiogroup");
group.setAttribute("aria-label", label);
for (const option of options) {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "ai-choice-option";
btn.disabled = option.disabled;
btn.dataset.value = option.value;
btn.innerHTML = `<span class="ai-choice-option-label"></span>`;
btn.querySelector(".ai-choice-option-label").textContent = option.label;
if (option.description) {
const desc = document.createElement("span");
desc.className = "ai-choice-option-desc";
desc.textContent = option.description;
btn.appendChild(desc);
}
if (option.selected || option.value === this._value) {
btn.classList.add("selected");
this._value = option.value;
}
btn.addEventListener("click", () => {
this._value = option.value;
group.querySelectorAll(".ai-choice-option").forEach((el) => el.classList.remove("selected"));
btn.classList.add("selected");
this._emit(name);
});
group.appendChild(btn);
}
this.appendChild(group);
}
_emit(name) {
this.dispatchEvent(
new CustomEvent("ai:change", {
bubbles: true,
composed: true,
detail: { name, value: this._value },
})
);
}
getValue() {
return this._value;
}
validate() {
if (this.boolAttr("required") !== false && !this._value) {
return { valid: false, message: `${this.attr("label", "Choice")} is required` };
}
return { valid: true };
}
}
if (!customElements.get("ai-choice")) {
customElements.define("ai-choice", AiChoice);
}
export default AiChoice;
@@ -0,0 +1,111 @@
// retoor <retoor@molodetz.nl>
import { Component } from "./Component.js";
import { assetUrl } from "../assetVersion.js";
const CSS_ID = "ai-choice-multi-css";
export class AiChoiceMulti extends Component {
static get observedAttributes() {
return ["name", "label", "help", "required", "disabled", "min-selected", "max-selected"];
}
constructor() {
super();
this._values = new Set();
}
_ensureCss() {
if (document.getElementById(CSS_ID)) return;
const link = document.createElement("link");
link.id = CSS_ID;
link.rel = "stylesheet";
link.href = assetUrl("/static/css/components/ai-choice.css");
document.head.appendChild(link);
}
connectedCallback() {
this._ensureCss();
if (this._built) return;
this._built = true;
this.classList.add("ai-choice-multi");
const name = this.attr("name", "choices");
const label = this.attr("label", "Choices");
const options = [...this.querySelectorAll("ai-option")].map((el) => ({
value: el.getAttribute("value") || "",
label: el.getAttribute("label") || el.textContent || "",
description: el.getAttribute("description") || "",
disabled: el.hasAttribute("disabled"),
selected: el.hasAttribute("selected"),
}));
this.innerHTML = "";
const title = document.createElement("div");
title.className = "ai-widget-label";
title.textContent = label;
this.appendChild(title);
if (this.attr("help")) {
const help = document.createElement("div");
help.className = "ai-widget-help";
help.textContent = this.attr("help");
this.appendChild(help);
}
const group = document.createElement("div");
group.className = "ai-choice-options multi";
for (const option of options) {
const labelEl = document.createElement("label");
labelEl.className = "ai-choice-check";
const input = document.createElement("input");
input.type = "checkbox";
input.value = option.value;
input.disabled = option.disabled;
if (option.selected) {
input.checked = true;
this._values.add(option.value);
}
input.addEventListener("change", () => {
if (input.checked) this._values.add(option.value);
else this._values.delete(option.value);
this.dispatchEvent(
new CustomEvent("ai:change", {
bubbles: true,
composed: true,
detail: { name, value: this.getValue() },
})
);
});
const span = document.createElement("span");
span.textContent = option.label;
labelEl.append(input, span);
if (option.description) {
const desc = document.createElement("small");
desc.textContent = option.description;
labelEl.appendChild(desc);
}
group.appendChild(labelEl);
}
this.appendChild(group);
}
getValue() {
return [...this._values];
}
validate() {
const min = this.intAttr("min-selected", this.boolAttr("required") ? 1 : 0);
const max = this.intAttr("max-selected", 0);
const count = this._values.size;
if (count < min) {
return { valid: false, message: `${this.attr("label", "Choices")}: select at least ${min}` };
}
if (max > 0 && count > max) {
return { valid: false, message: `${this.attr("label", "Choices")}: select at most ${max}` };
}
return { valid: true };
}
}
if (!customElements.get("ai-choice-multi")) {
customElements.define("ai-choice-multi", AiChoiceMulti);
}
export default AiChoiceMulti;
@@ -0,0 +1,103 @@
// retoor <retoor@molodetz.nl>
import { Component } from "./Component.js";
import { assetUrl } from "../assetVersion.js";
const CSS_ID = "ai-confirm-css";
export class AiConfirm extends Component {
static get observedAttributes() {
return ["name", "label", "help", "confirm-label", "reject-label", "default", "required", "disabled"];
}
constructor() {
super();
this._value = null;
}
_ensureCss() {
if (document.getElementById(CSS_ID)) return;
const link = document.createElement("link");
link.id = CSS_ID;
link.rel = "stylesheet";
link.href = assetUrl("/static/css/components/ai-confirm.css");
document.head.appendChild(link);
}
connectedCallback() {
this._ensureCss();
if (this._built) return;
this._built = true;
this.classList.add("ai-confirm");
const name = this.attr("name", "confirm");
const label = this.attr("label", "Confirm");
const yes = this.attr("confirm-label", "Yes");
const no = this.attr("reject-label", "No");
const def = this.getAttribute("default");
this._value = def === "true" ? true : def === "false" ? false : null;
this.innerHTML = "";
const title = document.createElement("div");
title.className = "ai-widget-label";
title.textContent = label;
this.appendChild(title);
if (this.attr("help")) {
const help = document.createElement("div");
help.className = "ai-widget-help";
help.textContent = this.attr("help");
this.appendChild(help);
}
const group = document.createElement("div");
group.className = "ai-confirm-options";
group.setAttribute("role", "radiogroup");
group.setAttribute("aria-label", label);
for (const [val, text] of [
[true, yes],
[false, no],
]) {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "ai-confirm-option";
btn.textContent = text;
btn.dataset.value = String(val);
if (this._value === val) btn.classList.add("selected");
btn.addEventListener("click", () => {
this._value = val;
group.querySelectorAll(".ai-confirm-option").forEach((el) => el.classList.remove("selected"));
btn.classList.add("selected");
this.dispatchEvent(
new CustomEvent("ai:change", {
bubbles: true,
composed: true,
detail: { name, value: val },
})
);
const shell = this.closest("ai-interaction");
if (shell && typeof shell.submit === "function") {
const fields = shell.querySelectorAll(
"ai-confirm, ai-choice, ai-choice-multi, ai-field, ai-select"
);
if (fields.length === 1) shell.submit();
}
});
group.appendChild(btn);
}
this.appendChild(group);
}
getValue() {
return this._value;
}
validate() {
if (this.boolAttr("required") && this._value === null) {
return { valid: false, message: `${this.attr("label", "Confirm")} is required` };
}
return { valid: true };
}
}
if (!customElements.get("ai-confirm")) {
customElements.define("ai-confirm", AiConfirm);
}
export default AiConfirm;
+207
View File
@@ -0,0 +1,207 @@
// retoor <retoor@molodetz.nl>
import { Component } from "./Component.js";
import { assetUrl } from "../assetVersion.js";
const CSS_ID = "ai-field-css";
const EUROPEAN_DATE = /^(\d{1,2})[./-](\d{1,2})[./-](\d{4})$/;
const ISO_DATE = /^(\d{4})-(\d{2})-(\d{2})$/;
function toEuropeanDate(value) {
const text = String(value || "").trim();
if (!text) return "";
const iso = text.match(ISO_DATE);
if (iso) return `${iso[3]}/${iso[2]}/${iso[1]}`;
const eu = text.match(EUROPEAN_DATE);
if (eu) {
const day = eu[1].padStart(2, "0");
const month = eu[2].padStart(2, "0");
return `${day}/${month}/${eu[3]}`;
}
return text;
}
function parseEuropeanDate(value) {
const text = String(value || "").trim();
if (!text) return null;
let day;
let month;
let year;
const iso = text.match(ISO_DATE);
if (iso) {
year = Number(iso[1]);
month = Number(iso[2]);
day = Number(iso[3]);
} else {
const eu = text.match(EUROPEAN_DATE);
if (!eu) return null;
day = Number(eu[1]);
month = Number(eu[2]);
year = Number(eu[3]);
}
if (month < 1 || month > 12 || day < 1 || day > 31) return null;
const date = new Date(Date.UTC(year, month - 1, day));
if (
date.getUTCFullYear() !== year ||
date.getUTCMonth() !== month - 1 ||
date.getUTCDate() !== day
) {
return null;
}
const dd = String(day).padStart(2, "0");
const mm = String(month).padStart(2, "0");
return {
european: `${dd}/${mm}/${year}`,
iso: `${year}-${mm}-${dd}`,
};
}
export class AiField extends Component {
static get observedAttributes() {
return [
"name",
"type",
"label",
"help",
"placeholder",
"min",
"max",
"step",
"maxlength",
"pattern",
"required",
"disabled",
"value",
];
}
_ensureCss() {
if (document.getElementById(CSS_ID)) return;
const link = document.createElement("link");
link.id = CSS_ID;
link.rel = "stylesheet";
link.href = assetUrl("/static/css/components/ai-field.css");
document.head.appendChild(link);
}
connectedCallback() {
this._ensureCss();
if (this._built) return;
this._built = true;
this.classList.add("ai-field");
const type = this.attr("type", "text");
this._fieldType = type;
const name = this.attr("name", "field");
const label = this.attr("label", name);
const wrap = document.createElement("label");
wrap.className = "ai-field-wrap";
const title = document.createElement("span");
title.className = "ai-widget-label";
title.textContent = label;
wrap.appendChild(title);
const helpText =
this.attr("help") ||
(type === "date" ? "Format: DD/MM/YYYY" : "");
if (helpText) {
const help = document.createElement("span");
help.className = "ai-widget-help";
help.textContent = helpText;
wrap.appendChild(help);
}
const input = document.createElement("input");
input.className = "ai-field-input";
input.name = name;
if (type === "number") {
input.type = "number";
} else {
input.type = "text";
}
if (type === "date") {
input.inputMode = "numeric";
input.autocomplete = "off";
input.placeholder = this.attr("placeholder", "DD/MM/YYYY");
input.maxLength = 10;
input.pattern = "\\d{1,2}[/.-]\\d{1,2}[/.-]\\d{4}";
input.title = "DD/MM/YYYY";
input.value = toEuropeanDate(this.attr("value", ""));
} else {
input.placeholder = this.attr("placeholder", "");
input.value = this.attr("value", "");
}
if (this.hasAttribute("min") && type === "number") input.min = this.attr("min");
if (this.hasAttribute("max") && type === "number") input.max = this.attr("max");
if (this.hasAttribute("step") && type === "number") input.step = this.attr("step");
if (this.hasAttribute("maxlength") && type !== "date") {
input.maxLength = this.intAttr("maxlength", 0) || 524288;
}
if (this.attr("pattern") && type !== "date") input.pattern = this.attr("pattern");
if (this.boolAttr("required")) input.required = true;
if (this.boolAttr("disabled")) input.disabled = true;
input.addEventListener("input", () => {
if (this._fieldType === "date") {
const cleaned = input.value.replace(/[^\d/.\-]/g, "").slice(0, 10);
if (cleaned !== input.value) input.value = cleaned;
}
this.dispatchEvent(
new CustomEvent("ai:change", {
bubbles: true,
composed: true,
detail: { name, value: this.getValue() },
})
);
});
input.addEventListener("blur", () => {
if (this._fieldType !== "date") return;
const parsed = parseEuropeanDate(input.value);
if (parsed) input.value = parsed.european;
});
wrap.appendChild(input);
this.innerHTML = "";
this.appendChild(wrap);
this._input = input;
}
getValue() {
if (!this._input) return this.attr("value", "");
if (this._fieldType === "number" || this._input.type === "number") {
const n = this._input.valueAsNumber;
return Number.isNaN(n) ? null : n;
}
if (this._fieldType === "date") {
const parsed = parseEuropeanDate(this._input.value);
return parsed ? parsed.european : this._input.value.trim();
}
return this._input.value;
}
validate() {
if (!this._input) return { valid: true };
if (this._fieldType === "date") {
const raw = this._input.value.trim();
if (!raw) {
if (this.boolAttr("required")) {
return { valid: false, message: "Enter a date as DD/MM/YYYY" };
}
return { valid: true };
}
if (!parseEuropeanDate(raw)) {
return { valid: false, message: "Date must be DD/MM/YYYY" };
}
return { valid: true };
}
if (!this._input.checkValidity()) {
return { valid: false, message: this._input.validationMessage || "Invalid value" };
}
return { valid: true };
}
focus() {
if (this._input) this._input.focus();
}
}
if (!customElements.get("ai-field")) {
customElements.define("ai-field", AiField);
}
export default AiField;
@@ -0,0 +1,38 @@
// retoor <retoor@molodetz.nl>
import { Component } from "./Component.js";
import { assetUrl } from "../assetVersion.js";
const CSS_ID = "ai-group-css";
export class AiGroup extends Component {
static get observedAttributes() {
return ["label", "collapsed", "compact"];
}
_ensureCss() {
if (document.getElementById(CSS_ID)) return;
const link = document.createElement("link");
link.id = CSS_ID;
link.rel = "stylesheet";
link.href = assetUrl("/static/css/components/ai-interaction.css");
document.head.appendChild(link);
}
connectedCallback() {
this._ensureCss();
if (this._built) return;
this._built = true;
this.classList.add("ai-group");
const legend = document.createElement("div");
legend.className = "ai-group-label";
legend.textContent = this.attr("label", "Group");
this.insertBefore(legend, this.firstChild);
}
}
if (!customElements.get("ai-group")) {
customElements.define("ai-group", AiGroup);
}
export default AiGroup;
+22
View File
@@ -0,0 +1,22 @@
// retoor <retoor@molodetz.nl>
import { Component } from "./Component.js";
export class AiHelp extends Component {
static get observedAttributes() {
return ["text"];
}
connectedCallback() {
this.classList.add("ai-help");
if (!this.textContent.trim()) {
this.textContent = this.attr("text", "");
}
}
}
if (!customElements.get("ai-help")) {
customElements.define("ai-help", AiHelp);
}
export default AiHelp;
@@ -0,0 +1,217 @@
// retoor <retoor@molodetz.nl>
import { Component } from "./Component.js";
import { assetUrl } from "../assetVersion.js";
const CSS_ID = "ai-interaction-css";
export class AiInteraction extends Component {
static get observedAttributes() {
return [
"id",
"channel",
"state",
"mode",
"title",
"cancelable",
"timeout-sec",
"submit-label",
"cancel-label",
];
}
constructor() {
super();
this._built = false;
this._timer = null;
this._closed = false;
}
_ensureCss() {
if (document.getElementById(CSS_ID)) return;
const link = document.createElement("link");
link.id = CSS_ID;
link.rel = "stylesheet";
link.href = assetUrl("/static/css/components/ai-interaction.css");
document.head.appendChild(link);
}
connectedCallback() {
this._ensureCss();
if (!this._built) {
this._built = true;
this._build();
}
this._bind();
this._startTimeout();
}
disconnectedCallback() {
if (this._timer) {
clearTimeout(this._timer);
this._timer = null;
}
}
_build() {
this.classList.add("ai-interaction");
if (!this.querySelector(".ai-interaction-head")) {
const head = document.createElement("div");
head.className = "ai-interaction-head";
const title = document.createElement("h3");
title.className = "ai-interaction-title";
title.textContent = this.attr("title", "Question");
head.appendChild(title);
const status = document.createElement("ai-status");
status.setAttribute("state", this.attr("state", "open"));
status.className = "ai-interaction-status";
head.appendChild(status);
this.insertBefore(head, this.firstChild);
}
if (!this.querySelector("ai-actions")) {
const onlyConfirm =
this.querySelectorAll("ai-confirm").length === 1 &&
this.querySelectorAll("ai-choice, ai-choice-multi, ai-field, ai-select").length === 0;
const actions = document.createElement("ai-actions");
actions.setAttribute("align", "end");
actions.setAttribute("submit-label", this.attr("submit-label", "Confirm"));
actions.setAttribute("cancel-label", this.attr("cancel-label", "Cancel"));
if (this.boolAttr("cancelable") || this.getAttribute("cancelable") === null) {
actions.setAttribute("cancelable", "");
}
if (onlyConfirm) {
actions.setAttribute("submit-hidden", "");
}
this.appendChild(actions);
}
}
_bind() {
if (this._bound) return;
this._bound = true;
this.addEventListener("click", (event) => {
const submit = event.target.closest("[data-ai-submit]");
const cancel = event.target.closest("[data-ai-cancel]");
if (submit) {
event.preventDefault();
this.submit();
} else if (cancel) {
event.preventDefault();
this.cancel("user");
}
});
this.addEventListener("keydown", (event) => {
if (event.key === "Escape" && this.boolAttr("cancelable") !== false) {
event.preventDefault();
this.cancel("escape");
}
});
}
_startTimeout() {
const sec = this.intAttr("timeout-sec", 0);
if (!sec || sec <= 0) return;
this._timer = setTimeout(() => this.timeout(), sec * 1000);
}
collectValues() {
const values = {};
const fields = this.querySelectorAll(
"ai-confirm, ai-choice, ai-choice-multi, ai-field, ai-select"
);
for (const field of fields) {
if (typeof field.getValue !== "function") continue;
const name = field.getAttribute("name");
if (!name) continue;
values[name] = field.getValue();
}
return values;
}
validate() {
const fields = this.querySelectorAll(
"ai-confirm, ai-choice, ai-choice-multi, ai-field, ai-select"
);
for (const field of fields) {
if (typeof field.validate === "function") {
const result = field.validate();
if (result && result.valid === false) {
if (typeof field.focus === "function") field.focus();
return result;
}
}
}
return { valid: true };
}
submit() {
if (this._closed) return;
const validity = this.validate();
if (!validity.valid) {
this._setStatus("error", validity.message || "Invalid input");
return;
}
const values = this.collectValues();
this._close("submitted");
this.dispatchEvent(
new CustomEvent("ai:submit", {
bubbles: true,
composed: true,
detail: { values, interaction_id: this.id },
})
);
}
cancel(reason = "user") {
if (this._closed) return;
this._close("cancelled");
this.dispatchEvent(
new CustomEvent("ai:cancel", {
bubbles: true,
composed: true,
detail: { reason, interaction_id: this.id },
})
);
}
timeout() {
if (this._closed) return;
this._close("timeout");
this.dispatchEvent(
new CustomEvent("ai:timeout", {
bubbles: true,
composed: true,
detail: {
timeout_sec: this.intAttr("timeout-sec", 0),
interaction_id: this.id,
},
})
);
}
_close(state) {
this._closed = true;
this.setAttribute("state", state);
this._setStatus(state);
this.querySelectorAll("button, input, select, textarea").forEach((el) => {
el.disabled = true;
});
if (this._timer) {
clearTimeout(this._timer);
this._timer = null;
}
}
_setStatus(state, message = "") {
const status = this.querySelector("ai-status");
if (!status) return;
status.setAttribute("state", state);
if (message) status.setAttribute("message", message);
}
}
if (!customElements.get("ai-interaction")) {
customElements.define("ai-interaction", AiInteraction);
}
export default AiInteraction;
@@ -0,0 +1,23 @@
// retoor <retoor@molodetz.nl>
import { Component } from "./Component.js";
export class AiMarkdownFallback extends Component {
static get observedAttributes() {
return ["source"];
}
connectedCallback() {
this.classList.add("ai-markdown-fallback");
const pre = document.createElement("pre");
pre.textContent = this.attr("source", this.textContent || "");
this.innerHTML = "";
this.appendChild(pre);
}
}
if (!customElements.get("ai-markdown-fallback")) {
customElements.define("ai-markdown-fallback", AiMarkdownFallback);
}
export default AiMarkdownFallback;
@@ -0,0 +1,15 @@
// retoor <retoor@molodetz.nl>
import { Component } from "./Component.js";
export class AiOption extends Component {
connectedCallback() {
this.hidden = true;
}
}
if (!customElements.get("ai-option")) {
customElements.define("ai-option", AiOption);
}
export default AiOption;
@@ -0,0 +1,16 @@
// retoor <retoor@molodetz.nl>
import { AiChoice } from "./AiChoice.js";
export class AiSelect extends AiChoice {
connectedCallback() {
if (!this.hasAttribute("display")) this.setAttribute("display", "select");
super.connectedCallback();
}
}
if (!customElements.get("ai-select")) {
customElements.define("ai-select", AiSelect);
}
export default AiSelect;
@@ -0,0 +1,32 @@
// retoor <retoor@molodetz.nl>
import { Component } from "./Component.js";
export class AiStatus extends Component {
static get observedAttributes() {
return ["state", "message"];
}
connectedCallback() {
this.classList.add("ai-status");
this.setAttribute("aria-live", "polite");
this._render();
}
attributeChangedCallback() {
this._render();
}
_render() {
const state = this.attr("state", "open");
const message = this.attr("message", state);
this.dataset.state = state;
this.textContent = message;
}
}
if (!customElements.get("ai-status")) {
customElements.define("ai-status", AiStatus);
}
export default AiStatus;
+2 -1
View File
@@ -19,7 +19,8 @@ export class AppTitle extends Component {
this._rendered = true;
this.classList.add("rendered-title");
const source = (this.textContent || "").trim();
this.innerHTML = contentRenderer.renderInline(source);
const authorAdmin = this.hasAttribute("data-author-admin");
this.innerHTML = contentRenderer.renderInline(source, authorAdmin);
}
}
+302 -5
View File
@@ -602,6 +602,10 @@ export default class DeviiTerminalElement extends FloatingWindow {
await this._avatarRequest(message);
} else if (message.type === "client") {
await this._clientRequest(message);
} else if (message.type === "interaction") {
await this._interactionRequest(message);
} else if (message.type === "interaction_closed") {
this._closeOpenInteraction(message.result || message);
} else if (message.type === "auth") {
this._onAuth(message);
}
@@ -675,6 +679,275 @@ export default class DeviiTerminalElement extends FloatingWindow {
this.socket.send({ type: "client_result", id: message.id, result });
}
async _interactionRequest(message) {
const args = message.args || {};
const interactionId = args.interaction_id || message.interaction_id || "";
try {
const result = await this._mountInteraction(args, interactionId);
this.socket.send({
type: "interaction_result",
id: message.id,
interaction_id: interactionId,
result,
});
} catch (error) {
this.socket.send({
type: "interaction_result",
id: message.id,
interaction_id: interactionId,
result: {
status: "error",
interaction_id: interactionId,
values: {},
error: String(error && error.message ? error.message : error),
},
});
}
}
async _mountInteraction(args, interactionId) {
const root = document.createElement("ai-interaction");
root.id = interactionId || `ix-${Date.now()}`;
root.setAttribute("channel", args.channel || "site-chat");
root.setAttribute("state", "open");
root.setAttribute("title", args.title || "Question");
root.setAttribute("submit-label", args.submit_label || "Confirm");
root.setAttribute("cancel-label", args.cancel_label || "Cancel");
if (args.cancelable !== false) root.setAttribute("cancelable", "");
if (args.timeout_sec) root.setAttribute("timeout-sec", String(args.timeout_sec));
if (args.description) {
const help = document.createElement("ai-help");
help.setAttribute("text", args.description);
root.appendChild(help);
}
for (const widget of args.widgets || []) {
const el = this._widgetElement(widget);
if (el) root.appendChild(el);
}
const line = document.createElement("div");
line.className = "devii-line devii-agent devii-interaction-line";
line.appendChild(root);
this._append(line);
const tags = new Set(
Array.from(root.querySelectorAll("*"))
.map((el) => el.localName)
.filter((name) => name && name.startsWith("ai-"))
);
tags.add("ai-interaction");
if (window.app && window.app.aiAutoload) {
window.app.aiAutoload.scan(root);
await Promise.all(
Array.from(tags).map((tag) =>
window.app.aiAutoload.ensure(tag).catch(() => null)
)
);
}
await Promise.all(
Array.from(tags).map((tag) => {
if (customElements.get(tag)) return Promise.resolve();
return Promise.race([
customElements.whenDefined(tag),
new Promise((resolve) => window.setTimeout(resolve, 2500)),
]).catch(() => null);
})
);
const missing = Array.from(tags).filter((tag) => !customElements.get(tag));
if (missing.length) {
console.error("Devii interaction widgets not defined:", missing.join(", "));
}
await new Promise((resolve) => {
window.requestAnimationFrame(() => window.requestAnimationFrame(resolve));
});
this._revealInteraction(root);
return new Promise((resolve) => {
let settled = false;
const finish = (status, values, error = null, meta = null) => {
if (settled) return;
settled = true;
if (this._interactionReveal) {
this._interactionReveal.disconnect();
this._interactionReveal = null;
}
if (this._openInteraction && this._openInteraction.id === interactionId) {
this._openInteraction = null;
}
if (root && typeof root._close === "function") {
root._close(status === "submitted" ? "submitted" : status);
} else if (root) {
root.setAttribute("state", status);
}
resolve({
status,
interaction_id: interactionId,
values: values || {},
error,
meta: meta || { adapter: "site-chat", degraded: false },
});
};
this._openInteraction = {
id: interactionId,
requestId: `ix:${interactionId}`,
root,
finish,
args,
};
root.addEventListener("ai:submit", (event) => {
finish("submitted", (event.detail && event.detail.values) || {});
});
root.addEventListener("ai:cancel", () => {
finish("cancelled", {});
});
root.addEventListener("ai:timeout", () => {
finish("timeout", {});
});
});
}
_closeOpenInteraction(result) {
const open = this._openInteraction;
if (!open || typeof open.finish !== "function") return;
const payload = result && typeof result === "object" ? result : {};
open.finish(
payload.status || "submitted",
payload.values || {},
payload.error || null,
payload.meta || { adapter: "site-chat", via: "text", degraded: true }
);
}
_revealInteraction(root) {
if (!root || !this.output) return;
const scrollToEnd = () => {
const actions = root.querySelector("ai-actions");
const target = actions || root;
try {
target.scrollIntoView({ block: "end", inline: "nearest", behavior: "instant" });
} catch (error) {
this.output.scrollTop = this.output.scrollHeight;
}
const rect = root.getBoundingClientRect();
const box = this.output.getBoundingClientRect();
if (rect.bottom > box.bottom - 8) {
this.output.scrollTop += rect.bottom - box.bottom + 16;
}
if (rect.top < box.top + 8 && root.offsetHeight <= this.output.clientHeight - 24) {
this.output.scrollTop -= box.top + 8 - rect.top;
}
};
scrollToEnd();
if (typeof ResizeObserver === "undefined") return;
if (this._interactionReveal) this._interactionReveal.disconnect();
let frames = 0;
this._interactionReveal = new ResizeObserver(() => {
frames += 1;
scrollToEnd();
if (frames >= 6 && this._interactionReveal) {
this._interactionReveal.disconnect();
this._interactionReveal = null;
}
});
this._interactionReveal.observe(root);
}
_widgetElement(widget) {
if (!widget || typeof widget !== "object") return null;
const type = widget.type;
if (type === "//") {
const help = document.createElement("ai-help");
help.setAttribute("text", widget.text || widget.label || "");
return help;
}
if (type === "group") {
const group = document.createElement("ai-group");
group.setAttribute("label", widget.label || "Group");
for (const child of widget.widgets || []) {
const el = this._widgetElement(child);
if (el) group.appendChild(el);
}
return group;
}
if (type === "confirm") {
const el = document.createElement("ai-confirm");
el.setAttribute("name", widget.name || "confirm");
el.setAttribute("label", widget.label || "Confirm");
if (widget.help) el.setAttribute("help", widget.help);
if (widget.default === true || widget.default === false) {
el.setAttribute("default", String(widget.default));
}
if (widget.required !== false) el.setAttribute("required", "");
return el;
}
if (type === "choice" || type === "select") {
const el = document.createElement(type === "select" ? "ai-select" : "ai-choice");
el.setAttribute("name", widget.name || "choice");
el.setAttribute("label", widget.label || "Choice");
if (widget.help) el.setAttribute("help", widget.help);
if (widget.display) el.setAttribute("display", widget.display);
if (widget.default != null) el.setAttribute("value", String(widget.default));
if (widget.required !== false) el.setAttribute("required", "");
for (const option of widget.options || []) {
const opt = document.createElement("ai-option");
opt.setAttribute("value", option.value || "");
opt.setAttribute("label", option.label || option.value || "");
if (option.description) opt.setAttribute("description", option.description);
if (option.disabled) opt.setAttribute("disabled", "");
if (widget.default != null && String(widget.default) === String(option.value)) {
opt.setAttribute("selected", "");
}
el.appendChild(opt);
}
return el;
}
if (type === "choice_multi") {
const el = document.createElement("ai-choice-multi");
el.setAttribute("name", widget.name || "choices");
el.setAttribute("label", widget.label || "Choices");
if (widget.help) el.setAttribute("help", widget.help);
if (widget.required !== false) el.setAttribute("required", "");
const defaults = Array.isArray(widget.default) ? widget.default.map(String) : [];
for (const option of widget.options || []) {
const opt = document.createElement("ai-option");
opt.setAttribute("value", option.value || "");
opt.setAttribute("label", option.label || option.value || "");
if (option.description) opt.setAttribute("description", option.description);
if (defaults.includes(String(option.value))) opt.setAttribute("selected", "");
el.appendChild(opt);
}
return el;
}
if (type === "text" || type === "number" || type === "date") {
const el = document.createElement("ai-field");
el.setAttribute("name", widget.name || "field");
el.setAttribute("type", type);
el.setAttribute("label", widget.label || widget.name || "Field");
if (type === "date") {
el.setAttribute("help", widget.help || "Format: DD/MM/YYYY");
el.setAttribute("placeholder", widget.placeholder || "DD/MM/YYYY");
} else if (widget.help) {
el.setAttribute("help", widget.help);
}
if (widget.placeholder && type !== "date") {
el.setAttribute("placeholder", widget.placeholder);
}
if (widget.max_length != null) el.setAttribute("maxlength", String(widget.max_length));
if (widget.min != null) el.setAttribute("min", String(widget.min));
if (widget.max != null) el.setAttribute("max", String(widget.max));
if (widget.step != null) el.setAttribute("step", String(widget.step));
if (widget.pattern) el.setAttribute("pattern", widget.pattern);
if (widget.default != null) el.setAttribute("value", String(widget.default));
if (widget.required !== false) el.setAttribute("required", "");
return el;
}
return null;
}
async _avatarRequest(message) {
const avatar = this._avatar();
let result = { error: "no avatar attached" };
@@ -800,19 +1073,43 @@ export default class DeviiTerminalElement extends FloatingWindow {
_renderHistory(messages) {
this.output.innerHTML = "";
(messages || []).forEach((message) => {
const content = this._normalizeText(message && message.content);
if (!content) return;
if (message.role === "user") {
this._userLine(message.content);
} else if (message.role === "assistant" && message.content) {
this._agent(message.content);
this._userLine(content);
} else if (message.role === "assistant") {
this._agent(content);
}
});
}
_normalizeText(text) {
let value = String(text == null ? "" : text);
while (value.indexOf("\\\\n") !== -1) {
value = value.split("\\\\n").join("\\n");
}
while (value.indexOf("\\\\t") !== -1) {
value = value.split("\\\\t").join("\\t");
}
const escapedN = (value.match(/\\n/g) || []).length;
const realN = (value.match(/\n/g) || []).length;
if (escapedN > realN) {
value = value.split("\\n").join("\n");
}
const escapedT = (value.match(/\\t/g) || []).length;
const realT = (value.match(/\t/g) || []).length;
if (escapedT > realT) {
value = value.split("\\t").join("\t");
}
return value;
}
_agent(text) {
const source = this._normalizeText(text);
const block = document.createElement("div");
block.className = "devii-line devii-agent";
block.innerHTML = this.markdown.render(text);
this._attachCopy(block, text);
block.innerHTML = this.markdown.render(source);
this._attachCopy(block, source);
this._append(block);
}