Add ez-assistant and kerberos service folders
This commit is contained in:
14
docker-compose/ez-assistant/ui/index.html
Normal file
14
docker-compose/ez-assistant/ui/index.html
Normal file
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Moltbot Control</title>
|
||||
<meta name="color-scheme" content="dark light" />
|
||||
<link rel="icon" href="/favicon.ico" sizes="any" />
|
||||
</head>
|
||||
<body>
|
||||
<moltbot-app></moltbot-app>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
24
docker-compose/ez-assistant/ui/package.json
Normal file
24
docker-compose/ez-assistant/ui/package.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "moltbot-control-ui",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run --config vitest.config.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@noble/ed25519": "3.0.0",
|
||||
"dompurify": "^3.3.1",
|
||||
"lit": "^3.3.2",
|
||||
"marked": "^17.0.1",
|
||||
"vite": "7.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitest/browser-playwright": "4.0.18",
|
||||
"playwright": "^1.58.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "4.0.18"
|
||||
}
|
||||
}
|
||||
BIN
docker-compose/ez-assistant/ui/public/favicon.ico
Normal file
BIN
docker-compose/ez-assistant/ui/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 94 KiB |
2
docker-compose/ez-assistant/ui/src/main.ts
Normal file
2
docker-compose/ez-assistant/ui/src/main.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
import "./styles.css";
|
||||
import "./ui/app.ts";
|
||||
5
docker-compose/ez-assistant/ui/src/styles.css
Normal file
5
docker-compose/ez-assistant/ui/src/styles.css
Normal file
@@ -0,0 +1,5 @@
|
||||
@import "./styles/base.css";
|
||||
@import "./styles/layout.css";
|
||||
@import "./styles/layout.mobile.css";
|
||||
@import "./styles/components.css";
|
||||
@import "./styles/config.css";
|
||||
372
docker-compose/ez-assistant/ui/src/styles/base.css
Normal file
372
docker-compose/ez-assistant/ui/src/styles/base.css
Normal file
@@ -0,0 +1,372 @@
|
||||
@import url("https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap");
|
||||
|
||||
:root {
|
||||
/* Background - Warmer dark with depth */
|
||||
--bg: #12141a;
|
||||
--bg-accent: #14161d;
|
||||
--bg-elevated: #1a1d25;
|
||||
--bg-hover: #262a35;
|
||||
--bg-muted: #262a35;
|
||||
|
||||
/* Card / Surface - More contrast between levels */
|
||||
--card: #181b22;
|
||||
--card-foreground: #f4f4f5;
|
||||
--card-highlight: rgba(255, 255, 255, 0.05);
|
||||
--popover: #181b22;
|
||||
--popover-foreground: #f4f4f5;
|
||||
|
||||
/* Panel */
|
||||
--panel: #12141a;
|
||||
--panel-strong: #1a1d25;
|
||||
--panel-hover: #262a35;
|
||||
--chrome: rgba(18, 20, 26, 0.95);
|
||||
--chrome-strong: rgba(18, 20, 26, 0.98);
|
||||
|
||||
/* Text - Slightly warmer */
|
||||
--text: #e4e4e7;
|
||||
--text-strong: #fafafa;
|
||||
--chat-text: #e4e4e7;
|
||||
--muted: #71717a;
|
||||
--muted-strong: #52525b;
|
||||
--muted-foreground: #71717a;
|
||||
|
||||
/* Border - Subtle but defined */
|
||||
--border: #27272a;
|
||||
--border-strong: #3f3f46;
|
||||
--border-hover: #52525b;
|
||||
--input: #27272a;
|
||||
--ring: #ff5c5c;
|
||||
|
||||
/* Accent - Punchy signature red */
|
||||
--accent: #ff5c5c;
|
||||
--accent-hover: #ff7070;
|
||||
--accent-muted: #ff5c5c;
|
||||
--accent-subtle: rgba(255, 92, 92, 0.15);
|
||||
--accent-foreground: #fafafa;
|
||||
--accent-glow: rgba(255, 92, 92, 0.25);
|
||||
--primary: #ff5c5c;
|
||||
--primary-foreground: #ffffff;
|
||||
|
||||
/* Secondary - Teal accent for variety */
|
||||
--secondary: #1e2028;
|
||||
--secondary-foreground: #f4f4f5;
|
||||
--accent-2: #14b8a6;
|
||||
--accent-2-muted: rgba(20, 184, 166, 0.7);
|
||||
--accent-2-subtle: rgba(20, 184, 166, 0.15);
|
||||
|
||||
/* Semantic - More saturated */
|
||||
--ok: #22c55e;
|
||||
--ok-muted: rgba(34, 197, 94, 0.75);
|
||||
--ok-subtle: rgba(34, 197, 94, 0.12);
|
||||
--destructive: #ef4444;
|
||||
--destructive-foreground: #fafafa;
|
||||
--warn: #f59e0b;
|
||||
--warn-muted: rgba(245, 158, 11, 0.75);
|
||||
--warn-subtle: rgba(245, 158, 11, 0.12);
|
||||
--danger: #ef4444;
|
||||
--danger-muted: rgba(239, 68, 68, 0.75);
|
||||
--danger-subtle: rgba(239, 68, 68, 0.12);
|
||||
--info: #3b82f6;
|
||||
|
||||
/* Focus - With glow */
|
||||
--focus: rgba(255, 92, 92, 0.25);
|
||||
--focus-ring: 0 0 0 2px var(--bg), 0 0 0 4px var(--ring);
|
||||
--focus-glow: 0 0 0 2px var(--bg), 0 0 0 4px var(--ring), 0 0 20px var(--accent-glow);
|
||||
|
||||
/* Grid */
|
||||
--grid-line: rgba(255, 255, 255, 0.04);
|
||||
|
||||
/* Theme transition */
|
||||
--theme-switch-x: 50%;
|
||||
--theme-switch-y: 50%;
|
||||
|
||||
/* Typography - Space Grotesk for personality */
|
||||
--mono: "JetBrains Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, Consolas, monospace;
|
||||
--font-body: "Space Grotesk", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
--font-display: "Space Grotesk", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
|
||||
/* Shadows - Richer with subtle color */
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.2);
|
||||
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.25), 0 0 0 1px rgba(255, 255, 255, 0.03);
|
||||
--shadow-lg: 0 12px 28px rgba(0, 0, 0, 0.35), 0 0 0 1px rgba(255, 255, 255, 0.03);
|
||||
--shadow-xl: 0 24px 48px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(255, 255, 255, 0.03);
|
||||
--shadow-glow: 0 0 30px var(--accent-glow);
|
||||
|
||||
/* Radii - Slightly larger for friendlier feel */
|
||||
--radius-sm: 6px;
|
||||
--radius-md: 8px;
|
||||
--radius-lg: 12px;
|
||||
--radius-xl: 16px;
|
||||
--radius-full: 9999px;
|
||||
--radius: 8px;
|
||||
|
||||
/* Transitions - Snappy but smooth */
|
||||
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
--ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
--duration-fast: 120ms;
|
||||
--duration-normal: 200ms;
|
||||
--duration-slow: 350ms;
|
||||
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
/* Light theme - Clean with subtle warmth */
|
||||
:root[data-theme="light"] {
|
||||
--bg: #fafafa;
|
||||
--bg-accent: #f5f5f5;
|
||||
--bg-elevated: #ffffff;
|
||||
--bg-hover: #f0f0f0;
|
||||
--bg-muted: #f0f0f0;
|
||||
--bg-content: #f5f5f5;
|
||||
|
||||
--card: #ffffff;
|
||||
--card-foreground: #18181b;
|
||||
--card-highlight: rgba(0, 0, 0, 0.03);
|
||||
--popover: #ffffff;
|
||||
--popover-foreground: #18181b;
|
||||
|
||||
--panel: #fafafa;
|
||||
--panel-strong: #f5f5f5;
|
||||
--panel-hover: #ebebeb;
|
||||
--chrome: rgba(250, 250, 250, 0.95);
|
||||
--chrome-strong: rgba(250, 250, 250, 0.98);
|
||||
|
||||
--text: #3f3f46;
|
||||
--text-strong: #18181b;
|
||||
--chat-text: #3f3f46;
|
||||
--muted: #71717a;
|
||||
--muted-strong: #52525b;
|
||||
--muted-foreground: #71717a;
|
||||
|
||||
--border: #e4e4e7;
|
||||
--border-strong: #d4d4d8;
|
||||
--border-hover: #a1a1aa;
|
||||
--input: #e4e4e7;
|
||||
|
||||
--accent: #dc2626;
|
||||
--accent-hover: #ef4444;
|
||||
--accent-muted: #dc2626;
|
||||
--accent-subtle: rgba(220, 38, 38, 0.12);
|
||||
--accent-foreground: #ffffff;
|
||||
--accent-glow: rgba(220, 38, 38, 0.15);
|
||||
--primary: #dc2626;
|
||||
--primary-foreground: #ffffff;
|
||||
|
||||
--secondary: #f4f4f5;
|
||||
--secondary-foreground: #3f3f46;
|
||||
--accent-2: #0d9488;
|
||||
--accent-2-muted: rgba(13, 148, 136, 0.75);
|
||||
--accent-2-subtle: rgba(13, 148, 136, 0.12);
|
||||
|
||||
--ok: #16a34a;
|
||||
--ok-muted: rgba(22, 163, 74, 0.75);
|
||||
--ok-subtle: rgba(22, 163, 74, 0.1);
|
||||
--destructive: #dc2626;
|
||||
--destructive-foreground: #fafafa;
|
||||
--warn: #d97706;
|
||||
--warn-muted: rgba(217, 119, 6, 0.75);
|
||||
--warn-subtle: rgba(217, 119, 6, 0.1);
|
||||
--danger: #dc2626;
|
||||
--danger-muted: rgba(220, 38, 38, 0.75);
|
||||
--danger-subtle: rgba(220, 38, 38, 0.1);
|
||||
--info: #2563eb;
|
||||
|
||||
--focus: rgba(220, 38, 38, 0.2);
|
||||
--focus-glow: 0 0 0 2px var(--bg), 0 0 0 4px var(--ring), 0 0 16px var(--accent-glow);
|
||||
|
||||
--grid-line: rgba(0, 0, 0, 0.05);
|
||||
|
||||
/* Light shadows */
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.06);
|
||||
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08), 0 0 0 1px rgba(0, 0, 0, 0.04);
|
||||
--shadow-lg: 0 12px 28px rgba(0, 0, 0, 0.12), 0 0 0 1px rgba(0, 0, 0, 0.04);
|
||||
--shadow-xl: 0 24px 48px rgba(0, 0, 0, 0.15), 0 0 0 1px rgba(0, 0, 0, 0.04);
|
||||
--shadow-glow: 0 0 24px var(--accent-glow);
|
||||
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font: 400 14px/1.55 var(--font-body);
|
||||
letter-spacing: -0.02em;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* Theme transition */
|
||||
@keyframes theme-circle-transition {
|
||||
0% {
|
||||
clip-path: circle(0% at var(--theme-switch-x, 50%) var(--theme-switch-y, 50%));
|
||||
}
|
||||
100% {
|
||||
clip-path: circle(150% at var(--theme-switch-x, 50%) var(--theme-switch-y, 50%));
|
||||
}
|
||||
}
|
||||
|
||||
html.theme-transition {
|
||||
view-transition-name: theme;
|
||||
}
|
||||
|
||||
html.theme-transition::view-transition-old(theme) {
|
||||
mix-blend-mode: normal;
|
||||
animation: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
html.theme-transition::view-transition-new(theme) {
|
||||
mix-blend-mode: normal;
|
||||
z-index: 2;
|
||||
animation: theme-circle-transition 0.4s var(--ease-out) forwards;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
html.theme-transition::view-transition-old(theme),
|
||||
html.theme-transition::view-transition-new(theme) {
|
||||
animation: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
moltbot-app {
|
||||
display: block;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: var(--accent-subtle);
|
||||
color: var(--text-strong);
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--border);
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--border-strong);
|
||||
}
|
||||
|
||||
/* Animations - Polished with spring feel */
|
||||
@keyframes rise {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes scale-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes dashboard-enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(12px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse-subtle {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes glow-pulse {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 0 rgba(255, 92, 92, 0);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 20px var(--accent-glow);
|
||||
}
|
||||
}
|
||||
|
||||
/* Stagger animation delays for grouped elements */
|
||||
.stagger-1 { animation-delay: 0ms; }
|
||||
.stagger-2 { animation-delay: 50ms; }
|
||||
.stagger-3 { animation-delay: 100ms; }
|
||||
.stagger-4 { animation-delay: 150ms; }
|
||||
.stagger-5 { animation-delay: 200ms; }
|
||||
.stagger-6 { animation-delay: 250ms; }
|
||||
|
||||
/* Focus visible styles */
|
||||
:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: var(--focus-ring);
|
||||
}
|
||||
5
docker-compose/ez-assistant/ui/src/styles/chat.css
Normal file
5
docker-compose/ez-assistant/ui/src/styles/chat.css
Normal file
@@ -0,0 +1,5 @@
|
||||
@import "./chat/layout.css";
|
||||
@import "./chat/text.css";
|
||||
@import "./chat/grouped.css";
|
||||
@import "./chat/tool-cards.css";
|
||||
@import "./chat/sidebar.css";
|
||||
268
docker-compose/ez-assistant/ui/src/styles/chat/grouped.css
Normal file
268
docker-compose/ez-assistant/ui/src/styles/chat/grouped.css
Normal file
@@ -0,0 +1,268 @@
|
||||
/* =============================================
|
||||
GROUPED CHAT LAYOUT (Slack-style)
|
||||
============================================= */
|
||||
|
||||
/* Chat Group Layout - default (assistant/other on left) */
|
||||
.chat-group {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 16px;
|
||||
margin-left: 4px;
|
||||
margin-right: 16px;
|
||||
}
|
||||
|
||||
/* User messages on right */
|
||||
.chat-group.user {
|
||||
flex-direction: row-reverse;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.chat-group-messages {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
max-width: min(900px, calc(100% - 60px));
|
||||
}
|
||||
|
||||
/* User messages align content right */
|
||||
.chat-group.user .chat-group-messages {
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.chat-group.user .chat-group-footer {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* Footer at bottom of message group (role + time) */
|
||||
.chat-group-footer {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: baseline;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.chat-sender-name {
|
||||
font-weight: 500;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.chat-group-timestamp {
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* Avatar Styles */
|
||||
.chat-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 8px;
|
||||
background: var(--panel-strong);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
flex-shrink: 0;
|
||||
align-self: flex-end; /* Align with last message in group */
|
||||
margin-bottom: 4px; /* Optical alignment */
|
||||
}
|
||||
|
||||
.chat-avatar.user {
|
||||
background: var(--accent-subtle);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.chat-avatar.assistant {
|
||||
background: var(--secondary);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.chat-avatar.other {
|
||||
background: var(--secondary);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.chat-avatar.tool {
|
||||
background: var(--secondary);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* Image avatar support */
|
||||
img.chat-avatar {
|
||||
display: block;
|
||||
object-fit: cover;
|
||||
object-position: center;
|
||||
}
|
||||
|
||||
/* Minimal Bubble Design - dynamic width based on content */
|
||||
.chat-bubble {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
border: 1px solid transparent;
|
||||
background: var(--card);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 10px 14px;
|
||||
box-shadow: none;
|
||||
transition: background 150ms ease-out, border-color 150ms ease-out;
|
||||
max-width: 100%;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.chat-bubble.has-copy {
|
||||
padding-right: 36px;
|
||||
}
|
||||
|
||||
.chat-copy-btn {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 8px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg);
|
||||
color: var(--muted);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 4px 6px;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 120ms ease-out, background 120ms ease-out;
|
||||
}
|
||||
|
||||
.chat-copy-btn__icon {
|
||||
display: inline-flex;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.chat-copy-btn__icon svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
stroke: currentColor;
|
||||
fill: none;
|
||||
stroke-width: 1.5px;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.chat-copy-btn__icon-copy,
|
||||
.chat-copy-btn__icon-check {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
transition: opacity 150ms ease;
|
||||
}
|
||||
|
||||
.chat-copy-btn__icon-check {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.chat-copy-btn[data-copied="1"] .chat-copy-btn__icon-copy {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.chat-copy-btn[data-copied="1"] .chat-copy-btn__icon-check {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.chat-bubble:hover .chat-copy-btn {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.chat-copy-btn:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.chat-copy-btn[data-copying="1"] {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.chat-copy-btn[data-error="1"] {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
border-color: var(--danger-subtle);
|
||||
background: var(--danger-subtle);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.chat-copy-btn[data-copied="1"] {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
border-color: var(--ok-subtle);
|
||||
background: var(--ok-subtle);
|
||||
color: var(--ok);
|
||||
}
|
||||
|
||||
.chat-copy-btn:focus-visible {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
@media (hover: none) {
|
||||
.chat-copy-btn {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* Light mode: restore borders */
|
||||
:root[data-theme="light"] .chat-bubble {
|
||||
border-color: var(--border);
|
||||
box-shadow: inset 0 1px 0 var(--card-highlight);
|
||||
}
|
||||
|
||||
.chat-bubble:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
/* User bubbles have different styling */
|
||||
.chat-group.user .chat-bubble {
|
||||
background: var(--accent-subtle);
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .chat-group.user .chat-bubble {
|
||||
border-color: rgba(234, 88, 12, 0.2);
|
||||
background: rgba(251, 146, 60, 0.12);
|
||||
}
|
||||
|
||||
.chat-group.user .chat-bubble:hover {
|
||||
background: rgba(255, 77, 77, 0.15);
|
||||
}
|
||||
|
||||
/* Streaming animation */
|
||||
.chat-bubble.streaming {
|
||||
animation: pulsing-border 1.5s ease-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulsing-border {
|
||||
0%, 100% {
|
||||
border-color: var(--border);
|
||||
}
|
||||
50% {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
}
|
||||
|
||||
/* Fade-in animation for new messages */
|
||||
.chat-bubble.fade-in {
|
||||
animation: fade-in 200ms ease-out;
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(4px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
397
docker-compose/ez-assistant/ui/src/styles/chat/layout.css
Normal file
397
docker-compose/ez-assistant/ui/src/styles/chat/layout.css
Normal file
@@ -0,0 +1,397 @@
|
||||
/* =============================================
|
||||
CHAT CARD LAYOUT - Flex container with sticky compose
|
||||
============================================= */
|
||||
|
||||
/* Main chat card - flex column layout, transparent background */
|
||||
.chat {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1 1 0;
|
||||
height: 100%;
|
||||
min-height: 0; /* Allow flex shrinking */
|
||||
overflow: hidden;
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
/* Chat header - fixed at top, transparent */
|
||||
.chat-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: nowrap;
|
||||
flex-shrink: 0;
|
||||
padding-bottom: 12px;
|
||||
margin-bottom: 12px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.chat-header__left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.chat-header__right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.chat-session {
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
/* Chat thread - scrollable middle section, transparent */
|
||||
.chat-thread {
|
||||
flex: 1 1 0; /* Grow, shrink, and use 0 base for proper scrolling */
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 12px 4px;
|
||||
margin: 0 -4px;
|
||||
min-height: 0; /* Allow shrinking for flex scroll behavior */
|
||||
border-radius: 12px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* Focus mode exit button */
|
||||
.chat-focus-exit {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
z-index: 100;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--panel);
|
||||
color: var(--muted);
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 150ms ease-out, color 150ms ease-out, border-color 150ms ease-out;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.chat-focus-exit:hover {
|
||||
background: var(--panel-strong);
|
||||
color: var(--text);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.chat-focus-exit svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
stroke: currentColor;
|
||||
fill: none;
|
||||
stroke-width: 2px;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
/* Chat compose - sticky at bottom */
|
||||
.chat-compose {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin-top: auto; /* Push to bottom of flex container */
|
||||
padding: 12px 4px 4px;
|
||||
background: linear-gradient(to bottom, transparent, var(--bg) 20%);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
/* Image attachments preview */
|
||||
.chat-attachments {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
background: var(--panel);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
align-self: flex-start; /* Don't stretch in flex column parent */
|
||||
}
|
||||
|
||||
.chat-attachment {
|
||||
position: relative;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.chat-attachment__img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.chat-attachment__remove {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
transition: opacity 150ms ease-out;
|
||||
}
|
||||
|
||||
.chat-attachment:hover .chat-attachment__remove {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.chat-attachment__remove:hover {
|
||||
background: rgba(220, 38, 38, 0.9);
|
||||
}
|
||||
|
||||
.chat-attachment__remove svg {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
stroke: currentColor;
|
||||
fill: none;
|
||||
stroke-width: 2px;
|
||||
}
|
||||
|
||||
/* Light theme attachment overrides */
|
||||
:root[data-theme="light"] .chat-attachments {
|
||||
background: #f8fafc;
|
||||
border-color: rgba(16, 24, 40, 0.1);
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .chat-attachment {
|
||||
border-color: rgba(16, 24, 40, 0.15);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .chat-attachment__remove {
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
/* Message images (sent images displayed in chat) */
|
||||
.chat-message-images {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.chat-message-image {
|
||||
max-width: 300px;
|
||||
max-height: 200px;
|
||||
border-radius: 8px;
|
||||
object-fit: contain;
|
||||
cursor: pointer;
|
||||
transition: transform 150ms ease-out;
|
||||
}
|
||||
|
||||
.chat-message-image:hover {
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
/* User message images align right */
|
||||
.chat-group.user .chat-message-images {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* Compose input row - horizontal layout */
|
||||
.chat-compose__row {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .chat-compose {
|
||||
background: linear-gradient(to bottom, transparent, var(--bg-content) 20%);
|
||||
}
|
||||
|
||||
.chat-compose__field {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
/* Hide the "Message" label - keep textarea only */
|
||||
.chat-compose__field > span {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Override .field textarea min-height (180px) from components.css */
|
||||
.chat-compose .chat-compose__field textarea {
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
min-height: 40px;
|
||||
max-height: 150px;
|
||||
padding: 9px 12px;
|
||||
border-radius: 8px;
|
||||
overflow-y: auto;
|
||||
resize: none;
|
||||
white-space: pre-wrap;
|
||||
font-family: var(--font-body);
|
||||
font-size: 14px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.chat-compose__field textarea:disabled {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.chat-compose__actions {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.chat-compose .chat-compose__actions .btn {
|
||||
padding: 0 16px;
|
||||
font-size: 13px;
|
||||
height: 40px;
|
||||
min-height: 40px;
|
||||
max-height: 40px;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Chat controls - moved to content-header area, left aligned */
|
||||
.chat-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.chat-controls__session {
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.chat-controls__thinking {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* Icon button style */
|
||||
.btn--icon {
|
||||
padding: 8px !important;
|
||||
min-width: 36px;
|
||||
height: 36px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid var(--border);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
/* Controls separator */
|
||||
.chat-controls__separator {
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
font-size: 18px;
|
||||
margin: 0 8px;
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .chat-controls__separator {
|
||||
color: rgba(16, 24, 40, 0.3);
|
||||
}
|
||||
|
||||
.btn--icon:hover {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
/* Light theme icon button overrides */
|
||||
:root[data-theme="light"] .btn--icon {
|
||||
background: #ffffff;
|
||||
border-color: var(--border);
|
||||
box-shadow: 0 1px 2px rgba(16, 24, 40, 0.05);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .btn--icon:hover {
|
||||
background: #ffffff;
|
||||
border-color: var(--border-strong);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.btn--icon svg {
|
||||
display: block;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
stroke: currentColor;
|
||||
fill: none;
|
||||
stroke-width: 1.5px;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.chat-controls__session select {
|
||||
padding: 6px 10px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.chat-controls__thinking {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
padding: 4px 10px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* Light theme thinking indicator override */
|
||||
:root[data-theme="light"] .chat-controls__thinking {
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
border-color: rgba(16, 24, 40, 0.15);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.chat-session {
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.chat-compose {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.chat-controls {
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.chat-controls__session {
|
||||
min-width: 120px;
|
||||
}
|
||||
}
|
||||
117
docker-compose/ez-assistant/ui/src/styles/chat/sidebar.css
Normal file
117
docker-compose/ez-assistant/ui/src/styles/chat/sidebar.css
Normal file
@@ -0,0 +1,117 @@
|
||||
/* Split View Layout */
|
||||
.chat-split-container {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.chat-main {
|
||||
min-width: 400px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
/* Smooth transition when sidebar opens/closes */
|
||||
transition: flex 250ms ease-out;
|
||||
}
|
||||
|
||||
.chat-sidebar {
|
||||
flex: 1;
|
||||
min-width: 300px;
|
||||
border-left: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
animation: slide-in 200ms ease-out;
|
||||
}
|
||||
|
||||
@keyframes slide-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Sidebar Panel */
|
||||
.sidebar-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: var(--panel);
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
background: var(--panel);
|
||||
}
|
||||
|
||||
/* Smaller close button for sidebar */
|
||||
.sidebar-header .btn {
|
||||
padding: 4px 8px;
|
||||
font-size: 14px;
|
||||
min-width: auto;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.sidebar-title {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.sidebar-content {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.sidebar-markdown {
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.sidebar-markdown pre {
|
||||
background: rgba(0, 0, 0, 0.12);
|
||||
border-radius: 4px;
|
||||
padding: 12px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.sidebar-markdown code {
|
||||
font-family: var(--mono);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* Mobile: Full-screen modal */
|
||||
@media (max-width: 768px) {
|
||||
.chat-split-container--open {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.chat-split-container--open .chat-main {
|
||||
display: none; /* Hide chat on mobile when sidebar open */
|
||||
}
|
||||
|
||||
.chat-split-container--open .chat-sidebar {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
border-left: none;
|
||||
}
|
||||
}
|
||||
124
docker-compose/ez-assistant/ui/src/styles/chat/text.css
Normal file
124
docker-compose/ez-assistant/ui/src/styles/chat/text.css
Normal file
@@ -0,0 +1,124 @@
|
||||
/* =============================================
|
||||
CHAT TEXT STYLING
|
||||
============================================= */
|
||||
|
||||
.chat-thinking {
|
||||
margin-bottom: 10px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
border: 1px dashed rgba(255, 255, 255, 0.18);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .chat-thinking {
|
||||
border-color: rgba(16, 24, 40, 0.25);
|
||||
background: rgba(16, 24, 40, 0.04);
|
||||
}
|
||||
|
||||
.chat-text {
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.chat-text :where(p, ul, ol, pre, blockquote, table) {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.chat-text :where(p + p, p + ul, p + ol, p + pre, p + blockquote) {
|
||||
margin-top: 0.75em;
|
||||
}
|
||||
|
||||
.chat-text :where(ul, ol) {
|
||||
padding-left: 1.5em;
|
||||
}
|
||||
|
||||
.chat-text :where(li + li) {
|
||||
margin-top: 0.25em;
|
||||
}
|
||||
|
||||
.chat-text :where(a) {
|
||||
color: var(--accent);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.chat-text :where(a:hover) {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.chat-text :where(code) {
|
||||
font-family: var(--mono);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.chat-text :where(:not(pre) > code) {
|
||||
background: rgba(0, 0, 0, 0.15);
|
||||
padding: 0.15em 0.4em;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.chat-text :where(pre) {
|
||||
background: rgba(0, 0, 0, 0.15);
|
||||
border-radius: 6px;
|
||||
padding: 10px 12px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.chat-text :where(pre code) {
|
||||
background: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.chat-text :where(blockquote) {
|
||||
border-left: 3px solid var(--border-strong);
|
||||
padding-left: 12px;
|
||||
margin-left: 0;
|
||||
color: var(--muted);
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
padding: 8px 12px;
|
||||
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
|
||||
}
|
||||
|
||||
.chat-text :where(blockquote blockquote) {
|
||||
margin-top: 8px;
|
||||
border-left-color: var(--border-hover);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.chat-text :where(blockquote blockquote blockquote) {
|
||||
border-left-color: var(--muted-strong);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .chat-text :where(blockquote) {
|
||||
background: rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .chat-text :where(blockquote blockquote) {
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .chat-text :where(blockquote blockquote blockquote) {
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .chat-text :where(:not(pre) > code) {
|
||||
background: rgba(0, 0, 0, 0.08);
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .chat-text :where(pre) {
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.chat-text :where(hr) {
|
||||
border: none;
|
||||
border-top: 1px solid var(--border);
|
||||
margin: 1em 0;
|
||||
}
|
||||
198
docker-compose/ez-assistant/ui/src/styles/chat/tool-cards.css
Normal file
198
docker-compose/ez-assistant/ui/src/styles/chat/tool-cards.css
Normal file
@@ -0,0 +1,198 @@
|
||||
/* Tool Card Styles */
|
||||
.chat-tool-card {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
margin-top: 8px;
|
||||
background: var(--card);
|
||||
box-shadow: inset 0 1px 0 var(--card-highlight);
|
||||
transition: border-color 150ms ease-out, background 150ms ease-out;
|
||||
/* Fixed max-height to ensure cards don't expand too much */
|
||||
max-height: 120px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chat-tool-card:hover {
|
||||
border-color: var(--border-strong);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
/* First tool card in a group - no top margin */
|
||||
.chat-tool-card:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.chat-tool-card--clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.chat-tool-card--clickable:focus {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Header with title and chevron */
|
||||
.chat-tool-card__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.chat-tool-card__title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.chat-tool-card__icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chat-tool-card__icon svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
stroke: currentColor;
|
||||
fill: none;
|
||||
stroke-width: 1.5px;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
/* "View >" action link */
|
||||
.chat-tool-card__action {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--accent);
|
||||
opacity: 0.8;
|
||||
transition: opacity 150ms ease-out;
|
||||
}
|
||||
|
||||
.chat-tool-card__action svg {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
stroke: currentColor;
|
||||
fill: none;
|
||||
stroke-width: 1.5px;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.chat-tool-card--clickable:hover .chat-tool-card__action {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Status indicator for completed/empty results */
|
||||
.chat-tool-card__status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: var(--ok);
|
||||
}
|
||||
|
||||
.chat-tool-card__status svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
stroke: currentColor;
|
||||
fill: none;
|
||||
stroke-width: 2px;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.chat-tool-card__status-text {
|
||||
font-size: 11px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.chat-tool-card__detail {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* Collapsed preview - fixed height with truncation */
|
||||
.chat-tool-card__preview {
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
margin-top: 8px;
|
||||
padding: 8px 10px;
|
||||
background: var(--secondary);
|
||||
border-radius: var(--radius-md);
|
||||
white-space: pre-wrap;
|
||||
overflow: hidden;
|
||||
max-height: 44px;
|
||||
line-height: 1.4;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.chat-tool-card--clickable:hover .chat-tool-card__preview {
|
||||
background: var(--bg-hover);
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
|
||||
/* Short inline output */
|
||||
.chat-tool-card__inline {
|
||||
font-size: 11px;
|
||||
color: var(--text);
|
||||
margin-top: 6px;
|
||||
padding: 6px 8px;
|
||||
background: var(--secondary);
|
||||
border-radius: var(--radius-sm);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* Reading Indicator */
|
||||
.chat-reading-indicator {
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
padding: 12px;
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.chat-reading-indicator__dots {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.chat-reading-indicator__dots span {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--muted);
|
||||
animation: reading-pulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.chat-reading-indicator__dots span:nth-child(1) {
|
||||
animation-delay: 0s;
|
||||
}
|
||||
|
||||
.chat-reading-indicator__dots span:nth-child(2) {
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
|
||||
.chat-reading-indicator__dots span:nth-child(3) {
|
||||
animation-delay: 0.4s;
|
||||
}
|
||||
|
||||
@keyframes reading-pulse {
|
||||
0%, 60%, 100% {
|
||||
opacity: 0.3;
|
||||
transform: scale(0.8);
|
||||
}
|
||||
30% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
1486
docker-compose/ez-assistant/ui/src/styles/components.css
Normal file
1486
docker-compose/ez-assistant/ui/src/styles/components.css
Normal file
File diff suppressed because it is too large
Load Diff
1447
docker-compose/ez-assistant/ui/src/styles/config.css
Normal file
1447
docker-compose/ez-assistant/ui/src/styles/config.css
Normal file
File diff suppressed because it is too large
Load Diff
609
docker-compose/ez-assistant/ui/src/styles/layout.css
Normal file
609
docker-compose/ez-assistant/ui/src/styles/layout.css
Normal file
@@ -0,0 +1,609 @@
|
||||
/* ===========================================
|
||||
Shell Layout
|
||||
=========================================== */
|
||||
|
||||
.shell {
|
||||
--shell-pad: 16px;
|
||||
--shell-gap: 16px;
|
||||
--shell-nav-width: 220px;
|
||||
--shell-topbar-height: 56px;
|
||||
--shell-focus-duration: 200ms;
|
||||
--shell-focus-ease: var(--ease-out);
|
||||
height: 100vh;
|
||||
display: grid;
|
||||
grid-template-columns: var(--shell-nav-width) minmax(0, 1fr);
|
||||
grid-template-rows: var(--shell-topbar-height) 1fr;
|
||||
grid-template-areas:
|
||||
"topbar topbar"
|
||||
"nav content";
|
||||
gap: 0;
|
||||
animation: dashboard-enter 0.4s var(--ease-out);
|
||||
transition: grid-template-columns var(--shell-focus-duration) var(--shell-focus-ease);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@supports (height: 100dvh) {
|
||||
.shell {
|
||||
height: 100dvh;
|
||||
}
|
||||
}
|
||||
|
||||
.shell--chat {
|
||||
min-height: 100vh;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@supports (height: 100dvh) {
|
||||
.shell--chat {
|
||||
height: 100dvh;
|
||||
}
|
||||
}
|
||||
|
||||
.shell--nav-collapsed {
|
||||
grid-template-columns: 0px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.shell--chat-focus {
|
||||
grid-template-columns: 0px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.shell--onboarding {
|
||||
grid-template-rows: 0 1fr;
|
||||
}
|
||||
|
||||
.shell--onboarding .topbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.shell--onboarding .content {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.shell--chat-focus .content {
|
||||
padding-top: 0;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
/* ===========================================
|
||||
Topbar
|
||||
=========================================== */
|
||||
|
||||
.topbar {
|
||||
grid-area: topbar;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 40;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 0 20px;
|
||||
height: var(--shell-topbar-height);
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.topbar-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.topbar .nav-collapse-toggle {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.topbar .nav-collapse-toggle__icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.topbar .nav-collapse-toggle__icon svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
/* Brand */
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.brand-logo img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.brand-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.brand-title {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.03em;
|
||||
line-height: 1.1;
|
||||
color: var(--text-strong);
|
||||
}
|
||||
|
||||
.brand-sub {
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
color: var(--muted);
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* Topbar status */
|
||||
.topbar-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.topbar-status .pill {
|
||||
padding: 6px 10px;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
height: 32px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.topbar-status .pill .mono {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
line-height: 1;
|
||||
margin-top: 0px;
|
||||
}
|
||||
|
||||
.topbar-status .statusDot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.topbar-status .theme-toggle {
|
||||
--theme-item: 24px;
|
||||
--theme-gap: 2px;
|
||||
--theme-pad: 3px;
|
||||
}
|
||||
|
||||
.topbar-status .theme-icon {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
/* ===========================================
|
||||
Navigation Sidebar
|
||||
=========================================== */
|
||||
|
||||
.nav {
|
||||
grid-area: nav;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 16px 12px;
|
||||
background: var(--bg);
|
||||
scrollbar-width: none; /* Firefox */
|
||||
transition:
|
||||
width var(--shell-focus-duration) var(--shell-focus-ease),
|
||||
padding var(--shell-focus-duration) var(--shell-focus-ease),
|
||||
opacity var(--shell-focus-duration) var(--shell-focus-ease);
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.nav::-webkit-scrollbar {
|
||||
display: none; /* Chrome/Safari */
|
||||
}
|
||||
|
||||
.shell--chat-focus .nav {
|
||||
width: 0;
|
||||
padding: 0;
|
||||
border-width: 0;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.nav--collapsed {
|
||||
width: 0;
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
border: none;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Nav collapse toggle */
|
||||
.nav-collapse-toggle {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background var(--duration-fast) ease,
|
||||
border-color var(--duration-fast) ease;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.nav-collapse-toggle:hover {
|
||||
background: var(--bg-hover);
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
.nav-collapse-toggle__icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: var(--muted);
|
||||
transition: color var(--duration-fast) ease;
|
||||
}
|
||||
|
||||
.nav-collapse-toggle__icon svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
stroke: currentColor;
|
||||
fill: none;
|
||||
stroke-width: 1.5px;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.nav-collapse-toggle:hover .nav-collapse-toggle__icon {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* Nav groups */
|
||||
.nav-group {
|
||||
margin-bottom: 20px;
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.nav-group:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.nav-group__items {
|
||||
display: grid;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.nav-group--collapsed .nav-group__items {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Nav label */
|
||||
.nav-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 6px 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--muted);
|
||||
margin-bottom: 4px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
border-radius: var(--radius-sm);
|
||||
transition:
|
||||
color var(--duration-fast) ease,
|
||||
background var(--duration-fast) ease;
|
||||
}
|
||||
|
||||
.nav-label:hover {
|
||||
color: var(--text);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.nav-label--static {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.nav-label--static:hover {
|
||||
color: var(--muted);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.nav-label__text {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.nav-label__chevron {
|
||||
font-size: 10px;
|
||||
opacity: 0.5;
|
||||
transition: transform var(--duration-fast) ease;
|
||||
}
|
||||
|
||||
.nav-group--collapsed .nav-label__chevron {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
/* Nav items */
|
||||
.nav-item {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 10px;
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
transition:
|
||||
border-color var(--duration-fast) ease,
|
||||
background var(--duration-fast) ease,
|
||||
color var(--duration-fast) ease;
|
||||
}
|
||||
|
||||
.nav-item__icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
opacity: 0.7;
|
||||
transition: opacity var(--duration-fast) ease;
|
||||
}
|
||||
|
||||
.nav-item__icon svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
stroke: currentColor;
|
||||
fill: none;
|
||||
stroke-width: 1.5px;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.nav-item__text {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
color: var(--text);
|
||||
background: var(--bg-hover);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.nav-item:hover .nav-item__icon {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
color: var(--text-strong);
|
||||
background: var(--accent-subtle);
|
||||
}
|
||||
|
||||
.nav-item.active .nav-item__icon {
|
||||
opacity: 1;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* ===========================================
|
||||
Content Area
|
||||
=========================================== */
|
||||
|
||||
.content {
|
||||
grid-area: content;
|
||||
padding: 12px 16px 32px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .content {
|
||||
background: var(--bg-content);
|
||||
}
|
||||
|
||||
.content--chat {
|
||||
overflow: hidden;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
/* Content header */
|
||||
.content-header {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 4px 8px;
|
||||
overflow: hidden;
|
||||
transform-origin: top center;
|
||||
transition:
|
||||
opacity var(--shell-focus-duration) var(--shell-focus-ease),
|
||||
transform var(--shell-focus-duration) var(--shell-focus-ease),
|
||||
max-height var(--shell-focus-duration) var(--shell-focus-ease),
|
||||
padding var(--shell-focus-duration) var(--shell-focus-ease);
|
||||
max-height: 80px;
|
||||
}
|
||||
|
||||
.shell--chat-focus .content-header {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
max-height: 0px;
|
||||
padding: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.035em;
|
||||
line-height: 1.15;
|
||||
color: var(--text-strong);
|
||||
}
|
||||
|
||||
.page-sub {
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
margin-top: 6px;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.page-meta {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* Chat view header adjustments */
|
||||
.content--chat .content-header {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.content--chat .content-header > div:first-child {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.content--chat .page-meta {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.content--chat .chat-controls {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ===========================================
|
||||
Grid Utilities
|
||||
=========================================== */
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.grid-cols-2 {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.grid-cols-3 {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.stat-grid {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
}
|
||||
|
||||
.note-grid {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.stack {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* ===========================================
|
||||
Responsive - Tablet
|
||||
=========================================== */
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.shell {
|
||||
--shell-pad: 12px;
|
||||
--shell-gap: 12px;
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: auto auto 1fr;
|
||||
grid-template-areas:
|
||||
"topbar"
|
||||
"nav"
|
||||
"content";
|
||||
}
|
||||
|
||||
.nav {
|
||||
position: static;
|
||||
max-height: none;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
overflow-x: auto;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 10px 14px;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.nav-group {
|
||||
grid-auto-flow: column;
|
||||
grid-template-columns: repeat(auto-fit, minmax(100px, 1fr));
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.grid-cols-2,
|
||||
.grid-cols-3 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
position: static;
|
||||
padding: 12px 14px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.topbar-status {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.table-head,
|
||||
.table-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.list-item {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
374
docker-compose/ez-assistant/ui/src/styles/layout.mobile.css
Normal file
374
docker-compose/ez-assistant/ui/src/styles/layout.mobile.css
Normal file
@@ -0,0 +1,374 @@
|
||||
/* ===========================================
|
||||
Mobile Layout
|
||||
=========================================== */
|
||||
|
||||
/* Tablet: Horizontal nav */
|
||||
@media (max-width: 1100px) {
|
||||
.nav {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: nowrap;
|
||||
gap: 4px;
|
||||
padding: 10px 14px;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.nav::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.nav-group {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.nav-group__items {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.nav-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.nav-group--collapsed .nav-group__items {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
padding: 8px 14px;
|
||||
font-size: 13px;
|
||||
border-radius: var(--radius-md);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile-specific styles */
|
||||
@media (max-width: 600px) {
|
||||
.shell {
|
||||
--shell-pad: 8px;
|
||||
--shell-gap: 8px;
|
||||
}
|
||||
|
||||
/* Topbar */
|
||||
.topbar {
|
||||
padding: 10px 12px;
|
||||
gap: 8px;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.brand {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.brand-title {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.brand-sub {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.topbar-status {
|
||||
gap: 6px;
|
||||
width: auto;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.topbar-status .pill {
|
||||
padding: 4px 8px;
|
||||
font-size: 11px;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.topbar-status .pill .mono {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.topbar-status .pill span:nth-child(2) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Nav */
|
||||
.nav {
|
||||
padding: 8px 10px;
|
||||
gap: 4px;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.nav::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.nav-group {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.nav-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
padding: 6px 10px;
|
||||
font-size: 12px;
|
||||
border-radius: var(--radius-md);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Content */
|
||||
.content-header {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 4px 4px 16px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* Cards */
|
||||
.card {
|
||||
padding: 12px;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* Stats */
|
||||
.stat-grid {
|
||||
gap: 8px;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.stat {
|
||||
padding: 10px;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
/* Notes */
|
||||
.note-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* Forms */
|
||||
.form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.field input,
|
||||
.field textarea,
|
||||
.field select {
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
padding: 8px 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Pills */
|
||||
.pill {
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Chat */
|
||||
.chat-header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.chat-header__left {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.chat-header__right {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.chat-session {
|
||||
min-width: unset;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.chat-thread {
|
||||
margin-top: 8px;
|
||||
padding: 12px 8px;
|
||||
}
|
||||
|
||||
.chat-msg {
|
||||
max-width: 90%;
|
||||
}
|
||||
|
||||
.chat-bubble {
|
||||
padding: 8px 12px;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.chat-compose {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.chat-compose__field textarea {
|
||||
min-height: 60px;
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Log stream */
|
||||
.log-stream {
|
||||
border-radius: var(--radius-md);
|
||||
max-height: 380px;
|
||||
}
|
||||
|
||||
.log-row {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 4px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.log-time {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.log-level {
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.log-subsystem {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.log-message {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Lists */
|
||||
.list-item {
|
||||
padding: 10px;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.list-title {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.list-sub {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* Code blocks */
|
||||
.code-block {
|
||||
padding: 8px;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* Theme toggle */
|
||||
.theme-toggle {
|
||||
--theme-item: 24px;
|
||||
--theme-gap: 2px;
|
||||
--theme-pad: 3px;
|
||||
}
|
||||
|
||||
.theme-icon {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Small mobile */
|
||||
@media (max-width: 400px) {
|
||||
.shell {
|
||||
--shell-pad: 4px;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.brand-title {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.nav {
|
||||
padding: 6px 8px;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
padding: 6px 8px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 4px 4px 12px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.stat {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.chat-bubble {
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.chat-compose__field textarea {
|
||||
min-height: 52px;
|
||||
padding: 8px 10px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 6px 10px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.topbar-status .pill {
|
||||
padding: 3px 6px;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.theme-toggle {
|
||||
--theme-item: 22px;
|
||||
--theme-gap: 2px;
|
||||
--theme-pad: 2px;
|
||||
}
|
||||
|
||||
.theme-icon {
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 34 KiB |
233
docker-compose/ez-assistant/ui/src/ui/app-channels.ts
Normal file
233
docker-compose/ez-assistant/ui/src/ui/app-channels.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
import {
|
||||
loadChannels,
|
||||
logoutWhatsApp,
|
||||
startWhatsAppLogin,
|
||||
waitWhatsAppLogin,
|
||||
} from "./controllers/channels";
|
||||
import { loadConfig, saveConfig } from "./controllers/config";
|
||||
import type { MoltbotApp } from "./app";
|
||||
import type { NostrProfile } from "./types";
|
||||
import { createNostrProfileFormState } from "./views/channels.nostr-profile-form";
|
||||
|
||||
export async function handleWhatsAppStart(host: MoltbotApp, force: boolean) {
|
||||
await startWhatsAppLogin(host, force);
|
||||
await loadChannels(host, true);
|
||||
}
|
||||
|
||||
export async function handleWhatsAppWait(host: MoltbotApp) {
|
||||
await waitWhatsAppLogin(host);
|
||||
await loadChannels(host, true);
|
||||
}
|
||||
|
||||
export async function handleWhatsAppLogout(host: MoltbotApp) {
|
||||
await logoutWhatsApp(host);
|
||||
await loadChannels(host, true);
|
||||
}
|
||||
|
||||
export async function handleChannelConfigSave(host: MoltbotApp) {
|
||||
await saveConfig(host);
|
||||
await loadConfig(host);
|
||||
await loadChannels(host, true);
|
||||
}
|
||||
|
||||
export async function handleChannelConfigReload(host: MoltbotApp) {
|
||||
await loadConfig(host);
|
||||
await loadChannels(host, true);
|
||||
}
|
||||
|
||||
function parseValidationErrors(details: unknown): Record<string, string> {
|
||||
if (!Array.isArray(details)) return {};
|
||||
const errors: Record<string, string> = {};
|
||||
for (const entry of details) {
|
||||
if (typeof entry !== "string") continue;
|
||||
const [rawField, ...rest] = entry.split(":");
|
||||
if (!rawField || rest.length === 0) continue;
|
||||
const field = rawField.trim();
|
||||
const message = rest.join(":").trim();
|
||||
if (field && message) errors[field] = message;
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
function resolveNostrAccountId(host: MoltbotApp): string {
|
||||
const accounts = host.channelsSnapshot?.channelAccounts?.nostr ?? [];
|
||||
return accounts[0]?.accountId ?? host.nostrProfileAccountId ?? "default";
|
||||
}
|
||||
|
||||
function buildNostrProfileUrl(accountId: string, suffix = ""): string {
|
||||
return `/api/channels/nostr/${encodeURIComponent(accountId)}/profile${suffix}`;
|
||||
}
|
||||
|
||||
export function handleNostrProfileEdit(
|
||||
host: MoltbotApp,
|
||||
accountId: string,
|
||||
profile: NostrProfile | null,
|
||||
) {
|
||||
host.nostrProfileAccountId = accountId;
|
||||
host.nostrProfileFormState = createNostrProfileFormState(profile ?? undefined);
|
||||
}
|
||||
|
||||
export function handleNostrProfileCancel(host: MoltbotApp) {
|
||||
host.nostrProfileFormState = null;
|
||||
host.nostrProfileAccountId = null;
|
||||
}
|
||||
|
||||
export function handleNostrProfileFieldChange(
|
||||
host: MoltbotApp,
|
||||
field: keyof NostrProfile,
|
||||
value: string,
|
||||
) {
|
||||
const state = host.nostrProfileFormState;
|
||||
if (!state) return;
|
||||
host.nostrProfileFormState = {
|
||||
...state,
|
||||
values: {
|
||||
...state.values,
|
||||
[field]: value,
|
||||
},
|
||||
fieldErrors: {
|
||||
...state.fieldErrors,
|
||||
[field]: "",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function handleNostrProfileToggleAdvanced(host: MoltbotApp) {
|
||||
const state = host.nostrProfileFormState;
|
||||
if (!state) return;
|
||||
host.nostrProfileFormState = {
|
||||
...state,
|
||||
showAdvanced: !state.showAdvanced,
|
||||
};
|
||||
}
|
||||
|
||||
export async function handleNostrProfileSave(host: MoltbotApp) {
|
||||
const state = host.nostrProfileFormState;
|
||||
if (!state || state.saving) return;
|
||||
const accountId = resolveNostrAccountId(host);
|
||||
|
||||
host.nostrProfileFormState = {
|
||||
...state,
|
||||
saving: true,
|
||||
error: null,
|
||||
success: null,
|
||||
fieldErrors: {},
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(buildNostrProfileUrl(accountId), {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(state.values),
|
||||
});
|
||||
const data = (await response.json().catch(() => null)) as
|
||||
| { ok?: boolean; error?: string; details?: unknown; persisted?: boolean }
|
||||
| null;
|
||||
|
||||
if (!response.ok || data?.ok === false || !data) {
|
||||
const errorMessage = data?.error ?? `Profile update failed (${response.status})`;
|
||||
host.nostrProfileFormState = {
|
||||
...state,
|
||||
saving: false,
|
||||
error: errorMessage,
|
||||
success: null,
|
||||
fieldErrors: parseValidationErrors(data?.details),
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
if (!data.persisted) {
|
||||
host.nostrProfileFormState = {
|
||||
...state,
|
||||
saving: false,
|
||||
error: "Profile publish failed on all relays.",
|
||||
success: null,
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
host.nostrProfileFormState = {
|
||||
...state,
|
||||
saving: false,
|
||||
error: null,
|
||||
success: "Profile published to relays.",
|
||||
fieldErrors: {},
|
||||
original: { ...state.values },
|
||||
};
|
||||
await loadChannels(host, true);
|
||||
} catch (err) {
|
||||
host.nostrProfileFormState = {
|
||||
...state,
|
||||
saving: false,
|
||||
error: `Profile update failed: ${String(err)}`,
|
||||
success: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleNostrProfileImport(host: MoltbotApp) {
|
||||
const state = host.nostrProfileFormState;
|
||||
if (!state || state.importing) return;
|
||||
const accountId = resolveNostrAccountId(host);
|
||||
|
||||
host.nostrProfileFormState = {
|
||||
...state,
|
||||
importing: true,
|
||||
error: null,
|
||||
success: null,
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(buildNostrProfileUrl(accountId, "/import"), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ autoMerge: true }),
|
||||
});
|
||||
const data = (await response.json().catch(() => null)) as
|
||||
| { ok?: boolean; error?: string; imported?: NostrProfile; merged?: NostrProfile; saved?: boolean }
|
||||
| null;
|
||||
|
||||
if (!response.ok || data?.ok === false || !data) {
|
||||
const errorMessage = data?.error ?? `Profile import failed (${response.status})`;
|
||||
host.nostrProfileFormState = {
|
||||
...state,
|
||||
importing: false,
|
||||
error: errorMessage,
|
||||
success: null,
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
const merged = data.merged ?? data.imported ?? null;
|
||||
const nextValues = merged ? { ...state.values, ...merged } : state.values;
|
||||
const showAdvanced = Boolean(
|
||||
nextValues.banner || nextValues.website || nextValues.nip05 || nextValues.lud16,
|
||||
);
|
||||
|
||||
host.nostrProfileFormState = {
|
||||
...state,
|
||||
importing: false,
|
||||
values: nextValues,
|
||||
error: null,
|
||||
success: data.saved
|
||||
? "Profile imported from relays. Review and publish."
|
||||
: "Profile imported. Review and publish.",
|
||||
showAdvanced,
|
||||
};
|
||||
|
||||
if (data.saved) {
|
||||
await loadChannels(host, true);
|
||||
}
|
||||
} catch (err) {
|
||||
host.nostrProfileFormState = {
|
||||
...state,
|
||||
importing: false,
|
||||
error: `Profile import failed: ${String(err)}`,
|
||||
success: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
208
docker-compose/ez-assistant/ui/src/ui/app-chat.ts
Normal file
208
docker-compose/ez-assistant/ui/src/ui/app-chat.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
import { abortChatRun, loadChatHistory, sendChatMessage } from "./controllers/chat";
|
||||
import { loadSessions } from "./controllers/sessions";
|
||||
import { generateUUID } from "./uuid";
|
||||
import { resetToolStream } from "./app-tool-stream";
|
||||
import { scheduleChatScroll } from "./app-scroll";
|
||||
import { setLastActiveSessionKey } from "./app-settings";
|
||||
import { normalizeBasePath } from "./navigation";
|
||||
import type { GatewayHelloOk } from "./gateway";
|
||||
import { parseAgentSessionKey } from "../../../src/sessions/session-key-utils.js";
|
||||
import type { MoltbotApp } from "./app";
|
||||
import type { ChatAttachment, ChatQueueItem } from "./ui-types";
|
||||
|
||||
type ChatHost = {
|
||||
connected: boolean;
|
||||
chatMessage: string;
|
||||
chatAttachments: ChatAttachment[];
|
||||
chatQueue: ChatQueueItem[];
|
||||
chatRunId: string | null;
|
||||
chatSending: boolean;
|
||||
sessionKey: string;
|
||||
basePath: string;
|
||||
hello: GatewayHelloOk | null;
|
||||
chatAvatarUrl: string | null;
|
||||
};
|
||||
|
||||
export function isChatBusy(host: ChatHost) {
|
||||
return host.chatSending || Boolean(host.chatRunId);
|
||||
}
|
||||
|
||||
export function isChatStopCommand(text: string) {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return false;
|
||||
const normalized = trimmed.toLowerCase();
|
||||
if (normalized === "/stop") return true;
|
||||
return (
|
||||
normalized === "stop" ||
|
||||
normalized === "esc" ||
|
||||
normalized === "abort" ||
|
||||
normalized === "wait" ||
|
||||
normalized === "exit"
|
||||
);
|
||||
}
|
||||
|
||||
export async function handleAbortChat(host: ChatHost) {
|
||||
if (!host.connected) return;
|
||||
host.chatMessage = "";
|
||||
await abortChatRun(host as unknown as MoltbotApp);
|
||||
}
|
||||
|
||||
function enqueueChatMessage(host: ChatHost, text: string, attachments?: ChatAttachment[]) {
|
||||
const trimmed = text.trim();
|
||||
const hasAttachments = Boolean(attachments && attachments.length > 0);
|
||||
if (!trimmed && !hasAttachments) return;
|
||||
host.chatQueue = [
|
||||
...host.chatQueue,
|
||||
{
|
||||
id: generateUUID(),
|
||||
text: trimmed,
|
||||
createdAt: Date.now(),
|
||||
attachments: hasAttachments ? attachments?.map((att) => ({ ...att })) : undefined,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
async function sendChatMessageNow(
|
||||
host: ChatHost,
|
||||
message: string,
|
||||
opts?: {
|
||||
previousDraft?: string;
|
||||
restoreDraft?: boolean;
|
||||
attachments?: ChatAttachment[];
|
||||
previousAttachments?: ChatAttachment[];
|
||||
restoreAttachments?: boolean;
|
||||
},
|
||||
) {
|
||||
resetToolStream(host as unknown as Parameters<typeof resetToolStream>[0]);
|
||||
const ok = await sendChatMessage(host as unknown as MoltbotApp, message, opts?.attachments);
|
||||
if (!ok && opts?.previousDraft != null) {
|
||||
host.chatMessage = opts.previousDraft;
|
||||
}
|
||||
if (!ok && opts?.previousAttachments) {
|
||||
host.chatAttachments = opts.previousAttachments;
|
||||
}
|
||||
if (ok) {
|
||||
setLastActiveSessionKey(host as unknown as Parameters<typeof setLastActiveSessionKey>[0], host.sessionKey);
|
||||
}
|
||||
if (ok && opts?.restoreDraft && opts.previousDraft?.trim()) {
|
||||
host.chatMessage = opts.previousDraft;
|
||||
}
|
||||
if (ok && opts?.restoreAttachments && opts.previousAttachments?.length) {
|
||||
host.chatAttachments = opts.previousAttachments;
|
||||
}
|
||||
scheduleChatScroll(host as unknown as Parameters<typeof scheduleChatScroll>[0]);
|
||||
if (ok && !host.chatRunId) {
|
||||
void flushChatQueue(host);
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
async function flushChatQueue(host: ChatHost) {
|
||||
if (!host.connected || isChatBusy(host)) return;
|
||||
const [next, ...rest] = host.chatQueue;
|
||||
if (!next) return;
|
||||
host.chatQueue = rest;
|
||||
const ok = await sendChatMessageNow(host, next.text, { attachments: next.attachments });
|
||||
if (!ok) {
|
||||
host.chatQueue = [next, ...host.chatQueue];
|
||||
}
|
||||
}
|
||||
|
||||
export function removeQueuedMessage(host: ChatHost, id: string) {
|
||||
host.chatQueue = host.chatQueue.filter((item) => item.id !== id);
|
||||
}
|
||||
|
||||
export async function handleSendChat(
|
||||
host: ChatHost,
|
||||
messageOverride?: string,
|
||||
opts?: { restoreDraft?: boolean },
|
||||
) {
|
||||
if (!host.connected) return;
|
||||
const previousDraft = host.chatMessage;
|
||||
const message = (messageOverride ?? host.chatMessage).trim();
|
||||
const attachments = host.chatAttachments ?? [];
|
||||
const attachmentsToSend = messageOverride == null ? attachments : [];
|
||||
const hasAttachments = attachmentsToSend.length > 0;
|
||||
|
||||
// Allow sending with just attachments (no message text required)
|
||||
if (!message && !hasAttachments) return;
|
||||
|
||||
if (isChatStopCommand(message)) {
|
||||
await handleAbortChat(host);
|
||||
return;
|
||||
}
|
||||
|
||||
if (messageOverride == null) {
|
||||
host.chatMessage = "";
|
||||
// Clear attachments when sending
|
||||
host.chatAttachments = [];
|
||||
}
|
||||
|
||||
if (isChatBusy(host)) {
|
||||
enqueueChatMessage(host, message, attachmentsToSend);
|
||||
return;
|
||||
}
|
||||
|
||||
await sendChatMessageNow(host, message, {
|
||||
previousDraft: messageOverride == null ? previousDraft : undefined,
|
||||
restoreDraft: Boolean(messageOverride && opts?.restoreDraft),
|
||||
attachments: hasAttachments ? attachmentsToSend : undefined,
|
||||
previousAttachments: messageOverride == null ? attachments : undefined,
|
||||
restoreAttachments: Boolean(messageOverride && opts?.restoreDraft),
|
||||
});
|
||||
}
|
||||
|
||||
export async function refreshChat(host: ChatHost) {
|
||||
await Promise.all([
|
||||
loadChatHistory(host as unknown as MoltbotApp),
|
||||
loadSessions(host as unknown as MoltbotApp),
|
||||
refreshChatAvatar(host),
|
||||
]);
|
||||
scheduleChatScroll(host as unknown as Parameters<typeof scheduleChatScroll>[0], true);
|
||||
}
|
||||
|
||||
export const flushChatQueueForEvent = flushChatQueue;
|
||||
|
||||
type SessionDefaultsSnapshot = {
|
||||
defaultAgentId?: string;
|
||||
};
|
||||
|
||||
function resolveAgentIdForSession(host: ChatHost): string | null {
|
||||
const parsed = parseAgentSessionKey(host.sessionKey);
|
||||
if (parsed?.agentId) return parsed.agentId;
|
||||
const snapshot = host.hello?.snapshot as { sessionDefaults?: SessionDefaultsSnapshot } | undefined;
|
||||
const fallback = snapshot?.sessionDefaults?.defaultAgentId?.trim();
|
||||
return fallback || "main";
|
||||
}
|
||||
|
||||
function buildAvatarMetaUrl(basePath: string, agentId: string): string {
|
||||
const base = normalizeBasePath(basePath);
|
||||
const encoded = encodeURIComponent(agentId);
|
||||
return base ? `${base}/avatar/${encoded}?meta=1` : `/avatar/${encoded}?meta=1`;
|
||||
}
|
||||
|
||||
export async function refreshChatAvatar(host: ChatHost) {
|
||||
if (!host.connected) {
|
||||
host.chatAvatarUrl = null;
|
||||
return;
|
||||
}
|
||||
const agentId = resolveAgentIdForSession(host);
|
||||
if (!agentId) {
|
||||
host.chatAvatarUrl = null;
|
||||
return;
|
||||
}
|
||||
host.chatAvatarUrl = null;
|
||||
const url = buildAvatarMetaUrl(host.basePath, agentId);
|
||||
try {
|
||||
const res = await fetch(url, { method: "GET" });
|
||||
if (!res.ok) {
|
||||
host.chatAvatarUrl = null;
|
||||
return;
|
||||
}
|
||||
const data = (await res.json()) as { avatarUrl?: unknown };
|
||||
const avatarUrl = typeof data.avatarUrl === "string" ? data.avatarUrl.trim() : "";
|
||||
host.chatAvatarUrl = avatarUrl || null;
|
||||
} catch {
|
||||
host.chatAvatarUrl = null;
|
||||
}
|
||||
}
|
||||
33
docker-compose/ez-assistant/ui/src/ui/app-defaults.ts
Normal file
33
docker-compose/ez-assistant/ui/src/ui/app-defaults.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { LogLevel } from "./types";
|
||||
import type { CronFormState } from "./ui-types";
|
||||
|
||||
export const DEFAULT_LOG_LEVEL_FILTERS: Record<LogLevel, boolean> = {
|
||||
trace: true,
|
||||
debug: true,
|
||||
info: true,
|
||||
warn: true,
|
||||
error: true,
|
||||
fatal: true,
|
||||
};
|
||||
|
||||
export const DEFAULT_CRON_FORM: CronFormState = {
|
||||
name: "",
|
||||
description: "",
|
||||
agentId: "",
|
||||
enabled: true,
|
||||
scheduleKind: "every",
|
||||
scheduleAt: "",
|
||||
everyAmount: "30",
|
||||
everyUnit: "minutes",
|
||||
cronExpr: "0 7 * * *",
|
||||
cronTz: "",
|
||||
sessionTarget: "main",
|
||||
wakeMode: "next-heartbeat",
|
||||
payloadKind: "systemEvent",
|
||||
payloadText: "",
|
||||
deliver: false,
|
||||
channel: "last",
|
||||
to: "",
|
||||
timeoutSeconds: "",
|
||||
postToMainPrefix: "",
|
||||
};
|
||||
5
docker-compose/ez-assistant/ui/src/ui/app-events.ts
Normal file
5
docker-compose/ez-assistant/ui/src/ui/app-events.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export type EventLogEntry = {
|
||||
ts: number;
|
||||
event: string;
|
||||
payload?: unknown;
|
||||
};
|
||||
258
docker-compose/ez-assistant/ui/src/ui/app-gateway.ts
Normal file
258
docker-compose/ez-assistant/ui/src/ui/app-gateway.ts
Normal file
@@ -0,0 +1,258 @@
|
||||
import { loadChatHistory } from "./controllers/chat";
|
||||
import { loadDevices } from "./controllers/devices";
|
||||
import { loadNodes } from "./controllers/nodes";
|
||||
import { loadAgents } from "./controllers/agents";
|
||||
import type { GatewayEventFrame, GatewayHelloOk } from "./gateway";
|
||||
import { GatewayBrowserClient } from "./gateway";
|
||||
import type { EventLogEntry } from "./app-events";
|
||||
import type { AgentsListResult, PresenceEntry, HealthSnapshot, StatusSummary } from "./types";
|
||||
import type { Tab } from "./navigation";
|
||||
import type { UiSettings } from "./storage";
|
||||
import { handleAgentEvent, resetToolStream, type AgentEventPayload } from "./app-tool-stream";
|
||||
import { flushChatQueueForEvent } from "./app-chat";
|
||||
import {
|
||||
applySettings,
|
||||
loadCron,
|
||||
refreshActiveTab,
|
||||
setLastActiveSessionKey,
|
||||
} from "./app-settings";
|
||||
import { handleChatEvent, type ChatEventPayload } from "./controllers/chat";
|
||||
import {
|
||||
addExecApproval,
|
||||
parseExecApprovalRequested,
|
||||
parseExecApprovalResolved,
|
||||
removeExecApproval,
|
||||
} from "./controllers/exec-approval";
|
||||
import type { MoltbotApp } from "./app";
|
||||
import type { ExecApprovalRequest } from "./controllers/exec-approval";
|
||||
import { loadAssistantIdentity } from "./controllers/assistant-identity";
|
||||
|
||||
type GatewayHost = {
|
||||
settings: UiSettings;
|
||||
password: string;
|
||||
client: GatewayBrowserClient | null;
|
||||
connected: boolean;
|
||||
hello: GatewayHelloOk | null;
|
||||
lastError: string | null;
|
||||
onboarding?: boolean;
|
||||
eventLogBuffer: EventLogEntry[];
|
||||
eventLog: EventLogEntry[];
|
||||
tab: Tab;
|
||||
presenceEntries: PresenceEntry[];
|
||||
presenceError: string | null;
|
||||
presenceStatus: StatusSummary | null;
|
||||
agentsLoading: boolean;
|
||||
agentsList: AgentsListResult | null;
|
||||
agentsError: string | null;
|
||||
debugHealth: HealthSnapshot | null;
|
||||
assistantName: string;
|
||||
assistantAvatar: string | null;
|
||||
assistantAgentId: string | null;
|
||||
sessionKey: string;
|
||||
chatRunId: string | null;
|
||||
execApprovalQueue: ExecApprovalRequest[];
|
||||
execApprovalError: string | null;
|
||||
};
|
||||
|
||||
type SessionDefaultsSnapshot = {
|
||||
defaultAgentId?: string;
|
||||
mainKey?: string;
|
||||
mainSessionKey?: string;
|
||||
scope?: string;
|
||||
};
|
||||
|
||||
function normalizeSessionKeyForDefaults(
|
||||
value: string | undefined,
|
||||
defaults: SessionDefaultsSnapshot,
|
||||
): string {
|
||||
const raw = (value ?? "").trim();
|
||||
const mainSessionKey = defaults.mainSessionKey?.trim();
|
||||
if (!mainSessionKey) return raw;
|
||||
if (!raw) return mainSessionKey;
|
||||
const mainKey = defaults.mainKey?.trim() || "main";
|
||||
const defaultAgentId = defaults.defaultAgentId?.trim();
|
||||
const isAlias =
|
||||
raw === "main" ||
|
||||
raw === mainKey ||
|
||||
(defaultAgentId &&
|
||||
(raw === `agent:${defaultAgentId}:main` ||
|
||||
raw === `agent:${defaultAgentId}:${mainKey}`));
|
||||
return isAlias ? mainSessionKey : raw;
|
||||
}
|
||||
|
||||
function applySessionDefaults(host: GatewayHost, defaults?: SessionDefaultsSnapshot) {
|
||||
if (!defaults?.mainSessionKey) return;
|
||||
const resolvedSessionKey = normalizeSessionKeyForDefaults(host.sessionKey, defaults);
|
||||
const resolvedSettingsSessionKey = normalizeSessionKeyForDefaults(
|
||||
host.settings.sessionKey,
|
||||
defaults,
|
||||
);
|
||||
const resolvedLastActiveSessionKey = normalizeSessionKeyForDefaults(
|
||||
host.settings.lastActiveSessionKey,
|
||||
defaults,
|
||||
);
|
||||
const nextSessionKey = resolvedSessionKey || resolvedSettingsSessionKey || host.sessionKey;
|
||||
const nextSettings = {
|
||||
...host.settings,
|
||||
sessionKey: resolvedSettingsSessionKey || nextSessionKey,
|
||||
lastActiveSessionKey: resolvedLastActiveSessionKey || nextSessionKey,
|
||||
};
|
||||
const shouldUpdateSettings =
|
||||
nextSettings.sessionKey !== host.settings.sessionKey ||
|
||||
nextSettings.lastActiveSessionKey !== host.settings.lastActiveSessionKey;
|
||||
if (nextSessionKey !== host.sessionKey) {
|
||||
host.sessionKey = nextSessionKey;
|
||||
}
|
||||
if (shouldUpdateSettings) {
|
||||
applySettings(host as unknown as Parameters<typeof applySettings>[0], nextSettings);
|
||||
}
|
||||
}
|
||||
|
||||
export function connectGateway(host: GatewayHost) {
|
||||
host.lastError = null;
|
||||
host.hello = null;
|
||||
host.connected = false;
|
||||
host.execApprovalQueue = [];
|
||||
host.execApprovalError = null;
|
||||
|
||||
host.client?.stop();
|
||||
host.client = new GatewayBrowserClient({
|
||||
url: host.settings.gatewayUrl,
|
||||
token: host.settings.token.trim() ? host.settings.token : undefined,
|
||||
password: host.password.trim() ? host.password : undefined,
|
||||
clientName: "moltbot-control-ui",
|
||||
mode: "webchat",
|
||||
onHello: (hello) => {
|
||||
host.connected = true;
|
||||
host.lastError = null;
|
||||
host.hello = hello;
|
||||
applySnapshot(host, hello);
|
||||
// Reset orphaned chat run state from before disconnect.
|
||||
// Any in-flight run's final event was lost during the disconnect window.
|
||||
host.chatRunId = null;
|
||||
(host as unknown as { chatStream: string | null }).chatStream = null;
|
||||
(host as unknown as { chatStreamStartedAt: number | null }).chatStreamStartedAt = null;
|
||||
resetToolStream(host as unknown as Parameters<typeof resetToolStream>[0]);
|
||||
void loadAssistantIdentity(host as unknown as MoltbotApp);
|
||||
void loadAgents(host as unknown as MoltbotApp);
|
||||
void loadNodes(host as unknown as MoltbotApp, { quiet: true });
|
||||
void loadDevices(host as unknown as MoltbotApp, { quiet: true });
|
||||
void refreshActiveTab(host as unknown as Parameters<typeof refreshActiveTab>[0]);
|
||||
},
|
||||
onClose: ({ code, reason }) => {
|
||||
host.connected = false;
|
||||
// Code 1012 = Service Restart (expected during config saves, don't show as error)
|
||||
if (code !== 1012) {
|
||||
host.lastError = `disconnected (${code}): ${reason || "no reason"}`;
|
||||
}
|
||||
},
|
||||
onEvent: (evt) => handleGatewayEvent(host, evt),
|
||||
onGap: ({ expected, received }) => {
|
||||
host.lastError = `event gap detected (expected seq ${expected}, got ${received}); refresh recommended`;
|
||||
},
|
||||
});
|
||||
host.client.start();
|
||||
}
|
||||
|
||||
export function handleGatewayEvent(host: GatewayHost, evt: GatewayEventFrame) {
|
||||
try {
|
||||
handleGatewayEventUnsafe(host, evt);
|
||||
} catch (err) {
|
||||
console.error("[gateway] handleGatewayEvent error:", evt.event, err);
|
||||
}
|
||||
}
|
||||
|
||||
function handleGatewayEventUnsafe(host: GatewayHost, evt: GatewayEventFrame) {
|
||||
host.eventLogBuffer = [
|
||||
{ ts: Date.now(), event: evt.event, payload: evt.payload },
|
||||
...host.eventLogBuffer,
|
||||
].slice(0, 250);
|
||||
if (host.tab === "debug") {
|
||||
host.eventLog = host.eventLogBuffer;
|
||||
}
|
||||
|
||||
if (evt.event === "agent") {
|
||||
if (host.onboarding) return;
|
||||
handleAgentEvent(
|
||||
host as unknown as Parameters<typeof handleAgentEvent>[0],
|
||||
evt.payload as AgentEventPayload | undefined,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (evt.event === "chat") {
|
||||
const payload = evt.payload as ChatEventPayload | undefined;
|
||||
if (payload?.sessionKey) {
|
||||
setLastActiveSessionKey(
|
||||
host as unknown as Parameters<typeof setLastActiveSessionKey>[0],
|
||||
payload.sessionKey,
|
||||
);
|
||||
}
|
||||
const state = handleChatEvent(host as unknown as MoltbotApp, payload);
|
||||
if (state === "final" || state === "error" || state === "aborted") {
|
||||
resetToolStream(host as unknown as Parameters<typeof resetToolStream>[0]);
|
||||
void flushChatQueueForEvent(
|
||||
host as unknown as Parameters<typeof flushChatQueueForEvent>[0],
|
||||
);
|
||||
}
|
||||
if (state === "final") void loadChatHistory(host as unknown as MoltbotApp);
|
||||
return;
|
||||
}
|
||||
|
||||
if (evt.event === "presence") {
|
||||
const payload = evt.payload as { presence?: PresenceEntry[] } | undefined;
|
||||
if (payload?.presence && Array.isArray(payload.presence)) {
|
||||
host.presenceEntries = payload.presence;
|
||||
host.presenceError = null;
|
||||
host.presenceStatus = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (evt.event === "cron" && host.tab === "cron") {
|
||||
void loadCron(host as unknown as Parameters<typeof loadCron>[0]);
|
||||
}
|
||||
|
||||
if (evt.event === "device.pair.requested" || evt.event === "device.pair.resolved") {
|
||||
void loadDevices(host as unknown as MoltbotApp, { quiet: true });
|
||||
}
|
||||
|
||||
if (evt.event === "exec.approval.requested") {
|
||||
const entry = parseExecApprovalRequested(evt.payload);
|
||||
if (entry) {
|
||||
host.execApprovalQueue = addExecApproval(host.execApprovalQueue, entry);
|
||||
host.execApprovalError = null;
|
||||
const delay = Math.max(0, entry.expiresAtMs - Date.now() + 500);
|
||||
window.setTimeout(() => {
|
||||
host.execApprovalQueue = removeExecApproval(host.execApprovalQueue, entry.id);
|
||||
}, delay);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (evt.event === "exec.approval.resolved") {
|
||||
const resolved = parseExecApprovalResolved(evt.payload);
|
||||
if (resolved) {
|
||||
host.execApprovalQueue = removeExecApproval(host.execApprovalQueue, resolved.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function applySnapshot(host: GatewayHost, hello: GatewayHelloOk) {
|
||||
const snapshot = hello.snapshot as
|
||||
| {
|
||||
presence?: PresenceEntry[];
|
||||
health?: HealthSnapshot;
|
||||
sessionDefaults?: SessionDefaultsSnapshot;
|
||||
}
|
||||
| undefined;
|
||||
if (snapshot?.presence && Array.isArray(snapshot.presence)) {
|
||||
host.presenceEntries = snapshot.presence;
|
||||
}
|
||||
if (snapshot?.health) {
|
||||
host.debugHealth = snapshot.health;
|
||||
}
|
||||
if (snapshot?.sessionDefaults) {
|
||||
applySessionDefaults(host, snapshot.sessionDefaults);
|
||||
}
|
||||
}
|
||||
111
docker-compose/ez-assistant/ui/src/ui/app-lifecycle.ts
Normal file
111
docker-compose/ez-assistant/ui/src/ui/app-lifecycle.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import type { Tab } from "./navigation";
|
||||
import { connectGateway } from "./app-gateway";
|
||||
import {
|
||||
applySettingsFromUrl,
|
||||
attachThemeListener,
|
||||
detachThemeListener,
|
||||
inferBasePath,
|
||||
syncTabWithLocation,
|
||||
syncThemeWithSettings,
|
||||
} from "./app-settings";
|
||||
import { observeTopbar, scheduleChatScroll, scheduleLogsScroll } from "./app-scroll";
|
||||
import {
|
||||
startLogsPolling,
|
||||
startNodesPolling,
|
||||
stopLogsPolling,
|
||||
stopNodesPolling,
|
||||
startDebugPolling,
|
||||
stopDebugPolling,
|
||||
} from "./app-polling";
|
||||
|
||||
type LifecycleHost = {
|
||||
basePath: string;
|
||||
tab: Tab;
|
||||
chatHasAutoScrolled: boolean;
|
||||
chatLoading: boolean;
|
||||
chatMessages: unknown[];
|
||||
chatToolMessages: unknown[];
|
||||
chatStream: string;
|
||||
logsAutoFollow: boolean;
|
||||
logsAtBottom: boolean;
|
||||
logsEntries: unknown[];
|
||||
popStateHandler: () => void;
|
||||
topbarObserver: ResizeObserver | null;
|
||||
};
|
||||
|
||||
export function handleConnected(host: LifecycleHost) {
|
||||
host.basePath = inferBasePath();
|
||||
syncTabWithLocation(
|
||||
host as unknown as Parameters<typeof syncTabWithLocation>[0],
|
||||
true,
|
||||
);
|
||||
syncThemeWithSettings(
|
||||
host as unknown as Parameters<typeof syncThemeWithSettings>[0],
|
||||
);
|
||||
attachThemeListener(
|
||||
host as unknown as Parameters<typeof attachThemeListener>[0],
|
||||
);
|
||||
window.addEventListener("popstate", host.popStateHandler);
|
||||
applySettingsFromUrl(
|
||||
host as unknown as Parameters<typeof applySettingsFromUrl>[0],
|
||||
);
|
||||
connectGateway(host as unknown as Parameters<typeof connectGateway>[0]);
|
||||
startNodesPolling(host as unknown as Parameters<typeof startNodesPolling>[0]);
|
||||
if (host.tab === "logs") {
|
||||
startLogsPolling(host as unknown as Parameters<typeof startLogsPolling>[0]);
|
||||
}
|
||||
if (host.tab === "debug") {
|
||||
startDebugPolling(host as unknown as Parameters<typeof startDebugPolling>[0]);
|
||||
}
|
||||
}
|
||||
|
||||
export function handleFirstUpdated(host: LifecycleHost) {
|
||||
observeTopbar(host as unknown as Parameters<typeof observeTopbar>[0]);
|
||||
}
|
||||
|
||||
export function handleDisconnected(host: LifecycleHost) {
|
||||
window.removeEventListener("popstate", host.popStateHandler);
|
||||
stopNodesPolling(host as unknown as Parameters<typeof stopNodesPolling>[0]);
|
||||
stopLogsPolling(host as unknown as Parameters<typeof stopLogsPolling>[0]);
|
||||
stopDebugPolling(host as unknown as Parameters<typeof stopDebugPolling>[0]);
|
||||
detachThemeListener(
|
||||
host as unknown as Parameters<typeof detachThemeListener>[0],
|
||||
);
|
||||
host.topbarObserver?.disconnect();
|
||||
host.topbarObserver = null;
|
||||
}
|
||||
|
||||
export function handleUpdated(
|
||||
host: LifecycleHost,
|
||||
changed: Map<PropertyKey, unknown>,
|
||||
) {
|
||||
if (
|
||||
host.tab === "chat" &&
|
||||
(changed.has("chatMessages") ||
|
||||
changed.has("chatToolMessages") ||
|
||||
changed.has("chatStream") ||
|
||||
changed.has("chatLoading") ||
|
||||
changed.has("tab"))
|
||||
) {
|
||||
const forcedByTab = changed.has("tab");
|
||||
const forcedByLoad =
|
||||
changed.has("chatLoading") &&
|
||||
changed.get("chatLoading") === true &&
|
||||
host.chatLoading === false;
|
||||
scheduleChatScroll(
|
||||
host as unknown as Parameters<typeof scheduleChatScroll>[0],
|
||||
forcedByTab || forcedByLoad || !host.chatHasAutoScrolled,
|
||||
);
|
||||
}
|
||||
if (
|
||||
host.tab === "logs" &&
|
||||
(changed.has("logsEntries") || changed.has("logsAutoFollow") || changed.has("tab"))
|
||||
) {
|
||||
if (host.logsAutoFollow && host.logsAtBottom) {
|
||||
scheduleLogsScroll(
|
||||
host as unknown as Parameters<typeof scheduleLogsScroll>[0],
|
||||
changed.has("tab") || changed.has("logsAutoFollow"),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
53
docker-compose/ez-assistant/ui/src/ui/app-polling.ts
Normal file
53
docker-compose/ez-assistant/ui/src/ui/app-polling.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { loadLogs } from "./controllers/logs";
|
||||
import { loadNodes } from "./controllers/nodes";
|
||||
import { loadDebug } from "./controllers/debug";
|
||||
import type { MoltbotApp } from "./app";
|
||||
|
||||
type PollingHost = {
|
||||
nodesPollInterval: number | null;
|
||||
logsPollInterval: number | null;
|
||||
debugPollInterval: number | null;
|
||||
tab: string;
|
||||
};
|
||||
|
||||
export function startNodesPolling(host: PollingHost) {
|
||||
if (host.nodesPollInterval != null) return;
|
||||
host.nodesPollInterval = window.setInterval(
|
||||
() => void loadNodes(host as unknown as MoltbotApp, { quiet: true }),
|
||||
5000,
|
||||
);
|
||||
}
|
||||
|
||||
export function stopNodesPolling(host: PollingHost) {
|
||||
if (host.nodesPollInterval == null) return;
|
||||
clearInterval(host.nodesPollInterval);
|
||||
host.nodesPollInterval = null;
|
||||
}
|
||||
|
||||
export function startLogsPolling(host: PollingHost) {
|
||||
if (host.logsPollInterval != null) return;
|
||||
host.logsPollInterval = window.setInterval(() => {
|
||||
if (host.tab !== "logs") return;
|
||||
void loadLogs(host as unknown as MoltbotApp, { quiet: true });
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
export function stopLogsPolling(host: PollingHost) {
|
||||
if (host.logsPollInterval == null) return;
|
||||
clearInterval(host.logsPollInterval);
|
||||
host.logsPollInterval = null;
|
||||
}
|
||||
|
||||
export function startDebugPolling(host: PollingHost) {
|
||||
if (host.debugPollInterval != null) return;
|
||||
host.debugPollInterval = window.setInterval(() => {
|
||||
if (host.tab !== "debug") return;
|
||||
void loadDebug(host as unknown as MoltbotApp);
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
export function stopDebugPolling(host: PollingHost) {
|
||||
if (host.debugPollInterval == null) return;
|
||||
clearInterval(host.debugPollInterval);
|
||||
host.debugPollInterval = null;
|
||||
}
|
||||
242
docker-compose/ez-assistant/ui/src/ui/app-render.helpers.ts
Normal file
242
docker-compose/ez-assistant/ui/src/ui/app-render.helpers.ts
Normal file
@@ -0,0 +1,242 @@
|
||||
import { html } from "lit";
|
||||
import { repeat } from "lit/directives/repeat.js";
|
||||
|
||||
import type { AppViewState } from "./app-view-state";
|
||||
import { iconForTab, pathForTab, titleForTab, type Tab } from "./navigation";
|
||||
import { icons } from "./icons";
|
||||
import { loadChatHistory } from "./controllers/chat";
|
||||
import { syncUrlWithSessionKey } from "./app-settings";
|
||||
import type { SessionsListResult } from "./types";
|
||||
import type { ThemeMode } from "./theme";
|
||||
import type { ThemeTransitionContext } from "./theme-transition";
|
||||
|
||||
export function renderTab(state: AppViewState, tab: Tab) {
|
||||
const href = pathForTab(tab, state.basePath);
|
||||
return html`
|
||||
<a
|
||||
href=${href}
|
||||
class="nav-item ${state.tab === tab ? "active" : ""}"
|
||||
@click=${(event: MouseEvent) => {
|
||||
if (
|
||||
event.defaultPrevented ||
|
||||
event.button !== 0 ||
|
||||
event.metaKey ||
|
||||
event.ctrlKey ||
|
||||
event.shiftKey ||
|
||||
event.altKey
|
||||
) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
state.setTab(tab);
|
||||
}}
|
||||
title=${titleForTab(tab)}
|
||||
>
|
||||
<span class="nav-item__icon" aria-hidden="true">${icons[iconForTab(tab)]}</span>
|
||||
<span class="nav-item__text">${titleForTab(tab)}</span>
|
||||
</a>
|
||||
`;
|
||||
}
|
||||
|
||||
export function renderChatControls(state: AppViewState) {
|
||||
const sessionOptions = resolveSessionOptions(state.sessionKey, state.sessionsResult);
|
||||
const disableThinkingToggle = state.onboarding;
|
||||
const disableFocusToggle = state.onboarding;
|
||||
const showThinking = state.onboarding ? false : state.settings.chatShowThinking;
|
||||
const focusActive = state.onboarding ? true : state.settings.chatFocusMode;
|
||||
// Refresh icon
|
||||
const refreshIcon = html`<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"></path><path d="M21 3v5h-5"></path></svg>`;
|
||||
const focusIcon = html`<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7V4h3"></path><path d="M20 7V4h-3"></path><path d="M4 17v3h3"></path><path d="M20 17v3h-3"></path><circle cx="12" cy="12" r="3"></circle></svg>`;
|
||||
return html`
|
||||
<div class="chat-controls">
|
||||
<label class="field chat-controls__session">
|
||||
<select
|
||||
.value=${state.sessionKey}
|
||||
?disabled=${!state.connected}
|
||||
@change=${(e: Event) => {
|
||||
const next = (e.target as HTMLSelectElement).value;
|
||||
state.sessionKey = next;
|
||||
state.chatMessage = "";
|
||||
state.chatStream = null;
|
||||
state.chatStreamStartedAt = null;
|
||||
state.chatRunId = null;
|
||||
state.resetToolStream();
|
||||
state.resetChatScroll();
|
||||
state.applySettings({
|
||||
...state.settings,
|
||||
sessionKey: next,
|
||||
lastActiveSessionKey: next,
|
||||
});
|
||||
void state.loadAssistantIdentity();
|
||||
syncUrlWithSessionKey(state, next, true);
|
||||
void loadChatHistory(state);
|
||||
}}
|
||||
>
|
||||
${repeat(
|
||||
sessionOptions,
|
||||
(entry) => entry.key,
|
||||
(entry) =>
|
||||
html`<option value=${entry.key}>
|
||||
${entry.displayName ?? entry.key}
|
||||
</option>`,
|
||||
)}
|
||||
</select>
|
||||
</label>
|
||||
<button
|
||||
class="btn btn--sm btn--icon"
|
||||
?disabled=${state.chatLoading || !state.connected}
|
||||
@click=${() => {
|
||||
state.resetToolStream();
|
||||
void loadChatHistory(state);
|
||||
}}
|
||||
title="Refresh chat history"
|
||||
>
|
||||
${refreshIcon}
|
||||
</button>
|
||||
<span class="chat-controls__separator">|</span>
|
||||
<button
|
||||
class="btn btn--sm btn--icon ${showThinking ? "active" : ""}"
|
||||
?disabled=${disableThinkingToggle}
|
||||
@click=${() => {
|
||||
if (disableThinkingToggle) return;
|
||||
state.applySettings({
|
||||
...state.settings,
|
||||
chatShowThinking: !state.settings.chatShowThinking,
|
||||
});
|
||||
}}
|
||||
aria-pressed=${showThinking}
|
||||
title=${disableThinkingToggle
|
||||
? "Disabled during onboarding"
|
||||
: "Toggle assistant thinking/working output"}
|
||||
>
|
||||
${icons.brain}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn--sm btn--icon ${focusActive ? "active" : ""}"
|
||||
?disabled=${disableFocusToggle}
|
||||
@click=${() => {
|
||||
if (disableFocusToggle) return;
|
||||
state.applySettings({
|
||||
...state.settings,
|
||||
chatFocusMode: !state.settings.chatFocusMode,
|
||||
});
|
||||
}}
|
||||
aria-pressed=${focusActive}
|
||||
title=${disableFocusToggle
|
||||
? "Disabled during onboarding"
|
||||
: "Toggle focus mode (hide sidebar + page header)"}
|
||||
>
|
||||
${focusIcon}
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function resolveSessionOptions(sessionKey: string, sessions: SessionsListResult | null) {
|
||||
const seen = new Set<string>();
|
||||
const options: Array<{ key: string; displayName?: string }> = [];
|
||||
|
||||
const resolvedCurrent = sessions?.sessions?.find((s) => s.key === sessionKey);
|
||||
|
||||
// Add current session key first
|
||||
seen.add(sessionKey);
|
||||
options.push({ key: sessionKey, displayName: resolvedCurrent?.displayName });
|
||||
|
||||
// Add sessions from the result
|
||||
if (sessions?.sessions) {
|
||||
for (const s of sessions.sessions) {
|
||||
if (!seen.has(s.key)) {
|
||||
seen.add(s.key);
|
||||
options.push({ key: s.key, displayName: s.displayName });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
const THEME_ORDER: ThemeMode[] = ["system", "light", "dark"];
|
||||
|
||||
export function renderThemeToggle(state: AppViewState) {
|
||||
const index = Math.max(0, THEME_ORDER.indexOf(state.theme));
|
||||
const applyTheme = (next: ThemeMode) => (event: MouseEvent) => {
|
||||
const element = event.currentTarget as HTMLElement;
|
||||
const context: ThemeTransitionContext = { element };
|
||||
if (event.clientX || event.clientY) {
|
||||
context.pointerClientX = event.clientX;
|
||||
context.pointerClientY = event.clientY;
|
||||
}
|
||||
state.setTheme(next, context);
|
||||
};
|
||||
|
||||
return html`
|
||||
<div class="theme-toggle" style="--theme-index: ${index};">
|
||||
<div class="theme-toggle__track" role="group" aria-label="Theme">
|
||||
<span class="theme-toggle__indicator"></span>
|
||||
<button
|
||||
class="theme-toggle__button ${state.theme === "system" ? "active" : ""}"
|
||||
@click=${applyTheme("system")}
|
||||
aria-pressed=${state.theme === "system"}
|
||||
aria-label="System theme"
|
||||
title="System"
|
||||
>
|
||||
${renderMonitorIcon()}
|
||||
</button>
|
||||
<button
|
||||
class="theme-toggle__button ${state.theme === "light" ? "active" : ""}"
|
||||
@click=${applyTheme("light")}
|
||||
aria-pressed=${state.theme === "light"}
|
||||
aria-label="Light theme"
|
||||
title="Light"
|
||||
>
|
||||
${renderSunIcon()}
|
||||
</button>
|
||||
<button
|
||||
class="theme-toggle__button ${state.theme === "dark" ? "active" : ""}"
|
||||
@click=${applyTheme("dark")}
|
||||
aria-pressed=${state.theme === "dark"}
|
||||
aria-label="Dark theme"
|
||||
title="Dark"
|
||||
>
|
||||
${renderMoonIcon()}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderSunIcon() {
|
||||
return html`
|
||||
<svg class="theme-icon" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="4"></circle>
|
||||
<path d="M12 2v2"></path>
|
||||
<path d="M12 20v2"></path>
|
||||
<path d="m4.93 4.93 1.41 1.41"></path>
|
||||
<path d="m17.66 17.66 1.41 1.41"></path>
|
||||
<path d="M2 12h2"></path>
|
||||
<path d="M20 12h2"></path>
|
||||
<path d="m6.34 17.66-1.41 1.41"></path>
|
||||
<path d="m19.07 4.93-1.41 1.41"></path>
|
||||
</svg>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderMoonIcon() {
|
||||
return html`
|
||||
<svg class="theme-icon" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path
|
||||
d="M20.985 12.486a9 9 0 1 1-9.473-9.472c.405-.022.617.46.402.803a6 6 0 0 0 8.268 8.268c.344-.215.825-.004.803.401"
|
||||
></path>
|
||||
</svg>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderMonitorIcon() {
|
||||
return html`
|
||||
<svg class="theme-icon" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<rect width="20" height="14" x="2" y="3" rx="2"></rect>
|
||||
<line x1="8" x2="16" y1="21" y2="21"></line>
|
||||
<line x1="12" x2="12" y1="17" y2="21"></line>
|
||||
</svg>
|
||||
`;
|
||||
}
|
||||
583
docker-compose/ez-assistant/ui/src/ui/app-render.ts
Normal file
583
docker-compose/ez-assistant/ui/src/ui/app-render.ts
Normal file
@@ -0,0 +1,583 @@
|
||||
import { html, nothing } from "lit";
|
||||
|
||||
import type { GatewayBrowserClient, GatewayHelloOk } from "./gateway";
|
||||
import type { AppViewState } from "./app-view-state";
|
||||
import { parseAgentSessionKey } from "../../../src/routing/session-key.js";
|
||||
import {
|
||||
TAB_GROUPS,
|
||||
iconForTab,
|
||||
pathForTab,
|
||||
subtitleForTab,
|
||||
titleForTab,
|
||||
type Tab,
|
||||
} from "./navigation";
|
||||
import { icons } from "./icons";
|
||||
import type { UiSettings } from "./storage";
|
||||
import type { ThemeMode } from "./theme";
|
||||
import type { ThemeTransitionContext } from "./theme-transition";
|
||||
import type {
|
||||
ConfigSnapshot,
|
||||
CronJob,
|
||||
CronRunLogEntry,
|
||||
CronStatus,
|
||||
HealthSnapshot,
|
||||
LogEntry,
|
||||
LogLevel,
|
||||
PresenceEntry,
|
||||
ChannelsStatusSnapshot,
|
||||
SessionsListResult,
|
||||
SkillStatusReport,
|
||||
StatusSummary,
|
||||
} from "./types";
|
||||
import type { ChatQueueItem, CronFormState } from "./ui-types";
|
||||
import { refreshChatAvatar } from "./app-chat";
|
||||
import { renderChat } from "./views/chat";
|
||||
import { renderConfig } from "./views/config";
|
||||
import { renderChannels } from "./views/channels";
|
||||
import { renderCron } from "./views/cron";
|
||||
import { renderDebug } from "./views/debug";
|
||||
import { renderInstances } from "./views/instances";
|
||||
import { renderLogs } from "./views/logs";
|
||||
import { renderNodes } from "./views/nodes";
|
||||
import { renderOverview } from "./views/overview";
|
||||
import { renderSessions } from "./views/sessions";
|
||||
import { renderExecApprovalPrompt } from "./views/exec-approval";
|
||||
import {
|
||||
approveDevicePairing,
|
||||
loadDevices,
|
||||
rejectDevicePairing,
|
||||
revokeDeviceToken,
|
||||
rotateDeviceToken,
|
||||
} from "./controllers/devices";
|
||||
import { renderSkills } from "./views/skills";
|
||||
import { renderChatControls, renderTab, renderThemeToggle } from "./app-render.helpers";
|
||||
import { loadChannels } from "./controllers/channels";
|
||||
import { loadPresence } from "./controllers/presence";
|
||||
import { deleteSession, loadSessions, patchSession } from "./controllers/sessions";
|
||||
import {
|
||||
installSkill,
|
||||
loadSkills,
|
||||
saveSkillApiKey,
|
||||
updateSkillEdit,
|
||||
updateSkillEnabled,
|
||||
type SkillMessage,
|
||||
} from "./controllers/skills";
|
||||
import { loadNodes } from "./controllers/nodes";
|
||||
import { loadChatHistory } from "./controllers/chat";
|
||||
import {
|
||||
applyConfig,
|
||||
loadConfig,
|
||||
runUpdate,
|
||||
saveConfig,
|
||||
updateConfigFormValue,
|
||||
removeConfigFormValue,
|
||||
} from "./controllers/config";
|
||||
import {
|
||||
loadExecApprovals,
|
||||
removeExecApprovalsFormValue,
|
||||
saveExecApprovals,
|
||||
updateExecApprovalsFormValue,
|
||||
} from "./controllers/exec-approvals";
|
||||
import { loadCronRuns, toggleCronJob, runCronJob, removeCronJob, addCronJob } from "./controllers/cron";
|
||||
import { loadDebug, callDebugMethod } from "./controllers/debug";
|
||||
import { loadLogs } from "./controllers/logs";
|
||||
|
||||
const AVATAR_DATA_RE = /^data:/i;
|
||||
const AVATAR_HTTP_RE = /^https?:\/\//i;
|
||||
|
||||
function resolveAssistantAvatarUrl(state: AppViewState): string | undefined {
|
||||
const list = state.agentsList?.agents ?? [];
|
||||
const parsed = parseAgentSessionKey(state.sessionKey);
|
||||
const agentId =
|
||||
parsed?.agentId ??
|
||||
state.agentsList?.defaultId ??
|
||||
"main";
|
||||
const agent = list.find((entry) => entry.id === agentId);
|
||||
const identity = agent?.identity;
|
||||
const candidate = identity?.avatarUrl ?? identity?.avatar;
|
||||
if (!candidate) return undefined;
|
||||
if (AVATAR_DATA_RE.test(candidate) || AVATAR_HTTP_RE.test(candidate)) return candidate;
|
||||
return identity?.avatarUrl;
|
||||
}
|
||||
|
||||
export function renderApp(state: AppViewState) {
|
||||
const presenceCount = state.presenceEntries.length;
|
||||
const sessionsCount = state.sessionsResult?.count ?? null;
|
||||
const cronNext = state.cronStatus?.nextWakeAtMs ?? null;
|
||||
const chatDisabledReason = state.connected ? null : "Disconnected from gateway.";
|
||||
const isChat = state.tab === "chat";
|
||||
const chatFocus = isChat && (state.settings.chatFocusMode || state.onboarding);
|
||||
const showThinking = state.onboarding ? false : state.settings.chatShowThinking;
|
||||
const assistantAvatarUrl = resolveAssistantAvatarUrl(state);
|
||||
const chatAvatarUrl = state.chatAvatarUrl ?? assistantAvatarUrl ?? null;
|
||||
|
||||
return html`
|
||||
<div class="shell ${isChat ? "shell--chat" : ""} ${chatFocus ? "shell--chat-focus" : ""} ${state.settings.navCollapsed ? "shell--nav-collapsed" : ""} ${state.onboarding ? "shell--onboarding" : ""}">
|
||||
<header class="topbar">
|
||||
<div class="topbar-left">
|
||||
<button
|
||||
class="nav-collapse-toggle"
|
||||
@click=${() =>
|
||||
state.applySettings({
|
||||
...state.settings,
|
||||
navCollapsed: !state.settings.navCollapsed,
|
||||
})}
|
||||
title="${state.settings.navCollapsed ? "Expand sidebar" : "Collapse sidebar"}"
|
||||
aria-label="${state.settings.navCollapsed ? "Expand sidebar" : "Collapse sidebar"}"
|
||||
>
|
||||
<span class="nav-collapse-toggle__icon">${icons.menu}</span>
|
||||
</button>
|
||||
<div class="brand">
|
||||
<div class="brand-logo">
|
||||
<img src="https://mintcdn.com/clawdhub/4rYvG-uuZrMK_URE/assets/pixel-lobster.svg?fit=max&auto=format&n=4rYvG-uuZrMK_URE&q=85&s=da2032e9eac3b5d9bfe7eb96ca6a8a26" alt="Moltbot" />
|
||||
</div>
|
||||
<div class="brand-text">
|
||||
<div class="brand-title">MOLTBOT</div>
|
||||
<div class="brand-sub">Gateway Dashboard</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="topbar-status">
|
||||
<div class="pill">
|
||||
<span class="statusDot ${state.connected ? "ok" : ""}"></span>
|
||||
<span>Health</span>
|
||||
<span class="mono">${state.connected ? "OK" : "Offline"}</span>
|
||||
</div>
|
||||
${renderThemeToggle(state)}
|
||||
</div>
|
||||
</header>
|
||||
<aside class="nav ${state.settings.navCollapsed ? "nav--collapsed" : ""}">
|
||||
${TAB_GROUPS.map((group) => {
|
||||
const isGroupCollapsed = state.settings.navGroupsCollapsed[group.label] ?? false;
|
||||
const hasActiveTab = group.tabs.some((tab) => tab === state.tab);
|
||||
return html`
|
||||
<div class="nav-group ${isGroupCollapsed && !hasActiveTab ? "nav-group--collapsed" : ""}">
|
||||
<button
|
||||
class="nav-label"
|
||||
@click=${() => {
|
||||
const next = { ...state.settings.navGroupsCollapsed };
|
||||
next[group.label] = !isGroupCollapsed;
|
||||
state.applySettings({
|
||||
...state.settings,
|
||||
navGroupsCollapsed: next,
|
||||
});
|
||||
}}
|
||||
aria-expanded=${!isGroupCollapsed}
|
||||
>
|
||||
<span class="nav-label__text">${group.label}</span>
|
||||
<span class="nav-label__chevron">${isGroupCollapsed ? "+" : "−"}</span>
|
||||
</button>
|
||||
<div class="nav-group__items">
|
||||
${group.tabs.map((tab) => renderTab(state, tab))}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
})}
|
||||
<div class="nav-group nav-group--links">
|
||||
<div class="nav-label nav-label--static">
|
||||
<span class="nav-label__text">Resources</span>
|
||||
</div>
|
||||
<div class="nav-group__items">
|
||||
<a
|
||||
class="nav-item nav-item--external"
|
||||
href="https://docs.molt.bot"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
title="Docs (opens in new tab)"
|
||||
>
|
||||
<span class="nav-item__icon" aria-hidden="true">${icons.book}</span>
|
||||
<span class="nav-item__text">Docs</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
<main class="content ${isChat ? "content--chat" : ""}">
|
||||
<section class="content-header">
|
||||
<div>
|
||||
<div class="page-title">${titleForTab(state.tab)}</div>
|
||||
<div class="page-sub">${subtitleForTab(state.tab)}</div>
|
||||
</div>
|
||||
<div class="page-meta">
|
||||
${state.lastError
|
||||
? html`<div class="pill danger">${state.lastError}</div>`
|
||||
: nothing}
|
||||
${isChat ? renderChatControls(state) : nothing}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
${state.tab === "overview"
|
||||
? renderOverview({
|
||||
connected: state.connected,
|
||||
hello: state.hello,
|
||||
settings: state.settings,
|
||||
password: state.password,
|
||||
lastError: state.lastError,
|
||||
presenceCount,
|
||||
sessionsCount,
|
||||
cronEnabled: state.cronStatus?.enabled ?? null,
|
||||
cronNext,
|
||||
lastChannelsRefresh: state.channelsLastSuccess,
|
||||
onSettingsChange: (next) => state.applySettings(next),
|
||||
onPasswordChange: (next) => (state.password = next),
|
||||
onSessionKeyChange: (next) => {
|
||||
state.sessionKey = next;
|
||||
state.chatMessage = "";
|
||||
state.resetToolStream();
|
||||
state.applySettings({
|
||||
...state.settings,
|
||||
sessionKey: next,
|
||||
lastActiveSessionKey: next,
|
||||
});
|
||||
void state.loadAssistantIdentity();
|
||||
},
|
||||
onConnect: () => state.connect(),
|
||||
onRefresh: () => state.loadOverview(),
|
||||
})
|
||||
: nothing}
|
||||
|
||||
${state.tab === "channels"
|
||||
? renderChannels({
|
||||
connected: state.connected,
|
||||
loading: state.channelsLoading,
|
||||
snapshot: state.channelsSnapshot,
|
||||
lastError: state.channelsError,
|
||||
lastSuccessAt: state.channelsLastSuccess,
|
||||
whatsappMessage: state.whatsappLoginMessage,
|
||||
whatsappQrDataUrl: state.whatsappLoginQrDataUrl,
|
||||
whatsappConnected: state.whatsappLoginConnected,
|
||||
whatsappBusy: state.whatsappBusy,
|
||||
configSchema: state.configSchema,
|
||||
configSchemaLoading: state.configSchemaLoading,
|
||||
configForm: state.configForm,
|
||||
configUiHints: state.configUiHints,
|
||||
configSaving: state.configSaving,
|
||||
configFormDirty: state.configFormDirty,
|
||||
nostrProfileFormState: state.nostrProfileFormState,
|
||||
nostrProfileAccountId: state.nostrProfileAccountId,
|
||||
onRefresh: (probe) => loadChannels(state, probe),
|
||||
onWhatsAppStart: (force) => state.handleWhatsAppStart(force),
|
||||
onWhatsAppWait: () => state.handleWhatsAppWait(),
|
||||
onWhatsAppLogout: () => state.handleWhatsAppLogout(),
|
||||
onConfigPatch: (path, value) => updateConfigFormValue(state, path, value),
|
||||
onConfigSave: () => state.handleChannelConfigSave(),
|
||||
onConfigReload: () => state.handleChannelConfigReload(),
|
||||
onNostrProfileEdit: (accountId, profile) =>
|
||||
state.handleNostrProfileEdit(accountId, profile),
|
||||
onNostrProfileCancel: () => state.handleNostrProfileCancel(),
|
||||
onNostrProfileFieldChange: (field, value) =>
|
||||
state.handleNostrProfileFieldChange(field, value),
|
||||
onNostrProfileSave: () => state.handleNostrProfileSave(),
|
||||
onNostrProfileImport: () => state.handleNostrProfileImport(),
|
||||
onNostrProfileToggleAdvanced: () => state.handleNostrProfileToggleAdvanced(),
|
||||
})
|
||||
: nothing}
|
||||
|
||||
${state.tab === "instances"
|
||||
? renderInstances({
|
||||
loading: state.presenceLoading,
|
||||
entries: state.presenceEntries,
|
||||
lastError: state.presenceError,
|
||||
statusMessage: state.presenceStatus,
|
||||
onRefresh: () => loadPresence(state),
|
||||
})
|
||||
: nothing}
|
||||
|
||||
${state.tab === "sessions"
|
||||
? renderSessions({
|
||||
loading: state.sessionsLoading,
|
||||
result: state.sessionsResult,
|
||||
error: state.sessionsError,
|
||||
activeMinutes: state.sessionsFilterActive,
|
||||
limit: state.sessionsFilterLimit,
|
||||
includeGlobal: state.sessionsIncludeGlobal,
|
||||
includeUnknown: state.sessionsIncludeUnknown,
|
||||
basePath: state.basePath,
|
||||
onFiltersChange: (next) => {
|
||||
state.sessionsFilterActive = next.activeMinutes;
|
||||
state.sessionsFilterLimit = next.limit;
|
||||
state.sessionsIncludeGlobal = next.includeGlobal;
|
||||
state.sessionsIncludeUnknown = next.includeUnknown;
|
||||
},
|
||||
onRefresh: () => loadSessions(state),
|
||||
onPatch: (key, patch) => patchSession(state, key, patch),
|
||||
onDelete: (key) => deleteSession(state, key),
|
||||
})
|
||||
: nothing}
|
||||
|
||||
${state.tab === "cron"
|
||||
? renderCron({
|
||||
loading: state.cronLoading,
|
||||
status: state.cronStatus,
|
||||
jobs: state.cronJobs,
|
||||
error: state.cronError,
|
||||
busy: state.cronBusy,
|
||||
form: state.cronForm,
|
||||
channels: state.channelsSnapshot?.channelMeta?.length
|
||||
? state.channelsSnapshot.channelMeta.map((entry) => entry.id)
|
||||
: state.channelsSnapshot?.channelOrder ?? [],
|
||||
channelLabels: state.channelsSnapshot?.channelLabels ?? {},
|
||||
channelMeta: state.channelsSnapshot?.channelMeta ?? [],
|
||||
runsJobId: state.cronRunsJobId,
|
||||
runs: state.cronRuns,
|
||||
onFormChange: (patch) => (state.cronForm = { ...state.cronForm, ...patch }),
|
||||
onRefresh: () => state.loadCron(),
|
||||
onAdd: () => addCronJob(state),
|
||||
onToggle: (job, enabled) => toggleCronJob(state, job, enabled),
|
||||
onRun: (job) => runCronJob(state, job),
|
||||
onRemove: (job) => removeCronJob(state, job),
|
||||
onLoadRuns: (jobId) => loadCronRuns(state, jobId),
|
||||
})
|
||||
: nothing}
|
||||
|
||||
${state.tab === "skills"
|
||||
? renderSkills({
|
||||
loading: state.skillsLoading,
|
||||
report: state.skillsReport,
|
||||
error: state.skillsError,
|
||||
filter: state.skillsFilter,
|
||||
edits: state.skillEdits,
|
||||
messages: state.skillMessages,
|
||||
busyKey: state.skillsBusyKey,
|
||||
onFilterChange: (next) => (state.skillsFilter = next),
|
||||
onRefresh: () => loadSkills(state, { clearMessages: true }),
|
||||
onToggle: (key, enabled) => updateSkillEnabled(state, key, enabled),
|
||||
onEdit: (key, value) => updateSkillEdit(state, key, value),
|
||||
onSaveKey: (key) => saveSkillApiKey(state, key),
|
||||
onInstall: (skillKey, name, installId) =>
|
||||
installSkill(state, skillKey, name, installId),
|
||||
})
|
||||
: nothing}
|
||||
|
||||
${state.tab === "nodes"
|
||||
? renderNodes({
|
||||
loading: state.nodesLoading,
|
||||
nodes: state.nodes,
|
||||
devicesLoading: state.devicesLoading,
|
||||
devicesError: state.devicesError,
|
||||
devicesList: state.devicesList,
|
||||
configForm: state.configForm ?? (state.configSnapshot?.config as Record<string, unknown> | null),
|
||||
configLoading: state.configLoading,
|
||||
configSaving: state.configSaving,
|
||||
configDirty: state.configFormDirty,
|
||||
configFormMode: state.configFormMode,
|
||||
execApprovalsLoading: state.execApprovalsLoading,
|
||||
execApprovalsSaving: state.execApprovalsSaving,
|
||||
execApprovalsDirty: state.execApprovalsDirty,
|
||||
execApprovalsSnapshot: state.execApprovalsSnapshot,
|
||||
execApprovalsForm: state.execApprovalsForm,
|
||||
execApprovalsSelectedAgent: state.execApprovalsSelectedAgent,
|
||||
execApprovalsTarget: state.execApprovalsTarget,
|
||||
execApprovalsTargetNodeId: state.execApprovalsTargetNodeId,
|
||||
onRefresh: () => loadNodes(state),
|
||||
onDevicesRefresh: () => loadDevices(state),
|
||||
onDeviceApprove: (requestId) => approveDevicePairing(state, requestId),
|
||||
onDeviceReject: (requestId) => rejectDevicePairing(state, requestId),
|
||||
onDeviceRotate: (deviceId, role, scopes) =>
|
||||
rotateDeviceToken(state, { deviceId, role, scopes }),
|
||||
onDeviceRevoke: (deviceId, role) =>
|
||||
revokeDeviceToken(state, { deviceId, role }),
|
||||
onLoadConfig: () => loadConfig(state),
|
||||
onLoadExecApprovals: () => {
|
||||
const target =
|
||||
state.execApprovalsTarget === "node" && state.execApprovalsTargetNodeId
|
||||
? { kind: "node" as const, nodeId: state.execApprovalsTargetNodeId }
|
||||
: { kind: "gateway" as const };
|
||||
return loadExecApprovals(state, target);
|
||||
},
|
||||
onBindDefault: (nodeId) => {
|
||||
if (nodeId) {
|
||||
updateConfigFormValue(state, ["tools", "exec", "node"], nodeId);
|
||||
} else {
|
||||
removeConfigFormValue(state, ["tools", "exec", "node"]);
|
||||
}
|
||||
},
|
||||
onBindAgent: (agentIndex, nodeId) => {
|
||||
const basePath = ["agents", "list", agentIndex, "tools", "exec", "node"];
|
||||
if (nodeId) {
|
||||
updateConfigFormValue(state, basePath, nodeId);
|
||||
} else {
|
||||
removeConfigFormValue(state, basePath);
|
||||
}
|
||||
},
|
||||
onSaveBindings: () => saveConfig(state),
|
||||
onExecApprovalsTargetChange: (kind, nodeId) => {
|
||||
state.execApprovalsTarget = kind;
|
||||
state.execApprovalsTargetNodeId = nodeId;
|
||||
state.execApprovalsSnapshot = null;
|
||||
state.execApprovalsForm = null;
|
||||
state.execApprovalsDirty = false;
|
||||
state.execApprovalsSelectedAgent = null;
|
||||
},
|
||||
onExecApprovalsSelectAgent: (agentId) => {
|
||||
state.execApprovalsSelectedAgent = agentId;
|
||||
},
|
||||
onExecApprovalsPatch: (path, value) =>
|
||||
updateExecApprovalsFormValue(state, path, value),
|
||||
onExecApprovalsRemove: (path) =>
|
||||
removeExecApprovalsFormValue(state, path),
|
||||
onSaveExecApprovals: () => {
|
||||
const target =
|
||||
state.execApprovalsTarget === "node" && state.execApprovalsTargetNodeId
|
||||
? { kind: "node" as const, nodeId: state.execApprovalsTargetNodeId }
|
||||
: { kind: "gateway" as const };
|
||||
return saveExecApprovals(state, target);
|
||||
},
|
||||
})
|
||||
: nothing}
|
||||
|
||||
${state.tab === "chat"
|
||||
? renderChat({
|
||||
sessionKey: state.sessionKey,
|
||||
onSessionKeyChange: (next) => {
|
||||
state.sessionKey = next;
|
||||
state.chatMessage = "";
|
||||
state.chatAttachments = [];
|
||||
state.chatStream = null;
|
||||
state.chatStreamStartedAt = null;
|
||||
state.chatRunId = null;
|
||||
state.chatQueue = [];
|
||||
state.resetToolStream();
|
||||
state.resetChatScroll();
|
||||
state.applySettings({
|
||||
...state.settings,
|
||||
sessionKey: next,
|
||||
lastActiveSessionKey: next,
|
||||
});
|
||||
void state.loadAssistantIdentity();
|
||||
void loadChatHistory(state);
|
||||
void refreshChatAvatar(state);
|
||||
},
|
||||
thinkingLevel: state.chatThinkingLevel,
|
||||
showThinking,
|
||||
loading: state.chatLoading,
|
||||
sending: state.chatSending,
|
||||
compactionStatus: state.compactionStatus,
|
||||
assistantAvatarUrl: chatAvatarUrl,
|
||||
messages: state.chatMessages,
|
||||
toolMessages: state.chatToolMessages,
|
||||
stream: state.chatStream,
|
||||
streamStartedAt: state.chatStreamStartedAt,
|
||||
draft: state.chatMessage,
|
||||
queue: state.chatQueue,
|
||||
connected: state.connected,
|
||||
canSend: state.connected,
|
||||
disabledReason: chatDisabledReason,
|
||||
error: state.lastError,
|
||||
sessions: state.sessionsResult,
|
||||
focusMode: chatFocus,
|
||||
onRefresh: () => {
|
||||
state.resetToolStream();
|
||||
return Promise.all([loadChatHistory(state), refreshChatAvatar(state)]);
|
||||
},
|
||||
onToggleFocusMode: () => {
|
||||
if (state.onboarding) return;
|
||||
state.applySettings({
|
||||
...state.settings,
|
||||
chatFocusMode: !state.settings.chatFocusMode,
|
||||
});
|
||||
},
|
||||
onChatScroll: (event) => state.handleChatScroll(event),
|
||||
onDraftChange: (next) => (state.chatMessage = next),
|
||||
attachments: state.chatAttachments,
|
||||
onAttachmentsChange: (next) => (state.chatAttachments = next),
|
||||
onSend: () => state.handleSendChat(),
|
||||
canAbort: Boolean(state.chatRunId),
|
||||
onAbort: () => void state.handleAbortChat(),
|
||||
onQueueRemove: (id) => state.removeQueuedMessage(id),
|
||||
onNewSession: () =>
|
||||
state.handleSendChat("/new", { restoreDraft: true }),
|
||||
// Sidebar props for tool output viewing
|
||||
sidebarOpen: state.sidebarOpen,
|
||||
sidebarContent: state.sidebarContent,
|
||||
sidebarError: state.sidebarError,
|
||||
splitRatio: state.splitRatio,
|
||||
onOpenSidebar: (content: string) => state.handleOpenSidebar(content),
|
||||
onCloseSidebar: () => state.handleCloseSidebar(),
|
||||
onSplitRatioChange: (ratio: number) => state.handleSplitRatioChange(ratio),
|
||||
assistantName: state.assistantName,
|
||||
assistantAvatar: state.assistantAvatar,
|
||||
})
|
||||
: nothing}
|
||||
|
||||
${state.tab === "config"
|
||||
? renderConfig({
|
||||
raw: state.configRaw,
|
||||
originalRaw: state.configRawOriginal,
|
||||
valid: state.configValid,
|
||||
issues: state.configIssues,
|
||||
loading: state.configLoading,
|
||||
saving: state.configSaving,
|
||||
applying: state.configApplying,
|
||||
updating: state.updateRunning,
|
||||
connected: state.connected,
|
||||
schema: state.configSchema,
|
||||
schemaLoading: state.configSchemaLoading,
|
||||
uiHints: state.configUiHints,
|
||||
formMode: state.configFormMode,
|
||||
formValue: state.configForm,
|
||||
originalValue: state.configFormOriginal,
|
||||
searchQuery: state.configSearchQuery,
|
||||
activeSection: state.configActiveSection,
|
||||
activeSubsection: state.configActiveSubsection,
|
||||
onRawChange: (next) => {
|
||||
state.configRaw = next;
|
||||
},
|
||||
onFormModeChange: (mode) => (state.configFormMode = mode),
|
||||
onFormPatch: (path, value) => updateConfigFormValue(state, path, value),
|
||||
onSearchChange: (query) => (state.configSearchQuery = query),
|
||||
onSectionChange: (section) => {
|
||||
state.configActiveSection = section;
|
||||
state.configActiveSubsection = null;
|
||||
},
|
||||
onSubsectionChange: (section) => (state.configActiveSubsection = section),
|
||||
onReload: () => loadConfig(state),
|
||||
onSave: () => saveConfig(state),
|
||||
onApply: () => applyConfig(state),
|
||||
onUpdate: () => runUpdate(state),
|
||||
})
|
||||
: nothing}
|
||||
|
||||
${state.tab === "debug"
|
||||
? renderDebug({
|
||||
loading: state.debugLoading,
|
||||
status: state.debugStatus,
|
||||
health: state.debugHealth,
|
||||
models: state.debugModels,
|
||||
heartbeat: state.debugHeartbeat,
|
||||
eventLog: state.eventLog,
|
||||
callMethod: state.debugCallMethod,
|
||||
callParams: state.debugCallParams,
|
||||
callResult: state.debugCallResult,
|
||||
callError: state.debugCallError,
|
||||
onCallMethodChange: (next) => (state.debugCallMethod = next),
|
||||
onCallParamsChange: (next) => (state.debugCallParams = next),
|
||||
onRefresh: () => loadDebug(state),
|
||||
onCall: () => callDebugMethod(state),
|
||||
})
|
||||
: nothing}
|
||||
|
||||
${state.tab === "logs"
|
||||
? renderLogs({
|
||||
loading: state.logsLoading,
|
||||
error: state.logsError,
|
||||
file: state.logsFile,
|
||||
entries: state.logsEntries,
|
||||
filterText: state.logsFilterText,
|
||||
levelFilters: state.logsLevelFilters,
|
||||
autoFollow: state.logsAutoFollow,
|
||||
truncated: state.logsTruncated,
|
||||
onFilterTextChange: (next) => (state.logsFilterText = next),
|
||||
onLevelToggle: (level, enabled) => {
|
||||
state.logsLevelFilters = { ...state.logsLevelFilters, [level]: enabled };
|
||||
},
|
||||
onToggleAutoFollow: (next) => (state.logsAutoFollow = next),
|
||||
onRefresh: () => loadLogs(state, { reset: true }),
|
||||
onExport: (lines, label) => state.exportLogs(lines, label),
|
||||
onScroll: (event) => state.handleLogsScroll(event),
|
||||
})
|
||||
: nothing}
|
||||
</main>
|
||||
${renderExecApprovalPrompt(state)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
122
docker-compose/ez-assistant/ui/src/ui/app-scroll.ts
Normal file
122
docker-compose/ez-assistant/ui/src/ui/app-scroll.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
type ScrollHost = {
|
||||
updateComplete: Promise<unknown>;
|
||||
querySelector: (selectors: string) => Element | null;
|
||||
style: CSSStyleDeclaration;
|
||||
chatScrollFrame: number | null;
|
||||
chatScrollTimeout: number | null;
|
||||
chatHasAutoScrolled: boolean;
|
||||
chatUserNearBottom: boolean;
|
||||
logsScrollFrame: number | null;
|
||||
logsAtBottom: boolean;
|
||||
topbarObserver: ResizeObserver | null;
|
||||
};
|
||||
|
||||
export function scheduleChatScroll(host: ScrollHost, force = false) {
|
||||
if (host.chatScrollFrame) cancelAnimationFrame(host.chatScrollFrame);
|
||||
if (host.chatScrollTimeout != null) {
|
||||
clearTimeout(host.chatScrollTimeout);
|
||||
host.chatScrollTimeout = null;
|
||||
}
|
||||
const pickScrollTarget = () => {
|
||||
const container = host.querySelector(".chat-thread") as HTMLElement | null;
|
||||
if (container) {
|
||||
const overflowY = getComputedStyle(container).overflowY;
|
||||
const canScroll =
|
||||
overflowY === "auto" ||
|
||||
overflowY === "scroll" ||
|
||||
container.scrollHeight - container.clientHeight > 1;
|
||||
if (canScroll) return container;
|
||||
}
|
||||
return (document.scrollingElement ?? document.documentElement) as HTMLElement | null;
|
||||
};
|
||||
// Wait for Lit render to complete, then scroll
|
||||
void host.updateComplete.then(() => {
|
||||
host.chatScrollFrame = requestAnimationFrame(() => {
|
||||
host.chatScrollFrame = null;
|
||||
const target = pickScrollTarget();
|
||||
if (!target) return;
|
||||
const distanceFromBottom =
|
||||
target.scrollHeight - target.scrollTop - target.clientHeight;
|
||||
const shouldStick = force || host.chatUserNearBottom || distanceFromBottom < 200;
|
||||
if (!shouldStick) return;
|
||||
if (force) host.chatHasAutoScrolled = true;
|
||||
target.scrollTop = target.scrollHeight;
|
||||
host.chatUserNearBottom = true;
|
||||
const retryDelay = force ? 150 : 120;
|
||||
host.chatScrollTimeout = window.setTimeout(() => {
|
||||
host.chatScrollTimeout = null;
|
||||
const latest = pickScrollTarget();
|
||||
if (!latest) return;
|
||||
const latestDistanceFromBottom =
|
||||
latest.scrollHeight - latest.scrollTop - latest.clientHeight;
|
||||
const shouldStickRetry =
|
||||
force || host.chatUserNearBottom || latestDistanceFromBottom < 200;
|
||||
if (!shouldStickRetry) return;
|
||||
latest.scrollTop = latest.scrollHeight;
|
||||
host.chatUserNearBottom = true;
|
||||
}, retryDelay);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function scheduleLogsScroll(host: ScrollHost, force = false) {
|
||||
if (host.logsScrollFrame) cancelAnimationFrame(host.logsScrollFrame);
|
||||
void host.updateComplete.then(() => {
|
||||
host.logsScrollFrame = requestAnimationFrame(() => {
|
||||
host.logsScrollFrame = null;
|
||||
const container = host.querySelector(".log-stream") as HTMLElement | null;
|
||||
if (!container) return;
|
||||
const distanceFromBottom =
|
||||
container.scrollHeight - container.scrollTop - container.clientHeight;
|
||||
const shouldStick = force || distanceFromBottom < 80;
|
||||
if (!shouldStick) return;
|
||||
container.scrollTop = container.scrollHeight;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function handleChatScroll(host: ScrollHost, event: Event) {
|
||||
const container = event.currentTarget as HTMLElement | null;
|
||||
if (!container) return;
|
||||
const distanceFromBottom =
|
||||
container.scrollHeight - container.scrollTop - container.clientHeight;
|
||||
host.chatUserNearBottom = distanceFromBottom < 200;
|
||||
}
|
||||
|
||||
export function handleLogsScroll(host: ScrollHost, event: Event) {
|
||||
const container = event.currentTarget as HTMLElement | null;
|
||||
if (!container) return;
|
||||
const distanceFromBottom =
|
||||
container.scrollHeight - container.scrollTop - container.clientHeight;
|
||||
host.logsAtBottom = distanceFromBottom < 80;
|
||||
}
|
||||
|
||||
export function resetChatScroll(host: ScrollHost) {
|
||||
host.chatHasAutoScrolled = false;
|
||||
host.chatUserNearBottom = true;
|
||||
}
|
||||
|
||||
export function exportLogs(lines: string[], label: string) {
|
||||
if (lines.length === 0) return;
|
||||
const blob = new Blob([`${lines.join("\n")}\n`], { type: "text/plain" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
const stamp = new Date().toISOString().slice(0, 19).replace(/[:T]/g, "-");
|
||||
anchor.href = url;
|
||||
anchor.download = `moltbot-logs-${label}-${stamp}.log`;
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
export function observeTopbar(host: ScrollHost) {
|
||||
if (typeof ResizeObserver === "undefined") return;
|
||||
const topbar = host.querySelector(".topbar");
|
||||
if (!topbar) return;
|
||||
const update = () => {
|
||||
const { height } = topbar.getBoundingClientRect();
|
||||
host.style.setProperty("--topbar-height", `${height}px`);
|
||||
};
|
||||
update();
|
||||
host.topbarObserver = new ResizeObserver(() => update());
|
||||
host.topbarObserver.observe(topbar);
|
||||
}
|
||||
71
docker-compose/ez-assistant/ui/src/ui/app-settings.test.ts
Normal file
71
docker-compose/ez-assistant/ui/src/ui/app-settings.test.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { Tab } from "./navigation";
|
||||
import { setTabFromRoute } from "./app-settings";
|
||||
|
||||
type SettingsHost = Parameters<typeof setTabFromRoute>[0] & {
|
||||
logsPollInterval: number | null;
|
||||
debugPollInterval: number | null;
|
||||
};
|
||||
|
||||
const createHost = (tab: Tab): SettingsHost => ({
|
||||
settings: {
|
||||
gatewayUrl: "",
|
||||
token: "",
|
||||
sessionKey: "main",
|
||||
lastActiveSessionKey: "main",
|
||||
theme: "system",
|
||||
chatFocusMode: false,
|
||||
chatShowThinking: true,
|
||||
splitRatio: 0.6,
|
||||
navCollapsed: false,
|
||||
navGroupsCollapsed: {},
|
||||
},
|
||||
theme: "system",
|
||||
themeResolved: "dark",
|
||||
applySessionKey: "main",
|
||||
sessionKey: "main",
|
||||
tab,
|
||||
connected: false,
|
||||
chatHasAutoScrolled: false,
|
||||
logsAtBottom: false,
|
||||
eventLog: [],
|
||||
eventLogBuffer: [],
|
||||
basePath: "",
|
||||
themeMedia: null,
|
||||
themeMediaHandler: null,
|
||||
logsPollInterval: null,
|
||||
debugPollInterval: null,
|
||||
});
|
||||
|
||||
describe("setTabFromRoute", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("starts and stops log polling based on the tab", () => {
|
||||
const host = createHost("chat");
|
||||
|
||||
setTabFromRoute(host, "logs");
|
||||
expect(host.logsPollInterval).not.toBeNull();
|
||||
expect(host.debugPollInterval).toBeNull();
|
||||
|
||||
setTabFromRoute(host, "chat");
|
||||
expect(host.logsPollInterval).toBeNull();
|
||||
});
|
||||
|
||||
it("starts and stops debug polling based on the tab", () => {
|
||||
const host = createHost("chat");
|
||||
|
||||
setTabFromRoute(host, "debug");
|
||||
expect(host.debugPollInterval).not.toBeNull();
|
||||
expect(host.logsPollInterval).toBeNull();
|
||||
|
||||
setTabFromRoute(host, "chat");
|
||||
expect(host.debugPollInterval).toBeNull();
|
||||
});
|
||||
});
|
||||
332
docker-compose/ez-assistant/ui/src/ui/app-settings.ts
Normal file
332
docker-compose/ez-assistant/ui/src/ui/app-settings.ts
Normal file
@@ -0,0 +1,332 @@
|
||||
import { loadConfig, loadConfigSchema } from "./controllers/config";
|
||||
import { loadCronJobs, loadCronStatus } from "./controllers/cron";
|
||||
import { loadChannels } from "./controllers/channels";
|
||||
import { loadDebug } from "./controllers/debug";
|
||||
import { loadLogs } from "./controllers/logs";
|
||||
import { loadDevices } from "./controllers/devices";
|
||||
import { loadNodes } from "./controllers/nodes";
|
||||
import { loadExecApprovals } from "./controllers/exec-approvals";
|
||||
import { loadPresence } from "./controllers/presence";
|
||||
import { loadSessions } from "./controllers/sessions";
|
||||
import { loadSkills } from "./controllers/skills";
|
||||
import { inferBasePathFromPathname, normalizeBasePath, normalizePath, pathForTab, tabFromPath, type Tab } from "./navigation";
|
||||
import { saveSettings, type UiSettings } from "./storage";
|
||||
import { resolveTheme, type ResolvedTheme, type ThemeMode } from "./theme";
|
||||
import { startThemeTransition, type ThemeTransitionContext } from "./theme-transition";
|
||||
import { scheduleChatScroll, scheduleLogsScroll } from "./app-scroll";
|
||||
import { startLogsPolling, stopLogsPolling, startDebugPolling, stopDebugPolling } from "./app-polling";
|
||||
import { refreshChat } from "./app-chat";
|
||||
import type { MoltbotApp } from "./app";
|
||||
|
||||
type SettingsHost = {
|
||||
settings: UiSettings;
|
||||
theme: ThemeMode;
|
||||
themeResolved: ResolvedTheme;
|
||||
applySessionKey: string;
|
||||
sessionKey: string;
|
||||
tab: Tab;
|
||||
connected: boolean;
|
||||
chatHasAutoScrolled: boolean;
|
||||
logsAtBottom: boolean;
|
||||
eventLog: unknown[];
|
||||
eventLogBuffer: unknown[];
|
||||
basePath: string;
|
||||
themeMedia: MediaQueryList | null;
|
||||
themeMediaHandler: ((event: MediaQueryListEvent) => void) | null;
|
||||
};
|
||||
|
||||
export function applySettings(host: SettingsHost, next: UiSettings) {
|
||||
const normalized = {
|
||||
...next,
|
||||
lastActiveSessionKey: next.lastActiveSessionKey?.trim() || next.sessionKey.trim() || "main",
|
||||
};
|
||||
host.settings = normalized;
|
||||
saveSettings(normalized);
|
||||
if (next.theme !== host.theme) {
|
||||
host.theme = next.theme;
|
||||
applyResolvedTheme(host, resolveTheme(next.theme));
|
||||
}
|
||||
host.applySessionKey = host.settings.lastActiveSessionKey;
|
||||
}
|
||||
|
||||
export function setLastActiveSessionKey(host: SettingsHost, next: string) {
|
||||
const trimmed = next.trim();
|
||||
if (!trimmed) return;
|
||||
if (host.settings.lastActiveSessionKey === trimmed) return;
|
||||
applySettings(host, { ...host.settings, lastActiveSessionKey: trimmed });
|
||||
}
|
||||
|
||||
export function applySettingsFromUrl(host: SettingsHost) {
|
||||
if (!window.location.search) return;
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const tokenRaw = params.get("token");
|
||||
const passwordRaw = params.get("password");
|
||||
const sessionRaw = params.get("session");
|
||||
const gatewayUrlRaw = params.get("gatewayUrl");
|
||||
let shouldCleanUrl = false;
|
||||
|
||||
if (tokenRaw != null) {
|
||||
const token = tokenRaw.trim();
|
||||
if (token && token !== host.settings.token) {
|
||||
applySettings(host, { ...host.settings, token });
|
||||
}
|
||||
params.delete("token");
|
||||
shouldCleanUrl = true;
|
||||
}
|
||||
|
||||
if (passwordRaw != null) {
|
||||
const password = passwordRaw.trim();
|
||||
if (password) {
|
||||
(host as { password: string }).password = password;
|
||||
}
|
||||
params.delete("password");
|
||||
shouldCleanUrl = true;
|
||||
}
|
||||
|
||||
if (sessionRaw != null) {
|
||||
const session = sessionRaw.trim();
|
||||
if (session) {
|
||||
host.sessionKey = session;
|
||||
applySettings(host, {
|
||||
...host.settings,
|
||||
sessionKey: session,
|
||||
lastActiveSessionKey: session,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (gatewayUrlRaw != null) {
|
||||
const gatewayUrl = gatewayUrlRaw.trim();
|
||||
if (gatewayUrl && gatewayUrl !== host.settings.gatewayUrl) {
|
||||
applySettings(host, { ...host.settings, gatewayUrl });
|
||||
}
|
||||
params.delete("gatewayUrl");
|
||||
shouldCleanUrl = true;
|
||||
}
|
||||
|
||||
if (!shouldCleanUrl) return;
|
||||
const url = new URL(window.location.href);
|
||||
url.search = params.toString();
|
||||
window.history.replaceState({}, "", url.toString());
|
||||
}
|
||||
|
||||
export function setTab(host: SettingsHost, next: Tab) {
|
||||
if (host.tab !== next) host.tab = next;
|
||||
if (next === "chat") host.chatHasAutoScrolled = false;
|
||||
if (next === "logs")
|
||||
startLogsPolling(host as unknown as Parameters<typeof startLogsPolling>[0]);
|
||||
else stopLogsPolling(host as unknown as Parameters<typeof stopLogsPolling>[0]);
|
||||
if (next === "debug")
|
||||
startDebugPolling(host as unknown as Parameters<typeof startDebugPolling>[0]);
|
||||
else stopDebugPolling(host as unknown as Parameters<typeof stopDebugPolling>[0]);
|
||||
void refreshActiveTab(host);
|
||||
syncUrlWithTab(host, next, false);
|
||||
}
|
||||
|
||||
export function setTheme(
|
||||
host: SettingsHost,
|
||||
next: ThemeMode,
|
||||
context?: ThemeTransitionContext,
|
||||
) {
|
||||
const applyTheme = () => {
|
||||
host.theme = next;
|
||||
applySettings(host, { ...host.settings, theme: next });
|
||||
applyResolvedTheme(host, resolveTheme(next));
|
||||
};
|
||||
startThemeTransition({
|
||||
nextTheme: next,
|
||||
applyTheme,
|
||||
context,
|
||||
currentTheme: host.theme,
|
||||
});
|
||||
}
|
||||
|
||||
export async function refreshActiveTab(host: SettingsHost) {
|
||||
if (host.tab === "overview") await loadOverview(host);
|
||||
if (host.tab === "channels") await loadChannelsTab(host);
|
||||
if (host.tab === "instances") await loadPresence(host as unknown as MoltbotApp);
|
||||
if (host.tab === "sessions") await loadSessions(host as unknown as MoltbotApp);
|
||||
if (host.tab === "cron") await loadCron(host);
|
||||
if (host.tab === "skills") await loadSkills(host as unknown as MoltbotApp);
|
||||
if (host.tab === "nodes") {
|
||||
await loadNodes(host as unknown as MoltbotApp);
|
||||
await loadDevices(host as unknown as MoltbotApp);
|
||||
await loadConfig(host as unknown as MoltbotApp);
|
||||
await loadExecApprovals(host as unknown as MoltbotApp);
|
||||
}
|
||||
if (host.tab === "chat") {
|
||||
await refreshChat(host as unknown as Parameters<typeof refreshChat>[0]);
|
||||
scheduleChatScroll(
|
||||
host as unknown as Parameters<typeof scheduleChatScroll>[0],
|
||||
!host.chatHasAutoScrolled,
|
||||
);
|
||||
}
|
||||
if (host.tab === "config") {
|
||||
await loadConfigSchema(host as unknown as MoltbotApp);
|
||||
await loadConfig(host as unknown as MoltbotApp);
|
||||
}
|
||||
if (host.tab === "debug") {
|
||||
await loadDebug(host as unknown as MoltbotApp);
|
||||
host.eventLog = host.eventLogBuffer;
|
||||
}
|
||||
if (host.tab === "logs") {
|
||||
host.logsAtBottom = true;
|
||||
await loadLogs(host as unknown as MoltbotApp, { reset: true });
|
||||
scheduleLogsScroll(
|
||||
host as unknown as Parameters<typeof scheduleLogsScroll>[0],
|
||||
true,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function inferBasePath() {
|
||||
if (typeof window === "undefined") return "";
|
||||
const configured = window.__CLAWDBOT_CONTROL_UI_BASE_PATH__;
|
||||
if (typeof configured === "string" && configured.trim()) {
|
||||
return normalizeBasePath(configured);
|
||||
}
|
||||
return inferBasePathFromPathname(window.location.pathname);
|
||||
}
|
||||
|
||||
export function syncThemeWithSettings(host: SettingsHost) {
|
||||
host.theme = host.settings.theme ?? "system";
|
||||
applyResolvedTheme(host, resolveTheme(host.theme));
|
||||
}
|
||||
|
||||
export function applyResolvedTheme(host: SettingsHost, resolved: ResolvedTheme) {
|
||||
host.themeResolved = resolved;
|
||||
if (typeof document === "undefined") return;
|
||||
const root = document.documentElement;
|
||||
root.dataset.theme = resolved;
|
||||
root.style.colorScheme = resolved;
|
||||
}
|
||||
|
||||
export function attachThemeListener(host: SettingsHost) {
|
||||
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return;
|
||||
host.themeMedia = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
host.themeMediaHandler = (event) => {
|
||||
if (host.theme !== "system") return;
|
||||
applyResolvedTheme(host, event.matches ? "dark" : "light");
|
||||
};
|
||||
if (typeof host.themeMedia.addEventListener === "function") {
|
||||
host.themeMedia.addEventListener("change", host.themeMediaHandler);
|
||||
return;
|
||||
}
|
||||
const legacy = host.themeMedia as MediaQueryList & {
|
||||
addListener: (cb: (event: MediaQueryListEvent) => void) => void;
|
||||
};
|
||||
legacy.addListener(host.themeMediaHandler);
|
||||
}
|
||||
|
||||
export function detachThemeListener(host: SettingsHost) {
|
||||
if (!host.themeMedia || !host.themeMediaHandler) return;
|
||||
if (typeof host.themeMedia.removeEventListener === "function") {
|
||||
host.themeMedia.removeEventListener("change", host.themeMediaHandler);
|
||||
return;
|
||||
}
|
||||
const legacy = host.themeMedia as MediaQueryList & {
|
||||
removeListener: (cb: (event: MediaQueryListEvent) => void) => void;
|
||||
};
|
||||
legacy.removeListener(host.themeMediaHandler);
|
||||
host.themeMedia = null;
|
||||
host.themeMediaHandler = null;
|
||||
}
|
||||
|
||||
export function syncTabWithLocation(host: SettingsHost, replace: boolean) {
|
||||
if (typeof window === "undefined") return;
|
||||
const resolved = tabFromPath(window.location.pathname, host.basePath) ?? "chat";
|
||||
setTabFromRoute(host, resolved);
|
||||
syncUrlWithTab(host, resolved, replace);
|
||||
}
|
||||
|
||||
export function onPopState(host: SettingsHost) {
|
||||
if (typeof window === "undefined") return;
|
||||
const resolved = tabFromPath(window.location.pathname, host.basePath);
|
||||
if (!resolved) return;
|
||||
|
||||
const url = new URL(window.location.href);
|
||||
const session = url.searchParams.get("session")?.trim();
|
||||
if (session) {
|
||||
host.sessionKey = session;
|
||||
applySettings(host, {
|
||||
...host.settings,
|
||||
sessionKey: session,
|
||||
lastActiveSessionKey: session,
|
||||
});
|
||||
}
|
||||
|
||||
setTabFromRoute(host, resolved);
|
||||
}
|
||||
|
||||
export function setTabFromRoute(host: SettingsHost, next: Tab) {
|
||||
if (host.tab !== next) host.tab = next;
|
||||
if (next === "chat") host.chatHasAutoScrolled = false;
|
||||
if (next === "logs")
|
||||
startLogsPolling(host as unknown as Parameters<typeof startLogsPolling>[0]);
|
||||
else stopLogsPolling(host as unknown as Parameters<typeof stopLogsPolling>[0]);
|
||||
if (next === "debug")
|
||||
startDebugPolling(host as unknown as Parameters<typeof startDebugPolling>[0]);
|
||||
else stopDebugPolling(host as unknown as Parameters<typeof stopDebugPolling>[0]);
|
||||
if (host.connected) void refreshActiveTab(host);
|
||||
}
|
||||
|
||||
export function syncUrlWithTab(host: SettingsHost, tab: Tab, replace: boolean) {
|
||||
if (typeof window === "undefined") return;
|
||||
const targetPath = normalizePath(pathForTab(tab, host.basePath));
|
||||
const currentPath = normalizePath(window.location.pathname);
|
||||
const url = new URL(window.location.href);
|
||||
|
||||
if (tab === "chat" && host.sessionKey) {
|
||||
url.searchParams.set("session", host.sessionKey);
|
||||
} else {
|
||||
url.searchParams.delete("session");
|
||||
}
|
||||
|
||||
if (currentPath !== targetPath) {
|
||||
url.pathname = targetPath;
|
||||
}
|
||||
|
||||
if (replace) {
|
||||
window.history.replaceState({}, "", url.toString());
|
||||
} else {
|
||||
window.history.pushState({}, "", url.toString());
|
||||
}
|
||||
}
|
||||
|
||||
export function syncUrlWithSessionKey(
|
||||
host: SettingsHost,
|
||||
sessionKey: string,
|
||||
replace: boolean,
|
||||
) {
|
||||
if (typeof window === "undefined") return;
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("session", sessionKey);
|
||||
if (replace) window.history.replaceState({}, "", url.toString());
|
||||
else window.history.pushState({}, "", url.toString());
|
||||
}
|
||||
|
||||
export async function loadOverview(host: SettingsHost) {
|
||||
await Promise.all([
|
||||
loadChannels(host as unknown as MoltbotApp, false),
|
||||
loadPresence(host as unknown as MoltbotApp),
|
||||
loadSessions(host as unknown as MoltbotApp),
|
||||
loadCronStatus(host as unknown as MoltbotApp),
|
||||
loadDebug(host as unknown as MoltbotApp),
|
||||
]);
|
||||
}
|
||||
|
||||
export async function loadChannelsTab(host: SettingsHost) {
|
||||
await Promise.all([
|
||||
loadChannels(host as unknown as MoltbotApp, true),
|
||||
loadConfigSchema(host as unknown as MoltbotApp),
|
||||
loadConfig(host as unknown as MoltbotApp),
|
||||
]);
|
||||
}
|
||||
|
||||
export async function loadCron(host: SettingsHost) {
|
||||
await Promise.all([
|
||||
loadChannels(host as unknown as MoltbotApp, false),
|
||||
loadCronStatus(host as unknown as MoltbotApp),
|
||||
loadCronJobs(host as unknown as MoltbotApp),
|
||||
]);
|
||||
}
|
||||
241
docker-compose/ez-assistant/ui/src/ui/app-tool-stream.ts
Normal file
241
docker-compose/ez-assistant/ui/src/ui/app-tool-stream.ts
Normal file
@@ -0,0 +1,241 @@
|
||||
import { truncateText } from "./format";
|
||||
|
||||
const TOOL_STREAM_LIMIT = 50;
|
||||
const TOOL_STREAM_THROTTLE_MS = 80;
|
||||
const TOOL_OUTPUT_CHAR_LIMIT = 120_000;
|
||||
|
||||
export type AgentEventPayload = {
|
||||
runId: string;
|
||||
seq: number;
|
||||
stream: string;
|
||||
ts: number;
|
||||
sessionKey?: string;
|
||||
data: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type ToolStreamEntry = {
|
||||
toolCallId: string;
|
||||
runId: string;
|
||||
sessionKey?: string;
|
||||
name: string;
|
||||
args?: unknown;
|
||||
output?: string;
|
||||
startedAt: number;
|
||||
updatedAt: number;
|
||||
message: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type ToolStreamHost = {
|
||||
sessionKey: string;
|
||||
chatRunId: string | null;
|
||||
toolStreamById: Map<string, ToolStreamEntry>;
|
||||
toolStreamOrder: string[];
|
||||
chatToolMessages: Record<string, unknown>[];
|
||||
toolStreamSyncTimer: number | null;
|
||||
};
|
||||
|
||||
function extractToolOutputText(value: unknown): string | null {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const record = value as Record<string, unknown>;
|
||||
if (typeof record.text === "string") return record.text;
|
||||
const content = record.content;
|
||||
if (!Array.isArray(content)) return null;
|
||||
const parts = content
|
||||
.map((item) => {
|
||||
if (!item || typeof item !== "object") return null;
|
||||
const entry = item as Record<string, unknown>;
|
||||
if (entry.type === "text" && typeof entry.text === "string") return entry.text;
|
||||
return null;
|
||||
})
|
||||
.filter((part): part is string => Boolean(part));
|
||||
if (parts.length === 0) return null;
|
||||
return parts.join("\n");
|
||||
}
|
||||
|
||||
function formatToolOutput(value: unknown): string | null {
|
||||
if (value === null || value === undefined) return null;
|
||||
if (typeof value === "number" || typeof value === "boolean") {
|
||||
return String(value);
|
||||
}
|
||||
const contentText = extractToolOutputText(value);
|
||||
let text: string;
|
||||
if (typeof value === "string") {
|
||||
text = value;
|
||||
} else if (contentText) {
|
||||
text = contentText;
|
||||
} else {
|
||||
try {
|
||||
text = JSON.stringify(value, null, 2);
|
||||
} catch {
|
||||
text = String(value);
|
||||
}
|
||||
}
|
||||
const truncated = truncateText(text, TOOL_OUTPUT_CHAR_LIMIT);
|
||||
if (!truncated.truncated) return truncated.text;
|
||||
return `${truncated.text}\n\n… truncated (${truncated.total} chars, showing first ${truncated.text.length}).`;
|
||||
}
|
||||
|
||||
function buildToolStreamMessage(entry: ToolStreamEntry): Record<string, unknown> {
|
||||
const content: Array<Record<string, unknown>> = [];
|
||||
content.push({
|
||||
type: "toolcall",
|
||||
name: entry.name,
|
||||
arguments: entry.args ?? {},
|
||||
});
|
||||
if (entry.output) {
|
||||
content.push({
|
||||
type: "toolresult",
|
||||
name: entry.name,
|
||||
text: entry.output,
|
||||
});
|
||||
}
|
||||
return {
|
||||
role: "assistant",
|
||||
toolCallId: entry.toolCallId,
|
||||
runId: entry.runId,
|
||||
content,
|
||||
timestamp: entry.startedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function trimToolStream(host: ToolStreamHost) {
|
||||
if (host.toolStreamOrder.length <= TOOL_STREAM_LIMIT) return;
|
||||
const overflow = host.toolStreamOrder.length - TOOL_STREAM_LIMIT;
|
||||
const removed = host.toolStreamOrder.splice(0, overflow);
|
||||
for (const id of removed) host.toolStreamById.delete(id);
|
||||
}
|
||||
|
||||
function syncToolStreamMessages(host: ToolStreamHost) {
|
||||
host.chatToolMessages = host.toolStreamOrder
|
||||
.map((id) => host.toolStreamById.get(id)?.message)
|
||||
.filter((msg): msg is Record<string, unknown> => Boolean(msg));
|
||||
}
|
||||
|
||||
export function flushToolStreamSync(host: ToolStreamHost) {
|
||||
if (host.toolStreamSyncTimer != null) {
|
||||
clearTimeout(host.toolStreamSyncTimer);
|
||||
host.toolStreamSyncTimer = null;
|
||||
}
|
||||
syncToolStreamMessages(host);
|
||||
}
|
||||
|
||||
export function scheduleToolStreamSync(host: ToolStreamHost, force = false) {
|
||||
if (force) {
|
||||
flushToolStreamSync(host);
|
||||
return;
|
||||
}
|
||||
if (host.toolStreamSyncTimer != null) return;
|
||||
host.toolStreamSyncTimer = window.setTimeout(
|
||||
() => flushToolStreamSync(host),
|
||||
TOOL_STREAM_THROTTLE_MS,
|
||||
);
|
||||
}
|
||||
|
||||
export function resetToolStream(host: ToolStreamHost) {
|
||||
host.toolStreamById.clear();
|
||||
host.toolStreamOrder = [];
|
||||
host.chatToolMessages = [];
|
||||
flushToolStreamSync(host);
|
||||
}
|
||||
|
||||
export type CompactionStatus = {
|
||||
active: boolean;
|
||||
startedAt: number | null;
|
||||
completedAt: number | null;
|
||||
};
|
||||
|
||||
type CompactionHost = ToolStreamHost & {
|
||||
compactionStatus?: CompactionStatus | null;
|
||||
compactionClearTimer?: number | null;
|
||||
};
|
||||
|
||||
const COMPACTION_TOAST_DURATION_MS = 5000;
|
||||
|
||||
export function handleCompactionEvent(host: CompactionHost, payload: AgentEventPayload) {
|
||||
const data = payload.data ?? {};
|
||||
const phase = typeof data.phase === "string" ? data.phase : "";
|
||||
|
||||
// Clear any existing timer
|
||||
if (host.compactionClearTimer != null) {
|
||||
window.clearTimeout(host.compactionClearTimer);
|
||||
host.compactionClearTimer = null;
|
||||
}
|
||||
|
||||
if (phase === "start") {
|
||||
host.compactionStatus = {
|
||||
active: true,
|
||||
startedAt: Date.now(),
|
||||
completedAt: null,
|
||||
};
|
||||
} else if (phase === "end") {
|
||||
host.compactionStatus = {
|
||||
active: false,
|
||||
startedAt: host.compactionStatus?.startedAt ?? null,
|
||||
completedAt: Date.now(),
|
||||
};
|
||||
// Auto-clear the toast after duration
|
||||
host.compactionClearTimer = window.setTimeout(() => {
|
||||
host.compactionStatus = null;
|
||||
host.compactionClearTimer = null;
|
||||
}, COMPACTION_TOAST_DURATION_MS);
|
||||
}
|
||||
}
|
||||
|
||||
export function handleAgentEvent(host: ToolStreamHost, payload?: AgentEventPayload) {
|
||||
if (!payload) return;
|
||||
|
||||
// Handle compaction events
|
||||
if (payload.stream === "compaction") {
|
||||
handleCompactionEvent(host as CompactionHost, payload);
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload.stream !== "tool") return;
|
||||
const sessionKey =
|
||||
typeof payload.sessionKey === "string" ? payload.sessionKey : undefined;
|
||||
if (sessionKey && sessionKey !== host.sessionKey) return;
|
||||
// Fallback: only accept session-less events for the active run.
|
||||
if (!sessionKey && host.chatRunId && payload.runId !== host.chatRunId) return;
|
||||
if (host.chatRunId && payload.runId !== host.chatRunId) return;
|
||||
if (!host.chatRunId) return;
|
||||
|
||||
const data = payload.data ?? {};
|
||||
const toolCallId = typeof data.toolCallId === "string" ? data.toolCallId : "";
|
||||
if (!toolCallId) return;
|
||||
const name = typeof data.name === "string" ? data.name : "tool";
|
||||
const phase = typeof data.phase === "string" ? data.phase : "";
|
||||
const args = phase === "start" ? data.args : undefined;
|
||||
const output =
|
||||
phase === "update"
|
||||
? formatToolOutput(data.partialResult)
|
||||
: phase === "result"
|
||||
? formatToolOutput(data.result)
|
||||
: undefined;
|
||||
|
||||
const now = Date.now();
|
||||
let entry = host.toolStreamById.get(toolCallId);
|
||||
if (!entry) {
|
||||
entry = {
|
||||
toolCallId,
|
||||
runId: payload.runId,
|
||||
sessionKey,
|
||||
name,
|
||||
args,
|
||||
output,
|
||||
startedAt: typeof payload.ts === "number" ? payload.ts : now,
|
||||
updatedAt: now,
|
||||
message: {},
|
||||
};
|
||||
host.toolStreamById.set(toolCallId, entry);
|
||||
host.toolStreamOrder.push(toolCallId);
|
||||
} else {
|
||||
entry.name = name;
|
||||
if (args !== undefined) entry.args = args;
|
||||
if (output !== undefined) entry.output = output;
|
||||
entry.updatedAt = now;
|
||||
}
|
||||
|
||||
entry.message = buildToolStreamMessage(entry);
|
||||
trimToolStream(host);
|
||||
scheduleToolStreamSync(host, phase === "result");
|
||||
}
|
||||
206
docker-compose/ez-assistant/ui/src/ui/app-view-state.ts
Normal file
206
docker-compose/ez-assistant/ui/src/ui/app-view-state.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
import type { GatewayBrowserClient, GatewayHelloOk } from "./gateway";
|
||||
import type { Tab } from "./navigation";
|
||||
import type { UiSettings } from "./storage";
|
||||
import type { ThemeMode } from "./theme";
|
||||
import type { ThemeTransitionContext } from "./theme-transition";
|
||||
import type {
|
||||
AgentsListResult,
|
||||
ChannelsStatusSnapshot,
|
||||
ConfigSnapshot,
|
||||
CronJob,
|
||||
CronRunLogEntry,
|
||||
CronStatus,
|
||||
HealthSnapshot,
|
||||
LogEntry,
|
||||
LogLevel,
|
||||
NostrProfile,
|
||||
PresenceEntry,
|
||||
SessionsListResult,
|
||||
SkillStatusReport,
|
||||
StatusSummary,
|
||||
} from "./types";
|
||||
import type { ChatAttachment, ChatQueueItem, CronFormState } from "./ui-types";
|
||||
import type { EventLogEntry } from "./app-events";
|
||||
import type { SkillMessage } from "./controllers/skills";
|
||||
import type {
|
||||
ExecApprovalsFile,
|
||||
ExecApprovalsSnapshot,
|
||||
} from "./controllers/exec-approvals";
|
||||
import type { DevicePairingList } from "./controllers/devices";
|
||||
import type { ExecApprovalRequest } from "./controllers/exec-approval";
|
||||
import type { NostrProfileFormState } from "./views/channels.nostr-profile-form";
|
||||
|
||||
export type AppViewState = {
|
||||
settings: UiSettings;
|
||||
password: string;
|
||||
tab: Tab;
|
||||
onboarding: boolean;
|
||||
basePath: string;
|
||||
connected: boolean;
|
||||
theme: ThemeMode;
|
||||
themeResolved: "light" | "dark";
|
||||
hello: GatewayHelloOk | null;
|
||||
lastError: string | null;
|
||||
eventLog: EventLogEntry[];
|
||||
assistantName: string;
|
||||
assistantAvatar: string | null;
|
||||
assistantAgentId: string | null;
|
||||
sessionKey: string;
|
||||
chatLoading: boolean;
|
||||
chatSending: boolean;
|
||||
chatMessage: string;
|
||||
chatAttachments: ChatAttachment[];
|
||||
chatMessages: unknown[];
|
||||
chatToolMessages: unknown[];
|
||||
chatStream: string | null;
|
||||
chatRunId: string | null;
|
||||
chatAvatarUrl: string | null;
|
||||
chatThinkingLevel: string | null;
|
||||
chatQueue: ChatQueueItem[];
|
||||
nodesLoading: boolean;
|
||||
nodes: Array<Record<string, unknown>>;
|
||||
devicesLoading: boolean;
|
||||
devicesError: string | null;
|
||||
devicesList: DevicePairingList | null;
|
||||
execApprovalsLoading: boolean;
|
||||
execApprovalsSaving: boolean;
|
||||
execApprovalsDirty: boolean;
|
||||
execApprovalsSnapshot: ExecApprovalsSnapshot | null;
|
||||
execApprovalsForm: ExecApprovalsFile | null;
|
||||
execApprovalsSelectedAgent: string | null;
|
||||
execApprovalsTarget: "gateway" | "node";
|
||||
execApprovalsTargetNodeId: string | null;
|
||||
execApprovalQueue: ExecApprovalRequest[];
|
||||
execApprovalBusy: boolean;
|
||||
execApprovalError: string | null;
|
||||
configLoading: boolean;
|
||||
configRaw: string;
|
||||
configRawOriginal: string;
|
||||
configValid: boolean | null;
|
||||
configIssues: unknown[];
|
||||
configSaving: boolean;
|
||||
configApplying: boolean;
|
||||
updateRunning: boolean;
|
||||
configSnapshot: ConfigSnapshot | null;
|
||||
configSchema: unknown | null;
|
||||
configSchemaLoading: boolean;
|
||||
configUiHints: Record<string, unknown>;
|
||||
configForm: Record<string, unknown> | null;
|
||||
configFormOriginal: Record<string, unknown> | null;
|
||||
configFormMode: "form" | "raw";
|
||||
channelsLoading: boolean;
|
||||
channelsSnapshot: ChannelsStatusSnapshot | null;
|
||||
channelsError: string | null;
|
||||
channelsLastSuccess: number | null;
|
||||
whatsappLoginMessage: string | null;
|
||||
whatsappLoginQrDataUrl: string | null;
|
||||
whatsappLoginConnected: boolean | null;
|
||||
whatsappBusy: boolean;
|
||||
nostrProfileFormState: NostrProfileFormState | null;
|
||||
nostrProfileAccountId: string | null;
|
||||
configFormDirty: boolean;
|
||||
presenceLoading: boolean;
|
||||
presenceEntries: PresenceEntry[];
|
||||
presenceError: string | null;
|
||||
presenceStatus: string | null;
|
||||
agentsLoading: boolean;
|
||||
agentsList: AgentsListResult | null;
|
||||
agentsError: string | null;
|
||||
sessionsLoading: boolean;
|
||||
sessionsResult: SessionsListResult | null;
|
||||
sessionsError: string | null;
|
||||
sessionsFilterActive: string;
|
||||
sessionsFilterLimit: string;
|
||||
sessionsIncludeGlobal: boolean;
|
||||
sessionsIncludeUnknown: boolean;
|
||||
cronLoading: boolean;
|
||||
cronJobs: CronJob[];
|
||||
cronStatus: CronStatus | null;
|
||||
cronError: string | null;
|
||||
cronForm: CronFormState;
|
||||
cronRunsJobId: string | null;
|
||||
cronRuns: CronRunLogEntry[];
|
||||
cronBusy: boolean;
|
||||
skillsLoading: boolean;
|
||||
skillsReport: SkillStatusReport | null;
|
||||
skillsError: string | null;
|
||||
skillsFilter: string;
|
||||
skillEdits: Record<string, string>;
|
||||
skillMessages: Record<string, SkillMessage>;
|
||||
skillsBusyKey: string | null;
|
||||
debugLoading: boolean;
|
||||
debugStatus: StatusSummary | null;
|
||||
debugHealth: HealthSnapshot | null;
|
||||
debugModels: unknown[];
|
||||
debugHeartbeat: unknown | null;
|
||||
debugCallMethod: string;
|
||||
debugCallParams: string;
|
||||
debugCallResult: string | null;
|
||||
debugCallError: string | null;
|
||||
logsLoading: boolean;
|
||||
logsError: string | null;
|
||||
logsFile: string | null;
|
||||
logsEntries: LogEntry[];
|
||||
logsFilterText: string;
|
||||
logsLevelFilters: Record<LogLevel, boolean>;
|
||||
logsAutoFollow: boolean;
|
||||
logsTruncated: boolean;
|
||||
client: GatewayBrowserClient | null;
|
||||
connect: () => void;
|
||||
setTab: (tab: Tab) => void;
|
||||
setTheme: (theme: ThemeMode, context?: ThemeTransitionContext) => void;
|
||||
applySettings: (next: UiSettings) => void;
|
||||
loadOverview: () => Promise<void>;
|
||||
loadAssistantIdentity: () => Promise<void>;
|
||||
loadCron: () => Promise<void>;
|
||||
handleWhatsAppStart: (force: boolean) => Promise<void>;
|
||||
handleWhatsAppWait: () => Promise<void>;
|
||||
handleWhatsAppLogout: () => Promise<void>;
|
||||
handleChannelConfigSave: () => Promise<void>;
|
||||
handleChannelConfigReload: () => Promise<void>;
|
||||
handleNostrProfileEdit: (accountId: string, profile: NostrProfile | null) => void;
|
||||
handleNostrProfileCancel: () => void;
|
||||
handleNostrProfileFieldChange: (field: keyof NostrProfile, value: string) => void;
|
||||
handleNostrProfileSave: () => Promise<void>;
|
||||
handleNostrProfileImport: () => Promise<void>;
|
||||
handleNostrProfileToggleAdvanced: () => void;
|
||||
handleExecApprovalDecision: (decision: "allow-once" | "allow-always" | "deny") => Promise<void>;
|
||||
handleConfigLoad: () => Promise<void>;
|
||||
handleConfigSave: () => Promise<void>;
|
||||
handleConfigApply: () => Promise<void>;
|
||||
handleConfigFormUpdate: (path: string, value: unknown) => void;
|
||||
handleConfigFormModeChange: (mode: "form" | "raw") => void;
|
||||
handleConfigRawChange: (raw: string) => void;
|
||||
handleInstallSkill: (key: string) => Promise<void>;
|
||||
handleUpdateSkill: (key: string) => Promise<void>;
|
||||
handleToggleSkillEnabled: (key: string, enabled: boolean) => Promise<void>;
|
||||
handleUpdateSkillEdit: (key: string, value: string) => void;
|
||||
handleSaveSkillApiKey: (key: string, apiKey: string) => Promise<void>;
|
||||
handleCronToggle: (jobId: string, enabled: boolean) => Promise<void>;
|
||||
handleCronRun: (jobId: string) => Promise<void>;
|
||||
handleCronRemove: (jobId: string) => Promise<void>;
|
||||
handleCronAdd: () => Promise<void>;
|
||||
handleCronRunsLoad: (jobId: string) => Promise<void>;
|
||||
handleCronFormUpdate: (path: string, value: unknown) => void;
|
||||
handleSessionsLoad: () => Promise<void>;
|
||||
handleSessionsPatch: (key: string, patch: unknown) => Promise<void>;
|
||||
handleLoadNodes: () => Promise<void>;
|
||||
handleLoadPresence: () => Promise<void>;
|
||||
handleLoadSkills: () => Promise<void>;
|
||||
handleLoadDebug: () => Promise<void>;
|
||||
handleLoadLogs: () => Promise<void>;
|
||||
handleDebugCall: () => Promise<void>;
|
||||
handleRunUpdate: () => Promise<void>;
|
||||
setPassword: (next: string) => void;
|
||||
setSessionKey: (next: string) => void;
|
||||
setChatMessage: (next: string) => void;
|
||||
handleChatSend: () => Promise<void>;
|
||||
handleChatAbort: () => Promise<void>;
|
||||
handleChatSelectQueueItem: (id: string) => void;
|
||||
handleChatDropQueueItem: (id: string) => void;
|
||||
handleChatClearQueue: () => void;
|
||||
handleLogsFilterChange: (next: string) => void;
|
||||
handleLogsLevelFilterToggle: (level: LogLevel) => void;
|
||||
handleLogsAutoFollowToggle: (next: boolean) => void;
|
||||
handleCallDebugMethod: (method: string, params: string) => Promise<void>;
|
||||
};
|
||||
485
docker-compose/ez-assistant/ui/src/ui/app.ts
Normal file
485
docker-compose/ez-assistant/ui/src/ui/app.ts
Normal file
@@ -0,0 +1,485 @@
|
||||
import { LitElement, html, nothing } from "lit";
|
||||
import { customElement, state } from "lit/decorators.js";
|
||||
|
||||
import type { GatewayBrowserClient, GatewayHelloOk } from "./gateway";
|
||||
import { resolveInjectedAssistantIdentity } from "./assistant-identity";
|
||||
import { loadSettings, type UiSettings } from "./storage";
|
||||
import { renderApp } from "./app-render";
|
||||
import type { Tab } from "./navigation";
|
||||
import type { ResolvedTheme, ThemeMode } from "./theme";
|
||||
import type {
|
||||
AgentsListResult,
|
||||
ConfigSnapshot,
|
||||
ConfigUiHints,
|
||||
CronJob,
|
||||
CronRunLogEntry,
|
||||
CronStatus,
|
||||
HealthSnapshot,
|
||||
LogEntry,
|
||||
LogLevel,
|
||||
PresenceEntry,
|
||||
ChannelsStatusSnapshot,
|
||||
SessionsListResult,
|
||||
SkillStatusReport,
|
||||
StatusSummary,
|
||||
NostrProfile,
|
||||
} from "./types";
|
||||
import { type ChatAttachment, type ChatQueueItem, type CronFormState } from "./ui-types";
|
||||
import type { EventLogEntry } from "./app-events";
|
||||
import { DEFAULT_CRON_FORM, DEFAULT_LOG_LEVEL_FILTERS } from "./app-defaults";
|
||||
import type {
|
||||
ExecApprovalsFile,
|
||||
ExecApprovalsSnapshot,
|
||||
} from "./controllers/exec-approvals";
|
||||
import type { DevicePairingList } from "./controllers/devices";
|
||||
import type { ExecApprovalRequest } from "./controllers/exec-approval";
|
||||
import {
|
||||
resetToolStream as resetToolStreamInternal,
|
||||
type ToolStreamEntry,
|
||||
} from "./app-tool-stream";
|
||||
import {
|
||||
exportLogs as exportLogsInternal,
|
||||
handleChatScroll as handleChatScrollInternal,
|
||||
handleLogsScroll as handleLogsScrollInternal,
|
||||
resetChatScroll as resetChatScrollInternal,
|
||||
} from "./app-scroll";
|
||||
import { connectGateway as connectGatewayInternal } from "./app-gateway";
|
||||
import {
|
||||
handleConnected,
|
||||
handleDisconnected,
|
||||
handleFirstUpdated,
|
||||
handleUpdated,
|
||||
} from "./app-lifecycle";
|
||||
import {
|
||||
applySettings as applySettingsInternal,
|
||||
loadCron as loadCronInternal,
|
||||
loadOverview as loadOverviewInternal,
|
||||
setTab as setTabInternal,
|
||||
setTheme as setThemeInternal,
|
||||
onPopState as onPopStateInternal,
|
||||
} from "./app-settings";
|
||||
import {
|
||||
handleAbortChat as handleAbortChatInternal,
|
||||
handleSendChat as handleSendChatInternal,
|
||||
removeQueuedMessage as removeQueuedMessageInternal,
|
||||
} from "./app-chat";
|
||||
import {
|
||||
handleChannelConfigReload as handleChannelConfigReloadInternal,
|
||||
handleChannelConfigSave as handleChannelConfigSaveInternal,
|
||||
handleNostrProfileCancel as handleNostrProfileCancelInternal,
|
||||
handleNostrProfileEdit as handleNostrProfileEditInternal,
|
||||
handleNostrProfileFieldChange as handleNostrProfileFieldChangeInternal,
|
||||
handleNostrProfileImport as handleNostrProfileImportInternal,
|
||||
handleNostrProfileSave as handleNostrProfileSaveInternal,
|
||||
handleNostrProfileToggleAdvanced as handleNostrProfileToggleAdvancedInternal,
|
||||
handleWhatsAppLogout as handleWhatsAppLogoutInternal,
|
||||
handleWhatsAppStart as handleWhatsAppStartInternal,
|
||||
handleWhatsAppWait as handleWhatsAppWaitInternal,
|
||||
} from "./app-channels";
|
||||
import type { NostrProfileFormState } from "./views/channels.nostr-profile-form";
|
||||
import { loadAssistantIdentity as loadAssistantIdentityInternal } from "./controllers/assistant-identity";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__CLAWDBOT_CONTROL_UI_BASE_PATH__?: string;
|
||||
}
|
||||
}
|
||||
|
||||
const injectedAssistantIdentity = resolveInjectedAssistantIdentity();
|
||||
|
||||
function resolveOnboardingMode(): boolean {
|
||||
if (!window.location.search) return false;
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const raw = params.get("onboarding");
|
||||
if (!raw) return false;
|
||||
const normalized = raw.trim().toLowerCase();
|
||||
return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on";
|
||||
}
|
||||
|
||||
@customElement("moltbot-app")
|
||||
export class MoltbotApp extends LitElement {
|
||||
@state() settings: UiSettings = loadSettings();
|
||||
@state() password = "";
|
||||
@state() tab: Tab = "chat";
|
||||
@state() onboarding = resolveOnboardingMode();
|
||||
@state() connected = false;
|
||||
@state() theme: ThemeMode = this.settings.theme ?? "system";
|
||||
@state() themeResolved: ResolvedTheme = "dark";
|
||||
@state() hello: GatewayHelloOk | null = null;
|
||||
@state() lastError: string | null = null;
|
||||
@state() eventLog: EventLogEntry[] = [];
|
||||
private eventLogBuffer: EventLogEntry[] = [];
|
||||
private toolStreamSyncTimer: number | null = null;
|
||||
private sidebarCloseTimer: number | null = null;
|
||||
|
||||
@state() assistantName = injectedAssistantIdentity.name;
|
||||
@state() assistantAvatar = injectedAssistantIdentity.avatar;
|
||||
@state() assistantAgentId = injectedAssistantIdentity.agentId ?? null;
|
||||
|
||||
@state() sessionKey = this.settings.sessionKey;
|
||||
@state() chatLoading = false;
|
||||
@state() chatSending = false;
|
||||
@state() chatMessage = "";
|
||||
@state() chatMessages: unknown[] = [];
|
||||
@state() chatToolMessages: unknown[] = [];
|
||||
@state() chatStream: string | null = null;
|
||||
@state() chatStreamStartedAt: number | null = null;
|
||||
@state() chatRunId: string | null = null;
|
||||
@state() compactionStatus: import("./app-tool-stream").CompactionStatus | null = null;
|
||||
@state() chatAvatarUrl: string | null = null;
|
||||
@state() chatThinkingLevel: string | null = null;
|
||||
@state() chatQueue: ChatQueueItem[] = [];
|
||||
@state() chatAttachments: ChatAttachment[] = [];
|
||||
// Sidebar state for tool output viewing
|
||||
@state() sidebarOpen = false;
|
||||
@state() sidebarContent: string | null = null;
|
||||
@state() sidebarError: string | null = null;
|
||||
@state() splitRatio = this.settings.splitRatio;
|
||||
|
||||
@state() nodesLoading = false;
|
||||
@state() nodes: Array<Record<string, unknown>> = [];
|
||||
@state() devicesLoading = false;
|
||||
@state() devicesError: string | null = null;
|
||||
@state() devicesList: DevicePairingList | null = null;
|
||||
@state() execApprovalsLoading = false;
|
||||
@state() execApprovalsSaving = false;
|
||||
@state() execApprovalsDirty = false;
|
||||
@state() execApprovalsSnapshot: ExecApprovalsSnapshot | null = null;
|
||||
@state() execApprovalsForm: ExecApprovalsFile | null = null;
|
||||
@state() execApprovalsSelectedAgent: string | null = null;
|
||||
@state() execApprovalsTarget: "gateway" | "node" = "gateway";
|
||||
@state() execApprovalsTargetNodeId: string | null = null;
|
||||
@state() execApprovalQueue: ExecApprovalRequest[] = [];
|
||||
@state() execApprovalBusy = false;
|
||||
@state() execApprovalError: string | null = null;
|
||||
|
||||
@state() configLoading = false;
|
||||
@state() configRaw = "{\n}\n";
|
||||
@state() configRawOriginal = "";
|
||||
@state() configValid: boolean | null = null;
|
||||
@state() configIssues: unknown[] = [];
|
||||
@state() configSaving = false;
|
||||
@state() configApplying = false;
|
||||
@state() updateRunning = false;
|
||||
@state() applySessionKey = this.settings.lastActiveSessionKey;
|
||||
@state() configSnapshot: ConfigSnapshot | null = null;
|
||||
@state() configSchema: unknown | null = null;
|
||||
@state() configSchemaVersion: string | null = null;
|
||||
@state() configSchemaLoading = false;
|
||||
@state() configUiHints: ConfigUiHints = {};
|
||||
@state() configForm: Record<string, unknown> | null = null;
|
||||
@state() configFormOriginal: Record<string, unknown> | null = null;
|
||||
@state() configFormDirty = false;
|
||||
@state() configFormMode: "form" | "raw" = "form";
|
||||
@state() configSearchQuery = "";
|
||||
@state() configActiveSection: string | null = null;
|
||||
@state() configActiveSubsection: string | null = null;
|
||||
|
||||
@state() channelsLoading = false;
|
||||
@state() channelsSnapshot: ChannelsStatusSnapshot | null = null;
|
||||
@state() channelsError: string | null = null;
|
||||
@state() channelsLastSuccess: number | null = null;
|
||||
@state() whatsappLoginMessage: string | null = null;
|
||||
@state() whatsappLoginQrDataUrl: string | null = null;
|
||||
@state() whatsappLoginConnected: boolean | null = null;
|
||||
@state() whatsappBusy = false;
|
||||
@state() nostrProfileFormState: NostrProfileFormState | null = null;
|
||||
@state() nostrProfileAccountId: string | null = null;
|
||||
|
||||
@state() presenceLoading = false;
|
||||
@state() presenceEntries: PresenceEntry[] = [];
|
||||
@state() presenceError: string | null = null;
|
||||
@state() presenceStatus: string | null = null;
|
||||
|
||||
@state() agentsLoading = false;
|
||||
@state() agentsList: AgentsListResult | null = null;
|
||||
@state() agentsError: string | null = null;
|
||||
|
||||
@state() sessionsLoading = false;
|
||||
@state() sessionsResult: SessionsListResult | null = null;
|
||||
@state() sessionsError: string | null = null;
|
||||
@state() sessionsFilterActive = "";
|
||||
@state() sessionsFilterLimit = "120";
|
||||
@state() sessionsIncludeGlobal = true;
|
||||
@state() sessionsIncludeUnknown = false;
|
||||
|
||||
@state() cronLoading = false;
|
||||
@state() cronJobs: CronJob[] = [];
|
||||
@state() cronStatus: CronStatus | null = null;
|
||||
@state() cronError: string | null = null;
|
||||
@state() cronForm: CronFormState = { ...DEFAULT_CRON_FORM };
|
||||
@state() cronRunsJobId: string | null = null;
|
||||
@state() cronRuns: CronRunLogEntry[] = [];
|
||||
@state() cronBusy = false;
|
||||
|
||||
@state() skillsLoading = false;
|
||||
@state() skillsReport: SkillStatusReport | null = null;
|
||||
@state() skillsError: string | null = null;
|
||||
@state() skillsFilter = "";
|
||||
@state() skillEdits: Record<string, string> = {};
|
||||
@state() skillsBusyKey: string | null = null;
|
||||
@state() skillMessages: Record<string, SkillMessage> = {};
|
||||
|
||||
@state() debugLoading = false;
|
||||
@state() debugStatus: StatusSummary | null = null;
|
||||
@state() debugHealth: HealthSnapshot | null = null;
|
||||
@state() debugModels: unknown[] = [];
|
||||
@state() debugHeartbeat: unknown | null = null;
|
||||
@state() debugCallMethod = "";
|
||||
@state() debugCallParams = "{}";
|
||||
@state() debugCallResult: string | null = null;
|
||||
@state() debugCallError: string | null = null;
|
||||
|
||||
@state() logsLoading = false;
|
||||
@state() logsError: string | null = null;
|
||||
@state() logsFile: string | null = null;
|
||||
@state() logsEntries: LogEntry[] = [];
|
||||
@state() logsFilterText = "";
|
||||
@state() logsLevelFilters: Record<LogLevel, boolean> = {
|
||||
...DEFAULT_LOG_LEVEL_FILTERS,
|
||||
};
|
||||
@state() logsAutoFollow = true;
|
||||
@state() logsTruncated = false;
|
||||
@state() logsCursor: number | null = null;
|
||||
@state() logsLastFetchAt: number | null = null;
|
||||
@state() logsLimit = 500;
|
||||
@state() logsMaxBytes = 250_000;
|
||||
@state() logsAtBottom = true;
|
||||
|
||||
client: GatewayBrowserClient | null = null;
|
||||
private chatScrollFrame: number | null = null;
|
||||
private chatScrollTimeout: number | null = null;
|
||||
private chatHasAutoScrolled = false;
|
||||
private chatUserNearBottom = true;
|
||||
private nodesPollInterval: number | null = null;
|
||||
private logsPollInterval: number | null = null;
|
||||
private debugPollInterval: number | null = null;
|
||||
private logsScrollFrame: number | null = null;
|
||||
private toolStreamById = new Map<string, ToolStreamEntry>();
|
||||
private toolStreamOrder: string[] = [];
|
||||
basePath = "";
|
||||
private popStateHandler = () =>
|
||||
onPopStateInternal(
|
||||
this as unknown as Parameters<typeof onPopStateInternal>[0],
|
||||
);
|
||||
private themeMedia: MediaQueryList | null = null;
|
||||
private themeMediaHandler: ((event: MediaQueryListEvent) => void) | null = null;
|
||||
private topbarObserver: ResizeObserver | null = null;
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
handleConnected(this as unknown as Parameters<typeof handleConnected>[0]);
|
||||
}
|
||||
|
||||
protected firstUpdated() {
|
||||
handleFirstUpdated(this as unknown as Parameters<typeof handleFirstUpdated>[0]);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
handleDisconnected(this as unknown as Parameters<typeof handleDisconnected>[0]);
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
protected updated(changed: Map<PropertyKey, unknown>) {
|
||||
handleUpdated(
|
||||
this as unknown as Parameters<typeof handleUpdated>[0],
|
||||
changed,
|
||||
);
|
||||
}
|
||||
|
||||
connect() {
|
||||
connectGatewayInternal(
|
||||
this as unknown as Parameters<typeof connectGatewayInternal>[0],
|
||||
);
|
||||
}
|
||||
|
||||
handleChatScroll(event: Event) {
|
||||
handleChatScrollInternal(
|
||||
this as unknown as Parameters<typeof handleChatScrollInternal>[0],
|
||||
event,
|
||||
);
|
||||
}
|
||||
|
||||
handleLogsScroll(event: Event) {
|
||||
handleLogsScrollInternal(
|
||||
this as unknown as Parameters<typeof handleLogsScrollInternal>[0],
|
||||
event,
|
||||
);
|
||||
}
|
||||
|
||||
exportLogs(lines: string[], label: string) {
|
||||
exportLogsInternal(lines, label);
|
||||
}
|
||||
|
||||
resetToolStream() {
|
||||
resetToolStreamInternal(
|
||||
this as unknown as Parameters<typeof resetToolStreamInternal>[0],
|
||||
);
|
||||
}
|
||||
|
||||
resetChatScroll() {
|
||||
resetChatScrollInternal(
|
||||
this as unknown as Parameters<typeof resetChatScrollInternal>[0],
|
||||
);
|
||||
}
|
||||
|
||||
async loadAssistantIdentity() {
|
||||
await loadAssistantIdentityInternal(this);
|
||||
}
|
||||
|
||||
applySettings(next: UiSettings) {
|
||||
applySettingsInternal(
|
||||
this as unknown as Parameters<typeof applySettingsInternal>[0],
|
||||
next,
|
||||
);
|
||||
}
|
||||
|
||||
setTab(next: Tab) {
|
||||
setTabInternal(this as unknown as Parameters<typeof setTabInternal>[0], next);
|
||||
}
|
||||
|
||||
setTheme(next: ThemeMode, context?: Parameters<typeof setThemeInternal>[2]) {
|
||||
setThemeInternal(
|
||||
this as unknown as Parameters<typeof setThemeInternal>[0],
|
||||
next,
|
||||
context,
|
||||
);
|
||||
}
|
||||
|
||||
async loadOverview() {
|
||||
await loadOverviewInternal(
|
||||
this as unknown as Parameters<typeof loadOverviewInternal>[0],
|
||||
);
|
||||
}
|
||||
|
||||
async loadCron() {
|
||||
await loadCronInternal(
|
||||
this as unknown as Parameters<typeof loadCronInternal>[0],
|
||||
);
|
||||
}
|
||||
|
||||
async handleAbortChat() {
|
||||
await handleAbortChatInternal(
|
||||
this as unknown as Parameters<typeof handleAbortChatInternal>[0],
|
||||
);
|
||||
}
|
||||
|
||||
removeQueuedMessage(id: string) {
|
||||
removeQueuedMessageInternal(
|
||||
this as unknown as Parameters<typeof removeQueuedMessageInternal>[0],
|
||||
id,
|
||||
);
|
||||
}
|
||||
|
||||
async handleSendChat(
|
||||
messageOverride?: string,
|
||||
opts?: Parameters<typeof handleSendChatInternal>[2],
|
||||
) {
|
||||
await handleSendChatInternal(
|
||||
this as unknown as Parameters<typeof handleSendChatInternal>[0],
|
||||
messageOverride,
|
||||
opts,
|
||||
);
|
||||
}
|
||||
|
||||
async handleWhatsAppStart(force: boolean) {
|
||||
await handleWhatsAppStartInternal(this, force);
|
||||
}
|
||||
|
||||
async handleWhatsAppWait() {
|
||||
await handleWhatsAppWaitInternal(this);
|
||||
}
|
||||
|
||||
async handleWhatsAppLogout() {
|
||||
await handleWhatsAppLogoutInternal(this);
|
||||
}
|
||||
|
||||
async handleChannelConfigSave() {
|
||||
await handleChannelConfigSaveInternal(this);
|
||||
}
|
||||
|
||||
async handleChannelConfigReload() {
|
||||
await handleChannelConfigReloadInternal(this);
|
||||
}
|
||||
|
||||
handleNostrProfileEdit(accountId: string, profile: NostrProfile | null) {
|
||||
handleNostrProfileEditInternal(this, accountId, profile);
|
||||
}
|
||||
|
||||
handleNostrProfileCancel() {
|
||||
handleNostrProfileCancelInternal(this);
|
||||
}
|
||||
|
||||
handleNostrProfileFieldChange(field: keyof NostrProfile, value: string) {
|
||||
handleNostrProfileFieldChangeInternal(this, field, value);
|
||||
}
|
||||
|
||||
async handleNostrProfileSave() {
|
||||
await handleNostrProfileSaveInternal(this);
|
||||
}
|
||||
|
||||
async handleNostrProfileImport() {
|
||||
await handleNostrProfileImportInternal(this);
|
||||
}
|
||||
|
||||
handleNostrProfileToggleAdvanced() {
|
||||
handleNostrProfileToggleAdvancedInternal(this);
|
||||
}
|
||||
|
||||
async handleExecApprovalDecision(decision: "allow-once" | "allow-always" | "deny") {
|
||||
const active = this.execApprovalQueue[0];
|
||||
if (!active || !this.client || this.execApprovalBusy) return;
|
||||
this.execApprovalBusy = true;
|
||||
this.execApprovalError = null;
|
||||
try {
|
||||
await this.client.request("exec.approval.resolve", {
|
||||
id: active.id,
|
||||
decision,
|
||||
});
|
||||
this.execApprovalQueue = this.execApprovalQueue.filter((entry) => entry.id !== active.id);
|
||||
} catch (err) {
|
||||
this.execApprovalError = `Exec approval failed: ${String(err)}`;
|
||||
} finally {
|
||||
this.execApprovalBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Sidebar handlers for tool output viewing
|
||||
handleOpenSidebar(content: string) {
|
||||
if (this.sidebarCloseTimer != null) {
|
||||
window.clearTimeout(this.sidebarCloseTimer);
|
||||
this.sidebarCloseTimer = null;
|
||||
}
|
||||
this.sidebarContent = content;
|
||||
this.sidebarError = null;
|
||||
this.sidebarOpen = true;
|
||||
}
|
||||
|
||||
handleCloseSidebar() {
|
||||
this.sidebarOpen = false;
|
||||
// Clear content after transition
|
||||
if (this.sidebarCloseTimer != null) {
|
||||
window.clearTimeout(this.sidebarCloseTimer);
|
||||
}
|
||||
this.sidebarCloseTimer = window.setTimeout(() => {
|
||||
if (this.sidebarOpen) return;
|
||||
this.sidebarContent = null;
|
||||
this.sidebarError = null;
|
||||
this.sidebarCloseTimer = null;
|
||||
}, 200);
|
||||
}
|
||||
|
||||
handleSplitRatioChange(ratio: number) {
|
||||
const newRatio = Math.max(0.4, Math.min(0.7, ratio));
|
||||
this.splitRatio = newRatio;
|
||||
this.applySettings({ ...this.settings, splitRatio: newRatio });
|
||||
}
|
||||
|
||||
render() {
|
||||
return renderApp(this);
|
||||
}
|
||||
}
|
||||
49
docker-compose/ez-assistant/ui/src/ui/assistant-identity.ts
Normal file
49
docker-compose/ez-assistant/ui/src/ui/assistant-identity.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
const MAX_ASSISTANT_NAME = 50;
|
||||
const MAX_ASSISTANT_AVATAR = 200;
|
||||
|
||||
export const DEFAULT_ASSISTANT_NAME = "Assistant";
|
||||
export const DEFAULT_ASSISTANT_AVATAR = "A";
|
||||
|
||||
export type AssistantIdentity = {
|
||||
agentId?: string | null;
|
||||
name: string;
|
||||
avatar: string | null;
|
||||
};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__CLAWDBOT_ASSISTANT_NAME__?: string;
|
||||
__CLAWDBOT_ASSISTANT_AVATAR__?: string;
|
||||
}
|
||||
}
|
||||
|
||||
function coerceIdentityValue(value: string | undefined, maxLength: number): string | undefined {
|
||||
if (typeof value !== "string") return undefined;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return undefined;
|
||||
if (trimmed.length <= maxLength) return trimmed;
|
||||
return trimmed.slice(0, maxLength);
|
||||
}
|
||||
|
||||
export function normalizeAssistantIdentity(
|
||||
input?: Partial<AssistantIdentity> | null,
|
||||
): AssistantIdentity {
|
||||
const name =
|
||||
coerceIdentityValue(input?.name, MAX_ASSISTANT_NAME) ?? DEFAULT_ASSISTANT_NAME;
|
||||
const avatar = coerceIdentityValue(input?.avatar ?? undefined, MAX_ASSISTANT_AVATAR) ?? null;
|
||||
const agentId =
|
||||
typeof input?.agentId === "string" && input.agentId.trim()
|
||||
? input.agentId.trim()
|
||||
: null;
|
||||
return { agentId, name, avatar };
|
||||
}
|
||||
|
||||
export function resolveInjectedAssistantIdentity(): AssistantIdentity {
|
||||
if (typeof window === "undefined") {
|
||||
return normalizeAssistantIdentity({});
|
||||
}
|
||||
return normalizeAssistantIdentity({
|
||||
name: window.__CLAWDBOT_ASSISTANT_NAME__,
|
||||
avatar: window.__CLAWDBOT_ASSISTANT_AVATAR__,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { MoltbotApp } from "./app";
|
||||
|
||||
const originalConnect = MoltbotApp.prototype.connect;
|
||||
|
||||
function mountApp(pathname: string) {
|
||||
window.history.replaceState({}, "", pathname);
|
||||
const app = document.createElement("moltbot-app") as MoltbotApp;
|
||||
document.body.append(app);
|
||||
return app;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
MoltbotApp.prototype.connect = () => {
|
||||
// no-op: avoid real gateway WS connections in browser tests
|
||||
};
|
||||
window.__CLAWDBOT_CONTROL_UI_BASE_PATH__ = undefined;
|
||||
localStorage.clear();
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
MoltbotApp.prototype.connect = originalConnect;
|
||||
window.__CLAWDBOT_CONTROL_UI_BASE_PATH__ = undefined;
|
||||
localStorage.clear();
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
describe("chat markdown rendering", () => {
|
||||
it("renders markdown inside tool output sidebar", async () => {
|
||||
const app = mountApp("/chat");
|
||||
await app.updateComplete;
|
||||
|
||||
const timestamp = Date.now();
|
||||
app.chatMessages = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "toolcall", name: "noop", arguments: {} },
|
||||
{ type: "toolresult", name: "noop", text: "Hello **world**" },
|
||||
],
|
||||
timestamp,
|
||||
},
|
||||
];
|
||||
|
||||
await app.updateComplete;
|
||||
|
||||
const toolCards = Array.from(
|
||||
app.querySelectorAll<HTMLElement>(".chat-tool-card"),
|
||||
);
|
||||
const toolCard = toolCards.find((card) =>
|
||||
card.querySelector(".chat-tool-card__preview, .chat-tool-card__inline"),
|
||||
);
|
||||
expect(toolCard).not.toBeUndefined();
|
||||
toolCard?.click();
|
||||
|
||||
await app.updateComplete;
|
||||
|
||||
const strong = app.querySelector(".sidebar-markdown strong");
|
||||
expect(strong?.textContent).toBe("world");
|
||||
});
|
||||
});
|
||||
12
docker-compose/ez-assistant/ui/src/ui/chat/constants.ts
Normal file
12
docker-compose/ez-assistant/ui/src/ui/chat/constants.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Chat-related constants for the UI layer.
|
||||
*/
|
||||
|
||||
/** Character threshold for showing tool output inline vs collapsed */
|
||||
export const TOOL_INLINE_THRESHOLD = 80;
|
||||
|
||||
/** Maximum lines to show in collapsed preview */
|
||||
export const PREVIEW_MAX_LINES = 2;
|
||||
|
||||
/** Maximum characters to show in collapsed preview */
|
||||
export const PREVIEW_MAX_CHARS = 100;
|
||||
@@ -0,0 +1,90 @@
|
||||
import { html, type TemplateResult } from "lit";
|
||||
import { icons } from "../icons";
|
||||
|
||||
const COPIED_FOR_MS = 1500;
|
||||
const ERROR_FOR_MS = 2000;
|
||||
const COPY_LABEL = "Copy as markdown";
|
||||
const COPIED_LABEL = "Copied";
|
||||
const ERROR_LABEL = "Copy failed";
|
||||
|
||||
type CopyButtonOptions = {
|
||||
text: () => string;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
async function copyTextToClipboard(text: string): Promise<boolean> {
|
||||
if (!text) return false;
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function setButtonLabel(button: HTMLButtonElement, label: string) {
|
||||
button.title = label;
|
||||
button.setAttribute("aria-label", label);
|
||||
}
|
||||
|
||||
function createCopyButton(options: CopyButtonOptions): TemplateResult {
|
||||
const idleLabel = options.label ?? COPY_LABEL;
|
||||
return html`
|
||||
<button
|
||||
class="chat-copy-btn"
|
||||
type="button"
|
||||
title=${idleLabel}
|
||||
aria-label=${idleLabel}
|
||||
@click=${async (e: Event) => {
|
||||
const btn = e.currentTarget as HTMLButtonElement | null;
|
||||
const iconContainer = btn?.querySelector(
|
||||
".chat-copy-btn__icon",
|
||||
) as HTMLElement | null;
|
||||
|
||||
if (!btn || btn.dataset.copying === "1") return;
|
||||
|
||||
btn.dataset.copying = "1";
|
||||
btn.setAttribute("aria-busy", "true");
|
||||
btn.disabled = true;
|
||||
|
||||
const copied = await copyTextToClipboard(options.text());
|
||||
if (!btn.isConnected) return;
|
||||
|
||||
delete btn.dataset.copying;
|
||||
btn.removeAttribute("aria-busy");
|
||||
btn.disabled = false;
|
||||
|
||||
if (!copied) {
|
||||
btn.dataset.error = "1";
|
||||
setButtonLabel(btn, ERROR_LABEL);
|
||||
|
||||
window.setTimeout(() => {
|
||||
if (!btn.isConnected) return;
|
||||
delete btn.dataset.error;
|
||||
setButtonLabel(btn, idleLabel);
|
||||
}, ERROR_FOR_MS);
|
||||
return;
|
||||
}
|
||||
|
||||
btn.dataset.copied = "1";
|
||||
setButtonLabel(btn, COPIED_LABEL);
|
||||
|
||||
window.setTimeout(() => {
|
||||
if (!btn.isConnected) return;
|
||||
delete btn.dataset.copied;
|
||||
setButtonLabel(btn, idleLabel);
|
||||
}, COPIED_FOR_MS);
|
||||
}}
|
||||
>
|
||||
<span class="chat-copy-btn__icon" aria-hidden="true">
|
||||
<span class="chat-copy-btn__icon-copy">${icons.copy}</span>
|
||||
<span class="chat-copy-btn__icon-check">${icons.check}</span>
|
||||
</span>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
export function renderCopyAsMarkdownButton(markdown: string): TemplateResult {
|
||||
return createCopyButton({ text: () => markdown, label: COPY_LABEL });
|
||||
}
|
||||
290
docker-compose/ez-assistant/ui/src/ui/chat/grouped-render.ts
Normal file
290
docker-compose/ez-assistant/ui/src/ui/chat/grouped-render.ts
Normal file
@@ -0,0 +1,290 @@
|
||||
import { html, nothing } from "lit";
|
||||
import { unsafeHTML } from "lit/directives/unsafe-html.js";
|
||||
|
||||
import type { AssistantIdentity } from "../assistant-identity";
|
||||
import { toSanitizedMarkdownHtml } from "../markdown";
|
||||
import type { MessageGroup } from "../types/chat-types";
|
||||
import { renderCopyAsMarkdownButton } from "./copy-as-markdown";
|
||||
import { isToolResultMessage, normalizeRoleForGrouping } from "./message-normalizer";
|
||||
import {
|
||||
extractTextCached,
|
||||
extractThinkingCached,
|
||||
formatReasoningMarkdown,
|
||||
} from "./message-extract";
|
||||
import { extractToolCards, renderToolCardSidebar } from "./tool-cards";
|
||||
|
||||
type ImageBlock = {
|
||||
url: string;
|
||||
alt?: string;
|
||||
};
|
||||
|
||||
function extractImages(message: unknown): ImageBlock[] {
|
||||
const m = message as Record<string, unknown>;
|
||||
const content = m.content;
|
||||
const images: ImageBlock[] = [];
|
||||
|
||||
if (Array.isArray(content)) {
|
||||
for (const block of content) {
|
||||
if (typeof block !== "object" || block === null) continue;
|
||||
const b = block as Record<string, unknown>;
|
||||
|
||||
if (b.type === "image") {
|
||||
// Handle source object format (from sendChatMessage)
|
||||
const source = b.source as Record<string, unknown> | undefined;
|
||||
if (source?.type === "base64" && typeof source.data === "string") {
|
||||
const data = source.data as string;
|
||||
const mediaType = (source.media_type as string) || "image/png";
|
||||
// If data is already a data URL, use it directly
|
||||
const url = data.startsWith("data:")
|
||||
? data
|
||||
: `data:${mediaType};base64,${data}`;
|
||||
images.push({ url });
|
||||
} else if (typeof b.url === "string") {
|
||||
images.push({ url: b.url });
|
||||
}
|
||||
} else if (b.type === "image_url") {
|
||||
// OpenAI format
|
||||
const imageUrl = b.image_url as Record<string, unknown> | undefined;
|
||||
if (typeof imageUrl?.url === "string") {
|
||||
images.push({ url: imageUrl.url });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return images;
|
||||
}
|
||||
|
||||
export function renderReadingIndicatorGroup(assistant?: AssistantIdentity) {
|
||||
return html`
|
||||
<div class="chat-group assistant">
|
||||
${renderAvatar("assistant", assistant)}
|
||||
<div class="chat-group-messages">
|
||||
<div class="chat-bubble chat-reading-indicator" aria-hidden="true">
|
||||
<span class="chat-reading-indicator__dots">
|
||||
<span></span><span></span><span></span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
export function renderStreamingGroup(
|
||||
text: string,
|
||||
startedAt: number,
|
||||
onOpenSidebar?: (content: string) => void,
|
||||
assistant?: AssistantIdentity,
|
||||
) {
|
||||
const timestamp = new Date(startedAt).toLocaleTimeString([], {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
});
|
||||
const name = assistant?.name ?? "Assistant";
|
||||
|
||||
return html`
|
||||
<div class="chat-group assistant">
|
||||
${renderAvatar("assistant", assistant)}
|
||||
<div class="chat-group-messages">
|
||||
${renderGroupedMessage(
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text }],
|
||||
timestamp: startedAt,
|
||||
},
|
||||
{ isStreaming: true, showReasoning: false },
|
||||
onOpenSidebar,
|
||||
)}
|
||||
<div class="chat-group-footer">
|
||||
<span class="chat-sender-name">${name}</span>
|
||||
<span class="chat-group-timestamp">${timestamp}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
export function renderMessageGroup(
|
||||
group: MessageGroup,
|
||||
opts: {
|
||||
onOpenSidebar?: (content: string) => void;
|
||||
showReasoning: boolean;
|
||||
assistantName?: string;
|
||||
assistantAvatar?: string | null;
|
||||
},
|
||||
) {
|
||||
const normalizedRole = normalizeRoleForGrouping(group.role);
|
||||
const assistantName = opts.assistantName ?? "Assistant";
|
||||
const who =
|
||||
normalizedRole === "user"
|
||||
? "You"
|
||||
: normalizedRole === "assistant"
|
||||
? assistantName
|
||||
: normalizedRole;
|
||||
const roleClass =
|
||||
normalizedRole === "user"
|
||||
? "user"
|
||||
: normalizedRole === "assistant"
|
||||
? "assistant"
|
||||
: "other";
|
||||
const timestamp = new Date(group.timestamp).toLocaleTimeString([], {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
});
|
||||
|
||||
return html`
|
||||
<div class="chat-group ${roleClass}">
|
||||
${renderAvatar(group.role, {
|
||||
name: assistantName,
|
||||
avatar: opts.assistantAvatar ?? null,
|
||||
})}
|
||||
<div class="chat-group-messages">
|
||||
${group.messages.map((item, index) =>
|
||||
renderGroupedMessage(
|
||||
item.message,
|
||||
{
|
||||
isStreaming:
|
||||
group.isStreaming && index === group.messages.length - 1,
|
||||
showReasoning: opts.showReasoning,
|
||||
},
|
||||
opts.onOpenSidebar,
|
||||
),
|
||||
)}
|
||||
<div class="chat-group-footer">
|
||||
<span class="chat-sender-name">${who}</span>
|
||||
<span class="chat-group-timestamp">${timestamp}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderAvatar(
|
||||
role: string,
|
||||
assistant?: Pick<AssistantIdentity, "name" | "avatar">,
|
||||
) {
|
||||
const normalized = normalizeRoleForGrouping(role);
|
||||
const assistantName = assistant?.name?.trim() || "Assistant";
|
||||
const assistantAvatar = assistant?.avatar?.trim() || "";
|
||||
const initial =
|
||||
normalized === "user"
|
||||
? "U"
|
||||
: normalized === "assistant"
|
||||
? assistantName.charAt(0).toUpperCase() || "A"
|
||||
: normalized === "tool"
|
||||
? "⚙"
|
||||
: "?";
|
||||
const className =
|
||||
normalized === "user"
|
||||
? "user"
|
||||
: normalized === "assistant"
|
||||
? "assistant"
|
||||
: normalized === "tool"
|
||||
? "tool"
|
||||
: "other";
|
||||
|
||||
if (assistantAvatar && normalized === "assistant") {
|
||||
if (isAvatarUrl(assistantAvatar)) {
|
||||
return html`<img
|
||||
class="chat-avatar ${className}"
|
||||
src="${assistantAvatar}"
|
||||
alt="${assistantName}"
|
||||
/>`;
|
||||
}
|
||||
return html`<div class="chat-avatar ${className}">${assistantAvatar}</div>`;
|
||||
}
|
||||
|
||||
return html`<div class="chat-avatar ${className}">${initial}</div>`;
|
||||
}
|
||||
|
||||
function isAvatarUrl(value: string): boolean {
|
||||
return (
|
||||
/^https?:\/\//i.test(value) ||
|
||||
/^data:image\//i.test(value) ||
|
||||
/^\//.test(value) // Relative paths from avatar endpoint
|
||||
);
|
||||
}
|
||||
|
||||
function renderMessageImages(images: ImageBlock[]) {
|
||||
if (images.length === 0) return nothing;
|
||||
|
||||
return html`
|
||||
<div class="chat-message-images">
|
||||
${images.map(
|
||||
(img) => html`
|
||||
<img
|
||||
src=${img.url}
|
||||
alt=${img.alt ?? "Attached image"}
|
||||
class="chat-message-image"
|
||||
@click=${() => window.open(img.url, "_blank")}
|
||||
/>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderGroupedMessage(
|
||||
message: unknown,
|
||||
opts: { isStreaming: boolean; showReasoning: boolean },
|
||||
onOpenSidebar?: (content: string) => void,
|
||||
) {
|
||||
const m = message as Record<string, unknown>;
|
||||
const role = typeof m.role === "string" ? m.role : "unknown";
|
||||
const isToolResult =
|
||||
isToolResultMessage(message) ||
|
||||
role.toLowerCase() === "toolresult" ||
|
||||
role.toLowerCase() === "tool_result" ||
|
||||
typeof m.toolCallId === "string" ||
|
||||
typeof m.tool_call_id === "string";
|
||||
|
||||
const toolCards = extractToolCards(message);
|
||||
const hasToolCards = toolCards.length > 0;
|
||||
const images = extractImages(message);
|
||||
const hasImages = images.length > 0;
|
||||
|
||||
const extractedText = extractTextCached(message);
|
||||
const extractedThinking =
|
||||
opts.showReasoning && role === "assistant"
|
||||
? extractThinkingCached(message)
|
||||
: null;
|
||||
const markdownBase = extractedText?.trim() ? extractedText : null;
|
||||
const reasoningMarkdown = extractedThinking
|
||||
? formatReasoningMarkdown(extractedThinking)
|
||||
: null;
|
||||
const markdown = markdownBase;
|
||||
const canCopyMarkdown = role === "assistant" && Boolean(markdown?.trim());
|
||||
|
||||
const bubbleClasses = [
|
||||
"chat-bubble",
|
||||
canCopyMarkdown ? "has-copy" : "",
|
||||
opts.isStreaming ? "streaming" : "",
|
||||
"fade-in",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
if (!markdown && hasToolCards && isToolResult) {
|
||||
return html`${toolCards.map((card) =>
|
||||
renderToolCardSidebar(card, onOpenSidebar),
|
||||
)}`;
|
||||
}
|
||||
|
||||
if (!markdown && !hasToolCards && !hasImages) return nothing;
|
||||
|
||||
return html`
|
||||
<div class="${bubbleClasses}">
|
||||
${canCopyMarkdown ? renderCopyAsMarkdownButton(markdown!) : nothing}
|
||||
${renderMessageImages(images)}
|
||||
${reasoningMarkdown
|
||||
? html`<div class="chat-thinking">${unsafeHTML(
|
||||
toSanitizedMarkdownHtml(reasoningMarkdown),
|
||||
)}</div>`
|
||||
: nothing}
|
||||
${markdown
|
||||
? html`<div class="chat-text">${unsafeHTML(toSanitizedMarkdownHtml(markdown))}</div>`
|
||||
: nothing}
|
||||
${toolCards.map((card) => renderToolCardSidebar(card, onOpenSidebar))}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
extractText,
|
||||
extractTextCached,
|
||||
extractThinking,
|
||||
extractThinkingCached,
|
||||
} from "./message-extract";
|
||||
|
||||
describe("extractTextCached", () => {
|
||||
it("matches extractText output", () => {
|
||||
const message = {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "Hello there" }],
|
||||
};
|
||||
expect(extractTextCached(message)).toBe(extractText(message));
|
||||
});
|
||||
|
||||
it("returns consistent output for repeated calls", () => {
|
||||
const message = {
|
||||
role: "user",
|
||||
content: "plain text",
|
||||
};
|
||||
expect(extractTextCached(message)).toBe("plain text");
|
||||
expect(extractTextCached(message)).toBe("plain text");
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractThinkingCached", () => {
|
||||
it("matches extractThinking output", () => {
|
||||
const message = {
|
||||
role: "assistant",
|
||||
content: [{ type: "thinking", thinking: "Plan A" }],
|
||||
};
|
||||
expect(extractThinkingCached(message)).toBe(extractThinking(message));
|
||||
});
|
||||
|
||||
it("returns consistent output for repeated calls", () => {
|
||||
const message = {
|
||||
role: "assistant",
|
||||
content: [{ type: "thinking", thinking: "Plan A" }],
|
||||
};
|
||||
expect(extractThinkingCached(message)).toBe("Plan A");
|
||||
expect(extractThinkingCached(message)).toBe("Plan A");
|
||||
});
|
||||
});
|
||||
139
docker-compose/ez-assistant/ui/src/ui/chat/message-extract.ts
Normal file
139
docker-compose/ez-assistant/ui/src/ui/chat/message-extract.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { stripThinkingTags } from "../format";
|
||||
|
||||
const ENVELOPE_PREFIX = /^\[([^\]]+)\]\s*/;
|
||||
const ENVELOPE_CHANNELS = [
|
||||
"WebChat",
|
||||
"WhatsApp",
|
||||
"Telegram",
|
||||
"Signal",
|
||||
"Slack",
|
||||
"Discord",
|
||||
"iMessage",
|
||||
"Teams",
|
||||
"Matrix",
|
||||
"Zalo",
|
||||
"Zalo Personal",
|
||||
"BlueBubbles",
|
||||
];
|
||||
|
||||
const textCache = new WeakMap<object, string | null>();
|
||||
const thinkingCache = new WeakMap<object, string | null>();
|
||||
|
||||
function looksLikeEnvelopeHeader(header: string): boolean {
|
||||
if (/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}Z\b/.test(header)) return true;
|
||||
if (/\d{4}-\d{2}-\d{2} \d{2}:\d{2}\b/.test(header)) return true;
|
||||
return ENVELOPE_CHANNELS.some((label) => header.startsWith(`${label} `));
|
||||
}
|
||||
|
||||
export function stripEnvelope(text: string): string {
|
||||
const match = text.match(ENVELOPE_PREFIX);
|
||||
if (!match) return text;
|
||||
const header = match[1] ?? "";
|
||||
if (!looksLikeEnvelopeHeader(header)) return text;
|
||||
return text.slice(match[0].length);
|
||||
}
|
||||
|
||||
export function extractText(message: unknown): string | null {
|
||||
const m = message as Record<string, unknown>;
|
||||
const role = typeof m.role === "string" ? m.role : "";
|
||||
const content = m.content;
|
||||
if (typeof content === "string") {
|
||||
const processed = role === "assistant" ? stripThinkingTags(content) : stripEnvelope(content);
|
||||
return processed;
|
||||
}
|
||||
if (Array.isArray(content)) {
|
||||
const parts = content
|
||||
.map((p) => {
|
||||
const item = p as Record<string, unknown>;
|
||||
if (item.type === "text" && typeof item.text === "string") return item.text;
|
||||
return null;
|
||||
})
|
||||
.filter((v): v is string => typeof v === "string");
|
||||
if (parts.length > 0) {
|
||||
const joined = parts.join("\n");
|
||||
const processed = role === "assistant" ? stripThinkingTags(joined) : stripEnvelope(joined);
|
||||
return processed;
|
||||
}
|
||||
}
|
||||
if (typeof m.text === "string") {
|
||||
const processed = role === "assistant" ? stripThinkingTags(m.text) : stripEnvelope(m.text);
|
||||
return processed;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function extractTextCached(message: unknown): string | null {
|
||||
if (!message || typeof message !== "object") return extractText(message);
|
||||
const obj = message as object;
|
||||
if (textCache.has(obj)) return textCache.get(obj) ?? null;
|
||||
const value = extractText(message);
|
||||
textCache.set(obj, value);
|
||||
return value;
|
||||
}
|
||||
|
||||
export function extractThinking(message: unknown): string | null {
|
||||
const m = message as Record<string, unknown>;
|
||||
const content = m.content;
|
||||
const parts: string[] = [];
|
||||
if (Array.isArray(content)) {
|
||||
for (const p of content) {
|
||||
const item = p as Record<string, unknown>;
|
||||
if (item.type === "thinking" && typeof item.thinking === "string") {
|
||||
const cleaned = item.thinking.trim();
|
||||
if (cleaned) parts.push(cleaned);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (parts.length > 0) return parts.join("\n");
|
||||
|
||||
// Back-compat: older logs may still have <think> tags inside text blocks.
|
||||
const rawText = extractRawText(message);
|
||||
if (!rawText) return null;
|
||||
const matches = [
|
||||
...rawText.matchAll(
|
||||
/<\s*think(?:ing)?\s*>([\s\S]*?)<\s*\/\s*think(?:ing)?\s*>/gi,
|
||||
),
|
||||
];
|
||||
const extracted = matches
|
||||
.map((m) => (m[1] ?? "").trim())
|
||||
.filter(Boolean);
|
||||
return extracted.length > 0 ? extracted.join("\n") : null;
|
||||
}
|
||||
|
||||
export function extractThinkingCached(message: unknown): string | null {
|
||||
if (!message || typeof message !== "object") return extractThinking(message);
|
||||
const obj = message as object;
|
||||
if (thinkingCache.has(obj)) return thinkingCache.get(obj) ?? null;
|
||||
const value = extractThinking(message);
|
||||
thinkingCache.set(obj, value);
|
||||
return value;
|
||||
}
|
||||
|
||||
export function extractRawText(message: unknown): string | null {
|
||||
const m = message as Record<string, unknown>;
|
||||
const content = m.content;
|
||||
if (typeof content === "string") return content;
|
||||
if (Array.isArray(content)) {
|
||||
const parts = content
|
||||
.map((p) => {
|
||||
const item = p as Record<string, unknown>;
|
||||
if (item.type === "text" && typeof item.text === "string") return item.text;
|
||||
return null;
|
||||
})
|
||||
.filter((v): v is string => typeof v === "string");
|
||||
if (parts.length > 0) return parts.join("\n");
|
||||
}
|
||||
if (typeof m.text === "string") return m.text;
|
||||
return null;
|
||||
}
|
||||
|
||||
export function formatReasoningMarkdown(text: string): string {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return "";
|
||||
const lines = trimmed
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.map((line) => `_${line}_`);
|
||||
return lines.length ? ["_Reasoning:_", ...lines].join("\n") : "";
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import {
|
||||
normalizeMessage,
|
||||
normalizeRoleForGrouping,
|
||||
isToolResultMessage,
|
||||
} from "./message-normalizer";
|
||||
|
||||
describe("message-normalizer", () => {
|
||||
describe("normalizeMessage", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2024-01-01T00:00:00Z"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("normalizes message with string content", () => {
|
||||
const result = normalizeMessage({
|
||||
role: "user",
|
||||
content: "Hello world",
|
||||
timestamp: 1000,
|
||||
id: "msg-1",
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "Hello world" }],
|
||||
timestamp: 1000,
|
||||
id: "msg-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes message with array content", () => {
|
||||
const result = normalizeMessage({
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "text", text: "Here is the result" },
|
||||
{ type: "tool_use", name: "bash", args: { command: "ls" } },
|
||||
],
|
||||
timestamp: 2000,
|
||||
});
|
||||
|
||||
expect(result.role).toBe("assistant");
|
||||
expect(result.content).toHaveLength(2);
|
||||
expect(result.content[0]).toEqual({ type: "text", text: "Here is the result", name: undefined, args: undefined });
|
||||
expect(result.content[1]).toEqual({ type: "tool_use", text: undefined, name: "bash", args: { command: "ls" } });
|
||||
});
|
||||
|
||||
it("normalizes message with text field (alternative format)", () => {
|
||||
const result = normalizeMessage({
|
||||
role: "user",
|
||||
text: "Alternative format",
|
||||
});
|
||||
|
||||
expect(result.content).toEqual([{ type: "text", text: "Alternative format" }]);
|
||||
});
|
||||
|
||||
it("detects tool result by toolCallId", () => {
|
||||
const result = normalizeMessage({
|
||||
role: "assistant",
|
||||
toolCallId: "call-123",
|
||||
content: "Tool output",
|
||||
});
|
||||
|
||||
expect(result.role).toBe("toolResult");
|
||||
});
|
||||
|
||||
it("detects tool result by tool_call_id (snake_case)", () => {
|
||||
const result = normalizeMessage({
|
||||
role: "assistant",
|
||||
tool_call_id: "call-456",
|
||||
content: "Tool output",
|
||||
});
|
||||
|
||||
expect(result.role).toBe("toolResult");
|
||||
});
|
||||
|
||||
it("handles missing role", () => {
|
||||
const result = normalizeMessage({ content: "No role" });
|
||||
expect(result.role).toBe("unknown");
|
||||
});
|
||||
|
||||
it("handles missing content", () => {
|
||||
const result = normalizeMessage({ role: "user" });
|
||||
expect(result.content).toEqual([]);
|
||||
});
|
||||
|
||||
it("uses current timestamp when not provided", () => {
|
||||
const result = normalizeMessage({ role: "user", content: "Test" });
|
||||
expect(result.timestamp).toBe(Date.now());
|
||||
});
|
||||
|
||||
it("handles arguments field (alternative to args)", () => {
|
||||
const result = normalizeMessage({
|
||||
role: "assistant",
|
||||
content: [{ type: "tool_use", name: "test", arguments: { foo: "bar" } }],
|
||||
});
|
||||
|
||||
expect(result.content[0].args).toEqual({ foo: "bar" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeRoleForGrouping", () => {
|
||||
it("returns tool for toolresult", () => {
|
||||
expect(normalizeRoleForGrouping("toolresult")).toBe("tool");
|
||||
expect(normalizeRoleForGrouping("toolResult")).toBe("tool");
|
||||
expect(normalizeRoleForGrouping("TOOLRESULT")).toBe("tool");
|
||||
});
|
||||
|
||||
it("returns tool for tool_result", () => {
|
||||
expect(normalizeRoleForGrouping("tool_result")).toBe("tool");
|
||||
expect(normalizeRoleForGrouping("TOOL_RESULT")).toBe("tool");
|
||||
});
|
||||
|
||||
it("returns tool for tool", () => {
|
||||
expect(normalizeRoleForGrouping("tool")).toBe("tool");
|
||||
expect(normalizeRoleForGrouping("Tool")).toBe("tool");
|
||||
});
|
||||
|
||||
it("returns tool for function", () => {
|
||||
expect(normalizeRoleForGrouping("function")).toBe("tool");
|
||||
expect(normalizeRoleForGrouping("Function")).toBe("tool");
|
||||
});
|
||||
|
||||
it("preserves user role", () => {
|
||||
expect(normalizeRoleForGrouping("user")).toBe("user");
|
||||
expect(normalizeRoleForGrouping("User")).toBe("User");
|
||||
});
|
||||
|
||||
it("preserves assistant role", () => {
|
||||
expect(normalizeRoleForGrouping("assistant")).toBe("assistant");
|
||||
});
|
||||
|
||||
it("preserves system role", () => {
|
||||
expect(normalizeRoleForGrouping("system")).toBe("system");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isToolResultMessage", () => {
|
||||
it("returns true for toolresult role", () => {
|
||||
expect(isToolResultMessage({ role: "toolresult" })).toBe(true);
|
||||
expect(isToolResultMessage({ role: "toolResult" })).toBe(true);
|
||||
expect(isToolResultMessage({ role: "TOOLRESULT" })).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for tool_result role", () => {
|
||||
expect(isToolResultMessage({ role: "tool_result" })).toBe(true);
|
||||
expect(isToolResultMessage({ role: "TOOL_RESULT" })).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for other roles", () => {
|
||||
expect(isToolResultMessage({ role: "user" })).toBe(false);
|
||||
expect(isToolResultMessage({ role: "assistant" })).toBe(false);
|
||||
expect(isToolResultMessage({ role: "tool" })).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for missing role", () => {
|
||||
expect(isToolResultMessage({})).toBe(false);
|
||||
expect(isToolResultMessage({ content: "test" })).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for non-string role", () => {
|
||||
expect(isToolResultMessage({ role: 123 })).toBe(false);
|
||||
expect(isToolResultMessage({ role: null })).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Message normalization utilities for chat rendering.
|
||||
*/
|
||||
|
||||
import type {
|
||||
NormalizedMessage,
|
||||
MessageContentItem,
|
||||
} from "../types/chat-types";
|
||||
|
||||
/**
|
||||
* Normalize a raw message object into a consistent structure.
|
||||
*/
|
||||
export function normalizeMessage(message: unknown): NormalizedMessage {
|
||||
const m = message as Record<string, unknown>;
|
||||
let role = typeof m.role === "string" ? m.role : "unknown";
|
||||
|
||||
// Detect tool messages by common gateway shapes.
|
||||
// Some tool events come through as assistant role with tool_* items in the content array.
|
||||
const hasToolId =
|
||||
typeof m.toolCallId === "string" || typeof m.tool_call_id === "string";
|
||||
|
||||
const contentRaw = m.content;
|
||||
const contentItems = Array.isArray(contentRaw) ? contentRaw : null;
|
||||
const hasToolContent =
|
||||
Array.isArray(contentItems) &&
|
||||
contentItems.some((item) => {
|
||||
const x = item as Record<string, unknown>;
|
||||
const t = String(x.type ?? "").toLowerCase();
|
||||
return t === "toolresult" || t === "tool_result";
|
||||
});
|
||||
|
||||
const hasToolName =
|
||||
typeof (m as Record<string, unknown>).toolName === "string" ||
|
||||
typeof (m as Record<string, unknown>).tool_name === "string";
|
||||
|
||||
if (hasToolId || hasToolContent || hasToolName) {
|
||||
role = "toolResult";
|
||||
}
|
||||
|
||||
// Extract content
|
||||
let content: MessageContentItem[] = [];
|
||||
|
||||
if (typeof m.content === "string") {
|
||||
content = [{ type: "text", text: m.content }];
|
||||
} else if (Array.isArray(m.content)) {
|
||||
content = m.content.map((item: Record<string, unknown>) => ({
|
||||
type: (item.type as MessageContentItem["type"]) || "text",
|
||||
text: item.text as string | undefined,
|
||||
name: item.name as string | undefined,
|
||||
args: item.args || item.arguments,
|
||||
}));
|
||||
} else if (typeof m.text === "string") {
|
||||
content = [{ type: "text", text: m.text }];
|
||||
}
|
||||
|
||||
const timestamp = typeof m.timestamp === "number" ? m.timestamp : Date.now();
|
||||
const id = typeof m.id === "string" ? m.id : undefined;
|
||||
|
||||
return { role, content, timestamp, id };
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize role for grouping purposes.
|
||||
*/
|
||||
export function normalizeRoleForGrouping(role: string): string {
|
||||
const lower = role.toLowerCase();
|
||||
// Preserve original casing when it's already a core role.
|
||||
if (role === "user" || role === "User") return role;
|
||||
if (role === "assistant") return "assistant";
|
||||
if (role === "system") return "system";
|
||||
// Keep tool-related roles distinct so the UI can style/toggle them.
|
||||
if (
|
||||
lower === "toolresult" ||
|
||||
lower === "tool_result" ||
|
||||
lower === "tool" ||
|
||||
lower === "function"
|
||||
) {
|
||||
return "tool";
|
||||
}
|
||||
return role;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a message is a tool result message based on its role.
|
||||
*/
|
||||
export function isToolResultMessage(message: unknown): boolean {
|
||||
const m = message as Record<string, unknown>;
|
||||
const role = typeof m.role === "string" ? m.role.toLowerCase() : "";
|
||||
return role === "toolresult" || role === "tool_result";
|
||||
}
|
||||
144
docker-compose/ez-assistant/ui/src/ui/chat/tool-cards.ts
Normal file
144
docker-compose/ez-assistant/ui/src/ui/chat/tool-cards.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import { html, nothing } from "lit";
|
||||
|
||||
import { formatToolDetail, resolveToolDisplay } from "../tool-display";
|
||||
import { icons } from "../icons";
|
||||
import type { ToolCard } from "../types/chat-types";
|
||||
import { TOOL_INLINE_THRESHOLD } from "./constants";
|
||||
import {
|
||||
formatToolOutputForSidebar,
|
||||
getTruncatedPreview,
|
||||
} from "./tool-helpers";
|
||||
import { isToolResultMessage } from "./message-normalizer";
|
||||
import { extractTextCached } from "./message-extract";
|
||||
|
||||
export function extractToolCards(message: unknown): ToolCard[] {
|
||||
const m = message as Record<string, unknown>;
|
||||
const content = normalizeContent(m.content);
|
||||
const cards: ToolCard[] = [];
|
||||
|
||||
for (const item of content) {
|
||||
const kind = String(item.type ?? "").toLowerCase();
|
||||
const isToolCall =
|
||||
["toolcall", "tool_call", "tooluse", "tool_use"].includes(kind) ||
|
||||
(typeof item.name === "string" && item.arguments != null);
|
||||
if (isToolCall) {
|
||||
cards.push({
|
||||
kind: "call",
|
||||
name: (item.name as string) ?? "tool",
|
||||
args: coerceArgs(item.arguments ?? item.args),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const item of content) {
|
||||
const kind = String(item.type ?? "").toLowerCase();
|
||||
if (kind !== "toolresult" && kind !== "tool_result") continue;
|
||||
const text = extractToolText(item);
|
||||
const name = typeof item.name === "string" ? item.name : "tool";
|
||||
cards.push({ kind: "result", name, text });
|
||||
}
|
||||
|
||||
if (
|
||||
isToolResultMessage(message) &&
|
||||
!cards.some((card) => card.kind === "result")
|
||||
) {
|
||||
const name =
|
||||
(typeof m.toolName === "string" && m.toolName) ||
|
||||
(typeof m.tool_name === "string" && m.tool_name) ||
|
||||
"tool";
|
||||
const text = extractTextCached(message) ?? undefined;
|
||||
cards.push({ kind: "result", name, text });
|
||||
}
|
||||
|
||||
return cards;
|
||||
}
|
||||
|
||||
export function renderToolCardSidebar(
|
||||
card: ToolCard,
|
||||
onOpenSidebar?: (content: string) => void,
|
||||
) {
|
||||
const display = resolveToolDisplay({ name: card.name, args: card.args });
|
||||
const detail = formatToolDetail(display);
|
||||
const hasText = Boolean(card.text?.trim());
|
||||
|
||||
const canClick = Boolean(onOpenSidebar);
|
||||
const handleClick = canClick
|
||||
? () => {
|
||||
if (hasText) {
|
||||
onOpenSidebar!(formatToolOutputForSidebar(card.text!));
|
||||
return;
|
||||
}
|
||||
const info = `## ${display.label}\n\n${
|
||||
detail ? `**Command:** \`${detail}\`\n\n` : ""
|
||||
}*No output — tool completed successfully.*`;
|
||||
onOpenSidebar!(info);
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const isShort = hasText && (card.text?.length ?? 0) <= TOOL_INLINE_THRESHOLD;
|
||||
const showCollapsed = hasText && !isShort;
|
||||
const showInline = hasText && isShort;
|
||||
const isEmpty = !hasText;
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="chat-tool-card ${canClick ? "chat-tool-card--clickable" : ""}"
|
||||
@click=${handleClick}
|
||||
role=${canClick ? "button" : nothing}
|
||||
tabindex=${canClick ? "0" : nothing}
|
||||
@keydown=${canClick
|
||||
? (e: KeyboardEvent) => {
|
||||
if (e.key !== "Enter" && e.key !== " ") return;
|
||||
e.preventDefault();
|
||||
handleClick?.();
|
||||
}
|
||||
: nothing}
|
||||
>
|
||||
<div class="chat-tool-card__header">
|
||||
<div class="chat-tool-card__title">
|
||||
<span class="chat-tool-card__icon">${icons[display.icon]}</span>
|
||||
<span>${display.label}</span>
|
||||
</div>
|
||||
${canClick
|
||||
? html`<span class="chat-tool-card__action">${hasText ? "View" : ""} ${icons.check}</span>`
|
||||
: nothing}
|
||||
${isEmpty && !canClick ? html`<span class="chat-tool-card__status">${icons.check}</span>` : nothing}
|
||||
</div>
|
||||
${detail
|
||||
? html`<div class="chat-tool-card__detail">${detail}</div>`
|
||||
: nothing}
|
||||
${isEmpty
|
||||
? html`<div class="chat-tool-card__status-text muted">Completed</div>`
|
||||
: nothing}
|
||||
${showCollapsed
|
||||
? html`<div class="chat-tool-card__preview mono">${getTruncatedPreview(card.text!)}</div>`
|
||||
: nothing}
|
||||
${showInline
|
||||
? html`<div class="chat-tool-card__inline mono">${card.text}</div>`
|
||||
: nothing}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function normalizeContent(content: unknown): Array<Record<string, unknown>> {
|
||||
if (!Array.isArray(content)) return [];
|
||||
return content.filter(Boolean) as Array<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
function coerceArgs(value: unknown): unknown {
|
||||
if (typeof value !== "string") return value;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return value;
|
||||
if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) return value;
|
||||
try {
|
||||
return JSON.parse(trimmed);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
function extractToolText(item: Record<string, unknown>): string | undefined {
|
||||
if (typeof item.text === "string") return item.text;
|
||||
if (typeof item.content === "string") return item.content;
|
||||
return undefined;
|
||||
}
|
||||
141
docker-compose/ez-assistant/ui/src/ui/chat/tool-helpers.test.ts
Normal file
141
docker-compose/ez-assistant/ui/src/ui/chat/tool-helpers.test.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { formatToolOutputForSidebar, getTruncatedPreview } from "./tool-helpers";
|
||||
|
||||
describe("tool-helpers", () => {
|
||||
describe("formatToolOutputForSidebar", () => {
|
||||
it("formats valid JSON object as code block", () => {
|
||||
const input = '{"name":"test","value":123}';
|
||||
const result = formatToolOutputForSidebar(input);
|
||||
|
||||
expect(result).toBe(`\`\`\`json
|
||||
{
|
||||
"name": "test",
|
||||
"value": 123
|
||||
}
|
||||
\`\`\``);
|
||||
});
|
||||
|
||||
it("formats valid JSON array as code block", () => {
|
||||
const input = '[1, 2, 3]';
|
||||
const result = formatToolOutputForSidebar(input);
|
||||
|
||||
expect(result).toBe(`\`\`\`json
|
||||
[
|
||||
1,
|
||||
2,
|
||||
3
|
||||
]
|
||||
\`\`\``);
|
||||
});
|
||||
|
||||
it("handles nested JSON objects", () => {
|
||||
const input = '{"outer":{"inner":"value"}}';
|
||||
const result = formatToolOutputForSidebar(input);
|
||||
|
||||
expect(result).toContain("```json");
|
||||
expect(result).toContain('"outer"');
|
||||
expect(result).toContain('"inner"');
|
||||
});
|
||||
|
||||
it("returns plain text for non-JSON content", () => {
|
||||
const input = "This is plain text output";
|
||||
const result = formatToolOutputForSidebar(input);
|
||||
|
||||
expect(result).toBe("This is plain text output");
|
||||
});
|
||||
|
||||
it("returns as-is for invalid JSON starting with {", () => {
|
||||
const input = "{not valid json";
|
||||
const result = formatToolOutputForSidebar(input);
|
||||
|
||||
expect(result).toBe("{not valid json");
|
||||
});
|
||||
|
||||
it("returns as-is for invalid JSON starting with [", () => {
|
||||
const input = "[not valid json";
|
||||
const result = formatToolOutputForSidebar(input);
|
||||
|
||||
expect(result).toBe("[not valid json");
|
||||
});
|
||||
|
||||
it("trims whitespace before detecting JSON", () => {
|
||||
const input = ' {"trimmed": true} ';
|
||||
const result = formatToolOutputForSidebar(input);
|
||||
|
||||
expect(result).toContain("```json");
|
||||
expect(result).toContain('"trimmed"');
|
||||
});
|
||||
|
||||
it("handles empty string", () => {
|
||||
const result = formatToolOutputForSidebar("");
|
||||
expect(result).toBe("");
|
||||
});
|
||||
|
||||
it("handles whitespace-only string", () => {
|
||||
const result = formatToolOutputForSidebar(" ");
|
||||
expect(result).toBe(" ");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTruncatedPreview", () => {
|
||||
it("returns short text unchanged", () => {
|
||||
const input = "Short text";
|
||||
const result = getTruncatedPreview(input);
|
||||
|
||||
expect(result).toBe("Short text");
|
||||
});
|
||||
|
||||
it("truncates text longer than max chars", () => {
|
||||
const input = "a".repeat(150);
|
||||
const result = getTruncatedPreview(input);
|
||||
|
||||
expect(result.length).toBe(101); // 100 chars + ellipsis
|
||||
expect(result.endsWith("…")).toBe(true);
|
||||
});
|
||||
|
||||
it("truncates to max lines", () => {
|
||||
const input = "Line 1\nLine 2\nLine 3\nLine 4\nLine 5";
|
||||
const result = getTruncatedPreview(input);
|
||||
|
||||
// Should only show first 2 lines (PREVIEW_MAX_LINES = 2)
|
||||
expect(result).toBe("Line 1\nLine 2…");
|
||||
});
|
||||
|
||||
it("adds ellipsis when lines are truncated", () => {
|
||||
const input = "Line 1\nLine 2\nLine 3";
|
||||
const result = getTruncatedPreview(input);
|
||||
|
||||
expect(result.endsWith("…")).toBe(true);
|
||||
});
|
||||
|
||||
it("does not add ellipsis when all lines fit", () => {
|
||||
const input = "Line 1\nLine 2";
|
||||
const result = getTruncatedPreview(input);
|
||||
|
||||
expect(result).toBe("Line 1\nLine 2");
|
||||
expect(result.endsWith("…")).toBe(false);
|
||||
});
|
||||
|
||||
it("handles single line within limits", () => {
|
||||
const input = "Single line";
|
||||
const result = getTruncatedPreview(input);
|
||||
|
||||
expect(result).toBe("Single line");
|
||||
});
|
||||
|
||||
it("handles empty string", () => {
|
||||
const result = getTruncatedPreview("");
|
||||
expect(result).toBe("");
|
||||
});
|
||||
|
||||
it("truncates by chars even within line limit", () => {
|
||||
// Two lines but very long content
|
||||
const longLine = "x".repeat(80);
|
||||
const input = `${longLine}\n${longLine}`;
|
||||
const result = getTruncatedPreview(input);
|
||||
|
||||
expect(result.length).toBe(101); // 100 + ellipsis
|
||||
expect(result.endsWith("…")).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
37
docker-compose/ez-assistant/ui/src/ui/chat/tool-helpers.ts
Normal file
37
docker-compose/ez-assistant/ui/src/ui/chat/tool-helpers.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Helper functions for tool card rendering.
|
||||
*/
|
||||
|
||||
import { PREVIEW_MAX_CHARS, PREVIEW_MAX_LINES } from "./constants";
|
||||
|
||||
/**
|
||||
* Format tool output content for display in the sidebar.
|
||||
* Detects JSON and wraps it in a code block with formatting.
|
||||
*/
|
||||
export function formatToolOutputForSidebar(text: string): string {
|
||||
const trimmed = text.trim();
|
||||
// Try to detect and format JSON
|
||||
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
return "```json\n" + JSON.stringify(parsed, null, 2) + "\n```";
|
||||
} catch {
|
||||
// Not valid JSON, return as-is
|
||||
}
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a truncated preview of tool output text.
|
||||
* Truncates to first N lines or first N characters, whichever is shorter.
|
||||
*/
|
||||
export function getTruncatedPreview(text: string): string {
|
||||
const allLines = text.split("\n");
|
||||
const lines = allLines.slice(0, PREVIEW_MAX_LINES);
|
||||
const preview = lines.join("\n");
|
||||
if (preview.length > PREVIEW_MAX_CHARS) {
|
||||
return preview.slice(0, PREVIEW_MAX_CHARS) + "…";
|
||||
}
|
||||
return lines.length < allLines.length ? preview + "…" : preview;
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { LitElement, html, css } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
|
||||
/**
|
||||
* A draggable divider for resizable split views.
|
||||
* Dispatches 'resize' events with { splitRatio: number } detail.
|
||||
*/
|
||||
@customElement("resizable-divider")
|
||||
export class ResizableDivider extends LitElement {
|
||||
@property({ type: Number }) splitRatio = 0.6;
|
||||
@property({ type: Number }) minRatio = 0.4;
|
||||
@property({ type: Number }) maxRatio = 0.7;
|
||||
|
||||
private isDragging = false;
|
||||
private startX = 0;
|
||||
private startRatio = 0;
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
width: 4px;
|
||||
cursor: col-resize;
|
||||
background: var(--border, #333);
|
||||
transition: background 150ms ease-out;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
:host::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -4px;
|
||||
right: -4px;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
:host(:hover) {
|
||||
background: var(--accent, #007bff);
|
||||
}
|
||||
|
||||
:host(.dragging) {
|
||||
background: var(--accent, #007bff);
|
||||
}
|
||||
`;
|
||||
|
||||
render() {
|
||||
return html``;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.addEventListener("mousedown", this.handleMouseDown);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this.removeEventListener("mousedown", this.handleMouseDown);
|
||||
document.removeEventListener("mousemove", this.handleMouseMove);
|
||||
document.removeEventListener("mouseup", this.handleMouseUp);
|
||||
}
|
||||
|
||||
private handleMouseDown = (e: MouseEvent) => {
|
||||
this.isDragging = true;
|
||||
this.startX = e.clientX;
|
||||
this.startRatio = this.splitRatio;
|
||||
this.classList.add("dragging");
|
||||
|
||||
document.addEventListener("mousemove", this.handleMouseMove);
|
||||
document.addEventListener("mouseup", this.handleMouseUp);
|
||||
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
private handleMouseMove = (e: MouseEvent) => {
|
||||
if (!this.isDragging) return;
|
||||
|
||||
const container = this.parentElement;
|
||||
if (!container) return;
|
||||
|
||||
const containerWidth = container.getBoundingClientRect().width;
|
||||
const deltaX = e.clientX - this.startX;
|
||||
const deltaRatio = deltaX / containerWidth;
|
||||
|
||||
let newRatio = this.startRatio + deltaRatio;
|
||||
newRatio = Math.max(this.minRatio, Math.min(this.maxRatio, newRatio));
|
||||
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("resize", {
|
||||
detail: { splitRatio: newRatio },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
private handleMouseUp = () => {
|
||||
this.isDragging = false;
|
||||
this.classList.remove("dragging");
|
||||
|
||||
document.removeEventListener("mousemove", this.handleMouseMove);
|
||||
document.removeEventListener("mouseup", this.handleMouseUp);
|
||||
};
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"resizable-divider": ResizableDivider;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
import { render } from "lit";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { analyzeConfigSchema, renderConfigForm } from "./views/config-form";
|
||||
|
||||
const rootSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
gateway: {
|
||||
type: "object",
|
||||
properties: {
|
||||
auth: {
|
||||
type: "object",
|
||||
properties: {
|
||||
token: { type: "string" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
allowFrom: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
},
|
||||
mode: {
|
||||
type: "string",
|
||||
enum: ["off", "token"],
|
||||
},
|
||||
enabled: {
|
||||
type: "boolean",
|
||||
},
|
||||
bind: {
|
||||
anyOf: [
|
||||
{ const: "auto" },
|
||||
{ const: "lan" },
|
||||
{ const: "tailnet" },
|
||||
{ const: "loopback" },
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
describe("config form renderer", () => {
|
||||
it("renders inputs and patches values", () => {
|
||||
const onPatch = vi.fn();
|
||||
const container = document.createElement("div");
|
||||
const analysis = analyzeConfigSchema(rootSchema);
|
||||
render(
|
||||
renderConfigForm({
|
||||
schema: analysis.schema,
|
||||
uiHints: {
|
||||
"gateway.auth.token": { label: "Gateway Token", sensitive: true },
|
||||
},
|
||||
unsupportedPaths: analysis.unsupportedPaths,
|
||||
value: {},
|
||||
onPatch,
|
||||
}),
|
||||
container,
|
||||
);
|
||||
|
||||
const tokenInput = container.querySelector(
|
||||
"input[type='password']",
|
||||
) as HTMLInputElement | null;
|
||||
expect(tokenInput).not.toBeNull();
|
||||
if (!tokenInput) return;
|
||||
tokenInput.value = "abc123";
|
||||
tokenInput.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
expect(onPatch).toHaveBeenCalledWith(
|
||||
["gateway", "auth", "token"],
|
||||
"abc123",
|
||||
);
|
||||
|
||||
const tokenButton = Array.from(
|
||||
container.querySelectorAll<HTMLButtonElement>(".cfg-segmented__btn"),
|
||||
).find((btn) => btn.textContent?.trim() === "token");
|
||||
expect(tokenButton).not.toBeUndefined();
|
||||
tokenButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
expect(onPatch).toHaveBeenCalledWith(["mode"], "token");
|
||||
|
||||
const checkbox = container.querySelector(
|
||||
"input[type='checkbox']",
|
||||
) as HTMLInputElement | null;
|
||||
expect(checkbox).not.toBeNull();
|
||||
if (!checkbox) return;
|
||||
checkbox.checked = true;
|
||||
checkbox.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
expect(onPatch).toHaveBeenCalledWith(["enabled"], true);
|
||||
});
|
||||
|
||||
it("adds and removes array entries", () => {
|
||||
const onPatch = vi.fn();
|
||||
const container = document.createElement("div");
|
||||
const analysis = analyzeConfigSchema(rootSchema);
|
||||
render(
|
||||
renderConfigForm({
|
||||
schema: analysis.schema,
|
||||
uiHints: {},
|
||||
unsupportedPaths: analysis.unsupportedPaths,
|
||||
value: { allowFrom: ["+1"] },
|
||||
onPatch,
|
||||
}),
|
||||
container,
|
||||
);
|
||||
|
||||
const addButton = container.querySelector(
|
||||
".cfg-array__add",
|
||||
) as HTMLButtonElement | null;
|
||||
expect(addButton).not.toBeUndefined();
|
||||
addButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
expect(onPatch).toHaveBeenCalledWith(["allowFrom"], ["+1", ""]);
|
||||
|
||||
const removeButton = container.querySelector(
|
||||
".cfg-array__item-remove",
|
||||
) as HTMLButtonElement | null;
|
||||
expect(removeButton).not.toBeUndefined();
|
||||
removeButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
expect(onPatch).toHaveBeenCalledWith(["allowFrom"], []);
|
||||
});
|
||||
|
||||
it("renders union literals as select options", () => {
|
||||
const onPatch = vi.fn();
|
||||
const container = document.createElement("div");
|
||||
const analysis = analyzeConfigSchema(rootSchema);
|
||||
render(
|
||||
renderConfigForm({
|
||||
schema: analysis.schema,
|
||||
uiHints: {},
|
||||
unsupportedPaths: analysis.unsupportedPaths,
|
||||
value: { bind: "auto" },
|
||||
onPatch,
|
||||
}),
|
||||
container,
|
||||
);
|
||||
|
||||
const tailnetButton = Array.from(
|
||||
container.querySelectorAll<HTMLButtonElement>(".cfg-segmented__btn"),
|
||||
).find((btn) => btn.textContent?.trim() === "tailnet");
|
||||
expect(tailnetButton).not.toBeUndefined();
|
||||
tailnetButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
expect(onPatch).toHaveBeenCalledWith(["bind"], "tailnet");
|
||||
});
|
||||
|
||||
it("renders map fields from additionalProperties", () => {
|
||||
const onPatch = vi.fn();
|
||||
const container = document.createElement("div");
|
||||
const schema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
slack: {
|
||||
type: "object",
|
||||
additionalProperties: {
|
||||
type: "string",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const analysis = analyzeConfigSchema(schema);
|
||||
render(
|
||||
renderConfigForm({
|
||||
schema: analysis.schema,
|
||||
uiHints: {},
|
||||
unsupportedPaths: analysis.unsupportedPaths,
|
||||
value: { slack: { channelA: "ok" } },
|
||||
onPatch,
|
||||
}),
|
||||
container,
|
||||
);
|
||||
|
||||
const removeButton = container.querySelector(
|
||||
".cfg-map__item-remove",
|
||||
) as HTMLButtonElement | null;
|
||||
expect(removeButton).not.toBeUndefined();
|
||||
removeButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
expect(onPatch).toHaveBeenCalledWith(["slack"], {});
|
||||
});
|
||||
|
||||
it("supports wildcard uiHints for map entries", () => {
|
||||
const onPatch = vi.fn();
|
||||
const container = document.createElement("div");
|
||||
const schema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
plugins: {
|
||||
type: "object",
|
||||
properties: {
|
||||
entries: {
|
||||
type: "object",
|
||||
additionalProperties: {
|
||||
type: "object",
|
||||
properties: {
|
||||
enabled: { type: "boolean" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const analysis = analyzeConfigSchema(schema);
|
||||
render(
|
||||
renderConfigForm({
|
||||
schema: analysis.schema,
|
||||
uiHints: {
|
||||
"plugins.entries.*.enabled": { label: "Plugin Enabled" },
|
||||
},
|
||||
unsupportedPaths: analysis.unsupportedPaths,
|
||||
value: { plugins: { entries: { "voice-call": { enabled: true } } } },
|
||||
onPatch,
|
||||
}),
|
||||
container,
|
||||
);
|
||||
|
||||
expect(container.textContent).toContain("Plugin Enabled");
|
||||
});
|
||||
|
||||
it("flags unsupported unions", () => {
|
||||
const schema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
mixed: {
|
||||
anyOf: [{ type: "string" }, { type: "object", properties: {} }],
|
||||
},
|
||||
},
|
||||
};
|
||||
const analysis = analyzeConfigSchema(schema);
|
||||
expect(analysis.unsupportedPaths).toContain("mixed");
|
||||
});
|
||||
|
||||
it("supports nullable types", () => {
|
||||
const schema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
note: { type: ["string", "null"] },
|
||||
},
|
||||
};
|
||||
const analysis = analyzeConfigSchema(schema);
|
||||
expect(analysis.unsupportedPaths).not.toContain("note");
|
||||
});
|
||||
|
||||
it("ignores untyped additionalProperties schemas", () => {
|
||||
const schema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
channels: {
|
||||
type: "object",
|
||||
properties: {
|
||||
whatsapp: {
|
||||
type: "object",
|
||||
properties: {
|
||||
enabled: { type: "boolean" },
|
||||
},
|
||||
},
|
||||
},
|
||||
additionalProperties: {},
|
||||
},
|
||||
},
|
||||
};
|
||||
const analysis = analyzeConfigSchema(schema);
|
||||
expect(analysis.unsupportedPaths).not.toContain("channels");
|
||||
});
|
||||
|
||||
it("flags additionalProperties true", () => {
|
||||
const schema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
extra: {
|
||||
type: "object",
|
||||
additionalProperties: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
const analysis = analyzeConfigSchema(schema);
|
||||
expect(analysis.unsupportedPaths).toContain("extra");
|
||||
});
|
||||
});
|
||||
25
docker-compose/ez-assistant/ui/src/ui/controllers/agents.ts
Normal file
25
docker-compose/ez-assistant/ui/src/ui/controllers/agents.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { GatewayBrowserClient } from "../gateway";
|
||||
import type { AgentsListResult } from "../types";
|
||||
|
||||
export type AgentsState = {
|
||||
client: GatewayBrowserClient | null;
|
||||
connected: boolean;
|
||||
agentsLoading: boolean;
|
||||
agentsError: string | null;
|
||||
agentsList: AgentsListResult | null;
|
||||
};
|
||||
|
||||
export async function loadAgents(state: AgentsState) {
|
||||
if (!state.client || !state.connected) return;
|
||||
if (state.agentsLoading) return;
|
||||
state.agentsLoading = true;
|
||||
state.agentsError = null;
|
||||
try {
|
||||
const res = (await state.client.request("agents.list", {})) as AgentsListResult | undefined;
|
||||
if (res) state.agentsList = res;
|
||||
} catch (err) {
|
||||
state.agentsError = String(err);
|
||||
} finally {
|
||||
state.agentsLoading = false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { GatewayBrowserClient } from "../gateway";
|
||||
import {
|
||||
normalizeAssistantIdentity,
|
||||
type AssistantIdentity,
|
||||
} from "../assistant-identity";
|
||||
|
||||
export type AssistantIdentityState = {
|
||||
client: GatewayBrowserClient | null;
|
||||
connected: boolean;
|
||||
sessionKey: string;
|
||||
assistantName: string;
|
||||
assistantAvatar: string | null;
|
||||
assistantAgentId: string | null;
|
||||
};
|
||||
|
||||
export async function loadAssistantIdentity(
|
||||
state: AssistantIdentityState,
|
||||
opts?: { sessionKey?: string },
|
||||
) {
|
||||
if (!state.client || !state.connected) return;
|
||||
const sessionKey = opts?.sessionKey?.trim() || state.sessionKey.trim();
|
||||
const params = sessionKey ? { sessionKey } : {};
|
||||
try {
|
||||
const res = (await state.client.request("agent.identity.get", params)) as
|
||||
| Partial<AssistantIdentity>
|
||||
| undefined;
|
||||
if (!res) return;
|
||||
const normalized = normalizeAssistantIdentity(res);
|
||||
state.assistantName = normalized.name;
|
||||
state.assistantAvatar = normalized.avatar;
|
||||
state.assistantAgentId = normalized.agentId ?? null;
|
||||
} catch {
|
||||
// Ignore errors; keep last known identity.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { ChannelsStatusSnapshot } from "../types";
|
||||
import type { ChannelsState } from "./channels.types";
|
||||
|
||||
export type { ChannelsState };
|
||||
|
||||
export async function loadChannels(state: ChannelsState, probe: boolean) {
|
||||
if (!state.client || !state.connected) return;
|
||||
if (state.channelsLoading) return;
|
||||
state.channelsLoading = true;
|
||||
state.channelsError = null;
|
||||
try {
|
||||
const res = (await state.client.request("channels.status", {
|
||||
probe,
|
||||
timeoutMs: 8000,
|
||||
})) as ChannelsStatusSnapshot;
|
||||
state.channelsSnapshot = res;
|
||||
state.channelsLastSuccess = Date.now();
|
||||
} catch (err) {
|
||||
state.channelsError = String(err);
|
||||
} finally {
|
||||
state.channelsLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function startWhatsAppLogin(state: ChannelsState, force: boolean) {
|
||||
if (!state.client || !state.connected || state.whatsappBusy) return;
|
||||
state.whatsappBusy = true;
|
||||
try {
|
||||
const res = (await state.client.request("web.login.start", {
|
||||
force,
|
||||
timeoutMs: 30000,
|
||||
})) as { message?: string; qrDataUrl?: string };
|
||||
state.whatsappLoginMessage = res.message ?? null;
|
||||
state.whatsappLoginQrDataUrl = res.qrDataUrl ?? null;
|
||||
state.whatsappLoginConnected = null;
|
||||
} catch (err) {
|
||||
state.whatsappLoginMessage = String(err);
|
||||
state.whatsappLoginQrDataUrl = null;
|
||||
state.whatsappLoginConnected = null;
|
||||
} finally {
|
||||
state.whatsappBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function waitWhatsAppLogin(state: ChannelsState) {
|
||||
if (!state.client || !state.connected || state.whatsappBusy) return;
|
||||
state.whatsappBusy = true;
|
||||
try {
|
||||
const res = (await state.client.request("web.login.wait", {
|
||||
timeoutMs: 120000,
|
||||
})) as { connected?: boolean; message?: string };
|
||||
state.whatsappLoginMessage = res.message ?? null;
|
||||
state.whatsappLoginConnected = res.connected ?? null;
|
||||
if (res.connected) state.whatsappLoginQrDataUrl = null;
|
||||
} catch (err) {
|
||||
state.whatsappLoginMessage = String(err);
|
||||
state.whatsappLoginConnected = null;
|
||||
} finally {
|
||||
state.whatsappBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function logoutWhatsApp(state: ChannelsState) {
|
||||
if (!state.client || !state.connected || state.whatsappBusy) return;
|
||||
state.whatsappBusy = true;
|
||||
try {
|
||||
await state.client.request("channels.logout", { channel: "whatsapp" });
|
||||
state.whatsappLoginMessage = "Logged out.";
|
||||
state.whatsappLoginQrDataUrl = null;
|
||||
state.whatsappLoginConnected = null;
|
||||
} catch (err) {
|
||||
state.whatsappLoginMessage = String(err);
|
||||
} finally {
|
||||
state.whatsappBusy = false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { GatewayBrowserClient } from "../gateway";
|
||||
import type { ChannelsStatusSnapshot } from "../types";
|
||||
|
||||
export type ChannelsState = {
|
||||
client: GatewayBrowserClient | null;
|
||||
connected: boolean;
|
||||
channelsLoading: boolean;
|
||||
channelsSnapshot: ChannelsStatusSnapshot | null;
|
||||
channelsError: string | null;
|
||||
channelsLastSuccess: number | null;
|
||||
whatsappLoginMessage: string | null;
|
||||
whatsappLoginQrDataUrl: string | null;
|
||||
whatsappLoginConnected: boolean | null;
|
||||
whatsappBusy: boolean;
|
||||
};
|
||||
@@ -0,0 +1,99 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
handleChatEvent,
|
||||
type ChatEventPayload,
|
||||
type ChatState,
|
||||
} from "./chat";
|
||||
|
||||
function createState(overrides: Partial<ChatState> = {}): ChatState {
|
||||
return {
|
||||
client: null,
|
||||
connected: true,
|
||||
sessionKey: "main",
|
||||
chatLoading: false,
|
||||
chatMessages: [],
|
||||
chatThinkingLevel: null,
|
||||
chatSending: false,
|
||||
chatMessage: "",
|
||||
chatRunId: null,
|
||||
chatStream: null,
|
||||
chatStreamStartedAt: null,
|
||||
lastError: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("handleChatEvent", () => {
|
||||
it("returns null when payload is missing", () => {
|
||||
const state = createState();
|
||||
expect(handleChatEvent(state, undefined)).toBe(null);
|
||||
});
|
||||
|
||||
it("returns null when sessionKey does not match", () => {
|
||||
const state = createState({ sessionKey: "main" });
|
||||
const payload: ChatEventPayload = {
|
||||
runId: "run-1",
|
||||
sessionKey: "other",
|
||||
state: "final",
|
||||
};
|
||||
expect(handleChatEvent(state, payload)).toBe(null);
|
||||
});
|
||||
|
||||
it("returns null for delta from another run", () => {
|
||||
const state = createState({
|
||||
sessionKey: "main",
|
||||
chatRunId: "run-user",
|
||||
chatStream: "Hello",
|
||||
});
|
||||
const payload: ChatEventPayload = {
|
||||
runId: "run-announce",
|
||||
sessionKey: "main",
|
||||
state: "delta",
|
||||
message: { role: "assistant", content: [{ type: "text", text: "Done" }] },
|
||||
};
|
||||
expect(handleChatEvent(state, payload)).toBe(null);
|
||||
expect(state.chatRunId).toBe("run-user");
|
||||
expect(state.chatStream).toBe("Hello");
|
||||
});
|
||||
|
||||
it("returns 'final' for final from another run (e.g. sub-agent announce) without clearing state", () => {
|
||||
const state = createState({
|
||||
sessionKey: "main",
|
||||
chatRunId: "run-user",
|
||||
chatStream: "Working...",
|
||||
chatStreamStartedAt: 123,
|
||||
});
|
||||
const payload: ChatEventPayload = {
|
||||
runId: "run-announce",
|
||||
sessionKey: "main",
|
||||
state: "final",
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "Sub-agent findings" }],
|
||||
},
|
||||
};
|
||||
expect(handleChatEvent(state, payload)).toBe("final");
|
||||
expect(state.chatRunId).toBe("run-user");
|
||||
expect(state.chatStream).toBe("Working...");
|
||||
expect(state.chatStreamStartedAt).toBe(123);
|
||||
});
|
||||
|
||||
it("processes final from own run and clears state", () => {
|
||||
const state = createState({
|
||||
sessionKey: "main",
|
||||
chatRunId: "run-1",
|
||||
chatStream: "Reply",
|
||||
chatStreamStartedAt: 100,
|
||||
});
|
||||
const payload: ChatEventPayload = {
|
||||
runId: "run-1",
|
||||
sessionKey: "main",
|
||||
state: "final",
|
||||
};
|
||||
expect(handleChatEvent(state, payload)).toBe("final");
|
||||
expect(state.chatRunId).toBe(null);
|
||||
expect(state.chatStream).toBe(null);
|
||||
expect(state.chatStreamStartedAt).toBe(null);
|
||||
});
|
||||
});
|
||||
199
docker-compose/ez-assistant/ui/src/ui/controllers/chat.ts
Normal file
199
docker-compose/ez-assistant/ui/src/ui/controllers/chat.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
import { extractText } from "../chat/message-extract";
|
||||
import type { GatewayBrowserClient } from "../gateway";
|
||||
import { generateUUID } from "../uuid";
|
||||
import type { ChatAttachment } from "../ui-types";
|
||||
|
||||
export type ChatState = {
|
||||
client: GatewayBrowserClient | null;
|
||||
connected: boolean;
|
||||
sessionKey: string;
|
||||
chatLoading: boolean;
|
||||
chatMessages: unknown[];
|
||||
chatThinkingLevel: string | null;
|
||||
chatSending: boolean;
|
||||
chatMessage: string;
|
||||
chatAttachments: ChatAttachment[];
|
||||
chatRunId: string | null;
|
||||
chatStream: string | null;
|
||||
chatStreamStartedAt: number | null;
|
||||
lastError: string | null;
|
||||
};
|
||||
|
||||
export type ChatEventPayload = {
|
||||
runId: string;
|
||||
sessionKey: string;
|
||||
state: "delta" | "final" | "aborted" | "error";
|
||||
message?: unknown;
|
||||
errorMessage?: string;
|
||||
};
|
||||
|
||||
export async function loadChatHistory(state: ChatState) {
|
||||
if (!state.client || !state.connected) return;
|
||||
state.chatLoading = true;
|
||||
state.lastError = null;
|
||||
try {
|
||||
const res = (await state.client.request("chat.history", {
|
||||
sessionKey: state.sessionKey,
|
||||
limit: 200,
|
||||
})) as { messages?: unknown[]; thinkingLevel?: string | null };
|
||||
state.chatMessages = Array.isArray(res.messages) ? res.messages : [];
|
||||
state.chatThinkingLevel = res.thinkingLevel ?? null;
|
||||
} catch (err) {
|
||||
state.lastError = String(err);
|
||||
} finally {
|
||||
state.chatLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function dataUrlToBase64(dataUrl: string): { content: string; mimeType: string } | null {
|
||||
const match = /^data:([^;]+);base64,(.+)$/.exec(dataUrl);
|
||||
if (!match) return null;
|
||||
return { mimeType: match[1], content: match[2] };
|
||||
}
|
||||
|
||||
export async function sendChatMessage(
|
||||
state: ChatState,
|
||||
message: string,
|
||||
attachments?: ChatAttachment[],
|
||||
): Promise<boolean> {
|
||||
if (!state.client || !state.connected) return false;
|
||||
const msg = message.trim();
|
||||
const hasAttachments = attachments && attachments.length > 0;
|
||||
if (!msg && !hasAttachments) return false;
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
// Build user message content blocks
|
||||
const contentBlocks: Array<{ type: string; text?: string; source?: unknown }> = [];
|
||||
if (msg) {
|
||||
contentBlocks.push({ type: "text", text: msg });
|
||||
}
|
||||
// Add image previews to the message for display
|
||||
if (hasAttachments) {
|
||||
for (const att of attachments) {
|
||||
contentBlocks.push({
|
||||
type: "image",
|
||||
source: { type: "base64", media_type: att.mimeType, data: att.dataUrl },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
state.chatMessages = [
|
||||
...state.chatMessages,
|
||||
{
|
||||
role: "user",
|
||||
content: contentBlocks,
|
||||
timestamp: now,
|
||||
},
|
||||
];
|
||||
|
||||
state.chatSending = true;
|
||||
state.lastError = null;
|
||||
const runId = generateUUID();
|
||||
state.chatRunId = runId;
|
||||
state.chatStream = "";
|
||||
state.chatStreamStartedAt = now;
|
||||
|
||||
// Convert attachments to API format
|
||||
const apiAttachments = hasAttachments
|
||||
? attachments
|
||||
.map((att) => {
|
||||
const parsed = dataUrlToBase64(att.dataUrl);
|
||||
if (!parsed) return null;
|
||||
return {
|
||||
type: "image",
|
||||
mimeType: parsed.mimeType,
|
||||
content: parsed.content,
|
||||
};
|
||||
})
|
||||
.filter((a): a is NonNullable<typeof a> => a !== null)
|
||||
: undefined;
|
||||
|
||||
try {
|
||||
await state.client.request("chat.send", {
|
||||
sessionKey: state.sessionKey,
|
||||
message: msg,
|
||||
deliver: false,
|
||||
idempotencyKey: runId,
|
||||
attachments: apiAttachments,
|
||||
});
|
||||
return true;
|
||||
} catch (err) {
|
||||
const error = String(err);
|
||||
state.chatRunId = null;
|
||||
state.chatStream = null;
|
||||
state.chatStreamStartedAt = null;
|
||||
state.lastError = error;
|
||||
state.chatMessages = [
|
||||
...state.chatMessages,
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "Error: " + error }],
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
];
|
||||
return false;
|
||||
} finally {
|
||||
state.chatSending = false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function abortChatRun(state: ChatState): Promise<boolean> {
|
||||
if (!state.client || !state.connected) return false;
|
||||
const runId = state.chatRunId;
|
||||
try {
|
||||
await state.client.request(
|
||||
"chat.abort",
|
||||
runId
|
||||
? { sessionKey: state.sessionKey, runId }
|
||||
: { sessionKey: state.sessionKey },
|
||||
);
|
||||
return true;
|
||||
} catch (err) {
|
||||
state.lastError = String(err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function handleChatEvent(
|
||||
state: ChatState,
|
||||
payload?: ChatEventPayload,
|
||||
) {
|
||||
if (!payload) return null;
|
||||
if (payload.sessionKey !== state.sessionKey) return null;
|
||||
|
||||
// Final from another run (e.g. sub-agent announce): refresh history to show new message.
|
||||
// See https://github.com/moltbot/moltbot/issues/1909
|
||||
if (
|
||||
payload.runId &&
|
||||
state.chatRunId &&
|
||||
payload.runId !== state.chatRunId
|
||||
) {
|
||||
if (payload.state === "final") return "final";
|
||||
return null;
|
||||
}
|
||||
|
||||
if (payload.state === "delta") {
|
||||
const next = extractText(payload.message);
|
||||
if (typeof next === "string") {
|
||||
const current = state.chatStream ?? "";
|
||||
if (!current || next.length >= current.length) {
|
||||
state.chatStream = next;
|
||||
}
|
||||
}
|
||||
} else if (payload.state === "final") {
|
||||
state.chatStream = null;
|
||||
state.chatRunId = null;
|
||||
state.chatStreamStartedAt = null;
|
||||
} else if (payload.state === "aborted") {
|
||||
state.chatStream = null;
|
||||
state.chatRunId = null;
|
||||
state.chatStreamStartedAt = null;
|
||||
} else if (payload.state === "error") {
|
||||
state.chatStream = null;
|
||||
state.chatRunId = null;
|
||||
state.chatStreamStartedAt = null;
|
||||
state.lastError = payload.errorMessage ?? "chat error";
|
||||
}
|
||||
return payload.state;
|
||||
}
|
||||
174
docker-compose/ez-assistant/ui/src/ui/controllers/config.test.ts
Normal file
174
docker-compose/ez-assistant/ui/src/ui/controllers/config.test.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
applyConfigSnapshot,
|
||||
applyConfig,
|
||||
runUpdate,
|
||||
updateConfigFormValue,
|
||||
type ConfigState,
|
||||
} from "./config";
|
||||
|
||||
function createState(): ConfigState {
|
||||
return {
|
||||
client: null,
|
||||
connected: false,
|
||||
applySessionKey: "main",
|
||||
configLoading: false,
|
||||
configRaw: "",
|
||||
configRawOriginal: "",
|
||||
configValid: null,
|
||||
configIssues: [],
|
||||
configSaving: false,
|
||||
configApplying: false,
|
||||
updateRunning: false,
|
||||
configSnapshot: null,
|
||||
configSchema: null,
|
||||
configSchemaVersion: null,
|
||||
configSchemaLoading: false,
|
||||
configUiHints: {},
|
||||
configForm: null,
|
||||
configFormOriginal: null,
|
||||
configFormDirty: false,
|
||||
configFormMode: "form",
|
||||
lastError: null,
|
||||
};
|
||||
}
|
||||
|
||||
describe("applyConfigSnapshot", () => {
|
||||
it("does not clobber form edits while dirty", () => {
|
||||
const state = createState();
|
||||
state.configFormMode = "form";
|
||||
state.configFormDirty = true;
|
||||
state.configForm = { gateway: { mode: "local", port: 18789 } };
|
||||
state.configRaw = "{\n}\n";
|
||||
|
||||
applyConfigSnapshot(state, {
|
||||
config: { gateway: { mode: "remote", port: 9999 } },
|
||||
valid: true,
|
||||
issues: [],
|
||||
raw: "{\n \"gateway\": { \"mode\": \"remote\", \"port\": 9999 }\n}\n",
|
||||
});
|
||||
|
||||
expect(state.configRaw).toBe(
|
||||
"{\n \"gateway\": {\n \"mode\": \"local\",\n \"port\": 18789\n }\n}\n",
|
||||
);
|
||||
});
|
||||
|
||||
it("updates config form when clean", () => {
|
||||
const state = createState();
|
||||
applyConfigSnapshot(state, {
|
||||
config: { gateway: { mode: "local" } },
|
||||
valid: true,
|
||||
issues: [],
|
||||
raw: "{}",
|
||||
});
|
||||
|
||||
expect(state.configForm).toEqual({ gateway: { mode: "local" } });
|
||||
});
|
||||
|
||||
it("sets configRawOriginal when clean for change detection", () => {
|
||||
const state = createState();
|
||||
applyConfigSnapshot(state, {
|
||||
config: { gateway: { mode: "local" } },
|
||||
valid: true,
|
||||
issues: [],
|
||||
raw: '{ "gateway": { "mode": "local" } }',
|
||||
});
|
||||
|
||||
expect(state.configRawOriginal).toBe('{ "gateway": { "mode": "local" } }');
|
||||
expect(state.configFormOriginal).toEqual({ gateway: { mode: "local" } });
|
||||
});
|
||||
|
||||
it("preserves configRawOriginal when dirty", () => {
|
||||
const state = createState();
|
||||
state.configFormDirty = true;
|
||||
state.configRawOriginal = '{ "original": true }';
|
||||
state.configFormOriginal = { original: true };
|
||||
|
||||
applyConfigSnapshot(state, {
|
||||
config: { gateway: { mode: "local" } },
|
||||
valid: true,
|
||||
issues: [],
|
||||
raw: '{ "gateway": { "mode": "local" } }',
|
||||
});
|
||||
|
||||
// Original values should be preserved when dirty
|
||||
expect(state.configRawOriginal).toBe('{ "original": true }');
|
||||
expect(state.configFormOriginal).toEqual({ original: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateConfigFormValue", () => {
|
||||
it("seeds from snapshot when form is null", () => {
|
||||
const state = createState();
|
||||
state.configSnapshot = {
|
||||
config: { channels: { telegram: { botToken: "t" } }, gateway: { mode: "local" } },
|
||||
valid: true,
|
||||
issues: [],
|
||||
raw: "{}",
|
||||
};
|
||||
|
||||
updateConfigFormValue(state, ["gateway", "port"], 18789);
|
||||
|
||||
expect(state.configFormDirty).toBe(true);
|
||||
expect(state.configForm).toEqual({
|
||||
channels: { telegram: { botToken: "t" } },
|
||||
gateway: { mode: "local", port: 18789 },
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps raw in sync while editing the form", () => {
|
||||
const state = createState();
|
||||
state.configSnapshot = {
|
||||
config: { gateway: { mode: "local" } },
|
||||
valid: true,
|
||||
issues: [],
|
||||
raw: "{\n}\n",
|
||||
};
|
||||
|
||||
updateConfigFormValue(state, ["gateway", "port"], 18789);
|
||||
|
||||
expect(state.configRaw).toBe(
|
||||
"{\n \"gateway\": {\n \"mode\": \"local\",\n \"port\": 18789\n }\n}\n",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyConfig", () => {
|
||||
it("sends config.apply with raw and session key", async () => {
|
||||
const request = vi.fn().mockResolvedValue({});
|
||||
const state = createState();
|
||||
state.connected = true;
|
||||
state.client = { request } as unknown as ConfigState["client"];
|
||||
state.applySessionKey = "agent:main:whatsapp:dm:+15555550123";
|
||||
state.configFormMode = "raw";
|
||||
state.configRaw = "{\n agent: { workspace: \"~/clawd\" }\n}\n";
|
||||
state.configSnapshot = {
|
||||
hash: "hash-123",
|
||||
};
|
||||
|
||||
await applyConfig(state);
|
||||
|
||||
expect(request).toHaveBeenCalledWith("config.apply", {
|
||||
raw: "{\n agent: { workspace: \"~/clawd\" }\n}\n",
|
||||
baseHash: "hash-123",
|
||||
sessionKey: "agent:main:whatsapp:dm:+15555550123",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("runUpdate", () => {
|
||||
it("sends update.run with session key", async () => {
|
||||
const request = vi.fn().mockResolvedValue({});
|
||||
const state = createState();
|
||||
state.connected = true;
|
||||
state.client = { request } as unknown as ConfigState["client"];
|
||||
state.applySessionKey = "agent:main:whatsapp:dm:+15555550123";
|
||||
|
||||
await runUpdate(state);
|
||||
|
||||
expect(request).toHaveBeenCalledWith("update.run", {
|
||||
sessionKey: "agent:main:whatsapp:dm:+15555550123",
|
||||
});
|
||||
});
|
||||
});
|
||||
202
docker-compose/ez-assistant/ui/src/ui/controllers/config.ts
Normal file
202
docker-compose/ez-assistant/ui/src/ui/controllers/config.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
import type { GatewayBrowserClient } from "../gateway";
|
||||
import type {
|
||||
ConfigSchemaResponse,
|
||||
ConfigSnapshot,
|
||||
ConfigUiHints,
|
||||
} from "../types";
|
||||
import {
|
||||
cloneConfigObject,
|
||||
removePathValue,
|
||||
serializeConfigForm,
|
||||
setPathValue,
|
||||
} from "./config/form-utils";
|
||||
|
||||
export type ConfigState = {
|
||||
client: GatewayBrowserClient | null;
|
||||
connected: boolean;
|
||||
applySessionKey: string;
|
||||
configLoading: boolean;
|
||||
configRaw: string;
|
||||
configRawOriginal: string;
|
||||
configValid: boolean | null;
|
||||
configIssues: unknown[];
|
||||
configSaving: boolean;
|
||||
configApplying: boolean;
|
||||
updateRunning: boolean;
|
||||
configSnapshot: ConfigSnapshot | null;
|
||||
configSchema: unknown | null;
|
||||
configSchemaVersion: string | null;
|
||||
configSchemaLoading: boolean;
|
||||
configUiHints: ConfigUiHints;
|
||||
configForm: Record<string, unknown> | null;
|
||||
configFormOriginal: Record<string, unknown> | null;
|
||||
configFormDirty: boolean;
|
||||
configFormMode: "form" | "raw";
|
||||
configSearchQuery: string;
|
||||
configActiveSection: string | null;
|
||||
configActiveSubsection: string | null;
|
||||
lastError: string | null;
|
||||
};
|
||||
|
||||
export async function loadConfig(state: ConfigState) {
|
||||
if (!state.client || !state.connected) return;
|
||||
state.configLoading = true;
|
||||
state.lastError = null;
|
||||
try {
|
||||
const res = (await state.client.request("config.get", {})) as ConfigSnapshot;
|
||||
applyConfigSnapshot(state, res);
|
||||
} catch (err) {
|
||||
state.lastError = String(err);
|
||||
} finally {
|
||||
state.configLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadConfigSchema(state: ConfigState) {
|
||||
if (!state.client || !state.connected) return;
|
||||
if (state.configSchemaLoading) return;
|
||||
state.configSchemaLoading = true;
|
||||
try {
|
||||
const res = (await state.client.request(
|
||||
"config.schema",
|
||||
{},
|
||||
)) as ConfigSchemaResponse;
|
||||
applyConfigSchema(state, res);
|
||||
} catch (err) {
|
||||
state.lastError = String(err);
|
||||
} finally {
|
||||
state.configSchemaLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
export function applyConfigSchema(
|
||||
state: ConfigState,
|
||||
res: ConfigSchemaResponse,
|
||||
) {
|
||||
state.configSchema = res.schema ?? null;
|
||||
state.configUiHints = res.uiHints ?? {};
|
||||
state.configSchemaVersion = res.version ?? null;
|
||||
}
|
||||
|
||||
export function applyConfigSnapshot(state: ConfigState, snapshot: ConfigSnapshot) {
|
||||
state.configSnapshot = snapshot;
|
||||
const rawFromSnapshot =
|
||||
typeof snapshot.raw === "string"
|
||||
? snapshot.raw
|
||||
: snapshot.config && typeof snapshot.config === "object"
|
||||
? serializeConfigForm(snapshot.config as Record<string, unknown>)
|
||||
: state.configRaw;
|
||||
if (!state.configFormDirty || state.configFormMode === "raw") {
|
||||
state.configRaw = rawFromSnapshot;
|
||||
} else if (state.configForm) {
|
||||
state.configRaw = serializeConfigForm(state.configForm);
|
||||
} else {
|
||||
state.configRaw = rawFromSnapshot;
|
||||
}
|
||||
state.configValid = typeof snapshot.valid === "boolean" ? snapshot.valid : null;
|
||||
state.configIssues = Array.isArray(snapshot.issues) ? snapshot.issues : [];
|
||||
|
||||
if (!state.configFormDirty) {
|
||||
state.configForm = cloneConfigObject(snapshot.config ?? {});
|
||||
state.configFormOriginal = cloneConfigObject(snapshot.config ?? {});
|
||||
state.configRawOriginal = rawFromSnapshot;
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveConfig(state: ConfigState) {
|
||||
if (!state.client || !state.connected) return;
|
||||
state.configSaving = true;
|
||||
state.lastError = null;
|
||||
try {
|
||||
const raw =
|
||||
state.configFormMode === "form" && state.configForm
|
||||
? serializeConfigForm(state.configForm)
|
||||
: state.configRaw;
|
||||
const baseHash = state.configSnapshot?.hash;
|
||||
if (!baseHash) {
|
||||
state.lastError = "Config hash missing; reload and retry.";
|
||||
return;
|
||||
}
|
||||
await state.client.request("config.set", { raw, baseHash });
|
||||
state.configFormDirty = false;
|
||||
await loadConfig(state);
|
||||
} catch (err) {
|
||||
state.lastError = String(err);
|
||||
} finally {
|
||||
state.configSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function applyConfig(state: ConfigState) {
|
||||
if (!state.client || !state.connected) return;
|
||||
state.configApplying = true;
|
||||
state.lastError = null;
|
||||
try {
|
||||
const raw =
|
||||
state.configFormMode === "form" && state.configForm
|
||||
? serializeConfigForm(state.configForm)
|
||||
: state.configRaw;
|
||||
const baseHash = state.configSnapshot?.hash;
|
||||
if (!baseHash) {
|
||||
state.lastError = "Config hash missing; reload and retry.";
|
||||
return;
|
||||
}
|
||||
await state.client.request("config.apply", {
|
||||
raw,
|
||||
baseHash,
|
||||
sessionKey: state.applySessionKey,
|
||||
});
|
||||
state.configFormDirty = false;
|
||||
await loadConfig(state);
|
||||
} catch (err) {
|
||||
state.lastError = String(err);
|
||||
} finally {
|
||||
state.configApplying = false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function runUpdate(state: ConfigState) {
|
||||
if (!state.client || !state.connected) return;
|
||||
state.updateRunning = true;
|
||||
state.lastError = null;
|
||||
try {
|
||||
await state.client.request("update.run", {
|
||||
sessionKey: state.applySessionKey,
|
||||
});
|
||||
} catch (err) {
|
||||
state.lastError = String(err);
|
||||
} finally {
|
||||
state.updateRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
export function updateConfigFormValue(
|
||||
state: ConfigState,
|
||||
path: Array<string | number>,
|
||||
value: unknown,
|
||||
) {
|
||||
const base = cloneConfigObject(
|
||||
state.configForm ?? state.configSnapshot?.config ?? {},
|
||||
);
|
||||
setPathValue(base, path, value);
|
||||
state.configForm = base;
|
||||
state.configFormDirty = true;
|
||||
if (state.configFormMode === "form") {
|
||||
state.configRaw = serializeConfigForm(base);
|
||||
}
|
||||
}
|
||||
|
||||
export function removeConfigFormValue(
|
||||
state: ConfigState,
|
||||
path: Array<string | number>,
|
||||
) {
|
||||
const base = cloneConfigObject(
|
||||
state.configForm ?? state.configSnapshot?.config ?? {},
|
||||
);
|
||||
removePathValue(base, path);
|
||||
state.configForm = base;
|
||||
state.configFormDirty = true;
|
||||
if (state.configFormMode === "form") {
|
||||
state.configRaw = serializeConfigForm(base);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
export function cloneConfigObject<T>(value: T): T {
|
||||
if (typeof structuredClone === "function") {
|
||||
return structuredClone(value);
|
||||
}
|
||||
return JSON.parse(JSON.stringify(value)) as T;
|
||||
}
|
||||
|
||||
export function serializeConfigForm(form: Record<string, unknown>): string {
|
||||
return `${JSON.stringify(form, null, 2).trimEnd()}\n`;
|
||||
}
|
||||
|
||||
export function setPathValue(
|
||||
obj: Record<string, unknown> | unknown[],
|
||||
path: Array<string | number>,
|
||||
value: unknown,
|
||||
) {
|
||||
if (path.length === 0) return;
|
||||
let current: Record<string, unknown> | unknown[] = obj;
|
||||
for (let i = 0; i < path.length - 1; i += 1) {
|
||||
const key = path[i];
|
||||
const nextKey = path[i + 1];
|
||||
if (typeof key === "number") {
|
||||
if (!Array.isArray(current)) return;
|
||||
if (current[key] == null) {
|
||||
current[key] =
|
||||
typeof nextKey === "number" ? [] : ({} as Record<string, unknown>);
|
||||
}
|
||||
current = current[key] as Record<string, unknown> | unknown[];
|
||||
} else {
|
||||
if (typeof current !== "object" || current == null) return;
|
||||
const record = current as Record<string, unknown>;
|
||||
if (record[key] == null) {
|
||||
record[key] =
|
||||
typeof nextKey === "number" ? [] : ({} as Record<string, unknown>);
|
||||
}
|
||||
current = record[key] as Record<string, unknown> | unknown[];
|
||||
}
|
||||
}
|
||||
const lastKey = path[path.length - 1];
|
||||
if (typeof lastKey === "number") {
|
||||
if (Array.isArray(current)) current[lastKey] = value;
|
||||
return;
|
||||
}
|
||||
if (typeof current === "object" && current != null) {
|
||||
(current as Record<string, unknown>)[lastKey] = value;
|
||||
}
|
||||
}
|
||||
|
||||
export function removePathValue(
|
||||
obj: Record<string, unknown> | unknown[],
|
||||
path: Array<string | number>,
|
||||
) {
|
||||
if (path.length === 0) return;
|
||||
let current: Record<string, unknown> | unknown[] = obj;
|
||||
for (let i = 0; i < path.length - 1; i += 1) {
|
||||
const key = path[i];
|
||||
if (typeof key === "number") {
|
||||
if (!Array.isArray(current)) return;
|
||||
current = current[key] as Record<string, unknown> | unknown[];
|
||||
} else {
|
||||
if (typeof current !== "object" || current == null) return;
|
||||
current = (current as Record<string, unknown>)[key] as
|
||||
| Record<string, unknown>
|
||||
| unknown[];
|
||||
}
|
||||
if (current == null) return;
|
||||
}
|
||||
const lastKey = path[path.length - 1];
|
||||
if (typeof lastKey === "number") {
|
||||
if (Array.isArray(current)) current.splice(lastKey, 1);
|
||||
return;
|
||||
}
|
||||
if (typeof current === "object" && current != null) {
|
||||
delete (current as Record<string, unknown>)[lastKey];
|
||||
}
|
||||
}
|
||||
192
docker-compose/ez-assistant/ui/src/ui/controllers/cron.ts
Normal file
192
docker-compose/ez-assistant/ui/src/ui/controllers/cron.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import { toNumber } from "../format";
|
||||
import type { GatewayBrowserClient } from "../gateway";
|
||||
import type { CronJob, CronRunLogEntry, CronStatus } from "../types";
|
||||
import type { CronFormState } from "../ui-types";
|
||||
|
||||
export type CronState = {
|
||||
client: GatewayBrowserClient | null;
|
||||
connected: boolean;
|
||||
cronLoading: boolean;
|
||||
cronJobs: CronJob[];
|
||||
cronStatus: CronStatus | null;
|
||||
cronError: string | null;
|
||||
cronForm: CronFormState;
|
||||
cronRunsJobId: string | null;
|
||||
cronRuns: CronRunLogEntry[];
|
||||
cronBusy: boolean;
|
||||
};
|
||||
|
||||
export async function loadCronStatus(state: CronState) {
|
||||
if (!state.client || !state.connected) return;
|
||||
try {
|
||||
const res = (await state.client.request("cron.status", {})) as CronStatus;
|
||||
state.cronStatus = res;
|
||||
} catch (err) {
|
||||
state.cronError = String(err);
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadCronJobs(state: CronState) {
|
||||
if (!state.client || !state.connected) return;
|
||||
if (state.cronLoading) return;
|
||||
state.cronLoading = true;
|
||||
state.cronError = null;
|
||||
try {
|
||||
const res = (await state.client.request("cron.list", {
|
||||
includeDisabled: true,
|
||||
})) as { jobs?: CronJob[] };
|
||||
state.cronJobs = Array.isArray(res.jobs) ? res.jobs : [];
|
||||
} catch (err) {
|
||||
state.cronError = String(err);
|
||||
} finally {
|
||||
state.cronLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
export function buildCronSchedule(form: CronFormState) {
|
||||
if (form.scheduleKind === "at") {
|
||||
const ms = Date.parse(form.scheduleAt);
|
||||
if (!Number.isFinite(ms)) throw new Error("Invalid run time.");
|
||||
return { kind: "at" as const, atMs: ms };
|
||||
}
|
||||
if (form.scheduleKind === "every") {
|
||||
const amount = toNumber(form.everyAmount, 0);
|
||||
if (amount <= 0) throw new Error("Invalid interval amount.");
|
||||
const unit = form.everyUnit;
|
||||
const mult = unit === "minutes" ? 60_000 : unit === "hours" ? 3_600_000 : 86_400_000;
|
||||
return { kind: "every" as const, everyMs: amount * mult };
|
||||
}
|
||||
const expr = form.cronExpr.trim();
|
||||
if (!expr) throw new Error("Cron expression required.");
|
||||
return { kind: "cron" as const, expr, tz: form.cronTz.trim() || undefined };
|
||||
}
|
||||
|
||||
export function buildCronPayload(form: CronFormState) {
|
||||
if (form.payloadKind === "systemEvent") {
|
||||
const text = form.payloadText.trim();
|
||||
if (!text) throw new Error("System event text required.");
|
||||
return { kind: "systemEvent" as const, text };
|
||||
}
|
||||
const message = form.payloadText.trim();
|
||||
if (!message) throw new Error("Agent message required.");
|
||||
const payload: {
|
||||
kind: "agentTurn";
|
||||
message: string;
|
||||
deliver?: boolean;
|
||||
channel?: string;
|
||||
to?: string;
|
||||
timeoutSeconds?: number;
|
||||
} = { kind: "agentTurn", message };
|
||||
if (form.deliver) payload.deliver = true;
|
||||
if (form.channel) payload.channel = form.channel;
|
||||
if (form.to.trim()) payload.to = form.to.trim();
|
||||
const timeoutSeconds = toNumber(form.timeoutSeconds, 0);
|
||||
if (timeoutSeconds > 0) payload.timeoutSeconds = timeoutSeconds;
|
||||
return payload;
|
||||
}
|
||||
|
||||
export async function addCronJob(state: CronState) {
|
||||
if (!state.client || !state.connected || state.cronBusy) return;
|
||||
state.cronBusy = true;
|
||||
state.cronError = null;
|
||||
try {
|
||||
const schedule = buildCronSchedule(state.cronForm);
|
||||
const payload = buildCronPayload(state.cronForm);
|
||||
const agentId = state.cronForm.agentId.trim();
|
||||
const job = {
|
||||
name: state.cronForm.name.trim(),
|
||||
description: state.cronForm.description.trim() || undefined,
|
||||
agentId: agentId || undefined,
|
||||
enabled: state.cronForm.enabled,
|
||||
schedule,
|
||||
sessionTarget: state.cronForm.sessionTarget,
|
||||
wakeMode: state.cronForm.wakeMode,
|
||||
payload,
|
||||
isolation:
|
||||
state.cronForm.postToMainPrefix.trim() &&
|
||||
state.cronForm.sessionTarget === "isolated"
|
||||
? { postToMainPrefix: state.cronForm.postToMainPrefix.trim() }
|
||||
: undefined,
|
||||
};
|
||||
if (!job.name) throw new Error("Name required.");
|
||||
await state.client.request("cron.add", job);
|
||||
state.cronForm = {
|
||||
...state.cronForm,
|
||||
name: "",
|
||||
description: "",
|
||||
payloadText: "",
|
||||
};
|
||||
await loadCronJobs(state);
|
||||
await loadCronStatus(state);
|
||||
} catch (err) {
|
||||
state.cronError = String(err);
|
||||
} finally {
|
||||
state.cronBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function toggleCronJob(
|
||||
state: CronState,
|
||||
job: CronJob,
|
||||
enabled: boolean,
|
||||
) {
|
||||
if (!state.client || !state.connected || state.cronBusy) return;
|
||||
state.cronBusy = true;
|
||||
state.cronError = null;
|
||||
try {
|
||||
await state.client.request("cron.update", { id: job.id, patch: { enabled } });
|
||||
await loadCronJobs(state);
|
||||
await loadCronStatus(state);
|
||||
} catch (err) {
|
||||
state.cronError = String(err);
|
||||
} finally {
|
||||
state.cronBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function runCronJob(state: CronState, job: CronJob) {
|
||||
if (!state.client || !state.connected || state.cronBusy) return;
|
||||
state.cronBusy = true;
|
||||
state.cronError = null;
|
||||
try {
|
||||
await state.client.request("cron.run", { id: job.id, mode: "force" });
|
||||
await loadCronRuns(state, job.id);
|
||||
} catch (err) {
|
||||
state.cronError = String(err);
|
||||
} finally {
|
||||
state.cronBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeCronJob(state: CronState, job: CronJob) {
|
||||
if (!state.client || !state.connected || state.cronBusy) return;
|
||||
state.cronBusy = true;
|
||||
state.cronError = null;
|
||||
try {
|
||||
await state.client.request("cron.remove", { id: job.id });
|
||||
if (state.cronRunsJobId === job.id) {
|
||||
state.cronRunsJobId = null;
|
||||
state.cronRuns = [];
|
||||
}
|
||||
await loadCronJobs(state);
|
||||
await loadCronStatus(state);
|
||||
} catch (err) {
|
||||
state.cronError = String(err);
|
||||
} finally {
|
||||
state.cronBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadCronRuns(state: CronState, jobId: string) {
|
||||
if (!state.client || !state.connected) return;
|
||||
try {
|
||||
const res = (await state.client.request("cron.runs", {
|
||||
id: jobId,
|
||||
limit: 50,
|
||||
})) as { entries?: CronRunLogEntry[] };
|
||||
state.cronRunsJobId = jobId;
|
||||
state.cronRuns = Array.isArray(res.entries) ? res.entries : [];
|
||||
} catch (err) {
|
||||
state.cronError = String(err);
|
||||
}
|
||||
}
|
||||
56
docker-compose/ez-assistant/ui/src/ui/controllers/debug.ts
Normal file
56
docker-compose/ez-assistant/ui/src/ui/controllers/debug.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import type { GatewayBrowserClient } from "../gateway";
|
||||
import type { HealthSnapshot, StatusSummary } from "../types";
|
||||
|
||||
export type DebugState = {
|
||||
client: GatewayBrowserClient | null;
|
||||
connected: boolean;
|
||||
debugLoading: boolean;
|
||||
debugStatus: StatusSummary | null;
|
||||
debugHealth: HealthSnapshot | null;
|
||||
debugModels: unknown[];
|
||||
debugHeartbeat: unknown | null;
|
||||
debugCallMethod: string;
|
||||
debugCallParams: string;
|
||||
debugCallResult: string | null;
|
||||
debugCallError: string | null;
|
||||
};
|
||||
|
||||
export async function loadDebug(state: DebugState) {
|
||||
if (!state.client || !state.connected) return;
|
||||
if (state.debugLoading) return;
|
||||
state.debugLoading = true;
|
||||
try {
|
||||
const [status, health, models, heartbeat] = await Promise.all([
|
||||
state.client.request("status", {}),
|
||||
state.client.request("health", {}),
|
||||
state.client.request("models.list", {}),
|
||||
state.client.request("last-heartbeat", {}),
|
||||
]);
|
||||
state.debugStatus = status as StatusSummary;
|
||||
state.debugHealth = health as HealthSnapshot;
|
||||
const modelPayload = models as { models?: unknown[] } | undefined;
|
||||
state.debugModels = Array.isArray(modelPayload?.models)
|
||||
? modelPayload?.models
|
||||
: [];
|
||||
state.debugHeartbeat = heartbeat as unknown;
|
||||
} catch (err) {
|
||||
state.debugCallError = String(err);
|
||||
} finally {
|
||||
state.debugLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function callDebugMethod(state: DebugState) {
|
||||
if (!state.client || !state.connected) return;
|
||||
state.debugCallError = null;
|
||||
state.debugCallResult = null;
|
||||
try {
|
||||
const params = state.debugCallParams.trim()
|
||||
? (JSON.parse(state.debugCallParams) as unknown)
|
||||
: {};
|
||||
const res = await state.client.request(state.debugCallMethod.trim(), params);
|
||||
state.debugCallResult = JSON.stringify(res, null, 2);
|
||||
} catch (err) {
|
||||
state.debugCallError = String(err);
|
||||
}
|
||||
}
|
||||
135
docker-compose/ez-assistant/ui/src/ui/controllers/devices.ts
Normal file
135
docker-compose/ez-assistant/ui/src/ui/controllers/devices.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import type { GatewayBrowserClient } from "../gateway";
|
||||
import { loadOrCreateDeviceIdentity } from "../device-identity";
|
||||
import { clearDeviceAuthToken, storeDeviceAuthToken } from "../device-auth";
|
||||
|
||||
export type DeviceTokenSummary = {
|
||||
role: string;
|
||||
scopes?: string[];
|
||||
createdAtMs?: number;
|
||||
rotatedAtMs?: number;
|
||||
revokedAtMs?: number;
|
||||
lastUsedAtMs?: number;
|
||||
};
|
||||
|
||||
export type PendingDevice = {
|
||||
requestId: string;
|
||||
deviceId: string;
|
||||
displayName?: string;
|
||||
role?: string;
|
||||
remoteIp?: string;
|
||||
isRepair?: boolean;
|
||||
ts?: number;
|
||||
};
|
||||
|
||||
export type PairedDevice = {
|
||||
deviceId: string;
|
||||
displayName?: string;
|
||||
roles?: string[];
|
||||
scopes?: string[];
|
||||
remoteIp?: string;
|
||||
tokens?: DeviceTokenSummary[];
|
||||
createdAtMs?: number;
|
||||
approvedAtMs?: number;
|
||||
};
|
||||
|
||||
export type DevicePairingList = {
|
||||
pending: PendingDevice[];
|
||||
paired: PairedDevice[];
|
||||
};
|
||||
|
||||
export type DevicesState = {
|
||||
client: GatewayBrowserClient | null;
|
||||
connected: boolean;
|
||||
devicesLoading: boolean;
|
||||
devicesError: string | null;
|
||||
devicesList: DevicePairingList | null;
|
||||
};
|
||||
|
||||
export async function loadDevices(state: DevicesState, opts?: { quiet?: boolean }) {
|
||||
if (!state.client || !state.connected) return;
|
||||
if (state.devicesLoading) return;
|
||||
state.devicesLoading = true;
|
||||
if (!opts?.quiet) state.devicesError = null;
|
||||
try {
|
||||
const res = (await state.client.request("device.pair.list", {})) as DevicePairingList | null;
|
||||
state.devicesList = {
|
||||
pending: Array.isArray(res?.pending) ? res!.pending : [],
|
||||
paired: Array.isArray(res?.paired) ? res!.paired : [],
|
||||
};
|
||||
} catch (err) {
|
||||
if (!opts?.quiet) state.devicesError = String(err);
|
||||
} finally {
|
||||
state.devicesLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function approveDevicePairing(state: DevicesState, requestId: string) {
|
||||
if (!state.client || !state.connected) return;
|
||||
try {
|
||||
await state.client.request("device.pair.approve", { requestId });
|
||||
await loadDevices(state);
|
||||
} catch (err) {
|
||||
state.devicesError = String(err);
|
||||
}
|
||||
}
|
||||
|
||||
export async function rejectDevicePairing(state: DevicesState, requestId: string) {
|
||||
if (!state.client || !state.connected) return;
|
||||
const confirmed = window.confirm("Reject this device pairing request?");
|
||||
if (!confirmed) return;
|
||||
try {
|
||||
await state.client.request("device.pair.reject", { requestId });
|
||||
await loadDevices(state);
|
||||
} catch (err) {
|
||||
state.devicesError = String(err);
|
||||
}
|
||||
}
|
||||
|
||||
export async function rotateDeviceToken(
|
||||
state: DevicesState,
|
||||
params: { deviceId: string; role: string; scopes?: string[] },
|
||||
) {
|
||||
if (!state.client || !state.connected) return;
|
||||
try {
|
||||
const res = (await state.client.request("device.token.rotate", params)) as
|
||||
| { token?: string; role?: string; deviceId?: string; scopes?: string[] }
|
||||
| undefined;
|
||||
if (res?.token) {
|
||||
const identity = await loadOrCreateDeviceIdentity();
|
||||
const role = res.role ?? params.role;
|
||||
if (res.deviceId === identity.deviceId || params.deviceId === identity.deviceId) {
|
||||
storeDeviceAuthToken({
|
||||
deviceId: identity.deviceId,
|
||||
role,
|
||||
token: res.token,
|
||||
scopes: res.scopes ?? params.scopes ?? [],
|
||||
});
|
||||
}
|
||||
window.prompt("New device token (copy and store securely):", res.token);
|
||||
}
|
||||
await loadDevices(state);
|
||||
} catch (err) {
|
||||
state.devicesError = String(err);
|
||||
}
|
||||
}
|
||||
|
||||
export async function revokeDeviceToken(
|
||||
state: DevicesState,
|
||||
params: { deviceId: string; role: string },
|
||||
) {
|
||||
if (!state.client || !state.connected) return;
|
||||
const confirmed = window.confirm(
|
||||
`Revoke token for ${params.deviceId} (${params.role})?`,
|
||||
);
|
||||
if (!confirmed) return;
|
||||
try {
|
||||
await state.client.request("device.token.revoke", params);
|
||||
const identity = await loadOrCreateDeviceIdentity();
|
||||
if (params.deviceId === identity.deviceId) {
|
||||
clearDeviceAuthToken({ deviceId: identity.deviceId, role: params.role });
|
||||
}
|
||||
await loadDevices(state);
|
||||
} catch (err) {
|
||||
state.devicesError = String(err);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
export type ExecApprovalRequestPayload = {
|
||||
command: string;
|
||||
cwd?: string | null;
|
||||
host?: string | null;
|
||||
security?: string | null;
|
||||
ask?: string | null;
|
||||
agentId?: string | null;
|
||||
resolvedPath?: string | null;
|
||||
sessionKey?: string | null;
|
||||
};
|
||||
|
||||
export type ExecApprovalRequest = {
|
||||
id: string;
|
||||
request: ExecApprovalRequestPayload;
|
||||
createdAtMs: number;
|
||||
expiresAtMs: number;
|
||||
};
|
||||
|
||||
export type ExecApprovalResolved = {
|
||||
id: string;
|
||||
decision?: string | null;
|
||||
resolvedBy?: string | null;
|
||||
ts?: number | null;
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
export function parseExecApprovalRequested(payload: unknown): ExecApprovalRequest | null {
|
||||
if (!isRecord(payload)) return null;
|
||||
const id = typeof payload.id === "string" ? payload.id.trim() : "";
|
||||
const request = payload.request;
|
||||
if (!id || !isRecord(request)) return null;
|
||||
const command = typeof request.command === "string" ? request.command.trim() : "";
|
||||
if (!command) return null;
|
||||
const createdAtMs = typeof payload.createdAtMs === "number" ? payload.createdAtMs : 0;
|
||||
const expiresAtMs = typeof payload.expiresAtMs === "number" ? payload.expiresAtMs : 0;
|
||||
if (!createdAtMs || !expiresAtMs) return null;
|
||||
return {
|
||||
id,
|
||||
request: {
|
||||
command,
|
||||
cwd: typeof request.cwd === "string" ? request.cwd : null,
|
||||
host: typeof request.host === "string" ? request.host : null,
|
||||
security: typeof request.security === "string" ? request.security : null,
|
||||
ask: typeof request.ask === "string" ? request.ask : null,
|
||||
agentId: typeof request.agentId === "string" ? request.agentId : null,
|
||||
resolvedPath: typeof request.resolvedPath === "string" ? request.resolvedPath : null,
|
||||
sessionKey: typeof request.sessionKey === "string" ? request.sessionKey : null,
|
||||
},
|
||||
createdAtMs,
|
||||
expiresAtMs,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseExecApprovalResolved(payload: unknown): ExecApprovalResolved | null {
|
||||
if (!isRecord(payload)) return null;
|
||||
const id = typeof payload.id === "string" ? payload.id.trim() : "";
|
||||
if (!id) return null;
|
||||
return {
|
||||
id,
|
||||
decision: typeof payload.decision === "string" ? payload.decision : null,
|
||||
resolvedBy: typeof payload.resolvedBy === "string" ? payload.resolvedBy : null,
|
||||
ts: typeof payload.ts === "number" ? payload.ts : null,
|
||||
};
|
||||
}
|
||||
|
||||
export function pruneExecApprovalQueue(queue: ExecApprovalRequest[]): ExecApprovalRequest[] {
|
||||
const now = Date.now();
|
||||
return queue.filter((entry) => entry.expiresAtMs > now);
|
||||
}
|
||||
|
||||
export function addExecApproval(
|
||||
queue: ExecApprovalRequest[],
|
||||
entry: ExecApprovalRequest,
|
||||
): ExecApprovalRequest[] {
|
||||
const next = pruneExecApprovalQueue(queue).filter((item) => item.id !== entry.id);
|
||||
next.push(entry);
|
||||
return next;
|
||||
}
|
||||
|
||||
export function removeExecApproval(queue: ExecApprovalRequest[], id: string): ExecApprovalRequest[] {
|
||||
return pruneExecApprovalQueue(queue).filter((entry) => entry.id !== id);
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import type { GatewayBrowserClient } from "../gateway";
|
||||
import { cloneConfigObject, removePathValue, setPathValue } from "./config/form-utils";
|
||||
|
||||
export type ExecApprovalsDefaults = {
|
||||
security?: string;
|
||||
ask?: string;
|
||||
askFallback?: string;
|
||||
autoAllowSkills?: boolean;
|
||||
};
|
||||
|
||||
export type ExecApprovalsAllowlistEntry = {
|
||||
id?: string;
|
||||
pattern: string;
|
||||
lastUsedAt?: number;
|
||||
lastUsedCommand?: string;
|
||||
lastResolvedPath?: string;
|
||||
};
|
||||
|
||||
export type ExecApprovalsAgent = ExecApprovalsDefaults & {
|
||||
allowlist?: ExecApprovalsAllowlistEntry[];
|
||||
};
|
||||
|
||||
export type ExecApprovalsFile = {
|
||||
version?: number;
|
||||
socket?: { path?: string };
|
||||
defaults?: ExecApprovalsDefaults;
|
||||
agents?: Record<string, ExecApprovalsAgent>;
|
||||
};
|
||||
|
||||
export type ExecApprovalsSnapshot = {
|
||||
path: string;
|
||||
exists: boolean;
|
||||
hash: string;
|
||||
file: ExecApprovalsFile;
|
||||
};
|
||||
|
||||
export type ExecApprovalsTarget =
|
||||
| { kind: "gateway" }
|
||||
| { kind: "node"; nodeId: string };
|
||||
|
||||
export type ExecApprovalsState = {
|
||||
client: GatewayBrowserClient | null;
|
||||
connected: boolean;
|
||||
execApprovalsLoading: boolean;
|
||||
execApprovalsSaving: boolean;
|
||||
execApprovalsDirty: boolean;
|
||||
execApprovalsSnapshot: ExecApprovalsSnapshot | null;
|
||||
execApprovalsForm: ExecApprovalsFile | null;
|
||||
execApprovalsSelectedAgent: string | null;
|
||||
lastError: string | null;
|
||||
};
|
||||
|
||||
function resolveExecApprovalsRpc(target?: ExecApprovalsTarget | null): {
|
||||
method: string;
|
||||
params: Record<string, unknown>;
|
||||
} | null {
|
||||
if (!target || target.kind === "gateway") {
|
||||
return { method: "exec.approvals.get", params: {} };
|
||||
}
|
||||
const nodeId = target.nodeId.trim();
|
||||
if (!nodeId) return null;
|
||||
return { method: "exec.approvals.node.get", params: { nodeId } };
|
||||
}
|
||||
|
||||
function resolveExecApprovalsSaveRpc(
|
||||
target: ExecApprovalsTarget | null | undefined,
|
||||
params: { file: ExecApprovalsFile; baseHash: string },
|
||||
): { method: string; params: Record<string, unknown> } | null {
|
||||
if (!target || target.kind === "gateway") {
|
||||
return { method: "exec.approvals.set", params };
|
||||
}
|
||||
const nodeId = target.nodeId.trim();
|
||||
if (!nodeId) return null;
|
||||
return { method: "exec.approvals.node.set", params: { ...params, nodeId } };
|
||||
}
|
||||
|
||||
export async function loadExecApprovals(
|
||||
state: ExecApprovalsState,
|
||||
target?: ExecApprovalsTarget | null,
|
||||
) {
|
||||
if (!state.client || !state.connected) return;
|
||||
if (state.execApprovalsLoading) return;
|
||||
state.execApprovalsLoading = true;
|
||||
state.lastError = null;
|
||||
try {
|
||||
const rpc = resolveExecApprovalsRpc(target);
|
||||
if (!rpc) {
|
||||
state.lastError = "Select a node before loading exec approvals.";
|
||||
return;
|
||||
}
|
||||
const res = (await state.client.request(rpc.method, rpc.params)) as ExecApprovalsSnapshot;
|
||||
applyExecApprovalsSnapshot(state, res);
|
||||
} catch (err) {
|
||||
state.lastError = String(err);
|
||||
} finally {
|
||||
state.execApprovalsLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
export function applyExecApprovalsSnapshot(
|
||||
state: ExecApprovalsState,
|
||||
snapshot: ExecApprovalsSnapshot,
|
||||
) {
|
||||
state.execApprovalsSnapshot = snapshot;
|
||||
if (!state.execApprovalsDirty) {
|
||||
state.execApprovalsForm = cloneConfigObject(snapshot.file ?? {});
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveExecApprovals(
|
||||
state: ExecApprovalsState,
|
||||
target?: ExecApprovalsTarget | null,
|
||||
) {
|
||||
if (!state.client || !state.connected) return;
|
||||
state.execApprovalsSaving = true;
|
||||
state.lastError = null;
|
||||
try {
|
||||
const baseHash = state.execApprovalsSnapshot?.hash;
|
||||
if (!baseHash) {
|
||||
state.lastError = "Exec approvals hash missing; reload and retry.";
|
||||
return;
|
||||
}
|
||||
const file =
|
||||
state.execApprovalsForm ??
|
||||
state.execApprovalsSnapshot?.file ??
|
||||
{};
|
||||
const rpc = resolveExecApprovalsSaveRpc(target, { file, baseHash });
|
||||
if (!rpc) {
|
||||
state.lastError = "Select a node before saving exec approvals.";
|
||||
return;
|
||||
}
|
||||
await state.client.request(rpc.method, rpc.params);
|
||||
state.execApprovalsDirty = false;
|
||||
await loadExecApprovals(state, target);
|
||||
} catch (err) {
|
||||
state.lastError = String(err);
|
||||
} finally {
|
||||
state.execApprovalsSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
export function updateExecApprovalsFormValue(
|
||||
state: ExecApprovalsState,
|
||||
path: Array<string | number>,
|
||||
value: unknown,
|
||||
) {
|
||||
const base = cloneConfigObject(
|
||||
state.execApprovalsForm ?? state.execApprovalsSnapshot?.file ?? {},
|
||||
);
|
||||
setPathValue(base, path, value);
|
||||
state.execApprovalsForm = base;
|
||||
state.execApprovalsDirty = true;
|
||||
}
|
||||
|
||||
export function removeExecApprovalsFormValue(
|
||||
state: ExecApprovalsState,
|
||||
path: Array<string | number>,
|
||||
) {
|
||||
const base = cloneConfigObject(
|
||||
state.execApprovalsForm ?? state.execApprovalsSnapshot?.file ?? {},
|
||||
);
|
||||
removePathValue(base, path);
|
||||
state.execApprovalsForm = base;
|
||||
state.execApprovalsDirty = true;
|
||||
}
|
||||
136
docker-compose/ez-assistant/ui/src/ui/controllers/logs.ts
Normal file
136
docker-compose/ez-assistant/ui/src/ui/controllers/logs.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import type { GatewayBrowserClient } from "../gateway";
|
||||
import type { LogEntry, LogLevel } from "../types";
|
||||
|
||||
export type LogsState = {
|
||||
client: GatewayBrowserClient | null;
|
||||
connected: boolean;
|
||||
logsLoading: boolean;
|
||||
logsError: string | null;
|
||||
logsCursor: number | null;
|
||||
logsFile: string | null;
|
||||
logsEntries: LogEntry[];
|
||||
logsTruncated: boolean;
|
||||
logsLastFetchAt: number | null;
|
||||
logsLimit: number;
|
||||
logsMaxBytes: number;
|
||||
};
|
||||
|
||||
const LOG_BUFFER_LIMIT = 2000;
|
||||
const LEVELS = new Set<LogLevel>([
|
||||
"trace",
|
||||
"debug",
|
||||
"info",
|
||||
"warn",
|
||||
"error",
|
||||
"fatal",
|
||||
]);
|
||||
|
||||
function parseMaybeJsonString(value: unknown) {
|
||||
if (typeof value !== "string") return null;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed) as unknown;
|
||||
if (!parsed || typeof parsed !== "object") return null;
|
||||
return parsed as Record<string, unknown>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeLevel(value: unknown): LogLevel | null {
|
||||
if (typeof value !== "string") return null;
|
||||
const lowered = value.toLowerCase() as LogLevel;
|
||||
return LEVELS.has(lowered) ? lowered : null;
|
||||
}
|
||||
|
||||
export function parseLogLine(line: string): LogEntry {
|
||||
if (!line.trim()) return { raw: line, message: line };
|
||||
try {
|
||||
const obj = JSON.parse(line) as Record<string, unknown>;
|
||||
const meta =
|
||||
obj && typeof obj._meta === "object" && obj._meta !== null
|
||||
? (obj._meta as Record<string, unknown>)
|
||||
: null;
|
||||
const time =
|
||||
typeof obj.time === "string"
|
||||
? obj.time
|
||||
: typeof meta?.date === "string"
|
||||
? meta?.date
|
||||
: null;
|
||||
const level = normalizeLevel(meta?.logLevelName ?? meta?.level);
|
||||
|
||||
const contextCandidate =
|
||||
typeof obj["0"] === "string"
|
||||
? (obj["0"] as string)
|
||||
: typeof meta?.name === "string"
|
||||
? (meta?.name as string)
|
||||
: null;
|
||||
const contextObj = parseMaybeJsonString(contextCandidate);
|
||||
let subsystem: string | null = null;
|
||||
if (contextObj) {
|
||||
if (typeof contextObj.subsystem === "string") subsystem = contextObj.subsystem;
|
||||
else if (typeof contextObj.module === "string") subsystem = contextObj.module;
|
||||
}
|
||||
if (!subsystem && contextCandidate && contextCandidate.length < 120) {
|
||||
subsystem = contextCandidate;
|
||||
}
|
||||
|
||||
let message: string | null = null;
|
||||
if (typeof obj["1"] === "string") message = obj["1"] as string;
|
||||
else if (!contextObj && typeof obj["0"] === "string") message = obj["0"] as string;
|
||||
else if (typeof obj.message === "string") message = obj.message as string;
|
||||
|
||||
return {
|
||||
raw: line,
|
||||
time,
|
||||
level,
|
||||
subsystem,
|
||||
message: message ?? line,
|
||||
meta: meta ?? undefined,
|
||||
};
|
||||
} catch {
|
||||
return { raw: line, message: line };
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadLogs(
|
||||
state: LogsState,
|
||||
opts?: { reset?: boolean; quiet?: boolean },
|
||||
) {
|
||||
if (!state.client || !state.connected) return;
|
||||
if (state.logsLoading && !opts?.quiet) return;
|
||||
if (!opts?.quiet) state.logsLoading = true;
|
||||
state.logsError = null;
|
||||
try {
|
||||
const res = await state.client.request("logs.tail", {
|
||||
cursor: opts?.reset ? undefined : state.logsCursor ?? undefined,
|
||||
limit: state.logsLimit,
|
||||
maxBytes: state.logsMaxBytes,
|
||||
});
|
||||
const payload = res as {
|
||||
file?: string;
|
||||
cursor?: number;
|
||||
size?: number;
|
||||
lines?: unknown;
|
||||
truncated?: boolean;
|
||||
reset?: boolean;
|
||||
};
|
||||
const lines = Array.isArray(payload.lines)
|
||||
? (payload.lines.filter((line) => typeof line === "string") as string[])
|
||||
: [];
|
||||
const entries = lines.map(parseLogLine);
|
||||
const shouldReset = Boolean(opts?.reset || payload.reset || state.logsCursor == null);
|
||||
state.logsEntries = shouldReset
|
||||
? entries
|
||||
: [...state.logsEntries, ...entries].slice(-LOG_BUFFER_LIMIT);
|
||||
if (typeof payload.cursor === "number") state.logsCursor = payload.cursor;
|
||||
if (typeof payload.file === "string") state.logsFile = payload.file;
|
||||
state.logsTruncated = Boolean(payload.truncated);
|
||||
state.logsLastFetchAt = Date.now();
|
||||
} catch (err) {
|
||||
state.logsError = String(err);
|
||||
} finally {
|
||||
if (!opts?.quiet) state.logsLoading = false;
|
||||
}
|
||||
}
|
||||
29
docker-compose/ez-assistant/ui/src/ui/controllers/nodes.ts
Normal file
29
docker-compose/ez-assistant/ui/src/ui/controllers/nodes.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { GatewayBrowserClient } from "../gateway";
|
||||
|
||||
export type NodesState = {
|
||||
client: GatewayBrowserClient | null;
|
||||
connected: boolean;
|
||||
nodesLoading: boolean;
|
||||
nodes: Array<Record<string, unknown>>;
|
||||
lastError: string | null;
|
||||
};
|
||||
|
||||
export async function loadNodes(
|
||||
state: NodesState,
|
||||
opts?: { quiet?: boolean },
|
||||
) {
|
||||
if (!state.client || !state.connected) return;
|
||||
if (state.nodesLoading) return;
|
||||
state.nodesLoading = true;
|
||||
if (!opts?.quiet) state.lastError = null;
|
||||
try {
|
||||
const res = (await state.client.request("node.list", {})) as {
|
||||
nodes?: Array<Record<string, unknown>>;
|
||||
};
|
||||
state.nodes = Array.isArray(res.nodes) ? res.nodes : [];
|
||||
} catch (err) {
|
||||
if (!opts?.quiet) state.lastError = String(err);
|
||||
} finally {
|
||||
state.nodesLoading = false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { GatewayBrowserClient } from "../gateway";
|
||||
import type { PresenceEntry } from "../types";
|
||||
|
||||
export type PresenceState = {
|
||||
client: GatewayBrowserClient | null;
|
||||
connected: boolean;
|
||||
presenceLoading: boolean;
|
||||
presenceEntries: PresenceEntry[];
|
||||
presenceError: string | null;
|
||||
presenceStatus: string | null;
|
||||
};
|
||||
|
||||
export async function loadPresence(state: PresenceState) {
|
||||
if (!state.client || !state.connected) return;
|
||||
if (state.presenceLoading) return;
|
||||
state.presenceLoading = true;
|
||||
state.presenceError = null;
|
||||
state.presenceStatus = null;
|
||||
try {
|
||||
const res = (await state.client.request("system-presence", {})) as
|
||||
| PresenceEntry[]
|
||||
| undefined;
|
||||
if (Array.isArray(res)) {
|
||||
state.presenceEntries = res;
|
||||
state.presenceStatus = res.length === 0 ? "No instances yet." : null;
|
||||
} else {
|
||||
state.presenceEntries = [];
|
||||
state.presenceStatus = "No presence payload.";
|
||||
}
|
||||
} catch (err) {
|
||||
state.presenceError = String(err);
|
||||
} finally {
|
||||
state.presenceLoading = false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { GatewayBrowserClient } from "../gateway";
|
||||
import { toNumber } from "../format";
|
||||
import type { SessionsListResult } from "../types";
|
||||
|
||||
export type SessionsState = {
|
||||
client: GatewayBrowserClient | null;
|
||||
connected: boolean;
|
||||
sessionsLoading: boolean;
|
||||
sessionsResult: SessionsListResult | null;
|
||||
sessionsError: string | null;
|
||||
sessionsFilterActive: string;
|
||||
sessionsFilterLimit: string;
|
||||
sessionsIncludeGlobal: boolean;
|
||||
sessionsIncludeUnknown: boolean;
|
||||
};
|
||||
|
||||
export async function loadSessions(state: SessionsState) {
|
||||
if (!state.client || !state.connected) return;
|
||||
if (state.sessionsLoading) return;
|
||||
state.sessionsLoading = true;
|
||||
state.sessionsError = null;
|
||||
try {
|
||||
const params: Record<string, unknown> = {
|
||||
includeGlobal: state.sessionsIncludeGlobal,
|
||||
includeUnknown: state.sessionsIncludeUnknown,
|
||||
};
|
||||
const activeMinutes = toNumber(state.sessionsFilterActive, 0);
|
||||
const limit = toNumber(state.sessionsFilterLimit, 0);
|
||||
if (activeMinutes > 0) params.activeMinutes = activeMinutes;
|
||||
if (limit > 0) params.limit = limit;
|
||||
const res = (await state.client.request("sessions.list", params)) as
|
||||
| SessionsListResult
|
||||
| undefined;
|
||||
if (res) state.sessionsResult = res;
|
||||
} catch (err) {
|
||||
state.sessionsError = String(err);
|
||||
} finally {
|
||||
state.sessionsLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function patchSession(
|
||||
state: SessionsState,
|
||||
key: string,
|
||||
patch: {
|
||||
label?: string | null;
|
||||
thinkingLevel?: string | null;
|
||||
verboseLevel?: string | null;
|
||||
reasoningLevel?: string | null;
|
||||
},
|
||||
) {
|
||||
if (!state.client || !state.connected) return;
|
||||
const params: Record<string, unknown> = { key };
|
||||
if ("label" in patch) params.label = patch.label;
|
||||
if ("thinkingLevel" in patch) params.thinkingLevel = patch.thinkingLevel;
|
||||
if ("verboseLevel" in patch) params.verboseLevel = patch.verboseLevel;
|
||||
if ("reasoningLevel" in patch) params.reasoningLevel = patch.reasoningLevel;
|
||||
try {
|
||||
await state.client.request("sessions.patch", params);
|
||||
await loadSessions(state);
|
||||
} catch (err) {
|
||||
state.sessionsError = String(err);
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteSession(state: SessionsState, key: string) {
|
||||
if (!state.client || !state.connected) return;
|
||||
if (state.sessionsLoading) return;
|
||||
const confirmed = window.confirm(
|
||||
`Delete session "${key}"?\n\nDeletes the session entry and archives its transcript.`,
|
||||
);
|
||||
if (!confirmed) return;
|
||||
state.sessionsLoading = true;
|
||||
state.sessionsError = null;
|
||||
try {
|
||||
await state.client.request("sessions.delete", { key, deleteTranscript: true });
|
||||
await loadSessions(state);
|
||||
} catch (err) {
|
||||
state.sessionsError = String(err);
|
||||
} finally {
|
||||
state.sessionsLoading = false;
|
||||
}
|
||||
}
|
||||
148
docker-compose/ez-assistant/ui/src/ui/controllers/skills.ts
Normal file
148
docker-compose/ez-assistant/ui/src/ui/controllers/skills.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import type { GatewayBrowserClient } from "../gateway";
|
||||
import type { SkillStatusReport } from "../types";
|
||||
|
||||
export type SkillsState = {
|
||||
client: GatewayBrowserClient | null;
|
||||
connected: boolean;
|
||||
skillsLoading: boolean;
|
||||
skillsReport: SkillStatusReport | null;
|
||||
skillsError: string | null;
|
||||
skillsBusyKey: string | null;
|
||||
skillEdits: Record<string, string>;
|
||||
skillMessages: SkillMessageMap;
|
||||
};
|
||||
|
||||
export type SkillMessage = {
|
||||
kind: "success" | "error";
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type SkillMessageMap = Record<string, SkillMessage>;
|
||||
|
||||
type LoadSkillsOptions = {
|
||||
clearMessages?: boolean;
|
||||
};
|
||||
|
||||
function setSkillMessage(state: SkillsState, key: string, message?: SkillMessage) {
|
||||
if (!key.trim()) return;
|
||||
const next = { ...state.skillMessages };
|
||||
if (message) next[key] = message;
|
||||
else delete next[key];
|
||||
state.skillMessages = next;
|
||||
}
|
||||
|
||||
function getErrorMessage(err: unknown) {
|
||||
if (err instanceof Error) return err.message;
|
||||
return String(err);
|
||||
}
|
||||
|
||||
export async function loadSkills(state: SkillsState, options?: LoadSkillsOptions) {
|
||||
if (options?.clearMessages && Object.keys(state.skillMessages).length > 0) {
|
||||
state.skillMessages = {};
|
||||
}
|
||||
if (!state.client || !state.connected) return;
|
||||
if (state.skillsLoading) return;
|
||||
state.skillsLoading = true;
|
||||
state.skillsError = null;
|
||||
try {
|
||||
const res = (await state.client.request("skills.status", {})) as
|
||||
| SkillStatusReport
|
||||
| undefined;
|
||||
if (res) state.skillsReport = res;
|
||||
} catch (err) {
|
||||
state.skillsError = getErrorMessage(err);
|
||||
} finally {
|
||||
state.skillsLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
export function updateSkillEdit(
|
||||
state: SkillsState,
|
||||
skillKey: string,
|
||||
value: string,
|
||||
) {
|
||||
state.skillEdits = { ...state.skillEdits, [skillKey]: value };
|
||||
}
|
||||
|
||||
export async function updateSkillEnabled(
|
||||
state: SkillsState,
|
||||
skillKey: string,
|
||||
enabled: boolean,
|
||||
) {
|
||||
if (!state.client || !state.connected) return;
|
||||
state.skillsBusyKey = skillKey;
|
||||
state.skillsError = null;
|
||||
try {
|
||||
await state.client.request("skills.update", { skillKey, enabled });
|
||||
await loadSkills(state);
|
||||
setSkillMessage(state, skillKey, {
|
||||
kind: "success",
|
||||
message: enabled ? "Skill enabled" : "Skill disabled",
|
||||
});
|
||||
} catch (err) {
|
||||
const message = getErrorMessage(err);
|
||||
state.skillsError = message;
|
||||
setSkillMessage(state, skillKey, {
|
||||
kind: "error",
|
||||
message,
|
||||
});
|
||||
} finally {
|
||||
state.skillsBusyKey = null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveSkillApiKey(state: SkillsState, skillKey: string) {
|
||||
if (!state.client || !state.connected) return;
|
||||
state.skillsBusyKey = skillKey;
|
||||
state.skillsError = null;
|
||||
try {
|
||||
const apiKey = state.skillEdits[skillKey] ?? "";
|
||||
await state.client.request("skills.update", { skillKey, apiKey });
|
||||
await loadSkills(state);
|
||||
setSkillMessage(state, skillKey, {
|
||||
kind: "success",
|
||||
message: "API key saved",
|
||||
});
|
||||
} catch (err) {
|
||||
const message = getErrorMessage(err);
|
||||
state.skillsError = message;
|
||||
setSkillMessage(state, skillKey, {
|
||||
kind: "error",
|
||||
message,
|
||||
});
|
||||
} finally {
|
||||
state.skillsBusyKey = null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function installSkill(
|
||||
state: SkillsState,
|
||||
skillKey: string,
|
||||
name: string,
|
||||
installId: string,
|
||||
) {
|
||||
if (!state.client || !state.connected) return;
|
||||
state.skillsBusyKey = skillKey;
|
||||
state.skillsError = null;
|
||||
try {
|
||||
const result = (await state.client.request("skills.install", {
|
||||
name,
|
||||
installId,
|
||||
timeoutMs: 120000,
|
||||
})) as { ok?: boolean; message?: string };
|
||||
await loadSkills(state);
|
||||
setSkillMessage(state, skillKey, {
|
||||
kind: "success",
|
||||
message: result?.message ?? "Installed",
|
||||
});
|
||||
} catch (err) {
|
||||
const message = getErrorMessage(err);
|
||||
state.skillsError = message;
|
||||
setSkillMessage(state, skillKey, {
|
||||
kind: "error",
|
||||
message,
|
||||
});
|
||||
} finally {
|
||||
state.skillsBusyKey = null;
|
||||
}
|
||||
}
|
||||
99
docker-compose/ez-assistant/ui/src/ui/device-auth.ts
Normal file
99
docker-compose/ez-assistant/ui/src/ui/device-auth.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
export type DeviceAuthEntry = {
|
||||
token: string;
|
||||
role: string;
|
||||
scopes: string[];
|
||||
updatedAtMs: number;
|
||||
};
|
||||
|
||||
type DeviceAuthStore = {
|
||||
version: 1;
|
||||
deviceId: string;
|
||||
tokens: Record<string, DeviceAuthEntry>;
|
||||
};
|
||||
|
||||
const STORAGE_KEY = "moltbot.device.auth.v1";
|
||||
|
||||
function normalizeRole(role: string): string {
|
||||
return role.trim();
|
||||
}
|
||||
|
||||
function normalizeScopes(scopes: string[] | undefined): string[] {
|
||||
if (!Array.isArray(scopes)) return [];
|
||||
const out = new Set<string>();
|
||||
for (const scope of scopes) {
|
||||
const trimmed = scope.trim();
|
||||
if (trimmed) out.add(trimmed);
|
||||
}
|
||||
return [...out].sort();
|
||||
}
|
||||
|
||||
function readStore(): DeviceAuthStore | null {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw) as DeviceAuthStore;
|
||||
if (!parsed || parsed.version !== 1) return null;
|
||||
if (!parsed.deviceId || typeof parsed.deviceId !== "string") return null;
|
||||
if (!parsed.tokens || typeof parsed.tokens !== "object") return null;
|
||||
return parsed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeStore(store: DeviceAuthStore) {
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(store));
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
|
||||
export function loadDeviceAuthToken(params: {
|
||||
deviceId: string;
|
||||
role: string;
|
||||
}): DeviceAuthEntry | null {
|
||||
const store = readStore();
|
||||
if (!store || store.deviceId !== params.deviceId) return null;
|
||||
const role = normalizeRole(params.role);
|
||||
const entry = store.tokens[role];
|
||||
if (!entry || typeof entry.token !== "string") return null;
|
||||
return entry;
|
||||
}
|
||||
|
||||
export function storeDeviceAuthToken(params: {
|
||||
deviceId: string;
|
||||
role: string;
|
||||
token: string;
|
||||
scopes?: string[];
|
||||
}): DeviceAuthEntry {
|
||||
const role = normalizeRole(params.role);
|
||||
const next: DeviceAuthStore = {
|
||||
version: 1,
|
||||
deviceId: params.deviceId,
|
||||
tokens: {},
|
||||
};
|
||||
const existing = readStore();
|
||||
if (existing && existing.deviceId === params.deviceId) {
|
||||
next.tokens = { ...existing.tokens };
|
||||
}
|
||||
const entry: DeviceAuthEntry = {
|
||||
token: params.token,
|
||||
role,
|
||||
scopes: normalizeScopes(params.scopes),
|
||||
updatedAtMs: Date.now(),
|
||||
};
|
||||
next.tokens[role] = entry;
|
||||
writeStore(next);
|
||||
return entry;
|
||||
}
|
||||
|
||||
export function clearDeviceAuthToken(params: { deviceId: string; role: string }) {
|
||||
const store = readStore();
|
||||
if (!store || store.deviceId !== params.deviceId) return;
|
||||
const role = normalizeRole(params.role);
|
||||
if (!store.tokens[role]) return;
|
||||
const next = { ...store, tokens: { ...store.tokens } };
|
||||
delete next.tokens[role];
|
||||
writeStore(next);
|
||||
}
|
||||
108
docker-compose/ez-assistant/ui/src/ui/device-identity.ts
Normal file
108
docker-compose/ez-assistant/ui/src/ui/device-identity.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { getPublicKeyAsync, signAsync, utils } from "@noble/ed25519";
|
||||
|
||||
type StoredIdentity = {
|
||||
version: 1;
|
||||
deviceId: string;
|
||||
publicKey: string;
|
||||
privateKey: string;
|
||||
createdAtMs: number;
|
||||
};
|
||||
|
||||
export type DeviceIdentity = {
|
||||
deviceId: string;
|
||||
publicKey: string;
|
||||
privateKey: string;
|
||||
};
|
||||
|
||||
const STORAGE_KEY = "moltbot-device-identity-v1";
|
||||
|
||||
function base64UrlEncode(bytes: Uint8Array): string {
|
||||
let binary = "";
|
||||
for (const byte of bytes) binary += String.fromCharCode(byte);
|
||||
return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/g, "");
|
||||
}
|
||||
|
||||
function base64UrlDecode(input: string): Uint8Array {
|
||||
const normalized = input.replaceAll("-", "+").replaceAll("_", "/");
|
||||
const padded = normalized + "=".repeat((4 - (normalized.length % 4)) % 4);
|
||||
const binary = atob(padded);
|
||||
const out = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i += 1) out[i] = binary.charCodeAt(i);
|
||||
return out;
|
||||
}
|
||||
|
||||
function bytesToHex(bytes: Uint8Array): string {
|
||||
return Array.from(bytes)
|
||||
.map((b) => b.toString(16).padStart(2, "0"))
|
||||
.join("");
|
||||
}
|
||||
|
||||
async function fingerprintPublicKey(publicKey: Uint8Array): Promise<string> {
|
||||
const hash = await crypto.subtle.digest("SHA-256", publicKey);
|
||||
return bytesToHex(new Uint8Array(hash));
|
||||
}
|
||||
|
||||
async function generateIdentity(): Promise<DeviceIdentity> {
|
||||
const privateKey = utils.randomSecretKey();
|
||||
const publicKey = await getPublicKeyAsync(privateKey);
|
||||
const deviceId = await fingerprintPublicKey(publicKey);
|
||||
return {
|
||||
deviceId,
|
||||
publicKey: base64UrlEncode(publicKey),
|
||||
privateKey: base64UrlEncode(privateKey),
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadOrCreateDeviceIdentity(): Promise<DeviceIdentity> {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw) as StoredIdentity;
|
||||
if (
|
||||
parsed?.version === 1 &&
|
||||
typeof parsed.deviceId === "string" &&
|
||||
typeof parsed.publicKey === "string" &&
|
||||
typeof parsed.privateKey === "string"
|
||||
) {
|
||||
const derivedId = await fingerprintPublicKey(base64UrlDecode(parsed.publicKey));
|
||||
if (derivedId !== parsed.deviceId) {
|
||||
const updated: StoredIdentity = {
|
||||
...parsed,
|
||||
deviceId: derivedId,
|
||||
};
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(updated));
|
||||
return {
|
||||
deviceId: derivedId,
|
||||
publicKey: parsed.publicKey,
|
||||
privateKey: parsed.privateKey,
|
||||
};
|
||||
}
|
||||
return {
|
||||
deviceId: parsed.deviceId,
|
||||
publicKey: parsed.publicKey,
|
||||
privateKey: parsed.privateKey,
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// fall through to regenerate
|
||||
}
|
||||
|
||||
const identity = await generateIdentity();
|
||||
const stored: StoredIdentity = {
|
||||
version: 1,
|
||||
deviceId: identity.deviceId,
|
||||
publicKey: identity.publicKey,
|
||||
privateKey: identity.privateKey,
|
||||
createdAtMs: Date.now(),
|
||||
};
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(stored));
|
||||
return identity;
|
||||
}
|
||||
|
||||
export async function signDevicePayload(privateKeyBase64Url: string, payload: string) {
|
||||
const key = base64UrlDecode(privateKeyBase64Url);
|
||||
const data = new TextEncoder().encode(payload);
|
||||
const sig = await signAsync(data, key);
|
||||
return base64UrlEncode(sig);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { MoltbotApp } from "./app";
|
||||
|
||||
const originalConnect = MoltbotApp.prototype.connect;
|
||||
|
||||
function mountApp(pathname: string) {
|
||||
window.history.replaceState({}, "", pathname);
|
||||
const app = document.createElement("moltbot-app") as MoltbotApp;
|
||||
document.body.append(app);
|
||||
return app;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
MoltbotApp.prototype.connect = () => {
|
||||
// no-op: avoid real gateway WS connections in browser tests
|
||||
};
|
||||
window.__CLAWDBOT_CONTROL_UI_BASE_PATH__ = undefined;
|
||||
localStorage.clear();
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
MoltbotApp.prototype.connect = originalConnect;
|
||||
window.__CLAWDBOT_CONTROL_UI_BASE_PATH__ = undefined;
|
||||
localStorage.clear();
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
describe("chat focus mode", () => {
|
||||
it("collapses header + sidebar on chat tab only", async () => {
|
||||
const app = mountApp("/chat");
|
||||
await app.updateComplete;
|
||||
|
||||
const shell = app.querySelector(".shell");
|
||||
expect(shell).not.toBeNull();
|
||||
expect(shell?.classList.contains("shell--chat-focus")).toBe(false);
|
||||
|
||||
const toggle = app.querySelector<HTMLButtonElement>(
|
||||
'button[title^="Toggle focus mode"]',
|
||||
);
|
||||
expect(toggle).not.toBeNull();
|
||||
toggle?.click();
|
||||
|
||||
await app.updateComplete;
|
||||
expect(shell?.classList.contains("shell--chat-focus")).toBe(true);
|
||||
|
||||
const link = app.querySelector<HTMLAnchorElement>('a.nav-item[href="/channels"]');
|
||||
expect(link).not.toBeNull();
|
||||
link?.dispatchEvent(
|
||||
new MouseEvent("click", { bubbles: true, cancelable: true, button: 0 }),
|
||||
);
|
||||
|
||||
await app.updateComplete;
|
||||
expect(app.tab).toBe("channels");
|
||||
expect(shell?.classList.contains("shell--chat-focus")).toBe(false);
|
||||
|
||||
const chatLink = app.querySelector<HTMLAnchorElement>('a.nav-item[href="/chat"]');
|
||||
chatLink?.dispatchEvent(
|
||||
new MouseEvent("click", { bubbles: true, cancelable: true, button: 0 }),
|
||||
);
|
||||
|
||||
await app.updateComplete;
|
||||
expect(app.tab).toBe("chat");
|
||||
expect(shell?.classList.contains("shell--chat-focus")).toBe(true);
|
||||
});
|
||||
});
|
||||
41
docker-compose/ez-assistant/ui/src/ui/format.test.ts
Normal file
41
docker-compose/ez-assistant/ui/src/ui/format.test.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { stripThinkingTags } from "./format";
|
||||
|
||||
describe("stripThinkingTags", () => {
|
||||
it("strips <think>…</think> segments", () => {
|
||||
const input = ["<think>", "secret", "</think>", "", "Hello"].join("\n");
|
||||
expect(stripThinkingTags(input)).toBe("Hello");
|
||||
});
|
||||
|
||||
it("strips <thinking>…</thinking> segments", () => {
|
||||
const input = ["<thinking>", "secret", "</thinking>", "", "Hello"].join("\n");
|
||||
expect(stripThinkingTags(input)).toBe("Hello");
|
||||
});
|
||||
|
||||
it("keeps text when tags are unpaired", () => {
|
||||
expect(stripThinkingTags("<think>\nsecret\nHello")).toBe("secret\nHello");
|
||||
expect(stripThinkingTags("Hello\n</think>")).toBe("Hello\n");
|
||||
});
|
||||
|
||||
it("returns original text when no tags exist", () => {
|
||||
expect(stripThinkingTags("Hello")).toBe("Hello");
|
||||
});
|
||||
|
||||
it("strips <final>…</final> segments", () => {
|
||||
const input = "<final>\n\nHello there\n\n</final>";
|
||||
expect(stripThinkingTags(input)).toBe("Hello there\n\n");
|
||||
});
|
||||
|
||||
it("strips mixed <think> and <final> tags", () => {
|
||||
const input = "<think>reasoning</think>\n\n<final>Hello</final>";
|
||||
expect(stripThinkingTags(input)).toBe("Hello");
|
||||
});
|
||||
|
||||
it("handles incomplete <final tag gracefully", () => {
|
||||
// When streaming splits mid-tag, we may see "<final" without closing ">"
|
||||
// This should not crash and should handle gracefully
|
||||
expect(stripThinkingTags("<final\nHello")).toBe("<final\nHello");
|
||||
expect(stripThinkingTags("Hello</final>")).toBe("Hello");
|
||||
});
|
||||
});
|
||||
74
docker-compose/ez-assistant/ui/src/ui/format.ts
Normal file
74
docker-compose/ez-assistant/ui/src/ui/format.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { stripReasoningTagsFromText } from "../../../src/shared/text/reasoning-tags.js";
|
||||
|
||||
export function formatMs(ms?: number | null): string {
|
||||
if (!ms && ms !== 0) return "n/a";
|
||||
return new Date(ms).toLocaleString();
|
||||
}
|
||||
|
||||
export function formatAgo(ms?: number | null): string {
|
||||
if (!ms && ms !== 0) return "n/a";
|
||||
const diff = Date.now() - ms;
|
||||
if (diff < 0) return "just now";
|
||||
const sec = Math.round(diff / 1000);
|
||||
if (sec < 60) return `${sec}s ago`;
|
||||
const min = Math.round(sec / 60);
|
||||
if (min < 60) return `${min}m ago`;
|
||||
const hr = Math.round(min / 60);
|
||||
if (hr < 48) return `${hr}h ago`;
|
||||
const day = Math.round(hr / 24);
|
||||
return `${day}d ago`;
|
||||
}
|
||||
|
||||
export function formatDurationMs(ms?: number | null): string {
|
||||
if (!ms && ms !== 0) return "n/a";
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
const sec = Math.round(ms / 1000);
|
||||
if (sec < 60) return `${sec}s`;
|
||||
const min = Math.round(sec / 60);
|
||||
if (min < 60) return `${min}m`;
|
||||
const hr = Math.round(min / 60);
|
||||
if (hr < 48) return `${hr}h`;
|
||||
const day = Math.round(hr / 24);
|
||||
return `${day}d`;
|
||||
}
|
||||
|
||||
export function formatList(values?: Array<string | null | undefined>): string {
|
||||
if (!values || values.length === 0) return "none";
|
||||
return values.filter((v): v is string => Boolean(v && v.trim())).join(", ");
|
||||
}
|
||||
|
||||
export function clampText(value: string, max = 120): string {
|
||||
if (value.length <= max) return value;
|
||||
return `${value.slice(0, Math.max(0, max - 1))}…`;
|
||||
}
|
||||
|
||||
export function truncateText(value: string, max: number): {
|
||||
text: string;
|
||||
truncated: boolean;
|
||||
total: number;
|
||||
} {
|
||||
if (value.length <= max) {
|
||||
return { text: value, truncated: false, total: value.length };
|
||||
}
|
||||
return {
|
||||
text: value.slice(0, Math.max(0, max)),
|
||||
truncated: true,
|
||||
total: value.length,
|
||||
};
|
||||
}
|
||||
|
||||
export function toNumber(value: string, fallback: number): number {
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) ? n : fallback;
|
||||
}
|
||||
|
||||
export function parseList(input: string): string[] {
|
||||
return input
|
||||
.split(/[,\n]/)
|
||||
.map((v) => v.trim())
|
||||
.filter((v) => v.length > 0);
|
||||
}
|
||||
|
||||
export function stripThinkingTags(value: string): string {
|
||||
return stripReasoningTagsFromText(value, { mode: "preserve", trim: "start" });
|
||||
}
|
||||
297
docker-compose/ez-assistant/ui/src/ui/gateway.ts
Normal file
297
docker-compose/ez-assistant/ui/src/ui/gateway.ts
Normal file
@@ -0,0 +1,297 @@
|
||||
import { generateUUID } from "./uuid";
|
||||
import {
|
||||
GATEWAY_CLIENT_MODES,
|
||||
GATEWAY_CLIENT_NAMES,
|
||||
type GatewayClientMode,
|
||||
type GatewayClientName,
|
||||
} from "../../../src/gateway/protocol/client-info.js";
|
||||
import { buildDeviceAuthPayload } from "../../../src/gateway/device-auth.js";
|
||||
import { loadOrCreateDeviceIdentity, signDevicePayload } from "./device-identity";
|
||||
import { clearDeviceAuthToken, loadDeviceAuthToken, storeDeviceAuthToken } from "./device-auth";
|
||||
|
||||
export type GatewayEventFrame = {
|
||||
type: "event";
|
||||
event: string;
|
||||
payload?: unknown;
|
||||
seq?: number;
|
||||
stateVersion?: { presence: number; health: number };
|
||||
};
|
||||
|
||||
export type GatewayResponseFrame = {
|
||||
type: "res";
|
||||
id: string;
|
||||
ok: boolean;
|
||||
payload?: unknown;
|
||||
error?: { code: string; message: string; details?: unknown };
|
||||
};
|
||||
|
||||
export type GatewayHelloOk = {
|
||||
type: "hello-ok";
|
||||
protocol: number;
|
||||
features?: { methods?: string[]; events?: string[] };
|
||||
snapshot?: unknown;
|
||||
auth?: {
|
||||
deviceToken?: string;
|
||||
role?: string;
|
||||
scopes?: string[];
|
||||
issuedAtMs?: number;
|
||||
};
|
||||
policy?: { tickIntervalMs?: number };
|
||||
};
|
||||
|
||||
type Pending = {
|
||||
resolve: (value: unknown) => void;
|
||||
reject: (err: unknown) => void;
|
||||
};
|
||||
|
||||
export type GatewayBrowserClientOptions = {
|
||||
url: string;
|
||||
token?: string;
|
||||
password?: string;
|
||||
clientName?: GatewayClientName;
|
||||
clientVersion?: string;
|
||||
platform?: string;
|
||||
mode?: GatewayClientMode;
|
||||
instanceId?: string;
|
||||
onHello?: (hello: GatewayHelloOk) => void;
|
||||
onEvent?: (evt: GatewayEventFrame) => void;
|
||||
onClose?: (info: { code: number; reason: string }) => void;
|
||||
onGap?: (info: { expected: number; received: number }) => void;
|
||||
};
|
||||
|
||||
// 4008 = application-defined code (browser rejects 1008 "Policy Violation")
|
||||
const CONNECT_FAILED_CLOSE_CODE = 4008;
|
||||
|
||||
export class GatewayBrowserClient {
|
||||
private ws: WebSocket | null = null;
|
||||
private pending = new Map<string, Pending>();
|
||||
private closed = false;
|
||||
private lastSeq: number | null = null;
|
||||
private connectNonce: string | null = null;
|
||||
private connectSent = false;
|
||||
private connectTimer: number | null = null;
|
||||
private backoffMs = 800;
|
||||
|
||||
constructor(private opts: GatewayBrowserClientOptions) {}
|
||||
|
||||
start() {
|
||||
this.closed = false;
|
||||
this.connect();
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.closed = true;
|
||||
this.ws?.close();
|
||||
this.ws = null;
|
||||
this.flushPending(new Error("gateway client stopped"));
|
||||
}
|
||||
|
||||
get connected() {
|
||||
return this.ws?.readyState === WebSocket.OPEN;
|
||||
}
|
||||
|
||||
private connect() {
|
||||
if (this.closed) return;
|
||||
this.ws = new WebSocket(this.opts.url);
|
||||
this.ws.onopen = () => this.queueConnect();
|
||||
this.ws.onmessage = (ev) => this.handleMessage(String(ev.data ?? ""));
|
||||
this.ws.onclose = (ev) => {
|
||||
const reason = String(ev.reason ?? "");
|
||||
this.ws = null;
|
||||
this.flushPending(new Error(`gateway closed (${ev.code}): ${reason}`));
|
||||
this.opts.onClose?.({ code: ev.code, reason });
|
||||
this.scheduleReconnect();
|
||||
};
|
||||
this.ws.onerror = () => {
|
||||
// ignored; close handler will fire
|
||||
};
|
||||
}
|
||||
|
||||
private scheduleReconnect() {
|
||||
if (this.closed) return;
|
||||
const delay = this.backoffMs;
|
||||
this.backoffMs = Math.min(this.backoffMs * 1.7, 15_000);
|
||||
window.setTimeout(() => this.connect(), delay);
|
||||
}
|
||||
|
||||
private flushPending(err: Error) {
|
||||
for (const [, p] of this.pending) p.reject(err);
|
||||
this.pending.clear();
|
||||
}
|
||||
|
||||
private async sendConnect() {
|
||||
if (this.connectSent) return;
|
||||
this.connectSent = true;
|
||||
if (this.connectTimer !== null) {
|
||||
window.clearTimeout(this.connectTimer);
|
||||
this.connectTimer = null;
|
||||
}
|
||||
|
||||
// crypto.subtle is only available in secure contexts (HTTPS, localhost).
|
||||
// Over plain HTTP, we skip device identity and fall back to token-only auth.
|
||||
// Gateways may reject this unless gateway.controlUi.allowInsecureAuth is enabled.
|
||||
const isSecureContext = typeof crypto !== "undefined" && !!crypto.subtle;
|
||||
|
||||
const scopes = ["operator.admin", "operator.approvals", "operator.pairing"];
|
||||
const role = "operator";
|
||||
let deviceIdentity: Awaited<ReturnType<typeof loadOrCreateDeviceIdentity>> | null = null;
|
||||
let canFallbackToShared = false;
|
||||
let authToken = this.opts.token;
|
||||
|
||||
if (isSecureContext) {
|
||||
deviceIdentity = await loadOrCreateDeviceIdentity();
|
||||
const storedToken = loadDeviceAuthToken({
|
||||
deviceId: deviceIdentity.deviceId,
|
||||
role,
|
||||
})?.token;
|
||||
authToken = storedToken ?? this.opts.token;
|
||||
canFallbackToShared = Boolean(storedToken && this.opts.token);
|
||||
}
|
||||
const auth =
|
||||
authToken || this.opts.password
|
||||
? {
|
||||
token: authToken,
|
||||
password: this.opts.password,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
let device:
|
||||
| {
|
||||
id: string;
|
||||
publicKey: string;
|
||||
signature: string;
|
||||
signedAt: number;
|
||||
nonce: string | undefined;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
if (isSecureContext && deviceIdentity) {
|
||||
const signedAtMs = Date.now();
|
||||
const nonce = this.connectNonce ?? undefined;
|
||||
const payload = buildDeviceAuthPayload({
|
||||
deviceId: deviceIdentity.deviceId,
|
||||
clientId: this.opts.clientName ?? GATEWAY_CLIENT_NAMES.CONTROL_UI,
|
||||
clientMode: this.opts.mode ?? GATEWAY_CLIENT_MODES.WEBCHAT,
|
||||
role,
|
||||
scopes,
|
||||
signedAtMs,
|
||||
token: authToken ?? null,
|
||||
nonce,
|
||||
});
|
||||
const signature = await signDevicePayload(deviceIdentity.privateKey, payload);
|
||||
device = {
|
||||
id: deviceIdentity.deviceId,
|
||||
publicKey: deviceIdentity.publicKey,
|
||||
signature,
|
||||
signedAt: signedAtMs,
|
||||
nonce,
|
||||
};
|
||||
}
|
||||
const params = {
|
||||
minProtocol: 3,
|
||||
maxProtocol: 3,
|
||||
client: {
|
||||
id: this.opts.clientName ?? GATEWAY_CLIENT_NAMES.CONTROL_UI,
|
||||
version: this.opts.clientVersion ?? "dev",
|
||||
platform: this.opts.platform ?? navigator.platform ?? "web",
|
||||
mode: this.opts.mode ?? GATEWAY_CLIENT_MODES.WEBCHAT,
|
||||
instanceId: this.opts.instanceId,
|
||||
},
|
||||
role,
|
||||
scopes,
|
||||
device,
|
||||
caps: [],
|
||||
auth,
|
||||
userAgent: navigator.userAgent,
|
||||
locale: navigator.language,
|
||||
};
|
||||
|
||||
void this.request<GatewayHelloOk>("connect", params)
|
||||
.then((hello) => {
|
||||
if (hello?.auth?.deviceToken && deviceIdentity) {
|
||||
storeDeviceAuthToken({
|
||||
deviceId: deviceIdentity.deviceId,
|
||||
role: hello.auth.role ?? role,
|
||||
token: hello.auth.deviceToken,
|
||||
scopes: hello.auth.scopes ?? [],
|
||||
});
|
||||
}
|
||||
this.backoffMs = 800;
|
||||
this.opts.onHello?.(hello);
|
||||
})
|
||||
.catch(() => {
|
||||
if (canFallbackToShared && deviceIdentity) {
|
||||
clearDeviceAuthToken({ deviceId: deviceIdentity.deviceId, role });
|
||||
}
|
||||
this.ws?.close(CONNECT_FAILED_CLOSE_CODE, "connect failed");
|
||||
});
|
||||
}
|
||||
|
||||
private handleMessage(raw: string) {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const frame = parsed as { type?: unknown };
|
||||
if (frame.type === "event") {
|
||||
const evt = parsed as GatewayEventFrame;
|
||||
if (evt.event === "connect.challenge") {
|
||||
const payload = evt.payload as { nonce?: unknown } | undefined;
|
||||
const nonce = payload && typeof payload.nonce === "string" ? payload.nonce : null;
|
||||
if (nonce) {
|
||||
this.connectNonce = nonce;
|
||||
void this.sendConnect();
|
||||
}
|
||||
return;
|
||||
}
|
||||
const seq = typeof evt.seq === "number" ? evt.seq : null;
|
||||
if (seq !== null) {
|
||||
if (this.lastSeq !== null && seq > this.lastSeq + 1) {
|
||||
this.opts.onGap?.({ expected: this.lastSeq + 1, received: seq });
|
||||
}
|
||||
this.lastSeq = seq;
|
||||
}
|
||||
try {
|
||||
this.opts.onEvent?.(evt);
|
||||
} catch (err) {
|
||||
console.error("[gateway] event handler error:", err);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (frame.type === "res") {
|
||||
const res = parsed as GatewayResponseFrame;
|
||||
const pending = this.pending.get(res.id);
|
||||
if (!pending) return;
|
||||
this.pending.delete(res.id);
|
||||
if (res.ok) pending.resolve(res.payload);
|
||||
else pending.reject(new Error(res.error?.message ?? "request failed"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
request<T = unknown>(method: string, params?: unknown): Promise<T> {
|
||||
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
||||
return Promise.reject(new Error("gateway not connected"));
|
||||
}
|
||||
const id = generateUUID();
|
||||
const frame = { type: "req", id, method, params };
|
||||
const p = new Promise<T>((resolve, reject) => {
|
||||
this.pending.set(id, { resolve: (v) => resolve(v as T), reject });
|
||||
});
|
||||
this.ws.send(JSON.stringify(frame));
|
||||
return p;
|
||||
}
|
||||
|
||||
private queueConnect() {
|
||||
this.connectNonce = null;
|
||||
this.connectSent = false;
|
||||
if (this.connectTimer !== null) window.clearTimeout(this.connectTimer);
|
||||
this.connectTimer = window.setTimeout(() => {
|
||||
void this.sendConnect();
|
||||
}, 750);
|
||||
}
|
||||
}
|
||||
62
docker-compose/ez-assistant/ui/src/ui/icons.ts
Normal file
62
docker-compose/ez-assistant/ui/src/ui/icons.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { html, type TemplateResult } from "lit";
|
||||
|
||||
// Lucide-style SVG icons
|
||||
// All icons use currentColor for stroke
|
||||
|
||||
export const icons = {
|
||||
// Navigation icons
|
||||
messageSquare: html`<svg viewBox="0 0 24 24"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>`,
|
||||
barChart: html`<svg viewBox="0 0 24 24"><line x1="12" x2="12" y1="20" y2="10"/><line x1="18" x2="18" y1="20" y2="4"/><line x1="6" x2="6" y1="20" y2="16"/></svg>`,
|
||||
link: html`<svg viewBox="0 0 24 24"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>`,
|
||||
radio: html`<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="2"/><path d="M16.24 7.76a6 6 0 0 1 0 8.49m-8.48-.01a6 6 0 0 1 0-8.49m11.31-2.82a10 10 0 0 1 0 14.14m-14.14 0a10 10 0 0 1 0-14.14"/></svg>`,
|
||||
fileText: html`<svg viewBox="0 0 24 24"><path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/><polyline points="14 2 14 8 20 8"/><line x1="16" x2="8" y1="13" y2="13"/><line x1="16" x2="8" y1="17" y2="17"/><line x1="10" x2="8" y1="9" y2="9"/></svg>`,
|
||||
zap: html`<svg viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>`,
|
||||
monitor: html`<svg viewBox="0 0 24 24"><rect width="20" height="14" x="2" y="3" rx="2"/><line x1="8" x2="16" y1="21" y2="21"/><line x1="12" x2="12" y1="17" y2="21"/></svg>`,
|
||||
settings: html`<svg viewBox="0 0 24 24"><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/></svg>`,
|
||||
bug: html`<svg viewBox="0 0 24 24"><path d="m8 2 1.88 1.88"/><path d="M14.12 3.88 16 2"/><path d="M9 7.13v-1a3.003 3.003 0 1 1 6 0v1"/><path d="M12 20c-3.3 0-6-2.7-6-6v-3a4 4 0 0 1 4-4h4a4 4 0 0 1 4 4v3c0 3.3-2.7 6-6 6"/><path d="M12 20v-9"/><path d="M6.53 9C4.6 8.8 3 7.1 3 5"/><path d="M6 13H2"/><path d="M3 21c0-2.1 1.7-3.9 3.8-4"/><path d="M20.97 5c0 2.1-1.6 3.8-3.5 4"/><path d="M22 13h-4"/><path d="M17.2 17c2.1.1 3.8 1.9 3.8 4"/></svg>`,
|
||||
scrollText: html`<svg viewBox="0 0 24 24"><path d="M8 21h12a2 2 0 0 0 2-2v-2H10v2a2 2 0 1 1-4 0V5a2 2 0 1 0-4 0v3h4"/><path d="M19 17V5a2 2 0 0 0-2-2H4"/><path d="M15 8h-5"/><path d="M15 12h-5"/></svg>`,
|
||||
folder: html`<svg viewBox="0 0 24 24"><path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/></svg>`,
|
||||
|
||||
// UI icons
|
||||
menu: html`<svg viewBox="0 0 24 24"><line x1="4" x2="20" y1="12" y2="12"/><line x1="4" x2="20" y1="6" y2="6"/><line x1="4" x2="20" y1="18" y2="18"/></svg>`,
|
||||
x: html`<svg viewBox="0 0 24 24"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>`,
|
||||
check: html`<svg viewBox="0 0 24 24"><path d="M20 6 9 17l-5-5"/></svg>`,
|
||||
copy: html`<svg viewBox="0 0 24 24"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>`,
|
||||
search: html`<svg viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>`,
|
||||
brain: html`<svg viewBox="0 0 24 24"><path d="M12 5a3 3 0 1 0-5.997.125 4 4 0 0 0-2.526 5.77 4 4 0 0 0 .556 6.588A4 4 0 1 0 12 18Z"/><path d="M12 5a3 3 0 1 1 5.997.125 4 4 0 0 1 2.526 5.77 4 4 0 0 1-.556 6.588A4 4 0 1 1 12 18Z"/><path d="M15 13a4.5 4.5 0 0 1-3-4 4.5 4.5 0 0 1-3 4"/><path d="M17.599 6.5a3 3 0 0 0 .399-1.375"/><path d="M6.003 5.125A3 3 0 0 0 6.401 6.5"/><path d="M3.477 10.896a4 4 0 0 1 .585-.396"/><path d="M19.938 10.5a4 4 0 0 1 .585.396"/><path d="M6 18a4 4 0 0 1-1.967-.516"/><path d="M19.967 17.484A4 4 0 0 1 18 18"/></svg>`,
|
||||
book: html`<svg viewBox="0 0 24 24"><path d="M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H20v20H6.5a2.5 2.5 0 0 1 0-5H20"/></svg>`,
|
||||
loader: html`<svg viewBox="0 0 24 24"><path d="M12 2v4"/><path d="m16.2 7.8 2.9-2.9"/><path d="M18 12h4"/><path d="m16.2 16.2 2.9 2.9"/><path d="M12 18v4"/><path d="m4.9 19.1 2.9-2.9"/><path d="M2 12h4"/><path d="m4.9 4.9 2.9 2.9"/></svg>`,
|
||||
|
||||
// Tool icons
|
||||
wrench: html`<svg viewBox="0 0 24 24"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>`,
|
||||
fileCode: html`<svg viewBox="0 0 24 24"><path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/><polyline points="14 2 14 8 20 8"/><path d="m10 13-2 2 2 2"/><path d="m14 17 2-2-2-2"/></svg>`,
|
||||
edit: html`<svg viewBox="0 0 24 24"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>`,
|
||||
penLine: html`<svg viewBox="0 0 24 24"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>`,
|
||||
paperclip: html`<svg viewBox="0 0 24 24"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 18 8.84l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>`,
|
||||
globe: html`<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/><path d="M2 12h20"/></svg>`,
|
||||
image: html`<svg viewBox="0 0 24 24"><rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/></svg>`,
|
||||
smartphone: html`<svg viewBox="0 0 24 24"><rect width="14" height="20" x="5" y="2" rx="2" ry="2"/><path d="M12 18h.01"/></svg>`,
|
||||
plug: html`<svg viewBox="0 0 24 24"><path d="M12 22v-5"/><path d="M9 8V2"/><path d="M15 8V2"/><path d="M18 8v5a4 4 0 0 1-4 4h-4a4 4 0 0 1-4-4V8Z"/></svg>`,
|
||||
circle: html`<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/></svg>`,
|
||||
puzzle: html`<svg viewBox="0 0 24 24"><path d="M19.439 7.85c-.049.322.059.648.289.878l1.568 1.568c.47.47.706 1.087.706 1.704s-.235 1.233-.706 1.704l-1.611 1.611a.98.98 0 0 1-.837.276c-.47-.07-.802-.48-.968-.925a2.501 2.501 0 1 0-3.214 3.214c.446.166.855.497.925.968a.979.979 0 0 1-.276.837l-1.61 1.61a2.404 2.404 0 0 1-1.705.707 2.402 2.402 0 0 1-1.704-.706l-1.568-1.568a1.026 1.026 0 0 0-.877-.29c-.493.074-.84.504-1.02.968a2.5 2.5 0 1 1-3.237-3.237c.464-.18.894-.527.967-1.02a1.026 1.026 0 0 0-.289-.877l-1.568-1.568A2.402 2.402 0 0 1 1.998 12c0-.617.236-1.234.706-1.704L4.23 8.77c.24-.24.581-.353.917-.303.515.076.874.54 1.02 1.02a2.5 2.5 0 1 0 3.237-3.237c-.48-.146-.944-.505-1.02-1.02a.98.98 0 0 1 .303-.917l1.526-1.526A2.402 2.402 0 0 1 11.998 2c.617 0 1.234.236 1.704.706l1.568 1.568c.23.23.556.338.877.29.493-.074.84-.504 1.02-.968a2.5 2.5 0 1 1 3.236 3.236c-.464.18-.894.527-.967 1.02Z"/></svg>`,
|
||||
} as const;
|
||||
|
||||
export type IconName = keyof typeof icons;
|
||||
|
||||
export function icon(name: IconName): TemplateResult {
|
||||
return icons[name];
|
||||
}
|
||||
|
||||
export function renderIcon(name: IconName, className = "nav-item__icon"): TemplateResult {
|
||||
return html`<span class=${className} aria-hidden="true">${icons[name]}</span>`;
|
||||
}
|
||||
|
||||
// Legacy function for compatibility
|
||||
export function renderEmojiIcon(iconContent: string | TemplateResult, className: string): TemplateResult {
|
||||
return html`<span class=${className} aria-hidden="true">${iconContent}</span>`;
|
||||
}
|
||||
|
||||
export function setEmojiIcon(target: HTMLElement | null, icon: string): void {
|
||||
if (!target) return;
|
||||
target.textContent = icon;
|
||||
}
|
||||
32
docker-compose/ez-assistant/ui/src/ui/markdown.test.ts
Normal file
32
docker-compose/ez-assistant/ui/src/ui/markdown.test.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { toSanitizedMarkdownHtml } from "./markdown";
|
||||
|
||||
describe("toSanitizedMarkdownHtml", () => {
|
||||
it("renders basic markdown", () => {
|
||||
const html = toSanitizedMarkdownHtml("Hello **world**");
|
||||
expect(html).toContain("<strong>world</strong>");
|
||||
});
|
||||
|
||||
it("strips scripts and unsafe links", () => {
|
||||
const html = toSanitizedMarkdownHtml(
|
||||
[
|
||||
"<script>alert(1)</script>",
|
||||
"",
|
||||
"[x](javascript:alert(1))",
|
||||
"",
|
||||
"[ok](https://example.com)",
|
||||
].join("\n"),
|
||||
);
|
||||
expect(html).not.toContain("<script");
|
||||
expect(html).not.toContain("javascript:");
|
||||
expect(html).toContain("https://example.com");
|
||||
});
|
||||
|
||||
it("renders fenced code blocks", () => {
|
||||
const html = toSanitizedMarkdownHtml(["```ts", "console.log(1)", "```"].join("\n"));
|
||||
expect(html).toContain("<pre>");
|
||||
expect(html).toContain("<code");
|
||||
expect(html).toContain("console.log(1)");
|
||||
});
|
||||
});
|
||||
118
docker-compose/ez-assistant/ui/src/ui/markdown.ts
Normal file
118
docker-compose/ez-assistant/ui/src/ui/markdown.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import DOMPurify from "dompurify";
|
||||
import { marked } from "marked";
|
||||
import { truncateText } from "./format";
|
||||
|
||||
marked.setOptions({
|
||||
gfm: true,
|
||||
breaks: true,
|
||||
mangle: false,
|
||||
});
|
||||
|
||||
const allowedTags = [
|
||||
"a",
|
||||
"b",
|
||||
"blockquote",
|
||||
"br",
|
||||
"code",
|
||||
"del",
|
||||
"em",
|
||||
"h1",
|
||||
"h2",
|
||||
"h3",
|
||||
"h4",
|
||||
"hr",
|
||||
"i",
|
||||
"li",
|
||||
"ol",
|
||||
"p",
|
||||
"pre",
|
||||
"strong",
|
||||
"table",
|
||||
"tbody",
|
||||
"td",
|
||||
"th",
|
||||
"thead",
|
||||
"tr",
|
||||
"ul",
|
||||
];
|
||||
|
||||
const allowedAttrs = ["class", "href", "rel", "target", "title", "start"];
|
||||
|
||||
let hooksInstalled = false;
|
||||
const MARKDOWN_CHAR_LIMIT = 140_000;
|
||||
const MARKDOWN_PARSE_LIMIT = 40_000;
|
||||
const MARKDOWN_CACHE_LIMIT = 200;
|
||||
const MARKDOWN_CACHE_MAX_CHARS = 50_000;
|
||||
const markdownCache = new Map<string, string>();
|
||||
|
||||
function getCachedMarkdown(key: string): string | null {
|
||||
const cached = markdownCache.get(key);
|
||||
if (cached === undefined) return null;
|
||||
markdownCache.delete(key);
|
||||
markdownCache.set(key, cached);
|
||||
return cached;
|
||||
}
|
||||
|
||||
function setCachedMarkdown(key: string, value: string) {
|
||||
markdownCache.set(key, value);
|
||||
if (markdownCache.size <= MARKDOWN_CACHE_LIMIT) return;
|
||||
const oldest = markdownCache.keys().next().value;
|
||||
if (oldest) markdownCache.delete(oldest);
|
||||
}
|
||||
|
||||
function installHooks() {
|
||||
if (hooksInstalled) return;
|
||||
hooksInstalled = true;
|
||||
|
||||
DOMPurify.addHook("afterSanitizeAttributes", (node) => {
|
||||
if (!(node instanceof HTMLAnchorElement)) return;
|
||||
const href = node.getAttribute("href");
|
||||
if (!href) return;
|
||||
node.setAttribute("rel", "noreferrer noopener");
|
||||
node.setAttribute("target", "_blank");
|
||||
});
|
||||
}
|
||||
|
||||
export function toSanitizedMarkdownHtml(markdown: string): string {
|
||||
const input = markdown.trim();
|
||||
if (!input) return "";
|
||||
installHooks();
|
||||
if (input.length <= MARKDOWN_CACHE_MAX_CHARS) {
|
||||
const cached = getCachedMarkdown(input);
|
||||
if (cached !== null) return cached;
|
||||
}
|
||||
const truncated = truncateText(input, MARKDOWN_CHAR_LIMIT);
|
||||
const suffix = truncated.truncated
|
||||
? `\n\n… truncated (${truncated.total} chars, showing first ${truncated.text.length}).`
|
||||
: "";
|
||||
if (truncated.text.length > MARKDOWN_PARSE_LIMIT) {
|
||||
const escaped = escapeHtml(`${truncated.text}${suffix}`);
|
||||
const html = `<pre class="code-block">${escaped}</pre>`;
|
||||
const sanitized = DOMPurify.sanitize(html, {
|
||||
ALLOWED_TAGS: allowedTags,
|
||||
ALLOWED_ATTR: allowedAttrs,
|
||||
});
|
||||
if (input.length <= MARKDOWN_CACHE_MAX_CHARS) {
|
||||
setCachedMarkdown(input, sanitized);
|
||||
}
|
||||
return sanitized;
|
||||
}
|
||||
const rendered = marked.parse(`${truncated.text}${suffix}`) as string;
|
||||
const sanitized = DOMPurify.sanitize(rendered, {
|
||||
ALLOWED_TAGS: allowedTags,
|
||||
ALLOWED_ATTR: allowedAttrs,
|
||||
});
|
||||
if (input.length <= MARKDOWN_CACHE_MAX_CHARS) {
|
||||
setCachedMarkdown(input, sanitized);
|
||||
}
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
182
docker-compose/ez-assistant/ui/src/ui/navigation.browser.test.ts
Normal file
182
docker-compose/ez-assistant/ui/src/ui/navigation.browser.test.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { MoltbotApp } from "./app";
|
||||
import "../styles.css";
|
||||
|
||||
const originalConnect = MoltbotApp.prototype.connect;
|
||||
|
||||
function mountApp(pathname: string) {
|
||||
window.history.replaceState({}, "", pathname);
|
||||
const app = document.createElement("moltbot-app") as MoltbotApp;
|
||||
document.body.append(app);
|
||||
return app;
|
||||
}
|
||||
|
||||
function nextFrame() {
|
||||
return new Promise<void>((resolve) => {
|
||||
requestAnimationFrame(() => resolve());
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
MoltbotApp.prototype.connect = () => {
|
||||
// no-op: avoid real gateway WS connections in browser tests
|
||||
};
|
||||
window.__CLAWDBOT_CONTROL_UI_BASE_PATH__ = undefined;
|
||||
localStorage.clear();
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
MoltbotApp.prototype.connect = originalConnect;
|
||||
window.__CLAWDBOT_CONTROL_UI_BASE_PATH__ = undefined;
|
||||
localStorage.clear();
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
describe("control UI routing", () => {
|
||||
it("hydrates the tab from the location", async () => {
|
||||
const app = mountApp("/sessions");
|
||||
await app.updateComplete;
|
||||
|
||||
expect(app.tab).toBe("sessions");
|
||||
expect(window.location.pathname).toBe("/sessions");
|
||||
});
|
||||
|
||||
it("respects /ui base paths", async () => {
|
||||
const app = mountApp("/ui/cron");
|
||||
await app.updateComplete;
|
||||
|
||||
expect(app.basePath).toBe("/ui");
|
||||
expect(app.tab).toBe("cron");
|
||||
expect(window.location.pathname).toBe("/ui/cron");
|
||||
});
|
||||
|
||||
it("infers nested base paths", async () => {
|
||||
const app = mountApp("/apps/moltbot/cron");
|
||||
await app.updateComplete;
|
||||
|
||||
expect(app.basePath).toBe("/apps/moltbot");
|
||||
expect(app.tab).toBe("cron");
|
||||
expect(window.location.pathname).toBe("/apps/moltbot/cron");
|
||||
});
|
||||
|
||||
it("honors explicit base path overrides", async () => {
|
||||
window.__CLAWDBOT_CONTROL_UI_BASE_PATH__ = "/moltbot";
|
||||
const app = mountApp("/moltbot/sessions");
|
||||
await app.updateComplete;
|
||||
|
||||
expect(app.basePath).toBe("/moltbot");
|
||||
expect(app.tab).toBe("sessions");
|
||||
expect(window.location.pathname).toBe("/moltbot/sessions");
|
||||
});
|
||||
|
||||
it("updates the URL when clicking nav items", async () => {
|
||||
const app = mountApp("/chat");
|
||||
await app.updateComplete;
|
||||
|
||||
const link = app.querySelector<HTMLAnchorElement>(
|
||||
'a.nav-item[href="/channels"]',
|
||||
);
|
||||
expect(link).not.toBeNull();
|
||||
link?.dispatchEvent(
|
||||
new MouseEvent("click", { bubbles: true, cancelable: true, button: 0 }),
|
||||
);
|
||||
|
||||
await app.updateComplete;
|
||||
expect(app.tab).toBe("channels");
|
||||
expect(window.location.pathname).toBe("/channels");
|
||||
});
|
||||
|
||||
it("keeps chat and nav usable on narrow viewports", async () => {
|
||||
const app = mountApp("/chat");
|
||||
await app.updateComplete;
|
||||
|
||||
expect(window.matchMedia("(max-width: 768px)").matches).toBe(true);
|
||||
|
||||
const split = app.querySelector(".chat-split-container") as HTMLElement | null;
|
||||
expect(split).not.toBeNull();
|
||||
if (split) {
|
||||
expect(getComputedStyle(split).position).not.toBe("fixed");
|
||||
}
|
||||
|
||||
const chatMain = app.querySelector(".chat-main") as HTMLElement | null;
|
||||
expect(chatMain).not.toBeNull();
|
||||
if (chatMain) {
|
||||
expect(getComputedStyle(chatMain).display).not.toBe("none");
|
||||
}
|
||||
|
||||
if (split) {
|
||||
split.classList.add("chat-split-container--open");
|
||||
await app.updateComplete;
|
||||
expect(getComputedStyle(split).position).toBe("fixed");
|
||||
}
|
||||
if (chatMain) {
|
||||
expect(getComputedStyle(chatMain).display).toBe("none");
|
||||
}
|
||||
});
|
||||
|
||||
it("auto-scrolls chat history to the latest message", async () => {
|
||||
const app = mountApp("/chat");
|
||||
await app.updateComplete;
|
||||
|
||||
const initialContainer = app.querySelector(".chat-thread") as HTMLElement | null;
|
||||
expect(initialContainer).not.toBeNull();
|
||||
if (!initialContainer) return;
|
||||
initialContainer.style.maxHeight = "180px";
|
||||
initialContainer.style.overflow = "auto";
|
||||
|
||||
app.chatMessages = Array.from({ length: 60 }, (_, index) => ({
|
||||
role: "assistant",
|
||||
content: `Line ${index} - ${"x".repeat(200)}`,
|
||||
timestamp: Date.now() + index,
|
||||
}));
|
||||
|
||||
await app.updateComplete;
|
||||
for (let i = 0; i < 6; i++) {
|
||||
await nextFrame();
|
||||
}
|
||||
|
||||
const container = app.querySelector(".chat-thread") as HTMLElement | null;
|
||||
expect(container).not.toBeNull();
|
||||
if (!container) return;
|
||||
const maxScroll = container.scrollHeight - container.clientHeight;
|
||||
expect(maxScroll).toBeGreaterThan(0);
|
||||
for (let i = 0; i < 10; i++) {
|
||||
if (container.scrollTop === maxScroll) break;
|
||||
await nextFrame();
|
||||
}
|
||||
expect(container.scrollTop).toBe(maxScroll);
|
||||
});
|
||||
|
||||
it("hydrates token from URL params and strips it", async () => {
|
||||
const app = mountApp("/ui/overview?token=abc123");
|
||||
await app.updateComplete;
|
||||
|
||||
expect(app.settings.token).toBe("abc123");
|
||||
expect(window.location.pathname).toBe("/ui/overview");
|
||||
expect(window.location.search).toBe("");
|
||||
});
|
||||
|
||||
it("hydrates password from URL params and strips it", async () => {
|
||||
const app = mountApp("/ui/overview?password=sekret");
|
||||
await app.updateComplete;
|
||||
|
||||
expect(app.password).toBe("sekret");
|
||||
expect(window.location.pathname).toBe("/ui/overview");
|
||||
expect(window.location.search).toBe("");
|
||||
});
|
||||
|
||||
it("hydrates token from URL params even when settings already set", async () => {
|
||||
localStorage.setItem(
|
||||
"moltbot.control.settings.v1",
|
||||
JSON.stringify({ token: "existing-token" }),
|
||||
);
|
||||
const app = mountApp("/ui/overview?token=abc123");
|
||||
await app.updateComplete;
|
||||
|
||||
expect(app.settings.token).toBe("abc123");
|
||||
expect(window.location.pathname).toBe("/ui/overview");
|
||||
expect(window.location.search).toBe("");
|
||||
});
|
||||
});
|
||||
190
docker-compose/ez-assistant/ui/src/ui/navigation.test.ts
Normal file
190
docker-compose/ez-assistant/ui/src/ui/navigation.test.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
TAB_GROUPS,
|
||||
iconForTab,
|
||||
inferBasePathFromPathname,
|
||||
normalizeBasePath,
|
||||
normalizePath,
|
||||
pathForTab,
|
||||
subtitleForTab,
|
||||
tabFromPath,
|
||||
titleForTab,
|
||||
type Tab,
|
||||
} from "./navigation";
|
||||
|
||||
/** All valid tab identifiers derived from TAB_GROUPS */
|
||||
const ALL_TABS: Tab[] = TAB_GROUPS.flatMap((group) => group.tabs) as Tab[];
|
||||
|
||||
describe("iconForTab", () => {
|
||||
it("returns a non-empty string for every tab", () => {
|
||||
for (const tab of ALL_TABS) {
|
||||
const icon = iconForTab(tab);
|
||||
expect(icon).toBeTruthy();
|
||||
expect(typeof icon).toBe("string");
|
||||
expect(icon.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns stable icons for known tabs", () => {
|
||||
expect(iconForTab("chat")).toBe("💬");
|
||||
expect(iconForTab("overview")).toBe("📊");
|
||||
expect(iconForTab("channels")).toBe("🔗");
|
||||
expect(iconForTab("instances")).toBe("📡");
|
||||
expect(iconForTab("sessions")).toBe("📄");
|
||||
expect(iconForTab("cron")).toBe("⏰");
|
||||
expect(iconForTab("skills")).toBe("⚡️");
|
||||
expect(iconForTab("nodes")).toBe("🖥️");
|
||||
expect(iconForTab("config")).toBe("⚙️");
|
||||
expect(iconForTab("debug")).toBe("🐞");
|
||||
expect(iconForTab("logs")).toBe("🧾");
|
||||
});
|
||||
|
||||
it("returns a fallback icon for unknown tab", () => {
|
||||
// TypeScript won't allow this normally, but runtime could receive unexpected values
|
||||
const unknownTab = "unknown" as Tab;
|
||||
expect(iconForTab(unknownTab)).toBe("📁");
|
||||
});
|
||||
});
|
||||
|
||||
describe("titleForTab", () => {
|
||||
it("returns a non-empty string for every tab", () => {
|
||||
for (const tab of ALL_TABS) {
|
||||
const title = titleForTab(tab);
|
||||
expect(title).toBeTruthy();
|
||||
expect(typeof title).toBe("string");
|
||||
}
|
||||
});
|
||||
|
||||
it("returns expected titles", () => {
|
||||
expect(titleForTab("chat")).toBe("Chat");
|
||||
expect(titleForTab("overview")).toBe("Overview");
|
||||
expect(titleForTab("cron")).toBe("Cron Jobs");
|
||||
});
|
||||
});
|
||||
|
||||
describe("subtitleForTab", () => {
|
||||
it("returns a string for every tab", () => {
|
||||
for (const tab of ALL_TABS) {
|
||||
const subtitle = subtitleForTab(tab);
|
||||
expect(typeof subtitle).toBe("string");
|
||||
}
|
||||
});
|
||||
|
||||
it("returns descriptive subtitles", () => {
|
||||
expect(subtitleForTab("chat")).toContain("chat session");
|
||||
expect(subtitleForTab("config")).toContain("moltbot.json");
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeBasePath", () => {
|
||||
it("returns empty string for falsy input", () => {
|
||||
expect(normalizeBasePath("")).toBe("");
|
||||
});
|
||||
|
||||
it("adds leading slash if missing", () => {
|
||||
expect(normalizeBasePath("ui")).toBe("/ui");
|
||||
});
|
||||
|
||||
it("removes trailing slash", () => {
|
||||
expect(normalizeBasePath("/ui/")).toBe("/ui");
|
||||
});
|
||||
|
||||
it("returns empty string for root path", () => {
|
||||
expect(normalizeBasePath("/")).toBe("");
|
||||
});
|
||||
|
||||
it("handles nested paths", () => {
|
||||
expect(normalizeBasePath("/apps/moltbot")).toBe("/apps/moltbot");
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizePath", () => {
|
||||
it("returns / for falsy input", () => {
|
||||
expect(normalizePath("")).toBe("/");
|
||||
});
|
||||
|
||||
it("adds leading slash if missing", () => {
|
||||
expect(normalizePath("chat")).toBe("/chat");
|
||||
});
|
||||
|
||||
it("removes trailing slash except for root", () => {
|
||||
expect(normalizePath("/chat/")).toBe("/chat");
|
||||
expect(normalizePath("/")).toBe("/");
|
||||
});
|
||||
});
|
||||
|
||||
describe("pathForTab", () => {
|
||||
it("returns correct path without base", () => {
|
||||
expect(pathForTab("chat")).toBe("/chat");
|
||||
expect(pathForTab("overview")).toBe("/overview");
|
||||
});
|
||||
|
||||
it("prepends base path", () => {
|
||||
expect(pathForTab("chat", "/ui")).toBe("/ui/chat");
|
||||
expect(pathForTab("sessions", "/apps/moltbot")).toBe("/apps/moltbot/sessions");
|
||||
});
|
||||
});
|
||||
|
||||
describe("tabFromPath", () => {
|
||||
it("returns tab for valid path", () => {
|
||||
expect(tabFromPath("/chat")).toBe("chat");
|
||||
expect(tabFromPath("/overview")).toBe("overview");
|
||||
expect(tabFromPath("/sessions")).toBe("sessions");
|
||||
});
|
||||
|
||||
it("returns chat for root path", () => {
|
||||
expect(tabFromPath("/")).toBe("chat");
|
||||
});
|
||||
|
||||
it("handles base paths", () => {
|
||||
expect(tabFromPath("/ui/chat", "/ui")).toBe("chat");
|
||||
expect(tabFromPath("/apps/moltbot/sessions", "/apps/moltbot")).toBe("sessions");
|
||||
});
|
||||
|
||||
it("returns null for unknown path", () => {
|
||||
expect(tabFromPath("/unknown")).toBeNull();
|
||||
});
|
||||
|
||||
it("is case-insensitive", () => {
|
||||
expect(tabFromPath("/CHAT")).toBe("chat");
|
||||
expect(tabFromPath("/Overview")).toBe("overview");
|
||||
});
|
||||
});
|
||||
|
||||
describe("inferBasePathFromPathname", () => {
|
||||
it("returns empty string for root", () => {
|
||||
expect(inferBasePathFromPathname("/")).toBe("");
|
||||
});
|
||||
|
||||
it("returns empty string for direct tab path", () => {
|
||||
expect(inferBasePathFromPathname("/chat")).toBe("");
|
||||
expect(inferBasePathFromPathname("/overview")).toBe("");
|
||||
});
|
||||
|
||||
it("infers base path from nested paths", () => {
|
||||
expect(inferBasePathFromPathname("/ui/chat")).toBe("/ui");
|
||||
expect(inferBasePathFromPathname("/apps/moltbot/sessions")).toBe("/apps/moltbot");
|
||||
});
|
||||
|
||||
it("handles index.html suffix", () => {
|
||||
expect(inferBasePathFromPathname("/index.html")).toBe("");
|
||||
expect(inferBasePathFromPathname("/ui/index.html")).toBe("/ui");
|
||||
});
|
||||
});
|
||||
|
||||
describe("TAB_GROUPS", () => {
|
||||
it("contains all expected groups", () => {
|
||||
const labels = TAB_GROUPS.map((g) => g.label);
|
||||
expect(labels).toContain("Chat");
|
||||
expect(labels).toContain("Control");
|
||||
expect(labels).toContain("Agent");
|
||||
expect(labels).toContain("Settings");
|
||||
});
|
||||
|
||||
it("all tabs are unique", () => {
|
||||
const allTabs = TAB_GROUPS.flatMap((g) => g.tabs);
|
||||
const uniqueTabs = new Set(allTabs);
|
||||
expect(uniqueTabs.size).toBe(allTabs.length);
|
||||
});
|
||||
});
|
||||
188
docker-compose/ez-assistant/ui/src/ui/navigation.ts
Normal file
188
docker-compose/ez-assistant/ui/src/ui/navigation.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
import type { IconName } from "./icons.js";
|
||||
|
||||
export const TAB_GROUPS = [
|
||||
{ label: "Chat", tabs: ["chat"] },
|
||||
{
|
||||
label: "Control",
|
||||
tabs: ["overview", "channels", "instances", "sessions", "cron"],
|
||||
},
|
||||
{ label: "Agent", tabs: ["skills", "nodes"] },
|
||||
{ label: "Settings", tabs: ["config", "debug", "logs"] },
|
||||
] as const;
|
||||
|
||||
export type Tab =
|
||||
| "overview"
|
||||
| "channels"
|
||||
| "instances"
|
||||
| "sessions"
|
||||
| "cron"
|
||||
| "skills"
|
||||
| "nodes"
|
||||
| "chat"
|
||||
| "config"
|
||||
| "debug"
|
||||
| "logs";
|
||||
|
||||
const TAB_PATHS: Record<Tab, string> = {
|
||||
overview: "/overview",
|
||||
channels: "/channels",
|
||||
instances: "/instances",
|
||||
sessions: "/sessions",
|
||||
cron: "/cron",
|
||||
skills: "/skills",
|
||||
nodes: "/nodes",
|
||||
chat: "/chat",
|
||||
config: "/config",
|
||||
debug: "/debug",
|
||||
logs: "/logs",
|
||||
};
|
||||
|
||||
const PATH_TO_TAB = new Map(
|
||||
Object.entries(TAB_PATHS).map(([tab, path]) => [path, tab as Tab]),
|
||||
);
|
||||
|
||||
export function normalizeBasePath(basePath: string): string {
|
||||
if (!basePath) return "";
|
||||
let base = basePath.trim();
|
||||
if (!base.startsWith("/")) base = `/${base}`;
|
||||
if (base === "/") return "";
|
||||
if (base.endsWith("/")) base = base.slice(0, -1);
|
||||
return base;
|
||||
}
|
||||
|
||||
export function normalizePath(path: string): string {
|
||||
if (!path) return "/";
|
||||
let normalized = path.trim();
|
||||
if (!normalized.startsWith("/")) normalized = `/${normalized}`;
|
||||
if (normalized.length > 1 && normalized.endsWith("/")) {
|
||||
normalized = normalized.slice(0, -1);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function pathForTab(tab: Tab, basePath = ""): string {
|
||||
const base = normalizeBasePath(basePath);
|
||||
const path = TAB_PATHS[tab];
|
||||
return base ? `${base}${path}` : path;
|
||||
}
|
||||
|
||||
export function tabFromPath(pathname: string, basePath = ""): Tab | null {
|
||||
const base = normalizeBasePath(basePath);
|
||||
let path = pathname || "/";
|
||||
if (base) {
|
||||
if (path === base) {
|
||||
path = "/";
|
||||
} else if (path.startsWith(`${base}/`)) {
|
||||
path = path.slice(base.length);
|
||||
}
|
||||
}
|
||||
let normalized = normalizePath(path).toLowerCase();
|
||||
if (normalized.endsWith("/index.html")) normalized = "/";
|
||||
if (normalized === "/") return "chat";
|
||||
return PATH_TO_TAB.get(normalized) ?? null;
|
||||
}
|
||||
|
||||
export function inferBasePathFromPathname(pathname: string): string {
|
||||
let normalized = normalizePath(pathname);
|
||||
if (normalized.endsWith("/index.html")) {
|
||||
normalized = normalizePath(normalized.slice(0, -"/index.html".length));
|
||||
}
|
||||
if (normalized === "/") return "";
|
||||
const segments = normalized.split("/").filter(Boolean);
|
||||
if (segments.length === 0) return "";
|
||||
for (let i = 0; i < segments.length; i++) {
|
||||
const candidate = `/${segments.slice(i).join("/")}`.toLowerCase();
|
||||
if (PATH_TO_TAB.has(candidate)) {
|
||||
const prefix = segments.slice(0, i);
|
||||
return prefix.length ? `/${prefix.join("/")}` : "";
|
||||
}
|
||||
}
|
||||
return `/${segments.join("/")}`;
|
||||
}
|
||||
|
||||
export function iconForTab(tab: Tab): IconName {
|
||||
switch (tab) {
|
||||
case "chat":
|
||||
return "messageSquare";
|
||||
case "overview":
|
||||
return "barChart";
|
||||
case "channels":
|
||||
return "link";
|
||||
case "instances":
|
||||
return "radio";
|
||||
case "sessions":
|
||||
return "fileText";
|
||||
case "cron":
|
||||
return "loader";
|
||||
case "skills":
|
||||
return "zap";
|
||||
case "nodes":
|
||||
return "monitor";
|
||||
case "config":
|
||||
return "settings";
|
||||
case "debug":
|
||||
return "bug";
|
||||
case "logs":
|
||||
return "scrollText";
|
||||
default:
|
||||
return "folder";
|
||||
}
|
||||
}
|
||||
|
||||
export function titleForTab(tab: Tab) {
|
||||
switch (tab) {
|
||||
case "overview":
|
||||
return "Overview";
|
||||
case "channels":
|
||||
return "Channels";
|
||||
case "instances":
|
||||
return "Instances";
|
||||
case "sessions":
|
||||
return "Sessions";
|
||||
case "cron":
|
||||
return "Cron Jobs";
|
||||
case "skills":
|
||||
return "Skills";
|
||||
case "nodes":
|
||||
return "Nodes";
|
||||
case "chat":
|
||||
return "Chat";
|
||||
case "config":
|
||||
return "Config";
|
||||
case "debug":
|
||||
return "Debug";
|
||||
case "logs":
|
||||
return "Logs";
|
||||
default:
|
||||
return "Control";
|
||||
}
|
||||
}
|
||||
|
||||
export function subtitleForTab(tab: Tab) {
|
||||
switch (tab) {
|
||||
case "overview":
|
||||
return "Gateway status, entry points, and a fast health read.";
|
||||
case "channels":
|
||||
return "Manage channels and settings.";
|
||||
case "instances":
|
||||
return "Presence beacons from connected clients and nodes.";
|
||||
case "sessions":
|
||||
return "Inspect active sessions and adjust per-session defaults.";
|
||||
case "cron":
|
||||
return "Schedule wakeups and recurring agent runs.";
|
||||
case "skills":
|
||||
return "Manage skill availability and API key injection.";
|
||||
case "nodes":
|
||||
return "Paired devices, capabilities, and command exposure.";
|
||||
case "chat":
|
||||
return "Direct gateway chat session for quick interventions.";
|
||||
case "config":
|
||||
return "Edit ~/.clawdbot/moltbot.json safely.";
|
||||
case "debug":
|
||||
return "Gateway snapshots, events, and manual RPC calls.";
|
||||
case "logs":
|
||||
return "Live tail of the gateway file logs.";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
57
docker-compose/ez-assistant/ui/src/ui/presenter.ts
Normal file
57
docker-compose/ez-assistant/ui/src/ui/presenter.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { formatAgo, formatDurationMs, formatMs } from "./format";
|
||||
import type { CronJob, GatewaySessionRow, PresenceEntry } from "./types";
|
||||
|
||||
export function formatPresenceSummary(entry: PresenceEntry): string {
|
||||
const host = entry.host ?? "unknown";
|
||||
const ip = entry.ip ? `(${entry.ip})` : "";
|
||||
const mode = entry.mode ?? "";
|
||||
const version = entry.version ?? "";
|
||||
return `${host} ${ip} ${mode} ${version}`.trim();
|
||||
}
|
||||
|
||||
export function formatPresenceAge(entry: PresenceEntry): string {
|
||||
const ts = entry.ts ?? null;
|
||||
return ts ? formatAgo(ts) : "n/a";
|
||||
}
|
||||
|
||||
export function formatNextRun(ms?: number | null) {
|
||||
if (!ms) return "n/a";
|
||||
return `${formatMs(ms)} (${formatAgo(ms)})`;
|
||||
}
|
||||
|
||||
export function formatSessionTokens(row: GatewaySessionRow) {
|
||||
if (row.totalTokens == null) return "n/a";
|
||||
const total = row.totalTokens ?? 0;
|
||||
const ctx = row.contextTokens ?? 0;
|
||||
return ctx ? `${total} / ${ctx}` : String(total);
|
||||
}
|
||||
|
||||
export function formatEventPayload(payload: unknown): string {
|
||||
if (payload == null) return "";
|
||||
try {
|
||||
return JSON.stringify(payload, null, 2);
|
||||
} catch {
|
||||
return String(payload);
|
||||
}
|
||||
}
|
||||
|
||||
export function formatCronState(job: CronJob) {
|
||||
const state = job.state ?? {};
|
||||
const next = state.nextRunAtMs ? formatMs(state.nextRunAtMs) : "n/a";
|
||||
const last = state.lastRunAtMs ? formatMs(state.lastRunAtMs) : "n/a";
|
||||
const status = state.lastStatus ?? "n/a";
|
||||
return `${status} · next ${next} · last ${last}`;
|
||||
}
|
||||
|
||||
export function formatCronSchedule(job: CronJob) {
|
||||
const s = job.schedule;
|
||||
if (s.kind === "at") return `At ${formatMs(s.atMs)}`;
|
||||
if (s.kind === "every") return `Every ${formatDurationMs(s.everyMs)}`;
|
||||
return `Cron ${s.expr}${s.tz ? ` (${s.tz})` : ""}`;
|
||||
}
|
||||
|
||||
export function formatCronPayload(job: CronJob) {
|
||||
const p = job.payload;
|
||||
if (p.kind === "systemEvent") return `System: ${p.text}`;
|
||||
return `Agent: ${p.message}`;
|
||||
}
|
||||
95
docker-compose/ez-assistant/ui/src/ui/storage.ts
Normal file
95
docker-compose/ez-assistant/ui/src/ui/storage.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
const KEY = "moltbot.control.settings.v1";
|
||||
|
||||
import type { ThemeMode } from "./theme";
|
||||
|
||||
export type UiSettings = {
|
||||
gatewayUrl: string;
|
||||
token: string;
|
||||
sessionKey: string;
|
||||
lastActiveSessionKey: string;
|
||||
theme: ThemeMode;
|
||||
chatFocusMode: boolean;
|
||||
chatShowThinking: boolean;
|
||||
splitRatio: number; // Sidebar split ratio (0.4 to 0.7, default 0.6)
|
||||
navCollapsed: boolean; // Collapsible sidebar state
|
||||
navGroupsCollapsed: Record<string, boolean>; // Which nav groups are collapsed
|
||||
};
|
||||
|
||||
export function loadSettings(): UiSettings {
|
||||
const defaultUrl = (() => {
|
||||
const proto = location.protocol === "https:" ? "wss" : "ws";
|
||||
return `${proto}://${location.host}`;
|
||||
})();
|
||||
|
||||
const defaults: UiSettings = {
|
||||
gatewayUrl: defaultUrl,
|
||||
token: "",
|
||||
sessionKey: "main",
|
||||
lastActiveSessionKey: "main",
|
||||
theme: "system",
|
||||
chatFocusMode: false,
|
||||
chatShowThinking: true,
|
||||
splitRatio: 0.6,
|
||||
navCollapsed: false,
|
||||
navGroupsCollapsed: {},
|
||||
};
|
||||
|
||||
try {
|
||||
const raw = localStorage.getItem(KEY);
|
||||
if (!raw) return defaults;
|
||||
const parsed = JSON.parse(raw) as Partial<UiSettings>;
|
||||
return {
|
||||
gatewayUrl:
|
||||
typeof parsed.gatewayUrl === "string" && parsed.gatewayUrl.trim()
|
||||
? parsed.gatewayUrl.trim()
|
||||
: defaults.gatewayUrl,
|
||||
token: typeof parsed.token === "string" ? parsed.token : defaults.token,
|
||||
sessionKey:
|
||||
typeof parsed.sessionKey === "string" && parsed.sessionKey.trim()
|
||||
? parsed.sessionKey.trim()
|
||||
: defaults.sessionKey,
|
||||
lastActiveSessionKey:
|
||||
typeof parsed.lastActiveSessionKey === "string" &&
|
||||
parsed.lastActiveSessionKey.trim()
|
||||
? parsed.lastActiveSessionKey.trim()
|
||||
: (typeof parsed.sessionKey === "string" &&
|
||||
parsed.sessionKey.trim()) ||
|
||||
defaults.lastActiveSessionKey,
|
||||
theme:
|
||||
parsed.theme === "light" ||
|
||||
parsed.theme === "dark" ||
|
||||
parsed.theme === "system"
|
||||
? parsed.theme
|
||||
: defaults.theme,
|
||||
chatFocusMode:
|
||||
typeof parsed.chatFocusMode === "boolean"
|
||||
? parsed.chatFocusMode
|
||||
: defaults.chatFocusMode,
|
||||
chatShowThinking:
|
||||
typeof parsed.chatShowThinking === "boolean"
|
||||
? parsed.chatShowThinking
|
||||
: defaults.chatShowThinking,
|
||||
splitRatio:
|
||||
typeof parsed.splitRatio === "number" &&
|
||||
parsed.splitRatio >= 0.4 &&
|
||||
parsed.splitRatio <= 0.7
|
||||
? parsed.splitRatio
|
||||
: defaults.splitRatio,
|
||||
navCollapsed:
|
||||
typeof parsed.navCollapsed === "boolean"
|
||||
? parsed.navCollapsed
|
||||
: defaults.navCollapsed,
|
||||
navGroupsCollapsed:
|
||||
typeof parsed.navGroupsCollapsed === "object" &&
|
||||
parsed.navGroupsCollapsed !== null
|
||||
? parsed.navGroupsCollapsed
|
||||
: defaults.navGroupsCollapsed,
|
||||
};
|
||||
} catch {
|
||||
return defaults;
|
||||
}
|
||||
}
|
||||
|
||||
export function saveSettings(next: UiSettings) {
|
||||
localStorage.setItem(KEY, JSON.stringify(next));
|
||||
}
|
||||
106
docker-compose/ez-assistant/ui/src/ui/theme-transition.ts
Normal file
106
docker-compose/ez-assistant/ui/src/ui/theme-transition.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import type { ThemeMode } from "./theme";
|
||||
|
||||
export type ThemeTransitionContext = {
|
||||
element?: HTMLElement | null;
|
||||
pointerClientX?: number;
|
||||
pointerClientY?: number;
|
||||
};
|
||||
|
||||
export type ThemeTransitionOptions = {
|
||||
nextTheme: ThemeMode;
|
||||
applyTheme: () => void;
|
||||
context?: ThemeTransitionContext;
|
||||
currentTheme?: ThemeMode | null;
|
||||
};
|
||||
|
||||
type DocumentWithViewTransition = Document & {
|
||||
startViewTransition?: (callback: () => void) => { finished: Promise<void> };
|
||||
};
|
||||
|
||||
const clamp01 = (value: number) => {
|
||||
if (Number.isNaN(value)) return 0.5;
|
||||
if (value <= 0) return 0;
|
||||
if (value >= 1) return 1;
|
||||
return value;
|
||||
};
|
||||
|
||||
const hasReducedMotionPreference = () => {
|
||||
if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
|
||||
return false;
|
||||
}
|
||||
return window.matchMedia("(prefers-reduced-motion: reduce)").matches ?? false;
|
||||
};
|
||||
|
||||
const cleanupThemeTransition = (root: HTMLElement) => {
|
||||
root.classList.remove("theme-transition");
|
||||
root.style.removeProperty("--theme-switch-x");
|
||||
root.style.removeProperty("--theme-switch-y");
|
||||
};
|
||||
|
||||
export const startThemeTransition = ({
|
||||
nextTheme,
|
||||
applyTheme,
|
||||
context,
|
||||
currentTheme,
|
||||
}: ThemeTransitionOptions) => {
|
||||
if (currentTheme === nextTheme) return;
|
||||
|
||||
const documentReference = globalThis.document ?? null;
|
||||
if (!documentReference) {
|
||||
applyTheme();
|
||||
return;
|
||||
}
|
||||
|
||||
const root = documentReference.documentElement;
|
||||
const document_ = documentReference as DocumentWithViewTransition;
|
||||
const prefersReducedMotion = hasReducedMotionPreference();
|
||||
|
||||
const canUseViewTransition =
|
||||
Boolean(document_.startViewTransition) && !prefersReducedMotion;
|
||||
|
||||
if (canUseViewTransition) {
|
||||
let xPercent = 0.5;
|
||||
let yPercent = 0.5;
|
||||
|
||||
if (
|
||||
context?.pointerClientX !== undefined &&
|
||||
context?.pointerClientY !== undefined &&
|
||||
typeof window !== "undefined"
|
||||
) {
|
||||
xPercent = clamp01(context.pointerClientX / window.innerWidth);
|
||||
yPercent = clamp01(context.pointerClientY / window.innerHeight);
|
||||
} else if (context?.element) {
|
||||
const rect = context.element.getBoundingClientRect();
|
||||
if (
|
||||
rect.width > 0 &&
|
||||
rect.height > 0 &&
|
||||
typeof window !== "undefined"
|
||||
) {
|
||||
xPercent = clamp01((rect.left + rect.width / 2) / window.innerWidth);
|
||||
yPercent = clamp01((rect.top + rect.height / 2) / window.innerHeight);
|
||||
}
|
||||
}
|
||||
|
||||
root.style.setProperty("--theme-switch-x", `${xPercent * 100}%`);
|
||||
root.style.setProperty("--theme-switch-y", `${yPercent * 100}%`);
|
||||
root.classList.add("theme-transition");
|
||||
|
||||
try {
|
||||
const transition = document_.startViewTransition?.(() => {
|
||||
applyTheme();
|
||||
});
|
||||
if (transition?.finished) {
|
||||
void transition.finished.finally(() => cleanupThemeTransition(root));
|
||||
} else {
|
||||
cleanupThemeTransition(root);
|
||||
}
|
||||
} catch {
|
||||
cleanupThemeTransition(root);
|
||||
applyTheme();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
applyTheme();
|
||||
cleanupThemeTransition(root);
|
||||
};
|
||||
16
docker-compose/ez-assistant/ui/src/ui/theme.ts
Normal file
16
docker-compose/ez-assistant/ui/src/ui/theme.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export type ThemeMode = "system" | "light" | "dark";
|
||||
export type ResolvedTheme = "light" | "dark";
|
||||
|
||||
export function getSystemTheme(): ResolvedTheme {
|
||||
if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
|
||||
return "dark";
|
||||
}
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||
? "dark"
|
||||
: "light";
|
||||
}
|
||||
|
||||
export function resolveTheme(mode: ThemeMode): ResolvedTheme {
|
||||
if (mode === "system") return getSystemTheme();
|
||||
return mode;
|
||||
}
|
||||
224
docker-compose/ez-assistant/ui/src/ui/tool-display.json
Normal file
224
docker-compose/ez-assistant/ui/src/ui/tool-display.json
Normal file
@@ -0,0 +1,224 @@
|
||||
{
|
||||
"version": 1,
|
||||
"fallback": {
|
||||
"icon": "puzzle",
|
||||
"detailKeys": [
|
||||
"command",
|
||||
"path",
|
||||
"url",
|
||||
"targetUrl",
|
||||
"targetId",
|
||||
"ref",
|
||||
"element",
|
||||
"node",
|
||||
"nodeId",
|
||||
"id",
|
||||
"requestId",
|
||||
"to",
|
||||
"channelId",
|
||||
"guildId",
|
||||
"userId",
|
||||
"name",
|
||||
"query",
|
||||
"pattern",
|
||||
"messageId"
|
||||
]
|
||||
},
|
||||
"tools": {
|
||||
"bash": {
|
||||
"icon": "wrench",
|
||||
"title": "Bash",
|
||||
"detailKeys": ["command"]
|
||||
},
|
||||
"process": {
|
||||
"icon": "wrench",
|
||||
"title": "Process",
|
||||
"detailKeys": ["sessionId"]
|
||||
},
|
||||
"read": {
|
||||
"icon": "fileText",
|
||||
"title": "Read",
|
||||
"detailKeys": ["path"]
|
||||
},
|
||||
"write": {
|
||||
"icon": "edit",
|
||||
"title": "Write",
|
||||
"detailKeys": ["path"]
|
||||
},
|
||||
"edit": {
|
||||
"icon": "penLine",
|
||||
"title": "Edit",
|
||||
"detailKeys": ["path"]
|
||||
},
|
||||
"attach": {
|
||||
"icon": "paperclip",
|
||||
"title": "Attach",
|
||||
"detailKeys": ["path", "url", "fileName"]
|
||||
},
|
||||
"browser": {
|
||||
"icon": "globe",
|
||||
"title": "Browser",
|
||||
"actions": {
|
||||
"status": { "label": "status" },
|
||||
"start": { "label": "start" },
|
||||
"stop": { "label": "stop" },
|
||||
"tabs": { "label": "tabs" },
|
||||
"open": { "label": "open", "detailKeys": ["targetUrl"] },
|
||||
"focus": { "label": "focus", "detailKeys": ["targetId"] },
|
||||
"close": { "label": "close", "detailKeys": ["targetId"] },
|
||||
"snapshot": {
|
||||
"label": "snapshot",
|
||||
"detailKeys": ["targetUrl", "targetId", "ref", "element", "format"]
|
||||
},
|
||||
"screenshot": {
|
||||
"label": "screenshot",
|
||||
"detailKeys": ["targetUrl", "targetId", "ref", "element"]
|
||||
},
|
||||
"navigate": {
|
||||
"label": "navigate",
|
||||
"detailKeys": ["targetUrl", "targetId"]
|
||||
},
|
||||
"console": { "label": "console", "detailKeys": ["level", "targetId"] },
|
||||
"pdf": { "label": "pdf", "detailKeys": ["targetId"] },
|
||||
"upload": {
|
||||
"label": "upload",
|
||||
"detailKeys": ["paths", "ref", "inputRef", "element", "targetId"]
|
||||
},
|
||||
"dialog": {
|
||||
"label": "dialog",
|
||||
"detailKeys": ["accept", "promptText", "targetId"]
|
||||
},
|
||||
"act": {
|
||||
"label": "act",
|
||||
"detailKeys": ["request.kind", "request.ref", "request.selector", "request.text", "request.value"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"canvas": {
|
||||
"icon": "image",
|
||||
"title": "Canvas",
|
||||
"actions": {
|
||||
"present": { "label": "present", "detailKeys": ["target", "node", "nodeId"] },
|
||||
"hide": { "label": "hide", "detailKeys": ["node", "nodeId"] },
|
||||
"navigate": { "label": "navigate", "detailKeys": ["url", "node", "nodeId"] },
|
||||
"eval": { "label": "eval", "detailKeys": ["javaScript", "node", "nodeId"] },
|
||||
"snapshot": { "label": "snapshot", "detailKeys": ["format", "node", "nodeId"] },
|
||||
"a2ui_push": { "label": "A2UI push", "detailKeys": ["jsonlPath", "node", "nodeId"] },
|
||||
"a2ui_reset": { "label": "A2UI reset", "detailKeys": ["node", "nodeId"] }
|
||||
}
|
||||
},
|
||||
"nodes": {
|
||||
"icon": "smartphone",
|
||||
"title": "Nodes",
|
||||
"actions": {
|
||||
"status": { "label": "status" },
|
||||
"describe": { "label": "describe", "detailKeys": ["node", "nodeId"] },
|
||||
"pending": { "label": "pending" },
|
||||
"approve": { "label": "approve", "detailKeys": ["requestId"] },
|
||||
"reject": { "label": "reject", "detailKeys": ["requestId"] },
|
||||
"notify": { "label": "notify", "detailKeys": ["node", "nodeId", "title", "body"] },
|
||||
"camera_snap": { "label": "camera snap", "detailKeys": ["node", "nodeId", "facing", "deviceId"] },
|
||||
"camera_list": { "label": "camera list", "detailKeys": ["node", "nodeId"] },
|
||||
"camera_clip": { "label": "camera clip", "detailKeys": ["node", "nodeId", "facing", "duration", "durationMs"] },
|
||||
"screen_record": {
|
||||
"label": "screen record",
|
||||
"detailKeys": ["node", "nodeId", "duration", "durationMs", "fps", "screenIndex"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"cron": {
|
||||
"icon": "loader",
|
||||
"title": "Cron",
|
||||
"actions": {
|
||||
"status": { "label": "status" },
|
||||
"list": { "label": "list" },
|
||||
"add": {
|
||||
"label": "add",
|
||||
"detailKeys": ["job.name", "job.id", "job.schedule", "job.cron"]
|
||||
},
|
||||
"update": { "label": "update", "detailKeys": ["id"] },
|
||||
"remove": { "label": "remove", "detailKeys": ["id"] },
|
||||
"run": { "label": "run", "detailKeys": ["id"] },
|
||||
"runs": { "label": "runs", "detailKeys": ["id"] },
|
||||
"wake": { "label": "wake", "detailKeys": ["text", "mode"] }
|
||||
}
|
||||
},
|
||||
"gateway": {
|
||||
"icon": "plug",
|
||||
"title": "Gateway",
|
||||
"actions": {
|
||||
"restart": { "label": "restart", "detailKeys": ["reason", "delayMs"] },
|
||||
"config.get": { "label": "config get" },
|
||||
"config.schema": { "label": "config schema" },
|
||||
"config.apply": {
|
||||
"label": "config apply",
|
||||
"detailKeys": ["restartDelayMs"]
|
||||
},
|
||||
"update.run": {
|
||||
"label": "update run",
|
||||
"detailKeys": ["restartDelayMs"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"whatsapp_login": {
|
||||
"icon": "circle",
|
||||
"title": "WhatsApp Login",
|
||||
"actions": {
|
||||
"start": { "label": "start" },
|
||||
"wait": { "label": "wait" }
|
||||
}
|
||||
},
|
||||
"discord": {
|
||||
"icon": "messageSquare",
|
||||
"title": "Discord",
|
||||
"actions": {
|
||||
"react": { "label": "react", "detailKeys": ["channelId", "messageId", "emoji"] },
|
||||
"reactions": { "label": "reactions", "detailKeys": ["channelId", "messageId"] },
|
||||
"sticker": { "label": "sticker", "detailKeys": ["to", "stickerIds"] },
|
||||
"poll": { "label": "poll", "detailKeys": ["question", "to"] },
|
||||
"permissions": { "label": "permissions", "detailKeys": ["channelId"] },
|
||||
"readMessages": { "label": "read messages", "detailKeys": ["channelId", "limit"] },
|
||||
"sendMessage": { "label": "send", "detailKeys": ["to", "content"] },
|
||||
"editMessage": { "label": "edit", "detailKeys": ["channelId", "messageId"] },
|
||||
"deleteMessage": { "label": "delete", "detailKeys": ["channelId", "messageId"] },
|
||||
"threadCreate": { "label": "thread create", "detailKeys": ["channelId", "name"] },
|
||||
"threadList": { "label": "thread list", "detailKeys": ["guildId", "channelId"] },
|
||||
"threadReply": { "label": "thread reply", "detailKeys": ["channelId", "content"] },
|
||||
"pinMessage": { "label": "pin", "detailKeys": ["channelId", "messageId"] },
|
||||
"unpinMessage": { "label": "unpin", "detailKeys": ["channelId", "messageId"] },
|
||||
"listPins": { "label": "list pins", "detailKeys": ["channelId"] },
|
||||
"searchMessages": { "label": "search", "detailKeys": ["guildId", "content"] },
|
||||
"memberInfo": { "label": "member", "detailKeys": ["guildId", "userId"] },
|
||||
"roleInfo": { "label": "roles", "detailKeys": ["guildId"] },
|
||||
"emojiList": { "label": "emoji list", "detailKeys": ["guildId"] },
|
||||
"roleAdd": { "label": "role add", "detailKeys": ["guildId", "userId", "roleId"] },
|
||||
"roleRemove": { "label": "role remove", "detailKeys": ["guildId", "userId", "roleId"] },
|
||||
"channelInfo": { "label": "channel", "detailKeys": ["channelId"] },
|
||||
"channelList": { "label": "channels", "detailKeys": ["guildId"] },
|
||||
"voiceStatus": { "label": "voice", "detailKeys": ["guildId", "userId"] },
|
||||
"eventList": { "label": "events", "detailKeys": ["guildId"] },
|
||||
"eventCreate": { "label": "event create", "detailKeys": ["guildId", "name"] },
|
||||
"timeout": { "label": "timeout", "detailKeys": ["guildId", "userId"] },
|
||||
"kick": { "label": "kick", "detailKeys": ["guildId", "userId"] },
|
||||
"ban": { "label": "ban", "detailKeys": ["guildId", "userId"] }
|
||||
}
|
||||
},
|
||||
"slack": {
|
||||
"icon": "messageSquare",
|
||||
"title": "Slack",
|
||||
"actions": {
|
||||
"react": { "label": "react", "detailKeys": ["channelId", "messageId", "emoji"] },
|
||||
"reactions": { "label": "reactions", "detailKeys": ["channelId", "messageId"] },
|
||||
"sendMessage": { "label": "send", "detailKeys": ["to", "content"] },
|
||||
"editMessage": { "label": "edit", "detailKeys": ["channelId", "messageId"] },
|
||||
"deleteMessage": { "label": "delete", "detailKeys": ["channelId", "messageId"] },
|
||||
"readMessages": { "label": "read messages", "detailKeys": ["channelId", "limit"] },
|
||||
"pinMessage": { "label": "pin", "detailKeys": ["channelId", "messageId"] },
|
||||
"unpinMessage": { "label": "unpin", "detailKeys": ["channelId", "messageId"] },
|
||||
"listPins": { "label": "list pins", "detailKeys": ["channelId"] },
|
||||
"memberInfo": { "label": "member", "detailKeys": ["userId"] },
|
||||
"emojiList": { "label": "emoji list" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
198
docker-compose/ez-assistant/ui/src/ui/tool-display.ts
Normal file
198
docker-compose/ez-assistant/ui/src/ui/tool-display.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
import rawConfig from "./tool-display.json";
|
||||
import type { IconName } from "./icons";
|
||||
|
||||
type ToolDisplayActionSpec = {
|
||||
label?: string;
|
||||
detailKeys?: string[];
|
||||
};
|
||||
|
||||
type ToolDisplaySpec = {
|
||||
icon?: string;
|
||||
title?: string;
|
||||
label?: string;
|
||||
detailKeys?: string[];
|
||||
actions?: Record<string, ToolDisplayActionSpec>;
|
||||
};
|
||||
|
||||
type ToolDisplayConfig = {
|
||||
version?: number;
|
||||
fallback?: ToolDisplaySpec;
|
||||
tools?: Record<string, ToolDisplaySpec>;
|
||||
};
|
||||
|
||||
export type ToolDisplay = {
|
||||
name: string;
|
||||
icon: IconName;
|
||||
title: string;
|
||||
label: string;
|
||||
verb?: string;
|
||||
detail?: string;
|
||||
};
|
||||
|
||||
const TOOL_DISPLAY_CONFIG = rawConfig as ToolDisplayConfig;
|
||||
const FALLBACK = TOOL_DISPLAY_CONFIG.fallback ?? { icon: "puzzle" };
|
||||
const TOOL_MAP = TOOL_DISPLAY_CONFIG.tools ?? {};
|
||||
|
||||
function normalizeToolName(name?: string): string {
|
||||
return (name ?? "tool").trim();
|
||||
}
|
||||
|
||||
function defaultTitle(name: string): string {
|
||||
const cleaned = name.replace(/_/g, " ").trim();
|
||||
if (!cleaned) return "Tool";
|
||||
return cleaned
|
||||
.split(/\s+/)
|
||||
.map((part) =>
|
||||
part.length <= 2 && part.toUpperCase() === part
|
||||
? part
|
||||
: `${part.at(0)?.toUpperCase() ?? ""}${part.slice(1)}`,
|
||||
)
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
function normalizeVerb(value?: string): string | undefined {
|
||||
const trimmed = value?.trim();
|
||||
if (!trimmed) return undefined;
|
||||
return trimmed.replace(/_/g, " ");
|
||||
}
|
||||
|
||||
function coerceDisplayValue(value: unknown): string | undefined {
|
||||
if (value === null || value === undefined) return undefined;
|
||||
if (typeof value === "string") {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return undefined;
|
||||
const firstLine = trimmed.split(/\r?\n/)[0]?.trim() ?? "";
|
||||
if (!firstLine) return undefined;
|
||||
return firstLine.length > 160 ? `${firstLine.slice(0, 157)}…` : firstLine;
|
||||
}
|
||||
if (typeof value === "number" || typeof value === "boolean") {
|
||||
return String(value);
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
const values = value
|
||||
.map((item) => coerceDisplayValue(item))
|
||||
.filter((item): item is string => Boolean(item));
|
||||
if (values.length === 0) return undefined;
|
||||
const preview = values.slice(0, 3).join(", ");
|
||||
return values.length > 3 ? `${preview}…` : preview;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function lookupValueByPath(args: unknown, path: string): unknown {
|
||||
if (!args || typeof args !== "object") return undefined;
|
||||
let current: unknown = args;
|
||||
for (const segment of path.split(".")) {
|
||||
if (!segment) return undefined;
|
||||
if (!current || typeof current !== "object") return undefined;
|
||||
const record = current as Record<string, unknown>;
|
||||
current = record[segment];
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
function resolveDetailFromKeys(args: unknown, keys: string[]): string | undefined {
|
||||
for (const key of keys) {
|
||||
const value = lookupValueByPath(args, key);
|
||||
const display = coerceDisplayValue(value);
|
||||
if (display) return display;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function resolveReadDetail(args: unknown): string | undefined {
|
||||
if (!args || typeof args !== "object") return undefined;
|
||||
const record = args as Record<string, unknown>;
|
||||
const path = typeof record.path === "string" ? record.path : undefined;
|
||||
if (!path) return undefined;
|
||||
const offset = typeof record.offset === "number" ? record.offset : undefined;
|
||||
const limit = typeof record.limit === "number" ? record.limit : undefined;
|
||||
if (offset !== undefined && limit !== undefined) {
|
||||
return `${path}:${offset}-${offset + limit}`;
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
function resolveWriteDetail(args: unknown): string | undefined {
|
||||
if (!args || typeof args !== "object") return undefined;
|
||||
const record = args as Record<string, unknown>;
|
||||
const path = typeof record.path === "string" ? record.path : undefined;
|
||||
return path;
|
||||
}
|
||||
|
||||
function resolveActionSpec(
|
||||
spec: ToolDisplaySpec | undefined,
|
||||
action: string | undefined,
|
||||
): ToolDisplayActionSpec | undefined {
|
||||
if (!spec || !action) return undefined;
|
||||
return spec.actions?.[action] ?? undefined;
|
||||
}
|
||||
|
||||
export function resolveToolDisplay(params: {
|
||||
name?: string;
|
||||
args?: unknown;
|
||||
meta?: string;
|
||||
}): ToolDisplay {
|
||||
const name = normalizeToolName(params.name);
|
||||
const key = name.toLowerCase();
|
||||
const spec = TOOL_MAP[key];
|
||||
const icon = (spec?.icon ?? FALLBACK.icon ?? "puzzle") as IconName;
|
||||
const title = spec?.title ?? defaultTitle(name);
|
||||
const label = spec?.label ?? name;
|
||||
const actionRaw =
|
||||
params.args && typeof params.args === "object"
|
||||
? ((params.args as Record<string, unknown>).action as string | undefined)
|
||||
: undefined;
|
||||
const action = typeof actionRaw === "string" ? actionRaw.trim() : undefined;
|
||||
const actionSpec = resolveActionSpec(spec, action);
|
||||
const verb = normalizeVerb(actionSpec?.label ?? action);
|
||||
|
||||
let detail: string | undefined;
|
||||
if (key === "read") detail = resolveReadDetail(params.args);
|
||||
if (!detail && (key === "write" || key === "edit" || key === "attach")) {
|
||||
detail = resolveWriteDetail(params.args);
|
||||
}
|
||||
|
||||
const detailKeys =
|
||||
actionSpec?.detailKeys ?? spec?.detailKeys ?? FALLBACK.detailKeys ?? [];
|
||||
if (!detail && detailKeys.length > 0) {
|
||||
detail = resolveDetailFromKeys(params.args, detailKeys);
|
||||
}
|
||||
|
||||
if (!detail && params.meta) {
|
||||
detail = params.meta;
|
||||
}
|
||||
|
||||
if (detail) {
|
||||
detail = shortenHomeInString(detail);
|
||||
}
|
||||
|
||||
return {
|
||||
name,
|
||||
icon,
|
||||
title,
|
||||
label,
|
||||
verb,
|
||||
detail,
|
||||
};
|
||||
}
|
||||
|
||||
export function formatToolDetail(display: ToolDisplay): string | undefined {
|
||||
const parts: string[] = [];
|
||||
if (display.verb) parts.push(display.verb);
|
||||
if (display.detail) parts.push(display.detail);
|
||||
if (parts.length === 0) return undefined;
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
export function formatToolSummary(display: ToolDisplay): string {
|
||||
const detail = formatToolDetail(display);
|
||||
return detail ? `${display.label}: ${detail}` : display.label;
|
||||
}
|
||||
|
||||
function shortenHomeInString(input: string): string {
|
||||
if (!input) return input;
|
||||
return input
|
||||
.replace(/\/Users\/[^/]+/g, "~")
|
||||
.replace(/\/home\/[^/]+/g, "~");
|
||||
}
|
||||
532
docker-compose/ez-assistant/ui/src/ui/types.ts
Normal file
532
docker-compose/ez-assistant/ui/src/ui/types.ts
Normal file
@@ -0,0 +1,532 @@
|
||||
export type ChannelsStatusSnapshot = {
|
||||
ts: number;
|
||||
channelOrder: string[];
|
||||
channelLabels: Record<string, string>;
|
||||
channelDetailLabels?: Record<string, string>;
|
||||
channelSystemImages?: Record<string, string>;
|
||||
channelMeta?: ChannelUiMetaEntry[];
|
||||
channels: Record<string, unknown>;
|
||||
channelAccounts: Record<string, ChannelAccountSnapshot[]>;
|
||||
channelDefaultAccountId: Record<string, string>;
|
||||
};
|
||||
|
||||
export type ChannelUiMetaEntry = {
|
||||
id: string;
|
||||
label: string;
|
||||
detailLabel: string;
|
||||
systemImage?: string;
|
||||
};
|
||||
|
||||
export const CRON_CHANNEL_LAST = "last";
|
||||
|
||||
export type ChannelAccountSnapshot = {
|
||||
accountId: string;
|
||||
name?: string | null;
|
||||
enabled?: boolean | null;
|
||||
configured?: boolean | null;
|
||||
linked?: boolean | null;
|
||||
running?: boolean | null;
|
||||
connected?: boolean | null;
|
||||
reconnectAttempts?: number | null;
|
||||
lastConnectedAt?: number | null;
|
||||
lastError?: string | null;
|
||||
lastStartAt?: number | null;
|
||||
lastStopAt?: number | null;
|
||||
lastInboundAt?: number | null;
|
||||
lastOutboundAt?: number | null;
|
||||
lastProbeAt?: number | null;
|
||||
mode?: string | null;
|
||||
dmPolicy?: string | null;
|
||||
allowFrom?: string[] | null;
|
||||
tokenSource?: string | null;
|
||||
botTokenSource?: string | null;
|
||||
appTokenSource?: string | null;
|
||||
credentialSource?: string | null;
|
||||
audienceType?: string | null;
|
||||
audience?: string | null;
|
||||
webhookPath?: string | null;
|
||||
webhookUrl?: string | null;
|
||||
baseUrl?: string | null;
|
||||
allowUnmentionedGroups?: boolean | null;
|
||||
cliPath?: string | null;
|
||||
dbPath?: string | null;
|
||||
port?: number | null;
|
||||
probe?: unknown;
|
||||
audit?: unknown;
|
||||
application?: unknown;
|
||||
};
|
||||
|
||||
export type WhatsAppSelf = {
|
||||
e164?: string | null;
|
||||
jid?: string | null;
|
||||
};
|
||||
|
||||
export type WhatsAppDisconnect = {
|
||||
at: number;
|
||||
status?: number | null;
|
||||
error?: string | null;
|
||||
loggedOut?: boolean | null;
|
||||
};
|
||||
|
||||
export type WhatsAppStatus = {
|
||||
configured: boolean;
|
||||
linked: boolean;
|
||||
authAgeMs?: number | null;
|
||||
self?: WhatsAppSelf | null;
|
||||
running: boolean;
|
||||
connected: boolean;
|
||||
lastConnectedAt?: number | null;
|
||||
lastDisconnect?: WhatsAppDisconnect | null;
|
||||
reconnectAttempts: number;
|
||||
lastMessageAt?: number | null;
|
||||
lastEventAt?: number | null;
|
||||
lastError?: string | null;
|
||||
};
|
||||
|
||||
export type TelegramBot = {
|
||||
id?: number | null;
|
||||
username?: string | null;
|
||||
};
|
||||
|
||||
export type TelegramWebhook = {
|
||||
url?: string | null;
|
||||
hasCustomCert?: boolean | null;
|
||||
};
|
||||
|
||||
export type TelegramProbe = {
|
||||
ok: boolean;
|
||||
status?: number | null;
|
||||
error?: string | null;
|
||||
elapsedMs?: number | null;
|
||||
bot?: TelegramBot | null;
|
||||
webhook?: TelegramWebhook | null;
|
||||
};
|
||||
|
||||
export type TelegramStatus = {
|
||||
configured: boolean;
|
||||
tokenSource?: string | null;
|
||||
running: boolean;
|
||||
mode?: string | null;
|
||||
lastStartAt?: number | null;
|
||||
lastStopAt?: number | null;
|
||||
lastError?: string | null;
|
||||
probe?: TelegramProbe | null;
|
||||
lastProbeAt?: number | null;
|
||||
};
|
||||
|
||||
export type DiscordBot = {
|
||||
id?: string | null;
|
||||
username?: string | null;
|
||||
};
|
||||
|
||||
export type DiscordProbe = {
|
||||
ok: boolean;
|
||||
status?: number | null;
|
||||
error?: string | null;
|
||||
elapsedMs?: number | null;
|
||||
bot?: DiscordBot | null;
|
||||
};
|
||||
|
||||
export type DiscordStatus = {
|
||||
configured: boolean;
|
||||
tokenSource?: string | null;
|
||||
running: boolean;
|
||||
lastStartAt?: number | null;
|
||||
lastStopAt?: number | null;
|
||||
lastError?: string | null;
|
||||
probe?: DiscordProbe | null;
|
||||
lastProbeAt?: number | null;
|
||||
};
|
||||
|
||||
export type GoogleChatProbe = {
|
||||
ok: boolean;
|
||||
status?: number | null;
|
||||
error?: string | null;
|
||||
elapsedMs?: number | null;
|
||||
};
|
||||
|
||||
export type GoogleChatStatus = {
|
||||
configured: boolean;
|
||||
credentialSource?: string | null;
|
||||
audienceType?: string | null;
|
||||
audience?: string | null;
|
||||
webhookPath?: string | null;
|
||||
webhookUrl?: string | null;
|
||||
running: boolean;
|
||||
lastStartAt?: number | null;
|
||||
lastStopAt?: number | null;
|
||||
lastError?: string | null;
|
||||
probe?: GoogleChatProbe | null;
|
||||
lastProbeAt?: number | null;
|
||||
};
|
||||
|
||||
export type SlackBot = {
|
||||
id?: string | null;
|
||||
name?: string | null;
|
||||
};
|
||||
|
||||
export type SlackTeam = {
|
||||
id?: string | null;
|
||||
name?: string | null;
|
||||
};
|
||||
|
||||
export type SlackProbe = {
|
||||
ok: boolean;
|
||||
status?: number | null;
|
||||
error?: string | null;
|
||||
elapsedMs?: number | null;
|
||||
bot?: SlackBot | null;
|
||||
team?: SlackTeam | null;
|
||||
};
|
||||
|
||||
export type SlackStatus = {
|
||||
configured: boolean;
|
||||
botTokenSource?: string | null;
|
||||
appTokenSource?: string | null;
|
||||
running: boolean;
|
||||
lastStartAt?: number | null;
|
||||
lastStopAt?: number | null;
|
||||
lastError?: string | null;
|
||||
probe?: SlackProbe | null;
|
||||
lastProbeAt?: number | null;
|
||||
};
|
||||
|
||||
export type SignalProbe = {
|
||||
ok: boolean;
|
||||
status?: number | null;
|
||||
error?: string | null;
|
||||
elapsedMs?: number | null;
|
||||
version?: string | null;
|
||||
};
|
||||
|
||||
export type SignalStatus = {
|
||||
configured: boolean;
|
||||
baseUrl: string;
|
||||
running: boolean;
|
||||
lastStartAt?: number | null;
|
||||
lastStopAt?: number | null;
|
||||
lastError?: string | null;
|
||||
probe?: SignalProbe | null;
|
||||
lastProbeAt?: number | null;
|
||||
};
|
||||
|
||||
export type IMessageProbe = {
|
||||
ok: boolean;
|
||||
error?: string | null;
|
||||
};
|
||||
|
||||
export type IMessageStatus = {
|
||||
configured: boolean;
|
||||
running: boolean;
|
||||
lastStartAt?: number | null;
|
||||
lastStopAt?: number | null;
|
||||
lastError?: string | null;
|
||||
cliPath?: string | null;
|
||||
dbPath?: string | null;
|
||||
probe?: IMessageProbe | null;
|
||||
lastProbeAt?: number | null;
|
||||
};
|
||||
|
||||
export type NostrProfile = {
|
||||
name?: string | null;
|
||||
displayName?: string | null;
|
||||
about?: string | null;
|
||||
picture?: string | null;
|
||||
banner?: string | null;
|
||||
website?: string | null;
|
||||
nip05?: string | null;
|
||||
lud16?: string | null;
|
||||
};
|
||||
|
||||
export type NostrStatus = {
|
||||
configured: boolean;
|
||||
publicKey?: string | null;
|
||||
running: boolean;
|
||||
lastStartAt?: number | null;
|
||||
lastStopAt?: number | null;
|
||||
lastError?: string | null;
|
||||
profile?: NostrProfile | null;
|
||||
};
|
||||
|
||||
export type MSTeamsProbe = {
|
||||
ok: boolean;
|
||||
error?: string | null;
|
||||
appId?: string | null;
|
||||
};
|
||||
|
||||
export type MSTeamsStatus = {
|
||||
configured: boolean;
|
||||
running: boolean;
|
||||
lastStartAt?: number | null;
|
||||
lastStopAt?: number | null;
|
||||
lastError?: string | null;
|
||||
port?: number | null;
|
||||
probe?: MSTeamsProbe | null;
|
||||
lastProbeAt?: number | null;
|
||||
};
|
||||
|
||||
export type ConfigSnapshotIssue = {
|
||||
path: string;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type ConfigSnapshot = {
|
||||
path?: string | null;
|
||||
exists?: boolean | null;
|
||||
raw?: string | null;
|
||||
hash?: string | null;
|
||||
parsed?: unknown;
|
||||
valid?: boolean | null;
|
||||
config?: Record<string, unknown> | null;
|
||||
issues?: ConfigSnapshotIssue[] | null;
|
||||
};
|
||||
|
||||
export type ConfigUiHint = {
|
||||
label?: string;
|
||||
help?: string;
|
||||
group?: string;
|
||||
order?: number;
|
||||
advanced?: boolean;
|
||||
sensitive?: boolean;
|
||||
placeholder?: string;
|
||||
itemTemplate?: unknown;
|
||||
};
|
||||
|
||||
export type ConfigUiHints = Record<string, ConfigUiHint>;
|
||||
|
||||
export type ConfigSchemaResponse = {
|
||||
schema: unknown;
|
||||
uiHints: ConfigUiHints;
|
||||
version: string;
|
||||
generatedAt: string;
|
||||
};
|
||||
|
||||
export type PresenceEntry = {
|
||||
instanceId?: string | null;
|
||||
host?: string | null;
|
||||
ip?: string | null;
|
||||
version?: string | null;
|
||||
platform?: string | null;
|
||||
deviceFamily?: string | null;
|
||||
modelIdentifier?: string | null;
|
||||
mode?: string | null;
|
||||
lastInputSeconds?: number | null;
|
||||
reason?: string | null;
|
||||
text?: string | null;
|
||||
ts?: number | null;
|
||||
};
|
||||
|
||||
export type GatewaySessionsDefaults = {
|
||||
model: string | null;
|
||||
contextTokens: number | null;
|
||||
};
|
||||
|
||||
export type GatewayAgentRow = {
|
||||
id: string;
|
||||
name?: string;
|
||||
identity?: {
|
||||
name?: string;
|
||||
theme?: string;
|
||||
emoji?: string;
|
||||
avatar?: string;
|
||||
avatarUrl?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type AgentsListResult = {
|
||||
defaultId: string;
|
||||
mainKey: string;
|
||||
scope: string;
|
||||
agents: GatewayAgentRow[];
|
||||
};
|
||||
|
||||
export type GatewaySessionRow = {
|
||||
key: string;
|
||||
kind: "direct" | "group" | "global" | "unknown";
|
||||
label?: string;
|
||||
displayName?: string;
|
||||
surface?: string;
|
||||
subject?: string;
|
||||
room?: string;
|
||||
space?: string;
|
||||
updatedAt: number | null;
|
||||
sessionId?: string;
|
||||
systemSent?: boolean;
|
||||
abortedLastRun?: boolean;
|
||||
thinkingLevel?: string;
|
||||
verboseLevel?: string;
|
||||
reasoningLevel?: string;
|
||||
elevatedLevel?: string;
|
||||
inputTokens?: number;
|
||||
outputTokens?: number;
|
||||
totalTokens?: number;
|
||||
model?: string;
|
||||
modelProvider?: string;
|
||||
contextTokens?: number;
|
||||
};
|
||||
|
||||
export type SessionsListResult = {
|
||||
ts: number;
|
||||
path: string;
|
||||
count: number;
|
||||
defaults: GatewaySessionsDefaults;
|
||||
sessions: GatewaySessionRow[];
|
||||
};
|
||||
|
||||
export type SessionsPatchResult = {
|
||||
ok: true;
|
||||
path: string;
|
||||
key: string;
|
||||
entry: {
|
||||
sessionId: string;
|
||||
updatedAt?: number;
|
||||
thinkingLevel?: string;
|
||||
verboseLevel?: string;
|
||||
reasoningLevel?: string;
|
||||
elevatedLevel?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type CronSchedule =
|
||||
| { kind: "at"; atMs: number }
|
||||
| { kind: "every"; everyMs: number; anchorMs?: number }
|
||||
| { kind: "cron"; expr: string; tz?: string };
|
||||
|
||||
export type CronSessionTarget = "main" | "isolated";
|
||||
export type CronWakeMode = "next-heartbeat" | "now";
|
||||
|
||||
export type CronPayload =
|
||||
| { kind: "systemEvent"; text: string }
|
||||
| {
|
||||
kind: "agentTurn";
|
||||
message: string;
|
||||
thinking?: string;
|
||||
timeoutSeconds?: number;
|
||||
deliver?: boolean;
|
||||
provider?:
|
||||
| "last"
|
||||
| "whatsapp"
|
||||
| "telegram"
|
||||
| "discord"
|
||||
| "slack"
|
||||
| "signal"
|
||||
| "imessage"
|
||||
| "msteams";
|
||||
to?: string;
|
||||
bestEffortDeliver?: boolean;
|
||||
};
|
||||
|
||||
export type CronIsolation = {
|
||||
postToMainPrefix?: string;
|
||||
};
|
||||
|
||||
export type CronJobState = {
|
||||
nextRunAtMs?: number;
|
||||
runningAtMs?: number;
|
||||
lastRunAtMs?: number;
|
||||
lastStatus?: "ok" | "error" | "skipped";
|
||||
lastError?: string;
|
||||
lastDurationMs?: number;
|
||||
};
|
||||
|
||||
export type CronJob = {
|
||||
id: string;
|
||||
agentId?: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
enabled: boolean;
|
||||
deleteAfterRun?: boolean;
|
||||
createdAtMs: number;
|
||||
updatedAtMs: number;
|
||||
schedule: CronSchedule;
|
||||
sessionTarget: CronSessionTarget;
|
||||
wakeMode: CronWakeMode;
|
||||
payload: CronPayload;
|
||||
isolation?: CronIsolation;
|
||||
state?: CronJobState;
|
||||
};
|
||||
|
||||
export type CronStatus = {
|
||||
enabled: boolean;
|
||||
jobs: number;
|
||||
nextWakeAtMs?: number | null;
|
||||
};
|
||||
|
||||
export type CronRunLogEntry = {
|
||||
ts: number;
|
||||
jobId: string;
|
||||
status: "ok" | "error" | "skipped";
|
||||
durationMs?: number;
|
||||
error?: string;
|
||||
summary?: string;
|
||||
};
|
||||
|
||||
export type SkillsStatusConfigCheck = {
|
||||
path: string;
|
||||
value: unknown;
|
||||
satisfied: boolean;
|
||||
};
|
||||
|
||||
export type SkillInstallOption = {
|
||||
id: string;
|
||||
kind: "brew" | "node" | "go" | "uv";
|
||||
label: string;
|
||||
bins: string[];
|
||||
};
|
||||
|
||||
export type SkillStatusEntry = {
|
||||
name: string;
|
||||
description: string;
|
||||
source: string;
|
||||
filePath: string;
|
||||
baseDir: string;
|
||||
skillKey: string;
|
||||
primaryEnv?: string;
|
||||
emoji?: string;
|
||||
homepage?: string;
|
||||
always: boolean;
|
||||
disabled: boolean;
|
||||
blockedByAllowlist: boolean;
|
||||
eligible: boolean;
|
||||
requirements: {
|
||||
bins: string[];
|
||||
env: string[];
|
||||
config: string[];
|
||||
os: string[];
|
||||
};
|
||||
missing: {
|
||||
bins: string[];
|
||||
env: string[];
|
||||
config: string[];
|
||||
os: string[];
|
||||
};
|
||||
configChecks: SkillsStatusConfigCheck[];
|
||||
install: SkillInstallOption[];
|
||||
};
|
||||
|
||||
export type SkillStatusReport = {
|
||||
workspaceDir: string;
|
||||
managedSkillsDir: string;
|
||||
skills: SkillStatusEntry[];
|
||||
};
|
||||
|
||||
export type StatusSummary = Record<string, unknown>;
|
||||
|
||||
export type HealthSnapshot = Record<string, unknown>;
|
||||
|
||||
export type LogLevel =
|
||||
| "trace"
|
||||
| "debug"
|
||||
| "info"
|
||||
| "warn"
|
||||
| "error"
|
||||
| "fatal";
|
||||
|
||||
export type LogEntry = {
|
||||
raw: string;
|
||||
time?: string | null;
|
||||
level?: LogLevel | null;
|
||||
subsystem?: string | null;
|
||||
message?: string | null;
|
||||
meta?: Record<string, unknown> | null;
|
||||
};
|
||||
43
docker-compose/ez-assistant/ui/src/ui/types/chat-types.ts
Normal file
43
docker-compose/ez-assistant/ui/src/ui/types/chat-types.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Chat message types for the UI layer.
|
||||
*/
|
||||
|
||||
/** Union type for items in the chat thread */
|
||||
export type ChatItem =
|
||||
| { kind: "message"; key: string; message: unknown }
|
||||
| { kind: "stream"; key: string; text: string; startedAt: number }
|
||||
| { kind: "reading-indicator"; key: string };
|
||||
|
||||
/** A group of consecutive messages from the same role (Slack-style layout) */
|
||||
export type MessageGroup = {
|
||||
kind: "group";
|
||||
key: string;
|
||||
role: string;
|
||||
messages: Array<{ message: unknown; key: string }>;
|
||||
timestamp: number;
|
||||
isStreaming: boolean;
|
||||
};
|
||||
|
||||
/** Content item types in a normalized message */
|
||||
export type MessageContentItem = {
|
||||
type: "text" | "tool_call" | "tool_result";
|
||||
text?: string;
|
||||
name?: string;
|
||||
args?: unknown;
|
||||
};
|
||||
|
||||
/** Normalized message structure for rendering */
|
||||
export type NormalizedMessage = {
|
||||
role: string;
|
||||
content: MessageContentItem[];
|
||||
timestamp: number;
|
||||
id?: string;
|
||||
};
|
||||
|
||||
/** Tool card representation for tool calls and results */
|
||||
export type ToolCard = {
|
||||
kind: "call" | "result";
|
||||
name: string;
|
||||
args?: unknown;
|
||||
text?: string;
|
||||
};
|
||||
36
docker-compose/ez-assistant/ui/src/ui/ui-types.ts
Normal file
36
docker-compose/ez-assistant/ui/src/ui/ui-types.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
export type ChatAttachment = {
|
||||
id: string;
|
||||
dataUrl: string;
|
||||
mimeType: string;
|
||||
};
|
||||
|
||||
export type ChatQueueItem = {
|
||||
id: string;
|
||||
text: string;
|
||||
createdAt: number;
|
||||
attachments?: ChatAttachment[];
|
||||
};
|
||||
|
||||
export const CRON_CHANNEL_LAST = "last";
|
||||
|
||||
export type CronFormState = {
|
||||
name: string;
|
||||
description: string;
|
||||
agentId: string;
|
||||
enabled: boolean;
|
||||
scheduleKind: "at" | "every" | "cron";
|
||||
scheduleAt: string;
|
||||
everyAmount: string;
|
||||
everyUnit: "minutes" | "hours" | "days";
|
||||
cronExpr: string;
|
||||
cronTz: string;
|
||||
sessionTarget: "main" | "isolated";
|
||||
wakeMode: "next-heartbeat" | "now";
|
||||
payloadKind: "systemEvent" | "agentTurn";
|
||||
payloadText: string;
|
||||
deliver: boolean;
|
||||
channel: string;
|
||||
to: string;
|
||||
timeoutSeconds: string;
|
||||
postToMainPrefix: string;
|
||||
};
|
||||
38
docker-compose/ez-assistant/ui/src/ui/uuid.test.ts
Normal file
38
docker-compose/ez-assistant/ui/src/ui/uuid.test.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { generateUUID } from "./uuid";
|
||||
|
||||
describe("generateUUID", () => {
|
||||
it("uses crypto.randomUUID when available", () => {
|
||||
const id = generateUUID({
|
||||
randomUUID: () => "randomuuid",
|
||||
getRandomValues: () => {
|
||||
throw new Error("should not be called");
|
||||
},
|
||||
});
|
||||
|
||||
expect(id).toBe("randomuuid");
|
||||
});
|
||||
|
||||
it("falls back to crypto.getRandomValues", () => {
|
||||
const id = generateUUID({
|
||||
getRandomValues: (bytes) => {
|
||||
for (let i = 0; i < bytes.length; i++) bytes[i] = i;
|
||||
return bytes;
|
||||
},
|
||||
});
|
||||
|
||||
expect(id).toBe("00010203-0405-4607-8809-0a0b0c0d0e0f");
|
||||
});
|
||||
|
||||
it("still returns a v4 UUID when crypto is missing", () => {
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
try {
|
||||
const id = generateUUID(null);
|
||||
expect(id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);
|
||||
expect(warnSpy).toHaveBeenCalled();
|
||||
} finally {
|
||||
warnSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
51
docker-compose/ez-assistant/ui/src/ui/uuid.ts
Normal file
51
docker-compose/ez-assistant/ui/src/ui/uuid.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
export type CryptoLike = {
|
||||
randomUUID?: (() => string) | undefined;
|
||||
getRandomValues?: ((array: Uint8Array) => Uint8Array) | undefined;
|
||||
};
|
||||
|
||||
let warnedWeakCrypto = false;
|
||||
|
||||
function uuidFromBytes(bytes: Uint8Array): string {
|
||||
bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4
|
||||
bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant 1
|
||||
|
||||
let hex = "";
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
hex += bytes[i]!.toString(16).padStart(2, "0");
|
||||
}
|
||||
|
||||
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(
|
||||
16,
|
||||
20,
|
||||
)}-${hex.slice(20)}`;
|
||||
}
|
||||
|
||||
function weakRandomBytes(): Uint8Array {
|
||||
const bytes = new Uint8Array(16);
|
||||
const now = Date.now();
|
||||
for (let i = 0; i < bytes.length; i++) bytes[i] = Math.floor(Math.random() * 256);
|
||||
bytes[0] ^= now & 0xff;
|
||||
bytes[1] ^= (now >>> 8) & 0xff;
|
||||
bytes[2] ^= (now >>> 16) & 0xff;
|
||||
bytes[3] ^= (now >>> 24) & 0xff;
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function warnWeakCryptoOnce() {
|
||||
if (warnedWeakCrypto) return;
|
||||
warnedWeakCrypto = true;
|
||||
console.warn("[uuid] crypto API missing; falling back to weak randomness");
|
||||
}
|
||||
|
||||
export function generateUUID(cryptoLike: CryptoLike | null = globalThis.crypto): string {
|
||||
if (cryptoLike && typeof cryptoLike.randomUUID === "function") return cryptoLike.randomUUID();
|
||||
|
||||
if (cryptoLike && typeof cryptoLike.getRandomValues === "function") {
|
||||
const bytes = new Uint8Array(16);
|
||||
cryptoLike.getRandomValues(bytes);
|
||||
return uuidFromBytes(bytes);
|
||||
}
|
||||
|
||||
warnWeakCryptoOnce();
|
||||
return uuidFromBytes(weakRandomBytes());
|
||||
}
|
||||
134
docker-compose/ez-assistant/ui/src/ui/views/channels.config.ts
Normal file
134
docker-compose/ez-assistant/ui/src/ui/views/channels.config.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { html } from "lit";
|
||||
|
||||
import type { ConfigUiHints } from "../types";
|
||||
import type { ChannelsProps } from "./channels.types";
|
||||
import {
|
||||
analyzeConfigSchema,
|
||||
renderNode,
|
||||
schemaType,
|
||||
type JsonSchema,
|
||||
} from "./config-form";
|
||||
|
||||
type ChannelConfigFormProps = {
|
||||
channelId: string;
|
||||
configValue: Record<string, unknown> | null;
|
||||
schema: unknown | null;
|
||||
uiHints: ConfigUiHints;
|
||||
disabled: boolean;
|
||||
onPatch: (path: Array<string | number>, value: unknown) => void;
|
||||
};
|
||||
|
||||
function resolveSchemaNode(
|
||||
schema: JsonSchema | null,
|
||||
path: Array<string | number>,
|
||||
): JsonSchema | null {
|
||||
let current = schema;
|
||||
for (const key of path) {
|
||||
if (!current) return null;
|
||||
const type = schemaType(current);
|
||||
if (type === "object") {
|
||||
const properties = current.properties ?? {};
|
||||
if (typeof key === "string" && properties[key]) {
|
||||
current = properties[key];
|
||||
continue;
|
||||
}
|
||||
const additional = current.additionalProperties;
|
||||
if (typeof key === "string" && additional && typeof additional === "object") {
|
||||
current = additional as JsonSchema;
|
||||
continue;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (type === "array") {
|
||||
if (typeof key !== "number") return null;
|
||||
const items = Array.isArray(current.items) ? current.items[0] : current.items;
|
||||
current = items ?? null;
|
||||
continue;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
function resolveChannelValue(
|
||||
config: Record<string, unknown>,
|
||||
channelId: string,
|
||||
): Record<string, unknown> {
|
||||
const channels = (config.channels ?? {}) as Record<string, unknown>;
|
||||
const fromChannels = channels[channelId];
|
||||
const fallback = config[channelId];
|
||||
const resolved =
|
||||
(fromChannels && typeof fromChannels === "object"
|
||||
? (fromChannels as Record<string, unknown>)
|
||||
: null) ??
|
||||
(fallback && typeof fallback === "object"
|
||||
? (fallback as Record<string, unknown>)
|
||||
: null);
|
||||
return resolved ?? {};
|
||||
}
|
||||
|
||||
export function renderChannelConfigForm(props: ChannelConfigFormProps) {
|
||||
const analysis = analyzeConfigSchema(props.schema);
|
||||
const normalized = analysis.schema;
|
||||
if (!normalized) {
|
||||
return html`<div class="callout danger">Schema unavailable. Use Raw.</div>`;
|
||||
}
|
||||
const node = resolveSchemaNode(normalized, ["channels", props.channelId]);
|
||||
if (!node) {
|
||||
return html`<div class="callout danger">Channel config schema unavailable.</div>`;
|
||||
}
|
||||
const configValue = props.configValue ?? {};
|
||||
const value = resolveChannelValue(configValue, props.channelId);
|
||||
return html`
|
||||
<div class="config-form">
|
||||
${renderNode({
|
||||
schema: node,
|
||||
value,
|
||||
path: ["channels", props.channelId],
|
||||
hints: props.uiHints,
|
||||
unsupported: new Set(analysis.unsupportedPaths),
|
||||
disabled: props.disabled,
|
||||
showLabel: false,
|
||||
onPatch: props.onPatch,
|
||||
})}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
export function renderChannelConfigSection(params: {
|
||||
channelId: string;
|
||||
props: ChannelsProps;
|
||||
}) {
|
||||
const { channelId, props } = params;
|
||||
const disabled = props.configSaving || props.configSchemaLoading;
|
||||
return html`
|
||||
<div style="margin-top: 16px;">
|
||||
${props.configSchemaLoading
|
||||
? html`<div class="muted">Loading config schema…</div>`
|
||||
: renderChannelConfigForm({
|
||||
channelId,
|
||||
configValue: props.configForm,
|
||||
schema: props.configSchema,
|
||||
uiHints: props.configUiHints,
|
||||
disabled,
|
||||
onPatch: props.onConfigPatch,
|
||||
})}
|
||||
<div class="row" style="margin-top: 12px;">
|
||||
<button
|
||||
class="btn primary"
|
||||
?disabled=${disabled || !props.configFormDirty}
|
||||
@click=${() => props.onConfigSave()}
|
||||
>
|
||||
${props.configSaving ? "Saving…" : "Save"}
|
||||
</button>
|
||||
<button
|
||||
class="btn"
|
||||
?disabled=${disabled}
|
||||
@click=${() => props.onConfigReload()}
|
||||
>
|
||||
Reload
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { html, nothing } from "lit";
|
||||
|
||||
import { formatAgo } from "../format";
|
||||
import type { DiscordStatus } from "../types";
|
||||
import type { ChannelsProps } from "./channels.types";
|
||||
import { renderChannelConfigSection } from "./channels.config";
|
||||
|
||||
export function renderDiscordCard(params: {
|
||||
props: ChannelsProps;
|
||||
discord?: DiscordStatus | null;
|
||||
accountCountLabel: unknown;
|
||||
}) {
|
||||
const { props, discord, accountCountLabel } = params;
|
||||
|
||||
return html`
|
||||
<div class="card">
|
||||
<div class="card-title">Discord</div>
|
||||
<div class="card-sub">Bot status and channel configuration.</div>
|
||||
${accountCountLabel}
|
||||
|
||||
<div class="status-list" style="margin-top: 16px;">
|
||||
<div>
|
||||
<span class="label">Configured</span>
|
||||
<span>${discord?.configured ? "Yes" : "No"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Running</span>
|
||||
<span>${discord?.running ? "Yes" : "No"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Last start</span>
|
||||
<span>${discord?.lastStartAt ? formatAgo(discord.lastStartAt) : "n/a"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Last probe</span>
|
||||
<span>${discord?.lastProbeAt ? formatAgo(discord.lastProbeAt) : "n/a"}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${discord?.lastError
|
||||
? html`<div class="callout danger" style="margin-top: 12px;">
|
||||
${discord.lastError}
|
||||
</div>`
|
||||
: nothing}
|
||||
|
||||
${discord?.probe
|
||||
? html`<div class="callout" style="margin-top: 12px;">
|
||||
Probe ${discord.probe.ok ? "ok" : "failed"} ·
|
||||
${discord.probe.status ?? ""} ${discord.probe.error ?? ""}
|
||||
</div>`
|
||||
: nothing}
|
||||
|
||||
${renderChannelConfigSection({ channelId: "discord", props })}
|
||||
|
||||
<div class="row" style="margin-top: 12px;">
|
||||
<button class="btn" @click=${() => props.onRefresh(true)}>
|
||||
Probe
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { html, nothing } from "lit";
|
||||
|
||||
import { formatAgo } from "../format";
|
||||
import type { GoogleChatStatus } from "../types";
|
||||
import { renderChannelConfigSection } from "./channels.config";
|
||||
import type { ChannelsProps } from "./channels.types";
|
||||
|
||||
export function renderGoogleChatCard(params: {
|
||||
props: ChannelsProps;
|
||||
googleChat?: GoogleChatStatus | null;
|
||||
accountCountLabel: unknown;
|
||||
}) {
|
||||
const { props, googleChat, accountCountLabel } = params;
|
||||
|
||||
return html`
|
||||
<div class="card">
|
||||
<div class="card-title">Google Chat</div>
|
||||
<div class="card-sub">Chat API webhook status and channel configuration.</div>
|
||||
${accountCountLabel}
|
||||
|
||||
<div class="status-list" style="margin-top: 16px;">
|
||||
<div>
|
||||
<span class="label">Configured</span>
|
||||
<span>${googleChat ? (googleChat.configured ? "Yes" : "No") : "n/a"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Running</span>
|
||||
<span>${googleChat ? (googleChat.running ? "Yes" : "No") : "n/a"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Credential</span>
|
||||
<span>${googleChat?.credentialSource ?? "n/a"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Audience</span>
|
||||
<span>
|
||||
${googleChat?.audienceType
|
||||
? `${googleChat.audienceType}${googleChat.audience ? ` · ${googleChat.audience}` : ""}`
|
||||
: "n/a"}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Last start</span>
|
||||
<span>${googleChat?.lastStartAt ? formatAgo(googleChat.lastStartAt) : "n/a"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Last probe</span>
|
||||
<span>${googleChat?.lastProbeAt ? formatAgo(googleChat.lastProbeAt) : "n/a"}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${googleChat?.lastError
|
||||
? html`<div class="callout danger" style="margin-top: 12px;">
|
||||
${googleChat.lastError}
|
||||
</div>`
|
||||
: nothing}
|
||||
|
||||
${googleChat?.probe
|
||||
? html`<div class="callout" style="margin-top: 12px;">
|
||||
Probe ${googleChat.probe.ok ? "ok" : "failed"} ·
|
||||
${googleChat.probe.status ?? ""} ${googleChat.probe.error ?? ""}
|
||||
</div>`
|
||||
: nothing}
|
||||
|
||||
${renderChannelConfigSection({ channelId: "googlechat", props })}
|
||||
|
||||
<div class="row" style="margin-top: 12px;">
|
||||
<button class="btn" @click=${() => props.onRefresh(true)}>
|
||||
Probe
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { html, nothing } from "lit";
|
||||
|
||||
import { formatAgo } from "../format";
|
||||
import type { IMessageStatus } from "../types";
|
||||
import type { ChannelsProps } from "./channels.types";
|
||||
import { renderChannelConfigSection } from "./channels.config";
|
||||
|
||||
export function renderIMessageCard(params: {
|
||||
props: ChannelsProps;
|
||||
imessage?: IMessageStatus | null;
|
||||
accountCountLabel: unknown;
|
||||
}) {
|
||||
const { props, imessage, accountCountLabel } = params;
|
||||
|
||||
return html`
|
||||
<div class="card">
|
||||
<div class="card-title">iMessage</div>
|
||||
<div class="card-sub">macOS bridge status and channel configuration.</div>
|
||||
${accountCountLabel}
|
||||
|
||||
<div class="status-list" style="margin-top: 16px;">
|
||||
<div>
|
||||
<span class="label">Configured</span>
|
||||
<span>${imessage?.configured ? "Yes" : "No"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Running</span>
|
||||
<span>${imessage?.running ? "Yes" : "No"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Last start</span>
|
||||
<span>${imessage?.lastStartAt ? formatAgo(imessage.lastStartAt) : "n/a"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Last probe</span>
|
||||
<span>${imessage?.lastProbeAt ? formatAgo(imessage.lastProbeAt) : "n/a"}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${imessage?.lastError
|
||||
? html`<div class="callout danger" style="margin-top: 12px;">
|
||||
${imessage.lastError}
|
||||
</div>`
|
||||
: nothing}
|
||||
|
||||
${imessage?.probe
|
||||
? html`<div class="callout" style="margin-top: 12px;">
|
||||
Probe ${imessage.probe.ok ? "ok" : "failed"} ·
|
||||
${imessage.probe.error ?? ""}
|
||||
</div>`
|
||||
: nothing}
|
||||
|
||||
${renderChannelConfigSection({ channelId: "imessage", props })}
|
||||
|
||||
<div class="row" style="margin-top: 12px;">
|
||||
<button class="btn" @click=${() => props.onRefresh(true)}>
|
||||
Probe
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
/**
|
||||
* Nostr Profile Edit Form
|
||||
*
|
||||
* Provides UI for editing and publishing Nostr profile (kind:0).
|
||||
*/
|
||||
|
||||
import { html, nothing, type TemplateResult } from "lit";
|
||||
|
||||
import type { NostrProfile as NostrProfileType } from "../types";
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
export interface NostrProfileFormState {
|
||||
/** Current form values */
|
||||
values: NostrProfileType;
|
||||
/** Original values for dirty detection */
|
||||
original: NostrProfileType;
|
||||
/** Whether the form is currently submitting */
|
||||
saving: boolean;
|
||||
/** Whether import is in progress */
|
||||
importing: boolean;
|
||||
/** Last error message */
|
||||
error: string | null;
|
||||
/** Last success message */
|
||||
success: string | null;
|
||||
/** Validation errors per field */
|
||||
fieldErrors: Record<string, string>;
|
||||
/** Whether to show advanced fields */
|
||||
showAdvanced: boolean;
|
||||
}
|
||||
|
||||
export interface NostrProfileFormCallbacks {
|
||||
/** Called when a field value changes */
|
||||
onFieldChange: (field: keyof NostrProfileType, value: string) => void;
|
||||
/** Called when save is clicked */
|
||||
onSave: () => void;
|
||||
/** Called when import is clicked */
|
||||
onImport: () => void;
|
||||
/** Called when cancel is clicked */
|
||||
onCancel: () => void;
|
||||
/** Called when toggle advanced is clicked */
|
||||
onToggleAdvanced: () => void;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Helpers
|
||||
// ============================================================================
|
||||
|
||||
function isFormDirty(state: NostrProfileFormState): boolean {
|
||||
const { values, original } = state;
|
||||
return (
|
||||
values.name !== original.name ||
|
||||
values.displayName !== original.displayName ||
|
||||
values.about !== original.about ||
|
||||
values.picture !== original.picture ||
|
||||
values.banner !== original.banner ||
|
||||
values.website !== original.website ||
|
||||
values.nip05 !== original.nip05 ||
|
||||
values.lud16 !== original.lud16
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Form Rendering
|
||||
// ============================================================================
|
||||
|
||||
export function renderNostrProfileForm(params: {
|
||||
state: NostrProfileFormState;
|
||||
callbacks: NostrProfileFormCallbacks;
|
||||
accountId: string;
|
||||
}): TemplateResult {
|
||||
const { state, callbacks, accountId } = params;
|
||||
const isDirty = isFormDirty(state);
|
||||
|
||||
const renderField = (
|
||||
field: keyof NostrProfileType,
|
||||
label: string,
|
||||
opts: {
|
||||
type?: "text" | "url" | "textarea";
|
||||
placeholder?: string;
|
||||
maxLength?: number;
|
||||
help?: string;
|
||||
} = {}
|
||||
) => {
|
||||
const { type = "text", placeholder, maxLength, help } = opts;
|
||||
const value = state.values[field] ?? "";
|
||||
const error = state.fieldErrors[field];
|
||||
|
||||
const inputId = `nostr-profile-${field}`;
|
||||
|
||||
if (type === "textarea") {
|
||||
return html`
|
||||
<div class="form-field" style="margin-bottom: 12px;">
|
||||
<label for="${inputId}" style="display: block; margin-bottom: 4px; font-weight: 500;">
|
||||
${label}
|
||||
</label>
|
||||
<textarea
|
||||
id="${inputId}"
|
||||
.value=${value}
|
||||
placeholder=${placeholder ?? ""}
|
||||
maxlength=${maxLength ?? 2000}
|
||||
rows="3"
|
||||
style="width: 100%; padding: 8px; border: 1px solid var(--border-color); border-radius: 4px; resize: vertical; font-family: inherit;"
|
||||
@input=${(e: InputEvent) => {
|
||||
const target = e.target as HTMLTextAreaElement;
|
||||
callbacks.onFieldChange(field, target.value);
|
||||
}}
|
||||
?disabled=${state.saving}
|
||||
></textarea>
|
||||
${help ? html`<div style="font-size: 12px; color: var(--text-muted); margin-top: 2px;">${help}</div>` : nothing}
|
||||
${error ? html`<div style="font-size: 12px; color: var(--danger-color); margin-top: 2px;">${error}</div>` : nothing}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="form-field" style="margin-bottom: 12px;">
|
||||
<label for="${inputId}" style="display: block; margin-bottom: 4px; font-weight: 500;">
|
||||
${label}
|
||||
</label>
|
||||
<input
|
||||
id="${inputId}"
|
||||
type=${type}
|
||||
.value=${value}
|
||||
placeholder=${placeholder ?? ""}
|
||||
maxlength=${maxLength ?? 256}
|
||||
style="width: 100%; padding: 8px; border: 1px solid var(--border-color); border-radius: 4px;"
|
||||
@input=${(e: InputEvent) => {
|
||||
const target = e.target as HTMLInputElement;
|
||||
callbacks.onFieldChange(field, target.value);
|
||||
}}
|
||||
?disabled=${state.saving}
|
||||
/>
|
||||
${help ? html`<div style="font-size: 12px; color: var(--text-muted); margin-top: 2px;">${help}</div>` : nothing}
|
||||
${error ? html`<div style="font-size: 12px; color: var(--danger-color); margin-top: 2px;">${error}</div>` : nothing}
|
||||
</div>
|
||||
`;
|
||||
};
|
||||
|
||||
const renderPicturePreview = () => {
|
||||
const picture = state.values.picture;
|
||||
if (!picture) return nothing;
|
||||
|
||||
return html`
|
||||
<div style="margin-bottom: 12px;">
|
||||
<img
|
||||
src=${picture}
|
||||
alt="Profile picture preview"
|
||||
style="max-width: 80px; max-height: 80px; border-radius: 50%; object-fit: cover; border: 2px solid var(--border-color);"
|
||||
@error=${(e: Event) => {
|
||||
const img = e.target as HTMLImageElement;
|
||||
img.style.display = "none";
|
||||
}}
|
||||
@load=${(e: Event) => {
|
||||
const img = e.target as HTMLImageElement;
|
||||
img.style.display = "block";
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
`;
|
||||
};
|
||||
|
||||
return html`
|
||||
<div class="nostr-profile-form" style="padding: 16px; background: var(--bg-secondary); border-radius: 8px; margin-top: 12px;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;">
|
||||
<div style="font-weight: 600; font-size: 16px;">Edit Profile</div>
|
||||
<div style="font-size: 12px; color: var(--text-muted);">Account: ${accountId}</div>
|
||||
</div>
|
||||
|
||||
${state.error
|
||||
? html`<div class="callout danger" style="margin-bottom: 12px;">${state.error}</div>`
|
||||
: nothing}
|
||||
|
||||
${state.success
|
||||
? html`<div class="callout success" style="margin-bottom: 12px;">${state.success}</div>`
|
||||
: nothing}
|
||||
|
||||
${renderPicturePreview()}
|
||||
|
||||
${renderField("name", "Username", {
|
||||
placeholder: "satoshi",
|
||||
maxLength: 256,
|
||||
help: "Short username (e.g., satoshi)",
|
||||
})}
|
||||
|
||||
${renderField("displayName", "Display Name", {
|
||||
placeholder: "Satoshi Nakamoto",
|
||||
maxLength: 256,
|
||||
help: "Your full display name",
|
||||
})}
|
||||
|
||||
${renderField("about", "Bio", {
|
||||
type: "textarea",
|
||||
placeholder: "Tell people about yourself...",
|
||||
maxLength: 2000,
|
||||
help: "A brief bio or description",
|
||||
})}
|
||||
|
||||
${renderField("picture", "Avatar URL", {
|
||||
type: "url",
|
||||
placeholder: "https://example.com/avatar.jpg",
|
||||
help: "HTTPS URL to your profile picture",
|
||||
})}
|
||||
|
||||
${state.showAdvanced
|
||||
? html`
|
||||
<div style="border-top: 1px solid var(--border-color); padding-top: 12px; margin-top: 12px;">
|
||||
<div style="font-weight: 500; margin-bottom: 12px; color: var(--text-muted);">Advanced</div>
|
||||
|
||||
${renderField("banner", "Banner URL", {
|
||||
type: "url",
|
||||
placeholder: "https://example.com/banner.jpg",
|
||||
help: "HTTPS URL to a banner image",
|
||||
})}
|
||||
|
||||
${renderField("website", "Website", {
|
||||
type: "url",
|
||||
placeholder: "https://example.com",
|
||||
help: "Your personal website",
|
||||
})}
|
||||
|
||||
${renderField("nip05", "NIP-05 Identifier", {
|
||||
placeholder: "you@example.com",
|
||||
help: "Verifiable identifier (e.g., you@domain.com)",
|
||||
})}
|
||||
|
||||
${renderField("lud16", "Lightning Address", {
|
||||
placeholder: "you@getalby.com",
|
||||
help: "Lightning address for tips (LUD-16)",
|
||||
})}
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
|
||||
<div style="display: flex; gap: 8px; margin-top: 16px; flex-wrap: wrap;">
|
||||
<button
|
||||
class="btn primary"
|
||||
@click=${callbacks.onSave}
|
||||
?disabled=${state.saving || !isDirty}
|
||||
>
|
||||
${state.saving ? "Saving..." : "Save & Publish"}
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="btn"
|
||||
@click=${callbacks.onImport}
|
||||
?disabled=${state.importing || state.saving}
|
||||
>
|
||||
${state.importing ? "Importing..." : "Import from Relays"}
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="btn"
|
||||
@click=${callbacks.onToggleAdvanced}
|
||||
>
|
||||
${state.showAdvanced ? "Hide Advanced" : "Show Advanced"}
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="btn"
|
||||
@click=${callbacks.onCancel}
|
||||
?disabled=${state.saving}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
|
||||
${isDirty
|
||||
? html`<div style="font-size: 12px; color: var(--warning-color); margin-top: 8px;">
|
||||
You have unsaved changes
|
||||
</div>`
|
||||
: nothing}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Factory
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Create initial form state from existing profile
|
||||
*/
|
||||
export function createNostrProfileFormState(
|
||||
profile: NostrProfileType | undefined
|
||||
): NostrProfileFormState {
|
||||
const values: NostrProfileType = {
|
||||
name: profile?.name ?? "",
|
||||
displayName: profile?.displayName ?? "",
|
||||
about: profile?.about ?? "",
|
||||
picture: profile?.picture ?? "",
|
||||
banner: profile?.banner ?? "",
|
||||
website: profile?.website ?? "",
|
||||
nip05: profile?.nip05 ?? "",
|
||||
lud16: profile?.lud16 ?? "",
|
||||
};
|
||||
|
||||
return {
|
||||
values,
|
||||
original: { ...values },
|
||||
saving: false,
|
||||
importing: false,
|
||||
error: null,
|
||||
success: null,
|
||||
fieldErrors: {},
|
||||
showAdvanced: Boolean(
|
||||
profile?.banner || profile?.website || profile?.nip05 || profile?.lud16
|
||||
),
|
||||
};
|
||||
}
|
||||
217
docker-compose/ez-assistant/ui/src/ui/views/channels.nostr.ts
Normal file
217
docker-compose/ez-assistant/ui/src/ui/views/channels.nostr.ts
Normal file
@@ -0,0 +1,217 @@
|
||||
import { html, nothing } from "lit";
|
||||
|
||||
import { formatAgo } from "../format";
|
||||
import type { ChannelAccountSnapshot, NostrStatus } from "../types";
|
||||
import type { ChannelsProps } from "./channels.types";
|
||||
import { renderChannelConfigSection } from "./channels.config";
|
||||
import {
|
||||
renderNostrProfileForm,
|
||||
type NostrProfileFormState,
|
||||
type NostrProfileFormCallbacks,
|
||||
} from "./channels.nostr-profile-form";
|
||||
|
||||
/**
|
||||
* Truncate a pubkey for display (shows first and last 8 chars)
|
||||
*/
|
||||
function truncatePubkey(pubkey: string | null | undefined): string {
|
||||
if (!pubkey) return "n/a";
|
||||
if (pubkey.length <= 20) return pubkey;
|
||||
return `${pubkey.slice(0, 8)}...${pubkey.slice(-8)}`;
|
||||
}
|
||||
|
||||
export function renderNostrCard(params: {
|
||||
props: ChannelsProps;
|
||||
nostr?: NostrStatus | null;
|
||||
nostrAccounts: ChannelAccountSnapshot[];
|
||||
accountCountLabel: unknown;
|
||||
/** Profile form state (optional - if provided, shows form) */
|
||||
profileFormState?: NostrProfileFormState | null;
|
||||
/** Profile form callbacks */
|
||||
profileFormCallbacks?: NostrProfileFormCallbacks | null;
|
||||
/** Called when Edit Profile is clicked */
|
||||
onEditProfile?: () => void;
|
||||
}) {
|
||||
const {
|
||||
props,
|
||||
nostr,
|
||||
nostrAccounts,
|
||||
accountCountLabel,
|
||||
profileFormState,
|
||||
profileFormCallbacks,
|
||||
onEditProfile,
|
||||
} = params;
|
||||
const primaryAccount = nostrAccounts[0];
|
||||
const summaryConfigured = nostr?.configured ?? primaryAccount?.configured ?? false;
|
||||
const summaryRunning = nostr?.running ?? primaryAccount?.running ?? false;
|
||||
const summaryPublicKey =
|
||||
nostr?.publicKey ??
|
||||
(primaryAccount as { publicKey?: string } | undefined)?.publicKey;
|
||||
const summaryLastStartAt = nostr?.lastStartAt ?? primaryAccount?.lastStartAt ?? null;
|
||||
const summaryLastError = nostr?.lastError ?? primaryAccount?.lastError ?? null;
|
||||
const hasMultipleAccounts = nostrAccounts.length > 1;
|
||||
const showingForm = profileFormState !== null && profileFormState !== undefined;
|
||||
|
||||
const renderAccountCard = (account: ChannelAccountSnapshot) => {
|
||||
const publicKey = (account as { publicKey?: string }).publicKey;
|
||||
const profile = (account as { profile?: { name?: string; displayName?: string } }).profile;
|
||||
const displayName = profile?.displayName ?? profile?.name ?? account.name ?? account.accountId;
|
||||
|
||||
return html`
|
||||
<div class="account-card">
|
||||
<div class="account-card-header">
|
||||
<div class="account-card-title">${displayName}</div>
|
||||
<div class="account-card-id">${account.accountId}</div>
|
||||
</div>
|
||||
<div class="status-list account-card-status">
|
||||
<div>
|
||||
<span class="label">Running</span>
|
||||
<span>${account.running ? "Yes" : "No"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Configured</span>
|
||||
<span>${account.configured ? "Yes" : "No"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Public Key</span>
|
||||
<span class="monospace" title="${publicKey ?? ""}">${truncatePubkey(publicKey)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Last inbound</span>
|
||||
<span>${account.lastInboundAt ? formatAgo(account.lastInboundAt) : "n/a"}</span>
|
||||
</div>
|
||||
${account.lastError
|
||||
? html`
|
||||
<div class="account-card-error">${account.lastError}</div>
|
||||
`
|
||||
: nothing}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
};
|
||||
|
||||
const renderProfileSection = () => {
|
||||
// If showing form, render the form instead of the read-only view
|
||||
if (showingForm && profileFormCallbacks) {
|
||||
return renderNostrProfileForm({
|
||||
state: profileFormState,
|
||||
callbacks: profileFormCallbacks,
|
||||
accountId: nostrAccounts[0]?.accountId ?? "default",
|
||||
});
|
||||
}
|
||||
|
||||
const profile =
|
||||
(primaryAccount as
|
||||
| {
|
||||
profile?: {
|
||||
name?: string;
|
||||
displayName?: string;
|
||||
about?: string;
|
||||
picture?: string;
|
||||
nip05?: string;
|
||||
};
|
||||
}
|
||||
| undefined)?.profile ?? nostr?.profile;
|
||||
const { name, displayName, about, picture, nip05 } = profile ?? {};
|
||||
const hasAnyProfileData = name || displayName || about || picture || nip05;
|
||||
|
||||
return html`
|
||||
<div style="margin-top: 16px; padding: 12px; background: var(--bg-secondary); border-radius: 8px;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
|
||||
<div style="font-weight: 500;">Profile</div>
|
||||
${summaryConfigured
|
||||
? html`
|
||||
<button
|
||||
class="btn btn-sm"
|
||||
@click=${onEditProfile}
|
||||
style="font-size: 12px; padding: 4px 8px;"
|
||||
>
|
||||
Edit Profile
|
||||
</button>
|
||||
`
|
||||
: nothing}
|
||||
</div>
|
||||
${hasAnyProfileData
|
||||
? html`
|
||||
<div class="status-list">
|
||||
${picture
|
||||
? html`
|
||||
<div style="margin-bottom: 8px;">
|
||||
<img
|
||||
src=${picture}
|
||||
alt="Profile picture"
|
||||
style="width: 48px; height: 48px; border-radius: 50%; object-fit: cover; border: 2px solid var(--border-color);"
|
||||
@error=${(e: Event) => {
|
||||
(e.target as HTMLImageElement).style.display = "none";
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
${name ? html`<div><span class="label">Name</span><span>${name}</span></div>` : nothing}
|
||||
${displayName
|
||||
? html`<div><span class="label">Display Name</span><span>${displayName}</span></div>`
|
||||
: nothing}
|
||||
${about
|
||||
? html`<div><span class="label">About</span><span style="max-width: 300px; overflow: hidden; text-overflow: ellipsis;">${about}</span></div>`
|
||||
: nothing}
|
||||
${nip05 ? html`<div><span class="label">NIP-05</span><span>${nip05}</span></div>` : nothing}
|
||||
</div>
|
||||
`
|
||||
: html`
|
||||
<div style="color: var(--text-muted); font-size: 13px;">
|
||||
No profile set. Click "Edit Profile" to add your name, bio, and avatar.
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
`;
|
||||
};
|
||||
|
||||
return html`
|
||||
<div class="card">
|
||||
<div class="card-title">Nostr</div>
|
||||
<div class="card-sub">Decentralized DMs via Nostr relays (NIP-04).</div>
|
||||
${accountCountLabel}
|
||||
|
||||
${hasMultipleAccounts
|
||||
? html`
|
||||
<div class="account-card-list">
|
||||
${nostrAccounts.map((account) => renderAccountCard(account))}
|
||||
</div>
|
||||
`
|
||||
: html`
|
||||
<div class="status-list" style="margin-top: 16px;">
|
||||
<div>
|
||||
<span class="label">Configured</span>
|
||||
<span>${summaryConfigured ? "Yes" : "No"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Running</span>
|
||||
<span>${summaryRunning ? "Yes" : "No"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Public Key</span>
|
||||
<span class="monospace" title="${summaryPublicKey ?? ""}"
|
||||
>${truncatePubkey(summaryPublicKey)}</span
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Last start</span>
|
||||
<span>${summaryLastStartAt ? formatAgo(summaryLastStartAt) : "n/a"}</span>
|
||||
</div>
|
||||
</div>
|
||||
`}
|
||||
|
||||
${summaryLastError
|
||||
? html`<div class="callout danger" style="margin-top: 12px;">${summaryLastError}</div>`
|
||||
: nothing}
|
||||
|
||||
${renderProfileSection()}
|
||||
|
||||
${renderChannelConfigSection({ channelId: "nostr", props })}
|
||||
|
||||
<div class="row" style="margin-top: 12px;">
|
||||
<button class="btn" @click=${() => props.onRefresh(false)}>Refresh</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { html, nothing } from "lit";
|
||||
|
||||
import type { ChannelAccountSnapshot } from "../types";
|
||||
import type { ChannelKey, ChannelsProps } from "./channels.types";
|
||||
|
||||
export function formatDuration(ms?: number | null) {
|
||||
if (!ms && ms !== 0) return "n/a";
|
||||
const sec = Math.round(ms / 1000);
|
||||
if (sec < 60) return `${sec}s`;
|
||||
const min = Math.round(sec / 60);
|
||||
if (min < 60) return `${min}m`;
|
||||
const hr = Math.round(min / 60);
|
||||
return `${hr}h`;
|
||||
}
|
||||
|
||||
export function channelEnabled(key: ChannelKey, props: ChannelsProps) {
|
||||
const snapshot = props.snapshot;
|
||||
const channels = snapshot?.channels as Record<string, unknown> | null;
|
||||
if (!snapshot || !channels) return false;
|
||||
const channelStatus = channels[key] as Record<string, unknown> | undefined;
|
||||
const configured = typeof channelStatus?.configured === "boolean" && channelStatus.configured;
|
||||
const running = typeof channelStatus?.running === "boolean" && channelStatus.running;
|
||||
const connected = typeof channelStatus?.connected === "boolean" && channelStatus.connected;
|
||||
const accounts = snapshot.channelAccounts?.[key] ?? [];
|
||||
const accountActive = accounts.some(
|
||||
(account) => account.configured || account.running || account.connected,
|
||||
);
|
||||
return configured || running || connected || accountActive;
|
||||
}
|
||||
|
||||
export function getChannelAccountCount(
|
||||
key: ChannelKey,
|
||||
channelAccounts?: Record<string, ChannelAccountSnapshot[]> | null,
|
||||
): number {
|
||||
return channelAccounts?.[key]?.length ?? 0;
|
||||
}
|
||||
|
||||
export function renderChannelAccountCount(
|
||||
key: ChannelKey,
|
||||
channelAccounts?: Record<string, ChannelAccountSnapshot[]> | null,
|
||||
) {
|
||||
const count = getChannelAccountCount(key, channelAccounts);
|
||||
if (count < 2) return nothing;
|
||||
return html`<div class="account-count">Accounts (${count})</div>`;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { html, nothing } from "lit";
|
||||
|
||||
import { formatAgo } from "../format";
|
||||
import type { SignalStatus } from "../types";
|
||||
import type { ChannelsProps } from "./channels.types";
|
||||
import { renderChannelConfigSection } from "./channels.config";
|
||||
|
||||
export function renderSignalCard(params: {
|
||||
props: ChannelsProps;
|
||||
signal?: SignalStatus | null;
|
||||
accountCountLabel: unknown;
|
||||
}) {
|
||||
const { props, signal, accountCountLabel } = params;
|
||||
|
||||
return html`
|
||||
<div class="card">
|
||||
<div class="card-title">Signal</div>
|
||||
<div class="card-sub">signal-cli status and channel configuration.</div>
|
||||
${accountCountLabel}
|
||||
|
||||
<div class="status-list" style="margin-top: 16px;">
|
||||
<div>
|
||||
<span class="label">Configured</span>
|
||||
<span>${signal?.configured ? "Yes" : "No"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Running</span>
|
||||
<span>${signal?.running ? "Yes" : "No"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Base URL</span>
|
||||
<span>${signal?.baseUrl ?? "n/a"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Last start</span>
|
||||
<span>${signal?.lastStartAt ? formatAgo(signal.lastStartAt) : "n/a"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Last probe</span>
|
||||
<span>${signal?.lastProbeAt ? formatAgo(signal.lastProbeAt) : "n/a"}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${signal?.lastError
|
||||
? html`<div class="callout danger" style="margin-top: 12px;">
|
||||
${signal.lastError}
|
||||
</div>`
|
||||
: nothing}
|
||||
|
||||
${signal?.probe
|
||||
? html`<div class="callout" style="margin-top: 12px;">
|
||||
Probe ${signal.probe.ok ? "ok" : "failed"} ·
|
||||
${signal.probe.status ?? ""} ${signal.probe.error ?? ""}
|
||||
</div>`
|
||||
: nothing}
|
||||
|
||||
${renderChannelConfigSection({ channelId: "signal", props })}
|
||||
|
||||
<div class="row" style="margin-top: 12px;">
|
||||
<button class="btn" @click=${() => props.onRefresh(true)}>
|
||||
Probe
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { html, nothing } from "lit";
|
||||
|
||||
import { formatAgo } from "../format";
|
||||
import type { SlackStatus } from "../types";
|
||||
import type { ChannelsProps } from "./channels.types";
|
||||
import { renderChannelConfigSection } from "./channels.config";
|
||||
|
||||
export function renderSlackCard(params: {
|
||||
props: ChannelsProps;
|
||||
slack?: SlackStatus | null;
|
||||
accountCountLabel: unknown;
|
||||
}) {
|
||||
const { props, slack, accountCountLabel } = params;
|
||||
|
||||
return html`
|
||||
<div class="card">
|
||||
<div class="card-title">Slack</div>
|
||||
<div class="card-sub">Socket mode status and channel configuration.</div>
|
||||
${accountCountLabel}
|
||||
|
||||
<div class="status-list" style="margin-top: 16px;">
|
||||
<div>
|
||||
<span class="label">Configured</span>
|
||||
<span>${slack?.configured ? "Yes" : "No"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Running</span>
|
||||
<span>${slack?.running ? "Yes" : "No"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Last start</span>
|
||||
<span>${slack?.lastStartAt ? formatAgo(slack.lastStartAt) : "n/a"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Last probe</span>
|
||||
<span>${slack?.lastProbeAt ? formatAgo(slack.lastProbeAt) : "n/a"}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${slack?.lastError
|
||||
? html`<div class="callout danger" style="margin-top: 12px;">
|
||||
${slack.lastError}
|
||||
</div>`
|
||||
: nothing}
|
||||
|
||||
${slack?.probe
|
||||
? html`<div class="callout" style="margin-top: 12px;">
|
||||
Probe ${slack.probe.ok ? "ok" : "failed"} ·
|
||||
${slack.probe.status ?? ""} ${slack.probe.error ?? ""}
|
||||
</div>`
|
||||
: nothing}
|
||||
|
||||
${renderChannelConfigSection({ channelId: "slack", props })}
|
||||
|
||||
<div class="row" style="margin-top: 12px;">
|
||||
<button class="btn" @click=${() => props.onRefresh(true)}>
|
||||
Probe
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user