Production-ready sidebar, toolbar, auth, and routing for loop42.de
Slim sidebar (Home, nyx, Impressum, Datenschutz, Sign-in/User), global AppToolbar for system features, /agents→/nyx rename, agent auto-select, OIDC user name extraction from id_token, theme-consistent content pages, removed DevView and old system panel. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
4718feb773
commit
db10ab93fd
597
css/sidebar.css
597
css/sidebar.css
@ -3,7 +3,7 @@
|
|||||||
:root {
|
:root {
|
||||||
--sidebar-width: 240px;
|
--sidebar-width: 240px;
|
||||||
--sidebar-collapsed-width: 48px;
|
--sidebar-collapsed-width: 48px;
|
||||||
--sidebar-header-height: 40px; /* aligns with content-top in views */
|
--sidebar-header-height: 40px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── App body: sidebar + content column ── */
|
/* ── App body: sidebar + content column ── */
|
||||||
@ -42,34 +42,20 @@
|
|||||||
z-index: 100;
|
z-index: 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Fade text/indicators on expand/collapse */
|
/* Fade text on collapse */
|
||||||
.sidebar-logo-label,
|
.sidebar-brand-name,
|
||||||
.sidebar-room-name,
|
|
||||||
.sidebar-segment-label,
|
|
||||||
.sidebar-channel-indicators,
|
|
||||||
.sidebar-room-mode-btn,
|
|
||||||
.sidebar-chevron-btn,
|
|
||||||
.sidebar-capture-btn,
|
|
||||||
.sidebar-link span,
|
.sidebar-link span,
|
||||||
.sidebar-user-name {
|
.sidebar-user-name {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transition: opacity 0.15s ease 0.1s; /* 0.1s delay so width expands first */
|
transition: opacity 0.15s ease 0.1s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-sidebar.is-collapsed .sidebar-logo-label,
|
.app-sidebar.is-collapsed .sidebar-brand-name,
|
||||||
.app-sidebar.is-collapsed .sidebar-room-name,
|
|
||||||
.app-sidebar.is-collapsed .sidebar-segment-label,
|
|
||||||
.app-sidebar.is-collapsed .sidebar-channel-indicators,
|
|
||||||
.app-sidebar.is-collapsed .sidebar-room-mode-btn,
|
|
||||||
.app-sidebar.is-collapsed .sidebar-chevron-btn,
|
|
||||||
.app-sidebar.is-collapsed .sidebar-capture-btn,
|
|
||||||
.app-sidebar.is-collapsed .sidebar-link span,
|
.app-sidebar.is-collapsed .sidebar-link span,
|
||||||
.app-sidebar.is-collapsed .sidebar-user-name {
|
.app-sidebar.is-collapsed .sidebar-user-name {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: opacity 0.05s ease; /* fade out fast */
|
transition: opacity 0.05s ease;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
position: relative;
|
|
||||||
z-index: 20;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-sidebar.is-collapsed {
|
.app-sidebar.is-collapsed {
|
||||||
@ -81,12 +67,7 @@
|
|||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Legacy gradient shadow — replaced by panel box-shadow */
|
/* Invisible click target to close sidebar */
|
||||||
.sidebar-shadow {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Invisible click target to close sidebar — behind sidebar content */
|
|
||||||
.sidebar-close-target {
|
.sidebar-close-target {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
@ -95,19 +76,15 @@
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Sidebar header (logo + collapse) ── */
|
/* ── Header (toggle + brand) ── */
|
||||||
.sidebar-header {
|
.sidebar-header {
|
||||||
height: var(--sidebar-header-height);
|
height: var(--sidebar-header-height);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
gap: 0;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-header { overflow: hidden; }
|
|
||||||
.app-sidebar.is-collapsed .sidebar-header { cursor: pointer; }
|
|
||||||
|
|
||||||
/* Brand: logo + name centered (flex:1 fills remaining space) */
|
|
||||||
.sidebar-brand {
|
.sidebar-brand {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -118,15 +95,13 @@
|
|||||||
color: var(--text);
|
color: var(--text);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: opacity 0.15s;
|
transition: color 0.12s;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
/* Offset for chevron width so brand is visually centered in sidebar */
|
padding-right: 36px; /* offset toggle btn width so brand is visually centered */
|
||||||
padding-right: var(--sidebar-collapsed-width);
|
|
||||||
}
|
}
|
||||||
.sidebar-brand:hover { opacity: 0.8; text-decoration: none; }
|
.sidebar-brand:hover { color: var(--text); text-decoration: none; }
|
||||||
|
|
||||||
/* Chevron rotation animation */
|
|
||||||
.sidebar-chevron-anim {
|
.sidebar-chevron-anim {
|
||||||
transition: transform 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
transition: transform 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
}
|
}
|
||||||
@ -145,10 +120,9 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Toggle button: right side when expanded, centered when collapsed */
|
|
||||||
.sidebar-toggle-btn {
|
.sidebar-toggle-btn {
|
||||||
width: var(--sidebar-collapsed-width);
|
width: 36px;
|
||||||
height: 100%;
|
height: 32px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@ -157,194 +131,20 @@
|
|||||||
border: none;
|
border: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
transition: color 0.15s, background 0.15s;
|
border-radius: var(--radius-sm);
|
||||||
|
transition: color 0.12s, background 0.12s;
|
||||||
|
margin-left: 0;
|
||||||
}
|
}
|
||||||
.sidebar-toggle-btn:hover { color: var(--text); background: color-mix(in srgb, var(--accent) 8%, transparent); }
|
.sidebar-toggle-btn:hover { color: var(--text); background: color-mix(in srgb, var(--accent) 8%, transparent); }
|
||||||
|
|
||||||
/* Icon slot: fixed 48px centered area — never moves on collapse */
|
/* ── Nav links ── */
|
||||||
.sidebar-logo-img,
|
.sidebar-nav {
|
||||||
.sidebar-theme-icon {
|
|
||||||
flex-shrink: 0;
|
|
||||||
width: var(--sidebar-collapsed-width);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
.sidebar-logo-img {
|
|
||||||
height: 18px;
|
|
||||||
object-fit: contain;
|
|
||||||
padding: 0 14px;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
.sidebar-theme-icon {
|
|
||||||
height: 20px;
|
|
||||||
stroke-width: 1.5;
|
|
||||||
color: var(--accent);
|
|
||||||
padding: 0 14px;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
.sidebar-logo-label {
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
color: var(--text);
|
|
||||||
letter-spacing: 0.02em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-chevron-icon {
|
|
||||||
margin-left: auto;
|
|
||||||
opacity: 0.5;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-chevron-btn {
|
|
||||||
width: var(--sidebar-collapsed-width);
|
|
||||||
height: 100%;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
flex-shrink: 0;
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
color: var(--text-dim);
|
|
||||||
transition: color 0.15s, background 0.15s;
|
|
||||||
}
|
|
||||||
.sidebar-chevron-btn:hover { color: var(--text); background: color-mix(in srgb, var(--accent) 8%, transparent); }
|
|
||||||
|
|
||||||
/* ── Rooms (agents) ── */
|
|
||||||
.sidebar-rooms {
|
|
||||||
flex: 1 1 0;
|
|
||||||
min-height: 0;
|
|
||||||
padding: 4px 0;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 1px;
|
|
||||||
overflow-x: hidden;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Segment label */
|
|
||||||
.sidebar-segment-label {
|
|
||||||
padding: 8px 14px 2px;
|
|
||||||
height: 27px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
font-size: var(--text-base);
|
|
||||||
font-weight: 600;
|
|
||||||
letter-spacing: 0.08em;
|
|
||||||
text-transform: capitalize;
|
|
||||||
color: var(--text-dim);
|
|
||||||
opacity: 0.5;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Agent row: icon slot is always 48px wide, centered */
|
|
||||||
.sidebar-room {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0;
|
|
||||||
padding: 0 10px 0 0;
|
|
||||||
height: 30px;
|
|
||||||
min-height: 30px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
color: var(--text-dim);
|
|
||||||
text-decoration: none;
|
|
||||||
font-size: var(--text-base);
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
transition: background 0.12s, color 0.12s;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.sidebar-room:hover { background: color-mix(in srgb, var(--accent) 8%, transparent); color: var(--text); }
|
|
||||||
.sidebar-room.active { color: var(--text); background: color-mix(in srgb, var(--accent) 12%, transparent); }
|
|
||||||
|
|
||||||
/* ── Role dots ── */
|
|
||||||
.sidebar-room-dot {
|
|
||||||
width: 8px;
|
|
||||||
height: 8px;
|
|
||||||
border-radius: 50%;
|
|
||||||
flex-shrink: 0;
|
|
||||||
position: relative;
|
|
||||||
/* Center dot in 48px icon slot (minus 6px panel padding when expanded) */
|
|
||||||
margin-left: 14px;
|
|
||||||
margin-right: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/* owner: solid filled dot */
|
|
||||||
.dot-owner {
|
|
||||||
background: var(--accent);
|
|
||||||
box-shadow: 0 0 0 0px transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* member: outlined ring (dot + same-color ring) */
|
|
||||||
.dot-member {
|
|
||||||
background: transparent;
|
|
||||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 60%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* common: small dot + outer ring gap */
|
|
||||||
.dot-common {
|
|
||||||
background: #34d399;
|
|
||||||
box-shadow: 0 0 0 2px color-mix(in srgb, #34d399 40%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* guest: dashed ring (simulate with border) */
|
|
||||||
.dot-guest {
|
|
||||||
background: transparent;
|
|
||||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--text-dim) 40%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* utility: dim solid */
|
|
||||||
.dot-utility {
|
|
||||||
background: color-mix(in srgb, var(--text-dim) 35%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-room-placeholder { pointer-events: none; opacity: 0; }
|
|
||||||
.sidebar-room-name {
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-room-mode-btn {
|
|
||||||
margin-left: auto;
|
|
||||||
color: var(--text-dim);
|
|
||||||
opacity: 0.55;
|
|
||||||
white-space: nowrap;
|
|
||||||
flex-shrink: 0;
|
|
||||||
text-decoration: none;
|
|
||||||
padding: 1px 6px;
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.sidebar-room-mode-btn:hover { opacity: 1; color: var(--text); background: color-mix(in srgb, var(--accent) 12%, transparent); }
|
|
||||||
.sidebar-room-mode-btn.active { opacity: 1; color: var(--accent); }
|
|
||||||
|
|
||||||
/* Collapsed: keep segment labels same height for Y alignment, just hide text */
|
|
||||||
.sidebar-segment-label.is-collapsed {
|
|
||||||
visibility: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Spacer ── */
|
|
||||||
.sidebar-spacer {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Home section (above agents) ── */
|
|
||||||
.sidebar-home-section {
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Nav links (viewer, dev) ── */
|
|
||||||
.sidebar-nav-links {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 2px;
|
|
||||||
padding: 4px 0;
|
padding: 4px 0;
|
||||||
overflow: hidden;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Shared row style: icon in fixed 48px slot, text after */
|
/* Unified link style — used for all sidebar items */
|
||||||
.sidebar-link {
|
.sidebar-link {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@ -362,131 +162,51 @@
|
|||||||
border: none;
|
border: none;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
}
|
}
|
||||||
.sidebar-link:hover { background: color-mix(in srgb, var(--accent) 8%, transparent); color: var(--text); }
|
.sidebar-link:hover { background: color-mix(in srgb, var(--accent) 8%, transparent); color: var(--text); }
|
||||||
.sidebar-link.active { color: var(--accent); }
|
.sidebar-link.active { color: var(--accent); }
|
||||||
.sidebar-link svg {
|
.sidebar-link svg {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
/* Center 16px icon in 48px slot (minus 6px panel padding) */
|
|
||||||
width: 16px;
|
width: 16px;
|
||||||
height: 16px;
|
height: 16px;
|
||||||
margin-left: 10px;
|
margin-left: 10px;
|
||||||
margin-right: 10px;
|
margin-right: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Spacer ── */
|
||||||
|
.sidebar-flex-spacer {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Channel state indicators (dual: private + public) ── */
|
/* ── Footer (legal + user) ── */
|
||||||
.sidebar-room { position: relative; }
|
.sidebar-footer {
|
||||||
.sidebar-channel-indicators {
|
|
||||||
position: absolute;
|
|
||||||
right: 44px;
|
|
||||||
top: 0;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 3px;
|
flex-direction: column;
|
||||||
align-items: center;
|
|
||||||
height: 30px;
|
|
||||||
pointer-events: none;
|
|
||||||
font-size: var(--text-base);
|
|
||||||
}
|
|
||||||
.sidebar-ch-dot { opacity: 0.7; }
|
|
||||||
.sidebar-ch-dot.ch-running { animation: pulse 2s infinite; }
|
|
||||||
.sidebar-ch-dot.ch-ready { opacity: 0.3; }
|
|
||||||
.sidebar-ch-dot.ch-fresh { opacity: 0.6; }
|
|
||||||
.sidebar-ch-dot.ch-nosession { opacity: 0.15; }
|
|
||||||
.sidebar-ch-dot.ch-none { opacity: 0.15; }
|
|
||||||
|
|
||||||
/* ── Connection status ── */
|
|
||||||
.sidebar-connection {
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
.sidebar-connection .sidebar-link { color: var(--text-dim); opacity: 0.4; }
|
|
||||||
.sidebar-connection.active .sidebar-link { color: var(--success, #22c55e); opacity: 0.7; }
|
|
||||||
|
|
||||||
/* ── Connection status panel ── */
|
|
||||||
.sidebar-conn-wrap {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Takeover status ── */
|
|
||||||
.sidebar-takeover-wrap {
|
|
||||||
padding: 0;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
.sidebar-takeover-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
.sidebar-takeover-row .sidebar-link { flex: 1; }
|
|
||||||
.sidebar-takeover-wrap .sidebar-link { color: var(--text-dim); opacity: 0.4; }
|
|
||||||
.sidebar-takeover-wrap.active .sidebar-link { color: var(--success, #22c55e); opacity: 0.7; }
|
|
||||||
.sidebar-takeover-wrap.active .sidebar-link svg { animation: pulse 2s infinite; }
|
|
||||||
|
|
||||||
.sidebar-capture-btn {
|
|
||||||
width: 28px;
|
|
||||||
height: 28px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
margin-right: 10px;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
color: var(--text-dim);
|
|
||||||
opacity: 0.5;
|
|
||||||
transition: opacity 0.15s, color 0.15s, background 0.15s;
|
|
||||||
}
|
|
||||||
.sidebar-capture-btn:hover { opacity: 1; background: color-mix(in srgb, var(--accent) 8%, transparent); }
|
|
||||||
.sidebar-capture-btn.active { color: var(--success, #22c55e); opacity: 1; }
|
|
||||||
.sidebar-capture-btn.active svg { animation: pulse 2s infinite; }
|
|
||||||
|
|
||||||
/* ── Bottom (user) ── */
|
|
||||||
.sidebar-bottom {
|
|
||||||
padding: 4px 0;
|
padding: 4px 0;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
z-index: 160;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Footer links without icons: indent text to match icon-based rows */
|
||||||
|
.sidebar-footer > a.sidebar-link:not(:has(svg)) {
|
||||||
|
padding-left: 36px;
|
||||||
|
}
|
||||||
|
.app-sidebar.is-collapsed .sidebar-footer > a.sidebar-link:not(:has(svg)) {
|
||||||
|
padding-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── User menu ── */
|
||||||
.sidebar-user-wrap {
|
.sidebar-user-wrap {
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-user-btn {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0;
|
|
||||||
padding: 0;
|
|
||||||
height: 32px;
|
|
||||||
width: 100%;
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
color: var(--text-dim);
|
|
||||||
font-size: var(--text-base);
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
transition: background 0.12s, color 0.12s;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
.sidebar-user-btn:hover { background: color-mix(in srgb, var(--accent) 8%, transparent); color: var(--text); }
|
|
||||||
.sidebar-user-btn svg {
|
|
||||||
flex-shrink: 0;
|
|
||||||
width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
margin-left: 10px;
|
|
||||||
margin-right: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-user-name { overflow: hidden; text-overflow: ellipsis; }
|
|
||||||
|
|
||||||
/* ── Panel backdrop: closes any open panel on click ── */
|
|
||||||
.sidebar-panel-backdrop {
|
.sidebar-panel-backdrop {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
z-index: 150;
|
z-index: 150;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Shared popup panel style ── */
|
|
||||||
.sidebar-panel,
|
|
||||||
.sidebar-user-menu {
|
.sidebar-user-menu {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
@ -496,57 +216,9 @@
|
|||||||
background: var(--panel-bg);
|
background: var(--panel-bg);
|
||||||
border-radius: var(--radius-panel);
|
border-radius: var(--radius-panel);
|
||||||
box-shadow: var(--panel-shadow);
|
box-shadow: var(--panel-shadow);
|
||||||
z-index: 200; /* above .sidebar-panel-backdrop (150) */
|
z-index: 200;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.sidebar-panel-header {
|
|
||||||
padding: 8px 12px 4px;
|
|
||||||
font-size: var(--text-base);
|
|
||||||
font-weight: 600;
|
|
||||||
letter-spacing: 0.06em;
|
|
||||||
text-transform: capitalize;
|
|
||||||
color: var(--text-dim);
|
|
||||||
}
|
|
||||||
.sidebar-panel-token {
|
|
||||||
padding: 8px 12px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.12s;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
.sidebar-panel-token:hover { background: color-mix(in srgb, var(--accent) 8%, transparent); }
|
|
||||||
.sidebar-panel-token code {
|
|
||||||
font-size: var(--text-base);
|
|
||||||
color: var(--accent);
|
|
||||||
word-break: break-all;
|
|
||||||
}
|
|
||||||
.sidebar-panel-copied {
|
|
||||||
position: absolute;
|
|
||||||
right: 12px;
|
|
||||||
top: 50%;
|
|
||||||
transform: translateY(-50%);
|
|
||||||
font-size: var(--text-base);
|
|
||||||
color: var(--success, #22c55e);
|
|
||||||
}
|
|
||||||
.sidebar-panel-row {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 6px 12px;
|
|
||||||
font-size: var(--text-base);
|
|
||||||
color: var(--text-dim);
|
|
||||||
}
|
|
||||||
.sidebar-panel-item {
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
padding: 7px 12px;
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
color: var(--text);
|
|
||||||
font-size: var(--text-base);
|
|
||||||
text-align: left;
|
|
||||||
transition: background 0.12s;
|
|
||||||
}
|
|
||||||
.sidebar-panel-item:hover { background: color-mix(in srgb, var(--error) 12%, transparent); color: var(--error); }
|
|
||||||
.sidebar-user-menu-header {
|
.sidebar-user-menu-header {
|
||||||
padding: 8px 12px 4px;
|
padding: 8px 12px 4px;
|
||||||
font-size: var(--text-base);
|
font-size: var(--text-base);
|
||||||
@ -562,16 +234,11 @@
|
|||||||
color: var(--text);
|
color: var(--text);
|
||||||
font-size: var(--text-base);
|
font-size: var(--text-base);
|
||||||
text-align: left;
|
text-align: left;
|
||||||
transition: background 0.12s;
|
transition: background 0.12s, color 0.12s;
|
||||||
}
|
}
|
||||||
.sidebar-user-menu-item:hover { background: color-mix(in srgb, var(--error) 12%, transparent); color: var(--error); }
|
.sidebar-user-menu-item:hover { background: color-mix(in srgb, var(--error) 12%, transparent); color: var(--error); }
|
||||||
|
|
||||||
/* Collapsed: smaller menu width */
|
/* ── Sidebar spacer (reserves rail width) ── */
|
||||||
.app-sidebar.is-collapsed .sidebar-user-menu {
|
|
||||||
width: 160px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Sidebar spacer (reserves rail width on all screens) ── */
|
|
||||||
.sidebar-spacer {
|
.sidebar-spacer {
|
||||||
display: block;
|
display: block;
|
||||||
width: var(--sidebar-collapsed-width);
|
width: var(--sidebar-collapsed-width);
|
||||||
@ -580,36 +247,7 @@
|
|||||||
flex: 0 0 var(--sidebar-collapsed-width);
|
flex: 0 0 var(--sidebar-collapsed-width);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Version ── */
|
/* ── Large screens: sidebar in flow ── */
|
||||||
.sidebar-version-wrap {
|
|
||||||
position: relative;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.sidebar-version-wrap.is-hidden {
|
|
||||||
visibility: hidden;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
.sidebar-version {
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
padding: 4px 12px;
|
|
||||||
text-align: left;
|
|
||||||
font-size: var(--text-base);
|
|
||||||
color: var(--text-dim);
|
|
||||||
opacity: 0.4;
|
|
||||||
cursor: pointer;
|
|
||||||
user-select: none;
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
transition: opacity 0.15s;
|
|
||||||
}
|
|
||||||
.sidebar-version:hover { opacity: 0.8; }
|
|
||||||
.sidebar-version-panel {
|
|
||||||
bottom: 100%;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Large screens: sidebar in flow, pushes content ── */
|
|
||||||
@media (min-width: 1024px) {
|
@media (min-width: 1024px) {
|
||||||
.app-sidebar {
|
.app-sidebar {
|
||||||
position: relative;
|
position: relative;
|
||||||
@ -623,163 +261,14 @@
|
|||||||
.app-sidebar:not(.is-collapsed) .sidebar-close-target {
|
.app-sidebar:not(.is-collapsed) .sidebar-close-target {
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
/* Spacer matches sidebar width (not fixed 48px) */
|
|
||||||
.sidebar-spacer {
|
.sidebar-spacer {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Mobile tweaks ── */
|
/* ── Mobile ── */
|
||||||
@media (max-width: 480px) {
|
@media (max-width: 480px) {
|
||||||
/* Panels: constrain to viewport */
|
|
||||||
.sidebar-panel,
|
|
||||||
.sidebar-user-menu {
|
.sidebar-user-menu {
|
||||||
width: min(220px, calc(100vw - var(--sidebar-collapsed-width) - 16px)) !important;
|
width: min(220px, calc(100vw - var(--sidebar-collapsed-width) - 16px)) !important;
|
||||||
}
|
}
|
||||||
/* Hide takeover panel on mobile — use /dev on desktop */
|
|
||||||
.sidebar-takeover-wrap .sidebar-panel { display: none !important; }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Top section: default agent + files — shrink-to-fit, shrink when needed */
|
|
||||||
.sidebar-top-section {
|
|
||||||
flex-shrink: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
min-height: 0;
|
|
||||||
}
|
|
||||||
.sidebar-top-section.has-tree {
|
|
||||||
flex-shrink: 1;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
.sidebar-top-section .sidebar-home {
|
|
||||||
flex-shrink: 0;
|
|
||||||
padding: 4px 0;
|
|
||||||
}
|
|
||||||
.sidebar-file-scroll {
|
|
||||||
min-height: 0;
|
|
||||||
font-size: var(--text-base);
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* File sections: toggle row + collapsible tree */
|
|
||||||
.sidebar-file-section {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
min-height: 0;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.sidebar-file-section.is-open {
|
|
||||||
flex-shrink: 1;
|
|
||||||
min-height: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
.sidebar-file-toggle {
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.sidebar-file-chev {
|
|
||||||
margin-left: auto;
|
|
||||||
color: var(--text-dim);
|
|
||||||
opacity: 0.3;
|
|
||||||
flex-shrink: 0;
|
|
||||||
margin-right: 10px;
|
|
||||||
}
|
|
||||||
.sidebar-file-toggle:hover .sidebar-file-chev { opacity: 1; }
|
|
||||||
|
|
||||||
/* Agents nav link */
|
|
||||||
.sidebar-nav-agents {
|
|
||||||
flex-shrink: 0;
|
|
||||||
padding: 4px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* File tree in sidebar — inherit sidebar font */
|
|
||||||
.sidebar-panel-section .file-tree {
|
|
||||||
font-size: var(--text-base);
|
|
||||||
font-family: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Segment divider inside agents panel */
|
|
||||||
.sidebar-segment-divider {
|
|
||||||
font-size: var(--text-base);
|
|
||||||
text-transform: capitalize;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
color: var(--text-dim);
|
|
||||||
opacity: 0.5;
|
|
||||||
padding: 6px 14px 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Collapsed top ── */
|
|
||||||
.sidebar-collapsed-top {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 2px;
|
|
||||||
padding: 4px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Flex spacer (both states) ── */
|
|
||||||
.sidebar-flex-spacer {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Unified bottom section ── */
|
|
||||||
.sidebar-bottom-section {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 2px;
|
|
||||||
padding: 4px 0;
|
|
||||||
position: relative;
|
|
||||||
z-index: 160; /* above backdrop (150) so buttons remain clickable */
|
|
||||||
}
|
|
||||||
.sidebar-bottom {
|
|
||||||
position: relative;
|
|
||||||
z-index: 160;
|
|
||||||
}
|
|
||||||
.sidebar-bottom-section .sidebar-conn-link.active { color: var(--success, #22c55e); }
|
|
||||||
.sidebar-bottom-section .sidebar-takeover-wrap.active .sidebar-link { color: var(--success, #22c55e); }
|
|
||||||
.sidebar-bottom-section .sidebar-takeover-wrap.active .sidebar-link svg { animation: pulse 2s infinite; }
|
|
||||||
|
|
||||||
.sidebar-version-link {
|
|
||||||
color: var(--text-dim) !important;
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
.sidebar-version-link:hover { opacity: 0.8 !important; }
|
|
||||||
.sidebar-version-text {
|
|
||||||
font-size: 0.65rem;
|
|
||||||
white-space: nowrap;
|
|
||||||
width: var(--sidebar-collapsed-width);
|
|
||||||
text-align: center;
|
|
||||||
/* Don't fade on collapse — it's the only content */
|
|
||||||
opacity: 1 !important;
|
|
||||||
pointer-events: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* SidebarPanel and system section above backdrop */
|
|
||||||
.sidebar-panel-section { position: relative; z-index: 160; }
|
|
||||||
.sidebar-system-section { position: relative; z-index: 160; overflow: visible; }
|
|
||||||
.sidebar-system-content { overflow: visible; }
|
|
||||||
.sidebar-system-toggle { opacity: 0.5; }
|
|
||||||
.sidebar-system-toggle:hover { opacity: 0.8; }
|
|
||||||
|
|
||||||
/* Clean up inside SidebarPanel */
|
|
||||||
.sidebar-panel-section .sidebar-nav-links { padding: 0; }
|
|
||||||
|
|
||||||
/* Compact bottom items: same size expanded and collapsed */
|
|
||||||
.sidebar-bottom-section .sidebar-link,
|
|
||||||
.sidebar-panel-content .sidebar-conn-link,
|
|
||||||
.sidebar-panel-content .sidebar-takeover-wrap .sidebar-link,
|
|
||||||
.sidebar-panel-content .sidebar-version-link {
|
|
||||||
height: 30px;
|
|
||||||
}
|
|
||||||
.sidebar-panel-content .sidebar-conn-link svg,
|
|
||||||
.sidebar-panel-content .sidebar-takeover-wrap .sidebar-link svg {
|
|
||||||
width: 14px;
|
|
||||||
height: 14px;
|
|
||||||
margin-left: 11px;
|
|
||||||
margin-right: 10px;
|
|
||||||
}
|
|
||||||
.sidebar-bottom-section .sidebar-link svg {
|
|
||||||
width: 14px;
|
|
||||||
height: 14px;
|
|
||||||
margin-left: 11px;
|
|
||||||
margin-right: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|||||||
48
src/App.vue
48
src/App.vue
@ -9,14 +9,16 @@
|
|||||||
<div class="sidebar-spacer" />
|
<div class="sidebar-spacer" />
|
||||||
|
|
||||||
<div class="main-column">
|
<div class="main-column">
|
||||||
|
<AppToolbar />
|
||||||
<div class="content-area">
|
<div class="content-area">
|
||||||
<TtsPlayerBar />
|
<TtsPlayerBar />
|
||||||
<!-- Socket views: v-if=visited (lazy first mount), class-based hide (preserve scroll) -->
|
<!-- Socket views: v-if=visited (lazy first mount), class-based hide (preserve scroll) -->
|
||||||
<AgentsView v-if="visited.agents" :class="{ 'view-hidden': route.name !== 'agents' }" />
|
<AgentsView v-if="visited.nyx" :class="{ 'view-hidden': route.name !== 'nyx' }" />
|
||||||
<ViewerView v-if="visited.viewer" :class="{ 'view-hidden': route.name !== 'viewer' }" />
|
<ViewerView v-if="visited.viewer" :class="{ 'view-hidden': route.name !== 'viewer' }" />
|
||||||
<DevView v-if="visited.dev" :class="{ 'view-hidden': route.name !== 'dev' }" />
|
<!-- Non-socket views: scrollable -->
|
||||||
<!-- Non-socket views: normal router -->
|
<div v-if="routerReady && !isSocketRoute" class="page-scroll">
|
||||||
<RouterView v-if="routerReady && !isSocketRoute" />
|
<RouterView />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<GridOverlay />
|
<GridOverlay />
|
||||||
<BreakpointBadge />
|
<BreakpointBadge />
|
||||||
@ -28,7 +30,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, reactive, onMounted, computed, watch } from 'vue';
|
import { ref, reactive, onMounted, computed, watch } from 'vue';
|
||||||
import { useRoute } from 'vue-router';
|
import { useRoute } from 'vue-router';
|
||||||
import { useUI } from './composables/ui';
|
|
||||||
import { ws, auth, agents } from './store';
|
import { ws, auth, agents } from './store';
|
||||||
import { THEME_LOGOS, useTheme } from './composables/useTheme';
|
import { THEME_LOGOS, useTheme } from './composables/useTheme';
|
||||||
import { useChatStore } from './store/chat';
|
import { useChatStore } from './store/chat';
|
||||||
@ -38,16 +39,16 @@ import { defineAsyncComponent } from 'vue';
|
|||||||
import GridOverlay from './components/GridOverlay.vue';
|
import GridOverlay from './components/GridOverlay.vue';
|
||||||
import BreakpointBadge from './components/BreakpointBadge.vue';
|
import BreakpointBadge from './components/BreakpointBadge.vue';
|
||||||
import AppSidebar from './components/AppSidebar.vue';
|
import AppSidebar from './components/AppSidebar.vue';
|
||||||
|
import AppToolbar from './components/AppToolbar.vue';
|
||||||
import router from './router';
|
import router from './router';
|
||||||
|
|
||||||
import TtsPlayerBar from './components/TtsPlayerBar.vue';
|
import TtsPlayerBar from './components/TtsPlayerBar.vue';
|
||||||
const AgentsView = defineAsyncComponent(() => import('./views/AgentsView.vue'));
|
const AgentsView = defineAsyncComponent(() => import('./views/AgentsView.vue'));
|
||||||
const ViewerView = defineAsyncComponent(() => import('./views/ViewerView.vue'));
|
const ViewerView = defineAsyncComponent(() => import('./views/ViewerView.vue'));
|
||||||
const DevView = defineAsyncComponent(() => import('./views/DevView.vue'));
|
|
||||||
|
|
||||||
const chatStore = useChatStore();
|
const chatStore = useChatStore();
|
||||||
const visited = reactive({ agents: false, viewer: false, dev: false });
|
const visited = reactive({ nyx: false, viewer: false });
|
||||||
const isSocketRoute = computed(() => ['agents', 'viewer', 'dev'].includes(route.name as string));
|
const isSocketRoute = computed(() => ['nyx', 'viewer'].includes(route.name as string));
|
||||||
|
|
||||||
const routerReady = ref(false);
|
const routerReady = ref(false);
|
||||||
router.isReady().then(() => { routerReady.value = true; });
|
router.isReady().then(() => { routerReady.value = true; });
|
||||||
@ -55,7 +56,6 @@ router.isReady().then(() => { routerReady.value = true; });
|
|||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
|
|
||||||
|
|
||||||
const { version } = useUI(ws.status);
|
|
||||||
const { isLoggedIn, doLogout } = auth;
|
const { isLoggedIn, doLogout } = auth;
|
||||||
const { currentUser, connected, status, sessionId, disconnect, onMessage: onWsMessage, replayBuffer } = ws;
|
const { currentUser, connected, status, sessionId, disconnect, onMessage: onWsMessage, replayBuffer } = ws;
|
||||||
const { selectedAgent, updateFromServer } = agents;
|
const { selectedAgent, updateFromServer } = agents;
|
||||||
@ -64,7 +64,7 @@ const { theme } = useTheme();
|
|||||||
|
|
||||||
function handleLogout() {
|
function handleLogout() {
|
||||||
doLogout(disconnect);
|
doLogout(disconnect);
|
||||||
visited.agents = visited.viewer = visited.dev = false;
|
visited.nyx = visited.viewer = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function maybeConnect() {
|
function maybeConnect() {
|
||||||
@ -119,29 +119,6 @@ watch(theme, (t) => {
|
|||||||
if (el) el.href = logo ?? '/favicon.ico';
|
if (el) el.href = logo ?? '/favicon.ico';
|
||||||
});
|
});
|
||||||
|
|
||||||
const beVersion = ref('');
|
|
||||||
const envLabel = import.meta.env.PROD ? 'prod' : 'dev';
|
|
||||||
const versionState = ref<'short' | 'full' | 'copied'>('short');
|
|
||||||
const fullVersionText = computed(() => `[${envLabel}] fe: ${version} | be: ${beVersion.value || '…'}`);
|
|
||||||
const versionDisplay = computed(() => {
|
|
||||||
if (versionState.value === 'short') return version.split('-')[0];
|
|
||||||
if (versionState.value === 'copied') return '✓ copied';
|
|
||||||
return fullVersionText.value;
|
|
||||||
});
|
|
||||||
|
|
||||||
function cycleVersion() {
|
|
||||||
if (versionState.value === 'short') {
|
|
||||||
versionState.value = 'full';
|
|
||||||
} else if (versionState.value === 'full') {
|
|
||||||
navigator.clipboard.writeText(fullVersionText.value).then(() => {
|
|
||||||
versionState.value = 'copied';
|
|
||||||
setTimeout(() => versionState.value = 'short', 2000);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
versionState.value = 'short';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
onWsMessage((data: any) => {
|
onWsMessage((data: any) => {
|
||||||
if (data.type === 'ready' || data.type === 'auth_ok') {
|
if (data.type === 'ready' || data.type === 'auth_ok') {
|
||||||
@ -149,9 +126,9 @@ onMounted(() => {
|
|||||||
currentUser.value = data.user;
|
currentUser.value = data.user;
|
||||||
sessionId.value = data.sessionId;
|
sessionId.value = data.sessionId;
|
||||||
status.value = 'Connected';
|
status.value = 'Connected';
|
||||||
if (data.version) { beVersion.value = data.version; chatStore.beVersion = data.version; }
|
if (data.version) { chatStore.beVersion = data.version; }
|
||||||
updateFromServer(data);
|
updateFromServer(data);
|
||||||
if (route.path === '/login') router.push('/agents');
|
if (route.path === '/login') router.push('/nyx');
|
||||||
} else if (data.type === 'cost_update') {
|
} else if (data.type === 'cost_update') {
|
||||||
chatStore.sessionUsage = data.usage;
|
chatStore.sessionUsage = data.usage;
|
||||||
chatStore.sessionCost = data.cost;
|
chatStore.sessionCost = data.cost;
|
||||||
@ -201,4 +178,5 @@ onMounted(() => {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.content-area { position: relative; }
|
.content-area { position: relative; }
|
||||||
|
.page-scroll { flex: 1; overflow-y: auto; min-height: 0; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -3,14 +3,12 @@
|
|||||||
class="app-sidebar"
|
class="app-sidebar"
|
||||||
:class="{ 'is-collapsed': !isOpen }"
|
:class="{ 'is-collapsed': !isOpen }"
|
||||||
>
|
>
|
||||||
<!-- Gradient shadow next to expanded sidebar -->
|
|
||||||
<div class="sidebar-shadow" />
|
|
||||||
<!-- Invisible full-screen click target to close -->
|
<!-- Invisible full-screen click target to close -->
|
||||||
<div class="sidebar-close-target" @click="collapse" />
|
<div class="sidebar-close-target" @click="collapse" />
|
||||||
|
|
||||||
<!-- Top: [chevron] [logo + brand] -->
|
<!-- Top: [chevron] [logo + brand] -->
|
||||||
<div class="sidebar-header">
|
<div class="sidebar-header">
|
||||||
<button class="sidebar-toggle-btn" @click="toggle" :title="isOpen ? 'Collapse' : 'Expand'">
|
<button class="sidebar-toggle-btn" @click="toggle">
|
||||||
<ChevronLeftIcon v-if="isOpen" class="sidebar-chevron-anim w-4 h-4" />
|
<ChevronLeftIcon v-if="isOpen" class="sidebar-chevron-anim w-4 h-4" />
|
||||||
<ChevronRightIcon v-else class="sidebar-chevron-anim w-4 h-4" />
|
<ChevronRightIcon v-else class="sidebar-chevron-anim w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
@ -21,232 +19,43 @@
|
|||||||
</RouterLink>
|
</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Top section: default agent + agents link + files (grows to fill space) -->
|
<!-- Nav -->
|
||||||
<div v-if="isLoggedIn && isOpen" class="sidebar-top-section" :class="{ 'has-tree': sharedOpen || workspaceOpen }">
|
<div class="sidebar-nav">
|
||||||
<!-- Default agent (placeholder keeps layout stable before WS config) -->
|
<RouterLink to="/" class="sidebar-link" :class="{ active: route.name === 'home' }" @click="collapse">
|
||||||
<div class="sidebar-home">
|
<HomeIcon class="w-4 h-4" />
|
||||||
<div
|
<span>Home</span>
|
||||||
v-if="homeAgent"
|
</RouterLink>
|
||||||
class="sidebar-room"
|
<button v-if="isLoggedIn" class="sidebar-link" :class="{ active: route.name === 'nyx' }" @click="goNyx">
|
||||||
:class="[`role-${homeAgent.role}`, { active: selectedAgent === homeAgent.id }]"
|
|
||||||
@click="handleModeClick(homeAgent.id, defaultMode(homeAgent))"
|
|
||||||
:title="'Chat with ' + homeAgent.name"
|
|
||||||
>
|
|
||||||
<span class="sidebar-room-dot" :class="`dot-${homeAgent.role}`"></span>
|
|
||||||
<span class="sidebar-room-name">{{ homeAgent.name }}</span>
|
|
||||||
</div>
|
|
||||||
<div v-else class="sidebar-room sidebar-room-placeholder">
|
|
||||||
<span class="sidebar-room-dot"></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Agents nav link -->
|
|
||||||
<button class="sidebar-link" :class="{ active: route.name === 'agents' && !route.query.agent }" @click="goAgentsOverview">
|
|
||||||
<ChatBubbleLeftRightIcon class="w-4 h-4" />
|
<ChatBubbleLeftRightIcon class="w-4 h-4" />
|
||||||
<span>Agents</span>
|
<span>nyx</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Shared files -->
|
|
||||||
<div v-if="viewerToken" class="sidebar-file-section" :class="{ 'is-open': sharedOpen }">
|
|
||||||
<button class="sidebar-link sidebar-file-toggle" :class="{ active: sharedOpen }" @click="toggleFileSection('shared')">
|
|
||||||
<FolderIcon class="w-4 h-4" />
|
|
||||||
<span>Shared</span>
|
|
||||||
</button>
|
|
||||||
<OverlayScrollbarsComponent v-if="sharedOpen" class="sidebar-file-scroll" :options="scrollbarOptions" element="div">
|
|
||||||
<FileTree
|
|
||||||
:token="viewerToken"
|
|
||||||
:active-path="lastViewerPath"
|
|
||||||
:expand-to="lastViewerPath"
|
|
||||||
:roots="['shared']"
|
|
||||||
:hide-root="true"
|
|
||||||
:folders-only="true"
|
|
||||||
@select="openInViewer"
|
|
||||||
/>
|
|
||||||
</OverlayScrollbarsComponent>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Workspace files -->
|
|
||||||
<div v-if="viewerToken" class="sidebar-file-section" :class="{ 'is-open': workspaceOpen }">
|
|
||||||
<button class="sidebar-link sidebar-file-toggle" :class="{ active: workspaceOpen }" @click="toggleFileSection('workspace')">
|
|
||||||
<FolderOpenIcon class="w-4 h-4" />
|
|
||||||
<span>Workspace</span>
|
|
||||||
</button>
|
|
||||||
<OverlayScrollbarsComponent v-if="workspaceOpen" class="sidebar-file-scroll" :options="scrollbarOptions" element="div">
|
|
||||||
<FileTree
|
|
||||||
:token="viewerToken"
|
|
||||||
:active-path="lastViewerPath"
|
|
||||||
:expand-to="lastViewerPath"
|
|
||||||
:roots="[viewerWorkspaceRoot]"
|
|
||||||
:hide-root="true"
|
|
||||||
:folders-only="true"
|
|
||||||
@select="openInViewer"
|
|
||||||
/>
|
|
||||||
</OverlayScrollbarsComponent>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<!-- Collapsed: top icons -->
|
|
||||||
<div v-if="isLoggedIn && !isOpen" class="sidebar-collapsed-top">
|
<!-- Spacer -->
|
||||||
<div
|
<div class="sidebar-flex-spacer" />
|
||||||
v-if="homeAgent"
|
|
||||||
class="sidebar-room"
|
<!-- Panel backdrop -->
|
||||||
:class="[`role-${homeAgent.role}`, { active: selectedAgent === homeAgent.id }]"
|
<div v-if="userMenuOpen" class="sidebar-panel-backdrop" @click="userMenuOpen = false" />
|
||||||
@click="handleModeClick(homeAgent.id, defaultMode(homeAgent))"
|
|
||||||
:title="homeAgent.name"
|
<!-- Footer: legal + user (always visible) -->
|
||||||
>
|
<div class="sidebar-footer">
|
||||||
<span class="sidebar-room-dot" :class="`dot-${homeAgent.role}`"></span>
|
<RouterLink to="/impressum" class="sidebar-link" :class="{ active: route.name === 'impressum' }" @click="collapse">
|
||||||
</div>
|
<DocumentTextIcon class="w-4 h-4" />
|
||||||
<button class="sidebar-link" :class="{ active: route.name === 'agents' && !route.query.agent }" title="Agents" @click="goAgentsOverview">
|
<span>Impressum</span>
|
||||||
<ChatBubbleLeftRightIcon class="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
<RouterLink :to="{ name: 'viewer', query: { path: 'shared' } }" class="sidebar-link" :class="{ active: route.name === 'viewer' && lastViewerPath.startsWith('shared') }" title="Shared files">
|
|
||||||
<FolderIcon class="w-4 h-4" />
|
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
<RouterLink :to="{ name: 'viewer', query: { path: viewerWorkspaceRoot } }" class="sidebar-link" :class="{ active: route.name === 'viewer' && lastViewerPath.startsWith('workspace') }" title="Workspace files">
|
<RouterLink to="/datenschutz" class="sidebar-link" :class="{ active: route.name === 'datenschutz' }" @click="collapse">
|
||||||
<FolderOpenIcon class="w-4 h-4" />
|
<ShieldCheckIcon class="w-4 h-4" />
|
||||||
|
<span>Datenschutz</span>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
<!-- Spacer: push bottom section down -->
|
|
||||||
<div v-if="isLoggedIn" class="sidebar-flex-spacer" />
|
|
||||||
|
|
||||||
<!-- Panel backdrop: click outside to close any open panel -->
|
|
||||||
<div v-if="anyPanelOpen" class="sidebar-panel-backdrop" @click="closeAllPanels" />
|
|
||||||
|
|
||||||
<!-- Expanded: collapsible System section (no OverlayScrollbars — panels must escape overflow) -->
|
|
||||||
<div v-if="isLoggedIn && isOpen" class="sidebar-system-section" :class="{ collapsed: !systemOpen }">
|
|
||||||
<button class="sidebar-link sidebar-system-toggle" @click="systemOpen = !systemOpen; sessionStorage.setItem('sidebar_panel_system', String(systemOpen))">
|
|
||||||
<ChevronDownIcon v-if="systemOpen" class="w-4 h-4" />
|
|
||||||
<ChevronRightIcon v-else class="w-4 h-4" />
|
|
||||||
<span>System</span>
|
|
||||||
</button>
|
|
||||||
<div v-if="systemOpen" class="sidebar-system-content">
|
|
||||||
<!-- Connection -->
|
|
||||||
<div class="sidebar-conn-wrap">
|
|
||||||
<button class="sidebar-link sidebar-conn-link" :class="{ active: connActive }" @click="toggleConnPanel">
|
|
||||||
<WifiIcon class="w-4 h-4" />
|
|
||||||
<span>{{ connLabel }}</span>
|
|
||||||
</button>
|
|
||||||
<div v-if="connPanelOpen" class="sidebar-panel">
|
|
||||||
<div class="sidebar-panel-header">Connection</div>
|
|
||||||
<div class="sidebar-panel-row"><span>WebSocket</span><span>{{ chatStore.connectionState }}</span></div>
|
|
||||||
<div class="sidebar-panel-row"><span>Channel</span><span>{{ chatStore.channelState }}</span></div>
|
|
||||||
<div class="sidebar-panel-row"><span>Agent</span><span>{{ selectedAgent || 'none' }}</span></div>
|
|
||||||
<div class="sidebar-panel-row"><span>Mode</span><span>{{ selectedMode }}</span></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Takeover -->
|
|
||||||
<div class="sidebar-takeover-wrap" :class="{ active: !!takeoverToken }">
|
|
||||||
<button class="sidebar-link" :class="{ active: !!takeoverToken }" @click="toggleTakeoverPanel">
|
|
||||||
<SignalIcon class="w-4 h-4" />
|
|
||||||
<span>Takeover</span>
|
|
||||||
</button>
|
|
||||||
<div v-if="takeoverPanelOpen && takeoverToken" class="sidebar-panel">
|
|
||||||
<div class="sidebar-panel-header">Takeover Token</div>
|
|
||||||
<div class="sidebar-panel-token" @click="copyToken" :title="tokenCopied ? 'Copied!' : 'Click to copy'">
|
|
||||||
<code>{{ takeoverToken }}</code>
|
|
||||||
<span v-if="tokenCopied" class="sidebar-panel-copied">Copied!</span>
|
|
||||||
</div>
|
|
||||||
<div class="sidebar-panel-row">
|
|
||||||
<span>Capture</span>
|
|
||||||
<span :style="{ color: captureActive ? 'var(--success, #22c55e)' : 'var(--text-dim)' }">{{ captureActive ? 'ON' : 'OFF' }}</span>
|
|
||||||
</div>
|
|
||||||
<button class="sidebar-panel-item" @click="toggleCapture">
|
|
||||||
{{ captureActive ? 'Disable Capture' : 'Enable Capture' }}
|
|
||||||
</button>
|
|
||||||
<button class="sidebar-panel-item" @click="revokeAndClose">Revoke</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Dev -->
|
|
||||||
<RouterLink to="/dev" class="sidebar-link" :class="{ active: route.name === 'dev' }" @click="collapse">
|
|
||||||
<CodeBracketIcon class="w-4 h-4" />
|
|
||||||
<span>dev</span>
|
|
||||||
</RouterLink>
|
|
||||||
|
|
||||||
<!-- Version -->
|
|
||||||
<div class="sidebar-version-wrap">
|
|
||||||
<button class="sidebar-link sidebar-version-link" @click="toggleVersionPanel">
|
|
||||||
<span class="sidebar-version-text">{{ versionShort }}</span>
|
|
||||||
</button>
|
|
||||||
<div v-if="versionPanelOpen" class="sidebar-panel sidebar-version-panel">
|
|
||||||
<div class="sidebar-panel-header">Version</div>
|
|
||||||
<div class="sidebar-panel-row"><span>Frontend</span><span>{{ version }}</span></div>
|
|
||||||
<div class="sidebar-panel-row"><span>Backend</span><span>{{ chatStore.beVersion || '...' }}</span></div>
|
|
||||||
<div class="sidebar-panel-row"><span>Env</span><span>{{ envLabel }}</span></div>
|
|
||||||
<button class="sidebar-panel-item" @click="copyVersionDetails">
|
|
||||||
{{ versionCopied ? '✓ Copied' : 'Copy details' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Collapsed: bottom icons -->
|
|
||||||
<div v-if="isLoggedIn && !isOpen" class="sidebar-bottom-section">
|
|
||||||
<div class="sidebar-conn-wrap">
|
|
||||||
<button class="sidebar-link sidebar-conn-link" :class="{ active: connActive }" @click="toggleConnPanel" :title="connLabel">
|
|
||||||
<WifiIcon class="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
<div v-if="connPanelOpen" class="sidebar-panel">
|
|
||||||
<div class="sidebar-panel-header">Connection</div>
|
|
||||||
<div class="sidebar-panel-row"><span>WebSocket</span><span>{{ chatStore.connectionState }}</span></div>
|
|
||||||
<div class="sidebar-panel-row"><span>Channel</span><span>{{ chatStore.channelState }}</span></div>
|
|
||||||
<div class="sidebar-panel-row"><span>Agent</span><span>{{ selectedAgent || 'none' }}</span></div>
|
|
||||||
<div class="sidebar-panel-row"><span>Mode</span><span>{{ selectedMode }}</span></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="sidebar-takeover-wrap" :class="{ active: !!takeoverToken }">
|
|
||||||
<button class="sidebar-link" :class="{ active: !!takeoverToken }" @click="toggleTakeoverPanel" title="Takeover">
|
|
||||||
<SignalIcon class="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
<div v-if="takeoverPanelOpen && takeoverToken" class="sidebar-panel">
|
|
||||||
<div class="sidebar-panel-header">Takeover Token</div>
|
|
||||||
<div class="sidebar-panel-token" @click="copyToken" :title="tokenCopied ? 'Copied!' : 'Click to copy'">
|
|
||||||
<code>{{ takeoverToken }}</code>
|
|
||||||
<span v-if="tokenCopied" class="sidebar-panel-copied">Copied!</span>
|
|
||||||
</div>
|
|
||||||
<div class="sidebar-panel-row">
|
|
||||||
<span>Capture</span>
|
|
||||||
<span :style="{ color: captureActive ? 'var(--success, #22c55e)' : 'var(--text-dim)' }">{{ captureActive ? 'ON' : 'OFF' }}</span>
|
|
||||||
</div>
|
|
||||||
<button class="sidebar-panel-item" @click="toggleCapture">
|
|
||||||
{{ captureActive ? 'Disable Capture' : 'Enable Capture' }}
|
|
||||||
</button>
|
|
||||||
<button class="sidebar-panel-item" @click="revokeAndClose">Revoke</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<RouterLink to="/dev" class="sidebar-link" :class="{ active: route.name === 'dev' }" title="Dev" @click="collapse">
|
|
||||||
<CodeBracketIcon class="w-4 h-4" />
|
|
||||||
</RouterLink>
|
|
||||||
<div class="sidebar-version-wrap">
|
|
||||||
<button class="sidebar-link sidebar-version-link" @click="toggleVersionPanel" :title="versionShort">
|
|
||||||
<span class="sidebar-version-text">{{ versionShort }}</span>
|
|
||||||
</button>
|
|
||||||
<div v-if="versionPanelOpen" class="sidebar-panel sidebar-version-panel">
|
|
||||||
<div class="sidebar-panel-header">Version</div>
|
|
||||||
<div class="sidebar-panel-row"><span>Frontend</span><span>{{ version }}</span></div>
|
|
||||||
<div class="sidebar-panel-row"><span>Backend</span><span>{{ chatStore.beVersion || '...' }}</span></div>
|
|
||||||
<div class="sidebar-panel-row"><span>Env</span><span>{{ envLabel }}</span></div>
|
|
||||||
<button class="sidebar-panel-item" @click="copyVersionDetails">
|
|
||||||
{{ versionCopied ? '✓ Copied' : 'Copy details' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- User (always unified) -->
|
|
||||||
<div class="sidebar-bottom">
|
|
||||||
<template v-if="isLoggedIn">
|
<template v-if="isLoggedIn">
|
||||||
<div class="sidebar-user-wrap" :class="{ open: userMenuOpen }">
|
<div class="sidebar-user-wrap" :class="{ open: userMenuOpen }">
|
||||||
<button
|
<button
|
||||||
class="sidebar-user-btn"
|
class="sidebar-link"
|
||||||
@click="toggleUserMenu"
|
@click="toggleUserMenu"
|
||||||
:title="isOpen ? '' : currentUser"
|
:title="isOpen ? '' : currentUser"
|
||||||
>
|
>
|
||||||
<UserCircleIcon class="w-4 h-4" />
|
<UserCircleIcon class="w-4 h-4" />
|
||||||
<span v-if="isOpen" class="sidebar-user-name">{{ currentUser }}</span>
|
<span>{{ currentUser || 'Nico' }}</span>
|
||||||
</button>
|
</button>
|
||||||
<div v-if="userMenuOpen" class="sidebar-user-menu">
|
<div v-if="userMenuOpen" class="sidebar-user-menu">
|
||||||
<div class="sidebar-user-menu-header">{{ currentUser }}</div>
|
<div class="sidebar-user-menu-header">{{ currentUser }}</div>
|
||||||
@ -256,7 +65,7 @@
|
|||||||
</template>
|
</template>
|
||||||
<RouterLink v-else to="/login" class="sidebar-link" :title="isOpen ? '' : 'Sign in'">
|
<RouterLink v-else to="/login" class="sidebar-link" :title="isOpen ? '' : 'Sign in'">
|
||||||
<ArrowRightEndOnRectangleIcon class="w-4 h-4" />
|
<ArrowRightEndOnRectangleIcon class="w-4 h-4" />
|
||||||
<span v-if="isOpen">Sign in</span>
|
<span>Sign in</span>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
@ -267,178 +76,17 @@ defineOptions({ name: 'AppSidebar' });
|
|||||||
import { ref, computed } from 'vue';
|
import { ref, computed } from 'vue';
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
import {
|
import {
|
||||||
ChevronDownIcon,
|
|
||||||
ChevronLeftIcon,
|
ChevronLeftIcon,
|
||||||
ChevronRightIcon,
|
ChevronRightIcon,
|
||||||
FolderIcon,
|
|
||||||
FolderOpenIcon,
|
|
||||||
CodeBracketIcon,
|
|
||||||
|
|
||||||
UserCircleIcon,
|
UserCircleIcon,
|
||||||
ArrowRightEndOnRectangleIcon,
|
ArrowRightEndOnRectangleIcon,
|
||||||
LockClosedIcon,
|
|
||||||
UserGroupIcon,
|
|
||||||
SignalIcon,
|
|
||||||
WifiIcon,
|
|
||||||
ChatBubbleLeftRightIcon,
|
ChatBubbleLeftRightIcon,
|
||||||
|
DocumentTextIcon,
|
||||||
|
ShieldCheckIcon,
|
||||||
|
HomeIcon,
|
||||||
} from '@heroicons/vue/20/solid';
|
} from '@heroicons/vue/20/solid';
|
||||||
import { OverlayScrollbarsComponent } from 'overlayscrollbars-vue';
|
|
||||||
import { scrollbarOptions } from '../composables/useScrollbar';
|
|
||||||
import { THEME_ICONS, THEME_LOGOS, THEME_NAMES, useTheme } from '../composables/useTheme';
|
import { THEME_ICONS, THEME_LOGOS, THEME_NAMES, useTheme } from '../composables/useTheme';
|
||||||
import { ws, auth, agents, takeover, lastViewerPath, useViewerStore } from '../store';
|
import { auth } from '../store';
|
||||||
import { useChatStore } from '../store/chat';
|
|
||||||
import FileTree from './FileTree.vue';
|
|
||||||
|
|
||||||
const takeoverToken = takeover.token;
|
|
||||||
const captureActive = takeover.capture.isActive;
|
|
||||||
const takeoverPanelOpen = ref(false);
|
|
||||||
const tokenCopied = ref(false);
|
|
||||||
|
|
||||||
async function toggleCapture() {
|
|
||||||
if (captureActive.value) {
|
|
||||||
takeover.capture.disable();
|
|
||||||
} else {
|
|
||||||
await takeover.capture.enable();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function copyToken() {
|
|
||||||
if (!takeoverToken.value) return;
|
|
||||||
navigator.clipboard.writeText(takeoverToken.value);
|
|
||||||
tokenCopied.value = true;
|
|
||||||
setTimeout(() => { tokenCopied.value = false; }, 1500);
|
|
||||||
}
|
|
||||||
|
|
||||||
const systemOpen = ref(sessionStorage.getItem('sidebar_panel_system') === 'true');
|
|
||||||
const connPanelOpen = ref(false);
|
|
||||||
const anyPanelOpen = computed(() => takeoverPanelOpen.value || userMenuOpen.value || versionPanelOpen.value || connPanelOpen.value);
|
|
||||||
|
|
||||||
function closeAllPanels() {
|
|
||||||
takeoverPanelOpen.value = false;
|
|
||||||
userMenuOpen.value = false;
|
|
||||||
versionPanelOpen.value = false;
|
|
||||||
connPanelOpen.value = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleConnPanel() {
|
|
||||||
const opening = !connPanelOpen.value;
|
|
||||||
closeAllPanels();
|
|
||||||
if (opening) connPanelOpen.value = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleTakeoverPanel() {
|
|
||||||
if (!takeoverToken.value) { router.push('/dev'); return; }
|
|
||||||
const opening = !takeoverPanelOpen.value;
|
|
||||||
closeAllPanels();
|
|
||||||
if (opening) takeoverPanelOpen.value = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleUserMenu() {
|
|
||||||
const opening = !userMenuOpen.value;
|
|
||||||
closeAllPanels();
|
|
||||||
if (opening) userMenuOpen.value = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function revokeAndClose() {
|
|
||||||
takeover.revoke();
|
|
||||||
closeAllPanels();
|
|
||||||
}
|
|
||||||
const homeAgent = computed(() => allAgents.value.find(a => a.id === defaultAgent.value));
|
|
||||||
const allFilteredSorted = computed(() =>
|
|
||||||
filteredAgents.value
|
|
||||||
.filter(a => a.id !== defaultAgent.value)
|
|
||||||
.sort((a, b) => a.name.localeCompare(b.name))
|
|
||||||
);
|
|
||||||
|
|
||||||
const chatStore = useChatStore();
|
|
||||||
|
|
||||||
// Viewer file tree in sidebar
|
|
||||||
const viewerStore = useViewerStore();
|
|
||||||
const viewerToken = computed(() => viewerStore.fstoken);
|
|
||||||
const viewerRoots = computed(() => viewerStore.roots);
|
|
||||||
|
|
||||||
const sharedOpen = ref(lastViewerPath.value.startsWith('shared'));
|
|
||||||
const workspaceOpen = ref(lastViewerPath.value.startsWith('workspace'));
|
|
||||||
|
|
||||||
function toggleFileSection(section: 'shared' | 'workspace') {
|
|
||||||
const root = section === 'shared' ? 'shared' : viewerWorkspaceRoot.value;
|
|
||||||
if (section === 'shared') {
|
|
||||||
sharedOpen.value = !sharedOpen.value;
|
|
||||||
if (sharedOpen.value) { workspaceOpen.value = false; openInViewer(root); }
|
|
||||||
} else {
|
|
||||||
workspaceOpen.value = !workspaceOpen.value;
|
|
||||||
if (workspaceOpen.value) { sharedOpen.value = false; openInViewer(root); }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const viewerWorkspaceRoot = computed(() => {
|
|
||||||
const ws = viewerRoots.value.find(r => r.startsWith('workspace'));
|
|
||||||
return ws || 'workspace-titan';
|
|
||||||
});
|
|
||||||
|
|
||||||
function openInViewer(path: string) {
|
|
||||||
lastViewerPath.value = path;
|
|
||||||
localStorage.setItem('viewer_last_path', path);
|
|
||||||
router.push({ name: 'viewer', query: { path } });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Version display
|
|
||||||
import { useUI } from '../composables/ui';
|
|
||||||
const { version } = useUI(ws.status);
|
|
||||||
const versionShort = version.split('-')[0];
|
|
||||||
const envLabel = import.meta.env.PROD ? 'prod' : 'dev';
|
|
||||||
const versionPanelOpen = ref(false);
|
|
||||||
const versionCopied = ref(false);
|
|
||||||
|
|
||||||
function toggleVersionPanel() {
|
|
||||||
const opening = !versionPanelOpen.value;
|
|
||||||
closeAllPanels();
|
|
||||||
if (opening) versionPanelOpen.value = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function copyVersionDetails() {
|
|
||||||
const details = `env: ${envLabel}\nfe: ${version}\nbe: ${chatStore.beVersion || 'unknown'}\nua: ${navigator.userAgent}`;
|
|
||||||
navigator.clipboard.writeText(details);
|
|
||||||
versionCopied.value = true;
|
|
||||||
setTimeout(() => { versionCopied.value = false; }, 2000);
|
|
||||||
}
|
|
||||||
|
|
||||||
function stateToIndicator(state: string | null) {
|
|
||||||
switch (state) {
|
|
||||||
case 'AGENT_RUNNING': return { icon: '⚙️', cls: 'ch-running' };
|
|
||||||
case 'FRESH': return { icon: '✨', cls: 'ch-fresh' };
|
|
||||||
case 'READY': return { icon: '●', cls: 'ch-ready' };
|
|
||||||
case 'HANDOVER_PENDING': return { icon: '📝', cls: 'ch-handover' };
|
|
||||||
case 'HANDOVER_DONE': return { icon: '✅', cls: 'ch-handover' };
|
|
||||||
case 'RESETTING': return { icon: '🔄', cls: 'ch-resetting' };
|
|
||||||
case 'NO_SESSION': return { icon: '○', cls: 'ch-nosession' };
|
|
||||||
default: return { icon: '·', cls: 'ch-none' };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// For selected agent: use live WS state from chat store
|
|
||||||
const selectedIndicator = computed(() => stateToIndicator(chatStore.channelState));
|
|
||||||
|
|
||||||
// For any agent: get from HTTP-polled channel states
|
|
||||||
function agentIndicator(agentId: string, mode: 'private' | 'public' = 'private') {
|
|
||||||
// For selected agent + mode: use live WS state only when SYNCED
|
|
||||||
if (agentId === selectedAgent.value && mode === selectedMode.value && chatStore.connectionState === 'SYNCED') {
|
|
||||||
return selectedIndicator.value;
|
|
||||||
}
|
|
||||||
const info = agents.getChannelState(agentId, mode);
|
|
||||||
return info ? stateToIndicator(info.state) : stateToIndicator(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
const connLabel = computed(() => {
|
|
||||||
switch (chatStore.connectionState) {
|
|
||||||
case 'CONNECTING': return 'Connecting...';
|
|
||||||
case 'LOADING_HISTORY': return 'Loading...';
|
|
||||||
case 'SWITCHING': return 'Switching...';
|
|
||||||
case 'SYNCED': return 'Connected';
|
|
||||||
default: return '';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
const connActive = computed(() => chatStore.connectionState === 'SYNCED');
|
|
||||||
|
|
||||||
const emit = defineEmits<{ logout: [] }>();
|
const emit = defineEmits<{ logout: [] }>();
|
||||||
|
|
||||||
@ -447,39 +95,7 @@ const router = useRouter();
|
|||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const navLogo = computed(() => THEME_LOGOS[theme.value]);
|
const navLogo = computed(() => THEME_LOGOS[theme.value]);
|
||||||
|
|
||||||
const { isLoggedIn } = auth;
|
const { isLoggedIn, currentUser } = auth;
|
||||||
const { currentUser, send } = ws;
|
|
||||||
const { filteredAgents, selectedAgent, selectedMode, defaultAgent, allAgents } = agents;
|
|
||||||
|
|
||||||
function defaultMode(agent: any): string {
|
|
||||||
if (agent.role === 'owner') return 'private';
|
|
||||||
if (agent.modes?.includes('public')) return 'public';
|
|
||||||
return 'private';
|
|
||||||
}
|
|
||||||
|
|
||||||
function extraMode(agent: any): string | null {
|
|
||||||
const def = defaultMode(agent);
|
|
||||||
const other = def === 'private' ? 'public' : 'private';
|
|
||||||
return agent.modes?.includes(other) ? other : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function activeMode(agent: any): string | null {
|
|
||||||
if (selectedAgent.value !== agent.id) return null;
|
|
||||||
return selectedMode.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
const SEGMENTS = ['personal', 'common', 'private', 'public'] as const;
|
|
||||||
|
|
||||||
const visibleSegments = computed(() =>
|
|
||||||
SEGMENTS
|
|
||||||
.map(key => ({
|
|
||||||
key,
|
|
||||||
agents: filteredAgents.value
|
|
||||||
.filter(a => (a.segment ?? 'utility') === key && a.id !== defaultAgent.value)
|
|
||||||
.sort((a, b) => a.name.localeCompare(b.name)),
|
|
||||||
}))
|
|
||||||
.filter(s => s.agents.length > 0)
|
|
||||||
);
|
|
||||||
|
|
||||||
const isMobile = window.innerWidth <= 480;
|
const isMobile = window.innerWidth <= 480;
|
||||||
const isLarge = window.innerWidth >= 1024;
|
const isLarge = window.innerWidth >= 1024;
|
||||||
@ -504,39 +120,18 @@ function toggle() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function collapse() {
|
function collapse() {
|
||||||
if (window.innerWidth >= 1024) return; // large screens: stay open
|
if (window.innerWidth >= 1024) return;
|
||||||
isOpen.value = false;
|
isOpen.value = false;
|
||||||
localStorage.setItem('sidebar_open', 'false');
|
localStorage.setItem('sidebar_open', 'false');
|
||||||
}
|
}
|
||||||
|
|
||||||
function goAgentsOverview() {
|
function goNyx() {
|
||||||
collapse();
|
collapse();
|
||||||
router.push({ name: 'agents', query: {} });
|
router.push({ name: 'nyx' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleUserMenu() {
|
||||||
|
userMenuOpen.value = !userMenuOpen.value;
|
||||||
function handleModeClick(agentId: string, mode: string) {
|
|
||||||
collapse();
|
|
||||||
const sameAgent = selectedAgent.value === agentId && selectedMode.value === mode;
|
|
||||||
// Already on this agent+mode on agents view — nothing to do
|
|
||||||
if (sameAgent && route.name === 'agents') return;
|
|
||||||
// Same agent but on a different view — just navigate back, don't re-switch
|
|
||||||
if (sameAgent) {
|
|
||||||
router.push({ name: 'agents', query: { agent: agentId, mode } });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Different agent — full switch
|
|
||||||
selectedAgent.value = agentId;
|
|
||||||
selectedMode.value = mode as 'private' | 'public';
|
|
||||||
sessionStorage.setItem('agent', agentId);
|
|
||||||
sessionStorage.setItem('agent_mode', mode);
|
|
||||||
if (ws.connected.value) {
|
|
||||||
ws.switchAgent(agentId, mode);
|
|
||||||
} else {
|
|
||||||
ws.connect(selectedAgent, auth.isLoggedIn, auth.loginError, selectedMode);
|
|
||||||
}
|
|
||||||
router.push({ name: 'agents', query: { agent: agentId, mode } });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleLogout() {
|
function handleLogout() {
|
||||||
|
|||||||
271
src/components/AppToolbar.vue
Normal file
271
src/components/AppToolbar.vue
Normal file
@ -0,0 +1,271 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="isLoggedIn" class="app-toolbar">
|
||||||
|
<!-- Connection pill -->
|
||||||
|
<button class="toolbar-pill" :class="{ active: connActive }" @click="togglePanel('conn')" :title="connLabel || 'Connection'">
|
||||||
|
<WifiIcon class="w-4 h-4" />
|
||||||
|
<span v-if="connLabel" class="toolbar-pill-label">{{ connLabel }}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="toolbar-spacer" />
|
||||||
|
|
||||||
|
<!-- Theme pills (icon only) -->
|
||||||
|
<button
|
||||||
|
v-for="t in THEMES"
|
||||||
|
:key="t"
|
||||||
|
class="toolbar-pill toolbar-pill-icon"
|
||||||
|
:class="{ active: theme === t }"
|
||||||
|
@click="setTheme(t)"
|
||||||
|
:title="THEME_NAMES[t]"
|
||||||
|
>
|
||||||
|
<component :is="THEME_ICONS[t]" class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Takeover / Capture -->
|
||||||
|
<button
|
||||||
|
v-if="takeoverToken"
|
||||||
|
class="toolbar-pill"
|
||||||
|
:class="{ active: captureActive }"
|
||||||
|
@click="togglePanel('takeover')"
|
||||||
|
title="Takeover"
|
||||||
|
>
|
||||||
|
<SignalIcon class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Version pill -->
|
||||||
|
<button class="toolbar-pill toolbar-pill-dim" @click="copyVersionDetails" :title="versionFull">
|
||||||
|
<span class="toolbar-version-text">{{ versionShort }}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Panels (dropdown from toolbar) -->
|
||||||
|
<div v-if="openPanel" class="toolbar-panel-backdrop" @click="openPanel = null" />
|
||||||
|
|
||||||
|
<!-- Connection panel -->
|
||||||
|
<div v-if="openPanel === 'conn'" class="toolbar-panel" style="right: auto; left: 0;">
|
||||||
|
<div class="toolbar-panel-header">Connection</div>
|
||||||
|
<div class="toolbar-panel-row"><span>HTTP</span><span>{{ chatStore.connectionState }}</span></div>
|
||||||
|
<div class="toolbar-panel-row"><span>Channel</span><span>{{ chatStore.channelState }}</span></div>
|
||||||
|
<div class="toolbar-panel-row"><span>Agent</span><span>{{ selectedAgent || 'none' }}</span></div>
|
||||||
|
<div class="toolbar-panel-row"><span>Mode</span><span>{{ selectedMode }}</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Takeover panel -->
|
||||||
|
<div v-if="openPanel === 'takeover'" class="toolbar-panel" style="right: 0;">
|
||||||
|
<div class="toolbar-panel-header">Takeover</div>
|
||||||
|
<div class="toolbar-panel-token" @click="copyToken" :title="tokenCopied ? 'Copied!' : 'Click to copy'">
|
||||||
|
<code>{{ takeoverToken }}</code>
|
||||||
|
<span v-if="tokenCopied" class="toolbar-panel-copied">Copied!</span>
|
||||||
|
</div>
|
||||||
|
<div class="toolbar-panel-row">
|
||||||
|
<span>Capture</span>
|
||||||
|
<span :style="{ color: captureActive ? 'var(--success, #22c55e)' : 'var(--text-dim)' }">{{ captureActive ? 'ON' : 'OFF' }}</span>
|
||||||
|
</div>
|
||||||
|
<button class="toolbar-panel-action" @click="toggleCapture">
|
||||||
|
{{ captureActive ? 'Disable Capture' : 'Enable Capture' }}
|
||||||
|
</button>
|
||||||
|
<button class="toolbar-panel-action" @click="revokeAndClose">Revoke</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
defineOptions({ name: 'AppToolbar' });
|
||||||
|
import { ref, computed } from 'vue';
|
||||||
|
import { WifiIcon, SignalIcon } from '@heroicons/vue/20/solid';
|
||||||
|
import { THEME_ICONS, THEME_NAMES, useTheme, type Theme } from '../composables/useTheme';
|
||||||
|
import { ws, auth, agents, takeover } from '../store';
|
||||||
|
import { useChatStore } from '../store/chat';
|
||||||
|
import { useUI } from '../composables/ui';
|
||||||
|
|
||||||
|
const THEMES: Theme[] = ['loop42', 'titan', 'eras'];
|
||||||
|
|
||||||
|
const { isLoggedIn } = auth;
|
||||||
|
const { selectedAgent, selectedMode } = agents;
|
||||||
|
const { theme, setTheme } = useTheme();
|
||||||
|
const chatStore = useChatStore();
|
||||||
|
|
||||||
|
// Connection
|
||||||
|
const connLabel = computed(() => {
|
||||||
|
switch (chatStore.connectionState) {
|
||||||
|
case 'CONNECTING': return 'Connecting...';
|
||||||
|
case 'LOADING_HISTORY': return 'Loading...';
|
||||||
|
case 'SWITCHING': return 'Switching...';
|
||||||
|
case 'SYNCED': return 'Connected';
|
||||||
|
default: return '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const connActive = computed(() => chatStore.connectionState === 'SYNCED');
|
||||||
|
|
||||||
|
// Takeover
|
||||||
|
const takeoverToken = takeover.token;
|
||||||
|
const captureActive = takeover.capture.isActive;
|
||||||
|
const tokenCopied = ref(false);
|
||||||
|
|
||||||
|
async function toggleCapture() {
|
||||||
|
if (captureActive.value) {
|
||||||
|
takeover.capture.disable();
|
||||||
|
} else {
|
||||||
|
await takeover.capture.enable();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyToken() {
|
||||||
|
if (!takeoverToken.value) return;
|
||||||
|
navigator.clipboard.writeText(takeoverToken.value);
|
||||||
|
tokenCopied.value = true;
|
||||||
|
setTimeout(() => { tokenCopied.value = false; }, 1500);
|
||||||
|
}
|
||||||
|
|
||||||
|
function revokeAndClose() {
|
||||||
|
takeover.revoke();
|
||||||
|
openPanel.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Version
|
||||||
|
const { version } = useUI(ws.status);
|
||||||
|
const versionShort = version.split('-')[0];
|
||||||
|
const envLabel = import.meta.env.PROD ? 'prod' : 'dev';
|
||||||
|
const versionFull = computed(() => `${envLabel} | fe: ${version} | be: ${chatStore.beVersion || '...'}`);
|
||||||
|
const versionCopied = ref(false);
|
||||||
|
|
||||||
|
function copyVersionDetails() {
|
||||||
|
const details = `env: ${envLabel}\nfe: ${version}\nbe: ${chatStore.beVersion || 'unknown'}\nua: ${navigator.userAgent}`;
|
||||||
|
navigator.clipboard.writeText(details);
|
||||||
|
versionCopied.value = true;
|
||||||
|
setTimeout(() => { versionCopied.value = false; }, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Panel toggle
|
||||||
|
const openPanel = ref<'conn' | 'takeover' | null>(null);
|
||||||
|
|
||||||
|
function togglePanel(panel: 'conn' | 'takeover') {
|
||||||
|
openPanel.value = openPanel.value === panel ? null : panel;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.app-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--panel-gap, 6px);
|
||||||
|
padding: var(--panel-gap, 6px);
|
||||||
|
padding-bottom: 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-spacer { flex: 1; }
|
||||||
|
|
||||||
|
.toolbar-pill {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
background: var(--panel-bg);
|
||||||
|
border-radius: var(--radius-panel, 12px);
|
||||||
|
box-shadow: var(--panel-shadow);
|
||||||
|
padding: 0 12px;
|
||||||
|
height: 34px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-dim);
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.12s, background 0.12s;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.toolbar-pill:hover { color: var(--text); }
|
||||||
|
.toolbar-pill.active { color: var(--accent); }
|
||||||
|
|
||||||
|
.toolbar-pill-icon {
|
||||||
|
width: 34px;
|
||||||
|
padding: 0;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-pill-dim {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
.toolbar-pill-dim:hover { opacity: 0.8; }
|
||||||
|
|
||||||
|
.toolbar-pill-label {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-version-text {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Panel dropdown */
|
||||||
|
.toolbar-panel-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 150;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-panel {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
margin-top: 4px;
|
||||||
|
width: 220px;
|
||||||
|
background: var(--panel-bg);
|
||||||
|
border-radius: var(--radius-panel, 12px);
|
||||||
|
box-shadow: var(--panel-shadow);
|
||||||
|
z-index: 200;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-panel-header {
|
||||||
|
padding: 8px 12px 4px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-panel-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-panel-token {
|
||||||
|
padding: 8px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.12s;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.toolbar-panel-token:hover { background: color-mix(in srgb, var(--accent) 8%, transparent); }
|
||||||
|
.toolbar-panel-token code {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--accent);
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
.toolbar-panel-copied {
|
||||||
|
position: absolute;
|
||||||
|
right: 12px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--success, #22c55e);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-panel-action {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: 7px 12px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
text-align: left;
|
||||||
|
transition: background 0.12s, color 0.12s;
|
||||||
|
}
|
||||||
|
.toolbar-panel-action:hover { background: color-mix(in srgb, var(--accent) 8%, transparent); color: var(--accent); }
|
||||||
|
|
||||||
|
/* Mobile: hide labels, shrink pills */
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.toolbar-pill-label { display: none; }
|
||||||
|
.toolbar-panel { width: min(220px, calc(100vw - 16px)); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -1,80 +1,222 @@
|
|||||||
import { ref, type Ref } from 'vue';
|
/**
|
||||||
|
* auth.ts — Zitadel OIDC PKCE authentication
|
||||||
|
*
|
||||||
|
* Flow:
|
||||||
|
* 1. User clicks "Sign in" → redirect to Zitadel authorize endpoint
|
||||||
|
* 2. Zitadel authenticates → redirects back with ?code=...
|
||||||
|
* 3. We exchange code for tokens (access_token + id_token)
|
||||||
|
* 4. access_token stored in localStorage, used for all /api/* requests
|
||||||
|
* 5. On load: check if token exists and is not expired
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ref, computed, type Ref } from 'vue';
|
||||||
import router from '../router';
|
import router from '../router';
|
||||||
|
|
||||||
const SESSION_TOKEN_KEY = 'nyx_session';
|
|
||||||
|
|
||||||
import { getApiBase } from '../utils/apiBase';
|
import { getApiBase } from '../utils/apiBase';
|
||||||
|
|
||||||
// Dev service token — auto-login for development
|
const TOKEN_KEY = 'nyx_session';
|
||||||
const DEV_TOKEN = '7Oorb9S3OpwFyWgm4zi_Tq7GeamefbjjTgooPVPWAwPDOf6B4TvgvQlLbhmT4DjsqBS_D1g';
|
const VERIFIER_KEY = 'pkce_verifier';
|
||||||
|
const RETURN_KEY = 'auth_return_path';
|
||||||
|
|
||||||
|
// Zitadel config — fetched from backend or hardcoded fallback
|
||||||
|
let _issuer = 'https://auth.loop42.de';
|
||||||
|
let _clientId = '365996029172056091';
|
||||||
|
const _redirectUri = `${window.location.origin}/login`;
|
||||||
|
const _scopes = 'openid profile email';
|
||||||
|
|
||||||
|
async function _fetchAuthConfig(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const base = getApiBase();
|
||||||
|
const res = await fetch(`${base}/auth/config`);
|
||||||
|
if (res.ok) {
|
||||||
|
const cfg = await res.json();
|
||||||
|
if (cfg.issuer) _issuer = cfg.issuer;
|
||||||
|
if (cfg.clientId) _clientId = cfg.clientId;
|
||||||
|
}
|
||||||
|
} catch { /* use defaults */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// PKCE helpers
|
||||||
|
function _generateVerifier(): string {
|
||||||
|
const arr = new Uint8Array(32);
|
||||||
|
crypto.getRandomValues(arr);
|
||||||
|
return btoa(String.fromCharCode(...arr))
|
||||||
|
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _generateChallenge(verifier: string): Promise<string> {
|
||||||
|
const data = new TextEncoder().encode(verifier);
|
||||||
|
const hash = await crypto.subtle.digest('SHA-256', data);
|
||||||
|
return btoa(String.fromCharCode(...new Uint8Array(hash)))
|
||||||
|
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function _isTokenExpired(token: string): boolean {
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(atob(token.split('.')[1]));
|
||||||
|
return payload.exp * 1000 < Date.now();
|
||||||
|
} catch {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _getStoredToken(): string | null {
|
||||||
|
const token = localStorage.getItem(TOKEN_KEY);
|
||||||
|
if (!token) return null;
|
||||||
|
if (_isTokenExpired(token)) {
|
||||||
|
localStorage.removeItem(TOKEN_KEY);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _getUserNameFromToken(): string {
|
||||||
|
// Prefer cached name from id_token (access_token may lack profile claims)
|
||||||
|
const cached = localStorage.getItem('nyx_user');
|
||||||
|
if (cached) return cached;
|
||||||
|
const token = localStorage.getItem(TOKEN_KEY);
|
||||||
|
if (!token) return '';
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(atob(token.split('.')[1]));
|
||||||
|
return payload.name || payload.preferred_username || payload.email || '';
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function useAuth(connectFn: () => void) {
|
export function useAuth(connectFn: () => void) {
|
||||||
// Auto-set dev token if no token exists
|
const isLoggedIn: Ref<boolean> = ref(!!_getStoredToken());
|
||||||
if (!localStorage.getItem(SESSION_TOKEN_KEY) && !localStorage.getItem('titan_token')) {
|
|
||||||
localStorage.setItem(SESSION_TOKEN_KEY, DEV_TOKEN);
|
|
||||||
}
|
|
||||||
const isLoggedIn: Ref<boolean> = ref(!!localStorage.getItem(SESSION_TOKEN_KEY));
|
|
||||||
const loginToken: Ref<string> = ref('');
|
|
||||||
const loginError: Ref<string> = ref('');
|
const loginError: Ref<string> = ref('');
|
||||||
const loggingIn: Ref<boolean> = ref(false);
|
const loggingIn: Ref<boolean> = ref(false);
|
||||||
|
// Legacy compat — not used in PKCE flow but some UI may reference it
|
||||||
|
const loginToken: Ref<string> = ref('');
|
||||||
|
const currentUser = computed(() => isLoggedIn.value ? _getUserNameFromToken() : '');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start PKCE login — redirect to Zitadel.
|
||||||
|
* Stores current path so we can return after login.
|
||||||
|
*/
|
||||||
async function doLogin(): Promise<void> {
|
async function doLogin(): Promise<void> {
|
||||||
const token = loginToken.value.trim();
|
|
||||||
if (!token) return;
|
|
||||||
loggingIn.value = true;
|
loggingIn.value = true;
|
||||||
loginError.value = '';
|
loginError.value = '';
|
||||||
|
|
||||||
|
await _fetchAuthConfig();
|
||||||
|
|
||||||
|
const verifier = _generateVerifier();
|
||||||
|
const challenge = await _generateChallenge(verifier);
|
||||||
|
sessionStorage.setItem(VERIFIER_KEY, verifier);
|
||||||
|
|
||||||
|
// Remember where to go after login
|
||||||
|
const returnPath = window.location.pathname;
|
||||||
|
if (returnPath !== '/login') {
|
||||||
|
sessionStorage.setItem(RETURN_KEY, returnPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
response_type: 'code',
|
||||||
|
client_id: _clientId,
|
||||||
|
redirect_uri: _redirectUri,
|
||||||
|
scope: _scopes,
|
||||||
|
code_challenge: challenge,
|
||||||
|
code_challenge_method: 'S256',
|
||||||
|
});
|
||||||
|
|
||||||
|
window.location.href = `${_issuer}/oauth/v2/authorize?${params}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle callback from Zitadel — exchange code for token.
|
||||||
|
* Called from LoginView when ?code= is in the URL.
|
||||||
|
*/
|
||||||
|
async function handleCallback(code: string): Promise<boolean> {
|
||||||
|
loggingIn.value = true;
|
||||||
|
loginError.value = '';
|
||||||
|
|
||||||
|
const verifier = sessionStorage.getItem(VERIFIER_KEY);
|
||||||
|
if (!verifier) {
|
||||||
|
loginError.value = 'Missing PKCE verifier — please try again.';
|
||||||
|
loggingIn.value = false;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
sessionStorage.removeItem(VERIFIER_KEY);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const nonceRes = await fetch(`${getApiBase()}/api/auth/nonce`);
|
const res = await fetch(`${_issuer}/oauth/v2/token`, {
|
||||||
if (!nonceRes.ok) { loginError.value = 'Auth unavailable'; loggingIn.value = false; return; }
|
|
||||||
const { nonce } = await nonceRes.json();
|
|
||||||
const res = await fetch(`${getApiBase()}/api/auth`, {
|
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
body: JSON.stringify({ token, nonce }),
|
body: new URLSearchParams({
|
||||||
|
grant_type: 'authorization_code',
|
||||||
|
client_id: _clientId,
|
||||||
|
code,
|
||||||
|
redirect_uri: _redirectUri,
|
||||||
|
code_verifier: verifier,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const err = await res.json().catch(() => ({ error: 'Login failed' }));
|
const err = await res.json().catch(() => ({}));
|
||||||
loginError.value = err.error || 'Invalid token';
|
loginError.value = err.error_description || err.error || 'Token exchange failed';
|
||||||
loggingIn.value = false;
|
loggingIn.value = false;
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
const { sessionToken } = await res.json();
|
|
||||||
localStorage.removeItem('titan_token');
|
const data = await res.json();
|
||||||
localStorage.removeItem('nyx_token');
|
const token = data.access_token;
|
||||||
localStorage.setItem(SESSION_TOKEN_KEY, sessionToken);
|
if (!token) {
|
||||||
sessionStorage.removeItem('agent');
|
loginError.value = 'No access token received';
|
||||||
|
loggingIn.value = false;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract user name from id_token (access_token may not have profile claims)
|
||||||
|
if (data.id_token) {
|
||||||
|
try {
|
||||||
|
const idPayload = JSON.parse(atob(data.id_token.split('.')[1]));
|
||||||
|
const name = idPayload.name || idPayload.preferred_username || idPayload.email || '';
|
||||||
|
if (name) localStorage.setItem('nyx_user', name);
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store token and mark logged in
|
||||||
|
localStorage.setItem(TOKEN_KEY, token);
|
||||||
|
isLoggedIn.value = true;
|
||||||
|
|
||||||
|
// Connect transport
|
||||||
|
connectFn();
|
||||||
|
|
||||||
|
// Navigate to return path or /nyx
|
||||||
|
const returnPath = sessionStorage.getItem(RETURN_KEY) || '/nyx';
|
||||||
|
sessionStorage.removeItem(RETURN_KEY);
|
||||||
|
router.push(returnPath);
|
||||||
|
|
||||||
|
loggingIn.value = false;
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
loginError.value = 'Network error during token exchange';
|
||||||
|
loggingIn.value = false;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-connect if we have a valid token on page load.
|
||||||
|
*/
|
||||||
|
function tryAutoConnect(): void {
|
||||||
|
if (_getStoredToken()) {
|
||||||
isLoggedIn.value = true;
|
isLoggedIn.value = true;
|
||||||
connectFn();
|
connectFn();
|
||||||
router.push('/chat');
|
|
||||||
setTimeout(() => { loggingIn.value = false; }, 500);
|
|
||||||
} catch {
|
|
||||||
loginError.value = 'Network error';
|
|
||||||
loggingIn.value = false;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function doLogout(disconnectFn?: () => void): Promise<void> {
|
async function doLogout(disconnectFn?: () => void): Promise<void> {
|
||||||
const sessionToken = localStorage.getItem(SESSION_TOKEN_KEY);
|
|
||||||
if (sessionToken) {
|
|
||||||
// Fire-and-forget revoke
|
|
||||||
fetch(`${getApiBase()}/api/auth/logout`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ sessionToken }),
|
|
||||||
}).catch(() => {});
|
|
||||||
}
|
|
||||||
if (disconnectFn) disconnectFn();
|
if (disconnectFn) disconnectFn();
|
||||||
localStorage.removeItem(SESSION_TOKEN_KEY);
|
localStorage.removeItem(TOKEN_KEY);
|
||||||
localStorage.removeItem('titan_token');
|
localStorage.removeItem('nyx_user');
|
||||||
localStorage.removeItem('nyx_token');
|
sessionStorage.removeItem('nyx_ws_session');
|
||||||
sessionStorage.removeItem('agent');
|
sessionStorage.removeItem('agent');
|
||||||
sessionStorage.removeItem('viewer_auth'); // clear cached fstoken
|
|
||||||
isLoggedIn.value = false;
|
isLoggedIn.value = false;
|
||||||
loginToken.value = '';
|
|
||||||
loggingIn.value = false;
|
loggingIn.value = false;
|
||||||
router.push('/');
|
router.push('/');
|
||||||
}
|
}
|
||||||
|
|
||||||
return { isLoggedIn, loginToken, loginError, loggingIn, doLogin, doLogout };
|
return { isLoggedIn, loginToken, loginError, loggingIn, currentUser, doLogin, doLogout, handleCallback, tryAutoConnect };
|
||||||
}
|
}
|
||||||
|
|||||||
@ -56,7 +56,7 @@ export function useBreakout() {
|
|||||||
pendingRequest.value = null;
|
pendingRequest.value = null;
|
||||||
if (!confirmed) { resolve({ error: 'rejected by user' }); return; }
|
if (!confirmed) { resolve({ error: 'rejected by user' }); return; }
|
||||||
const token = deriveToken(parentToken, name);
|
const token = deriveToken(parentToken, name);
|
||||||
const url = `${window.location.origin}${window.location.pathname}?breakout_token=${token}/agents`;
|
const url = `${window.location.origin}${window.location.pathname}?breakout_token=${token}/nyx`;
|
||||||
const popup = window.open(url, `hermes_breakout_${name}`, `width=${pw},height=${ph},resizable=yes,scrollbars=yes`);
|
const popup = window.open(url, `hermes_breakout_${name}`, `width=${pw},height=${ph},resizable=yes,scrollbars=yes`);
|
||||||
if (!popup) { resolve({ error: 'popup blocked' }); return; }
|
if (!popup) { resolve({ error: 'popup blocked' }); return; }
|
||||||
windows.set(name, popup);
|
windows.set(name, popup);
|
||||||
@ -70,7 +70,7 @@ export function useBreakout() {
|
|||||||
function openDirect(name: string, presetStr: string, parentToken: string) {
|
function openDirect(name: string, presetStr: string, parentToken: string) {
|
||||||
const [w, h] = presetStr.split('x').map(Number);
|
const [w, h] = presetStr.split('x').map(Number);
|
||||||
const token = deriveToken(parentToken, name);
|
const token = deriveToken(parentToken, name);
|
||||||
const url = `${window.location.origin}${window.location.pathname}?breakout_token=${token}/agents`;
|
const url = `${window.location.origin}${window.location.pathname}?breakout_token=${token}/nyx`;
|
||||||
const popup = window.open(url, `hermes_breakout_${name}`, `width=${w},height=${h},resizable=yes,scrollbars=yes`);
|
const popup = window.open(url, `hermes_breakout_${name}`, `width=${w},height=${h},resizable=yes,scrollbars=yes`);
|
||||||
if (!popup) { alert('Popup blocked -- allow popups for this site'); return; }
|
if (!popup) { alert('Popup blocked -- allow popups for this site'); return; }
|
||||||
windows.set(name, popup);
|
windows.set(name, popup);
|
||||||
|
|||||||
@ -1,8 +1,12 @@
|
|||||||
/**
|
/**
|
||||||
* ws.ts — WebSocket transport layer
|
* ws.ts — Streamable HTTP transport layer
|
||||||
*
|
*
|
||||||
* Pure connection management: connect, disconnect, reconnect, send, onMessage.
|
* Replaced WebSocket with HTTP POST + SSE streaming.
|
||||||
* Delegates takeover commands to useTakeover.
|
* Each message = one POST /api/chat, response is SSE stream (delta, hud, done, etc.).
|
||||||
|
* Session init = GET /api/session.
|
||||||
|
*
|
||||||
|
* Keeps the same API surface (connect, send, onMessage, connected, etc.)
|
||||||
|
* so useAgentSocket.ts and all consumers work unchanged.
|
||||||
*
|
*
|
||||||
* All mutable state is module-level so it survives Vite HMR.
|
* All mutable state is module-level so it survives Vite HMR.
|
||||||
* useWebSocket() returns a stable API over that shared state.
|
* useWebSocket() returns a stable API over that shared state.
|
||||||
@ -22,22 +26,18 @@ interface MessagePayload {
|
|||||||
|
|
||||||
// ── Module-level state — stored on window.__hermes to survive Vite HMR ──
|
// ── Module-level state — stored on window.__hermes to survive Vite HMR ──
|
||||||
const H = useHermes();
|
const H = useHermes();
|
||||||
// WebSocket + ping timer
|
|
||||||
let _ws: WebSocket | null = H.ws ?? null;
|
|
||||||
let _pingInterval: ReturnType<typeof setInterval> | null = H.wsPing ?? null;
|
|
||||||
// Callbacks + buffer
|
// Callbacks + buffer
|
||||||
const _onMessageCallbacks: ((data: any) => void)[] = H.wsCbs ?? [];
|
const _onMessageCallbacks: ((data: any) => void)[] = H.wsCbs ?? [];
|
||||||
const _messageBuffer: any[] = H.wsBuf ?? [];
|
const _messageBuffer: any[] = H.wsBuf ?? [];
|
||||||
// Reconnect
|
|
||||||
let _reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
||||||
let _reconnectDelay = 1000;
|
|
||||||
let _pendingAuth = false; // WS open but agent was empty — send auth when agent is set
|
|
||||||
// Refs (re-bound on connect)
|
// Refs (re-bound on connect)
|
||||||
let _selectedAgentRef: Ref<string> | null = null;
|
|
||||||
let _selectedModeRef: Ref<string> | null = null;
|
|
||||||
let _isLoggedInRef: Ref<boolean> | null = null;
|
let _isLoggedInRef: Ref<boolean> | null = null;
|
||||||
let _loginErrorRef: Ref<string> | null = null;
|
let _loginErrorRef: Ref<string> | null = null;
|
||||||
let _takeover: ReturnType<typeof useTakeover> | null = null;
|
let _takeover: ReturnType<typeof useTakeover> | null = null;
|
||||||
|
// Abort controller for in-flight chat request
|
||||||
|
let _chatAbort: AbortController | null = null;
|
||||||
|
// Health stream abort + reconnect
|
||||||
|
let _healthAbort: AbortController | null = null;
|
||||||
|
let _healthReconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
const connected = H.wsConnected ?? ref(false);
|
const connected = H.wsConnected ?? ref(false);
|
||||||
const status = H.wsStatus ?? ref('Disconnected');
|
const status = H.wsStatus ?? ref('Disconnected');
|
||||||
@ -54,8 +54,25 @@ H.wsInit = isInitialLoad;
|
|||||||
H.wsCbs = _onMessageCallbacks;
|
H.wsCbs = _onMessageCallbacks;
|
||||||
H.wsBuf = _messageBuffer;
|
H.wsBuf = _messageBuffer;
|
||||||
|
|
||||||
function send(payload: MessagePayload): void {
|
function getToken(): string {
|
||||||
if (_ws && _ws.readyState === WebSocket.OPEN) _ws.send(JSON.stringify(payload));
|
return localStorage.getItem('nyx_session') || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getApiBase(): string {
|
||||||
|
const wsUrl = import.meta.env.VITE_WS_URL as string | undefined;
|
||||||
|
if (!wsUrl) return '';
|
||||||
|
try {
|
||||||
|
const url = new URL(wsUrl);
|
||||||
|
const proto = url.protocol === 'wss:' ? 'https:' : 'http:';
|
||||||
|
return `${proto}//${url.host}`;
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _dispatch(data: any) {
|
||||||
|
_messageBuffer.push(data);
|
||||||
|
_onMessageCallbacks.forEach(fn => fn(data));
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTakeover() {
|
function getTakeover() {
|
||||||
@ -63,132 +80,278 @@ function getTakeover() {
|
|||||||
return _takeover;
|
return _takeover;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dev service token — skip login UI for now
|
/**
|
||||||
const DEV_TOKEN = '7Oorb9S3OpwFyWgm4zi_Tq7GeamefbjjTgooPVPWAwPDOf6B4TvgvQlLbhmT4DjsqBS_D1g';
|
* Health stream: SSE connection for instant disconnect detection.
|
||||||
|
* Server sends heartbeat every 15s. If stream breaks → disconnected.
|
||||||
|
*/
|
||||||
|
async function _startHealthStream(): Promise<void> {
|
||||||
|
if (_healthAbort) _healthAbort.abort();
|
||||||
|
_healthAbort = new AbortController();
|
||||||
|
const base = getApiBase();
|
||||||
|
const token = getToken();
|
||||||
|
|
||||||
function getWsUrl(): string {
|
try {
|
||||||
let base: string;
|
const res = await fetch(`${base}/api/health-stream`, {
|
||||||
if (import.meta.env.VITE_WS_URL) {
|
headers: { 'Authorization': `Bearer ${token}` },
|
||||||
base = import.meta.env.VITE_WS_URL as string;
|
signal: _healthAbort.signal,
|
||||||
} else {
|
});
|
||||||
const params = new URLSearchParams(window.location.search);
|
if (!res.ok) return;
|
||||||
const wsHost = params.get('ws') || window.location.hostname;
|
|
||||||
const wsPort = params.get('port') || window.location.port;
|
const reader = res.body!.getReader();
|
||||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
const decoder = new TextDecoder();
|
||||||
base = `${protocol}//${wsHost}:${wsPort}/ws`;
|
const wasDisconnected = !connected.value;
|
||||||
|
connected.value = true;
|
||||||
|
status.value = 'Connected';
|
||||||
|
if (wasDisconnected) {
|
||||||
|
_dispatch({ type: 'connection_state', state: 'SYNCED' });
|
||||||
|
}
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
const text = decoder.decode(value, { stream: true });
|
||||||
|
if (text.includes('heartbeat')) {
|
||||||
|
connected.value = true;
|
||||||
|
status.value = 'Connected';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e.name === 'AbortError') return;
|
||||||
}
|
}
|
||||||
// Append token + session as query params (assay auth pattern)
|
|
||||||
const token = localStorage.getItem('nyx_session') || localStorage.getItem('titan_token') || DEV_TOKEN;
|
// Stream ended or errored — server gone
|
||||||
const session = sessionStorage.getItem('nyx_ws_session') || '';
|
connected.value = false;
|
||||||
const qs = new URLSearchParams();
|
status.value = 'Disconnected';
|
||||||
if (token) qs.set('token', token);
|
_dispatch({ type: 'connection_state', state: 'CONNECTING' });
|
||||||
if (session) qs.set('session', session);
|
// Auto-reconnect after 3s
|
||||||
return qs.toString() ? `${base}?${qs}` : base;
|
if (_healthReconnectTimer) clearTimeout(_healthReconnectTimer);
|
||||||
|
_healthReconnectTimer = setTimeout(() => {
|
||||||
|
_healthReconnectTimer = null;
|
||||||
|
if (_isLoggedInRef?.value !== false) _startHealthStream();
|
||||||
|
}, 3000);
|
||||||
}
|
}
|
||||||
|
|
||||||
function scheduleReconnect() {
|
/**
|
||||||
if (_reconnectTimer) return;
|
* Connect: fetch or create session via GET /api/session, then mark connected.
|
||||||
if (!_isLoggedInRef?.value) return;
|
*/
|
||||||
status.value = `Reconnecting in ${Math.round(_reconnectDelay / 1000)}s…`;
|
async function connect(
|
||||||
_reconnectTimer = setTimeout(() => {
|
|
||||||
_reconnectTimer = null;
|
|
||||||
if (_isLoggedInRef?.value) connect(_selectedAgentRef!, _isLoggedInRef!, _loginErrorRef!);
|
|
||||||
}, _reconnectDelay);
|
|
||||||
_reconnectDelay = Math.min(_reconnectDelay * 2, 16000);
|
|
||||||
}
|
|
||||||
|
|
||||||
function connect(
|
|
||||||
selectedAgent: Ref<string>,
|
selectedAgent: Ref<string>,
|
||||||
isLoggedInRef: Ref<boolean>,
|
isLoggedInRef: Ref<boolean>,
|
||||||
loginErrorRef: Ref<string>,
|
loginErrorRef: Ref<string>,
|
||||||
selectedMode?: Ref<string>
|
_selectedMode?: Ref<string>
|
||||||
): void {
|
): Promise<void> {
|
||||||
if (_ws && _ws.readyState <= WebSocket.OPEN) return;
|
|
||||||
_selectedAgentRef = selectedAgent;
|
|
||||||
_selectedModeRef = selectedMode ?? null;
|
|
||||||
_isLoggedInRef = isLoggedInRef;
|
_isLoggedInRef = isLoggedInRef;
|
||||||
_loginErrorRef = loginErrorRef;
|
_loginErrorRef = loginErrorRef;
|
||||||
_reconnectDelay = 1000;
|
|
||||||
// Poll setup happens in onopen if agent is empty
|
|
||||||
console.log('WS CONNECT attempt, ws state:', _ws?.readyState, 'url:', getWsUrl());
|
|
||||||
const wsUrl = getWsUrl();
|
|
||||||
if (isInitialLoad.value) {
|
if (isInitialLoad.value) {
|
||||||
status.value = 'Connecting...';
|
status.value = 'Connecting...';
|
||||||
isInitialLoad.value = false;
|
isInitialLoad.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
_ws = new WebSocket(wsUrl);
|
const base = getApiBase();
|
||||||
H.ws = _ws;
|
const token = getToken();
|
||||||
|
const existingSession = sessionStorage.getItem('nyx_ws_session') || '';
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
if (existingSession) qs.set('session', existingSession);
|
||||||
|
|
||||||
_ws.onopen = () => {
|
try {
|
||||||
_reconnectDelay = 1000;
|
const res = await fetch(`${base}/api/session?${qs}`, {
|
||||||
// Auth is via query params — no auth message needed
|
headers: { 'Authorization': `Bearer ${token}` },
|
||||||
// Just start ping keepalive
|
});
|
||||||
if (_pingInterval) clearInterval(_pingInterval);
|
if (res.status === 401) {
|
||||||
_pingInterval = setInterval(() => {
|
|
||||||
if (_ws?.readyState === WebSocket.OPEN) _ws.send(JSON.stringify({ type: 'ping' }));
|
|
||||||
}, 30000);
|
|
||||||
H.wsPing = _pingInterval;
|
|
||||||
};
|
|
||||||
|
|
||||||
_ws.onmessage = (event) => {
|
|
||||||
try {
|
|
||||||
const data = JSON.parse(event.data);
|
|
||||||
if (data.type === 'dev_cmd' && data.cmdId && data.cmd) {
|
|
||||||
getTakeover().dispatch(data.cmdId, data.cmd, data.args || {}, send);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Session info from assay — store for reconnect
|
|
||||||
if (data.type === 'session_info') {
|
|
||||||
sessionId.value = data.session_id;
|
|
||||||
sessionStorage.setItem('nyx_ws_session', data.session_id);
|
|
||||||
}
|
|
||||||
// Ready signal from assay — mark connected
|
|
||||||
if (data.type === 'ready') {
|
|
||||||
connected.value = true;
|
|
||||||
status.value = 'Connected';
|
|
||||||
}
|
|
||||||
if (data.type === 'error' && data.code === 'SESSION_TERMINATED') {
|
|
||||||
console.warn('Message bounced: Session terminated.');
|
|
||||||
}
|
|
||||||
_messageBuffer.push(data);
|
|
||||||
_onMessageCallbacks.forEach(fn => fn(data));
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Parse error:', e);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
_ws.onclose = (e) => {
|
|
||||||
if (_pingInterval) { clearInterval(_pingInterval); _pingInterval = null; }
|
|
||||||
connected.value = false;
|
|
||||||
_messageBuffer.length = 0;
|
|
||||||
if (e.code === 4001) {
|
|
||||||
isLoggedInRef.value = false;
|
isLoggedInRef.value = false;
|
||||||
loginErrorRef.value = 'Session expired. Please log in again.';
|
loginErrorRef.value = 'Session expired. Please log in again.';
|
||||||
localStorage.removeItem('nyx_session');
|
localStorage.removeItem('nyx_session');
|
||||||
localStorage.removeItem('titan_token');
|
localStorage.removeItem('titan_token');
|
||||||
sessionStorage.removeItem('agent');
|
|
||||||
status.value = 'Logged out';
|
status.value = 'Logged out';
|
||||||
// Redirect to login — avoids broken UI with stale state
|
|
||||||
if (window.location.pathname !== '/login') {
|
if (window.location.pathname !== '/login') {
|
||||||
window.location.pathname = '/login';
|
window.location.pathname = '/login';
|
||||||
}
|
}
|
||||||
} else {
|
return;
|
||||||
scheduleReconnect();
|
|
||||||
}
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
status.value = `Error ${res.status}`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
sessionId.value = data.session_id;
|
||||||
|
sessionStorage.setItem('nyx_ws_session', data.session_id);
|
||||||
|
|
||||||
|
// Dispatch session_info + ready (same as old WS handshake)
|
||||||
|
_dispatch({ type: 'session_info', session_id: data.session_id });
|
||||||
|
_dispatch({
|
||||||
|
type: 'ready',
|
||||||
|
session_id: data.session_id,
|
||||||
|
graph: data.graph,
|
||||||
|
history_len: data.history_len,
|
||||||
|
});
|
||||||
|
|
||||||
|
connected.value = true;
|
||||||
|
status.value = 'Connected';
|
||||||
|
|
||||||
|
// Start health stream for instant disconnect detection
|
||||||
|
_startHealthStream();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[http] connect failed:', e);
|
||||||
|
status.value = 'Connection failed';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a message via POST /api/chat. Response is SSE stream.
|
||||||
|
* Events are dispatched through the same onMessage callbacks.
|
||||||
|
*/
|
||||||
|
function send(payload: MessagePayload): void {
|
||||||
|
if (!connected.value) return;
|
||||||
|
|
||||||
|
const base = getApiBase();
|
||||||
|
const token = getToken();
|
||||||
|
|
||||||
|
// Build request body
|
||||||
|
const body: Record<string, any> = {
|
||||||
|
session_id: sessionId.value,
|
||||||
};
|
};
|
||||||
|
|
||||||
_ws.onerror = () => {};
|
if (payload.type === 'message') {
|
||||||
|
body.content = payload.content || '';
|
||||||
|
if (payload.dashboard) body.dashboard = payload.dashboard;
|
||||||
|
} else if (payload.type === 'action') {
|
||||||
|
body.action = payload.action;
|
||||||
|
if (payload.data) body.action_data = payload.data;
|
||||||
|
} else if (payload.type === 'new') {
|
||||||
|
// New session — use dedicated endpoint
|
||||||
|
_createNewSession();
|
||||||
|
return;
|
||||||
|
} else if (payload.type === 'stop') {
|
||||||
|
_stopPipeline();
|
||||||
|
return;
|
||||||
|
} else if (payload.type === 'ping') {
|
||||||
|
return; // No-op for HTTP
|
||||||
|
} else if (payload.type === 'switch_agent') {
|
||||||
|
// Agent switching not yet implemented in HTTP — ignore for now
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
// Unknown type — skip
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Abort previous in-flight request if any
|
||||||
|
if (_chatAbort) _chatAbort.abort();
|
||||||
|
_chatAbort = new AbortController();
|
||||||
|
|
||||||
|
_streamChat(base, token, body, _chatAbort.signal);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _streamChat(base: string, token: string, body: Record<string, any>, signal: AbortSignal): Promise<void> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${base}/api/chat`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status === 401) {
|
||||||
|
_isLoggedInRef!.value = false;
|
||||||
|
_loginErrorRef!.value = 'Session expired. Please log in again.';
|
||||||
|
status.value = 'Logged out';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
const detail = await res.text().catch(() => `${res.status}`);
|
||||||
|
console.error('[chat] HTTP error:', res.status, detail);
|
||||||
|
_dispatch({ type: 'error', detail });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read SSE stream
|
||||||
|
const reader = res.body!.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = '';
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
|
||||||
|
// Parse SSE events from buffer
|
||||||
|
const lines = buffer.split('\n');
|
||||||
|
buffer = lines.pop()!; // Keep incomplete last line
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.startsWith('data: ')) {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(line.slice(6));
|
||||||
|
_dispatch(data);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[chat] SSE parse error:', e, line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Skip event: lines — we get the type from the data payload
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process any remaining buffer
|
||||||
|
if (buffer.startsWith('data: ')) {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(buffer.slice(6));
|
||||||
|
_dispatch(data);
|
||||||
|
} catch { /* ignore partial */ }
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e.name === 'AbortError') return; // Intentional abort
|
||||||
|
console.error('[chat] stream error:', e);
|
||||||
|
} finally {
|
||||||
|
_chatAbort = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _createNewSession(): Promise<void> {
|
||||||
|
const base = getApiBase();
|
||||||
|
const token = getToken();
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${base}/api/sessions`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
});
|
||||||
|
if (!res.ok) return;
|
||||||
|
const data = await res.json();
|
||||||
|
sessionId.value = data.session_id;
|
||||||
|
sessionStorage.setItem('nyx_ws_session', data.session_id);
|
||||||
|
_dispatch({ type: 'session_info', session_id: data.session_id });
|
||||||
|
_dispatch({ type: 'cleared' });
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[http] new session failed:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _stopPipeline(): Promise<void> {
|
||||||
|
const base = getApiBase();
|
||||||
|
const token = getToken();
|
||||||
|
try {
|
||||||
|
await fetch(`${base}/api/stop`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Authorization': `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
if (_chatAbort) _chatAbort.abort();
|
||||||
|
_dispatch({ type: 'stopped' });
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[http] stop failed:', e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function disconnect(): void {
|
function disconnect(): void {
|
||||||
if (_pingInterval) { clearInterval(_pingInterval); _pingInterval = null; H.wsPing = null; }
|
if (_chatAbort) { _chatAbort.abort(); _chatAbort = null; }
|
||||||
if (_ws) {
|
if (_healthAbort) { _healthAbort.abort(); _healthAbort = null; }
|
||||||
_ws.onclose = null;
|
if (_healthReconnectTimer) { clearTimeout(_healthReconnectTimer); _healthReconnectTimer = null; }
|
||||||
_ws.close();
|
|
||||||
_ws = null;
|
|
||||||
H.ws = null;
|
|
||||||
}
|
|
||||||
connected.value = false;
|
connected.value = false;
|
||||||
status.value = 'Disconnected';
|
status.value = 'Disconnected';
|
||||||
currentUser.value = '';
|
currentUser.value = '';
|
||||||
@ -196,23 +359,9 @@ function disconnect(): void {
|
|||||||
isInitialLoad.value = true;
|
isInitialLoad.value = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function sendDeferredAuth(): void {
|
|
||||||
// Auth is via query params now — no-op for backward compat
|
|
||||||
}
|
|
||||||
|
|
||||||
// Poll for agent becoming available after deferred auth (no Vue watch to avoid circular imports)
|
|
||||||
let _deferredAuthPoll: ReturnType<typeof setInterval> | null = null;
|
|
||||||
function setupDeferredAuthPoll() {
|
|
||||||
if (_deferredAuthPoll) return;
|
|
||||||
_deferredAuthPoll = setInterval(() => {
|
|
||||||
if (!_pendingAuth) { clearInterval(_deferredAuthPoll!); _deferredAuthPoll = null; return; }
|
|
||||||
sendDeferredAuth();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
|
|
||||||
function switchAgent(agentId: string, mode?: string): void {
|
function switchAgent(agentId: string, mode?: string): void {
|
||||||
_messageBuffer.length = 0;
|
_messageBuffer.length = 0;
|
||||||
send({ type: 'switch_agent', agent: agentId, mode: mode ?? _selectedModeRef?.value ?? 'private' });
|
send({ type: 'switch_agent', agent: agentId, mode: mode ?? 'private' });
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearBuffer(): void {
|
function clearBuffer(): void {
|
||||||
@ -231,6 +380,9 @@ function replayBuffer(fn: (data: any) => void): void {
|
|||||||
_messageBuffer.forEach(data => fn(data));
|
_messageBuffer.forEach(data => fn(data));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Backward compat — not needed for HTTP but some code may reference it
|
||||||
|
function sendDeferredAuth(): void {}
|
||||||
|
|
||||||
export function useWebSocket() {
|
export function useWebSocket() {
|
||||||
return {
|
return {
|
||||||
connected, status, currentUser, sessionId,
|
connected, status, currentUser, sessionId,
|
||||||
|
|||||||
@ -28,7 +28,6 @@ import '../css/markdown.css';
|
|||||||
import '../css/views/agents.css';
|
import '../css/views/agents.css';
|
||||||
import '../css/views/home.css';
|
import '../css/views/home.css';
|
||||||
import '../css/views/login.css';
|
import '../css/views/login.css';
|
||||||
import '../css/views/dev.css';
|
|
||||||
import App from './App.vue';
|
import App from './App.vue';
|
||||||
import router from './router';
|
import router from './router';
|
||||||
|
|
||||||
|
|||||||
@ -9,14 +9,25 @@ const router = createRouter({
|
|||||||
{ path: '/impressum', name: 'impressum', component: () => import('./views/ImpressumView.vue'), meta: { suffix: 'Impressum' } },
|
{ path: '/impressum', name: 'impressum', component: () => import('./views/ImpressumView.vue'), meta: { suffix: 'Impressum' } },
|
||||||
{ path: '/datenschutz', name: 'datenschutz', component: () => import('./views/DatenschutzView.vue'), meta: { suffix: 'Datenschutz' } },
|
{ path: '/datenschutz', name: 'datenschutz', component: () => import('./views/DatenschutzView.vue'), meta: { suffix: 'Datenschutz' } },
|
||||||
{ path: '/login', name: 'login', component: LoginView, meta: { suffix: 'Login' } },
|
{ path: '/login', name: 'login', component: LoginView, meta: { suffix: 'Login' } },
|
||||||
{ path: '/agents', name: 'agents', component: () => import('./views/AgentsView.vue'), meta: { suffix: 'Home', requiresSocket: true } },
|
{ path: '/nyx', name: 'nyx', component: () => import('./views/AgentsView.vue'), meta: { suffix: 'nyx', requiresAuth: true } },
|
||||||
{ path: '/chat', redirect: '/agents' },
|
{ path: '/agents', redirect: '/nyx' },
|
||||||
{ path: '/dev', name: 'dev', component: () => import('./views/DevView.vue'), meta: { suffix: 'Dev', requiresSocket: true } },
|
{ path: '/chat', redirect: '/nyx' },
|
||||||
{ path: '/viewer', name: 'viewer', component: () => import('./views/ViewerView.vue'), meta: { suffix: 'Viewer', requiresSocket: true } },
|
{ path: '/viewer', name: 'viewer', component: () => import('./views/ViewerView.vue'), meta: { suffix: 'Viewer', requiresAuth: true } },
|
||||||
{ path: '/:pathMatch(.*)*', redirect: '/' },
|
{ path: '/:pathMatch(.*)*', redirect: '/' },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Auth guard — redirect to /login for protected routes
|
||||||
|
router.beforeEach((to) => {
|
||||||
|
if (to.meta?.requiresAuth) {
|
||||||
|
const token = localStorage.getItem('nyx_session');
|
||||||
|
if (!token) {
|
||||||
|
sessionStorage.setItem('auth_return_path', to.fullPath);
|
||||||
|
return { name: 'login' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
router.afterEach((to) => {
|
router.afterEach((to) => {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const brand = THEME_NAMES[theme.value] || 'loop42';
|
const brand = THEME_NAMES[theme.value] || 'loop42';
|
||||||
|
|||||||
@ -28,12 +28,14 @@ export const takeover = ws.getTakeover();
|
|||||||
export const agents = useAgents(ws.connected);
|
export const agents = useAgents(ws.connected);
|
||||||
agents.setCurrentUser(ws.currentUser);
|
agents.setCurrentUser(ws.currentUser);
|
||||||
|
|
||||||
// Auth — on login, connect WS (sends auth — BE returns agent list even without agent selected)
|
// Auth — on login/auto-connect, start HTTP transport
|
||||||
export const auth = useAuth(() => {
|
export const auth = useAuth(() => {
|
||||||
ws.connect(agents.selectedAgent, auth.isLoggedIn, auth.loginError, agents.selectedMode);
|
ws.connect(agents.selectedAgent, auth.isLoggedIn, auth.loginError, agents.selectedMode);
|
||||||
// Pre-warm viewer token in background so agent→viewer is instant
|
// Pre-warm viewer token in background so agent→viewer is instant
|
||||||
useViewerStore().acquire();
|
useViewerStore().acquire();
|
||||||
});
|
});
|
||||||
|
// Auto-connect if valid token exists from previous session (deferred so Pinia is ready)
|
||||||
|
setTimeout(() => auth.tryAutoConnect(), 0);
|
||||||
|
|
||||||
// Re-export viewer store accessor for use in views
|
// Re-export viewer store accessor for use in views
|
||||||
export { useViewerStore };
|
export { useViewerStore };
|
||||||
|
|||||||
@ -239,15 +239,8 @@ import { getApiBase } from '../utils/apiBase';
|
|||||||
const agentsRoute = useRoute();
|
const agentsRoute = useRoute();
|
||||||
const chatStore = useChatStore();
|
const chatStore = useChatStore();
|
||||||
|
|
||||||
// Show picker when route has no ?agent= param (overview mode)
|
// No picker — always show chat.
|
||||||
const showPicker = computed(() => !agentsRoute.query.agent);
|
const showPicker = computed(() => false);
|
||||||
|
|
||||||
// When navigating to /agents without params, clear selection so picker shows
|
|
||||||
watch(() => agentsRoute.query.agent, (val) => {
|
|
||||||
if (!val && agentsRoute.name === 'agents') {
|
|
||||||
// Don't clear selectedAgent ref — just show picker via showPicker computed
|
|
||||||
}
|
|
||||||
}, { immediate: true });
|
|
||||||
// Previous sessions (server-fetched, supports load-more)
|
// Previous sessions (server-fetched, supports load-more)
|
||||||
interface PrevSession { messages: any[]; timestamp: string | null; timeLabel: string }
|
interface PrevSession { messages: any[]; timestamp: string | null; timeLabel: string }
|
||||||
const prevSessions = ref<PrevSession[]>([]);
|
const prevSessions = ref<PrevSession[]>([]);
|
||||||
@ -316,7 +309,7 @@ function pickAgent(agentId: string, mode: string) {
|
|||||||
sessionStorage.setItem('agent', agentId);
|
sessionStorage.setItem('agent', agentId);
|
||||||
sessionStorage.setItem('agent_mode', mode);
|
sessionStorage.setItem('agent_mode', mode);
|
||||||
// Update URL with query params so showPicker switches to chat
|
// Update URL with query params so showPicker switches to chat
|
||||||
router.push({ name: 'agents', query: { agent: agentId, mode } });
|
router.push({ name: 'nyx', query: { agent: agentId, mode } });
|
||||||
if (ws.connected.value) {
|
if (ws.connected.value) {
|
||||||
ws.switchAgent(agentId, mode);
|
ws.switchAgent(agentId, mode);
|
||||||
} else {
|
} else {
|
||||||
@ -326,6 +319,18 @@ function pickAgent(agentId: string, mode: string) {
|
|||||||
|
|
||||||
const agentLogo = computed(() => getAgentLogo(selectedAgent.value));
|
const agentLogo = computed(() => getAgentLogo(selectedAgent.value));
|
||||||
|
|
||||||
|
// Auto-select default agent when navigating to /nyx without ?agent= param
|
||||||
|
// Watch both query.agent and defaultAgent — defaultAgent arrives from server after connect
|
||||||
|
watch(
|
||||||
|
[() => agentsRoute.query.agent, defaultAgent],
|
||||||
|
([queryAgent, defAgent]) => {
|
||||||
|
if (!queryAgent && agentsRoute.name === 'nyx' && defAgent && !selectedAgent.value) {
|
||||||
|
pickAgent(defAgent, 'private');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
const { sending, input, messagesEl, scrollToBottom, scrollIfAtBottom, send: _send, onInputChange, navigateHistory, restoreLastSent } = useMessages(wsSend);
|
const { sending, input, messagesEl, scrollToBottom, scrollIfAtBottom, send: _send, onInputChange, navigateHistory, restoreLastSent } = useMessages(wsSend);
|
||||||
|
|
||||||
const controlsEl = ref<HTMLElement | null>(null);
|
const controlsEl = ref<HTMLElement | null>(null);
|
||||||
@ -374,7 +379,7 @@ if (typeof window !== 'undefined') {
|
|||||||
(window as any).__toolCallMap = toolCallMapSnapshot;
|
(window as any).__toolCallMap = toolCallMapSnapshot;
|
||||||
}
|
}
|
||||||
|
|
||||||
const viewActive = computed(() => agentsRoute.name === 'agents');
|
const viewActive = computed(() => agentsRoute.name === 'nyx');
|
||||||
|
|
||||||
// --- Scroll logic ---
|
// --- Scroll logic ---
|
||||||
// On user send: scroll so user message sits at top of viewport.
|
// On user send: scroll so user message sits at top of viewport.
|
||||||
|
|||||||
@ -5,7 +5,6 @@
|
|||||||
<p>Eigene Plattform. Eigene Infrastruktur. Eigene Regeln.</p>
|
<p>Eigene Plattform. Eigene Infrastruktur. Eigene Regeln.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<hr class="divider">
|
|
||||||
|
|
||||||
<section id="plattform">
|
<section id="plattform">
|
||||||
<div class="section-label">Plattform</div>
|
<div class="section-label">Plattform</div>
|
||||||
@ -26,7 +25,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<hr class="divider">
|
|
||||||
|
|
||||||
<section id="produkte">
|
<section id="produkte">
|
||||||
<div class="section-label">Produkte</div>
|
<div class="section-label">Produkte</div>
|
||||||
@ -43,37 +41,22 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<hr class="divider">
|
|
||||||
|
|
||||||
<div class="cta">
|
<div class="cta">
|
||||||
<h2>nyx</h2>
|
<h2>nyx</h2>
|
||||||
<p>Produkte kennenlernen, ausprobieren, Zugang einrichten — direkt hier, direkt mit nyx.</p>
|
<p>Produkte kennenlernen, ausprobieren, Zugang einrichten — direkt hier, direkt mit nyx.</p>
|
||||||
<router-link to="/agents" class="btn">nyx öffnen</router-link>
|
<router-link to="/nyx" class="btn">nyx öffnen</router-link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<footer>
|
<footer>
|
||||||
<div class="footer-links">
|
<span>© 2026 loop42 GmbH</span>
|
||||||
<router-link to="/impressum">Impressum</router-link>
|
|
||||||
<router-link to="/datenschutz">Datenschutz</router-link>
|
|
||||||
</div>
|
|
||||||
<span>© 2026 loop42 UG (haftungsbeschränkt)</span>
|
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.company-page {
|
.company-page {
|
||||||
--cp-bg: #0a0a0a;
|
color: var(--text);
|
||||||
--cp-bg2: #111;
|
|
||||||
--cp-border: #1e1e1e;
|
|
||||||
--cp-text: #e8e8e8;
|
|
||||||
--cp-muted: rgba(232,232,232,0.4);
|
|
||||||
--cp-accent: #7c6af7;
|
|
||||||
--cp-accent-dim: rgba(124,106,247,0.12);
|
|
||||||
|
|
||||||
color: var(--cp-text);
|
|
||||||
overflow-y: auto;
|
|
||||||
height: 100%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero {
|
.hero {
|
||||||
@ -96,19 +79,19 @@
|
|||||||
|
|
||||||
.hero h1 em {
|
.hero h1 em {
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
color: var(--cp-accent);
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero p {
|
.hero p {
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
color: var(--cp-muted);
|
color: var(--text-dim);
|
||||||
max-width: 480px;
|
max-width: 480px;
|
||||||
margin: 0 auto 2.5rem;
|
margin: 0 auto 2.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.divider {
|
.divider {
|
||||||
border: none;
|
border: none;
|
||||||
border-top: 1px solid var(--cp-border);
|
border-top: 1px solid var(--border, #1e1e1e);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -122,7 +105,7 @@ section {
|
|||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
letter-spacing: 0.15em;
|
letter-spacing: 0.15em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: var(--cp-accent);
|
color: var(--accent);
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -140,8 +123,8 @@ section h2 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.card {
|
.card {
|
||||||
background: var(--cp-bg2);
|
background: var(--panel-bg);
|
||||||
border: 1px solid var(--cp-border);
|
border: 1px solid var(--border, #1e1e1e);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
}
|
}
|
||||||
@ -154,7 +137,7 @@ section h2 {
|
|||||||
|
|
||||||
.card p {
|
.card p {
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
color: var(--cp-muted);
|
color: var(--text-dim);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
@ -162,9 +145,9 @@ section h2 {
|
|||||||
.cta {
|
.cta {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 4rem 2rem;
|
padding: 4rem 2rem;
|
||||||
background: var(--cp-accent-dim);
|
background: color-mix(in srgb, var(--accent) 12%, transparent);
|
||||||
border-top: 1px solid var(--cp-border);
|
border-top: 1px solid var(--border, #1e1e1e);
|
||||||
border-bottom: 1px solid var(--cp-border);
|
border-bottom: 1px solid var(--border, #1e1e1e);
|
||||||
}
|
}
|
||||||
|
|
||||||
.cta h2 {
|
.cta h2 {
|
||||||
@ -174,7 +157,7 @@ section h2 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.cta p {
|
.cta p {
|
||||||
color: var(--cp-muted);
|
color: var(--text-dim);
|
||||||
margin: 0 auto 2rem;
|
margin: 0 auto 2rem;
|
||||||
max-width: 480px;
|
max-width: 480px;
|
||||||
}
|
}
|
||||||
@ -182,7 +165,7 @@ section h2 {
|
|||||||
.btn {
|
.btn {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
padding: 0.75rem 2rem;
|
padding: 0.75rem 2rem;
|
||||||
background: var(--cp-accent);
|
background: var(--accent);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
@ -196,26 +179,13 @@ footer {
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
color: var(--cp-muted);
|
color: var(--text-dim);
|
||||||
border-top: 1px solid var(--cp-border);
|
border-top: 1px solid var(--border, #1e1e1e);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.footer-links {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer-links a {
|
|
||||||
color: var(--cp-muted);
|
|
||||||
font-size: 0.8rem;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
.footer-links a:hover { color: var(--cp-text); }
|
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
@media (max-width: 600px) {
|
||||||
section { padding: 3rem 1.25rem; }
|
section { padding: 3rem 1.25rem; }
|
||||||
.hero { padding: 4rem 1.25rem 3rem; }
|
.hero { padding: 4rem 1.25rem 3rem; }
|
||||||
|
|||||||
@ -67,49 +67,32 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<footer>
|
<footer>
|
||||||
<div class="footer-links">
|
<span>© 2026 loop42 GmbH</span>
|
||||||
<router-link to="/impressum">Impressum</router-link>
|
|
||||||
<router-link to="/datenschutz">Datenschutz</router-link>
|
|
||||||
</div>
|
|
||||||
<span>© 2026 loop42 UG (haftungsbeschränkt)</span>
|
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.legal-page {
|
.legal-page {
|
||||||
--cp-border: #1e1e1e;
|
|
||||||
--cp-muted: rgba(232,232,232,0.4);
|
|
||||||
--cp-accent: #7c6af7;
|
|
||||||
--cp-text: #e8e8e8;
|
|
||||||
|
|
||||||
padding: 3rem 2rem 2rem;
|
padding: 3rem 2rem 2rem;
|
||||||
max-width: 720px;
|
max-width: 720px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
color: var(--cp-text);
|
color: var(--text);
|
||||||
overflow-y: auto;
|
|
||||||
height: 100%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
h1 { font-size: 2rem; font-weight: 300; margin-bottom: 2rem; }
|
h1 { font-size: 2rem; font-weight: 300; margin-bottom: 2rem; }
|
||||||
h2 { font-size: 1.1rem; font-weight: 500; margin: 2rem 0 0.5rem; }
|
h2 { font-size: 1.1rem; font-weight: 500; margin: 2rem 0 0.5rem; }
|
||||||
p, li { color: var(--cp-muted); font-size: 0.95rem; line-height: 1.7; }
|
p, li { color: var(--text-dim); font-size: 0.95rem; line-height: 1.7; }
|
||||||
ul { padding-left: 1.5rem; }
|
ul { padding-left: 1.5rem; }
|
||||||
a { color: var(--cp-accent); text-decoration: none; }
|
a { color: var(--accent); text-decoration: none; }
|
||||||
a:hover { text-decoration: underline; }
|
a:hover { text-decoration: underline; }
|
||||||
|
|
||||||
footer {
|
footer {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 3rem 0 1rem;
|
padding: 3rem 0 1rem;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
color: var(--cp-muted);
|
color: var(--text-dim);
|
||||||
border-top: 1px solid var(--cp-border);
|
border-top: 1px solid var(--border, #1e1e1e);
|
||||||
margin-top: 3rem;
|
margin-top: 3rem;
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.75rem;
|
|
||||||
}
|
}
|
||||||
.footer-links { display: flex; justify-content: center; gap: 1.5rem; }
|
|
||||||
.footer-links a { color: var(--cp-muted); font-size: 0.8rem; }
|
|
||||||
.footer-links a:hover { color: var(--cp-text); }
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -6,7 +6,7 @@
|
|||||||
<h1>{{ THEME_NAMES[theme] }}</h1>
|
<h1>{{ THEME_NAMES[theme] }}</h1>
|
||||||
<p class="home-sub">Don't Panic.</p>
|
<p class="home-sub">Don't Panic.</p>
|
||||||
<RouterLink v-if="!isLoggedIn" to="/login" class="home-btn">Sign in →</RouterLink>
|
<RouterLink v-if="!isLoggedIn" to="/login" class="home-btn">Sign in →</RouterLink>
|
||||||
<RouterLink v-else to="/agents" class="home-btn">Sign in →</RouterLink>
|
<RouterLink v-else to="/nyx" class="home-btn">Sign in →</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -48,49 +48,32 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<footer>
|
<footer>
|
||||||
<div class="footer-links">
|
<span>© 2026 loop42 GmbH</span>
|
||||||
<router-link to="/impressum">Impressum</router-link>
|
|
||||||
<router-link to="/datenschutz">Datenschutz</router-link>
|
|
||||||
</div>
|
|
||||||
<span>© 2026 loop42 UG (haftungsbeschränkt)</span>
|
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.legal-page {
|
.legal-page {
|
||||||
--cp-border: #1e1e1e;
|
|
||||||
--cp-muted: rgba(232,232,232,0.4);
|
|
||||||
--cp-accent: #7c6af7;
|
|
||||||
--cp-text: #e8e8e8;
|
|
||||||
|
|
||||||
padding: 3rem 2rem 2rem;
|
padding: 3rem 2rem 2rem;
|
||||||
max-width: 720px;
|
max-width: 720px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
color: var(--cp-text);
|
color: var(--text);
|
||||||
overflow-y: auto;
|
|
||||||
height: 100%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
h1 { font-size: 2rem; font-weight: 300; margin-bottom: 2rem; }
|
h1 { font-size: 2rem; font-weight: 300; margin-bottom: 2rem; }
|
||||||
h2 { font-size: 1.1rem; font-weight: 500; margin: 2rem 0 0.5rem; }
|
h2 { font-size: 1.1rem; font-weight: 500; margin: 2rem 0 0.5rem; }
|
||||||
p, li { color: var(--cp-muted); font-size: 0.95rem; line-height: 1.7; }
|
p, li { color: var(--text-dim); font-size: 0.95rem; line-height: 1.7; }
|
||||||
ul { padding-left: 1.5rem; }
|
ul { padding-left: 1.5rem; }
|
||||||
a { color: var(--cp-accent); text-decoration: none; }
|
a { color: var(--accent); text-decoration: none; }
|
||||||
a:hover { text-decoration: underline; }
|
a:hover { text-decoration: underline; }
|
||||||
|
|
||||||
footer {
|
footer {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 3rem 0 1rem;
|
padding: 3rem 0 1rem;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
color: var(--cp-muted);
|
color: var(--text-dim);
|
||||||
border-top: 1px solid var(--cp-border);
|
border-top: 1px solid var(--border, #1e1e1e);
|
||||||
margin-top: 3rem;
|
margin-top: 3rem;
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.75rem;
|
|
||||||
}
|
}
|
||||||
.footer-links { display: flex; justify-content: center; gap: 1.5rem; }
|
|
||||||
.footer-links a { color: var(--cp-muted); font-size: 0.8rem; }
|
|
||||||
.footer-links a:hover { color: var(--cp-text); }
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -4,27 +4,23 @@
|
|||||||
<div class="login-card">
|
<div class="login-card">
|
||||||
<!-- Already logged in -->
|
<!-- Already logged in -->
|
||||||
<template v-if="isLoggedIn">
|
<template v-if="isLoggedIn">
|
||||||
<h2>✅ Already signed in</h2>
|
<h2>Signed in</h2>
|
||||||
<p class="login-info">You're connected. Head back to chat or sign out below.</p>
|
<p class="login-info">You're connected.</p>
|
||||||
<button @click="router.push('/agents')">Go to Chat</button>
|
<button @click="router.push('/nyx')">Go to Chat</button>
|
||||||
<button class="logout-btn" @click="doLogout(ws.disconnect)">Sign out</button>
|
<button class="logout-btn" @click="doLogout(ws.disconnect)">Sign out</button>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- Login form -->
|
<!-- Handling callback -->
|
||||||
|
<template v-else-if="loggingIn">
|
||||||
|
<h2>Signing in...</h2>
|
||||||
|
<p class="login-info">Exchanging credentials with auth server.</p>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Login prompt -->
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<h2>🔐 Sign in</h2>
|
<h2>Sign in</h2>
|
||||||
<label class="login-label">Enter your Login Token</label>
|
<p class="login-info">Sign in with your loop42 account.</p>
|
||||||
<input
|
<button @click="doLogin" :disabled="loggingIn">Sign in</button>
|
||||||
v-model="loginToken"
|
|
||||||
type="password"
|
|
||||||
placeholder="Login Token"
|
|
||||||
@keyup.enter="doLogin"
|
|
||||||
:disabled="loggingIn"
|
|
||||||
autofocus
|
|
||||||
/>
|
|
||||||
<button @click="doLogin" :disabled="loggingIn">
|
|
||||||
{{ loggingIn ? 'Connecting...' : 'Connect' }}
|
|
||||||
</button>
|
|
||||||
<div v-if="loginError" class="login-error">{{ loginError }}</div>
|
<div v-if="loginError" class="login-error">{{ loginError }}</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
@ -32,8 +28,20 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { onMounted } from 'vue';
|
||||||
import router from '../router';
|
import router from '../router';
|
||||||
import { auth, ws } from '../store';
|
import { auth, ws } from '../store';
|
||||||
import WebGLBackground from '../components/WebGLBackground.vue';
|
import WebGLBackground from '../components/WebGLBackground.vue';
|
||||||
const { isLoggedIn, loginToken, loginError, loggingIn, doLogin, doLogout } = auth;
|
const { isLoggedIn, loginError, loggingIn, doLogin, doLogout, handleCallback } = auth;
|
||||||
|
|
||||||
|
// Handle OIDC callback — Zitadel redirects back with ?code=...
|
||||||
|
onMounted(async () => {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const code = params.get('code');
|
||||||
|
if (code && !isLoggedIn.value) {
|
||||||
|
// Clean URL before processing
|
||||||
|
window.history.replaceState({}, '', '/login');
|
||||||
|
await handleCallback(code);
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -13,13 +13,16 @@ export default defineConfig({
|
|||||||
port: 5173,
|
port: 5173,
|
||||||
proxy: {
|
proxy: {
|
||||||
'/ws': {
|
'/ws': {
|
||||||
target: 'ws://localhost:8000',
|
target: 'wss://assay.loop42.de',
|
||||||
ws: true,
|
ws: true,
|
||||||
rewriteWsOrigin: true,
|
rewriteWsOrigin: true,
|
||||||
|
secure: true,
|
||||||
|
changeOrigin: true,
|
||||||
},
|
},
|
||||||
'/api': {
|
'/api': {
|
||||||
target: 'http://localhost:8000',
|
target: 'https://assay.loop42.de',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
|
secure: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user