diff --git a/public/components/admin.html b/public/components/admin.html index e8356bf6..68c3dae8 100644 --- a/public/components/admin.html +++ b/public/components/admin.html @@ -299,20 +299,11 @@
Chat models
-
- - -
Image models
-
- - -
-

Discovery only lists what the gateway advertises. Add any id your gateway routes — it is checked on first use, and stays in the list once saved.

diff --git a/public/components/assistant.html b/public/components/assistant.html index 87bbcdcf..1c6f9c48 100644 --- a/public/components/assistant.html +++ b/public/components/assistant.html @@ -1,5 +1,4 @@
-

AI Clinical Assistant

@@ -14,19 +13,15 @@
+ diff --git a/public/css/assistant.css b/public/css/assistant.css index e8365c0d..f86cbdd0 100644 --- a/public/css/assistant.css +++ b/public/css/assistant.css @@ -15,32 +15,32 @@ body.assistant-workspace .assistant-topbar-title h2 { position:absolute; width:1 .assistant-layout { display:grid; grid-template-columns:260px minmax(0,1fr) 330px; gap:14px; align-items:stretch; } .assistant-layout > * { min-width:0; } .assistant-history { display:grid; gap:10px; align-content:start; position:sticky; top:52px; } -.assistant-new-chat { display:flex; align-items:center; justify-content:center; gap:8px; width:100%; border:1px solid var(--purple-light); background:#faf5ff; color:var(--purple); border-radius:10px; padding:10px 12px; font-size:13px; font-weight:700; cursor:pointer; } -.assistant-new-chat:hover { border-color:var(--purple); background:var(--purple-light); } +.assistant-new-chat { display:flex; align-items:center; justify-content:center; gap:8px; width:100%; border:1px solid var(--blue-light); background:var(--blue-light); color:var(--blue); border-radius:10px; padding:10px 12px; font-size:13px; font-weight:700; cursor:pointer; } +.assistant-new-chat:hover { border-color:var(--blue); background:var(--blue-light); } .assistant-main { display:flex; flex-direction:column; min-height:calc(100vh - 200px); min-width:0; } #assistant-chat-view { display:grid; grid-template-rows:auto minmax(420px,1fr) auto; min-height:calc(100vh - 200px); min-width:0; } .assistant-toolbar { display:flex; justify-content:space-between; align-items:center; gap:10px; padding:10px 14px; border-bottom:1px solid var(--g200); background:var(--g50); } .assistant-toolbar-actions { display:flex; gap:6px; flex-wrap:wrap; } .assistant-messages { padding:16px; max-height:65vh; overflow-y:auto; overflow-x:hidden; background:linear-gradient(180deg,#fff,var(--g50)); min-width:0; } .assistant-empty { max-width:680px; margin:50px auto; text-align:center; color:var(--g500); } -.assistant-empty i { font-size:34px; color:var(--purple); margin-bottom:10px; } +.assistant-empty i { font-size:34px; color:var(--blue); margin-bottom:10px; } .assistant-empty h3 { color:var(--g800); font-size:18px; margin-bottom:6px; } .assistant-examples { display:flex; gap:8px; flex-wrap:wrap; justify-content:center; margin-top:16px; } .assistant-examples button { border:1px solid var(--g200); background:white; color:var(--g700); border-radius:999px; padding:7px 10px; font-size:12px; cursor:pointer; } .assistant-suggestion-buttons { display:flex; gap:8px; flex-wrap:wrap; margin-top:12px; } -.assistant-suggestion-buttons button { border:1px solid var(--purple-light); background:#faf5ff; color:var(--purple); border-radius:999px; padding:7px 10px; font-size:12px; cursor:pointer; text-align:left; } -.assistant-suggestion-buttons button:hover { border-color:var(--purple); background:var(--purple-light); } +.assistant-suggestion-buttons button { border:1px solid var(--blue-light); background:var(--blue-light); color:var(--blue); border-radius:999px; padding:7px 10px; font-size:12px; cursor:pointer; text-align:left; } +.assistant-suggestion-buttons button:hover { border-color:var(--blue); background:var(--blue-light); } .assistant-msg { max-width:900px; min-width:0; margin:0 0 14px; display:grid; gap:6px; position:relative; } .assistant-msg.user { margin-left:auto; max-width:760px; } .assistant-msg-label { font-size:11px; font-weight:700; color:var(--g400); text-transform:uppercase; letter-spacing:.04em; } .assistant-msg-actions { display:flex; gap:4px; opacity:0; transition:opacity .12s ease; } .assistant-msg:hover .assistant-msg-actions, .assistant-msg-actions:focus-within { opacity:1; } .assistant-msg-actions button { border:1px solid var(--g200); background:white; color:var(--g500); border-radius:7px; padding:3px 7px; font-size:11px; cursor:pointer; } -.assistant-msg-actions button:hover { color:var(--purple); border-color:var(--purple-light); } +.assistant-msg-actions button:hover { color:var(--blue); border-color:var(--blue-light); } .assistant-translate-pop { display:grid; gap:4px; border:1px solid var(--g200); background:white; border-radius:10px; padding:8px; box-shadow:var(--shadow); position:absolute; right:0; top:100%; z-index:6; min-width:200px; } .assistant-translate-providers { display:flex; gap:4px; border-bottom:1px solid var(--g100); padding-bottom:6px; } .assistant-translate-providers button { flex:1; border:1px solid var(--g200); background:var(--g50); color:var(--g600); border-radius:7px; padding:4px; font-size:11px; font-weight:700; cursor:pointer; } -.assistant-translate-providers button.active { background:var(--purple); color:white; border-color:var(--purple); } +.assistant-translate-providers button.active { background:var(--blue); color:white; border-color:var(--blue); } .assistant-translate-pop [data-assistant-translate-lang] { border:0; background:transparent; text-align:left; padding:4px 6px; font-size:12px; color:var(--g700); border-radius:6px; cursor:pointer; } .assistant-translate-pop [data-assistant-translate-lang]:hover { background:var(--g50); } .assistant-translated-sources { margin-top:10px; padding-top:8px; border-top:1px solid var(--g200); font-size:12px; color:var(--g500); } @@ -50,7 +50,7 @@ body.assistant-workspace .assistant-topbar-title h2 { position:absolute; width:1 .assistant-message-attachment img { width:64px; height:64px; object-fit:cover; border-radius:8px; border:1px solid var(--g200); background:var(--g50); display:block; } .assistant-message-attachment figcaption { font-size:10px; color:var(--g500); max-width:72px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } .assistant-bubble { border:none; border-radius:0; padding:2px 4px; background:transparent; box-shadow:none; font-size:13px; line-height:1.75; overflow-wrap:anywhere; min-width:0; max-width:100%; } -.assistant-msg.user .assistant-bubble { white-space:pre-wrap; background:var(--purple); color:white; border-radius:16px; padding:10px 14px; } +.assistant-msg.user .assistant-bubble { white-space:pre-wrap; background:var(--blue); color:white; border-radius:16px; padding:10px 14px; } .assistant-bubble h1, .assistant-bubble h2, .assistant-bubble h3 { margin:16px 0 8px; line-height:1.25; color:var(--g900); } .assistant-bubble h1:first-child, .assistant-bubble h2:first-child, .assistant-bubble h3:first-child { margin-top:0; } .assistant-bubble h1 { font-size:20px; } @@ -60,7 +60,7 @@ body.assistant-workspace .assistant-topbar-title h2 { position:absolute; width:1 .assistant-bubble p:last-child { margin-bottom:0; } .assistant-bubble ul, .assistant-bubble ol { padding-left:20px; margin:8px 0; } .assistant-bubble li { margin:4px 0; } -.assistant-bubble blockquote { margin:10px 0; padding:8px 12px; border-left:3px solid var(--purple); background:var(--purple-light); color:var(--g700); border-radius:8px; } +.assistant-bubble blockquote { margin:10px 0; padding:8px 12px; border-left:3px solid var(--blue); background:var(--blue-light); color:var(--g700); border-radius:8px; } .assistant-bubble hr { border:0; border-top:1px solid var(--g200); margin:18px 0; } .assistant-bubble img { max-width:100%; border-radius:8px; margin:10px 0; } .assistant-msg.user .assistant-bubble code { background:rgba(255,255,255,.25); color:#fff; } @@ -81,7 +81,7 @@ body.assistant-workspace .assistant-topbar-title h2 { position:absolute; width:1 .assistant-table-actions { position:absolute; top:6px; right:8px; z-index:2; display:flex; gap:4px; opacity:0; transition:opacity .12s ease; } .assistant-table-scroll:hover .assistant-table-actions, .assistant-table-scroll:focus-within .assistant-table-actions { opacity:1; } .assistant-table-actions button { border:1px solid var(--g200); background:white; color:var(--g600); border-radius:6px; padding:2px 7px; font-size:10px; font-weight:700; cursor:pointer; } -.assistant-table-actions button:hover { color:var(--purple); border-color:var(--purple-light); } +.assistant-table-actions button:hover { color:var(--blue); border-color:var(--blue-light); } .assistant-codeblock { position:relative; margin:10px 0; } .assistant-codeblock pre { margin:0; } .assistant-code-copy { position:absolute; top:6px; right:8px; border:1px solid rgba(255,255,255,.25); background:rgba(17,24,39,.7); color:#fff; border-radius:6px; padding:2px 8px; font-size:10px; font-weight:700; cursor:pointer; } @@ -92,7 +92,7 @@ body.assistant-workspace .assistant-topbar-title h2 { position:absolute; width:1 .assistant-thinking { background:linear-gradient(90deg,#fff,#f8fafc,#fff); background-size:220% 100%; animation:assistantShimmer 1.8s ease-in-out infinite; } .assistant-thinking-line { display:flex; align-items:center; gap:6px; color:var(--g800); } .assistant-thinking-detail { color:var(--g500); font-size:12px; margin-top:3px; } -.assistant-thinking-dot { width:7px; height:7px; border-radius:50%; background:var(--purple); display:inline-block; animation:assistantBounce 1.2s infinite ease-in-out; } +.assistant-thinking-dot { width:7px; height:7px; border-radius:50%; background:var(--blue); display:inline-block; animation:assistantBounce 1.2s infinite ease-in-out; } .assistant-thinking-dot:nth-child(2) { animation-delay:.15s; } .assistant-thinking-dot:nth-child(3) { animation-delay:.3s; margin-right:3px; } @keyframes assistantBounce { 0%,80%,100% { transform:scale(.65); opacity:.45; } 40% { transform:scale(1); opacity:1; } } @@ -107,9 +107,9 @@ body.assistant-workspace .assistant-topbar-title h2 { position:absolute; width:1 .assistant-attachment-remove { position:absolute; top:3px; right:3px; width:18px; height:18px; border:none; border-radius:50%; background:rgba(0,0,0,.65); color:#fff; font-size:10px; line-height:18px; cursor:pointer; padding:0; } .assistant-attachment-remove:hover { background:rgba(0,0,0,.85); } .assistant-attach { display:inline-flex; align-items:center; gap:6px; font-size:12px; color:var(--g600); cursor:pointer; padding:6px 10px; border:1px dashed var(--g300); border-radius:8px; } -.assistant-attach:hover { border-color:var(--purple); color:var(--purple); } +.assistant-attach:hover { border-color:var(--blue); color:var(--blue); } .assistant-composer textarea, .assistant-side textarea { width:100%; border:1.5px solid var(--g300); border-radius:10px; padding:10px 12px; resize:vertical; font-family:inherit; font-size:13px; outline:none; } -.assistant-composer textarea:focus, .assistant-side textarea:focus { border-color:var(--purple); box-shadow:0 0 0 3px var(--purple-light); } +.assistant-composer textarea:focus, .assistant-side textarea:focus { border-color:var(--blue); box-shadow:0 0 0 3px var(--blue-light); } .assistant-composer-footer { display:flex; justify-content:space-between; align-items:center; gap:10px; } .assistant-composer-footer .btn-generate { width:auto; margin:0; padding:9px 18px; } .assistant-composer-footer #btn-assistant-cancel[hidden] { display:none !important; } @@ -121,7 +121,7 @@ body.assistant-workspace .assistant-topbar-title h2 { position:absolute; width:1 .assistant-generated-image { display:grid; gap:8px; justify-items:start; } /* Thumbnail in the transcript; Preview (or clicking it) opens full resolution. */ .assistant-generated-image img { width:auto; max-width:min(100%,320px); max-height:240px; object-fit:contain; border-radius:10px; border:1px solid var(--g200); background:white; cursor:zoom-in; } -.assistant-generated-image img:focus-visible { outline:2px solid var(--purple); outline-offset:2px; } +.assistant-generated-image img:focus-visible { outline:2px solid var(--blue); outline-offset:2px; } @media (max-width:960px) { .assistant-generated-image img { max-width:100%; max-height:200px; } } .assistant-image-actions { display:flex; gap:8px; flex-wrap:wrap; } .assistant-image-preview-open { overflow:hidden; } @@ -137,9 +137,9 @@ body.assistant-workspace .assistant-topbar-title h2 { position:absolute; width:1 else competing for attention before the first question is asked. */ .assistant-empty h3 { color:var(--g900); font-size:26px; font-weight:600; letter-spacing:-.01em; margin:0 0 12px; } .assistant-empty-badge { display:inline-flex; align-items:center; gap:7px; margin:0; padding:7px 14px; border:1px solid var(--g200); border-radius:999px; background:white; box-shadow:var(--shadow); font-size:12.5px; color:var(--g600); } -.assistant-empty-badge i { color:var(--purple); font-size:12px; } +.assistant-empty-badge i { color:var(--blue); font-size:12px; } .assistant-examples button { border:1px solid var(--g200); background:white; color:var(--g700); border-radius:10px; padding:9px 14px; font-size:12.5px; box-shadow:var(--shadow); } -.assistant-examples button:hover { border-color:var(--purple-light); background:#faf5ff; color:var(--purple); } +.assistant-examples button:hover { border-color:var(--blue-light); background:var(--blue-light); color:var(--blue); } /* A faint graph-paper ground so the composer card reads as a raised surface. */ .assistant-messages { background-image:linear-gradient(var(--g100) 1px, transparent 1px), linear-gradient(90deg, var(--g100) 1px, transparent 1px); background-size:28px 28px; background-position:-1px -1px; } @@ -158,34 +158,13 @@ body.assistant-workspace .assistant-topbar-title h2 { position:absolute; width:1 .assistant-mode-switch { display:grid; grid-template-columns:1fr 1fr; gap:4px; background:var(--g100); border:1px solid var(--g200); border-radius:10px; padding:4px; margin-bottom:8px; } .assistant-mode-switch button { display:flex; align-items:center; justify-content:center; gap:6px; border:0; border-radius:7px; padding:7px 6px; font-size:12px; font-weight:600; color:var(--g500); background:transparent; cursor:pointer; } .assistant-mode-switch button i { font-size:11px; } -.assistant-mode-switch button.active { background:white; color:var(--purple); box-shadow:var(--shadow); } +.assistant-mode-switch button.active { background:white; color:var(--blue); box-shadow:var(--shadow); } -/* Workspace view: same ground, same card language and same spacing as the empty - state, so switching modes reads as the same app rather than a separate page. */ -.assistant-workspace-view { padding:24px 18px; overflow-y:auto; min-height:0; background-image:linear-gradient(var(--g100) 1px, transparent 1px), linear-gradient(90deg, var(--g100) 1px, transparent 1px); background-size:28px 28px; background-position:-1px -1px; } -.assistant-workspace-cards { display:grid; grid-template-columns:repeat(auto-fill,minmax(190px,1fr)); gap:10px; max-width:860px; margin:0 auto; } -.assistant-workspace-card { display:flex; align-items:center; gap:10px; border:1px solid var(--g200); background:white; border-radius:10px; padding:12px 14px; font-size:12.5px; color:var(--g700); text-align:left; cursor:pointer; box-shadow:var(--shadow); } -.assistant-workspace-card:hover { border-color:var(--purple-light); background:#faf5ff; color:var(--purple); } -.assistant-workspace-card i { width:16px; text-align:center; color:var(--g400); font-size:13px; } -.assistant-workspace-card:hover i { color:var(--purple); } /* On a phone the rail IS the drawer menu; a second copy as cards is redundant and pushes the real list off the screen. */ - -/* Workspace section in the rail: the app's own menu, one level down, the way - Claude's sidebar carries both its chats and its top-level navigation. */ -.assistant-rail-workspace { display:flex; flex-direction:column; gap:1px; margin-top:8px; padding-top:8px; border-top:1px solid var(--g100); } -.assistant-rail-section { display:flex; align-items:center; gap:8px; width:100%; border:none; background:none; cursor:pointer; padding:6px 10px; border-radius:8px; font-size:11px; font-weight:700; letter-spacing:.04em; text-transform:uppercase; color:var(--g400); text-align:left; } -.assistant-rail-section:hover { background:var(--g100); color:var(--g600); } -.assistant-rail-caret { transition:transform .15s ease; font-size:9px; } -.assistant-rail-section.is-open .assistant-rail-caret { transform:rotate(90deg); } -.assistant-rail-links { display:flex; flex-direction:column; gap:1px; } -.assistant-rail-link { display:flex; align-items:center; gap:10px; width:100%; border:none; background:none; cursor:pointer; padding:8px 10px; border-radius:8px; font-size:13px; color:var(--g700); text-align:left; } -.assistant-rail-link:hover { background:var(--g100); color:var(--g900); } -.assistant-rail-link i { width:16px; text-align:center; color:var(--g400); font-size:12px; } -.assistant-rail-link:hover i { color:var(--purple); } .assistant-saved-chat-group { display:block; } .assistant-saved-chat-group + .assistant-saved-chat-group { margin-top:10px; } .assistant-saved-chat-heading { margin:0 0 2px; padding:0 10px; font-size:11px; font-weight:700; letter-spacing:.04em; text-transform:uppercase; color:var(--g400); } @@ -196,11 +175,11 @@ body.assistant-workspace .assistant-topbar-title h2 { position:absolute; width:1 .assistant-saved-chat-meta { font-size:11px; color:var(--g500); display:block; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; } .assistant-save-actions { display:flex; gap:6px; flex-wrap:wrap; } .assistant-source { border:1px solid var(--g200); border-radius:10px; padding:10px 12px; background:white; box-shadow:var(--shadow); font-size:12px; line-height:1.5; transition:border-color .12s ease; } -.assistant-source:hover { border-color:var(--purple-light); } -.assistant-source:target { border-color:var(--purple); box-shadow:0 0 0 3px var(--purple-light); } +.assistant-source:hover { border-color:var(--blue-light); } +.assistant-source:target { border-color:var(--blue); box-shadow:0 0 0 3px var(--blue-light); } .assistant-source strong { color:var(--g800); } .assistant-source-badges { display:flex; gap:5px; flex-wrap:wrap; margin-top:6px; } -.assistant-source-badges span { border:1px solid var(--purple-light); border-radius:999px; background:#faf5ff; color:var(--purple); padding:2px 7px; font-size:10px; font-weight:700; text-transform:uppercase; letter-spacing:.03em; } +.assistant-source-badges span { border:1px solid var(--blue-light); border-radius:999px; background:var(--blue-light); color:var(--blue); padding:2px 7px; font-size:10px; font-weight:700; text-transform:uppercase; letter-spacing:.03em; } .assistant-source-meta { color:var(--g500); font-size:11px; margin-top:3px; } .assistant-source-preview { margin-top:8px; } .assistant-source-preview button { border:0; padding:0; background:transparent; cursor:pointer; width:100%; display:block; } @@ -228,7 +207,6 @@ body.assistant-workspace .assistant-topbar-title h2 { position:absolute; width:1 #btn-assistant-goback { padding:8px; } .assistant-topbar-title h2 { font-size:15px; } .assistant-status { margin-left:auto; font-size:11px; } - .assistant-toggle-history { display:none; } /* 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-main { flex:1 1 auto; min-height:0; display:flex; } @@ -262,8 +240,8 @@ body.assistant-workspace .assistant-topbar-title h2 { position:absolute; width:1 .assistant-layout .assistant-saved-chat-menu { position:absolute; right:6px; top:50%; transform:translateY(-50%); opacity:1; pointer-events:auto; } .assistant-new-chat { display:flex; align-items:center; gap:10px; width:100%; border:none; background:none; color:var(--g700); font-size:14px; padding:10px 12px; border-radius:8px; text-align:left; } .assistant-new-chat:hover { background:var(--g100); } - .assistant-create-image { flex:0 0 auto; width:40px; padding:0; justify-content:center; background:#faf5ff; color:var(--purple); border-radius:8px; } - .assistant-create-image:hover { background:var(--purple-light,#faf5ff); } + .assistant-create-image { flex:0 0 auto; width:40px; padding:0; justify-content:center; background:var(--blue-light); color:var(--blue); border-radius:8px; } + .assistant-create-image:hover { background:var(--blue-light); } .assistant-view-switch { margin:6px 8px; } .assistant-empty { margin:28px auto; max-width:100%; } .assistant-empty i { font-size:26px; } @@ -287,7 +265,6 @@ body.assistant-workspace .assistant-topbar-title h2 { position:absolute; width:1 .assistant-mic i { font-size:18px; } } - /* Patient take home modal + actions */ .assistant-takehome-modal { position: fixed; inset: 0; background: rgba(15, 23, 42, .45); display: flex; align-items: center; justify-content: center; z-index: 120; } .assistant-takehome-modal .modal-content { width: min(640px, 92vw); max-height: 82vh; display: flex; flex-direction: column; } @@ -332,9 +309,6 @@ body.assistant-workspace .assistant-layout { height: calc(100vh - 64px); min-hei body.assistant-workspace .assistant-learning-view { height:100dvh; } } /* Desktop: the saved-chats rail collapses like ChatGPT's sidebar */ -.assistant-toggle-history { border:none; background:none; color:var(--g600); padding:6px 8px; border-radius:8px; cursor:pointer; line-height:1; } -.assistant-toggle-history:hover { background:var(--g100); color:var(--g800); } -.assistant-toggle-history.is-collapsed { color:var(--purple); background:var(--purple-light); } .assistant-layout { transition:grid-template-columns .18s ease; } /* Open WebUI-style rail: the column collapses to zero and the chat takes the space, animated so the change reads as a fold rather than a jump. */ @@ -362,12 +336,11 @@ body.assistant-workspace #assistant-chat-view { min-height: 0; } .assistant-saved-chat-delete:hover { color:var(--danger, #dc2626); background:rgba(220,38,38,.08); } /* Create image entry + Images gallery (ChatGPT-style left rail) */ -/* New chat is the row; Create image rides beside it as a square icon rather than a second full-width banner competing with it. */ .assistant-rail-actions { display:flex; align-items:stretch; gap:6px; } .assistant-rail-actions .assistant-new-chat { flex:1 1 auto; min-width:0; } -.assistant-create-image { flex:0 0 auto; display:inline-flex; align-items:center; justify-content:center; width:36px; border:1px solid var(--purple-light); border-radius:10px; padding:0; background:#faf5ff; color:var(--purple); font-size:13px; cursor:pointer; } -.assistant-create-image:hover { background:var(--purple-light); border-color:var(--purple); } +.assistant-create-image { flex:0 0 auto; display:inline-flex; align-items:center; justify-content:center; width:36px; border:1px solid var(--blue-light); border-radius:10px; padding:0; background:var(--blue-light); color:var(--blue); font-size:13px; cursor:pointer; } +.assistant-create-image:hover { border-color:var(--blue); } .assistant-gallery-card { flex:0 1 auto; } .assistant-image-gallery { display:grid; grid-template-columns:repeat(auto-fill,minmax(56px,1fr)); gap:6px; padding:10px 12px; max-height:220px; overflow-y:auto; } .assistant-gallery-item { border:none; background:none; padding:0; border-radius:8px; overflow:hidden; cursor:pointer; border:1px solid var(--g200); } @@ -390,7 +363,7 @@ body.assistant-workspace #assistant-chat-view { min-height: 0; } .assistant-check { display:flex; align-items:center; justify-content:center; width:38px; height:38px; border-radius:50%; cursor:pointer; color:var(--g500); position:relative; } .assistant-check i { font-size:13px; } .assistant-check input { position:absolute; inset:0; opacity:0; cursor:pointer; } -.assistant-check:has(input:checked) { color:var(--purple); background:var(--purple-light,#faf5ff); } +.assistant-check:has(input:checked) { color:var(--blue); background:var(--blue-light); } .btn-send { width:40px; height:40px; border:none; border-radius:50%; background:var(--accent,#0f766e); color:#fff; display:flex; align-items:center; justify-content:center; cursor:pointer; font-size:16px; } .btn-send:disabled { opacity:.5; cursor:not-allowed; } .assistant-composer .assistant-muted { flex-basis:100%; } @@ -413,7 +386,7 @@ body.assistant-workspace #assistant-chat-view { min-height: 0; } .assistant-chat-menu button:hover { background:var(--g100); } .assistant-chat-menu button[data-chat-action="delete"] { color:var(--danger,#dc2626); } .assistant-chat-menu button i { width:14px; text-align:center; } -.assistant-saved-chat-rename { width:100%; border:1.5px solid var(--purple); border-radius:6px; padding:4px 6px; font-size:12px; } +.assistant-saved-chat-rename { width:100%; border:1.5px solid var(--blue); border-radius:6px; padding:4px 6px; font-size:12px; } /* Create-image popup: generation progress + full history grid */ .create-image-progress { margin-top:10px; padding:8px 10px; border-radius:8px; background:var(--g50,#f9fafb); color:var(--g600); font-size:13px; } @@ -464,33 +437,53 @@ body.assistant-workspace #assistant-chat-view { min-height: 0; } .assistant-history .card-header { padding:2px 4px; } .assistant-history .card-header h3 { font-size:11px; font-weight:700; color:var(--g500); text-transform:uppercase; letter-spacing:.04em; } +/* ── Assistant / Workspace switch ──────────────────────────────────────────── + Rendered identically in the app sidebar and the assistant rail, so moving + between them changes which pill is highlighted and nothing else. */ +.assistant-mode-switch { display:grid; grid-template-columns:1fr 1fr; gap:4px; margin:8px 12px 10px; padding:4px; background:var(--g100); border:1px solid var(--g200); border-radius:10px; } +.assistant-mode-switch button { display:flex; align-items:center; justify-content:center; gap:6px; border:0; border-radius:7px; padding:7px 6px; font-family:inherit; font-size:12px; font-weight:600; color:var(--g600); background:transparent; cursor:pointer; } +.assistant-mode-switch button i { font-size:11px; } +.assistant-mode-switch button:hover { color:var(--g900); } +.assistant-mode-switch button.active { background:white; color:var(--blue); box-shadow:var(--shadow); } +/* Inside the assistant rail the gutters already exist, so it only needs the gap. */ +.assistant-history .assistant-mode-switch { margin:0 0 8px; } -/* ── The two modes, in one place ───────────────────────────────────────────── - Everything below is driven by a single body class. These elements all carry - their own `display:` rules, so toggling the [hidden] attribute from JS was - silently overridden — which is why the chat list never came back. Owning both - modes here keeps them one interface rather than two that half-hide each other. */ +/* The one menu toggle, identical in the app sidebar and the assistant rail. */ +.assistant-menu-toggle { display:flex; align-items:center; justify-content:center; width:32px; height:32px; margin:10px 12px 0; border:1px solid var(--g200); border-radius:8px; background:white; color:var(--g500); font-size:12px; cursor:pointer; } +.assistant-menu-toggle:hover { color:var(--blue); border-color:var(--blue-light); background:var(--blue-light); } +.assistant-history .assistant-menu-toggle { margin:0 0 8px; } -/* Assistant mode: the workspace surfaces are simply absent. */ -.assistant-workspace-view, -.assistant-rail-workspace { display:none; } +/* Hidden menu: the rail collapses in both places, and the layout takes the + space back rather than leaving a gutter. */ +body.menu-hidden .sidebar { width:0; border:none; overflow:hidden; } +body.assistant-workspace.menu-hidden .assistant-layout { grid-template-columns:0 minmax(0,1fr) 330px; } +body.assistant-workspace.menu-hidden.assistant-no-citations .assistant-layout { grid-template-columns:0 minmax(0,1fr); } +body.assistant-workspace.menu-hidden .assistant-history > *:not(.assistant-menu-toggle) { display:none; } +body.assistant-workspace.menu-hidden .assistant-history { width:0; min-width:0; overflow:visible; padding:0; } +/* The toggle must survive its own collapse, or there is no way back. */ +body.assistant-workspace.menu-hidden .assistant-history .assistant-menu-toggle { position:absolute; left:8px; top:8px; z-index:5; } +@media (max-width:640px) { .assistant-menu-toggle { display:none; } body.menu-hidden .sidebar { width:auto; } } -/* Workspace mode: the chat, its list and its sources all step aside together. */ -body.assistant-mode-workspace #assistant-chat-view, -body.assistant-mode-workspace .assistant-history .card, -body.assistant-mode-workspace .assistant-create-image, -body.assistant-mode-workspace .assistant-new-chat, -body.assistant-mode-workspace .assistant-side { display:none; } +/* Workspace launcher: same ground, card language and spacing as the empty state, + so the two views are one interface. Desktop only. */ +.assistant-workspace-view { flex:1 1 auto; min-height:0; padding:24px 18px; overflow-y:auto; + background-image:linear-gradient(var(--g100) 1px, transparent 1px), linear-gradient(90deg, var(--g100) 1px, transparent 1px); + background-size:28px 28px; background-position:-1px -1px; } +.assistant-workspace-cards { display:grid; grid-template-columns:repeat(auto-fill,minmax(190px,1fr)); gap:10px; max-width:860px; margin:0 auto; } +.assistant-workspace-card { display:flex; align-items:center; gap:10px; border:1px solid var(--g200); background:white; border-radius:10px; padding:12px 14px; font-size:12.5px; color:var(--g700); text-align:left; cursor:pointer; box-shadow:var(--shadow); } +.assistant-workspace-card:hover { border-color:var(--blue-light); background:var(--blue-light); color:var(--blue); } +.assistant-workspace-card i { width:16px; text-align:center; color:var(--g400); font-size:13px; } +.assistant-workspace-card:hover i { color:var(--blue); } + +/* Both views occupy the same box, so switching never moves the page. */ +.assistant-workspace-view { display:none; } body.assistant-mode-workspace .assistant-workspace-view { display:block; } -body.assistant-mode-workspace .assistant-rail-workspace { display:flex; } - -/* The sources track goes with the panel; hiding one and keeping the other left - an empty 330px gutter. */ +body.assistant-mode-workspace #assistant-chat-view { display:none; } +body.assistant-mode-workspace .assistant-history .card, +body.assistant-mode-workspace .assistant-rail-actions, +body.assistant-mode-workspace .assistant-side { display:none; } body.assistant-mode-workspace .assistant-layout { grid-template-columns:260px minmax(0,1fr); } -body.assistant-mode-workspace .assistant-layout.history-collapsed { grid-template-columns:0 minmax(0,1fr); } - -/* Phones: the drawer already is the menu, so the card grid is redundant there - and the rail list carries workspace on its own. */ +body.assistant-mode-workspace.menu-hidden .assistant-layout { grid-template-columns:0 minmax(0,1fr); } @media (max-width:640px) { body.assistant-mode-workspace .assistant-workspace-view { display:none; } body.assistant-mode-workspace #assistant-chat-view { display:flex; } diff --git a/public/css/styles.css b/public/css/styles.css index edbd2db8..1bb90e6f 100644 --- a/public/css/styles.css +++ b/public/css/styles.css @@ -80,10 +80,6 @@ body{font-family:'Inter',system-ui,sans-serif;background:var(--g50);color:var(-- .sidebar-overlay{display:none;position:fixed;inset:0;background:rgba(0,0,0,0.45);z-index:199;} .btn-menu-toggle{display:none;} /* Sidebar collapse/expand buttons (desktop only) */ -.sidebar-pin-btn{display:flex;align-items:center;justify-content:center;padding:10px;border:none;background:none;color:var(--g400);cursor:pointer;font-size:13px;border-top:1px solid var(--g200);transition:color 0.15s;flex-shrink:0;} -.sidebar-pin-btn:hover{color:var(--blue);} -.sidebar-expand-btn{position:fixed;left:0;top:50%;transform:translateY(-50%);z-index:101;background:white;border:1.5px solid var(--g200);border-left:none;border-radius:0 8px 8px 0;padding:10px 6px;color:var(--g500);cursor:pointer;font-size:13px;box-shadow:2px 0 8px rgba(0,0,0,0.06);transition:color 0.15s;} -.sidebar-expand-btn:hover{color:var(--blue);} /* MAIN */ .main-content{flex:1;min-width:0;padding:20px 20px;} diff --git a/public/index.html b/public/index.html index eab1f51a..7efa1bad 100644 --- a/public/index.html +++ b/public/index.html @@ -197,10 +197,14 @@
- - - - diff --git a/public/js/admin/clinicalAssistant.js b/public/js/admin/clinicalAssistant.js index 33deafa1..59070dbd 100644 --- a/public/js/admin/clinicalAssistant.js +++ b/public/js/admin/clinicalAssistant.js @@ -51,38 +51,6 @@ export function initClinicalAssistantAdmin(adminEscapeHtml) { container.appendChild(row); }); } - // Discovery only returns what the gateway advertises, so an admin could never - // offer a model it does not list. Typed ids join the same checkbox list and - // persist through the normal allowed_models save. - function addAssistantModel(containerId, id) { - var container = document.getElementById(containerId); - id = String(id || '').trim(); - if (!container || !id) return false; - var existing = Array.prototype.map.call(container.querySelectorAll('input[type="checkbox"]'), function(b) { return b.value; }); - if (existing.indexOf(id) !== -1) { - var already = container.querySelector('input[value="' + (window.CSS && CSS.escape ? CSS.escape(id) : id) + '"]'); - if (already) already.checked = true; - return true; - } - var roster = containerId === 'assistant-allowed-chat-models' ? chatRoster : imageRoster; - roster.push(id); - renderAssistantCheckboxList(containerId, roster, existing.filter(function(v) { - var box = container.querySelector('input[value="' + (window.CSS && CSS.escape ? CSS.escape(v) : v) + '"]'); - return box && box.checked; - }).concat([id])); - return true; - } - - document.addEventListener('click', function(event) { - var button = event.target.closest && event.target.closest('[data-assistant-add-model]'); - if (!button) return; - var containerId = button.getAttribute('data-assistant-add-model'); - var field = document.getElementById(containerId === 'assistant-allowed-chat-models' ? 'assistant-add-chat-model' : 'assistant-add-image-model'); - if (!field) return; - if (addAssistantModel(containerId, field.value)) field.value = ''; - else if (typeof window.showToast === 'function') window.showToast('Enter a model id first', 'error'); - }); - function checkedAssistantModels(containerId) { var container = document.getElementById(containerId); if (!container) return []; diff --git a/public/js/app.js b/public/js/app.js index d9e80f77..7e442e53 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -158,6 +158,27 @@ document.addEventListener('DOMContentLoaded', function() { activateTab(fallback && fallback !== 'assistant' ? fallback : 'encounter'); }); + // Assistant / Workspace switch. The app IS workspace mode, so switching is + // navigation between two pages rather than an in-page state machine — which + // is also what keeps the two looking identical: the only difference is which + // pill is highlighted. A reload is fine; each is its own document. + document.addEventListener('click', function(event) { + var pill = event.target.closest && event.target.closest('[data-assistant-mode]'); + if (!pill) return; + var wantsAssistant = pill.getAttribute('data-assistant-mode') === 'assistant'; + var onAssistant = window.location.pathname === '/assistant'; + if (!onAssistant) { + // In the app you ARE the workspace, so only the Assistant pill navigates. + if (wantsAssistant) window.location.href = '/assistant'; + return; + } + // Inside the assistant, Workspace opens the launcher in place rather than + // reloading into whichever tab happened to be last. + if (typeof window.assistantShowWorkspaceLauncher === 'function') { + window.assistantShowWorkspaceLauncher(!wantsAssistant); + } + }); + // Expose activateTab globally so auth.js can call it after login window.activateTab = activateTab; @@ -189,33 +210,31 @@ document.addEventListener('DOMContentLoaded', function() { } // --- DESKTOP SIDEBAR COLLAPSE --- - var sidebarPinBtn = document.getElementById('btn-sidebar-pin'); - var sidebarExpandBtn = document.getElementById('btn-sidebar-expand'); - // Restore collapse state - try { - if (localStorage.getItem('ped_sidebar_collapsed') === '1') { - if (sidebar) sidebar.classList.add('collapsed'); - if (sidebarExpandBtn) sidebarExpandBtn.style.display = 'flex'; - if (sidebarPinBtn) sidebarPinBtn.style.display = 'none'; - } - } catch(e) {} - if (sidebarPinBtn) { - sidebarPinBtn.addEventListener('click', function() { - if (sidebar) sidebar.classList.add('collapsed'); - if (sidebarExpandBtn) sidebarExpandBtn.style.display = 'flex'; - if (sidebarPinBtn) sidebarPinBtn.style.display = 'none'; - try { localStorage.setItem('ped_sidebar_collapsed', '1'); } catch(e) {} - }); - } - if (sidebarExpandBtn) { - sidebarExpandBtn.addEventListener('click', function() { - if (sidebar) sidebar.classList.remove('collapsed'); - if (sidebarExpandBtn) sidebarExpandBtn.style.display = 'none'; - if (sidebarPinBtn) sidebarPinBtn.style.display = 'flex'; - try { localStorage.setItem('ped_sidebar_collapsed', '0'); } catch(e) {} + // One menu toggle, shared by the app sidebar and the assistant rail. It used + // to be a pin button plus a separate floating expand button here, and a third + // control inside the assistant — three affordances for one idea. + function syncMenuToggles(hidden) { + var label = hidden ? 'Show menu' : 'Hide menu'; + document.querySelectorAll('[data-menu-toggle]').forEach(function(button) { + button.setAttribute('aria-expanded', hidden ? 'false' : 'true'); + button.setAttribute('title', label); + button.setAttribute('aria-label', label); + button.classList.toggle('is-collapsed', hidden); }); } + var menuHidden = false; + try { menuHidden = localStorage.getItem('ped_sidebar_collapsed') === '1'; } catch (e) {} + document.body.classList.toggle('menu-hidden', menuHidden); + syncMenuToggles(menuHidden); + + document.addEventListener('click', function(event) { + if (!(event.target.closest && event.target.closest('[data-menu-toggle]'))) return; + var nowHidden = document.body.classList.toggle('menu-hidden'); + syncMenuToggles(nowHidden); + try { localStorage.setItem('ped_sidebar_collapsed', nowHidden ? '1' : '0'); } catch (e) {} + }); + // Set footer year var yearEl = document.getElementById('app-year'); if (yearEl) yearEl.textContent = new Date().getFullYear(); diff --git a/public/js/clinicalAssistant.js b/public/js/clinicalAssistant.js index 5a7b89bd..36952e15 100644 --- a/public/js/clinicalAssistant.js +++ b/public/js/clinicalAssistant.js @@ -165,72 +165,48 @@ import { // The rail mirrors the app's real tab list rather than restating it, so a tab // added, renamed or hidden in index.html shows up here with no extra work. - // Cards are the clinical work surface: account and administration tabs stay - // in the rail list, where someone looking for them will go, but they do not - // sit alongside Encounter HPI and Calculators. - var NON_WORK_TABS = ['settings', 'admin', 'docs', 'faq']; + // Clinical work only. Account and content-management tabs stay in the app + // sidebar, where someone looking for them will go; they are not work + // surfaces to launch into beside Encounter HPI. + var NON_WORK_TABS = ['settings', 'admin', 'docs', 'faq', 'cms']; - var renderWorkspaceLinks = function() { - var host = document.getElementById('assistant-workspace-links'); + var renderWorkspaceCards = function() { var cards = document.getElementById('assistant-workspace-cards'); - if (!host) return; - host.innerHTML = ''; - if (cards) cards.innerHTML = ''; + if (!cards) return; + cards.innerHTML = ''; Array.prototype.forEach.call(document.querySelectorAll('.tab-btn'), function(tab) { var name = tab.getAttribute('data-tab'); - if (!name || name === 'assistant' || tab.classList.contains('hidden')) return; + if (!name || tab.classList.contains('hidden') || NON_WORK_TABS.indexOf(name) !== -1) return; var icon = tab.querySelector('i'); var label = tab.querySelector('span'); - var link = document.createElement('button'); - link.type = 'button'; - link.className = 'assistant-rail-link'; - link.setAttribute('data-assistant-workspace-tab', name); - link.innerHTML = '' + + var card = document.createElement('button'); + card.type = 'button'; + card.className = 'assistant-workspace-card'; + card.setAttribute('data-assistant-workspace-tab', name); + card.innerHTML = '' + '' + escapeHtml(label ? label.textContent : name) + ''; - host.appendChild(link); - if (cards && NON_WORK_TABS.indexOf(name) === -1) { - var card = document.createElement('button'); - card.type = 'button'; - card.className = 'assistant-workspace-card'; - card.setAttribute('data-assistant-workspace-tab', name); - card.innerHTML = '' + - '' + escapeHtml(label ? label.textContent : name) + ''; - cards.appendChild(card); - } + cards.appendChild(card); }); }; - // Two modes, the way Home and Code swap the whole page: Assistant keeps the - // chat and its saved-chat list; Workspace replaces both the rail contents and - // the chat column with the app's own menu. - // One switch, one class. Every element this affects already carries a - // `display:` rule, and a class selector beats the UA stylesheet's - // [hidden]{display:none} — so toggling .hidden here silently did nothing and - // the chat list never came back. CSS owns what each mode shows. - var setAssistantMode = function(mode) { - var workspace = mode === 'workspace'; - if (workspace) renderWorkspaceLinks(); - document.body.classList.toggle('assistant-mode-workspace', workspace); - try { localStorage.setItem('ped_assistant_mode', workspace ? 'workspace' : 'assistant'); } catch (e) {} + // In the assistant, Workspace shows the launcher; in the app you are already + // there. Either way the switch only changes which pill is highlighted. + var showWorkspaceLauncher = function(show) { + renderWorkspaceCards(); + document.body.classList.toggle('assistant-mode-workspace', !!show); document.querySelectorAll('[data-assistant-mode]').forEach(function(button) { - var on = button.getAttribute('data-assistant-mode') === mode; + var on = (button.getAttribute('data-assistant-mode') === 'workspace') === !!show; button.classList.toggle('active', on); button.setAttribute('aria-selected', on ? 'true' : 'false'); }); }; + window.assistantShowWorkspaceLauncher = showWorkspaceLauncher; onceOnDocument('click', function(event) { - var pill = event.target.closest && event.target.closest('[data-assistant-mode]'); - if (pill) setAssistantMode(pill.getAttribute('data-assistant-mode')); - }); - onceOnDocument('click', function(event) { - var link = event.target.closest && event.target.closest('[data-assistant-workspace-tab]'); - if (!link) return; - var layout = document.getElementById('assistant-layout'); - if (layout) layout.classList.remove('mobile-chats-open'); - document.body.classList.remove('assistant-workspace'); - setAssistantMode('assistant'); // coming back lands on the chat, not an empty rail - if (typeof window.activateTab === 'function') window.activateTab(link.getAttribute('data-assistant-workspace-tab')); + var card = event.target.closest && event.target.closest('[data-assistant-workspace-tab]'); + if (!card) return; + document.body.classList.remove('assistant-workspace', 'assistant-mode-workspace'); + if (typeof window.activateTab === 'function') window.activateTab(card.getAttribute('data-assistant-workspace-tab')); }); var chatsToggle = document.getElementById('btn-assistant-chats-toggle'); diff --git a/test/admin-clinical-assistant-wiring.test.js b/test/admin-clinical-assistant-wiring.test.js index b5577762..725824eb 100644 --- a/test/admin-clinical-assistant-wiring.test.js +++ b/test/admin-clinical-assistant-wiring.test.js @@ -126,36 +126,13 @@ test('real extracted initializer displays the server default only when returned "source 'default' must not be reported as coming from the environment variable"); }); -test('an admin can add a model id discovery never returned', async t => { - const dom = new JSDOM(read('public/components/admin.html')); - browserGlobals(t, dom, async () => ({ ok: true, json: async () => ({ - success: true, config: [], models: [{ id: 'discovered-model', name: 'Discovered' }], - conversationBudget: { limit: 120000, source: 'default' } - }) }), []); - const { initClinicalAssistantAdmin } = await import(pathToFileURL(path.join(root, 'public/js/admin/clinicalAssistant.js')).href); - initClinicalAssistantAdmin(value => value); - document.dispatchEvent(new dom.window.CustomEvent('tabChanged', { detail: { tab: 'admin' } })); - await tick(); - - const list = document.getElementById('assistant-allowed-chat-models'); - const field = document.getElementById('assistant-add-chat-model'); - assert.ok(field, 'there is somewhere to type a model id'); - const before = list.querySelectorAll('input[type="checkbox"]').length; - - field.value = 'openrouter-deepseek-v4-pro'; - document.querySelector('[data-assistant-add-model="assistant-allowed-chat-models"]').click(); - await tick(); - - const values = Array.from(list.querySelectorAll('input[type="checkbox"]')).map(b => b.value); - assert.ok(values.includes('openrouter-deepseek-v4-pro'), 'the typed id joins the list'); - assert.equal(values.length, before + 1); - const added = list.querySelector('input[value="openrouter-deepseek-v4-pro"]'); - assert.equal(added.checked, true, 'and is enabled, since that is why it was added'); - assert.equal(field.value, '', 'the field clears for the next one'); - - // Adding the same id twice must not duplicate the row. - field.value = 'openrouter-deepseek-v4-pro'; - document.querySelector('[data-assistant-add-model="assistant-allowed-chat-models"]').click(); - await tick(); - assert.equal(list.querySelectorAll('input[value="openrouter-deepseek-v4-pro"]').length, 1); +test('model availability comes from discovery, never hand-typed', () => { + const html = read('public/components/admin.html'); + // A hand-typed id cannot be verified here, so the roster is exactly what the + // gateway advertises. + assert.doesNotMatch(html, /assistant-add-chat-model|assistant-add-image-model/, 'no manual entry field'); + assert.doesNotMatch(html, /data-assistant-add-model/, 'and no Add button'); + assert.match(html, /id="assistant-allowed-chat-models"/, 'the discovered roster stays'); + assert.match(html, /id="assistant-allowed-image-models"/); }); + diff --git a/test/assistant-workspace-layout.test.js b/test/assistant-workspace-layout.test.js index 63a5047c..2a9c5f06 100644 --- a/test/assistant-workspace-layout.test.js +++ b/test/assistant-workspace-layout.test.js @@ -89,30 +89,6 @@ test('Learning Hub is a top-level main-menu entry, not part of the assistant wor assert.match(indexHtml, /
<\/section>/, 'assistant tab is a clean standalone section'); }); -test('a workspace entry leaves the assistant and lands back on the chat next time', t => { - const app = workspace(t); - app.document.dispatchEvent(new app.window.CustomEvent('tabChanged', { detail: { tab: 'assistant' } })); - app.document.querySelector('[data-assistant-mode="workspace"]').click(); - assert.ok(app.document.body.classList.contains('assistant-mode-workspace'), 'workspace mode engaged'); - - const entry = app.document.querySelector('#assistant-workspace-links [data-assistant-workspace-tab="notes"]'); - assert.ok(entry, 'the rail lists the app tabs'); - // Cards are the clinical work surface; the rail keeps everything. - const cardTabs = [...app.document.querySelectorAll('#assistant-workspace-cards [data-assistant-workspace-tab]')] - .map(b => b.getAttribute('data-assistant-workspace-tab')); - assert.deepEqual(cardTabs, ['notes', 'calculators'], 'Settings is not a card; hidden Admin is nowhere'); - assert.ok(app.document.querySelector('#assistant-workspace-links [data-assistant-workspace-tab="settings"]'), - 'but Settings is still reachable from the rail'); - assert.equal(app.document.querySelector('[data-assistant-workspace-tab="admin"]'), null, - 'a hidden tab stays hidden in both'); - entry.click(); - assert.deepEqual(app.activated, ['notes'], 'opens that part of the app'); - assert.ok(!app.document.body.classList.contains('assistant-workspace'), 'and leaves the assistant chrome'); - // Returning must show the chat, not the empty rail the mode was left in. - assert.ok(!app.document.body.classList.contains('assistant-mode-workspace'), - 'the assistant reopens on its chat rather than in workspace mode'); -}); - test('the assistant tab keeps its clean structure next to the restored learning tab', () => { const indexHtml = read('public/index.html'); assert.match(indexHtml, /