fix: phone menu links, Workspace pill and sources column; image model discovery
Phone - The desktop collapse rules (52px icon rail) were unscoped, and menu-hidden is remembered across viewports, so after collapsing on a desktop the phone sheet opened with its links, the Assistant/Workspace switch and the account name all display:none. Scoped them to min-width:769px. - Workspace pill: the sheet now closes on tap, and the launcher is no longer display:none below 640px, so the pill opens the workspace instead of toggling a view that could never appear. - The menu button has its own 48px row instead of floating over the page; layouts subtract --mobile-bar (0px on desktop, so desktop is unchanged). - Sources open as their own scrollable band under that row, and the toggle sits in the row, instead of a sheet/FAB covering the composer. - The sheet uses dvh, so the account card is not pushed below the visible screen by the browser toolbar. Admin - Image Generation card beside TTS and STT: search the gateway's image models and test one. No Set button: an image model is chosen per workflow. It searches only on demand, so opening Admin still makes one discovery call. - Account card sits at the same height in every rail state; the model availability list points to where new models are added. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W4rptBNvn6RYieQw54GXNS
This commit is contained in:
parent
924ce8716c
commit
b8d2153cca
8 changed files with 314 additions and 61 deletions
|
|
@ -296,6 +296,7 @@
|
|||
<strong class="admin-row-label">Model availability</strong>
|
||||
<div style="flex:1;display:flex;flex-direction:column;gap:8px;min-width:0;">
|
||||
<p style="margin:0;font-size:12px;color:var(--g500);">Models users may select in the Clinical Assistant. Leave a list empty to keep only the configured model.</p>
|
||||
<p style="margin:0;font-size:12px;color:var(--g500);">This list is what the gateway already offers. To make a new model appear here, add it in <a href="#" data-admin-jump="admin-model-search" style="color:var(--blue);font-weight:600;">AI Model Management</a> above — search the gateway, press <strong>+</strong>, and it shows up in this list.</p>
|
||||
<div>
|
||||
<div style="font-size:12px;font-weight:600;color:var(--g600);margin-bottom:2px;">Chat models</div>
|
||||
<div id="assistant-allowed-chat-models" aria-label="Chat models available to the Clinical Assistant"></div>
|
||||
|
|
@ -444,6 +445,39 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Image Model Management ─────────────────────────────────── -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-image"></i> Image Generation</h3>
|
||||
<span id="admin-image-provider-badge" style="font-size:11px;padding:2px 8px;border-radius:10px;background:var(--g100);color:var(--g600);">Gateway</span>
|
||||
</div>
|
||||
<div style="padding:16px;display:flex;flex-direction:column;gap:14px;">
|
||||
<div id="admin-image-info" style="font-size:12px;color:var(--g500);">Image models come from the LiteLLM gateway. Search to list them, and test one before assigning it to a workflow.</div>
|
||||
|
||||
<!-- Search image models from provider -->
|
||||
<div>
|
||||
<label style="font-size:12px;font-weight:600;color:var(--g600);display:block;margin-bottom:8px;">Search Models from Provider</label>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;">
|
||||
<input type="text" id="admin-image-search" placeholder="Filter models (e.g. dall-e, imagen, flux)" style="font-size:13px;padding:6px 10px;border:1px solid var(--g300);border-radius:6px;flex:1;min-width:180px;">
|
||||
<button id="btn-discover-image" class="btn-sm btn-primary" type="button"><i class="fas fa-magnifying-glass"></i> Search</button>
|
||||
</div>
|
||||
<div id="admin-image-discovered" style="margin-top:8px;display:flex;flex-direction:column;gap:4px;max-height:300px;overflow-y:auto;"></div>
|
||||
<p id="admin-image-discover-hint" style="font-size:12px;color:var(--g500);margin:6px 0 0;">Click Search to list the image models your gateway advertises. Unlike a voice, an image model has no single default: pick which workflow uses it under <strong>Clinical Assistant / Learning</strong>, and tick it there to offer it to users in chat.</p>
|
||||
</div>
|
||||
|
||||
<!-- Test image generation -->
|
||||
<div style="border-top:1px solid var(--g100);padding-top:12px;">
|
||||
<label style="font-size:12px;font-weight:600;color:var(--g600);display:block;margin-bottom:8px;">Test Generation</label>
|
||||
<p style="font-size:12px;color:var(--g500);margin:0 0 8px;">Press Test on a model above, or type an id the gateway does not advertise.</p>
|
||||
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;">
|
||||
<input type="text" id="admin-image-test-model" placeholder="Model id to test" style="font-size:13px;padding:6px 10px;border:1px solid var(--g300);border-radius:6px;flex:1;min-width:180px;">
|
||||
<button id="btn-test-image-model" class="btn-sm btn-primary" type="button"><i class="fas fa-vial"></i> Test</button>
|
||||
</div>
|
||||
<div id="admin-image-test-result" role="status" style="font-size:12px;color:var(--g500);margin-top:8px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Embedding Models ───────────────────────────────────────── -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
|
|
|
|||
|
|
@ -182,7 +182,10 @@
|
|||
.assistant-source-modal-meta { font-size:13px; font-weight:700; color:var(--g800); margin-bottom:10px; }
|
||||
.assistant-source-modal-excerpt { white-space:pre-wrap; font-size:12px; line-height:1.6; color:var(--g600); background:var(--g50); border:1px solid var(--g200); border-radius:10px; padding:10px 12px; max-height:52vh; overflow:auto; margin-bottom:10px; }
|
||||
/* Saved chats occupy the full left rail and scroll inside it */
|
||||
.assistant-history { display:flex; flex-direction:column; height:100%; min-height:0; }
|
||||
/* 8px below the account card, matching .sidebar-nav in the app. Without it the
|
||||
card sat flush against the bottom edge here and 8px higher there, so the name
|
||||
appeared to jump when you opened a workspace tab. The higher position wins. */
|
||||
.assistant-history { display:flex; flex-direction:column; height:100%; min-height:0; padding-bottom:8px; }
|
||||
.assistant-history .card { flex:1 1 auto; display:flex; flex-direction:column; min-height:0; overflow:hidden; }
|
||||
#assistant-saved-chats { flex:1 1 auto; min-height:0; overflow-y:auto; overscroll-behavior:contain; }
|
||||
@media (max-width: 960px) { .assistant-layout { grid-template-columns:minmax(0,1fr) 300px; } .assistant-history { position:static; grid-column:1 / -1; grid-template-columns:auto 1fr; align-items:start; } .assistant-new-chat { grid-column:1; } .assistant-view-switch { grid-column:1; } .assistant-history .card { grid-column:2; grid-row:1 / span 2; } .assistant-main { min-height:auto; } #assistant-chat-view { min-height:auto; grid-template-rows:minmax(0,1fr) auto; } }
|
||||
|
|
@ -193,20 +196,26 @@
|
|||
#btn-assistant-goback { padding:8px; }
|
||||
.assistant-status { margin-left:auto; font-size:11px; }
|
||||
/* The assistant fills the viewport exactly; nothing behind can scroll */
|
||||
.assistant-layout { display:flex; flex-direction:column; height:100dvh; min-height:0; overflow:hidden; }
|
||||
.assistant-layout { display:flex; flex-direction:column; height:calc(100dvh - var(--mobile-bar, 0px)); min-height:0; overflow:hidden; }
|
||||
.assistant-main { flex:1 1 auto; min-height:0; display:flex; }
|
||||
.assistant-main.card { box-shadow:none; border:none; margin:0; }
|
||||
#assistant-chat-view { flex:1 1 auto; min-height:0; height:auto; display:flex; flex-direction:column; }
|
||||
.assistant-messages { flex:1 1 auto; min-height:0; max-height:none; overflow-y:auto; -webkit-overflow-scrolling:touch; padding:8px 14px 12px; }
|
||||
/* Sources are not deleted on a phone; they slide up as a sheet, because a
|
||||
citation with nowhere to lead is worse than a narrow column. */
|
||||
.assistant-side { position:fixed; left:0; right:0; bottom:0; z-index:140; display:flex;
|
||||
max-height:76vh; margin:0; padding:0; background:white; border-top:1px solid var(--g200);
|
||||
border-radius:16px 16px 0 0; box-shadow:0 -12px 40px rgba(15,23,42,.18);
|
||||
transform:translateY(102%); transition:transform .2s ease; }
|
||||
body.assistant-sources-open .assistant-side { transform:translateY(0); }
|
||||
.assistant-side .card { flex:1 1 auto; min-height:0; border:none; box-shadow:none; border-radius:0; }
|
||||
.assistant-sources { padding-bottom:calc(16px + env(safe-area-inset-bottom)); }
|
||||
/* Sources are not deleted on a phone, and they do not cover the conversation
|
||||
either: opening them gives the column its own band at the foot of the flex
|
||||
layout, so the chat shrinks above it and the composer stays reachable.
|
||||
A fixed overlay sat on top of the question box, which is what made the
|
||||
sheet feel like it had nowhere to live. */
|
||||
.assistant-side { order:-1; flex:0 0 auto; display:none; min-height:0; margin:0; padding:0;
|
||||
background:white; border-bottom:1px solid var(--g200); border-radius:0;
|
||||
box-shadow:0 8px 24px rgba(15,23,42,.08); overflow:hidden; }
|
||||
body.assistant-sources-open .assistant-side { display:flex; height:min(46vh,340px); }
|
||||
.assistant-side .card { flex:1 1 auto; min-height:0; display:flex; flex-direction:column;
|
||||
border:none; box-shadow:none; border-radius:0; }
|
||||
.assistant-side .card-header { flex:0 0 auto; }
|
||||
/* The band is fixed height, so the list inside it is what scrolls. */
|
||||
.assistant-sources { flex:1 1 auto; min-height:0; overflow-y:auto; -webkit-overflow-scrolling:touch;
|
||||
padding-bottom:calc(12px + env(safe-area-inset-bottom)); }
|
||||
/* Slim toolbar: icon-only actions in one row */
|
||||
/* Saved chats: a fixed drawer overlaying the chat */
|
||||
.assistant-history { position:fixed; left:0; top:0; bottom:0; width:min(85vw,320px); z-index:130; background:white; border-right:1px solid var(--g100); box-shadow:0 0 40px rgba(15,23,42,.14); transform:translateX(-105%); transition:transform .18s ease; display:flex; flex-direction:column; align-items:stretch; padding:10px 8px; overflow-y:auto; overflow-x:hidden; overscroll-behavior:contain; -webkit-overflow-scrolling:touch; gap:2px; border-radius:0; }
|
||||
|
|
@ -288,11 +297,11 @@
|
|||
|
||||
/* Full-screen Open WebUI workspace: the assistant replaces the app chrome */
|
||||
/* No header bar anywhere now, so the layout owns the whole viewport. */
|
||||
body.assistant-workspace .assistant-layout { height: 100vh; min-height: 0; grid-template-rows: minmax(0, 1fr); overflow: hidden; }
|
||||
body.assistant-workspace .assistant-layout { height: calc(100vh - var(--mobile-bar, 0px)); min-height: 0; grid-template-rows: minmax(0, 1fr); overflow: hidden; }
|
||||
/* Phones: workspace mode hides .app-header, so there is no 64px to subtract and
|
||||
the mobile flex layout must survive the more specific workspace selectors. */
|
||||
@media (max-width: 640px) {
|
||||
body.assistant-workspace .assistant-layout { height:100dvh; display:flex; flex-direction:column; grid-template-rows:none; overflow:hidden; }
|
||||
body.assistant-workspace .assistant-layout { height:calc(100dvh - var(--mobile-bar, 0px)); display:flex; flex-direction:column; grid-template-rows:none; overflow:hidden; }
|
||||
|
||||
}
|
||||
/* Desktop: the saved-chats rail collapses like ChatGPT's sidebar */
|
||||
|
|
@ -453,9 +462,15 @@ body.assistant-mode-workspace .assistant-rail-actions,
|
|||
body.assistant-mode-workspace .assistant-side { display:none; }
|
||||
body.assistant-mode-workspace .assistant-layout { grid-template-columns:210px minmax(0,1fr); }
|
||||
body.assistant-mode-workspace.menu-hidden .assistant-layout { grid-template-columns:0 minmax(0,1fr); }
|
||||
/* Workspace mode works on a phone too: the launcher is the same grid of cards,
|
||||
one per column. Suppressing it here is what made the Workspace pill do
|
||||
nothing at all on mobile. */
|
||||
@media (max-width:640px) {
|
||||
body.assistant-mode-workspace .assistant-workspace-view { display:none; }
|
||||
body.assistant-mode-workspace #assistant-chat-view { display:flex; }
|
||||
.assistant-workspace-cards { grid-template-columns:1fr; }
|
||||
.assistant-workspace-view { padding:14px 12px calc(14px + env(safe-area-inset-bottom)); }
|
||||
/* No sources or chat rail belong to the launcher. */
|
||||
body.assistant-mode-workspace .assistant-side,
|
||||
body.assistant-mode-workspace .assistant-sources-fab { display:none; }
|
||||
}
|
||||
|
||||
/* Rail menu in workspace mode: the app's own .tab-btn shape, so the assistant
|
||||
|
|
@ -527,10 +542,12 @@ body.assistant-mode-workspace .assistant-rail-workspace { display:flex; }
|
|||
ever shown on a phone, and only when the answer actually has citations. */
|
||||
.assistant-sources-fab { display:none; }
|
||||
@media (max-width:640px) {
|
||||
.assistant-sources-fab { position:fixed; right:12px; bottom:calc(84px + env(safe-area-inset-bottom)); z-index:120;
|
||||
display:inline-flex; align-items:center; gap:7px; padding:9px 14px; border:1px solid var(--g200);
|
||||
/* It sits in the top bar's row, beside the menu button, so it never covers
|
||||
the conversation — floating above the composer, it did. */
|
||||
.assistant-sources-fab { position:fixed; right:8px; top:8px; z-index:81;
|
||||
display:inline-flex; align-items:center; gap:6px; height:32px; padding:0 12px; border:1px solid var(--g200);
|
||||
border-radius:999px; background:white; color:var(--blue); font-family:inherit; font-size:12.5px;
|
||||
font-weight:600; box-shadow:0 6px 20px rgba(15,23,42,.16); cursor:pointer; }
|
||||
font-weight:600; cursor:pointer; }
|
||||
.assistant-sources-fab[hidden] { display:none; }
|
||||
body.assistant-sources-open .assistant-sources-fab { opacity:0; pointer-events:none; }
|
||||
body.assistant-sources-open .assistant-sources-fab { background:var(--blue-light); border-color:var(--blue-light); }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -59,8 +59,10 @@ body{font-family:'Inter',system-ui,sans-serif;background:var(--g50);color:var(--
|
|||
.menu-icon-btn:hover { background:var(--g100); color:var(--g800); }
|
||||
.assistant-history .menu-head { padding:0 0 8px; }
|
||||
.btn-menu-toggle { display:none; }
|
||||
:root { --mobile-bar:0px; }
|
||||
@media(max-width:768px){
|
||||
.btn-menu-toggle { display:inline-flex; align-items:center; justify-content:center; position:fixed; top:10px; left:10px; z-index:150; width:38px; height:38px; border:1px solid var(--g200); border-radius:10px; background:white; color:var(--g700); font-size:15px; box-shadow:var(--shadow); }
|
||||
.btn-menu-toggle { display:inline-flex; align-items:center; justify-content:center; position:fixed; top:6px; left:8px; z-index:81; width:36px; height:36px; border:none; border-radius:10px; background:none; color:var(--g700); font-size:16px; }
|
||||
.btn-menu-toggle:hover { background:var(--g100); }
|
||||
}
|
||||
|
||||
/* APP BODY LAYOUT */
|
||||
|
|
@ -1293,39 +1295,44 @@ button, a, .btn-sm, .btn-generate, .btn-send, .tab-btn, input, textarea, select,
|
|||
/* Collapsed = a narrow icon rail, not a disappearance. The brand mark, the
|
||||
toggle, search and the account avatar stay reachable, so nothing has to be
|
||||
floated over the content and there is always a way back. */
|
||||
body.menu-hidden .sidebar { width:52px; }
|
||||
body.menu-hidden .sidebar-tabs,
|
||||
body.menu-hidden .assistant-mode-switch,
|
||||
body.menu-hidden .sidebar-section-label,
|
||||
body.menu-hidden .menu-brand h1,
|
||||
body.menu-hidden .account-id,
|
||||
body.menu-hidden .account-chevron { display:none; }
|
||||
/* Collapsed, the head stacks. Every child is the same fixed-size centred box on
|
||||
one axis — the brand kept its expanded `margin-right:auto`, which pushed the
|
||||
stethoscope off the line the two buttons sat on. */
|
||||
body.menu-hidden .menu-head { flex-direction:column; align-items:center; gap:6px; padding:10px 0 8px; }
|
||||
body.menu-hidden .menu-head > * { width:32px; height:32px; margin:0; flex:0 0 auto;
|
||||
display:flex; align-items:center; justify-content:center; }
|
||||
body.menu-hidden .menu-brand { gap:0; }
|
||||
body.menu-hidden .menu-brand i { font-size:18px; }
|
||||
body.menu-hidden .account-card { padding:8px 0; }
|
||||
body.menu-hidden .account-card-btn { justify-content:center; padding:6px 0; }
|
||||
/* The collapsed rail is 52px and .sidebar clips its overflow, so a menu laid out
|
||||
inside it is cut off — which hid Settings, FAQ and Log out behind the avatar.
|
||||
Fixed positioning takes it out of that box entirely. */
|
||||
body.menu-hidden .account-menu { position:fixed; left:56px; right:auto; bottom:12px; width:210px; }
|
||||
@media (min-width:769px) {
|
||||
/* Collapsing is a desktop idea: it turns the rail into a 52px strip.
|
||||
The phone sheet has nothing to collapse — the same control closes it —
|
||||
so none of this may leak past the breakpoint. */
|
||||
body.menu-hidden .sidebar { width:52px; }
|
||||
body.menu-hidden .sidebar-tabs,
|
||||
body.menu-hidden .assistant-mode-switch,
|
||||
body.menu-hidden .sidebar-section-label,
|
||||
body.menu-hidden .menu-brand h1,
|
||||
body.menu-hidden .account-id,
|
||||
body.menu-hidden .account-chevron { display:none; }
|
||||
/* Collapsed, the head stacks. Every child is the same fixed-size centred box on
|
||||
one axis — the brand kept its expanded `margin-right:auto`, which pushed the
|
||||
stethoscope off the line the two buttons sat on. */
|
||||
body.menu-hidden .menu-head { flex-direction:column; align-items:center; gap:6px; padding:10px 0 8px; }
|
||||
body.menu-hidden .menu-head > * { width:32px; height:32px; margin:0; flex:0 0 auto;
|
||||
display:flex; align-items:center; justify-content:center; }
|
||||
body.menu-hidden .menu-brand { gap:0; }
|
||||
body.menu-hidden .menu-brand i { font-size:18px; }
|
||||
body.menu-hidden .account-card { padding:8px 0; }
|
||||
body.menu-hidden .account-card-btn { justify-content:center; padding:6px 0; }
|
||||
/* The collapsed rail is 52px and .sidebar clips its overflow, so a menu laid out
|
||||
inside it is cut off — which hid Settings, FAQ and Log out behind the avatar.
|
||||
Fixed positioning takes it out of that box entirely. */
|
||||
body.menu-hidden .account-menu { position:fixed; left:56px; right:auto; bottom:12px; width:210px; }
|
||||
|
||||
/* The assistant rail collapses to the same strip rather than to nothing. */
|
||||
body.assistant-workspace.menu-hidden .assistant-layout { grid-template-columns:52px minmax(0,1fr) 330px; }
|
||||
body.assistant-workspace.menu-hidden.assistant-no-citations .assistant-layout,
|
||||
body.assistant-mode-workspace.menu-hidden .assistant-layout { grid-template-columns:52px minmax(0,1fr); }
|
||||
body.assistant-workspace.menu-hidden .assistant-history { width:auto; min-width:0; overflow:visible; padding:0; }
|
||||
body.assistant-workspace.menu-hidden .assistant-history .card,
|
||||
body.assistant-workspace.menu-hidden .assistant-rail-workspace,
|
||||
body.assistant-workspace.menu-hidden .assistant-rail-actions,
|
||||
body.assistant-workspace.menu-hidden .assistant-mode-switch { display:none; }
|
||||
|
||||
@media (max-width:640px) { .assistant-menu-toggle { display:none; } body.menu-hidden .sidebar { width:auto; } }
|
||||
/* The assistant rail collapses to the same strip rather than to nothing. */
|
||||
body.assistant-workspace.menu-hidden .assistant-layout { grid-template-columns:52px minmax(0,1fr) 330px; }
|
||||
body.assistant-workspace.menu-hidden.assistant-no-citations .assistant-layout,
|
||||
body.assistant-mode-workspace.menu-hidden .assistant-layout { grid-template-columns:52px minmax(0,1fr); }
|
||||
/* padding:0 would drop the 8px below the account card, so the avatar would sit
|
||||
lower when collapsed than when expanded. Keep the bottom gap. */
|
||||
body.assistant-workspace.menu-hidden .assistant-history { width:auto; min-width:0; overflow:visible; padding:0 0 8px; }
|
||||
body.assistant-workspace.menu-hidden .assistant-history .card,
|
||||
body.assistant-workspace.menu-hidden .assistant-rail-workspace,
|
||||
body.assistant-workspace.menu-hidden .assistant-rail-actions,
|
||||
body.assistant-workspace.menu-hidden .assistant-mode-switch { display:none; }
|
||||
}
|
||||
|
||||
/* Signed-out preview: the assistant is usable, everything that needs an account
|
||||
is not shown rather than shown and refused. */
|
||||
|
|
@ -1403,7 +1410,23 @@ body.assistant-preview .account-card { display:none; }
|
|||
.sidebar-section-label{ padding:12px 8px 4px; }
|
||||
.tab-btn{ padding:12px 10px; font-size:14.5px; border-radius:10px; border-left:none; }
|
||||
.tab-btn.active{ border-left:none; }
|
||||
.account-card{ padding:10px 12px calc(10px + env(safe-area-inset-bottom)); }
|
||||
.account-card{ padding:8px 12px calc(8px + env(safe-area-inset-bottom)); }
|
||||
.account-name{ font-size:14px; }
|
||||
.account-email{ font-size:12px; }
|
||||
|
||||
/* The menu button gets a row of its own. Floating over the page it sat on
|
||||
whatever the page began with: the assistant's title, the questions, a card
|
||||
header. The strip is fixed; the app starts below it. It belongs to
|
||||
#main-app, so the sign-in screen never shows it. z-index stays under every
|
||||
modal (the image preview is 99) and over page content (composer 60). */
|
||||
:root{ --mobile-bar:48px; }
|
||||
#main-app::before{ content:''; position:fixed; top:0; left:0; right:0; height:var(--mobile-bar);
|
||||
z-index:80; background:white; border-bottom:1px solid var(--g200); }
|
||||
#main-app{ padding-top:var(--mobile-bar); }
|
||||
.app-body, body.assistant-workspace .app-body{ min-height:calc(100dvh - var(--mobile-bar)); }
|
||||
.busy-bar{ top:var(--mobile-bar); }
|
||||
/* 100vh on a phone includes the area behind the browser's toolbar, so the
|
||||
foot of the sheet — the account card — sat lower than the visible screen
|
||||
and read as "too far down". dvh is the height actually visible. */
|
||||
.sidebar{ height:100vh; height:100dvh; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -909,6 +909,19 @@ initImageSettings();
|
|||
|
||||
container.innerHTML = '<p style="color:var(--g400);font-size:13px;"><i class="fas fa-spinner fa-spin"></i> Querying provider API...</p>';
|
||||
if (hint) hint.style.display = 'none';
|
||||
// The Clinical Assistant lists models but cannot add them; adding happens in
|
||||
// AI Model Management, one section up. Without this the two sections look
|
||||
// unrelated and there is no visible way to get a new model into the list.
|
||||
document.addEventListener('click', function(event) {
|
||||
var jump = event.target.closest && event.target.closest('[data-admin-jump]');
|
||||
if (!jump) return;
|
||||
event.preventDefault();
|
||||
var target = document.getElementById(jump.getAttribute('data-admin-jump'));
|
||||
if (!target) return;
|
||||
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
target.focus({ preventScroll: true });
|
||||
});
|
||||
|
||||
|
||||
fetch('/api/admin/config/models/discover?q=' + encodeURIComponent(search), { headers: getAuthHeaders() })
|
||||
.then(function(r) { return r.json(); })
|
||||
|
|
@ -1462,6 +1475,100 @@ initImageSettings();
|
|||
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// ADMIN IMAGE MODEL MANAGEMENT
|
||||
// ============================================================
|
||||
// Unlike TTS and STT there is no single default to Set: an image model is
|
||||
// chosen per workflow, so discovery here ends in a Test, and the workflow
|
||||
// pickers in the Clinical Assistant card consume the same discovery call.
|
||||
// Nothing loads on tab entry: the workflow pickers already make the one
|
||||
// discovery call opening Admin needs, so this card asks only when searched.
|
||||
{
|
||||
document.addEventListener('click', function(e) {
|
||||
if (e.target.closest('#btn-discover-image')) discoverImageModels();
|
||||
if (e.target.closest('#btn-test-image-model')) testImageModel((document.getElementById('admin-image-test-model') || {}).value || '');
|
||||
var pick = e.target.closest('.admin-image-test-btn');
|
||||
if (pick) {
|
||||
var field = document.getElementById('admin-image-test-model');
|
||||
if (field) field.value = pick.dataset.id;
|
||||
testImageModel(pick.dataset.id, pick);
|
||||
}
|
||||
});
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.target.id === 'admin-image-search' && e.key === 'Enter') { e.preventDefault(); discoverImageModels(); }
|
||||
if (e.target.id === 'admin-image-test-model' && e.key === 'Enter') { e.preventDefault(); testImageModel(e.target.value || ''); }
|
||||
});
|
||||
|
||||
const esc = adminEscapeHtml;
|
||||
|
||||
function discoverImageModels() {
|
||||
var search = (document.getElementById('admin-image-search') || {}).value || '';
|
||||
var container = document.getElementById('admin-image-discovered');
|
||||
var hint = document.getElementById('admin-image-discover-hint');
|
||||
if (!container) return;
|
||||
container.innerHTML = '<p style="font-size:13px;color:var(--g400);"><i class="fas fa-spinner fa-spin"></i> Querying provider...</p>';
|
||||
if (hint) hint.style.display = 'none';
|
||||
|
||||
fetch('/api/admin/config/image-models/discover?q=' + encodeURIComponent(search), { headers: getAuthHeaders() })
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (!data.success) {
|
||||
container.innerHTML = '<p style="font-size:13px;color:var(--red);">Error: ' + esc(data.error || 'Unknown') + '</p>';
|
||||
return;
|
||||
}
|
||||
var items = data.models || [];
|
||||
var badge = document.getElementById('admin-image-provider-badge');
|
||||
if (badge && !search) badge.textContent = data.count + ' available';
|
||||
if (items.length === 0) {
|
||||
container.innerHTML = '<p style="font-size:13px;color:var(--g400);">No image models found' + (search ? ' matching "' + esc(search) + '"' : '') + '</p>';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = '<p style="font-size:12px;color:var(--g500);margin:0 0 6px;">Found ' + data.count + ' image model' + (data.count === 1 ? '' : 's') + '</p>' +
|
||||
items.map(function(m) {
|
||||
return '<div style="display:flex;align-items:center;gap:8px;padding:5px 8px;border-radius:6px;background:var(--g50);font-size:13px;">' +
|
||||
'<button class="btn-sm btn-primary admin-image-test-btn" type="button" data-id="' + esc(m.id) + '" style="padding:2px 8px;font-size:11px;">Test</button>' +
|
||||
'<span style="flex:1;min-width:0;overflow-wrap:anywhere;">' + esc(m.name || m.id) + '</span>' +
|
||||
'<span style="font-size:10px;color:var(--g400);">' + esc(m.source || '') + '</span>' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
})
|
||||
.catch(function(err) {
|
||||
container.innerHTML = '<p style="font-size:13px;color:var(--red);">Request failed: ' + esc(err.message) + '</p>';
|
||||
});
|
||||
}
|
||||
|
||||
function testImageModel(modelId, btn) {
|
||||
var id = String(modelId || '').trim();
|
||||
var result = document.getElementById('admin-image-test-result');
|
||||
if (!id) {
|
||||
if (result) result.innerHTML = '<span style="color:var(--red);">Enter or pick a model id first.</span>';
|
||||
return;
|
||||
}
|
||||
if (btn) adminSetButtonText(btn, '...', true);
|
||||
if (result) result.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Generating with ' + esc(id) + '... this can take a minute.';
|
||||
fetch('/api/admin/config/image-models/test', {
|
||||
method: 'POST', headers: getAuthHeaders(), body: JSON.stringify({ modelId: id })
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (btn) adminSetButtonText(btn, 'Test', false);
|
||||
if (!data.success) {
|
||||
if (result) result.innerHTML = '<span style="color:var(--red);">' + esc(data.error || 'Image test failed') + '</span>';
|
||||
return;
|
||||
}
|
||||
var src = data.imageUrl || (data.base64 ? ('data:image/png;base64,' + data.base64) : '');
|
||||
if (result) {
|
||||
result.innerHTML = esc(id) + ' works (' + data.duration + ' ms).' +
|
||||
(src ? '<div style="margin-top:8px;"><img src="' + esc(src) + '" alt="Test image generated by ' + esc(id) + '" style="max-width:180px;border:1px solid var(--g200);border-radius:8px;"></div>' : '');
|
||||
}
|
||||
})
|
||||
.catch(function(err) {
|
||||
if (btn) adminSetButtonText(btn, 'Test', false);
|
||||
if (result) result.innerHTML = '<span style="color:var(--red);">Request failed: ' + esc(err.message) + '</span>';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// ADMIN EMBEDDING MODELS MANAGEMENT
|
||||
// ============================================================
|
||||
|
|
|
|||
|
|
@ -189,6 +189,13 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
}
|
||||
});
|
||||
|
||||
// On a phone the switch is inside the open sheet, so the view it just changed
|
||||
// is behind that sheet — tapping Workspace looked like it did nothing.
|
||||
document.addEventListener('click', function(event) {
|
||||
if (!(event.target.closest && event.target.closest('[data-assistant-mode]'))) return;
|
||||
if (window.innerWidth <= 768 && sidebar) sidebar.classList.remove('open');
|
||||
});
|
||||
|
||||
// Account card. Rendered in both menus from the same markup, so whichever view
|
||||
// you are in ends the same way. Settings and Log out used to be unlabelled
|
||||
// icon buttons in the header, which the slim bar no longer explains.
|
||||
|
|
|
|||
|
|
@ -136,3 +136,30 @@ test('model availability comes from discovery, never hand-typed', () => {
|
|||
assert.match(html, /id="assistant-allowed-image-models"/);
|
||||
});
|
||||
|
||||
|
||||
// Image models had a discovery ENDPOINT but no UI, so the only way to reach a
|
||||
// newly added gateway model was to already know its id.
|
||||
test('image model discovery sits beside TTS and STT, and ends in a test', () => {
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const root = path.join(__dirname, '..');
|
||||
const html = fs.readFileSync(path.join(root, 'public/components/admin.html'), 'utf8');
|
||||
const js = fs.readFileSync(path.join(root, 'public/js/admin.js'), 'utf8');
|
||||
|
||||
// Same shape as the two discovery cards it now sits between.
|
||||
['admin-image-search', 'btn-discover-image', 'admin-image-discovered', 'admin-image-discover-hint']
|
||||
.forEach(id => assert.ok(html.includes('id="' + id + '"'), 'admin.html has #' + id));
|
||||
assert.ok(html.indexOf('id="admin-image-search"') > html.indexOf('id="admin-stt-search"'),
|
||||
'the image card follows the STT card');
|
||||
assert.ok(html.indexOf('id="admin-image-search"') < html.indexOf('id="admin-embed-search"'),
|
||||
'and precedes embeddings');
|
||||
|
||||
assert.match(js, /if \(e\.target\.closest\('#btn-discover-image'\)\) discoverImageModels\(\);/);
|
||||
assert.match(js, /'\/api\/admin\/config\/image-models\/discover\?q=' \+ encodeURIComponent\(search\)/,
|
||||
'it calls the endpoint that already existed');
|
||||
// Unlike a voice there is no single default to Set: an image model belongs to
|
||||
// a workflow, so discovery ends in Test rather than Set.
|
||||
assert.match(js, /admin-image-test-btn/);
|
||||
assert.doesNotMatch(js, /admin-image-set-btn/);
|
||||
assert.match(js, /'\/api\/admin\/config\/image-models\/test'/);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -37,8 +37,12 @@ test('sources are reachable on a phone instead of deleted', () => {
|
|||
const mobile = css.slice(css.indexOf('@media (max-width: 640px)'));
|
||||
// display:none left every citation pointing at nothing.
|
||||
assert.doesNotMatch(mobile.slice(0, 2000), /\.assistant-side \{ display:none; \}/);
|
||||
assert.match(mobile, /body\.assistant-sources-open \.assistant-side \{ transform:translateY\(0\); \}/,
|
||||
'they slide up as a sheet');
|
||||
assert.match(mobile, /body\.assistant-sources-open \.assistant-side \{ display:flex; height:min\(46vh,340px\); \}/,
|
||||
'they open into a band of their own rather than covering the composer');
|
||||
assert.doesNotMatch(mobile, /\.assistant-side \{ position:fixed;/,
|
||||
'a fixed overlay sat on top of the question box');
|
||||
const sources = mobile.slice(mobile.indexOf('.assistant-sources {'));
|
||||
assert.match(sources.slice(0, 200), /overflow-y:auto/, 'the list inside the band scrolls');
|
||||
|
||||
const js = read('public/js/clinicalAssistant.js');
|
||||
assert.match(js, /button\.hidden = !\(citationsOn && count > 0\);/,
|
||||
|
|
@ -81,3 +85,34 @@ test('the account card sits at the same height in both rails', () => {
|
|||
assert.match(css, /\.account-card-btn \{ min-height:44px; \}/,
|
||||
'and the same row height in both');
|
||||
});
|
||||
|
||||
test('a desktop collapse does not follow the user onto a phone', () => {
|
||||
const css = read('public/css/styles.css');
|
||||
// menu-hidden is remembered in localStorage across viewports. Unscoped, its
|
||||
// 52px-rail rules hid .sidebar-tabs, the Assistant/Workspace switch and the
|
||||
// account name INSIDE the phone sheet — the menu opened onto a strip of icons
|
||||
// and every link looked broken.
|
||||
const start = css.indexOf('@media (min-width:769px) {\n /* Collapsing is a desktop idea');
|
||||
assert.ok(start !== -1, 'the collapse rules are wrapped in a desktop-only query');
|
||||
const block = css.slice(start, css.indexOf('\n}\n', start));
|
||||
assert.match(block, /body\.menu-hidden \.sidebar-tabs/, 'including the one that hid the links');
|
||||
assert.match(block, /body\.menu-hidden \.assistant-mode-switch/, 'and the one that hid Workspace');
|
||||
assert.match(block, /body\.menu-hidden \.account-menu \{ position:fixed;/);
|
||||
// Nothing outside that query may still collapse the phone sheet.
|
||||
const outside = css.slice(0, start) + css.slice(css.indexOf('\n}\n', start));
|
||||
assert.doesNotMatch(outside, /body\.menu-hidden \.sidebar \{ width:/);
|
||||
});
|
||||
|
||||
test('the Workspace pill works on a phone', () => {
|
||||
const app = read('public/js/app.js');
|
||||
// The switch lives inside the sheet, so the view it changes is behind the
|
||||
// sheet until the sheet closes.
|
||||
assert.match(app, /\[data-assistant-mode\]'\)\)\) return;\s*\n\s*if \(window\.innerWidth <= 768 && sidebar\) sidebar\.classList\.remove\('open'\);/);
|
||||
|
||||
const css = read('public/css/assistant.css');
|
||||
// The launcher used to be display:none below 640px, so the pill toggled a
|
||||
// view that could never appear.
|
||||
assert.doesNotMatch(css, /body\.assistant-mode-workspace \.assistant-workspace-view \{ display:none; \}/);
|
||||
assert.match(css, /\.assistant-workspace-cards \{ grid-template-columns:1fr; \}/,
|
||||
'and its cards stack into one column');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -141,7 +141,7 @@ test('one menu toggle serves both the app sidebar and the assistant rail', () =>
|
|||
|
||||
test('mobile drawer rows carry no chat icons and keep the options menu', () => {
|
||||
const css = read('public/css/assistant.css');
|
||||
assert.ok(/\.assistant-layout \{ display:flex; flex-direction:column; height:100dvh;/.test(css), 'mobile layout locks the viewport');
|
||||
assert.ok(/\.assistant-layout \{ display:flex; flex-direction:column; height:calc\(100dvh - var\(--mobile-bar, 0px\)\);/.test(css), 'mobile layout locks the viewport, below the menu row');
|
||||
assert.ok(/\.assistant-side \{ display:none; \}/.test(css), 'no sources column under the chat on mobile');
|
||||
});
|
||||
|
||||
|
|
@ -200,8 +200,8 @@ test('workspace mode does not override the mobile layout with a desktop header o
|
|||
// specific than the .assistant-layout rule inside @media (max-width:640px), so
|
||||
// the mobile rule needs a matching-specificity override to win at all.
|
||||
// Both now subtract the app header, which the assistant keeps rather than hides.
|
||||
const override = css.indexOf('body.assistant-workspace .assistant-layout { height:100dvh;');
|
||||
const desktop = css.indexOf('body.assistant-workspace .assistant-layout { height: 100vh');
|
||||
const override = css.indexOf('body.assistant-workspace .assistant-layout { height:calc(100dvh - var(--mobile-bar, 0px));');
|
||||
const desktop = css.indexOf('body.assistant-workspace .assistant-layout { height: calc(100vh - var(--mobile-bar, 0px))');
|
||||
assert.ok(desktop > 0, 'the desktop workspace rule exists');
|
||||
assert.ok(override > desktop, 'the mobile override comes after it, so it wins');
|
||||
const rule = css.slice(override, css.indexOf('}', override));
|
||||
|
|
@ -297,7 +297,7 @@ test('chrome rendered on the main app page is styled by the stylesheet that page
|
|||
|
||||
for (const cls of ['assistant-mode-switch', 'assistant-menu-toggle']) {
|
||||
assert.match(index, new RegExp('class="[^"]*' + cls), cls + ' is rendered on the app page');
|
||||
assert.ok(styles.includes('.' + cls + ' {'), cls + ' must be defined in styles.css');
|
||||
assert.match(styles, new RegExp('\\.' + cls + '[\\s{]'), cls + ' must be defined in styles.css');
|
||||
// Defined in both files, the two copies drift and one silently wins.
|
||||
assert.ok(!assistantCss.includes('.' + cls + ' {'), cls + ' must not be duplicated in assistant.css');
|
||||
}
|
||||
|
|
@ -413,8 +413,10 @@ test('there is no header bar; the brand heads the menu instead', () => {
|
|||
assert.doesNotMatch(css, stale, 'no stale header offset in styles.css');
|
||||
assert.doesNotMatch(read('public/css/assistant.css'), stale, 'none in assistant.css');
|
||||
}
|
||||
assert.match(read('public/css/assistant.css'), /body\.assistant-workspace \.assistant-layout \{ height: 100vh;/,
|
||||
// The only offset left is the phone's menu row, a variable that is 0px on desktop.
|
||||
assert.match(read('public/css/assistant.css'), /body\.assistant-workspace \.assistant-layout \{ height: calc\(100vh - var\(--mobile-bar, 0px\)\);/,
|
||||
'the layout owns the full viewport');
|
||||
assert.match(css, /:root \{ --mobile-bar:0px; \}/, 'which is zero outside the phone breakpoint');
|
||||
});
|
||||
|
||||
test('every view sits in the same shell, so opening a menu item is not a new app', () => {
|
||||
|
|
@ -453,7 +455,8 @@ test('the rail scrolls in both modes, not only after leaving for the app', () =>
|
|||
assert.match(css, /#assistant-saved-chats \{ flex:1 1 auto; min-height:0; overflow-y:auto/,
|
||||
'and so does the saved-chat list');
|
||||
// The rail itself is the flex column that makes those two scroll.
|
||||
assert.match(css, /\.assistant-history \{ display:flex; flex-direction:column; height:100%; min-height:0; \}/);
|
||||
assert.match(css, /\.assistant-history \{ display:flex; flex-direction:column; height:100%; min-height:0; padding-bottom:8px; \}/,
|
||||
'the rail is the flex column that makes those scroll, with the same bottom gap as the app');
|
||||
});
|
||||
|
||||
test('the menu can always be reopened after it is hidden', () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue