mirror of
https://github.com/imartinez/privateGPT.git
synced 2026-07-16 17:00:12 +00:00
* feat: add bundle to remove * fix: spaces * feat: add xml render as skill spec * feat: add skill volume root to cache * fix: deduplicate values * fix: change the mount path to skill_id * fix: use different paths * feat: add principal (cherry picked from commit 5db64fe721d5706440ce9f342b1388ffe742bc16) # Conflicts: # private_gpt/components/code_execution/base.py # private_gpt/components/code_execution/code_execution_component.py # private_gpt/components/code_execution/local.py # private_gpt/components/environment/manager.py # private_gpt/components/tools/builders/bash_tool_builder.py # private_gpt/components/tools/builders/text_editor_tool_builder.py # private_gpt/components/tools/processors/bash_processor.py * fix: mypy ... * fix: config * fix: sandbox config * feat: add forward cookies * fix: loop * feat: add present server * feat: add feature flag for tools * refactor: move principal to another better place * feat: add api key principal * docs: fix docs * docs: add present server * fix: principal * fix: mypy * fix: avoid to block the loop * fix: blocks in expansion * fix: remove maximum concurrent users ... * fix: multiplexer * fix: readers * fix: more fixes ... * fix: impl * feat: tool scheduler * feat: add adaptative * feat: add chat worker * fix: config * fix: max * feat: add chat/tools workers * fix: mypy * feat: add generic scheduler * fix: get result * feat: do serializable the tool executor * fix: tools * fix: config * fix: config * fix: args * fix: config * fix: serializer * Revert "fix: blocks in expansion" This reverts commita2110f94a8. * fix: unify all logic * feat: add ingestion scheduler * fix: settings * fix: config * feat: add arq worker to chat * fix: arq worker * fix: add nest * fix: mypy * fix: await * fix: script stress * fix: tokenizer * fix: chat scheduler * fix: mypy * fix: add async tokenizer * fix: improve condense * fix: tool scheduler * feat: add initial real async chat worker * fix: mypy * fix: do resumable local executor ... ... ... fix: revert usleess changes fix: remove parent chat job fix: refactor fix: loop ref: rename models fix: chat engine fix: mypy ... ... ... fix: fix deps * fix: tests * fix: tests * ... * fix: stream * fix: config * fix: scheduler * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Handle PGPT_WORKER_MODE=celery in health check worker status * fix: cancel * fix: arch * fix: test ingestion * fix: deserialization of chat messages * fix: broken results * fix: mypy * fix: test * fix: config * fix: remove arq tool worker * fix: output cls * fix: preserve early resumable tool callbacks * fix: preserve async tool result order * refactor: address worker PR review comments * fix: mypy * test: colocate ARQ chat enqueue coverage * fix: remove redis from tests * fix: mypy * fix: tests * test: isolate chat mocks and cancellation timing * fix: tests * fix: tests * fix: test (cherry picked from commitf8ee460af2) * fix: worker config * test: remove flaky chat cancellation assertion (cherry picked from commit1115ff2349) # Conflicts: # tests/server/chat/test_chat_routes.py * fix: emit chat pings from stream listeners * fix: don't duplciate the output * fix: rss memory ... * fix: pass the args * fix: threads * fix: websearch --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
7575 lines
276 KiB
HTML
7575 lines
276 KiB
HTML
<!doctype html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||
<link href="favicon.ico" rel="icon">
|
||
<title>PrivateGPT Workbench</title>
|
||
<style>
|
||
:root {
|
||
color-scheme: dark;
|
||
--bg: #090b12;
|
||
--bg-start: #081427;
|
||
--bg-mid: #15121c;
|
||
--bg-end: #130b05;
|
||
--bg-glow-a: rgba(36, 125, 190, 0.58);
|
||
--bg-glow-b: rgba(212, 148, 63, 0.38);
|
||
--bg-glow-c: rgba(92, 50, 116, 0.34);
|
||
--text: #f7f7fb;
|
||
--muted: rgba(255, 255, 255, 0.58);
|
||
--muted-strong: rgba(255, 255, 255, 0.74);
|
||
--faint: rgba(255, 255, 255, 0.36);
|
||
--glass: rgba(255, 255, 255, 0.1);
|
||
--glass-soft: rgba(255, 255, 255, 0.07);
|
||
--glass-strong: rgba(255, 255, 255, 0.16);
|
||
--surface-tint: rgba(41, 65, 92, 0.18);
|
||
--surface-tint-strong: rgba(41, 65, 92, 0.3);
|
||
--sidebar-wash-top: rgba(29, 36, 52, 0.9);
|
||
--sidebar-wash-bottom: rgba(42, 61, 82, 0.74);
|
||
--sidebar-glow: rgba(62, 132, 190, 0.22);
|
||
--border: rgba(255, 255, 255, 0.24);
|
||
--border-soft: rgba(255, 255, 255, 0.14);
|
||
--shadow: 0 24px 80px rgba(0, 0, 0, 0.38);
|
||
--button-primary-text: #11131b;
|
||
--button-primary-border: rgba(255, 255, 255, 0.46);
|
||
--button-primary-bg-start: #ffffff;
|
||
--button-primary-bg-mid: #d7e8ff;
|
||
--button-primary-bg-end: #f3c17f;
|
||
--button-primary-shadow: 0 14px 32px rgba(0, 0, 0, 0.22);
|
||
--danger: #ff9f9f;
|
||
--ok: #91e8bd;
|
||
--warn: #ffd18a;
|
||
--accent: #f0a247;
|
||
--blue: #70b7ff;
|
||
--radius-xl: 32px;
|
||
--radius-lg: 24px;
|
||
--radius-md: 16px;
|
||
--radius-sm: 10px;
|
||
--sidebar: 286px;
|
||
--font: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||
}
|
||
|
||
* {
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
html,
|
||
body {
|
||
width: 100%;
|
||
min-height: 100%;
|
||
margin: 0;
|
||
}
|
||
|
||
body {
|
||
font-family: var(--font);
|
||
color: var(--text);
|
||
background:
|
||
radial-gradient(circle at 17% 78%, var(--bg-glow-a), transparent 36%),
|
||
radial-gradient(circle at 83% 16%, var(--bg-glow-b), transparent 31%),
|
||
radial-gradient(circle at 53% 42%, var(--bg-glow-c), transparent 40%),
|
||
linear-gradient(135deg, var(--bg-start) 0%, var(--bg-mid) 42%, var(--bg-end) 100%);
|
||
overflow: hidden;
|
||
letter-spacing: 0;
|
||
}
|
||
|
||
body[data-theme-mode="light"] {
|
||
color-scheme: light;
|
||
}
|
||
|
||
body::before {
|
||
content: "";
|
||
position: fixed;
|
||
inset: 0;
|
||
pointer-events: none;
|
||
opacity: 0.24;
|
||
background-image: radial-gradient(rgba(255, 255, 255, 0.18) 0.65px, transparent 0.65px);
|
||
background-size: 3px 3px;
|
||
mix-blend-mode: overlay;
|
||
z-index: 0;
|
||
}
|
||
|
||
body[data-theme-mode="light"]::before {
|
||
opacity: 0.14;
|
||
mix-blend-mode: multiply;
|
||
}
|
||
|
||
button,
|
||
input,
|
||
textarea,
|
||
select {
|
||
font: inherit;
|
||
}
|
||
|
||
button {
|
||
color: inherit;
|
||
cursor: pointer;
|
||
}
|
||
|
||
* {
|
||
scrollbar-width: thin;
|
||
scrollbar-color: rgba(255, 255, 255, 0.15) transparent;
|
||
}
|
||
|
||
*::-webkit-scrollbar {
|
||
width: 5px;
|
||
height: 5px;
|
||
}
|
||
|
||
*::-webkit-scrollbar-track {
|
||
background: transparent;
|
||
}
|
||
|
||
*::-webkit-scrollbar-thumb {
|
||
background: rgba(255, 255, 255, 0.13);
|
||
border-radius: 99px;
|
||
}
|
||
|
||
*::-webkit-scrollbar-thumb:hover {
|
||
background: rgba(255, 255, 255, 0.26);
|
||
}
|
||
|
||
*::-webkit-scrollbar-corner {
|
||
background: transparent;
|
||
}
|
||
|
||
.app {
|
||
position: relative;
|
||
z-index: 1;
|
||
display: grid;
|
||
grid-template-columns: var(--sidebar) minmax(0, 1fr);
|
||
width: 100vw;
|
||
height: 100vh;
|
||
padding: 16px;
|
||
gap: 16px;
|
||
}
|
||
|
||
.glass {
|
||
background:
|
||
linear-gradient(135deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0.055));
|
||
border: 1px solid var(--border);
|
||
box-shadow: var(--shadow);
|
||
backdrop-filter: blur(22px);
|
||
-webkit-backdrop-filter: blur(22px);
|
||
}
|
||
|
||
.sidebar {
|
||
display: flex;
|
||
flex-direction: column;
|
||
min-width: 0;
|
||
min-height: 0;
|
||
padding: 22px 16px;
|
||
border-radius: 0 var(--radius-xl) var(--radius-xl) 0;
|
||
overflow: hidden;
|
||
contain: paint;
|
||
isolation: isolate;
|
||
background:
|
||
linear-gradient(135deg, rgba(255, 255, 255, 0.13), rgba(255, 255, 255, 0.055)),
|
||
linear-gradient(180deg, var(--sidebar-wash-top), var(--sidebar-wash-bottom));
|
||
backdrop-filter: none;
|
||
-webkit-backdrop-filter: none;
|
||
}
|
||
|
||
.sidebar::before {
|
||
content: "";
|
||
position: absolute;
|
||
inset: 0;
|
||
z-index: -1;
|
||
pointer-events: none;
|
||
background:
|
||
radial-gradient(circle at 0% 100%, var(--sidebar-glow), transparent 58%),
|
||
linear-gradient(90deg, rgba(255, 255, 255, 0.05), transparent 45%);
|
||
opacity: 0.82;
|
||
}
|
||
|
||
.sidebar-spacer {
|
||
flex: 0 0 auto;
|
||
min-height: 12px;
|
||
}
|
||
|
||
.github-widget {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 10px;
|
||
min-height: 42px;
|
||
margin: 6px 0 0;
|
||
padding: 9px 12px;
|
||
border: 1px solid var(--border-soft);
|
||
border-radius: 15px;
|
||
color: var(--muted-strong);
|
||
text-decoration: none;
|
||
background: rgba(255, 255, 255, 0.07);
|
||
transition: background 150ms ease, border-color 150ms ease, color 150ms ease;
|
||
}
|
||
|
||
.github-widget:hover {
|
||
color: var(--text);
|
||
border-color: var(--border);
|
||
background: rgba(255, 255, 255, 0.12);
|
||
}
|
||
|
||
.github-widget-main,
|
||
.github-widget-stars {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
min-width: 0;
|
||
}
|
||
|
||
.github-widget svg {
|
||
width: 17px;
|
||
height: 17px;
|
||
flex: 0 0 auto;
|
||
}
|
||
|
||
.github-widget-name {
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.github-widget-stars {
|
||
flex: 0 0 auto;
|
||
font-size: 13px;
|
||
font-weight: 760;
|
||
color: rgba(255, 255, 255, 0.86);
|
||
}
|
||
|
||
.production-notice-button {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
width: 100%;
|
||
min-height: 40px;
|
||
margin: 8px 0 0;
|
||
padding: 9px 12px;
|
||
border: 1px solid rgba(255, 159, 159, 0.24);
|
||
border-radius: 15px;
|
||
color: rgba(255, 225, 225, 0.86);
|
||
background: rgba(255, 159, 159, 0.07);
|
||
cursor: pointer;
|
||
transition: background 150ms ease, border-color 150ms ease, color 150ms ease;
|
||
}
|
||
|
||
.production-notice-button:hover {
|
||
color: var(--text);
|
||
border-color: rgba(255, 159, 159, 0.4);
|
||
background: rgba(255, 159, 159, 0.11);
|
||
}
|
||
|
||
.production-notice-button svg {
|
||
width: 17px;
|
||
height: 17px;
|
||
flex: 0 0 auto;
|
||
}
|
||
|
||
.production-notice-button span:last-child {
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
font-weight: 720;
|
||
}
|
||
|
||
.logo {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: flex-start;
|
||
margin: 0 10px 24px;
|
||
}
|
||
|
||
.logo img {
|
||
display: block;
|
||
width: 210px;
|
||
height: auto;
|
||
}
|
||
|
||
|
||
.logo-caption {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 2px;
|
||
width: 210px;
|
||
max-width: 100%;
|
||
text-align: center;
|
||
}
|
||
|
||
.logo-caption strong {
|
||
display: none;
|
||
font-size: 13px;
|
||
letter-spacing: 0.02em;
|
||
}
|
||
|
||
.logo-caption span {
|
||
font-size: 12px;
|
||
color: var(--muted);
|
||
}
|
||
|
||
.nav-block {
|
||
display: grid;
|
||
gap: 8px;
|
||
margin-bottom: 24px;
|
||
}
|
||
|
||
.nav-button,
|
||
.chat-row,
|
||
.icon-button,
|
||
.pill,
|
||
.ghost-button,
|
||
.primary-button {
|
||
border: 1px solid transparent;
|
||
background: transparent;
|
||
cursor: pointer;
|
||
transition: background 150ms ease, border-color 150ms ease, transform 150ms ease, opacity 150ms ease;
|
||
}
|
||
|
||
.nav-button,
|
||
.chat-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
width: 100%;
|
||
max-width: 100%;
|
||
min-width: 0;
|
||
min-height: 44px;
|
||
padding: 10px 12px;
|
||
border-radius: 15px;
|
||
color: var(--muted-strong);
|
||
text-align: left;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.sidebar-spacer + .nav-button,
|
||
#apiDebuggerNav + .nav-button {
|
||
margin-bottom: 6px;
|
||
}
|
||
|
||
.nav-button:hover,
|
||
.chat-row:hover,
|
||
.pill:hover,
|
||
.ghost-button:hover,
|
||
.icon-button:hover {
|
||
background: rgba(255, 255, 255, 0.09);
|
||
border-color: var(--border-soft);
|
||
}
|
||
|
||
.nav-button.active,
|
||
.chat-row.active {
|
||
color: var(--text);
|
||
background: rgba(255, 255, 255, 0.14);
|
||
border-color: var(--border);
|
||
}
|
||
|
||
.nav-button svg,
|
||
.chat-row svg {
|
||
width: 18px;
|
||
height: 18px;
|
||
flex: 0 0 auto;
|
||
opacity: 0.86;
|
||
}
|
||
|
||
.sidebar-label {
|
||
margin: 6px 12px 10px;
|
||
color: var(--muted);
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
letter-spacing: 0.08em;
|
||
text-transform: uppercase;
|
||
}
|
||
|
||
.chat-list {
|
||
display: grid;
|
||
gap: 6px;
|
||
max-width: 100%;
|
||
padding-right: 2px;
|
||
}
|
||
|
||
.chat-row {
|
||
position: relative;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.chat-row-title {
|
||
flex: 1;
|
||
display: block;
|
||
min-width: 0;
|
||
max-width: 100%;
|
||
overflow: hidden;
|
||
white-space: nowrap;
|
||
text-overflow: ellipsis;
|
||
}
|
||
|
||
.row-menu {
|
||
position: absolute;
|
||
right: 8px;
|
||
display: none;
|
||
gap: 4px;
|
||
flex: 0 0 auto;
|
||
}
|
||
|
||
.chat-row:hover .row-menu,
|
||
.chat-row:focus-within .row-menu {
|
||
display: flex;
|
||
}
|
||
|
||
.chat-row:hover .chat-row-title,
|
||
.chat-row:focus-within .chat-row-title {
|
||
padding-right: 60px;
|
||
}
|
||
|
||
.mini-button {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 24px;
|
||
height: 24px;
|
||
padding: 0;
|
||
color: var(--muted-strong);
|
||
background: rgba(255, 255, 255, 0.08);
|
||
border: 1px solid var(--border-soft);
|
||
border-radius: 8px;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.main {
|
||
min-width: 0;
|
||
min-height: 0;
|
||
border-radius: var(--radius-xl);
|
||
overflow: hidden;
|
||
}
|
||
|
||
.screen {
|
||
display: none;
|
||
width: 100%;
|
||
height: 100%;
|
||
min-height: 0;
|
||
}
|
||
|
||
.screen.active {
|
||
display: flex;
|
||
}
|
||
|
||
.context-screen {
|
||
flex-direction: column;
|
||
padding: 24px;
|
||
gap: 18px;
|
||
overflow: auto;
|
||
}
|
||
|
||
.context-header,
|
||
.chat-header {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
justify-content: space-between;
|
||
gap: 20px;
|
||
}
|
||
|
||
h1,
|
||
h2,
|
||
h3,
|
||
p {
|
||
margin: 0;
|
||
}
|
||
|
||
h1 {
|
||
font-size: 26px;
|
||
line-height: 1.2;
|
||
font-weight: 750;
|
||
}
|
||
|
||
h2 {
|
||
font-size: 18px;
|
||
line-height: 1.3;
|
||
}
|
||
|
||
h3 {
|
||
font-size: 15px;
|
||
line-height: 1.25;
|
||
}
|
||
|
||
.subtle {
|
||
color: var(--muted);
|
||
line-height: 1.5;
|
||
}
|
||
|
||
.settings-card,
|
||
.context-panel,
|
||
.debug-panel,
|
||
.composer,
|
||
.message-bubble,
|
||
.menu-panel,
|
||
.model-dropdown,
|
||
.modal-card {
|
||
background:
|
||
linear-gradient(135deg, rgba(255, 255, 255, 0.14), rgba(255, 255, 255, 0.055));
|
||
border: 1px solid var(--border);
|
||
box-shadow: 0 18px 60px rgba(0, 0, 0, 0.28);
|
||
backdrop-filter: blur(22px);
|
||
-webkit-backdrop-filter: blur(22px);
|
||
}
|
||
|
||
.settings-card {
|
||
display: grid;
|
||
grid-template-columns: minmax(210px, 0.42fr) minmax(260px, 1fr);
|
||
gap: 18px;
|
||
padding: 18px;
|
||
border-radius: var(--radius-lg);
|
||
}
|
||
|
||
.settings-stack {
|
||
display: grid;
|
||
gap: 16px;
|
||
}
|
||
|
||
.color-grid,
|
||
.feature-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
gap: 12px;
|
||
align-items: start;
|
||
}
|
||
|
||
.color-field {
|
||
display: grid;
|
||
gap: 8px;
|
||
}
|
||
|
||
.color-field input[type="color"] {
|
||
width: 100%;
|
||
min-height: 44px;
|
||
padding: 6px;
|
||
border-radius: 14px;
|
||
background: rgba(255, 255, 255, 0.07);
|
||
}
|
||
|
||
.feature-grid .toggle-row {
|
||
min-height: 48px;
|
||
}
|
||
|
||
.generator-bar {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 12px;
|
||
align-items: center;
|
||
}
|
||
|
||
.generator-status {
|
||
min-height: 20px;
|
||
color: var(--muted);
|
||
}
|
||
|
||
.risk-list {
|
||
display: grid;
|
||
gap: 10px;
|
||
margin-top: 16px;
|
||
padding-left: 18px;
|
||
color: var(--muted-strong);
|
||
line-height: 1.48;
|
||
}
|
||
|
||
.risk-list li::marker {
|
||
color: var(--danger);
|
||
}
|
||
|
||
.form-grid {
|
||
display: grid;
|
||
gap: 12px;
|
||
}
|
||
|
||
.field {
|
||
display: grid;
|
||
gap: 7px;
|
||
}
|
||
|
||
.field.inline {
|
||
grid-template-columns: minmax(140px, 0.38fr) minmax(180px, 1fr);
|
||
align-items: center;
|
||
}
|
||
|
||
.field label,
|
||
.toggle-label {
|
||
color: var(--muted-strong);
|
||
font-size: 13px;
|
||
font-weight: 650;
|
||
}
|
||
|
||
.field small {
|
||
color: var(--muted);
|
||
line-height: 1.4;
|
||
}
|
||
|
||
input,
|
||
textarea,
|
||
select {
|
||
width: 100%;
|
||
color: var(--text);
|
||
background: rgba(10, 11, 18, 0.28);
|
||
border: 1px solid var(--border-soft);
|
||
border-radius: 14px;
|
||
outline: none;
|
||
padding: 11px 13px;
|
||
}
|
||
|
||
#settingsScreen input:not([type="checkbox"]):not([type="color"])::placeholder,
|
||
#settingsScreen textarea::placeholder {
|
||
color: rgba(255, 255, 255, 0.72);
|
||
opacity: 1;
|
||
}
|
||
|
||
textarea {
|
||
min-height: 110px;
|
||
resize: vertical;
|
||
line-height: 1.45;
|
||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
||
font-size: 13px;
|
||
}
|
||
|
||
textarea.prose-input {
|
||
min-height: 130px;
|
||
font-family: var(--font);
|
||
font-size: 14px;
|
||
}
|
||
|
||
textarea.textarea-tall {
|
||
min-height: 320px;
|
||
}
|
||
|
||
input:focus,
|
||
textarea:focus,
|
||
select:focus {
|
||
border-color: rgba(255, 255, 255, 0.46);
|
||
background: rgba(10, 11, 18, 0.38);
|
||
}
|
||
|
||
.password-field {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
background: rgba(10, 11, 18, 0.28);
|
||
border: 1px solid var(--border-soft);
|
||
border-radius: 14px;
|
||
padding: 0 6px 0 0;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.password-field input {
|
||
min-width: 0;
|
||
border: 0;
|
||
background: transparent;
|
||
padding-right: 0;
|
||
}
|
||
|
||
.password-field .icon-button {
|
||
flex: 0 0 34px;
|
||
width: 34px;
|
||
min-height: 34px;
|
||
height: 34px;
|
||
border-radius: 10px;
|
||
}
|
||
|
||
.tabs {
|
||
display: flex;
|
||
gap: 8px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.pill {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
min-height: 38px;
|
||
padding: 8px 12px;
|
||
border-radius: 999px;
|
||
color: var(--muted-strong);
|
||
background: rgba(255, 255, 255, 0.08);
|
||
border-color: var(--border-soft);
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.pill.active {
|
||
color: var(--text);
|
||
background: rgba(255, 255, 255, 0.18);
|
||
border-color: rgba(255, 255, 255, 0.34);
|
||
}
|
||
|
||
[data-icon] {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
flex: 0 0 auto;
|
||
line-height: 0;
|
||
}
|
||
|
||
.pill svg,
|
||
.ghost-button svg,
|
||
.primary-button svg,
|
||
.icon-button svg,
|
||
[data-icon] svg {
|
||
width: 17px;
|
||
height: 17px;
|
||
flex: 0 0 auto;
|
||
vertical-align: middle;
|
||
}
|
||
|
||
.context-body {
|
||
display: grid;
|
||
grid-template-columns: 180px minmax(0, 1fr);
|
||
gap: 16px;
|
||
min-height: 0;
|
||
}
|
||
|
||
.context-nav {
|
||
display: grid;
|
||
align-content: start;
|
||
gap: 8px;
|
||
}
|
||
|
||
.context-panel {
|
||
display: none;
|
||
min-width: 0;
|
||
padding: 18px;
|
||
border-radius: var(--radius-lg);
|
||
}
|
||
|
||
.context-panel.active {
|
||
display: block;
|
||
}
|
||
|
||
.panel-head {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
justify-content: space-between;
|
||
gap: 16px;
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.panel-actions {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.panel-actions-skill {
|
||
display: grid;
|
||
grid-template-columns: repeat(2, minmax(0, max-content));
|
||
justify-content: end;
|
||
}
|
||
|
||
.panel-actions-skill > button {
|
||
min-width: 0;
|
||
}
|
||
|
||
.source-list {
|
||
display: grid;
|
||
gap: 8px;
|
||
}
|
||
|
||
.source-row {
|
||
display: grid;
|
||
grid-template-columns: minmax(0, 1fr) auto auto;
|
||
gap: 12px;
|
||
align-items: center;
|
||
min-height: 62px;
|
||
padding: 12px;
|
||
border: 1px solid var(--border-soft);
|
||
border-radius: var(--radius-md);
|
||
background: rgba(255, 255, 255, 0.07);
|
||
}
|
||
|
||
.source-main {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
min-width: 0;
|
||
}
|
||
|
||
.source-icon,
|
||
.avatar-orb {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
flex: 0 0 auto;
|
||
width: 34px;
|
||
height: 34px;
|
||
border-radius: 50%;
|
||
color: white;
|
||
background:
|
||
radial-gradient(circle at 35% 35%, #f3a33b 0 18%, #315ef4 47%, #151722 75%);
|
||
box-shadow: inset 0 0 18px rgba(255, 255, 255, 0.16);
|
||
}
|
||
|
||
.avatar-orb.loading {
|
||
animation: breathe 1.65s ease-in-out infinite;
|
||
}
|
||
|
||
@keyframes breathe {
|
||
0%, 100% {
|
||
opacity: 0.68;
|
||
transform: scale(0.9);
|
||
box-shadow: inset 0 0 18px rgba(255, 255, 255, 0.12), 0 0 0 rgba(112, 183, 255, 0);
|
||
}
|
||
50% {
|
||
opacity: 1;
|
||
transform: scale(1.08);
|
||
box-shadow: inset 0 0 22px rgba(255, 255, 255, 0.22), 0 0 28px rgba(112, 183, 255, 0.22);
|
||
}
|
||
}
|
||
|
||
.source-icon svg {
|
||
width: 18px;
|
||
height: 18px;
|
||
}
|
||
|
||
.source-text {
|
||
min-width: 0;
|
||
}
|
||
|
||
.source-title {
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
font-weight: 650;
|
||
}
|
||
|
||
.source-meta {
|
||
margin-top: 3px;
|
||
color: var(--muted);
|
||
font-size: 13px;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.badge {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
min-height: 26px;
|
||
padding: 4px 10px;
|
||
border: 1px solid var(--border-soft);
|
||
border-radius: 9px;
|
||
color: var(--muted-strong);
|
||
background: rgba(255, 255, 255, 0.06);
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
text-transform: uppercase;
|
||
}
|
||
|
||
.badge.ok {
|
||
color: var(--ok);
|
||
border-color: rgba(145, 232, 189, 0.28);
|
||
background: rgba(145, 232, 189, 0.08);
|
||
}
|
||
|
||
.badge.warn {
|
||
color: var(--warn);
|
||
border-color: rgba(255, 209, 138, 0.32);
|
||
background: rgba(255, 209, 138, 0.08);
|
||
}
|
||
|
||
.empty-state {
|
||
display: grid;
|
||
place-items: center;
|
||
min-height: 180px;
|
||
padding: 24px;
|
||
color: var(--muted);
|
||
text-align: center;
|
||
border: 1px dashed var(--border-soft);
|
||
border-radius: var(--radius-lg);
|
||
background: rgba(255, 255, 255, 0.04);
|
||
}
|
||
|
||
.ghost-button,
|
||
.primary-button,
|
||
.icon-button {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 8px;
|
||
min-height: 38px;
|
||
padding: 8px 12px;
|
||
border-radius: 13px;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.ghost-button {
|
||
color: var(--muted-strong);
|
||
border-color: var(--border-soft);
|
||
background: rgba(255, 255, 255, 0.06);
|
||
}
|
||
|
||
.primary-button {
|
||
color: var(--button-primary-text);
|
||
border-color: var(--button-primary-border);
|
||
background: linear-gradient(135deg, var(--button-primary-bg-start), var(--button-primary-bg-mid) 50%, var(--button-primary-bg-end));
|
||
box-shadow: var(--button-primary-shadow);
|
||
font-weight: 760;
|
||
}
|
||
|
||
.primary-button:hover {
|
||
transform: translateY(-1px);
|
||
}
|
||
|
||
.generator-secondary-button:disabled {
|
||
opacity: 0.46;
|
||
cursor: not-allowed;
|
||
transform: none;
|
||
}
|
||
|
||
.generator-secondary-button {
|
||
color: rgba(255, 255, 255, 0.96);
|
||
background: rgba(255, 255, 255, 0.1);
|
||
}
|
||
|
||
.generator-secondary-button:disabled:hover {
|
||
background: rgba(255, 255, 255, 0.1);
|
||
border-color: var(--border-soft);
|
||
}
|
||
|
||
.danger-button {
|
||
color: rgba(255, 220, 220, 0.95);
|
||
border-color: rgba(255, 100, 100, 0.4);
|
||
background: linear-gradient(135deg, rgba(200, 60, 60, 0.85), rgba(160, 40, 40, 0.9));
|
||
}
|
||
.danger-button:hover { transform: translateY(-1px); }
|
||
|
||
.icon-button {
|
||
width: 38px;
|
||
padding: 0;
|
||
color: var(--muted-strong);
|
||
border-color: var(--border-soft);
|
||
background: rgba(255, 255, 255, 0.07);
|
||
}
|
||
|
||
.icon-button.send {
|
||
width: 44px;
|
||
height: 44px;
|
||
align-self: start;
|
||
margin-top: 2px;
|
||
border-radius: 18px;
|
||
color: var(--text);
|
||
background: rgba(255, 255, 255, 0.11);
|
||
}
|
||
|
||
.icon-button.send svg {
|
||
width: 20px;
|
||
height: 20px;
|
||
}
|
||
|
||
.icon-button.send:disabled {
|
||
opacity: 0.35;
|
||
cursor: not-allowed;
|
||
pointer-events: none;
|
||
}
|
||
|
||
.chat-screen {
|
||
flex-direction: column;
|
||
min-height: 0;
|
||
}
|
||
|
||
.chat-top {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 16px;
|
||
padding: 18px 22px 0;
|
||
}
|
||
|
||
.chat-title-area {
|
||
min-width: 0;
|
||
}
|
||
|
||
.chat-title {
|
||
font-size: 20px;
|
||
font-weight: 760;
|
||
overflow: hidden;
|
||
white-space: nowrap;
|
||
text-overflow: ellipsis;
|
||
}
|
||
|
||
.chat-workspace {
|
||
display: flex;
|
||
min-height: 0;
|
||
flex: 1;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
padding: 12px 22px 22px;
|
||
gap: 14px;
|
||
}
|
||
|
||
.messages {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 14px;
|
||
width: min(860px, 100%);
|
||
min-height: 0;
|
||
flex: 1;
|
||
overflow: auto;
|
||
padding: 12px 4px;
|
||
--fade-top-stop: 0px;
|
||
--fade-bot-stop: 0px;
|
||
mask-image: linear-gradient(
|
||
to bottom,
|
||
transparent 0,
|
||
black var(--fade-top-stop),
|
||
black calc(100% - var(--fade-bot-stop)),
|
||
transparent 100%
|
||
);
|
||
-webkit-mask-image: linear-gradient(
|
||
to bottom,
|
||
transparent 0,
|
||
black var(--fade-top-stop),
|
||
black calc(100% - var(--fade-bot-stop)),
|
||
transparent 100%
|
||
);
|
||
}
|
||
|
||
.message {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
gap: 10px;
|
||
width: 100%;
|
||
animation: messageIn 0.2s ease-out both;
|
||
}
|
||
|
||
.message-content {
|
||
display: grid;
|
||
gap: 5px;
|
||
max-width: min(620px, 76%);
|
||
min-width: 0;
|
||
}
|
||
|
||
.message.user {
|
||
justify-content: flex-end;
|
||
}
|
||
|
||
.message.user .message-content {
|
||
justify-items: end;
|
||
}
|
||
|
||
.message.assistant,
|
||
.message.tool {
|
||
justify-content: flex-start;
|
||
}
|
||
|
||
.message-bubble {
|
||
max-width: 100%;
|
||
padding: 14px 16px;
|
||
border-radius: 22px;
|
||
line-height: 1.55;
|
||
color: rgba(255, 255, 255, 0.94);
|
||
overflow-wrap: anywhere;
|
||
}
|
||
|
||
.message.user .message-bubble {
|
||
border-top-right-radius: 7px;
|
||
background:
|
||
linear-gradient(135deg, rgba(255, 255, 255, 0.18), rgba(255, 255, 255, 0.08));
|
||
}
|
||
|
||
.message.assistant .message-bubble {
|
||
border-top-left-radius: 7px;
|
||
}
|
||
|
||
.message.tool .message-bubble {
|
||
color: var(--muted-strong);
|
||
font-size: 13px;
|
||
border-style: dashed;
|
||
background: rgba(255, 255, 255, 0.055);
|
||
}
|
||
|
||
.message.loading .message-bubble {
|
||
color: var(--muted-strong);
|
||
background: rgba(255, 255, 255, 0.055);
|
||
}
|
||
|
||
.thinking-text {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
}
|
||
|
||
.thinking-text span {
|
||
width: 5px;
|
||
height: 5px;
|
||
border-radius: 50%;
|
||
background: currentColor;
|
||
opacity: 0.45;
|
||
animation: dotPulse 1.2s ease-in-out infinite;
|
||
}
|
||
|
||
.thinking-text span:nth-child(2) {
|
||
animation-delay: 0.16s;
|
||
}
|
||
|
||
.thinking-text span:nth-child(3) {
|
||
animation-delay: 0.32s;
|
||
}
|
||
|
||
@keyframes dotPulse {
|
||
0%, 100% { transform: translateY(0); opacity: 0.35; }
|
||
50% { transform: translateY(-3px); opacity: 1; }
|
||
}
|
||
|
||
.stream-working {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 7px;
|
||
font-size: 12px;
|
||
color: var(--muted);
|
||
margin-top: 8px;
|
||
}
|
||
|
||
.stream-working::before {
|
||
content: "";
|
||
display: inline-block;
|
||
width: 11px;
|
||
height: 11px;
|
||
border: 1.5px solid rgba(255, 255, 255, 0.12);
|
||
border-top-color: var(--blue);
|
||
border-radius: 50%;
|
||
animation: spin 0.72s linear infinite;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
@keyframes spin {
|
||
to { transform: rotate(360deg); }
|
||
}
|
||
|
||
.message-role {
|
||
margin-bottom: 5px;
|
||
color: var(--muted);
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.message-time {
|
||
padding: 0 4px;
|
||
color: rgba(255, 255, 255, 0.48);
|
||
font-size: 11px;
|
||
font-weight: 650;
|
||
}
|
||
|
||
.message-text {
|
||
white-space: pre-wrap;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 10px;
|
||
}
|
||
|
||
.tool-call {
|
||
margin: 0;
|
||
padding: 10px;
|
||
border: 1px solid var(--border-soft);
|
||
border-radius: 12px;
|
||
background: rgba(0, 0, 0, 0.16);
|
||
font-size: 13px;
|
||
}
|
||
|
||
.tool-call summary {
|
||
cursor: pointer;
|
||
color: var(--muted-strong);
|
||
font-weight: 720;
|
||
}
|
||
|
||
.thinking-block {
|
||
margin: 0;
|
||
padding: 8px 12px;
|
||
border: 1px solid rgba(180, 160, 255, 0.18);
|
||
border-radius: 12px;
|
||
background: rgba(100, 60, 180, 0.08);
|
||
font-size: 13px;
|
||
}
|
||
|
||
.thinking-block summary {
|
||
cursor: pointer;
|
||
list-style: none;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
color: rgba(200, 180, 255, 0.72);
|
||
font-weight: 650;
|
||
user-select: none;
|
||
}
|
||
|
||
.thinking-block summary::-webkit-details-marker { display: none; }
|
||
|
||
.thinking-block summary::before {
|
||
content: "▶";
|
||
font-size: 8px;
|
||
opacity: 0.6;
|
||
transition: transform 0.15s ease;
|
||
flex: 0 0 auto;
|
||
}
|
||
|
||
.thinking-block[open] summary::before {
|
||
transform: rotate(90deg);
|
||
}
|
||
|
||
.thinking-block pre {
|
||
margin-top: 8px;
|
||
white-space: pre-wrap;
|
||
word-break: break-word;
|
||
font-size: 12px;
|
||
line-height: 1.52;
|
||
color: rgba(200, 180, 255, 0.6);
|
||
font-family: inherit;
|
||
}
|
||
|
||
.thinking-pre-wrap {
|
||
margin-top: 8px;
|
||
mask-image: linear-gradient(to bottom, transparent 0%, black 32%);
|
||
-webkit-mask-image: linear-gradient(to bottom, transparent 0%, black 32%);
|
||
}
|
||
.thinking-pre-wrap pre {
|
||
max-height: 55px;
|
||
overflow-y: auto;
|
||
scrollbar-width: none;
|
||
margin-top: 0;
|
||
}
|
||
.thinking-pre-wrap pre::-webkit-scrollbar { display: none; }
|
||
|
||
.message-error-badge {
|
||
margin-top: 6px;
|
||
padding: 5px 10px;
|
||
border-radius: 8px;
|
||
font-size: 12px;
|
||
color: rgba(255, 159, 159, 0.9);
|
||
background: rgba(255, 80, 80, 0.09);
|
||
border: 1px solid rgba(255, 159, 159, 0.22);
|
||
}
|
||
|
||
.message-actions {
|
||
display: flex;
|
||
gap: 4px;
|
||
padding: 2px 0;
|
||
opacity: 0;
|
||
transition: opacity 0.15s;
|
||
}
|
||
.message-content:hover .message-actions { opacity: 1; }
|
||
.message.user .message-actions { justify-content: flex-end; }
|
||
|
||
.msg-action-btn {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 3px;
|
||
padding: 3px 9px;
|
||
border-radius: 8px;
|
||
font-size: 11.5px;
|
||
cursor: pointer;
|
||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||
background: rgba(255, 255, 255, 0.05);
|
||
color: rgba(255, 255, 255, 0.4);
|
||
transition: all 0.15s;
|
||
}
|
||
.msg-action-btn:hover {
|
||
background: rgba(255, 255, 255, 0.1);
|
||
color: rgba(255, 255, 255, 0.85);
|
||
border-color: rgba(255, 255, 255, 0.18);
|
||
}
|
||
.msg-action-btn:disabled {
|
||
opacity: 0.3;
|
||
cursor: not-allowed;
|
||
pointer-events: none;
|
||
}
|
||
.msg-action-btn.danger:hover {
|
||
background: rgba(255, 70, 70, 0.14);
|
||
color: rgba(255, 150, 150, 0.9);
|
||
border-color: rgba(255, 100, 100, 0.2);
|
||
}
|
||
|
||
.thinking-chip {
|
||
gap: 5px;
|
||
}
|
||
|
||
.thinking-chip[hidden] {
|
||
display: none;
|
||
}
|
||
|
||
.thinking-chip.active {
|
||
border-color: rgba(130, 80, 230, 0.55);
|
||
background: rgba(100, 55, 200, 0.18);
|
||
color: rgba(200, 165, 255, 0.95);
|
||
box-shadow: 0 0 14px rgba(120, 70, 220, 0.18);
|
||
}
|
||
|
||
.thinking-chip.active [data-icon] svg {
|
||
stroke: rgba(190, 150, 255, 0.9);
|
||
}
|
||
|
||
.toggle-row.disabled {
|
||
opacity: 0.38;
|
||
pointer-events: none;
|
||
}
|
||
|
||
.message-text { min-height: 20px; }
|
||
|
||
.citation-button {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 24px;
|
||
height: 24px;
|
||
margin: 0 3px;
|
||
padding: 0;
|
||
border: 1px solid rgba(112, 183, 255, 0.34);
|
||
border-radius: 50%;
|
||
color: rgba(232, 243, 255, 0.94);
|
||
background: rgba(112, 183, 255, 0.16);
|
||
font-size: 12px;
|
||
font-weight: 800;
|
||
line-height: 1;
|
||
vertical-align: super;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.citation-button:hover {
|
||
border-color: rgba(255, 255, 255, 0.5);
|
||
background: rgba(112, 183, 255, 0.28);
|
||
}
|
||
|
||
.citation-excerpt {
|
||
max-height: 340px;
|
||
overflow: auto;
|
||
margin-top: 12px;
|
||
padding: 14px;
|
||
border: 1px solid var(--border-soft);
|
||
border-radius: 14px;
|
||
background: rgba(0, 0, 0, 0.2);
|
||
color: rgba(255, 255, 255, 0.9);
|
||
white-space: pre-wrap;
|
||
line-height: 1.55;
|
||
}
|
||
|
||
.citation-meta {
|
||
display: grid;
|
||
gap: 8px;
|
||
margin-top: 12px;
|
||
}
|
||
|
||
.citation-meta-row {
|
||
display: grid;
|
||
grid-template-columns: 130px minmax(0, 1fr);
|
||
gap: 10px;
|
||
color: var(--muted-strong);
|
||
font-size: 13px;
|
||
}
|
||
|
||
.citation-meta-row strong {
|
||
color: var(--muted);
|
||
}
|
||
|
||
.markdown {
|
||
display: grid;
|
||
gap: 10px;
|
||
}
|
||
|
||
.markdown p,
|
||
.markdown ul,
|
||
.markdown ol,
|
||
.markdown table,
|
||
.markdown pre {
|
||
margin: 0;
|
||
}
|
||
|
||
.markdown-table-scroll {
|
||
max-width: 100%;
|
||
overflow-x: auto;
|
||
}
|
||
|
||
.markdown table {
|
||
width: 100%;
|
||
border-collapse: collapse;
|
||
overflow: hidden;
|
||
border: 1px solid var(--border-soft);
|
||
border-radius: 12px;
|
||
background: rgba(0, 0, 0, 0.14);
|
||
font-size: 0.94em;
|
||
white-space: normal;
|
||
}
|
||
|
||
.markdown th,
|
||
.markdown td {
|
||
padding: 8px 10px;
|
||
border: 1px solid var(--border-soft);
|
||
text-align: left;
|
||
vertical-align: top;
|
||
overflow-wrap: break-word;
|
||
}
|
||
|
||
.markdown th {
|
||
color: rgba(255, 255, 255, 0.96);
|
||
background: rgba(255, 255, 255, 0.08);
|
||
font-weight: 750;
|
||
}
|
||
|
||
.markdown ul,
|
||
.markdown ol {
|
||
padding-left: 22px;
|
||
}
|
||
|
||
.markdown li + li {
|
||
margin-top: 5px;
|
||
}
|
||
|
||
.markdown code {
|
||
padding: 2px 5px;
|
||
border: 1px solid var(--border-soft);
|
||
border-radius: 7px;
|
||
background: rgba(0, 0, 0, 0.22);
|
||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
||
font-size: 0.92em;
|
||
}
|
||
|
||
.markdown pre code {
|
||
display: block;
|
||
padding: 0;
|
||
border: 0;
|
||
background: transparent;
|
||
}
|
||
|
||
pre {
|
||
margin: 0;
|
||
white-space: pre-wrap;
|
||
overflow-wrap: anywhere;
|
||
color: rgba(255, 255, 255, 0.84);
|
||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
||
font-size: 12px;
|
||
line-height: 1.5;
|
||
}
|
||
|
||
.composer {
|
||
width: min(860px, 100%);
|
||
border-radius: 24px;
|
||
padding: 12px;
|
||
}
|
||
|
||
.composer-row {
|
||
display: grid;
|
||
grid-template-columns: minmax(0, 1fr) auto;
|
||
gap: 10px;
|
||
align-items: end;
|
||
}
|
||
|
||
.composer textarea {
|
||
min-height: 54px;
|
||
max-height: 180px;
|
||
padding: 13px 14px;
|
||
border: 0;
|
||
background: transparent;
|
||
font-family: var(--font);
|
||
font-size: 18px;
|
||
resize: none;
|
||
}
|
||
|
||
.composer textarea::placeholder {
|
||
color: rgba(255, 255, 255, 0.46);
|
||
opacity: 1;
|
||
}
|
||
|
||
.composer textarea:focus {
|
||
background: transparent;
|
||
border: 0;
|
||
}
|
||
|
||
.composer-tools {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
flex-wrap: wrap;
|
||
padding: 8px 4px 0;
|
||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||
}
|
||
|
||
.tool-menu-wrap {
|
||
position: relative;
|
||
}
|
||
|
||
.menu-panel {
|
||
position: absolute;
|
||
left: 0;
|
||
bottom: calc(100% + 10px);
|
||
display: none;
|
||
width: 300px;
|
||
max-height: 460px;
|
||
overflow-y: auto;
|
||
padding: 8px;
|
||
border-radius: 18px;
|
||
z-index: 20;
|
||
}
|
||
|
||
.menu-panel.open {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 2px;
|
||
}
|
||
|
||
.menu-group {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 2px;
|
||
}
|
||
|
||
.menu-group + .menu-group {
|
||
margin-top: 4px;
|
||
padding-top: 8px;
|
||
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||
}
|
||
|
||
.menu-group-title {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 7px;
|
||
padding: 4px 10px 2px;
|
||
font-size: 10.5px;
|
||
font-weight: 700;
|
||
letter-spacing: 0.07em;
|
||
text-transform: uppercase;
|
||
color: rgba(255, 255, 255, 0.34);
|
||
pointer-events: none;
|
||
}
|
||
|
||
.menu-empty {
|
||
padding: 5px 10px 6px;
|
||
font-size: 12px;
|
||
color: rgba(255, 255, 255, 0.28);
|
||
font-style: italic;
|
||
}
|
||
|
||
/* toggle rows inside menu — lighter, no border */
|
||
.menu-group .toggle-row {
|
||
border: 0;
|
||
background: transparent;
|
||
border-radius: 10px;
|
||
min-height: 36px;
|
||
padding: 7px 10px;
|
||
transition: background 0.1s;
|
||
}
|
||
|
||
.menu-group .toggle-row:hover {
|
||
background: rgba(255, 255, 255, 0.07);
|
||
}
|
||
|
||
.menu-group .toggle-row.disabled {
|
||
opacity: 0.38;
|
||
}
|
||
|
||
/* ── Model custom dropdown ─────────────────────────────────────────────── */
|
||
.model-select-wrap {
|
||
position: relative;
|
||
}
|
||
|
||
.model-select-btn {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
max-width: 210px;
|
||
}
|
||
|
||
.model-select-btn #modelSelectLabel {
|
||
flex: 1;
|
||
min-width: 0;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.model-chevron {
|
||
flex: 0 0 auto;
|
||
opacity: 0.6;
|
||
transition: transform 0.18s ease;
|
||
}
|
||
|
||
.model-select-wrap.open .model-chevron {
|
||
transform: rotate(180deg);
|
||
}
|
||
|
||
.model-select-btn:disabled {
|
||
opacity: 0.42;
|
||
cursor: not-allowed;
|
||
pointer-events: none;
|
||
}
|
||
|
||
.model-select-btn:disabled .model-chevron {
|
||
display: none;
|
||
}
|
||
|
||
.model-dropdown {
|
||
position: absolute;
|
||
left: 0;
|
||
bottom: calc(100% + 8px);
|
||
display: none;
|
||
min-width: 240px;
|
||
max-width: 320px;
|
||
max-height: 300px;
|
||
overflow-y: auto;
|
||
padding: 6px;
|
||
border-radius: 16px;
|
||
z-index: 30;
|
||
}
|
||
|
||
.model-dropdown.open {
|
||
display: block;
|
||
animation: menuIn 0.14s ease-out both;
|
||
}
|
||
|
||
.model-option {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 9px;
|
||
width: 100%;
|
||
padding: 8px 11px;
|
||
border-radius: 10px;
|
||
text-align: left;
|
||
font-size: 13px;
|
||
color: var(--muted-strong);
|
||
background: transparent;
|
||
border: 0;
|
||
cursor: pointer;
|
||
transition: background 0.1s, color 0.1s;
|
||
}
|
||
|
||
.model-option:hover {
|
||
background: rgba(255, 255, 255, 0.10);
|
||
color: var(--text);
|
||
}
|
||
|
||
.model-option.selected {
|
||
background: rgba(255, 255, 255, 0.08);
|
||
color: var(--text);
|
||
}
|
||
|
||
.model-option .opt-check {
|
||
flex: 0 0 14px;
|
||
width: 14px;
|
||
height: 14px;
|
||
opacity: 0;
|
||
color: var(--blue);
|
||
}
|
||
|
||
.model-option.selected .opt-check {
|
||
opacity: 1;
|
||
}
|
||
|
||
.model-option .opt-name {
|
||
flex: 1;
|
||
min-width: 0;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.toggle-row {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
min-height: 42px;
|
||
padding: 9px 10px;
|
||
border: 1px solid var(--border-soft);
|
||
border-radius: 13px;
|
||
background: rgba(255, 255, 255, 0.055);
|
||
}
|
||
|
||
.toggle-row span {
|
||
color: var(--muted-strong);
|
||
font-size: 13px;
|
||
}
|
||
|
||
input[type="checkbox"] {
|
||
appearance: none;
|
||
-webkit-appearance: none;
|
||
position: relative;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
flex-shrink: 0;
|
||
width: 34px;
|
||
height: 20px;
|
||
border-radius: 99px;
|
||
border: 1.5px solid rgba(255, 255, 255, 0.20);
|
||
background: rgba(255, 255, 255, 0.09);
|
||
cursor: pointer;
|
||
transition: background 0.18s ease, border-color 0.18s ease;
|
||
vertical-align: middle;
|
||
}
|
||
|
||
input[type="checkbox"]::after {
|
||
content: "";
|
||
position: absolute;
|
||
top: 50%;
|
||
left: 3px;
|
||
transform: translateY(-50%);
|
||
width: 12px;
|
||
height: 12px;
|
||
border-radius: 50%;
|
||
background: rgba(255, 255, 255, 0.50);
|
||
transition: left 0.18s ease, background 0.18s ease;
|
||
}
|
||
|
||
input[type="checkbox"]:checked {
|
||
background: rgba(80, 150, 255, 0.32);
|
||
border-color: rgba(100, 170, 255, 0.55);
|
||
}
|
||
|
||
input[type="checkbox"]:checked::after {
|
||
left: calc(100% - 15px);
|
||
background: rgba(150, 205, 255, 0.96);
|
||
}
|
||
|
||
input[type="checkbox"]:disabled {
|
||
opacity: 0.38;
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
.debug-workspace {
|
||
display: grid;
|
||
grid-template-columns: minmax(240px, 0.34fr) minmax(0, 1fr);
|
||
gap: 14px;
|
||
padding: 18px 22px 22px;
|
||
min-height: 0;
|
||
width: 100%;
|
||
}
|
||
|
||
.debug-panel {
|
||
min-width: 0;
|
||
min-height: 0;
|
||
border-radius: var(--radius-lg);
|
||
padding: 14px;
|
||
overflow: auto;
|
||
}
|
||
|
||
.debug-list {
|
||
display: grid;
|
||
gap: 8px;
|
||
min-width: 0;
|
||
}
|
||
|
||
.debug-event {
|
||
width: 100%;
|
||
min-width: 0;
|
||
padding: 10px;
|
||
border: 1px solid var(--border-soft);
|
||
border-radius: 13px;
|
||
color: var(--muted-strong);
|
||
background: rgba(255, 255, 255, 0.055);
|
||
text-align: left;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.debug-event.active {
|
||
color: var(--text);
|
||
border-color: var(--border);
|
||
background: rgba(255, 255, 255, 0.13);
|
||
}
|
||
|
||
.debug-event-title {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
gap: 10px;
|
||
font-size: 13px;
|
||
font-weight: 720;
|
||
min-width: 0;
|
||
}
|
||
|
||
.debug-event-title span:first-child {
|
||
flex: 1;
|
||
min-width: 0;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.debug-event-title span:last-child {
|
||
flex: 0 0 auto;
|
||
}
|
||
|
||
.debug-event-meta {
|
||
margin-top: 4px;
|
||
color: var(--muted);
|
||
font-size: 12px;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.debug-detail-head {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 10px;
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.debug-callout {
|
||
margin-bottom: 12px;
|
||
padding: 10px 12px;
|
||
border: 1px solid var(--border-soft);
|
||
border-radius: 14px;
|
||
color: var(--muted-strong);
|
||
background: rgba(255, 255, 255, 0.055);
|
||
font-size: 13px;
|
||
line-height: 1.45;
|
||
}
|
||
|
||
.code-block {
|
||
display: grid;
|
||
gap: 8px;
|
||
margin-top: 12px;
|
||
}
|
||
|
||
.code-block-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 8px;
|
||
color: var(--muted-strong);
|
||
font-size: 13px;
|
||
font-weight: 720;
|
||
}
|
||
|
||
.code-box {
|
||
padding: 12px;
|
||
border: 1px solid var(--border-soft);
|
||
border-radius: 14px;
|
||
background: rgba(0, 0, 0, 0.22);
|
||
overflow: auto;
|
||
}
|
||
|
||
.modal-backdrop {
|
||
position: fixed;
|
||
inset: 0;
|
||
z-index: 50;
|
||
display: none;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 18px;
|
||
background: rgba(0, 0, 0, 0.32);
|
||
}
|
||
|
||
.modal-backdrop.open {
|
||
display: flex;
|
||
}
|
||
|
||
.modal-card {
|
||
width: min(720px, 100%);
|
||
max-height: min(760px, 92vh);
|
||
overflow: auto;
|
||
padding: 18px;
|
||
border-radius: var(--radius-lg);
|
||
}
|
||
|
||
.modal-head {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.split-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
gap: 12px;
|
||
}
|
||
|
||
.toast-stack {
|
||
position: fixed;
|
||
right: 18px;
|
||
bottom: 18px;
|
||
z-index: 80;
|
||
display: grid;
|
||
gap: 8px;
|
||
width: min(420px, calc(100vw - 36px));
|
||
}
|
||
|
||
.toast {
|
||
padding: 12px 14px;
|
||
border: 1px solid var(--border);
|
||
border-radius: 14px;
|
||
background:
|
||
linear-gradient(135deg, rgba(25, 28, 40, 0.92), rgba(34, 25, 23, 0.88));
|
||
box-shadow: 0 18px 60px rgba(0, 0, 0, 0.3);
|
||
color: var(--text);
|
||
animation: toastIn 0.18s ease-out both;
|
||
}
|
||
|
||
.toast.loading {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
border-color: rgba(112, 183, 255, 0.38);
|
||
}
|
||
|
||
.toast.loading::before {
|
||
content: "";
|
||
width: 14px;
|
||
height: 14px;
|
||
flex: 0 0 auto;
|
||
border: 2px solid rgba(255, 255, 255, 0.28);
|
||
border-top-color: rgba(255, 255, 255, 0.92);
|
||
border-radius: 50%;
|
||
animation: toast-spin 0.8s linear infinite;
|
||
}
|
||
|
||
@keyframes toast-spin {
|
||
to {
|
||
transform: rotate(360deg);
|
||
}
|
||
}
|
||
|
||
@keyframes toastIn {
|
||
from { opacity: 0; transform: translateX(20px); }
|
||
to { opacity: 1; transform: translateX(0); }
|
||
}
|
||
|
||
@keyframes messageIn {
|
||
from { opacity: 0; transform: translateY(9px); }
|
||
to { opacity: 1; transform: translateY(0); }
|
||
}
|
||
|
||
@keyframes cursorBlink {
|
||
0%, 100% { opacity: 0.8; }
|
||
50% { opacity: 0; }
|
||
}
|
||
|
||
.toast.error {
|
||
border-color: rgba(255, 159, 159, 0.42);
|
||
}
|
||
|
||
.toast.ok {
|
||
border-color: rgba(145, 232, 189, 0.42);
|
||
}
|
||
|
||
.hidden {
|
||
display: none !important;
|
||
}
|
||
|
||
/* ── Focus rings (keyboard nav) ─────────────────────────────────────── */
|
||
:focus-visible {
|
||
outline: 2px solid rgba(112, 183, 255, 0.65);
|
||
outline-offset: 2px;
|
||
}
|
||
input:focus-visible,
|
||
textarea:focus-visible,
|
||
select:focus-visible {
|
||
outline: none; /* border-color handles focus for inputs */
|
||
}
|
||
|
||
/* ── Active / pressed feedback on all interactive controls ──────────── */
|
||
.primary-button:active,
|
||
.ghost-button:active,
|
||
.danger-button:active,
|
||
.icon-button:active,
|
||
.nav-button:active,
|
||
.pill:active,
|
||
.github-widget:active,
|
||
.production-notice-button:active {
|
||
transform: scale(0.97);
|
||
opacity: 0.88;
|
||
}
|
||
.primary-button:active { transform: scale(0.97) translateY(0); }
|
||
.danger-button:active { transform: scale(0.97) translateY(0); }
|
||
.msg-action-btn:active { transform: scale(0.95); opacity: 0.8; }
|
||
.mini-button:active { transform: scale(0.9); }
|
||
|
||
/* ── Input / select hover ────────────────────────────────────────────── */
|
||
input:hover:not(:focus),
|
||
textarea:hover:not(:focus),
|
||
select:hover:not(:focus) {
|
||
border-color: rgba(255, 255, 255, 0.24);
|
||
}
|
||
input,
|
||
textarea,
|
||
select {
|
||
transition: border-color 150ms ease;
|
||
}
|
||
|
||
/* ── Source / context rows ───────────────────────────────────────────── */
|
||
.source-row {
|
||
transition: background 150ms ease, border-color 150ms ease;
|
||
cursor: default;
|
||
}
|
||
.source-row:hover {
|
||
background: rgba(255, 255, 255, 0.10);
|
||
border-color: var(--border);
|
||
}
|
||
|
||
/* ── Toggle rows (tools menu) ────────────────────────────────────────── */
|
||
.toggle-row {
|
||
cursor: pointer;
|
||
transition: background 150ms ease, border-color 150ms ease;
|
||
}
|
||
.toggle-row:hover:not(.disabled) {
|
||
background: rgba(255, 255, 255, 0.09);
|
||
border-color: var(--border);
|
||
}
|
||
.toggle-row:active:not(.disabled) { transform: scale(0.99); }
|
||
|
||
/* ── Chat row title fade before action menu ──────────────────────────── */
|
||
.chat-row-title {
|
||
transition: padding-right 150ms ease;
|
||
}
|
||
|
||
/* ── Mini buttons (rename / delete on chat row) ──────────────────────── */
|
||
.mini-button {
|
||
transition: background 150ms ease, border-color 150ms ease, color 150ms ease, transform 100ms ease;
|
||
}
|
||
.mini-button:hover {
|
||
background: rgba(255, 255, 255, 0.16);
|
||
border-color: var(--border);
|
||
color: var(--text);
|
||
}
|
||
|
||
/* ── Debug events ────────────────────────────────────────────────────── */
|
||
.debug-event {
|
||
transition: background 150ms ease, border-color 150ms ease;
|
||
}
|
||
.debug-event:hover:not(.active) {
|
||
background: rgba(255, 255, 255, 0.09);
|
||
border-color: var(--border);
|
||
}
|
||
|
||
/* ── Details / summary expand feedback ──────────────────────────────── */
|
||
.tool-call summary,
|
||
.thinking-block summary {
|
||
transition: color 120ms ease;
|
||
}
|
||
.tool-call summary:hover { color: var(--text); }
|
||
.thinking-block summary:hover { color: rgba(220, 200, 255, 0.9); }
|
||
|
||
/* ── Send button glow ────────────────────────────────────────────────── */
|
||
.icon-button.send {
|
||
transition: background 150ms ease, border-color 150ms ease, box-shadow 150ms ease, transform 150ms ease;
|
||
}
|
||
.icon-button.send:hover {
|
||
background: rgba(255, 255, 255, 0.18);
|
||
border-color: rgba(255, 255, 255, 0.34);
|
||
box-shadow: 0 0 16px rgba(112, 183, 255, 0.22);
|
||
}
|
||
.icon-button.send:active { transform: scale(0.93); }
|
||
|
||
/* ── Composer focus glow ─────────────────────────────────────────────── */
|
||
.composer {
|
||
transition: box-shadow 200ms ease;
|
||
}
|
||
.composer:focus-within {
|
||
box-shadow: 0 18px 60px rgba(0, 0, 0, 0.28), 0 0 0 1px rgba(112, 183, 255, 0.18), 0 0 28px rgba(112, 183, 255, 0.07);
|
||
}
|
||
|
||
/* ── Menu panel slide-in ─────────────────────────────────────────────── */
|
||
.menu-panel.open {
|
||
animation: menuIn 0.14s ease-out both;
|
||
}
|
||
@keyframes menuIn {
|
||
from { opacity: 0; transform: translateY(6px) scale(0.98); }
|
||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||
}
|
||
|
||
/* ── Modal card scale-in ─────────────────────────────────────────────── */
|
||
.modal-backdrop.open .modal-card {
|
||
animation: cardIn 0.18s ease-out both;
|
||
}
|
||
@keyframes cardIn {
|
||
from { opacity: 0; transform: translateY(10px) scale(0.97); }
|
||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||
}
|
||
|
||
/* ── Code block copy button ──────────────────────────────────────────── */
|
||
.code-block-header .ghost-button {
|
||
font-size: 12px;
|
||
min-height: 30px;
|
||
padding: 4px 10px;
|
||
}
|
||
|
||
/* ── Message actions always visible on mobile / when streaming done ──── */
|
||
.message-actions {
|
||
transition: opacity 0.15s ease, transform 0.15s ease;
|
||
transform: translateY(2px);
|
||
}
|
||
.message-content:hover .message-actions {
|
||
opacity: 1;
|
||
transform: translateY(0);
|
||
}
|
||
|
||
/* ── Pill active pulse (when a tool is enabled) ──────────────────────── */
|
||
.pill.active {
|
||
transition: background 150ms ease, border-color 150ms ease, color 150ms ease;
|
||
}
|
||
|
||
/* ── Thinking chip hover ─────────────────────────────────────────────── */
|
||
.thinking-chip:hover:not(.active) {
|
||
border-color: rgba(130, 80, 230, 0.30);
|
||
color: rgba(200, 165, 255, 0.75);
|
||
}
|
||
|
||
/* ── API Debugger detail URL wrap ────────────────────────────────────── */
|
||
.debug-detail-head p.subtle {
|
||
overflow-wrap: break-word;
|
||
word-break: break-all;
|
||
}
|
||
|
||
/* ── Scroll fade for overflow containers ────────────────────────────── */
|
||
.chat-list-wrap {
|
||
position: relative;
|
||
flex: 1 1 0;
|
||
min-height: 0;
|
||
overflow-y: auto;
|
||
overflow-x: hidden;
|
||
overscroll-behavior: contain;
|
||
}
|
||
/* mask-image controls the fade; four stops, updated by JS via custom props */
|
||
.chat-list-wrap {
|
||
--fade-top-stop: 0px;
|
||
--fade-bot-stop: 0px;
|
||
mask-image: linear-gradient(
|
||
to bottom,
|
||
transparent 0,
|
||
black var(--fade-top-stop),
|
||
black calc(100% - var(--fade-bot-stop)),
|
||
transparent 100%
|
||
);
|
||
-webkit-mask-image: linear-gradient(
|
||
to bottom,
|
||
transparent 0,
|
||
black var(--fade-top-stop),
|
||
black calc(100% - var(--fade-bot-stop)),
|
||
transparent 100%
|
||
);
|
||
transition: mask-image 150ms ease;
|
||
}
|
||
|
||
/* ── Frosted glass — light translucent, heavy blur, heavier at bottom ── */
|
||
.modal-card,
|
||
.menu-panel,
|
||
.model-dropdown {
|
||
background: linear-gradient(to bottom,
|
||
rgba(255, 255, 255, 0.14) 0%,
|
||
rgba(255, 255, 255, 0.26) 100%
|
||
);
|
||
backdrop-filter: blur(72px) saturate(1.6);
|
||
-webkit-backdrop-filter: blur(72px) saturate(1.6);
|
||
}
|
||
|
||
/* ── Code execution blocks (bash, text_editor, view, str_replace, create, insert) ── */
|
||
.code-exec-block {
|
||
margin: 0;
|
||
padding: 0;
|
||
border: 1px solid var(--border-soft);
|
||
border-radius: 12px;
|
||
background: rgba(0, 0, 0, 0.16);
|
||
overflow: hidden;
|
||
}
|
||
|
||
.code-exec-block summary {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 10px 12px;
|
||
cursor: pointer;
|
||
color: var(--muted-strong);
|
||
font-weight: 720;
|
||
font-size: 13px;
|
||
min-height: 40px;
|
||
user-select: none;
|
||
list-style: none;
|
||
transition: background 150ms ease;
|
||
}
|
||
|
||
.code-exec-block summary::-webkit-details-marker { display: none; }
|
||
|
||
.code-exec-block summary:hover {
|
||
background: rgba(255, 255, 255, 0.04);
|
||
}
|
||
|
||
.code-exec-block summary::before {
|
||
content: "▶";
|
||
font-size: 8px;
|
||
opacity: 0.6;
|
||
transition: transform 0.15s ease;
|
||
flex: 0 0 auto;
|
||
margin-right: 2px;
|
||
}
|
||
|
||
.code-exec-block[open] summary::before {
|
||
transform: rotate(90deg);
|
||
}
|
||
|
||
.code-exec-block .code-exec-body {
|
||
padding: 0 12px 12px;
|
||
display: grid;
|
||
gap: 10px;
|
||
}
|
||
|
||
.code-exec-block .code-exec-command {
|
||
padding: 8px 12px;
|
||
border: 1px solid rgba(255, 255, 255, 0.10);
|
||
border-radius: 10px;
|
||
background: rgba(0, 0, 0, 0.22);
|
||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||
font-size: 12px;
|
||
line-height: 1.5;
|
||
color: var(--muted-strong);
|
||
}
|
||
|
||
.code-exec-block .code-exec-command .prompt {
|
||
color: var(--accent);
|
||
margin-right: 6px;
|
||
user-select: none;
|
||
}
|
||
|
||
.code-exec-block .code-exec-terminal {
|
||
padding: 10px;
|
||
border: 1px solid rgba(255, 255, 255, 0.10);
|
||
border-radius: 10px;
|
||
background: rgba(0, 0, 0, 0.28);
|
||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||
font-size: 12px;
|
||
line-height: 1.55;
|
||
color: rgba(220, 220, 220, 0.92);
|
||
white-space: pre-wrap;
|
||
word-break: break-word;
|
||
max-height: 420px;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
.code-exec-block .code-exec-terminal.is-stderr {
|
||
color: rgba(255, 159, 159, 0.92);
|
||
border-color: rgba(255, 159, 159, 0.16);
|
||
}
|
||
|
||
.code-exec-block .code-exec-result-meta {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
font-size: 12px;
|
||
color: var(--muted);
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.code-exec-block .code-exec-result-meta .exit-code {
|
||
padding: 2px 8px;
|
||
border-radius: 6px;
|
||
font-weight: 700;
|
||
font-size: 11px;
|
||
}
|
||
|
||
.code-exec-block .code-exec-result-meta .exit-code.ok {
|
||
background: rgba(145, 232, 189, 0.12);
|
||
color: var(--ok);
|
||
}
|
||
|
||
.code-exec-block .code-exec-result-meta .exit-code.error {
|
||
background: rgba(255, 159, 159, 0.12);
|
||
color: var(--danger);
|
||
}
|
||
|
||
.code-exec-block .code-exec-file-info {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
font-size: 12px;
|
||
color: var(--muted);
|
||
}
|
||
|
||
.code-exec-block .code-exec-file-path {
|
||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||
color: var(--muted-strong);
|
||
font-size: 12px;
|
||
}
|
||
|
||
.code-exec-block .code-exec-file-content {
|
||
padding: 0;
|
||
border: 1px solid rgba(255, 255, 255, 0.10);
|
||
border-radius: 10px;
|
||
background: rgba(0, 0, 0, 0.22);
|
||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||
font-size: 12px;
|
||
line-height: 1.55;
|
||
overflow: auto;
|
||
max-height: 420px;
|
||
}
|
||
|
||
.code-exec-block .code-exec-file-content table {
|
||
width: 100%;
|
||
border-collapse: collapse;
|
||
table-layout: auto;
|
||
}
|
||
|
||
.code-exec-block .code-exec-file-content td {
|
||
padding: 2px 0;
|
||
vertical-align: top;
|
||
}
|
||
|
||
.code-exec-block .code-exec-file-content .line-number {
|
||
padding-right: 12px;
|
||
padding-left: 8px;
|
||
color: var(--faint);
|
||
text-align: right;
|
||
user-select: none;
|
||
white-space: nowrap;
|
||
min-width: 40px;
|
||
font-size: 11px;
|
||
}
|
||
|
||
.code-exec-block .code-exec-file-content .line-text {
|
||
padding-right: 8px;
|
||
color: rgba(220, 220, 220, 0.88);
|
||
white-space: pre;
|
||
}
|
||
|
||
.code-exec-block .code-exec-diff {
|
||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||
font-size: 12px;
|
||
line-height: 1.55;
|
||
padding: 10px;
|
||
border: 1px solid rgba(255, 255, 255, 0.10);
|
||
border-radius: 10px;
|
||
background: rgba(0, 0, 0, 0.22);
|
||
white-space: pre-wrap;
|
||
word-break: break-word;
|
||
}
|
||
|
||
.code-exec-block .code-exec-diff .diff-removed {
|
||
color: rgba(255, 159, 159, 0.92);
|
||
}
|
||
|
||
.code-exec-block .code-exec-diff .diff-added {
|
||
color: rgba(145, 232, 189, 0.92);
|
||
}
|
||
|
||
.code-exec-block .code-exec-present-files {
|
||
display: grid;
|
||
gap: 6px;
|
||
}
|
||
|
||
.code-exec-block .code-exec-present-file {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 8px 12px;
|
||
border: 1px solid rgba(255, 255, 255, 0.10);
|
||
border-radius: 10px;
|
||
background: rgba(255, 255, 255, 0.04);
|
||
font-size: 12px;
|
||
}
|
||
|
||
.code-exec-block .code-exec-present-file .file-icon {
|
||
flex: 0 0 auto;
|
||
opacity: 0.7;
|
||
}
|
||
|
||
.code-exec-block .code-exec-present-file .file-name {
|
||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||
color: var(--muted-strong);
|
||
flex: 1;
|
||
min-width: 0;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.code-exec-block .code-exec-tool-label {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
padding: 2px 8px;
|
||
border-radius: 6px;
|
||
font-size: 11px;
|
||
font-weight: 700;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.04em;
|
||
}
|
||
|
||
.code-exec-block .code-exec-tool-label.bash {
|
||
background: rgba(100, 180, 120, 0.14);
|
||
color: rgba(145, 215, 155, 0.88);
|
||
}
|
||
|
||
.code-exec-block .code-exec-tool-label.file {
|
||
background: rgba(112, 183, 255, 0.14);
|
||
color: rgba(140, 200, 255, 0.88);
|
||
}
|
||
|
||
.code-exec-block .code-exec-tool-label.create {
|
||
background: rgba(145, 232, 189, 0.14);
|
||
color: var(--ok);
|
||
}
|
||
|
||
.code-exec-block .code-exec-tool-label.edit {
|
||
background: rgba(255, 209, 138, 0.14);
|
||
color: var(--warn);
|
||
}
|
||
|
||
.code-exec-block .code-exec-tool-label.present {
|
||
background: rgba(190, 150, 255, 0.14);
|
||
color: rgba(200, 170, 255, 0.92);
|
||
}
|
||
|
||
.code-exec-block .code-exec-tool-label.server {
|
||
background: rgba(112, 183, 255, 0.14);
|
||
color: rgba(140, 200, 255, 0.92);
|
||
}
|
||
|
||
/* ── Code execution file downloads ──────────────────────────────────── */
|
||
.code-exec-download {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 8px 12px;
|
||
border: 1px solid rgba(112, 183, 255, 0.22);
|
||
border-radius: 10px;
|
||
background: rgba(112, 183, 255, 0.08);
|
||
color: rgba(140, 200, 255, 0.92);
|
||
font-size: 13px;
|
||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||
text-decoration: none;
|
||
transition: background 150ms ease, border-color 150ms ease;
|
||
cursor: pointer;
|
||
width: 100%;
|
||
box-sizing: border-box;
|
||
}
|
||
.code-exec-download:hover {
|
||
background: rgba(112, 183, 255, 0.16);
|
||
border-color: rgba(112, 183, 255, 0.36);
|
||
}
|
||
.code-exec-download svg {
|
||
flex: 0 0 auto;
|
||
width: 14px;
|
||
height: 14px;
|
||
}
|
||
.code-exec-download .code-exec-file-name {
|
||
flex: 1;
|
||
min-width: 0;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
.code-exec-mime {
|
||
font-size: 11px;
|
||
opacity: 0.6;
|
||
flex: 0 0 auto;
|
||
}
|
||
|
||
/* ── Code execution server links (present_server) ──────────────────── */
|
||
.code-exec-server-link {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 8px 12px;
|
||
border: 1px solid rgba(112, 183, 255, 0.22);
|
||
border-radius: 10px;
|
||
background: rgba(112, 183, 255, 0.08);
|
||
color: rgba(140, 200, 255, 0.92);
|
||
font-size: 13px;
|
||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||
text-decoration: none;
|
||
transition: background 150ms ease, border-color 150ms ease;
|
||
cursor: pointer;
|
||
width: 100%;
|
||
box-sizing: border-box;
|
||
}
|
||
.code-exec-server-link:hover {
|
||
background: rgba(112, 183, 255, 0.16);
|
||
border-color: rgba(112, 183, 255, 0.36);
|
||
}
|
||
.code-exec-server-link svg {
|
||
flex: 0 0 auto;
|
||
width: 14px;
|
||
height: 14px;
|
||
}
|
||
.code-exec-server-link .code-exec-server-name {
|
||
font-weight: 600;
|
||
flex: 0 0 auto;
|
||
}
|
||
.code-exec-server-link .code-exec-server-url {
|
||
flex: 1;
|
||
min-width: 0;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
opacity: 0.85;
|
||
}
|
||
|
||
/* ── Modal backdrop blur ─────────────────────────────────────────────── */
|
||
.modal-backdrop {
|
||
backdrop-filter: blur(14px);
|
||
-webkit-backdrop-filter: blur(14px);
|
||
}
|
||
|
||
/* ── Auth fields side by side ────────────────────────────────────────── */
|
||
.auth-grid {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
gap: 12px;
|
||
align-items: start;
|
||
}
|
||
|
||
/* ── Smooth page/screen transitions ─────────────────────────────────── */
|
||
.screen.active {
|
||
animation: screenIn 0.15s ease-out both;
|
||
}
|
||
@keyframes screenIn {
|
||
from { opacity: 0; }
|
||
to { opacity: 1; }
|
||
}
|
||
|
||
@media (max-width: 900px) {
|
||
body {
|
||
overflow: auto;
|
||
}
|
||
|
||
.app {
|
||
grid-template-columns: 1fr;
|
||
height: auto;
|
||
min-height: 100vh;
|
||
}
|
||
|
||
.sidebar {
|
||
border-radius: var(--radius-xl);
|
||
max-height: 330px;
|
||
}
|
||
|
||
.onboarding-overlay {
|
||
align-items: flex-start;
|
||
padding: 16px;
|
||
}
|
||
|
||
.onboarding-shell {
|
||
grid-template-columns: 1fr;
|
||
height: auto;
|
||
max-height: none;
|
||
min-height: auto;
|
||
}
|
||
|
||
.onboarding-aside {
|
||
border-right: none;
|
||
border-bottom: 1px solid var(--border-soft);
|
||
}
|
||
|
||
.main {
|
||
min-height: 720px;
|
||
}
|
||
|
||
.context-body,
|
||
.settings-card,
|
||
.debug-workspace,
|
||
.split-grid,
|
||
.color-grid,
|
||
.feature-grid {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
|
||
.message-bubble {
|
||
max-width: 88%;
|
||
}
|
||
}
|
||
|
||
@media (max-width: 640px) {
|
||
.panel-head {
|
||
flex-direction: column;
|
||
align-items: stretch;
|
||
}
|
||
|
||
.panel-actions {
|
||
width: 100%;
|
||
}
|
||
|
||
.panel-actions-skill {
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
}
|
||
|
||
.panel-actions-skill > button {
|
||
width: 100%;
|
||
}
|
||
}
|
||
|
||
/* ── Fix: move backdrop-filter off .composer so dropdown escapes stacking context ── */
|
||
.composer {
|
||
position: relative;
|
||
background: transparent;
|
||
border: none;
|
||
box-shadow: none;
|
||
backdrop-filter: none;
|
||
-webkit-backdrop-filter: none;
|
||
}
|
||
|
||
.composer::before {
|
||
content: "";
|
||
position: absolute;
|
||
inset: 0;
|
||
z-index: -1;
|
||
border-radius: 24px;
|
||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.14), rgba(255, 255, 255, 0.055));
|
||
border: 1px solid var(--border);
|
||
box-shadow: 0 18px 60px rgba(0, 0, 0, 0.28);
|
||
backdrop-filter: blur(22px);
|
||
-webkit-backdrop-filter: blur(22px);
|
||
pointer-events: none;
|
||
}
|
||
|
||
/* ── Apple-style vibrancy dropdown ── */
|
||
.model-select-wrap {
|
||
isolation: auto;
|
||
}
|
||
|
||
.model-dropdown.open {
|
||
z-index: 100;
|
||
}
|
||
|
||
.model-dropdown,
|
||
.menu-panel {
|
||
background: rgba(28, 32, 44, 0.72) !important;
|
||
backdrop-filter: blur(40px) saturate(1.6) brightness(1.1) !important;
|
||
-webkit-backdrop-filter: blur(40px) saturate(1.6) brightness(1.1) !important;
|
||
border: 0.5px solid rgba(255, 255, 255, 0.12) !important;
|
||
box-shadow:
|
||
0 1px 0 rgba(255, 255, 255, 0.08) inset,
|
||
0 16px 48px rgba(0, 0, 0, 0.36) !important;
|
||
}
|
||
|
||
.model-option {
|
||
color: rgba(255, 255, 255, 0.88) !important;
|
||
font-size: 13.5px;
|
||
letter-spacing: -0.01em;
|
||
}
|
||
|
||
.model-option:hover,
|
||
.model-option.selected {
|
||
background: rgba(255, 255, 255, 0.10) !important;
|
||
color: #fff !important;
|
||
}
|
||
|
||
.menu-group-title {
|
||
color: rgba(255, 255, 255, 0.36) !important;
|
||
}
|
||
|
||
.menu-group .toggle-row span {
|
||
color: rgba(255, 255, 255, 0.82) !important;
|
||
}
|
||
|
||
.menu-group .toggle-row:hover {
|
||
background: rgba(255, 255, 255, 0.08) !important;
|
||
}
|
||
|
||
.menu-empty {
|
||
color: rgba(255, 255, 255, 0.36) !important;
|
||
}
|
||
|
||
body[data-theme-mode="light"] .sidebar {
|
||
background:
|
||
linear-gradient(135deg, rgba(255, 255, 255, 0.58), rgba(255, 255, 255, 0.32)),
|
||
linear-gradient(180deg, var(--sidebar-wash-top), var(--sidebar-wash-bottom));
|
||
}
|
||
|
||
body[data-theme-mode="light"] .app,
|
||
body[data-theme-mode="light"] .sidebar,
|
||
body[data-theme-mode="light"] .main,
|
||
body[data-theme-mode="light"] .onboarding-overlay,
|
||
body[data-theme-mode="light"] .onboarding-aside,
|
||
body[data-theme-mode="light"] .onboarding-main,
|
||
body[data-theme-mode="light"] .modal-card,
|
||
body[data-theme-mode="light"] .menu-panel,
|
||
body[data-theme-mode="light"] .model-dropdown {
|
||
color: var(--text);
|
||
}
|
||
|
||
body[data-theme-mode="light"] .sidebar::before {
|
||
opacity: 0.48;
|
||
}
|
||
|
||
body[data-theme-mode="light"] .settings-card,
|
||
body[data-theme-mode="light"] .context-panel,
|
||
body[data-theme-mode="light"] .debug-panel,
|
||
body[data-theme-mode="light"] .source-row,
|
||
body[data-theme-mode="light"] .message-bubble,
|
||
body[data-theme-mode="light"] .preview-tile,
|
||
body[data-theme-mode="light"] .advanced-panel,
|
||
body[data-theme-mode="light"] .step-pill,
|
||
body[data-theme-mode="light"] .chat-row,
|
||
body[data-theme-mode="light"] .nav-button,
|
||
body[data-theme-mode="light"] .github-widget,
|
||
body[data-theme-mode="light"] .production-notice-button {
|
||
background:
|
||
linear-gradient(135deg, rgba(255, 255, 255, 0.78), rgba(255, 255, 255, 0.54));
|
||
border-color: var(--border);
|
||
box-shadow: 0 18px 48px rgba(15, 23, 42, 0.08);
|
||
}
|
||
|
||
body[data-theme-mode="light"] .composer::before {
|
||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.84), rgba(255, 255, 255, 0.6));
|
||
border-color: var(--border);
|
||
box-shadow: 0 18px 60px rgba(15, 23, 42, 0.1);
|
||
}
|
||
|
||
body[data-theme-mode="light"] .production-notice-button {
|
||
color: #7f1d1d;
|
||
background: linear-gradient(135deg, rgba(254, 242, 242, 0.96), rgba(254, 226, 226, 0.88));
|
||
border-color: rgba(239, 68, 68, 0.22);
|
||
box-shadow: 0 14px 36px rgba(239, 68, 68, 0.08);
|
||
}
|
||
|
||
body[data-theme-mode="light"] .production-notice-button:hover {
|
||
color: #681313;
|
||
background: linear-gradient(135deg, rgba(254, 235, 235, 0.98), rgba(254, 220, 220, 0.92));
|
||
border-color: rgba(239, 68, 68, 0.34);
|
||
}
|
||
|
||
body[data-theme-mode="light"] .model-dropdown,
|
||
body[data-theme-mode="light"] .menu-panel,
|
||
body[data-theme-mode="light"] .modal-card {
|
||
background: rgba(248, 250, 252, 0.94) !important;
|
||
color: var(--text) !important;
|
||
border: 1px solid var(--border) !important;
|
||
box-shadow:
|
||
0 1px 0 rgba(255, 255, 255, 0.5) inset,
|
||
0 16px 48px rgba(15, 23, 42, 0.14) !important;
|
||
}
|
||
|
||
body[data-theme-mode="light"] .menu-group-title,
|
||
body[data-theme-mode="light"] .menu-empty,
|
||
body[data-theme-mode="light"] .subtle,
|
||
body[data-theme-mode="light"] small {
|
||
color: var(--muted) !important;
|
||
}
|
||
|
||
body[data-theme-mode="light"] .model-option,
|
||
body[data-theme-mode="light"] .menu-group .toggle-row span {
|
||
color: var(--text) !important;
|
||
}
|
||
|
||
body[data-theme-mode="light"] .sidebar-label,
|
||
body[data-theme-mode="light"] .chat-row-title,
|
||
body[data-theme-mode="light"] .chat-title,
|
||
body[data-theme-mode="light"] .message-role,
|
||
body[data-theme-mode="light"] .message-time,
|
||
body[data-theme-mode="light"] .source-title,
|
||
body[data-theme-mode="light"] .source-meta,
|
||
body[data-theme-mode="light"] .github-widget-name,
|
||
body[data-theme-mode="light"] .github-widget-stars,
|
||
body[data-theme-mode="light"] .logo-caption strong,
|
||
body[data-theme-mode="light"] .logo-caption span,
|
||
body[data-theme-mode="light"] .onboarding-kicker,
|
||
body[data-theme-mode="light"] .step-pill strong,
|
||
body[data-theme-mode="light"] .step-pill span,
|
||
body[data-theme-mode="light"] .debug-event-title,
|
||
body[data-theme-mode="light"] .debug-event-meta,
|
||
body[data-theme-mode="light"] .debug-callout,
|
||
body[data-theme-mode="light"] .code-block-header span,
|
||
body[data-theme-mode="light"] label,
|
||
body[data-theme-mode="light"] .badge {
|
||
color: var(--text) !important;
|
||
}
|
||
|
||
body[data-theme-mode="light"] input:not([type="checkbox"]):not([type="color"]),
|
||
body[data-theme-mode="light"] textarea,
|
||
body[data-theme-mode="light"] select {
|
||
color: #0f172a;
|
||
background: #ffffff;
|
||
border-color: rgba(15, 23, 42, 0.14);
|
||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.8);
|
||
}
|
||
|
||
body[data-theme-mode="light"] input:not([type="checkbox"]):not([type="color"])::placeholder,
|
||
body[data-theme-mode="light"] textarea::placeholder {
|
||
color: rgba(71, 85, 105, 0.72);
|
||
}
|
||
|
||
body[data-theme-mode="light"] #settingsScreen input:not([type="checkbox"]):not([type="color"])::placeholder,
|
||
body[data-theme-mode="light"] #settingsScreen textarea::placeholder {
|
||
color: rgba(51, 65, 85, 0.82);
|
||
}
|
||
|
||
body[data-theme-mode="light"] input:not([type="checkbox"]):not([type="color"]):focus,
|
||
body[data-theme-mode="light"] textarea:focus,
|
||
body[data-theme-mode="light"] select:focus {
|
||
border-color: rgba(37, 99, 235, 0.38);
|
||
background: #ffffff;
|
||
}
|
||
|
||
body[data-theme-mode="light"] .password-field {
|
||
background: #ffffff;
|
||
border-color: rgba(15, 23, 42, 0.14);
|
||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.8);
|
||
}
|
||
|
||
body[data-theme-mode="light"] .password-field input {
|
||
background: transparent;
|
||
border-color: transparent;
|
||
box-shadow: none;
|
||
}
|
||
|
||
body[data-theme-mode="light"] .password-field .icon-button {
|
||
color: rgba(15, 23, 42, 0.72);
|
||
}
|
||
|
||
body[data-theme-mode="light"] .check-state {
|
||
background: rgba(248, 250, 252, 0.92);
|
||
}
|
||
|
||
body[data-theme-mode="light"] .check-state.checking {
|
||
color: #1d4ed8;
|
||
}
|
||
|
||
.onboarding-overlay {
|
||
position: fixed;
|
||
inset: 0;
|
||
z-index: 30;
|
||
display: none;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 24px;
|
||
overflow: auto;
|
||
background: rgba(4, 7, 12, 0.72);
|
||
backdrop-filter: blur(20px);
|
||
-webkit-backdrop-filter: blur(20px);
|
||
}
|
||
|
||
.onboarding-overlay.open {
|
||
display: flex;
|
||
}
|
||
|
||
.onboarding-shell {
|
||
width: min(1120px, 100%);
|
||
height: calc((100vh - 48px) * 0.8);
|
||
max-height: calc(100vh - 48px);
|
||
display: grid;
|
||
grid-template-columns: minmax(280px, 0.75fr) minmax(0, 1.25fr);
|
||
grid-template-rows: minmax(0, 1fr);
|
||
border-radius: 34px;
|
||
overflow: hidden;
|
||
border: 1px solid var(--border);
|
||
box-shadow: 0 44px 120px rgba(0, 0, 0, 0.45);
|
||
background:
|
||
linear-gradient(135deg, rgba(255, 255, 255, 0.12), rgba(255, 255, 255, 0.05)),
|
||
linear-gradient(145deg, rgba(8, 14, 26, 0.94), rgba(18, 27, 44, 0.9));
|
||
}
|
||
|
||
.onboarding-aside {
|
||
padding: 30px 26px;
|
||
min-height: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 14px;
|
||
overflow: auto;
|
||
border-right: 1px solid var(--border-soft);
|
||
background:
|
||
radial-gradient(circle at 10% 90%, var(--surface-tint-strong), transparent 55%),
|
||
linear-gradient(180deg, rgba(255, 255, 255, 0.06), rgba(255, 255, 255, 0.02));
|
||
}
|
||
|
||
.onboarding-kicker {
|
||
font-size: 12px;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.18em;
|
||
color: var(--muted);
|
||
}
|
||
|
||
.onboarding-aside h1 {
|
||
margin: 0;
|
||
font-size: clamp(24px, 3vw, 34px);
|
||
line-height: 1.02;
|
||
letter-spacing: -0.04em;
|
||
}
|
||
|
||
.onboarding-aside p {
|
||
margin: 0;
|
||
color: var(--muted-strong);
|
||
line-height: 1.6;
|
||
}
|
||
|
||
.step-indicator {
|
||
display: grid;
|
||
gap: 8px;
|
||
margin-top: auto;
|
||
}
|
||
|
||
.step-pill {
|
||
display: grid;
|
||
gap: 4px;
|
||
padding: 11px 12px;
|
||
border-radius: 18px;
|
||
border: 1px solid var(--border-soft);
|
||
background: rgba(255, 255, 255, 0.05);
|
||
cursor: pointer;
|
||
transition: border-color 150ms ease, background 150ms ease, transform 120ms ease;
|
||
}
|
||
|
||
.step-pill:hover {
|
||
border-color: rgba(255, 255, 255, 0.2);
|
||
transform: translateY(-1px);
|
||
}
|
||
|
||
.step-pill.active {
|
||
border-color: rgba(255, 255, 255, 0.3);
|
||
background:
|
||
linear-gradient(135deg, rgba(255, 255, 255, 0.14), rgba(255, 255, 255, 0.05)),
|
||
var(--surface-tint);
|
||
}
|
||
|
||
.step-pill strong {
|
||
font-size: 13px;
|
||
}
|
||
|
||
.step-pill span {
|
||
color: var(--muted);
|
||
font-size: 12px;
|
||
}
|
||
|
||
.onboarding-main {
|
||
padding: 26px;
|
||
min-height: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 18px;
|
||
min-width: 0;
|
||
overflow-y: auto;
|
||
overflow-x: hidden;
|
||
}
|
||
|
||
.onboarding-step {
|
||
display: none;
|
||
gap: 18px;
|
||
min-height: 0;
|
||
}
|
||
|
||
.onboarding-step.active {
|
||
display: flex;
|
||
flex-direction: column;
|
||
flex: 1;
|
||
min-height: 0;
|
||
overflow: visible;
|
||
}
|
||
|
||
.onboarding-step-content {
|
||
display: flex;
|
||
flex-direction: column;
|
||
flex: 1;
|
||
gap: 16px;
|
||
min-height: 0;
|
||
width: min(100%, 760px);
|
||
}
|
||
|
||
.onboarding-step-header {
|
||
display: grid;
|
||
gap: 8px;
|
||
}
|
||
|
||
.onboarding-status-chips {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 10px;
|
||
align-items: center;
|
||
margin-top: 10px;
|
||
}
|
||
|
||
.onboarding-step-content h2,
|
||
.onboarding-step-content h3,
|
||
.onboarding-step-content p {
|
||
margin-top: 0;
|
||
}
|
||
|
||
.advanced-panel {
|
||
border: 1px solid var(--border-soft);
|
||
border-radius: 16px;
|
||
background: rgba(255, 255, 255, 0.03);
|
||
overflow: hidden;
|
||
}
|
||
|
||
.advanced-panel summary {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
list-style: none;
|
||
cursor: pointer;
|
||
padding: 10px 12px;
|
||
font-size: 13px;
|
||
font-weight: 700;
|
||
color: var(--muted-strong);
|
||
}
|
||
|
||
.advanced-panel summary::-webkit-details-marker {
|
||
display: none;
|
||
}
|
||
|
||
.advanced-panel .panel-chevron {
|
||
flex-shrink: 0;
|
||
width: 16px;
|
||
height: 16px;
|
||
color: var(--muted);
|
||
transition: transform 0.2s ease;
|
||
}
|
||
|
||
.advanced-panel[open] .panel-chevron {
|
||
transform: rotate(180deg);
|
||
}
|
||
|
||
.advanced-panel[open] summary {
|
||
border-bottom: 1px solid var(--border-soft);
|
||
}
|
||
|
||
.advanced-panel-body {
|
||
padding: 12px;
|
||
display: grid;
|
||
gap: 12px;
|
||
}
|
||
|
||
.check-state {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 8px 12px;
|
||
border-radius: 999px;
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
border: 1px solid var(--border-soft);
|
||
color: var(--muted-strong);
|
||
background: rgba(255, 255, 255, 0.06);
|
||
}
|
||
|
||
.check-state.checking {
|
||
color: rgba(173, 216, 255, 0.96);
|
||
border-color: rgba(112, 183, 255, 0.28);
|
||
background: rgba(112, 183, 255, 0.08);
|
||
}
|
||
|
||
.check-state.ok {
|
||
color: var(--ok);
|
||
border-color: rgba(145, 232, 189, 0.26);
|
||
background: rgba(145, 232, 189, 0.08);
|
||
}
|
||
|
||
.check-state.error {
|
||
color: var(--danger);
|
||
border-color: rgba(255, 159, 159, 0.28);
|
||
background: rgba(255, 159, 159, 0.08);
|
||
}
|
||
|
||
.preview-tile {
|
||
display: grid;
|
||
gap: 12px;
|
||
min-height: 100%;
|
||
padding: 20px;
|
||
border-radius: 22px;
|
||
border: 1px solid var(--border-soft);
|
||
background:
|
||
radial-gradient(circle at 0% 100%, var(--surface-tint-strong), transparent 52%),
|
||
linear-gradient(145deg, rgba(6, 10, 18, 0.94), rgba(18, 24, 38, 0.88));
|
||
}
|
||
|
||
.preview-chip {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
width: fit-content;
|
||
padding: 8px 12px;
|
||
border-radius: 999px;
|
||
background: rgba(255, 255, 255, 0.08);
|
||
color: var(--muted-strong);
|
||
font-size: 12px;
|
||
}
|
||
|
||
.preview-title {
|
||
font-size: 26px;
|
||
line-height: 1.08;
|
||
letter-spacing: -0.03em;
|
||
}
|
||
|
||
.preview-copy {
|
||
color: var(--muted-strong);
|
||
line-height: 1.6;
|
||
}
|
||
|
||
.button-row {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 12px;
|
||
align-items: center;
|
||
}
|
||
|
||
.onboarding-action-row {
|
||
justify-content: flex-end;
|
||
margin-top: auto;
|
||
padding-top: 18px;
|
||
}
|
||
|
||
.button-row .subtle {
|
||
margin: 0;
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="app">
|
||
<aside class="sidebar glass">
|
||
<div class="logo">
|
||
<img id="brandLogo" src="./references/logo-privategpt-wordmark-white-transparent.svg" alt="PrivateGPT" />
|
||
<div class="logo-caption">
|
||
<strong id="brandNameText">PrivateGPT</strong>
|
||
<span id="brandSurfaceText">Workbench</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="nav-block">
|
||
<button class="nav-button" id="contextNav" type="button">
|
||
<span data-icon="layers"></span>
|
||
<span>Context</span>
|
||
</button>
|
||
<button class="nav-button" id="newChatButton" type="button">
|
||
<span data-icon="plus"></span>
|
||
<span>New Chat</span>
|
||
</button>
|
||
</div>
|
||
|
||
<div class="sidebar-label">Chats</div>
|
||
<div class="chat-list-wrap scroll-fade" id="chatListWrap">
|
||
<div class="chat-list" id="chatList"></div>
|
||
</div>
|
||
<div class="sidebar-spacer"></div>
|
||
<button class="nav-button" id="apiDebuggerNav" type="button">
|
||
<span data-icon="zap"></span>
|
||
<span>API Debugger</span>
|
||
</button>
|
||
<button class="nav-button" id="settingsNav" type="button">
|
||
<span data-icon="sliders"></span>
|
||
<span>Settings</span>
|
||
</button>
|
||
<a class="github-widget" id="githubWidget" href="https://github.com/zylon-ai/private-gpt" target="_blank" rel="noreferrer" aria-label="Open PrivateGPT on GitHub">
|
||
<span class="github-widget-main">
|
||
<span data-icon="github"></span>
|
||
<span class="github-widget-name">GitHub</span>
|
||
</span>
|
||
<span class="github-widget-stars">
|
||
<span data-icon="star"></span>
|
||
<span id="githubStars">Stars</span>
|
||
</span>
|
||
</a>
|
||
<button class="production-notice-button" id="productionNoticeButton" type="button">
|
||
<span data-icon="info"></span>
|
||
<span>Not for Production</span>
|
||
</button>
|
||
</aside>
|
||
|
||
<main class="main glass">
|
||
<section class="screen context-screen" id="contextScreen">
|
||
<div class="context-header">
|
||
<div>
|
||
<h1>Context</h1>
|
||
<p class="subtle">Configure what this local assistant can use. These settings stay in this browser.</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="context-body">
|
||
<nav class="context-nav" id="contextTabs"></nav>
|
||
<div>
|
||
<section class="context-panel" data-context-panel="documents">
|
||
<div class="panel-head">
|
||
<div>
|
||
<h2>Documents</h2>
|
||
<p class="subtle">Upload and manage local knowledge collections.</p>
|
||
</div>
|
||
<div class="panel-actions">
|
||
<input class="hidden" id="fileUploadInput" type="file" multiple />
|
||
<button class="ghost-button" id="refreshDocsButton" type="button"><span data-icon="refresh"></span>Refresh</button>
|
||
<button class="primary-button" id="uploadDocsButton" type="button"><span data-icon="upload"></span>Upload</button>
|
||
</div>
|
||
</div>
|
||
<div class="source-list" id="documentsList"></div>
|
||
</section>
|
||
|
||
<section class="context-panel" data-context-panel="databases">
|
||
<div class="panel-head">
|
||
<div>
|
||
<h2>Databases</h2>
|
||
<p class="subtle">Store SQL database artifacts locally for chat requests.</p>
|
||
</div>
|
||
<button class="primary-button" data-open-modal="database" type="button"><span data-icon="plus"></span>Add database</button>
|
||
</div>
|
||
<div class="source-list" id="databasesList"></div>
|
||
</section>
|
||
|
||
<section class="context-panel" data-context-panel="web">
|
||
<div class="panel-head">
|
||
<div>
|
||
<h2>Web</h2>
|
||
<p class="subtle">Use web search and fetch tools when the PrivateGPT backend has them configured.</p>
|
||
</div>
|
||
</div>
|
||
<div class="settings-card">
|
||
<div>
|
||
<h3>Backend configuration</h3>
|
||
<p class="subtle">Workbench does not store web provider credentials.</p>
|
||
</div>
|
||
<div>
|
||
<p class="subtle">Configure the web search provider and API key in PrivateGPT's backend config. From the UI, enable Web in a chat's Tools menu to include the `web_search` and `web_fetch` tools in that request.</p>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="context-panel" data-context-panel="mcp">
|
||
<div class="panel-head">
|
||
<div>
|
||
<h2>MCP</h2>
|
||
<p class="subtle">Add MCP server configs passed to the messages API.</p>
|
||
</div>
|
||
<button class="primary-button" data-open-modal="mcp" type="button"><span data-icon="plus"></span>Add MCP</button>
|
||
</div>
|
||
<div class="source-list" id="mcpList"></div>
|
||
</section>
|
||
|
||
<section class="context-panel" data-context-panel="skills">
|
||
<div class="panel-head">
|
||
<div>
|
||
<h2>Skills</h2>
|
||
<p class="subtle">Manage backend skills for the active collection and enable them in chat.</p>
|
||
</div>
|
||
<div class="panel-actions panel-actions-skill">
|
||
<button class="ghost-button" id="refreshSkillsButton" type="button"><span data-icon="refresh"></span>Refresh</button>
|
||
<button class="primary-button" data-open-modal="skill" type="button"><span data-icon="plus"></span>Add skill</button>
|
||
</div>
|
||
</div>
|
||
<div class="source-list" id="skillsList"></div>
|
||
</section>
|
||
|
||
<section class="context-panel" data-context-panel="customTools">
|
||
<div class="panel-head">
|
||
<div>
|
||
<h2>Custom Tools</h2>
|
||
<p class="subtle">Define tool schemas and browser-executed JavaScript handlers.</p>
|
||
</div>
|
||
<button class="primary-button" data-open-modal="customTool" type="button"><span data-icon="plus"></span>Add custom tool</button>
|
||
</div>
|
||
<div class="source-list" id="customToolsList"></div>
|
||
</section>
|
||
|
||
<section class="context-panel" data-context-panel="codeExecution">
|
||
<div class="panel-head">
|
||
<div>
|
||
<h2>Code Execution</h2>
|
||
<p class="subtle">Manage files in the active chat's sandbox session. Enable Code Execution in the chat Tools menu to activate.</p>
|
||
</div>
|
||
<div class="panel-actions" id="codeExecPanelActions">
|
||
<input class="hidden" id="sessionContextFileInput" type="file" multiple />
|
||
<button class="ghost-button" id="refreshSessionFilesButton" type="button"><span data-icon="refresh"></span>Refresh</button>
|
||
<button class="primary-button" id="uploadSessionContextButton" type="button"><span data-icon="upload"></span>Upload</button>
|
||
</div>
|
||
</div>
|
||
<div class="settings-card" id="codeExecInfo" style="display:none">
|
||
<div>
|
||
<h3>No active code execution session</h3>
|
||
<p class="subtle">Open a chat and enable Code Execution from the Tools menu, then return here to manage session files.</p>
|
||
</div>
|
||
</div>
|
||
<div class="source-list" id="sessionFilesList"></div>
|
||
</section>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="screen context-screen" id="settingsScreen">
|
||
<div class="context-header">
|
||
<div>
|
||
<h1>Settings</h1>
|
||
<p class="subtle">Configure the PrivateGPT API connection used by this browser.</p>
|
||
</div>
|
||
<div class="panel-actions">
|
||
<button class="ghost-button" id="restartOnboardingButton" type="button"><span data-icon="sparkles"></span>Run onboarding</button>
|
||
<button class="ghost-button" id="testConnectionButton" type="button"><span data-icon="zap"></span>Test API</button>
|
||
<button class="primary-button" id="saveConnectionButton" type="button"><span data-icon="save"></span>Save</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="settings-stack">
|
||
<div class="settings-card">
|
||
<div>
|
||
<h2>PrivateGPT API</h2>
|
||
<p class="subtle">Workbench talks only to PrivateGPT. LLM gateway settings belong in the PrivateGPT backend config.</p>
|
||
</div>
|
||
<div class="form-grid">
|
||
<div class="field">
|
||
<label for="privateGptBaseUrl">PrivateGPT base URL</label>
|
||
<input id="privateGptBaseUrl" autocomplete="off" spellcheck="false" />
|
||
</div>
|
||
<details class="advanced-panel">
|
||
<summary><span>Authentication <span class="subtle" style="font-weight:400;font-size:12px;">(HTTP Basic Auth)</span></span><span data-icon="chevron-down" class="panel-chevron"></span></summary>
|
||
<div class="advanced-panel-body">
|
||
<p class="subtle" style="margin:0;font-size:12px;">Credentials are Base64-encoded and sent as an <code>Authorization</code> header on every request. Only enable if your PrivateGPT server requires Basic Auth. Stored only in this browser; debugger headers are always redacted.</p>
|
||
<div class="auth-grid">
|
||
<div class="field">
|
||
<label for="privateGptUsername">Username</label>
|
||
<input id="privateGptUsername" autocomplete="username" spellcheck="false" placeholder="Optional" />
|
||
</div>
|
||
<div class="field">
|
||
<label for="privateGptPassword">Password</label>
|
||
<div class="password-field">
|
||
<input id="privateGptPassword" type="password" autocomplete="current-password" spellcheck="false" placeholder="Optional" />
|
||
<button class="icon-button" id="togglePasswordButton" type="button" aria-label="Show password"><span data-icon="eye"></span></button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</details>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="settings-card">
|
||
<div>
|
||
<h2>Chat defaults</h2>
|
||
<p class="subtle">Controls the system prompt and request-level features sent with every chat message.</p>
|
||
</div>
|
||
<div class="form-grid">
|
||
<div class="field">
|
||
<label for="systemPrompt">System prompt</label>
|
||
<textarea id="systemPrompt" class="prose-input" spellcheck="false" placeholder="Optional assistant behavior instructions."></textarea>
|
||
<small>Optional. Sent as the top-level system field on chat requests, not as a system role message.</small>
|
||
</div>
|
||
<label class="toggle-row">
|
||
<span>Use citations</span>
|
||
<input id="useCitations" type="checkbox" />
|
||
</label>
|
||
<label class="toggle-row">
|
||
<span>Default prompt</span>
|
||
<input id="useDefaultPrompt" type="checkbox" />
|
||
</label>
|
||
<label class="toggle-row">
|
||
<span>Enable thinking</span>
|
||
<input id="enableThinking" type="checkbox" />
|
||
</label>
|
||
<label class="toggle-row">
|
||
<span>Code Execution</span>
|
||
<input id="enableCodeExecution" type="checkbox" />
|
||
</label>
|
||
<div class="field">
|
||
<label for="extensions">Extensions</label>
|
||
<input id="extensions" autocomplete="off" spellcheck="false" placeholder="zylon" />
|
||
<small>Comma-separated list of enabled extensions, e.g. <code>zylon</code>.</small>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="settings-card">
|
||
<div>
|
||
<h2>Collection</h2>
|
||
<p class="subtle">Used for ingest, document list, delete, skill management, and search. Some deployments restrict collection IDs per bearer token.</p>
|
||
</div>
|
||
<div class="form-grid">
|
||
<div class="field">
|
||
<label for="defaultCollection">Collection ID</label>
|
||
<input id="defaultCollection" autocomplete="off" spellcheck="false" placeholder="pgpt_collection" />
|
||
<small>All Documents and Skills operations use this collection. Document-enabled chats search artifacts here.</small>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="settings-card">
|
||
<div>
|
||
<h2>Look and feel</h2>
|
||
<p class="subtle">Describe the product surface you want, let PrivateGPT generate a theme, then fine-tune the result if needed.</p>
|
||
</div>
|
||
<div class="form-grid">
|
||
<div class="field">
|
||
<label for="appearanceAIBrief">Theme brief</label>
|
||
<textarea id="appearanceAIBrief" class="prose-input" spellcheck="false" placeholder="Example: Make this feel like a calm legal copilot for enterprise clients, with restrained colors, high contrast, and only documents plus citations visible."></textarea>
|
||
<small>Use natural language. Mention brand personality, audience, tone, colors to lean into or avoid, and which features should stay visible.</small>
|
||
</div>
|
||
<div class="generator-bar">
|
||
<button class="primary-button" id="appearanceGenerateButton" type="button"><span data-icon="sparkles"></span>Generate with AI</button>
|
||
<div class="generator-status" id="appearanceGeneratorStatus"></div>
|
||
</div>
|
||
<details class="advanced-panel">
|
||
<summary>Advanced overrides</summary>
|
||
<div class="advanced-panel-body">
|
||
<div class="field">
|
||
<label for="appearanceBrandName">Brand name</label>
|
||
<input id="appearanceBrandName" autocomplete="off" spellcheck="false" placeholder="PrivateGPT" />
|
||
</div>
|
||
<div class="field">
|
||
<label for="appearanceWelcomeTitle">Welcome title</label>
|
||
<input id="appearanceWelcomeTitle" autocomplete="off" spellcheck="false" placeholder="Ask PrivateGPT" />
|
||
</div>
|
||
<div class="field">
|
||
<label for="appearanceWelcomeSubtitle">Welcome subtitle</label>
|
||
<textarea id="appearanceWelcomeSubtitle" class="prose-input" spellcheck="false" placeholder="Use your local documents, tools, databases, MCP servers, skills, and custom browser tools from one chat."></textarea>
|
||
</div>
|
||
<div class="field">
|
||
<label for="appearanceCustomInstructions">Workspace instructions</label>
|
||
<textarea id="appearanceCustomInstructions" class="prose-input" spellcheck="false" placeholder="Optional. Added before the system prompt to steer behavior for this workspace."></textarea>
|
||
<small>Use this to tailor tone, role, or domain guidance without changing backend config.</small>
|
||
</div>
|
||
<div class="field">
|
||
<h3>Palette</h3>
|
||
<small>These colors feed the workspace gradients, accents, and glass tints.</small>
|
||
</div>
|
||
<div class="color-grid">
|
||
<label class="color-field" for="appearanceAccentColor"><span>Accent</span><input id="appearanceAccentColor" type="color" /></label>
|
||
<label class="color-field" for="appearanceSecondaryColor"><span>Secondary</span><input id="appearanceSecondaryColor" type="color" /></label>
|
||
<label class="color-field" for="appearanceSurfaceColor"><span>Surface tint</span><input id="appearanceSurfaceColor" type="color" /></label>
|
||
<label class="color-field" for="appearanceBackgroundColor"><span>Background base</span><input id="appearanceBackgroundColor" type="color" /></label>
|
||
</div>
|
||
<div class="field">
|
||
<h3>Optional sections</h3>
|
||
<small>Hide parts of the workbench when you want a narrower, simpler surface.</small>
|
||
</div>
|
||
<div class="feature-grid">
|
||
<label class="toggle-row"><span>Databases</span><input id="appearanceFeatureDatabases" type="checkbox" /></label>
|
||
<label class="toggle-row"><span>Web</span><input id="appearanceFeatureWeb" type="checkbox" /></label>
|
||
<label class="toggle-row"><span>MCP</span><input id="appearanceFeatureMcp" type="checkbox" /></label>
|
||
<label class="toggle-row"><span>Skills</span><input id="appearanceFeatureSkills" type="checkbox" /></label>
|
||
<label class="toggle-row"><span>Custom Tools</span><input id="appearanceFeatureCustomTools" type="checkbox" /></label>
|
||
<label class="toggle-row"><span>API Debugger</span><input id="appearanceFeatureApiDebugger" type="checkbox" /></label>
|
||
</div>
|
||
</div>
|
||
</details>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="settings-card">
|
||
<div>
|
||
<h2>Local browser data</h2>
|
||
<p class="subtle">Clear this workbench's saved chats, settings, token, context, and preferences from this browser.</p>
|
||
</div>
|
||
<div class="form-grid">
|
||
<div>
|
||
<p class="subtle">This does not delete data stored in PrivateGPT itself, such as ingested documents or backend configuration.</p>
|
||
</div>
|
||
<div class="panel-actions">
|
||
<button class="ghost-button" id="clearLocalDataButton" type="button"><span data-icon="trash"></span>Clear local data</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="screen context-screen" id="apiDebuggerScreen">
|
||
<div class="context-header">
|
||
<div>
|
||
<h1>API Debugger</h1>
|
||
<p class="subtle">Inspect API requests and responses made during this browser session.</p>
|
||
</div>
|
||
<button class="ghost-button" id="clearDebuggerButton" type="button">Clear</button>
|
||
</div>
|
||
<div class="debug-workspace">
|
||
<div class="debug-panel">
|
||
<div class="debug-detail-head">
|
||
<h2>Timeline</h2>
|
||
</div>
|
||
<div class="debug-callout">Live trace for this session only. It clears when you refresh the page.</div>
|
||
<div class="debug-list" id="debugList"></div>
|
||
</div>
|
||
<div class="debug-panel" id="debugDetail"></div>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="screen chat-screen" id="chatScreen">
|
||
<div class="chat-top">
|
||
<div class="chat-title-area">
|
||
<div class="chat-title" id="activeChatTitle">New chat</div>
|
||
<p class="subtle" id="activeChatSubtitle">Local session</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="chat-workspace" id="chatPanel">
|
||
<div class="messages" id="messages"></div>
|
||
<form class="composer" id="composer">
|
||
<div class="composer-row">
|
||
<textarea id="composerInput" placeholder="How can I help?" rows="1"></textarea>
|
||
<button class="icon-button send" type="submit" aria-label="Send"><span data-icon="send"></span></button>
|
||
</div>
|
||
<div class="composer-tools">
|
||
<div class="model-select-wrap" id="modelSelectWrap">
|
||
<button class="ghost-button model-select-btn" id="modelSelectBtn" type="button">
|
||
<span data-icon="cpu"></span>
|
||
<span id="modelSelectLabel">Model</span>
|
||
<span data-icon="chevron-down" class="model-chevron"></span>
|
||
</button>
|
||
<div class="model-dropdown" id="modelDropdown"></div>
|
||
</div>
|
||
<button class="icon-button" id="refreshModelsButton" type="button" aria-label="Refresh models" title="Refresh models"><span data-icon="refresh"></span></button>
|
||
<button class="ghost-button thinking-chip" id="thinkingToggle" type="button" title="Enable extended thinking"><span data-icon="zap"></span>Thinking</button>
|
||
<div class="tool-menu-wrap">
|
||
<button class="ghost-button" id="toolsMenuButton" type="button"><span data-icon="sliders"></span>Tools</button>
|
||
<div class="menu-panel" id="toolsMenu"></div>
|
||
</div>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</section>
|
||
</main>
|
||
</div>
|
||
|
||
<section class="onboarding-overlay" id="onboardingOverlay" aria-hidden="true">
|
||
<div class="onboarding-shell">
|
||
<aside class="onboarding-aside">
|
||
<div class="onboarding-kicker">Guided setup</div>
|
||
<h1>Get this workspace ready in two short steps.</h1>
|
||
<p>First, point the UI at PrivateGPT and verify the collection works. Then, if you want, tune the surface so the workbench feels closer to your product.</p>
|
||
<div class="step-indicator">
|
||
<div class="step-pill active" id="onboardingStepPill1">
|
||
<strong>1. Connect PrivateGPT</strong>
|
||
<span>Base URL, collection, and a live check.</span>
|
||
<div class="onboarding-status-chips" id="onboardingCheckChips"></div>
|
||
</div>
|
||
<div class="step-pill" id="onboardingStepPill2">
|
||
<strong>2. Customize appearance</strong>
|
||
<span>Optional colors, feature visibility, and instructions.</span>
|
||
</div>
|
||
</div>
|
||
</aside>
|
||
|
||
<div class="onboarding-main">
|
||
<section class="onboarding-step active" id="onboardingStep1">
|
||
<div class="onboarding-step-content">
|
||
<div class="onboarding-step-header">
|
||
<h2>Connection</h2>
|
||
<p class="subtle">Use the same PrivateGPT API URL and collection the rest of the workbench will rely on.</p>
|
||
</div>
|
||
<div class="form-grid">
|
||
<div class="field">
|
||
<label for="onboardingBaseUrl">PrivateGPT base URL</label>
|
||
<input id="onboardingBaseUrl" autocomplete="off" spellcheck="false" placeholder="Current host" />
|
||
</div>
|
||
<details class="advanced-panel">
|
||
<summary><span>Authentication <span class="subtle" style="font-weight:400;font-size:12px;">(HTTP Basic Auth)</span></span><span data-icon="chevron-down" class="panel-chevron"></span></summary>
|
||
<div class="advanced-panel-body">
|
||
<p class="subtle" style="margin:0;font-size:12px;">Credentials are Base64-encoded and sent as an <code>Authorization</code> header on every request. Only enable if your PrivateGPT server requires Basic Auth. Stored only in this browser; debugger headers are always redacted.</p>
|
||
<div class="auth-grid">
|
||
<div class="field">
|
||
<label for="onboardingUsername">Username</label>
|
||
<input id="onboardingUsername" autocomplete="username" spellcheck="false" placeholder="Optional" />
|
||
</div>
|
||
<div class="field">
|
||
<label for="onboardingPassword">Password</label>
|
||
<div class="password-field">
|
||
<input id="onboardingPassword" type="password" autocomplete="current-password" spellcheck="false" placeholder="Optional" />
|
||
<button class="icon-button" id="toggleOnboardingPasswordButton" type="button" aria-label="Show password"><span data-icon="eye"></span></button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</details>
|
||
<div class="field">
|
||
<label for="onboardingCollection">Collection ID</label>
|
||
<input id="onboardingCollection" autocomplete="off" spellcheck="false" placeholder="pgpt_collection" />
|
||
</div>
|
||
<p class="subtle">This check hits the same models, artifacts, and skills endpoints the main app uses.</p>
|
||
</div>
|
||
<div class="button-row onboarding-action-row">
|
||
<button class="primary-button" id="onboardingCheckButton" type="button"><span data-icon="zap"></span>Save and check</button>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="onboarding-step" id="onboardingStep2">
|
||
<div class="onboarding-step-content">
|
||
<div class="onboarding-step-header">
|
||
<h2>Customize the workbench</h2>
|
||
<p class="subtle">This step is optional. Describe the experience you want and let the model generate a starting theme, then adjust the result if needed.</p>
|
||
</div>
|
||
<div class="form-grid">
|
||
<div class="field">
|
||
<label for="onboardingAIBrief">Theme brief</label>
|
||
<textarea id="onboardingAIBrief" class="prose-input" spellcheck="false" placeholder="Example: Make this feel like an internal biotech research workspace. Use mineral blues and slate neutrals, keep the copy technical but clear, and hide GitHub plus the API debugger."></textarea>
|
||
</div>
|
||
<div class="generator-bar">
|
||
<button class="ghost-button generator-secondary-button" id="onboardingGenerateButton" type="button"><span data-icon="sparkles"></span>Generate with AI</button>
|
||
<div class="generator-status" id="onboardingGeneratorStatus"></div>
|
||
</div>
|
||
<details class="advanced-panel">
|
||
<summary>Advanced overrides</summary>
|
||
<div class="advanced-panel-body">
|
||
<div class="field">
|
||
<label for="onboardingBrandName">Brand name</label>
|
||
<input id="onboardingBrandName" autocomplete="off" spellcheck="false" placeholder="PrivateGPT" />
|
||
</div>
|
||
<div class="field">
|
||
<label for="onboardingWelcomeTitle">Welcome title</label>
|
||
<input id="onboardingWelcomeTitle" autocomplete="off" spellcheck="false" placeholder="Ask PrivateGPT" />
|
||
</div>
|
||
<div class="field">
|
||
<label for="onboardingWelcomeSubtitle">Welcome subtitle</label>
|
||
<textarea id="onboardingWelcomeSubtitle" class="prose-input" spellcheck="false" placeholder="Use your local documents, tools, databases, MCP servers, skills, and custom browser tools from one chat."></textarea>
|
||
</div>
|
||
<div class="field">
|
||
<label for="onboardingCustomInstructions">Workspace instructions</label>
|
||
<textarea id="onboardingCustomInstructions" class="prose-input" spellcheck="false" placeholder="Optional. Added to the system prompt so the assistant behaves the way this workspace expects."></textarea>
|
||
</div>
|
||
<div class="field">
|
||
<h3>Colors</h3>
|
||
<small>These update the same runtime CSS variables available later in Settings.</small>
|
||
</div>
|
||
<div class="color-grid">
|
||
<label class="color-field" for="onboardingAccentColor"><span>Accent</span><input id="onboardingAccentColor" type="color" /></label>
|
||
<label class="color-field" for="onboardingSecondaryColor"><span>Secondary</span><input id="onboardingSecondaryColor" type="color" /></label>
|
||
<label class="color-field" for="onboardingSurfaceColor"><span>Surface tint</span><input id="onboardingSurfaceColor" type="color" /></label>
|
||
<label class="color-field" for="onboardingBackgroundColor"><span>Background base</span><input id="onboardingBackgroundColor" type="color" /></label>
|
||
</div>
|
||
<div class="field">
|
||
<h3>Features</h3>
|
||
<small>Use these toggles to simplify the workbench surface.</small>
|
||
</div>
|
||
<div class="feature-grid">
|
||
<label class="toggle-row"><span>Databases</span><input id="onboardingFeatureDatabases" type="checkbox" /></label>
|
||
<label class="toggle-row"><span>Web</span><input id="onboardingFeatureWeb" type="checkbox" /></label>
|
||
<label class="toggle-row"><span>MCP</span><input id="onboardingFeatureMcp" type="checkbox" /></label>
|
||
<label class="toggle-row"><span>Skills</span><input id="onboardingFeatureSkills" type="checkbox" /></label>
|
||
<label class="toggle-row"><span>Custom Tools</span><input id="onboardingFeatureCustomTools" type="checkbox" /></label>
|
||
<label class="toggle-row"><span>API Debugger</span><input id="onboardingFeatureApiDebugger" type="checkbox" /></label>
|
||
</div>
|
||
</div>
|
||
</details>
|
||
</div>
|
||
<div class="button-row onboarding-action-row">
|
||
<button class="primary-button" id="onboardingFinishButton" type="button"><span data-icon="sparkles"></span>Finish setup</button>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<div class="modal-backdrop" id="modalBackdrop">
|
||
<div class="modal-card" id="modalCard"></div>
|
||
</div>
|
||
|
||
<div class="toast-stack" id="toastStack"></div>
|
||
|
||
<script>
|
||
const STORAGE_KEY = "privategpt-workbench-state-v1";
|
||
const DEFAULT_BASE_URL = window.location.origin === "null" ? "http://127.0.0.1:8080" : window.location.origin;
|
||
|
||
const icons = {
|
||
layers: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="m12 3 9 5-9 5-9-5 9-5Z"/><path d="m3 12 9 5 9-5"/><path d="m3 16 9 5 9-5"/></svg>',
|
||
plus: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 5v14M5 12h14"/></svg>',
|
||
chat: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M21 15a4 4 0 0 1-4 4H8l-5 3V7a4 4 0 0 1 4-4h10a4 4 0 0 1 4 4v8Z"/></svg>',
|
||
save: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2Z"/><path d="M17 21v-8H7v8"/><path d="M7 3v5h8"/></svg>',
|
||
eye: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12Z"/><circle cx="12" cy="12" r="3"/></svg>',
|
||
zap: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="m13 2-10 12h8l-1 8 10-12h-8l1-8Z"/></svg>',
|
||
refresh: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M21 12a9 9 0 0 1-15.5 6.2L3 16"/><path d="M3 21v-5h5"/><path d="M3 12A9 9 0 0 1 18.5 5.8L21 8"/><path d="M21 3v5h-5"/></svg>',
|
||
upload: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="M17 8 12 3 7 8"/><path d="M12 3v12"/></svg>',
|
||
send: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="m22 2-7 20-4-9-9-4 20-7Z"/><path d="M22 2 11 13"/></svg>',
|
||
sliders: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M4 21v-7"/><path d="M4 10V3"/><path d="M12 21v-9"/><path d="M12 8V3"/><path d="M20 21v-5"/><path d="M20 12V3"/><path d="M2 14h4"/><path d="M10 8h4"/><path d="M18 16h4"/></svg>',
|
||
info: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="12" r="10"/><path d="M12 11v6"/><circle cx="12" cy="7.5" r="1" fill="currentColor" stroke="none"/></svg>',
|
||
github: '<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 .7a11.3 11.3 0 0 0-3.6 22c.57.1.78-.25.78-.55v-2.1c-3.18.7-3.85-1.36-3.85-1.36-.52-1.32-1.27-1.67-1.27-1.67-1.04-.71.08-.7.08-.7 1.15.08 1.76 1.18 1.76 1.18 1.02 1.75 2.68 1.24 3.33.95.1-.74.4-1.24.72-1.52-2.54-.29-5.2-1.27-5.2-5.64 0-1.25.45-2.26 1.18-3.06-.12-.29-.51-1.45.11-3.02 0 0 .96-.31 3.14 1.17a10.8 10.8 0 0 1 5.72 0c2.18-1.48 3.14-1.17 3.14-1.17.62 1.57.23 2.73.11 3.02.74.8 1.18 1.81 1.18 3.06 0 4.38-2.67 5.35-5.22 5.63.41.36.77 1.05.77 2.12v3.14c0 .31.2.66.79.55A11.3 11.3 0 0 0 12 .7Z"/></svg>',
|
||
star: '<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="m12 2.5 2.9 5.88 6.5.95-4.7 4.58 1.1 6.47L12 17.32l-5.8 3.06 1.1-6.47-4.7-4.58 6.5-.95L12 2.5Z"/></svg>',
|
||
paperclip: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="m21.4 11.6-8.5 8.5a6 6 0 0 1-8.5-8.5l9.2-9.2a4 4 0 0 1 5.7 5.7l-9.2 9.2a2 2 0 0 1-2.8-2.8l8.5-8.5"/></svg>',
|
||
file: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8Z"/><path d="M14 2v6h6"/></svg>',
|
||
database: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><ellipse cx="12" cy="5" rx="8" ry="3"/><path d="M4 5v14c0 1.7 3.6 3 8 3s8-1.3 8-3V5"/><path d="M4 12c0 1.7 3.6 3 8 3s8-1.3 8-3"/></svg>',
|
||
globe: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="12" r="10"/><path d="M2 12h20"/><path d="M12 2a15.3 15.3 0 0 1 0 20"/><path d="M12 2a15.3 15.3 0 0 0 0 20"/></svg>',
|
||
more: '<svg viewBox="0 0 24 24" fill="currentColor"><circle cx="5" cy="12" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="19" cy="12" r="2"/></svg>',
|
||
trash: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M3 6h18"/><path d="M8 6V4h8v2"/><path d="m19 6-1 14H6L5 6"/><path d="M10 11v6M14 11v6"/></svg>',
|
||
edit: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4 12.5-12.5Z"/></svg>',
|
||
'chevron-down': '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="m6 9 6 6 6-6"/></svg>',
|
||
'check': '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M20 6 9 17l-5-5"/></svg>',
|
||
cpu: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><rect x="4" y="4" width="16" height="16" rx="2"/><rect x="9" y="9" width="6" height="6"/><path d="M9 2v2M15 2v2M9 20v2M15 20v2M2 9h2M2 15h2M20 9h2M20 15h2"/></svg>',
|
||
sparkles: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="m12 3 1.6 4.4L18 9l-4.4 1.6L12 15l-1.6-4.4L6 9l4.4-1.6L12 3Z"/><path d="m19 15 .9 2.1L22 18l-2.1.9L19 21l-.9-2.1L16 18l2.1-.9L19 15Z"/><path d="m5 14 .9 2.1L8 17l-2.1.9L5 20l-.9-2.1L2 17l2.1-.9L5 14Z"/></svg>',
|
||
download: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="m7 10 5 5 5-5"/><path d="M12 15V3"/></svg>'
|
||
};
|
||
|
||
const DEFAULT_APPEARANCE = Object.freeze({
|
||
brief: "",
|
||
themeMode: "auto",
|
||
brandName: "PrivateGPT",
|
||
welcomeTitle: "Ask PrivateGPT",
|
||
welcomeSubtitle: "Use your local documents, tools, databases, MCP servers, skills, and custom browser tools from one chat.",
|
||
customInstructions: "",
|
||
palette: {
|
||
accent: "#f0a247",
|
||
secondary: "#70b7ff",
|
||
surface: "#29415c",
|
||
background: "#081427"
|
||
},
|
||
features: {
|
||
databases: true,
|
||
web: true,
|
||
mcp: true,
|
||
skills: true,
|
||
customTools: true,
|
||
apiDebugger: true,
|
||
github: true,
|
||
productionNotice: true
|
||
}
|
||
});
|
||
|
||
const DEFAULT_ONBOARDING = Object.freeze({
|
||
completed: false,
|
||
step: 1,
|
||
appearanceSkipped: false,
|
||
lastCheck: null
|
||
});
|
||
|
||
const defaultCustomTool = () => ({
|
||
id: generateUUID(),
|
||
name: "currency_converter",
|
||
description: "Convert USD to EUR using a locally configured exchange rate.",
|
||
input_schema_json: JSON.stringify({
|
||
type: "object",
|
||
properties: { amount: { type: "number" } },
|
||
required: ["amount"]
|
||
}, null, 2),
|
||
javascript_handler: [
|
||
"async function handle(input, context) {",
|
||
" const rate = Number(context.localStorage.getItem('usd_eur_rate') || '0.92');",
|
||
" return {",
|
||
" type: 'text',",
|
||
" text: `${input.amount} USD is approximately ${input.amount * rate} EUR.`",
|
||
" };",
|
||
"}"
|
||
].join("\n"),
|
||
test_input_json: JSON.stringify({ amount: 100 }, null, 2),
|
||
last_test_result: ""
|
||
});
|
||
|
||
const defaultState = () => ({
|
||
privateGptBaseUrl: DEFAULT_BASE_URL,
|
||
privateGptUsername: "",
|
||
privateGptPassword: "",
|
||
systemPrompt: "",
|
||
useCitations: true,
|
||
useDefaultPrompt: true,
|
||
enableThinking: true,
|
||
enableCodeExecution: true,
|
||
extensions: ["zylon"],
|
||
selectedModel: null,
|
||
models: [],
|
||
uiAppearance: JSON.parse(JSON.stringify(DEFAULT_APPEARANCE)),
|
||
onboarding: JSON.parse(JSON.stringify(DEFAULT_ONBOARDING)),
|
||
context: {
|
||
documents: { defaultCollection: "pgpt_collection", knownArtifacts: [] },
|
||
databases: [],
|
||
mcpServers: [],
|
||
skills: [],
|
||
customTools: [defaultCustomTool()]
|
||
},
|
||
chats: [],
|
||
activeChatId: null
|
||
});
|
||
|
||
let state = loadState();
|
||
let runtime = {
|
||
view: state.activeChatId ? "chat" : "context",
|
||
contextTab: "documents",
|
||
docs: [],
|
||
debugEvents: [],
|
||
citations: new Map(),
|
||
selectedDebugId: null,
|
||
githubStars: null,
|
||
onboardingCheckRunning: false,
|
||
appearanceGeneration: {
|
||
activePrefix: null,
|
||
statusByPrefix: {
|
||
onboarding: "",
|
||
appearance: ""
|
||
}
|
||
},
|
||
sendingChatIds: new Set()
|
||
};
|
||
|
||
const $ = (selector, root = document) => root.querySelector(selector);
|
||
const $$ = (selector, root = document) => Array.from(root.querySelectorAll(selector));
|
||
|
||
function generateUUID() {
|
||
if (crypto.randomUUID) {
|
||
return crypto.randomUUID();
|
||
}
|
||
// Fallback for non-secure contexts or older browsers
|
||
return ([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g, c =>
|
||
(c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16)
|
||
);
|
||
}
|
||
|
||
function loadState() {
|
||
try {
|
||
const raw = localStorage.getItem(STORAGE_KEY);
|
||
if (!raw) return defaultState();
|
||
return migrateState(JSON.parse(raw));
|
||
} catch {
|
||
return defaultState();
|
||
}
|
||
}
|
||
|
||
function migrateState(value) {
|
||
const base = defaultState();
|
||
const merged = {
|
||
...base,
|
||
...value,
|
||
uiAppearance: {
|
||
...base.uiAppearance,
|
||
...(value.uiAppearance || {}),
|
||
palette: {
|
||
...base.uiAppearance.palette,
|
||
...(value.uiAppearance?.palette || {})
|
||
},
|
||
features: {
|
||
...base.uiAppearance.features,
|
||
...(value.uiAppearance?.features || {})
|
||
}
|
||
},
|
||
onboarding: {
|
||
...base.onboarding,
|
||
...(value.onboarding || {})
|
||
},
|
||
context: {
|
||
...base.context,
|
||
...(value.context || {}),
|
||
documents: { ...base.context.documents, ...(value.context?.documents || {}) }
|
||
}
|
||
};
|
||
merged.useCitations = value.useCitations !== false;
|
||
merged.useDefaultPrompt = value.useDefaultPrompt !== false;
|
||
merged.enableThinking = value.enableThinking !== false;
|
||
merged.enableCodeExecution = value.enableCodeExecution !== false;
|
||
merged.extensions = Array.isArray(value.extensions) ? value.extensions : ["zylon"];
|
||
const migratedCredentials =
|
||
decodeBasicAuthHeader(value.privateGptAuthHeader) ||
|
||
decodeBasicAuthHeader(value.privateGptToken) ||
|
||
{
|
||
username: typeof value.privateGptUsername === "string" ? value.privateGptUsername.trim() : "",
|
||
password: typeof value.privateGptPassword === "string" ? value.privateGptPassword : ""
|
||
};
|
||
merged.privateGptUsername = migratedCredentials.username;
|
||
merged.privateGptPassword = migratedCredentials.password;
|
||
if (!Array.isArray(merged.context.customTools) || merged.context.customTools.length === 0) {
|
||
merged.context.customTools = [defaultCustomTool()];
|
||
}
|
||
["databases", "mcpServers", "skills", "customTools"].forEach(bucket => {
|
||
merged.context[bucket] = (merged.context[bucket] || []).map(item => {
|
||
const next = { ...item };
|
||
delete next.enabled;
|
||
return next;
|
||
});
|
||
});
|
||
delete merged.context.web;
|
||
merged.context.customTools = merged.context.customTools.map(tool => {
|
||
if (typeof tool.javascript_handler === "string" && tool.javascript_handler.includes("\\n")) {
|
||
return { ...tool, javascript_handler: tool.javascript_handler.replaceAll("\\n", "\n") };
|
||
}
|
||
return tool;
|
||
});
|
||
if (!Array.isArray(merged.chats)) merged.chats = [];
|
||
if (typeof merged.onboarding.step !== "number" || ![1, 2].includes(merged.onboarding.step)) {
|
||
merged.onboarding.step = 1;
|
||
}
|
||
return merged;
|
||
}
|
||
|
||
function decodeBasicAuthHeader(value) {
|
||
const trimmed = typeof value === "string" ? value.trim() : "";
|
||
const match = /^Basic\s+(\S+)$/i.exec(trimmed);
|
||
if (!match) return null;
|
||
try {
|
||
const decoded = decodeUtf8Base64(match[1]);
|
||
const separator = decoded.indexOf(":");
|
||
if (separator < 0) return null;
|
||
return {
|
||
username: decoded.slice(0, separator),
|
||
password: decoded.slice(separator + 1)
|
||
};
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function encodeUtf8Base64(value) {
|
||
const bytes = new TextEncoder().encode(value);
|
||
let binary = "";
|
||
bytes.forEach(byte => {
|
||
binary += String.fromCharCode(byte);
|
||
});
|
||
return btoa(binary);
|
||
}
|
||
|
||
function decodeUtf8Base64(value) {
|
||
const binary = atob(value);
|
||
const bytes = Uint8Array.from(binary, char => char.charCodeAt(0));
|
||
return new TextDecoder().decode(bytes);
|
||
}
|
||
|
||
function createBasicAuthHeader(username, password) {
|
||
if (!username && !password) return "";
|
||
return `Basic ${encodeUtf8Base64(`${username}:${password}`)}`;
|
||
}
|
||
|
||
function appearanceFormIds(prefix) {
|
||
return {
|
||
brief: `${prefix}AIBrief`,
|
||
brandName: `${prefix}BrandName`,
|
||
welcomeTitle: `${prefix}WelcomeTitle`,
|
||
welcomeSubtitle: `${prefix}WelcomeSubtitle`,
|
||
customInstructions: `${prefix}CustomInstructions`,
|
||
accent: `${prefix}AccentColor`,
|
||
secondary: `${prefix}SecondaryColor`,
|
||
surface: `${prefix}SurfaceColor`,
|
||
background: `${prefix}BackgroundColor`,
|
||
features: {
|
||
databases: `${prefix}FeatureDatabases`,
|
||
web: `${prefix}FeatureWeb`,
|
||
mcp: `${prefix}FeatureMcp`,
|
||
skills: `${prefix}FeatureSkills`,
|
||
customTools: `${prefix}FeatureCustomTools`,
|
||
apiDebugger: `${prefix}FeatureApiDebugger`,
|
||
github: `${prefix}FeatureGithub`,
|
||
productionNotice: `${prefix}FeatureProductionNotice`
|
||
}
|
||
};
|
||
}
|
||
|
||
function hexToRgb(value) {
|
||
const normalized = String(value || "").trim().replace("#", "");
|
||
if (!/^[0-9a-fA-F]{6}$/.test(normalized)) return { r: 0, g: 0, b: 0 };
|
||
return {
|
||
r: parseInt(normalized.slice(0, 2), 16),
|
||
g: parseInt(normalized.slice(2, 4), 16),
|
||
b: parseInt(normalized.slice(4, 6), 16)
|
||
};
|
||
}
|
||
|
||
function rgbToHex({ r, g, b }) {
|
||
return `#${[r, g, b].map(channel => Math.max(0, Math.min(255, Math.round(channel))).toString(16).padStart(2, "0")).join("")}`;
|
||
}
|
||
|
||
function shiftHex(value, delta) {
|
||
const { r, g, b } = hexToRgb(value);
|
||
return rgbToHex({ r: r + delta, g: g + delta, b: b + delta });
|
||
}
|
||
|
||
function mixHex(base, target, amount = 0.5) {
|
||
const a = hexToRgb(base);
|
||
const b = hexToRgb(target);
|
||
const t = Math.max(0, Math.min(1, amount));
|
||
return rgbToHex({
|
||
r: a.r + (b.r - a.r) * t,
|
||
g: a.g + (b.g - a.g) * t,
|
||
b: a.b + (b.b - a.b) * t
|
||
});
|
||
}
|
||
|
||
function withAlpha(value, alpha) {
|
||
const { r, g, b } = hexToRgb(value);
|
||
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
||
}
|
||
|
||
function relativeLuminance(value) {
|
||
const { r, g, b } = hexToRgb(value);
|
||
const convert = channel => {
|
||
const c = channel / 255;
|
||
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
|
||
};
|
||
const [rr, gg, bb] = [convert(r), convert(g), convert(b)];
|
||
return 0.2126 * rr + 0.7152 * gg + 0.0722 * bb;
|
||
}
|
||
|
||
function contrastRatio(a, b) {
|
||
const l1 = relativeLuminance(a);
|
||
const l2 = relativeLuminance(b);
|
||
const lighter = Math.max(l1, l2);
|
||
const darker = Math.min(l1, l2);
|
||
return (lighter + 0.05) / (darker + 0.05);
|
||
}
|
||
|
||
function featureEnabled(key) {
|
||
if (key === "github") return true;
|
||
return state.uiAppearance?.features?.[key] !== false;
|
||
}
|
||
|
||
function assistantBrandName() {
|
||
return state.uiAppearance?.brandName?.trim() || DEFAULT_APPEARANCE.brandName;
|
||
}
|
||
|
||
function workspaceWelcomeTitle() {
|
||
return state.uiAppearance?.welcomeTitle?.trim() || DEFAULT_APPEARANCE.welcomeTitle;
|
||
}
|
||
|
||
function workspaceWelcomeSubtitle() {
|
||
return state.uiAppearance?.welcomeSubtitle?.trim() || DEFAULT_APPEARANCE.welcomeSubtitle;
|
||
}
|
||
|
||
function readAppearanceForm(prefix) {
|
||
const ids = appearanceFormIds(prefix);
|
||
return {
|
||
brief: $(`#${ids.brief}`)?.value.trim() || "",
|
||
themeMode: state.uiAppearance?.themeMode || DEFAULT_APPEARANCE.themeMode,
|
||
brandName: $(`#${ids.brandName}`)?.value.trim() || DEFAULT_APPEARANCE.brandName,
|
||
welcomeTitle: $(`#${ids.welcomeTitle}`)?.value.trim() || DEFAULT_APPEARANCE.welcomeTitle,
|
||
welcomeSubtitle: $(`#${ids.welcomeSubtitle}`)?.value.trim() || DEFAULT_APPEARANCE.welcomeSubtitle,
|
||
customInstructions: $(`#${ids.customInstructions}`)?.value.trim() || "",
|
||
palette: {
|
||
accent: $(`#${ids.accent}`)?.value || DEFAULT_APPEARANCE.palette.accent,
|
||
secondary: $(`#${ids.secondary}`)?.value || DEFAULT_APPEARANCE.palette.secondary,
|
||
surface: $(`#${ids.surface}`)?.value || DEFAULT_APPEARANCE.palette.surface,
|
||
background: $(`#${ids.background}`)?.value || DEFAULT_APPEARANCE.palette.background
|
||
},
|
||
features: Object.fromEntries(
|
||
Object.entries(ids.features).map(([key, id]) => [key, $(`#${id}`)?.checked !== false])
|
||
)
|
||
};
|
||
}
|
||
|
||
function appearanceBriefHasContent(prefix) {
|
||
return Boolean($(`#${appearanceFormIds(prefix).brief}`)?.value.trim());
|
||
}
|
||
|
||
function populateAppearanceForm(prefix) {
|
||
const ids = appearanceFormIds(prefix);
|
||
const appearance = state.uiAppearance || DEFAULT_APPEARANCE;
|
||
const setValue = (id, value) => { const el = $(`#${id}`); if (el) el.value = value; };
|
||
const setChecked = (id, value) => { const el = $(`#${id}`); if (el) el.checked = value !== false; };
|
||
setValue(ids.brief, appearance.brief || "");
|
||
setValue(ids.brandName, appearance.brandName || DEFAULT_APPEARANCE.brandName);
|
||
setValue(ids.welcomeTitle, appearance.welcomeTitle || DEFAULT_APPEARANCE.welcomeTitle);
|
||
setValue(ids.welcomeSubtitle, appearance.welcomeSubtitle || DEFAULT_APPEARANCE.welcomeSubtitle);
|
||
setValue(ids.customInstructions, appearance.customInstructions || "");
|
||
setValue(ids.accent, appearance.palette?.accent || DEFAULT_APPEARANCE.palette.accent);
|
||
setValue(ids.secondary, appearance.palette?.secondary || DEFAULT_APPEARANCE.palette.secondary);
|
||
setValue(ids.surface, appearance.palette?.surface || DEFAULT_APPEARANCE.palette.surface);
|
||
setValue(ids.background, appearance.palette?.background || DEFAULT_APPEARANCE.palette.background);
|
||
Object.entries(ids.features).forEach(([key, id]) => {
|
||
setChecked(id, appearance.features?.[key] !== false);
|
||
});
|
||
}
|
||
|
||
function applyAppearanceState(nextAppearance) {
|
||
state.uiAppearance = {
|
||
...DEFAULT_APPEARANCE,
|
||
...nextAppearance,
|
||
palette: {
|
||
...DEFAULT_APPEARANCE.palette,
|
||
...(nextAppearance?.palette || {})
|
||
},
|
||
features: {
|
||
...DEFAULT_APPEARANCE.features,
|
||
...(nextAppearance?.features || {})
|
||
}
|
||
};
|
||
state.uiAppearance.features.github = true;
|
||
applyAppearance();
|
||
}
|
||
|
||
function paintAppearance(appearanceInput) {
|
||
const appearance = appearanceInput || DEFAULT_APPEARANCE;
|
||
const palette = appearance.palette || DEFAULT_APPEARANCE.palette;
|
||
const brandName = appearance.brandName?.trim() || DEFAULT_APPEARANCE.brandName;
|
||
const root = document.documentElement;
|
||
const requestedMode = ["light", "dark", "auto"].includes(appearance.themeMode) ? appearance.themeMode : "auto";
|
||
const inferredLightTheme = contrastRatio(palette.background || DEFAULT_APPEARANCE.palette.background, "#ffffff") < 4.5;
|
||
const isLightTheme = requestedMode === "light" ? true : requestedMode === "dark" ? false : inferredLightTheme;
|
||
const textColor = isLightTheme ? "#0f172a" : "#f7f7fb";
|
||
const mutedColor = isLightTheme ? "rgba(15, 23, 42, 0.68)" : "rgba(255, 255, 255, 0.58)";
|
||
const mutedStrongColor = isLightTheme ? "rgba(15, 23, 42, 0.82)" : "rgba(255, 255, 255, 0.74)";
|
||
const faintColor = isLightTheme ? "rgba(15, 23, 42, 0.42)" : "rgba(255, 255, 255, 0.36)";
|
||
const glassColor = isLightTheme ? "rgba(255, 255, 255, 0.62)" : "rgba(255, 255, 255, 0.10)";
|
||
const glassSoftColor = isLightTheme ? "rgba(255, 255, 255, 0.46)" : "rgba(255, 255, 255, 0.07)";
|
||
const glassStrongColor = isLightTheme ? "rgba(255, 255, 255, 0.78)" : "rgba(255, 255, 255, 0.16)";
|
||
const borderColor = isLightTheme ? "rgba(15, 23, 42, 0.14)" : "rgba(255, 255, 255, 0.24)";
|
||
const borderSoftColor = isLightTheme ? "rgba(15, 23, 42, 0.08)" : "rgba(255, 255, 255, 0.14)";
|
||
const shadowColor = isLightTheme ? "0 24px 80px rgba(15, 23, 42, 0.14)" : "0 24px 80px rgba(0, 0, 0, 0.38)";
|
||
root.style.setProperty("--accent", palette.accent || DEFAULT_APPEARANCE.palette.accent);
|
||
root.style.setProperty("--blue", palette.secondary || DEFAULT_APPEARANCE.palette.secondary);
|
||
root.style.setProperty("--text", textColor);
|
||
root.style.setProperty("--muted", mutedColor);
|
||
root.style.setProperty("--muted-strong", mutedStrongColor);
|
||
root.style.setProperty("--faint", faintColor);
|
||
root.style.setProperty("--glass", glassColor);
|
||
root.style.setProperty("--glass-soft", glassSoftColor);
|
||
root.style.setProperty("--glass-strong", glassStrongColor);
|
||
root.style.setProperty("--border", borderColor);
|
||
root.style.setProperty("--border-soft", borderSoftColor);
|
||
root.style.setProperty("--shadow", shadowColor);
|
||
root.style.setProperty("--surface-tint", withAlpha(palette.surface || DEFAULT_APPEARANCE.palette.surface, 0.18));
|
||
root.style.setProperty("--surface-tint-strong", withAlpha(palette.surface || DEFAULT_APPEARANCE.palette.surface, 0.28));
|
||
root.style.setProperty("--bg-start", isLightTheme
|
||
? mixHex(palette.background || DEFAULT_APPEARANCE.palette.background, "#ffffff", 0.18)
|
||
: shiftHex(palette.background || DEFAULT_APPEARANCE.palette.background, -10));
|
||
root.style.setProperty("--bg-mid", isLightTheme
|
||
? mixHex(palette.surface || DEFAULT_APPEARANCE.palette.surface, "#ffffff", 0.38)
|
||
: shiftHex(palette.surface || DEFAULT_APPEARANCE.palette.surface, -26));
|
||
root.style.setProperty("--bg-end", isLightTheme
|
||
? mixHex(palette.accent || DEFAULT_APPEARANCE.palette.accent, "#ffffff", 0.62)
|
||
: shiftHex(palette.accent || DEFAULT_APPEARANCE.palette.accent, -62));
|
||
root.style.setProperty("--bg-glow-a", withAlpha(palette.surface || DEFAULT_APPEARANCE.palette.surface, isLightTheme ? 0.22 : 0.55));
|
||
root.style.setProperty("--bg-glow-b", withAlpha(palette.accent || DEFAULT_APPEARANCE.palette.accent, isLightTheme ? 0.18 : 0.34));
|
||
root.style.setProperty("--bg-glow-c", withAlpha(palette.secondary || DEFAULT_APPEARANCE.palette.secondary, isLightTheme ? 0.16 : 0.28));
|
||
root.style.setProperty("--sidebar-wash-top", withAlpha(
|
||
isLightTheme
|
||
? mixHex(palette.surface || DEFAULT_APPEARANCE.palette.surface, "#ffffff", 0.74)
|
||
: shiftHex(palette.surface || DEFAULT_APPEARANCE.palette.surface, -24),
|
||
isLightTheme ? 0.94 : 0.92
|
||
));
|
||
root.style.setProperty("--sidebar-wash-bottom", withAlpha(
|
||
isLightTheme
|
||
? mixHex(palette.surface || DEFAULT_APPEARANCE.palette.surface, "#ffffff", 0.52)
|
||
: shiftHex(palette.surface || DEFAULT_APPEARANCE.palette.surface, -8),
|
||
isLightTheme ? 0.82 : 0.74
|
||
));
|
||
root.style.setProperty("--sidebar-glow", withAlpha(palette.secondary || DEFAULT_APPEARANCE.palette.secondary, isLightTheme ? 0.14 : 0.24));
|
||
document.body.dataset.themeMode = isLightTheme ? "light" : "dark";
|
||
const brandLogo = $("#brandLogo");
|
||
if (brandLogo) {
|
||
brandLogo.src = isLightTheme
|
||
? "./references/logo-privategpt-wordmark-dark-transparent.svg"
|
||
: "./references/logo-privategpt-wordmark-white-transparent.svg";
|
||
}
|
||
document.title = `${brandName} Workbench`;
|
||
$("#brandNameText") && ($("#brandNameText").textContent = brandName);
|
||
$("#brandSurfaceText") && ($("#brandSurfaceText").textContent = "Workbench");
|
||
}
|
||
|
||
function applyAppearance() {
|
||
paintAppearance(state.uiAppearance || DEFAULT_APPEARANCE);
|
||
}
|
||
|
||
function combinedSystemPrompt() {
|
||
return [state.uiAppearance?.customInstructions || "", state.systemPrompt || ""]
|
||
.map(value => value.trim())
|
||
.filter(Boolean)
|
||
.join("\n\n");
|
||
}
|
||
|
||
function ensureVisibleView() {
|
||
if (runtime.view === "apiDebugger" && !featureEnabled("apiDebugger")) {
|
||
runtime.view = "settings";
|
||
}
|
||
if (!CONTEXT_TAB_IDS.has(runtime.contextTab) || !contextSections().some(([id]) => id === runtime.contextTab)) {
|
||
runtime.contextTab = contextSections()[0]?.[0] || "documents";
|
||
}
|
||
}
|
||
|
||
function saveState() {
|
||
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
|
||
}
|
||
|
||
function activeChat() {
|
||
return state.chats.find(chat => chat.id === state.activeChatId) || null;
|
||
}
|
||
|
||
function defaultChatSettings() {
|
||
return {
|
||
enabledDocuments: true,
|
||
enabledDatabases: [],
|
||
enabledWeb: false,
|
||
enabledTabular: false,
|
||
enabledCodeExecution: false,
|
||
enabledMcpServers: [],
|
||
enabledSkills: [],
|
||
enabledCustomTools: [],
|
||
enabledThinking: false
|
||
};
|
||
}
|
||
|
||
function createChat() {
|
||
const chat = {
|
||
id: generateUUID(),
|
||
title: "New chat",
|
||
createdAt: new Date().toISOString(),
|
||
updatedAt: new Date().toISOString(),
|
||
messages: [],
|
||
settings: defaultChatSettings()
|
||
};
|
||
state.chats.unshift(chat);
|
||
state.activeChatId = chat.id;
|
||
runtime.view = "chat";
|
||
saveState();
|
||
render();
|
||
setTimeout(() => $("#composerInput")?.focus(), 40);
|
||
}
|
||
|
||
function getDebugEvents() {
|
||
return runtime.debugEvents;
|
||
}
|
||
|
||
function addDebugEvent(type, payload = {}) {
|
||
const event = {
|
||
id: generateUUID(),
|
||
type,
|
||
timestamp: new Date().toISOString(),
|
||
...payload
|
||
};
|
||
getDebugEvents().push(event);
|
||
runtime.selectedDebugId = event.id;
|
||
if (runtime.view === "apiDebugger") renderDebugger();
|
||
return event;
|
||
}
|
||
|
||
function updateDebugEvent(eventId, patch = {}) {
|
||
if (!eventId) return null;
|
||
const events = getDebugEvents();
|
||
const event = events.find(item => item.id === eventId);
|
||
if (!event) return null;
|
||
Object.assign(event, patch);
|
||
if (runtime.view === "apiDebugger") renderDebugger();
|
||
return event;
|
||
}
|
||
|
||
function toast(message, tone = "", options = {}) {
|
||
const node = document.createElement("div");
|
||
$("#toastStack").appendChild(node);
|
||
let timeoutId = null;
|
||
|
||
const apply = (nextMessage, nextTone = "", nextOptions = {}) => {
|
||
node.className = `toast ${nextTone}`;
|
||
node.textContent = nextMessage;
|
||
if (timeoutId) clearTimeout(timeoutId);
|
||
const autoClose = nextOptions.autoClose ?? !options.persistent;
|
||
if (autoClose) {
|
||
timeoutId = setTimeout(() => node.remove(), nextOptions.duration || 4200);
|
||
}
|
||
};
|
||
|
||
apply(message, tone, { autoClose: !options.persistent, duration: options.duration });
|
||
return {
|
||
update(nextMessage, nextTone = "", nextOptions = {}) {
|
||
apply(nextMessage, nextTone, { autoClose: true, ...nextOptions });
|
||
},
|
||
remove() {
|
||
if (timeoutId) clearTimeout(timeoutId);
|
||
node.remove();
|
||
},
|
||
node
|
||
};
|
||
}
|
||
|
||
function renderIcons(root = document) {
|
||
$$("[data-icon]", root).forEach(node => {
|
||
const name = node.getAttribute("data-icon");
|
||
node.innerHTML = icons[name] || "";
|
||
});
|
||
}
|
||
|
||
const CONTEXT_TAB_IDS = new Set(["documents", "databases", "web", "mcp", "skills", "customTools", "codeExecution"]);
|
||
|
||
function syncHash() {
|
||
let hash;
|
||
if (runtime.view === "chat" && state.activeChatId) {
|
||
hash = `#chat/${state.activeChatId}`;
|
||
} else if (runtime.view === "context") {
|
||
hash = `#context/${runtime.contextTab}`;
|
||
} else {
|
||
hash = `#${runtime.view}`;
|
||
}
|
||
if (location.hash !== hash) history.replaceState(null, "", hash);
|
||
}
|
||
|
||
function restoreFromHash() {
|
||
const hash = location.hash.slice(1);
|
||
if (!hash) return;
|
||
if (hash.startsWith("chat/")) {
|
||
const chatId = hash.slice(5);
|
||
if (state.chats.find(c => c.id === chatId)) {
|
||
state.activeChatId = chatId;
|
||
runtime.view = "chat";
|
||
}
|
||
} else if (hash.startsWith("context/")) {
|
||
const tab = hash.slice(8);
|
||
runtime.view = "context";
|
||
if (CONTEXT_TAB_IDS.has(tab)) runtime.contextTab = tab;
|
||
} else if (["context", "settings", "apiDebugger"].includes(hash)) {
|
||
runtime.view = hash;
|
||
}
|
||
}
|
||
|
||
function render() {
|
||
ensureVisibleView();
|
||
applyAppearance();
|
||
renderSidebar();
|
||
renderConnectionForm();
|
||
renderContextTabs();
|
||
renderContextPanels();
|
||
renderChat();
|
||
renderOnboarding();
|
||
renderIcons();
|
||
syncHash();
|
||
}
|
||
|
||
function renderSidebar() {
|
||
$("#contextNav").classList.toggle("active", runtime.view === "context");
|
||
$("#apiDebuggerNav").classList.toggle("active", runtime.view === "apiDebugger");
|
||
$("#settingsNav").classList.toggle("active", runtime.view === "settings");
|
||
$("#apiDebuggerNav").hidden = !featureEnabled("apiDebugger");
|
||
$("#githubWidget").hidden = !featureEnabled("github");
|
||
$("#productionNoticeButton").hidden = !featureEnabled("productionNotice");
|
||
renderGitHubStars();
|
||
const list = $("#chatList");
|
||
list.innerHTML = "";
|
||
if (!state.chats.length) {
|
||
list.innerHTML = '<div class="empty-state" style="min-height:120px;">No chats yet.</div>';
|
||
return;
|
||
}
|
||
[...state.chats].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)).forEach(chat => {
|
||
const row = document.createElement("button");
|
||
row.type = "button";
|
||
row.className = `chat-row ${chat.id === state.activeChatId && runtime.view === "chat" ? "active" : ""}`;
|
||
row.innerHTML = `
|
||
<span data-icon="chat"></span>
|
||
<span class="chat-row-title"></span>
|
||
<span class="row-menu">
|
||
<span class="mini-button" data-action="rename" title="Rename">${icons.edit}</span>
|
||
<span class="mini-button" data-action="delete" title="Delete">${icons.trash}</span>
|
||
</span>
|
||
`;
|
||
$(".chat-row-title", row).textContent = chat.title || "New chat";
|
||
row.addEventListener("click", event => {
|
||
const action = event.target.closest("[data-action]")?.dataset.action;
|
||
if (action === "rename") {
|
||
event.stopPropagation();
|
||
renameChat(chat.id);
|
||
return;
|
||
}
|
||
if (action === "delete") {
|
||
event.stopPropagation();
|
||
deleteChat(chat.id);
|
||
return;
|
||
}
|
||
state.activeChatId = chat.id;
|
||
runtime.view = "chat";
|
||
runtime.selectedDebugId = getDebugEvents(chat.id).at(-1)?.id || null;
|
||
saveState();
|
||
render();
|
||
});
|
||
list.appendChild(row);
|
||
});
|
||
renderIcons(list);
|
||
updateChatListFade();
|
||
}
|
||
|
||
function renderGitHubStars() {
|
||
const target = $("#githubStars");
|
||
if (!target) return;
|
||
target.textContent = runtime.githubStars == null ? "Stars" : compactNumber(runtime.githubStars);
|
||
}
|
||
|
||
function renameChat(chatId) {
|
||
const chat = state.chats.find(item => item.id === chatId);
|
||
if (!chat) return;
|
||
openModal("Rename chat", `
|
||
<div class="field">
|
||
<label for="chatTitleInput">Name</label>
|
||
<input id="chatTitleInput" name="title" class="prose-input" type="text" value="${escapeHtml(chat.title || "New chat")}" autocomplete="off" autofocus>
|
||
</div>
|
||
`, ({ title }) => {
|
||
chat.title = (title || "").trim() || "New chat";
|
||
chat.updatedAt = new Date().toISOString();
|
||
saveState();
|
||
render();
|
||
closeModal();
|
||
});
|
||
}
|
||
|
||
function deleteChat(chatId) {
|
||
const chat = state.chats.find(item => item.id === chatId);
|
||
if (!chat) return;
|
||
openConfirmModal(
|
||
"Delete chat",
|
||
`Delete "${escapeHtml(chat.title || "New chat")}"? This cannot be undone.`,
|
||
() => {
|
||
state.chats = state.chats.filter(item => item.id !== chatId);
|
||
if (state.activeChatId === chatId) {
|
||
state.activeChatId = state.chats[0]?.id || null;
|
||
runtime.view = state.activeChatId ? "chat" : "context";
|
||
}
|
||
saveState();
|
||
render();
|
||
closeModal();
|
||
}
|
||
);
|
||
}
|
||
|
||
function renderConnectionForm() {
|
||
$("#privateGptBaseUrl").value = state.privateGptBaseUrl || DEFAULT_BASE_URL;
|
||
$("#privateGptUsername").value = state.privateGptUsername || "";
|
||
$("#privateGptPassword").value = state.privateGptPassword || "";
|
||
$("#systemPrompt").value = state.systemPrompt || "";
|
||
$("#useCitations").checked = state.useCitations !== false;
|
||
$("#useDefaultPrompt").checked = state.useDefaultPrompt !== false;
|
||
$("#enableThinking").checked = state.enableThinking !== false;
|
||
$("#enableCodeExecution").checked = state.enableCodeExecution !== false;
|
||
$("#extensions").value = (state.extensions || []).join(", ");
|
||
$("#defaultCollection").value = state.context.documents.defaultCollection || "pgpt_collection";
|
||
$("#onboardingBaseUrl").value = state.privateGptBaseUrl || DEFAULT_BASE_URL;
|
||
$("#onboardingUsername").value = state.privateGptUsername || "";
|
||
$("#onboardingPassword").value = state.privateGptPassword || "";
|
||
$("#onboardingCollection").value = state.context.documents.defaultCollection || "pgpt_collection";
|
||
populateAppearanceForm("appearance");
|
||
populateAppearanceForm("onboarding");
|
||
setAppearanceGeneratorStatus("appearance", runtime.appearanceGeneration.statusByPrefix.appearance || "");
|
||
setAppearanceGeneratorStatus("onboarding", runtime.appearanceGeneration.statusByPrefix.onboarding || "");
|
||
updateOnboardingPreview();
|
||
}
|
||
|
||
function contextSections() {
|
||
return [
|
||
["documents", "Documents", "file"],
|
||
...(featureEnabled("databases") ? [["databases", "Databases", "database"]] : []),
|
||
...(featureEnabled("web") ? [["web", "Web", "globe"]] : []),
|
||
...(featureEnabled("mcp") ? [["mcp", "MCP", "layers"]] : []),
|
||
...(featureEnabled("skills") ? [["skills", "Skills", "zap"]] : []),
|
||
...(featureEnabled("customTools") ? [["customTools", "Custom Tools", "sliders"]] : []),
|
||
...(state.enableCodeExecution !== false ? [["codeExecution", "Code Execution", "cpu"]] : [])
|
||
];
|
||
}
|
||
|
||
function renderContextTabs() {
|
||
const tabs = $("#contextTabs");
|
||
tabs.innerHTML = "";
|
||
contextSections().forEach(([id, label, icon]) => {
|
||
const button = document.createElement("button");
|
||
button.className = `pill ${runtime.contextTab === id ? "active" : ""}`;
|
||
button.type = "button";
|
||
button.innerHTML = `<span data-icon="${icon}"></span>${label}`;
|
||
button.addEventListener("click", () => {
|
||
runtime.contextTab = id;
|
||
renderContextTabs();
|
||
renderContextPanels();
|
||
syncHash();
|
||
});
|
||
tabs.appendChild(button);
|
||
});
|
||
renderIcons(tabs);
|
||
}
|
||
|
||
function renderContextPanels() {
|
||
const visiblePanels = new Set(contextSections().map(([id]) => id));
|
||
$$(".context-panel").forEach(panel => {
|
||
panel.hidden = !visiblePanels.has(panel.dataset.contextPanel);
|
||
panel.classList.toggle("active", panel.dataset.contextPanel === runtime.contextTab);
|
||
});
|
||
renderDocuments();
|
||
renderDatabases();
|
||
renderMcp();
|
||
renderSkills();
|
||
renderCustomTools();
|
||
renderCodeExecutionPanel();
|
||
}
|
||
|
||
function updateOnboardingPreview() {
|
||
paintAppearance(normalizeAppearanceSuggestion(readAppearanceForm("onboarding"), readAppearanceForm("onboarding").brief || ""));
|
||
}
|
||
|
||
function setAppearanceGeneratorStatus(prefix, message = "") {
|
||
runtime.appearanceGeneration.statusByPrefix[prefix] = message;
|
||
const node = $(`#${prefix}GeneratorStatus`);
|
||
if (node) node.textContent = message;
|
||
const button = $(`#${prefix}GenerateButton`);
|
||
if (button) {
|
||
button.disabled = runtime.appearanceGeneration.activePrefix === prefix || (prefix === "onboarding" && !appearanceBriefHasContent(prefix));
|
||
}
|
||
}
|
||
|
||
function appearanceGeneratorPrompt(brief) {
|
||
return [
|
||
"Return one JSON object only.",
|
||
"Generate a UI appearance proposal for a local AI workspace.",
|
||
"Respect the user's brief, but keep the result practical and readable.",
|
||
"All colors must be 6-digit hex strings.",
|
||
"Respond with this exact shape:",
|
||
"{",
|
||
' "brief": "string",',
|
||
' "themeMode": "dark" | "light",',
|
||
' "brandName": "string",',
|
||
' "welcomeTitle": "string",',
|
||
' "welcomeSubtitle": "string",',
|
||
' "customInstructions": "string",',
|
||
' "palette": { "accent": "#RRGGBB", "secondary": "#RRGGBB", "surface": "#RRGGBB", "background": "#RRGGBB" },',
|
||
' "features": { "databases": true, "web": true, "mcp": true, "skills": true, "customTools": true, "apiDebugger": true, "github": true, "productionNotice": true }',
|
||
"}",
|
||
"The brand name should be short.",
|
||
"The welcome title should be concise.",
|
||
"The welcome subtitle should be one sentence.",
|
||
"The custom instructions should guide the assistant to fit the described workspace.",
|
||
"Choose dark or light mode intentionally based on the brief and the palette.",
|
||
"The github field must always stay true.",
|
||
"Disable features only when the brief clearly implies a simpler surface.",
|
||
"",
|
||
`User brief: ${brief}`
|
||
].join("\n");
|
||
}
|
||
|
||
function extractJsonObject(text) {
|
||
const trimmed = String(text || "").trim();
|
||
if (!trimmed) throw new Error("Empty response from generator.");
|
||
try {
|
||
return JSON.parse(trimmed);
|
||
} catch {}
|
||
const fenced = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i);
|
||
if (fenced) {
|
||
try { return JSON.parse(fenced[1].trim()); } catch {}
|
||
}
|
||
const start = trimmed.indexOf("{");
|
||
const end = trimmed.lastIndexOf("}");
|
||
if (start >= 0 && end > start) {
|
||
return JSON.parse(trimmed.slice(start, end + 1));
|
||
}
|
||
throw new Error("Generator did not return valid JSON.");
|
||
}
|
||
|
||
function normalizeAppearanceSuggestion(candidate, fallbackBrief = "") {
|
||
const appearance = {
|
||
...DEFAULT_APPEARANCE,
|
||
...candidate,
|
||
brief: typeof candidate?.brief === "string" && candidate.brief.trim() ? candidate.brief.trim() : fallbackBrief,
|
||
themeMode: ["light", "dark", "auto"].includes(candidate?.themeMode) ? candidate.themeMode : DEFAULT_APPEARANCE.themeMode,
|
||
palette: {
|
||
...DEFAULT_APPEARANCE.palette,
|
||
...(candidate?.palette || {})
|
||
},
|
||
features: {
|
||
...DEFAULT_APPEARANCE.features,
|
||
...(candidate?.features || {})
|
||
}
|
||
};
|
||
["accent", "secondary", "surface", "background"].forEach(key => {
|
||
const value = String(appearance.palette[key] || "").trim();
|
||
appearance.palette[key] = /^[#][0-9a-fA-F]{6}$/.test(value) ? value : DEFAULT_APPEARANCE.palette[key];
|
||
});
|
||
Object.keys(DEFAULT_APPEARANCE.features).forEach(key => {
|
||
appearance.features[key] = appearance.features[key] !== false;
|
||
});
|
||
appearance.features.github = true;
|
||
return appearance;
|
||
}
|
||
|
||
function applyAppearanceToForm(prefix, appearance) {
|
||
const ids = appearanceFormIds(prefix);
|
||
const setValue = (id, value) => { const node = $(`#${id}`); if (node) node.value = value; };
|
||
const setChecked = (id, value) => { const node = $(`#${id}`); if (node) node.checked = value !== false; };
|
||
setValue(ids.brief, appearance.brief || "");
|
||
setValue(ids.brandName, appearance.brandName || DEFAULT_APPEARANCE.brandName);
|
||
setValue(ids.welcomeTitle, appearance.welcomeTitle || DEFAULT_APPEARANCE.welcomeTitle);
|
||
setValue(ids.welcomeSubtitle, appearance.welcomeSubtitle || DEFAULT_APPEARANCE.welcomeSubtitle);
|
||
setValue(ids.customInstructions, appearance.customInstructions || "");
|
||
setValue(ids.accent, appearance.palette?.accent || DEFAULT_APPEARANCE.palette.accent);
|
||
setValue(ids.secondary, appearance.palette?.secondary || DEFAULT_APPEARANCE.palette.secondary);
|
||
setValue(ids.surface, appearance.palette?.surface || DEFAULT_APPEARANCE.palette.surface);
|
||
setValue(ids.background, appearance.palette?.background || DEFAULT_APPEARANCE.palette.background);
|
||
Object.entries(ids.features).forEach(([key, id]) => {
|
||
setChecked(id, appearance.features?.[key] !== false);
|
||
});
|
||
}
|
||
|
||
function renderOnboarding() {
|
||
const overlay = $("#onboardingOverlay");
|
||
const isOpen = state.onboarding?.completed !== true;
|
||
const step = state.onboarding?.step === 2 ? 2 : 1;
|
||
overlay.classList.toggle("open", isOpen);
|
||
overlay.setAttribute("aria-hidden", String(!isOpen));
|
||
$("#onboardingStep1").classList.toggle("active", step === 1);
|
||
$("#onboardingStep2").classList.toggle("active", step === 2);
|
||
$("#onboardingStepPill1").classList.toggle("active", step === 1);
|
||
$("#onboardingStepPill2").classList.toggle("active", step === 2);
|
||
const check = state.onboarding?.lastCheck;
|
||
$("#onboardingCheckButton").disabled = runtime.onboardingCheckRunning;
|
||
$("#onboardingGenerateButton").disabled = runtime.appearanceGeneration.activePrefix === "onboarding" || !appearanceBriefHasContent("onboarding");
|
||
$("#appearanceGenerateButton") && ($("#appearanceGenerateButton").disabled = runtime.appearanceGeneration.activePrefix === "appearance");
|
||
$("#onboardingGeneratorStatus").textContent = runtime.appearanceGeneration.statusByPrefix.onboarding || "";
|
||
$("#appearanceGeneratorStatus") && ($("#appearanceGeneratorStatus").textContent = runtime.appearanceGeneration.statusByPrefix.appearance || "");
|
||
const checkList = $("#onboardingCheckChips");
|
||
const item = document.createElement("span");
|
||
const toneClass = runtime.onboardingCheckRunning ? "checking" : check?.ok === true ? "ok" : check?.ok === false || check?.steps?.some(row => row.ok === false) ? "error" : "";
|
||
const toneLabel = runtime.onboardingCheckRunning
|
||
? "Checking"
|
||
: check?.ok === true
|
||
? "All good"
|
||
: check?.ok === false || check?.steps?.some(row => row.ok === false)
|
||
? "Error"
|
||
: "Not checked";
|
||
checkList.innerHTML = "";
|
||
item.className = `check-state ${toneClass}`.trim();
|
||
item.textContent = toneLabel;
|
||
checkList.appendChild(item);
|
||
updateOnboardingPreview();
|
||
}
|
||
|
||
function onboardingErrorToastMessage(check, fallback) {
|
||
const errors = (check?.steps || [])
|
||
.filter(step => step.ok === false)
|
||
.map(step => `${step.label}: ${step.detail}`);
|
||
return errors.length ? errors.join(" ") : fallback;
|
||
}
|
||
|
||
function setOnboardingCheckError(summary, stepLabel = "Connection") {
|
||
state.onboarding.lastCheck = {
|
||
ok: false,
|
||
summary,
|
||
testedAt: new Date().toISOString(),
|
||
steps: [{ label: stepLabel, detail: summary, ok: false }]
|
||
};
|
||
saveState();
|
||
renderOnboarding();
|
||
}
|
||
|
||
function renderDocuments() {
|
||
const list = $("#documentsList");
|
||
const docs = runtime.docs.length ? runtime.docs : (state.context.documents.knownArtifacts || []);
|
||
if (!docs.length) {
|
||
list.innerHTML = '<div class="empty-state">No documents loaded. Upload files or refresh from PrivateGPT.</div>';
|
||
return;
|
||
}
|
||
list.innerHTML = "";
|
||
docs.forEach(doc => {
|
||
const artifact = doc.artifact || doc.id || doc.name || "artifact";
|
||
const meta = doc.doc_metadata || doc.metadata || {};
|
||
const fileName = meta.file_name || artifact;
|
||
const ext = (fileName.split(".").pop() || "doc").slice(0, 5);
|
||
const row = sourceRow({
|
||
icon: "file",
|
||
title: fileName,
|
||
meta: `${artifact} · ${meta.collection || state.context.documents.defaultCollection || "collection"}`,
|
||
badge: ext,
|
||
status: "Indexed",
|
||
actions: [
|
||
["Delete", () => deleteDocument(artifact)]
|
||
]
|
||
});
|
||
list.appendChild(row);
|
||
});
|
||
renderIcons(list);
|
||
}
|
||
|
||
function sourceRow({ icon, title, meta, badge, status, actions = [] }) {
|
||
const row = document.createElement("div");
|
||
row.className = "source-row";
|
||
row.innerHTML = `
|
||
<div class="source-main">
|
||
<span class="source-icon" data-icon="${icon}"></span>
|
||
<div class="source-text">
|
||
<div class="source-title"></div>
|
||
<div class="source-meta"></div>
|
||
</div>
|
||
</div>
|
||
<span class="badge"></span>
|
||
<div class="panel-actions"></div>
|
||
`;
|
||
$(".source-title", row).textContent = title;
|
||
$(".source-meta", row).textContent = meta || "";
|
||
$(".badge", row).textContent = badge || status || "item";
|
||
if (status) $(".badge", row).classList.add("ok");
|
||
const actionWrap = $(".panel-actions", row);
|
||
actions.forEach(([label, handler]) => {
|
||
const button = document.createElement("button");
|
||
button.type = "button";
|
||
button.className = "ghost-button";
|
||
button.textContent = label;
|
||
button.addEventListener("click", handler);
|
||
actionWrap.appendChild(button);
|
||
});
|
||
return row;
|
||
}
|
||
|
||
function renderDatabases() {
|
||
renderConfigList("databasesList", state.context.databases, "database", "No databases configured.", item => ({
|
||
title: item.name,
|
||
meta: item.description || compactConnection(item.connection_string),
|
||
badge: item.schemas || "SQL",
|
||
actions: [["Edit", () => openDatabaseModal(item.id)], ["Delete", () => removeContextItem("databases", item.id)]]
|
||
}));
|
||
}
|
||
|
||
function renderMcp() {
|
||
renderConfigList("mcpList", state.context.mcpServers, "layers", "No MCP servers configured.", item => ({
|
||
title: item.name,
|
||
meta: configSummary(item.server_config_json),
|
||
badge: "MCP",
|
||
actions: [["Edit", () => openMcpModal(item.id)], ["Delete", () => removeContextItem("mcpServers", item.id)]]
|
||
}));
|
||
}
|
||
|
||
function renderSkills() {
|
||
renderConfigList("skillsList", state.context.skills, "zap", "No skills configured.", item => ({
|
||
title: item.display_title || item.name || "Skill",
|
||
meta: [
|
||
item.collection || state.context.documents.defaultCollection || "collection",
|
||
item.latest_version || "no version",
|
||
item.loading || "lazy",
|
||
item.readonly ? "readonly" : null
|
||
].filter(Boolean).join(" · "),
|
||
badge: item.source || "skill",
|
||
actions: [
|
||
["New version", () => openSkillModal(item.id)],
|
||
...(item.readonly ? [] : [["Delete", () => deleteSkill(item.id)]])
|
||
]
|
||
}));
|
||
}
|
||
|
||
function renderCustomTools() {
|
||
renderConfigList("customToolsList", state.context.customTools, "sliders", "No custom tools configured.", item => ({
|
||
title: item.name,
|
||
meta: item.description || "Browser JavaScript handler",
|
||
badge: "Tool",
|
||
actions: [["Test", () => testCustomTool(item.id)], ["Edit", () => openCustomToolModal(item.id)], ["Delete", () => removeContextItem("customTools", item.id)]]
|
||
}));
|
||
}
|
||
|
||
function renderConfigList(elementId, items, icon, empty, map) {
|
||
const list = $(`#${elementId}`);
|
||
if (!items.length) {
|
||
list.innerHTML = `<div class="empty-state">${escapeHtml(empty)}</div>`;
|
||
return;
|
||
}
|
||
list.innerHTML = "";
|
||
items.forEach(item => list.appendChild(sourceRow({ icon, ...map(item) })));
|
||
renderIcons(list);
|
||
}
|
||
|
||
function renderCodeExecutionPanel() {
|
||
const chat = activeChat();
|
||
const enabled = Boolean(chat?.settings?.enabledCodeExecution);
|
||
const info = $("#codeExecInfo");
|
||
const list = $("#sessionFilesList");
|
||
const actions = $("#codeExecPanelActions");
|
||
if (!info || !list || !actions) return;
|
||
if (!enabled) {
|
||
info.style.display = "";
|
||
list.innerHTML = "";
|
||
actions.style.display = "none";
|
||
return;
|
||
}
|
||
info.style.display = "none";
|
||
actions.style.display = "";
|
||
if (runtime.contextTab === "codeExecution") {
|
||
refreshSessionFiles({ showToast: false });
|
||
}
|
||
}
|
||
|
||
async function refreshSessionFiles({ showToast: showT = true } = {}) {
|
||
const chat = activeChat();
|
||
if (!chat || !chat.settings.enabledCodeExecution) return;
|
||
const list = $("#sessionFilesList");
|
||
if (!list) return;
|
||
const progress = showT ? toast("Loading session files...", "loading", { persistent: true }) : null;
|
||
try {
|
||
const data = await apiFetch(`/v1/files?scope_id=${encodeURIComponent(chat.id)}`, {}, { label: "List session files" });
|
||
const files = data?.data || [];
|
||
list.innerHTML = "";
|
||
if (!files.length) {
|
||
list.innerHTML = `<div class="empty-state">No files in session. Upload files or run code that generates output.</div>`;
|
||
progress?.update("No session files.", "ok");
|
||
return;
|
||
}
|
||
const base = (state.privateGptBaseUrl || DEFAULT_BASE_URL).replace(/\/+$/, "");
|
||
files.forEach(f => {
|
||
const row = document.createElement("div");
|
||
row.className = "source-row";
|
||
const isGenerated = f.downloadable;
|
||
const sizeLabel = f.size_bytes < 1024 ? `${f.size_bytes} B` : f.size_bytes < 1048576 ? `${(f.size_bytes / 1024).toFixed(1)} KB` : `${(f.size_bytes / 1048576).toFixed(1)} MB`;
|
||
const downloadUrl = `${base}/v1/files/${encodeURIComponent(f.id)}/content?scope_id=${encodeURIComponent(chat.id)}`;
|
||
row.innerHTML = `
|
||
<span class="source-icon">${icons.file}</span>
|
||
<span class="source-main">
|
||
<span class="source-title">${escapeHtml(f.filename)}</span>
|
||
<span class="source-meta">${escapeHtml(f.mime_type)} · ${sizeLabel}${isGenerated ? " · generated" : " · uploaded"}</span>
|
||
</span>
|
||
<span class="source-actions">
|
||
<a class="mini-button" href="${escapeAttr(downloadUrl)}" download="${escapeAttr(f.filename)}" target="_blank" title="Download">${icons.download}</a>
|
||
${!isGenerated ? `<span class="mini-button" data-delete-session-file="${escapeAttr(f.id)}" title="Delete">${icons.trash}</span>` : ""}
|
||
</span>`;
|
||
list.appendChild(row);
|
||
});
|
||
renderIcons(list);
|
||
list.querySelectorAll("[data-delete-session-file]").forEach(btn => {
|
||
btn.addEventListener("click", () => deleteSessionFile(btn.dataset.deleteSessionFile));
|
||
});
|
||
progress?.update(`${files.length} file(s) in session.`, "ok");
|
||
} catch (error) {
|
||
list.innerHTML = `<div class="empty-state">Could not load session files: ${escapeHtml(error.message)}</div>`;
|
||
progress?.update(`Could not load files: ${error.message}`, "error");
|
||
}
|
||
}
|
||
|
||
async function deleteSessionFile(fileId) {
|
||
const chat = activeChat();
|
||
if (!chat || !confirm(`Delete ${fileId}?`)) return;
|
||
const progress = toast("Deleting file...", "loading", { persistent: true });
|
||
try {
|
||
await apiFetch(`/v1/files/${encodeURIComponent(fileId)}?scope_id=${encodeURIComponent(chat.id)}`, { method: "DELETE" }, { label: "Delete session file" });
|
||
progress.update("File deleted.", "ok");
|
||
await refreshSessionFiles({ showToast: false });
|
||
} catch (error) {
|
||
progress.update(`Delete failed: ${error.message}`, "error");
|
||
}
|
||
}
|
||
|
||
function removeContextItem(bucket, id) {
|
||
if (!confirm("Delete this item?")) return;
|
||
state.context[bucket] = state.context[bucket].filter(item => item.id !== id);
|
||
saveState();
|
||
render();
|
||
}
|
||
|
||
function renderChat() {
|
||
$("#contextScreen").classList.toggle("active", runtime.view === "context");
|
||
$("#settingsScreen").classList.toggle("active", runtime.view === "settings");
|
||
$("#apiDebuggerScreen").classList.toggle("active", runtime.view === "apiDebugger");
|
||
$("#chatScreen").classList.toggle("active", runtime.view === "chat");
|
||
if (runtime.view === "apiDebugger") renderDebugger();
|
||
renderThinkingChip();
|
||
if (runtime.view !== "chat") return;
|
||
let chat = activeChat();
|
||
if (!chat) {
|
||
createChat();
|
||
return;
|
||
}
|
||
$("#activeChatTitle").textContent = chat.title || "New chat";
|
||
$("#activeChatSubtitle").textContent = `${state.context.documents.defaultCollection || "pgpt_collection"} · ${chat.messages.length} messages`;
|
||
$("#chatPanel").classList.add("active");
|
||
renderModelSelect();
|
||
renderToolMenu();
|
||
renderMessages();
|
||
const sendBtn = $(".icon-button.send");
|
||
if (sendBtn) sendBtn.disabled = !state.selectedModel;
|
||
}
|
||
|
||
function renderModelSelect() {
|
||
const btn = $("#modelSelectBtn");
|
||
const label = $("#modelSelectLabel");
|
||
const dropdown = $("#modelDropdown");
|
||
if (!btn || !label || !dropdown) return;
|
||
const current = state.selectedModel;
|
||
const models = state.models;
|
||
const modelName = m => (m.display_name && m.display_name !== m.id) ? m.display_name : m.id;
|
||
const active = models.find(m => m.id === current);
|
||
label.textContent = active ? modelName(active) : (models.length ? "Select model" : "No models");
|
||
btn.disabled = !models.length;
|
||
btn.title = models.length ? "Models loaded from PrivateGPT" : "Refresh models to load from PrivateGPT";
|
||
const sendBtn = $(".icon-button.send");
|
||
if (sendBtn) sendBtn.disabled = !state.selectedModel;
|
||
dropdown.innerHTML = "";
|
||
models.forEach(model => {
|
||
const row = document.createElement("button");
|
||
row.type = "button";
|
||
row.className = `model-option${model.id === current ? " selected" : ""}`;
|
||
row.innerHTML = `<span data-icon="check" class="opt-check"></span><span class="opt-name">${escapeHtml(modelName(model))}</span>`;
|
||
row.addEventListener("click", () => {
|
||
state.selectedModel = model.id;
|
||
saveState();
|
||
renderModelSelect();
|
||
closeModelDropdown();
|
||
});
|
||
dropdown.appendChild(row);
|
||
});
|
||
renderIcons(dropdown);
|
||
}
|
||
|
||
function closeModelDropdown() {
|
||
$("#modelDropdown")?.classList.remove("open");
|
||
$("#modelSelectWrap")?.classList.remove("open");
|
||
}
|
||
|
||
function hasAvailableDocuments() {
|
||
return runtime.docs.length > 0 || (state.context.documents.knownArtifacts || []).length > 0;
|
||
}
|
||
|
||
function normalizeDocumentBackedSettings(chat) {
|
||
if (!chat?.settings) return false;
|
||
const hasDocs = hasAvailableDocuments();
|
||
if (hasDocs) return true;
|
||
let changed = false;
|
||
if (chat.settings.enabledDocuments) {
|
||
chat.settings.enabledDocuments = false;
|
||
changed = true;
|
||
}
|
||
if (chat.settings.enabledTabular) {
|
||
chat.settings.enabledTabular = false;
|
||
changed = true;
|
||
}
|
||
if (changed) {
|
||
touchChat(chat);
|
||
saveState();
|
||
}
|
||
return false;
|
||
}
|
||
|
||
function normalizeChatContextSelections() {
|
||
const hasDocs = hasAvailableDocuments();
|
||
const availableSkillIds = new Set((state.context.skills || []).map(skill => skill.id).filter(Boolean));
|
||
let changed = false;
|
||
|
||
(state.chats || []).forEach(chat => {
|
||
if (!chat?.settings) return;
|
||
let chatChanged = false;
|
||
|
||
if (!hasDocs) {
|
||
if (chat.settings.enabledDocuments) {
|
||
chat.settings.enabledDocuments = false;
|
||
changed = true;
|
||
chatChanged = true;
|
||
}
|
||
if (chat.settings.enabledTabular) {
|
||
chat.settings.enabledTabular = false;
|
||
changed = true;
|
||
chatChanged = true;
|
||
}
|
||
}
|
||
|
||
const nextEnabledSkills = (chat.settings.enabledSkills || []).filter(id => availableSkillIds.has(id));
|
||
if (nextEnabledSkills.length !== (chat.settings.enabledSkills || []).length) {
|
||
chat.settings.enabledSkills = nextEnabledSkills;
|
||
changed = true;
|
||
chatChanged = true;
|
||
}
|
||
|
||
if (chatChanged) touchChat(chat);
|
||
});
|
||
|
||
if (changed) saveState();
|
||
return changed;
|
||
}
|
||
|
||
function renderToolMenu() {
|
||
const chat = activeChat();
|
||
if (!chat) return;
|
||
const menu = $("#toolsMenu");
|
||
const hasDocs = normalizeDocumentBackedSettings(chat);
|
||
menu.innerHTML = "";
|
||
|
||
const knowledgeRows = [
|
||
toggleRow("Documents", Boolean(chat.settings.enabledDocuments), checked => {
|
||
chat.settings.enabledDocuments = checked; touchChat(chat); saveState();
|
||
}, !hasDocs),
|
||
toggleRow("Tabular", Boolean(chat.settings.enabledTabular), checked => {
|
||
chat.settings.enabledTabular = checked; touchChat(chat); saveState();
|
||
}, !hasDocs)
|
||
];
|
||
if (featureEnabled("web")) {
|
||
knowledgeRows.push(toggleRow("Web", Boolean(chat.settings.enabledWeb), checked => {
|
||
chat.settings.enabledWeb = checked; touchChat(chat); saveState();
|
||
}));
|
||
}
|
||
menu.appendChild(menuGroup("Knowledge", knowledgeRows));
|
||
if (state.enableCodeExecution !== false) {
|
||
menu.appendChild(menuGroup("Code", [
|
||
toggleRow("Code Execution", Boolean(chat.settings.enabledCodeExecution), checked => {
|
||
chat.settings.enabledCodeExecution = checked; touchChat(chat); saveState();
|
||
})
|
||
]));
|
||
}
|
||
if (featureEnabled("databases")) menu.appendChild(selectableGroup("Databases", state.context.databases, chat.settings.enabledDatabases, ids => chat.settings.enabledDatabases = ids));
|
||
if (featureEnabled("mcp")) menu.appendChild(selectableGroup("MCP", state.context.mcpServers, chat.settings.enabledMcpServers, ids => chat.settings.enabledMcpServers = ids));
|
||
if (featureEnabled("skills")) menu.appendChild(selectableGroup("Skills", state.context.skills, chat.settings.enabledSkills, ids => chat.settings.enabledSkills = ids));
|
||
if (featureEnabled("customTools")) menu.appendChild(selectableGroup("Custom Tools", state.context.customTools, chat.settings.enabledCustomTools, ids => chat.settings.enabledCustomTools = ids));
|
||
}
|
||
|
||
function menuGroup(label, rows) {
|
||
const wrap = document.createElement("div");
|
||
wrap.className = "menu-group";
|
||
const title = document.createElement("div");
|
||
title.className = "menu-group-title";
|
||
title.textContent = label;
|
||
wrap.appendChild(title);
|
||
rows.forEach(r => wrap.appendChild(r));
|
||
return wrap;
|
||
}
|
||
|
||
function renderThinkingChip() {
|
||
const chat = activeChat();
|
||
const btn = $("#thinkingToggle");
|
||
if (!btn) return;
|
||
btn.hidden = state.enableThinking === false;
|
||
btn.classList.toggle("active", state.enableThinking !== false && Boolean(chat?.settings?.enabledThinking));
|
||
}
|
||
|
||
function toggleRow(label, checked, onChange, disabled = false) {
|
||
const row = document.createElement("label");
|
||
row.className = "toggle-row" + (disabled ? " disabled" : "");
|
||
row.innerHTML = `<span>${escapeHtml(label)}</span><input type="checkbox" ${checked ? "checked" : ""} ${disabled ? "disabled" : ""}>`;
|
||
if (!disabled) $("input", row).addEventListener("change", event => onChange(event.target.checked));
|
||
return row;
|
||
}
|
||
|
||
function selectableGroup(label, items, selectedIds, setter) {
|
||
const wrap = document.createElement("div");
|
||
wrap.className = "menu-group";
|
||
const title = document.createElement("div");
|
||
title.className = "menu-group-title";
|
||
title.textContent = label;
|
||
wrap.appendChild(title);
|
||
if (!items.length) {
|
||
const empty = document.createElement("div");
|
||
empty.className = "menu-empty";
|
||
empty.textContent = `No ${label.toLowerCase()} configured.`;
|
||
wrap.appendChild(empty);
|
||
return wrap;
|
||
}
|
||
items.forEach(item => {
|
||
const ids = new Set(selectedIds || []);
|
||
wrap.appendChild(toggleRow(item.display_title || item.name || item.url || "Item", ids.has(item.id), checked => {
|
||
if (checked) ids.add(item.id); else ids.delete(item.id);
|
||
setter(Array.from(ids));
|
||
const chat = activeChat();
|
||
if (chat) touchChat(chat);
|
||
saveState();
|
||
}));
|
||
});
|
||
return wrap;
|
||
}
|
||
|
||
function renderMessages() {
|
||
const chat = activeChat();
|
||
const wrap = $("#messages");
|
||
const visibleMessages = chat.messages.filter(message => !message.hidden);
|
||
const showLoading = runtime.sendingChatIds.has(chat.id) && !chat.messages.some(m => m.streaming);
|
||
runtime.citations = new Map();
|
||
if (!visibleMessages.length && !showLoading) {
|
||
if (!wrap.querySelector(":scope > .empty-state")) {
|
||
wrap.innerHTML = `
|
||
<div class="empty-state">
|
||
<div>
|
||
<h2>${escapeHtml(workspaceWelcomeTitle())}</h2>
|
||
<p class="subtle">${escapeHtml(workspaceWelcomeSubtitle())}</p>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
return;
|
||
}
|
||
|
||
wrap.querySelector(":scope > .empty-state")?.remove();
|
||
const expectedIds = new Set(visibleMessages.map(message => message.id));
|
||
Array.from(wrap.children).forEach(child => {
|
||
const messageId = child.dataset.messageId;
|
||
if (messageId && !expectedIds.has(messageId)) child.remove();
|
||
if (child.dataset.loadingMessage && !showLoading) child.remove();
|
||
});
|
||
|
||
let previousNode = null;
|
||
visibleMessages.forEach(message => {
|
||
const key = messageRenderKey(message);
|
||
let node = Array.from(wrap.children).find(child => child.dataset.messageId === message.id);
|
||
if (!node || node.dataset.renderKey !== key) {
|
||
if (node) {
|
||
updateMessageNode(node, message);
|
||
node.dataset.renderKey = key;
|
||
} else {
|
||
node = renderMessage(message);
|
||
node.dataset.renderKey = key;
|
||
}
|
||
}
|
||
const referenceNode = previousNode ? previousNode.nextSibling : wrap.firstChild;
|
||
if (node !== referenceNode) wrap.insertBefore(node, referenceNode);
|
||
previousNode = node;
|
||
});
|
||
|
||
let loadingNode = Array.from(wrap.children).find(child => child.dataset.loadingMessage);
|
||
if (showLoading && !loadingNode) {
|
||
loadingNode = renderLoadingMessage();
|
||
loadingNode.dataset.loadingMessage = "true";
|
||
wrap.appendChild(loadingNode);
|
||
}
|
||
wrap.scrollTop = wrap.scrollHeight;
|
||
updateMessagesFade();
|
||
}
|
||
|
||
function updateMessageNode(node, message) {
|
||
const time = $(".message-time", node);
|
||
if (time) time.textContent = formatMessageTime(message.createdAt);
|
||
const role = $(".message-role", node);
|
||
if (role) role.textContent = message.role === "user" ? "You" : message.role === "tool" ? "Tool" : assistantBrandName();
|
||
const text = $(".message-text", node);
|
||
if (text) {
|
||
const hasStreamingContent = message.text || message.contentBlocks.some(b => b?.type !== "text" || b.text);
|
||
if (message.streaming && !hasStreamingContent) {
|
||
text.innerHTML = `<div class="thinking-text">Working<span></span><span></span><span></span></div>`;
|
||
} else {
|
||
const citationContext = buildCitationContext(message.contentBlocks);
|
||
const html = blocksToHtml(message.contentBlocks, message.text, citationContext, { streaming: Boolean(message.streaming) });
|
||
if (text.innerHTML !== html) text.innerHTML = html;
|
||
}
|
||
}
|
||
const contentWrap = $(".message-content", node);
|
||
$(".message-error-badge", node)?.remove();
|
||
if (message.error && contentWrap) {
|
||
const badge = document.createElement("div");
|
||
badge.className = "message-error-badge";
|
||
badge.textContent = `⚠ ${message.error}`;
|
||
contentWrap.appendChild(badge);
|
||
}
|
||
$(".message-actions", node)?.remove();
|
||
if (!message.streaming && (message.role === "assistant" || message.role === "user") && contentWrap) {
|
||
contentWrap.appendChild(renderMessageActions(message));
|
||
}
|
||
}
|
||
|
||
function messageRenderKey(message) {
|
||
return JSON.stringify({
|
||
id: message.id,
|
||
role: message.role,
|
||
text: message.text || "",
|
||
contentBlocks: message.contentBlocks || [],
|
||
streaming: Boolean(message.streaming),
|
||
error: message.error || "",
|
||
createdAt: message.createdAt || "",
|
||
assistantBrand: assistantBrandName(),
|
||
canRetry: Boolean(state.selectedModel)
|
||
});
|
||
}
|
||
|
||
function renderLoadingMessage() {
|
||
const row = document.createElement("div");
|
||
row.className = "message assistant loading";
|
||
const avatar = document.createElement("span");
|
||
avatar.className = "avatar-orb loading";
|
||
row.appendChild(avatar);
|
||
const contentWrap = document.createElement("div");
|
||
contentWrap.className = "message-content";
|
||
const time = document.createElement("div");
|
||
time.className = "message-time";
|
||
time.textContent = formatMessageTime(new Date().toISOString());
|
||
contentWrap.appendChild(time);
|
||
const bubble = document.createElement("div");
|
||
bubble.className = "message-bubble";
|
||
bubble.innerHTML = `
|
||
<div class="message-role">${escapeHtml(assistantBrandName())}</div>
|
||
<div class="thinking-text">Working<span></span><span></span><span></span></div>
|
||
`;
|
||
contentWrap.appendChild(bubble);
|
||
row.appendChild(contentWrap);
|
||
return row;
|
||
}
|
||
|
||
function renderMessage(message) {
|
||
const row = document.createElement("div");
|
||
row.className = `message ${message.role === "user" ? "user" : message.role === "tool" ? "tool" : "assistant"}`;
|
||
row.dataset.messageId = message.id;
|
||
if (message.role !== "user") {
|
||
const avatar = document.createElement("span");
|
||
avatar.className = "avatar-orb";
|
||
row.appendChild(avatar);
|
||
}
|
||
const contentWrap = document.createElement("div");
|
||
contentWrap.className = "message-content";
|
||
const time = document.createElement("div");
|
||
time.className = "message-time";
|
||
time.textContent = formatMessageTime(message.createdAt);
|
||
contentWrap.appendChild(time);
|
||
const bubble = document.createElement("div");
|
||
bubble.className = "message-bubble";
|
||
const role = document.createElement("div");
|
||
role.className = "message-role";
|
||
role.textContent = message.role === "user" ? "You" : message.role === "tool" ? "Tool" : assistantBrandName();
|
||
bubble.appendChild(role);
|
||
const content = document.createElement("div");
|
||
content.className = "message-text";
|
||
if (message.streaming) {
|
||
const hasContent = message.text || message.contentBlocks.some(b => b?.type !== "text" || b.text);
|
||
if (hasContent) {
|
||
const citationContext = buildCitationContext(message.contentBlocks);
|
||
content.innerHTML = blocksToHtml(message.contentBlocks, message.text, citationContext, { streaming: true });
|
||
} else {
|
||
content.innerHTML = `<div class="thinking-text">Working<span></span><span></span><span></span></div>`;
|
||
}
|
||
} else {
|
||
const citationContext = buildCitationContext(message.contentBlocks);
|
||
content.innerHTML = blocksToHtml(message.contentBlocks, message.text, citationContext);
|
||
}
|
||
bubble.appendChild(content);
|
||
contentWrap.appendChild(bubble);
|
||
if (message.error) {
|
||
const badge = document.createElement("div");
|
||
badge.className = "message-error-badge";
|
||
badge.textContent = `⚠ ${message.error}`;
|
||
contentWrap.appendChild(badge);
|
||
}
|
||
if (!message.streaming && (message.role === "assistant" || message.role === "user")) {
|
||
contentWrap.appendChild(renderMessageActions(message));
|
||
}
|
||
row.appendChild(contentWrap);
|
||
return row;
|
||
}
|
||
|
||
function renderMessageActions(message) {
|
||
const actions = document.createElement("div");
|
||
actions.className = "message-actions";
|
||
const chatId = activeChat()?.id || "";
|
||
if (message.role === "assistant") {
|
||
const retryBtn = document.createElement("button");
|
||
retryBtn.type = "button";
|
||
retryBtn.className = "msg-action-btn";
|
||
retryBtn.dataset.action = "retry-message";
|
||
retryBtn.dataset.chatId = chatId;
|
||
retryBtn.dataset.messageId = message.id;
|
||
retryBtn.textContent = "↺ Retry";
|
||
retryBtn.disabled = !state.selectedModel;
|
||
actions.appendChild(retryBtn);
|
||
} else {
|
||
const delBtn = document.createElement("button");
|
||
delBtn.type = "button";
|
||
delBtn.className = "msg-action-btn danger";
|
||
delBtn.dataset.action = "delete-message";
|
||
delBtn.dataset.chatId = chatId;
|
||
delBtn.dataset.messageId = message.id;
|
||
delBtn.textContent = "✕ Delete";
|
||
actions.appendChild(delBtn);
|
||
}
|
||
return actions;
|
||
}
|
||
|
||
function formatMessageTime(value) {
|
||
const date = value ? new Date(value) : new Date();
|
||
if (Number.isNaN(date.getTime())) return "";
|
||
const now = new Date();
|
||
const sameDay = date.toDateString() === now.toDateString();
|
||
const yesterday = new Date(now);
|
||
yesterday.setDate(now.getDate() - 1);
|
||
const time = date.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
|
||
if (sameDay) return `Today at ${time}`;
|
||
if (date.toDateString() === yesterday.toDateString()) return `Yesterday at ${time}`;
|
||
return `${date.toLocaleDateString([], { month: "short", day: "numeric" })} at ${time}`;
|
||
}
|
||
|
||
function renderImageSource(source) {
|
||
if (!source) return "";
|
||
const src = source.type === "base64"
|
||
? `data:${source.media_type || "image/png"};base64,${source.data}`
|
||
: source.url || "";
|
||
if (!src) return "";
|
||
return `<img src="${escapeHtml(src)}" style="max-width:100%;border-radius:8px;margin:4px 0;display:block;" alt="Tool output image">`;
|
||
}
|
||
|
||
function renderToolResultContent(content) {
|
||
if (typeof content === "string") return `<pre>${escapeHtml(content)}</pre>`;
|
||
if (!Array.isArray(content)) return `<pre>${escapeHtml(JSON.stringify(content, null, 2))}</pre>`;
|
||
return content.map(item => {
|
||
if (typeof item === "string") return `<pre>${escapeHtml(item)}</pre>`;
|
||
if (!item || typeof item !== "object") return "";
|
||
if (item.type === "image") return renderImageSource(item.source);
|
||
if (item.type === "text") return `<pre>${escapeHtml(item.text || "")}</pre>`;
|
||
return `<pre>${escapeHtml(JSON.stringify(item, null, 2))}</pre>`;
|
||
}).join("");
|
||
}
|
||
|
||
function isCodeExecTool(name) {
|
||
return ["bash", "view", "str_replace", "create", "insert", "present_files", "present_server"].includes(name);
|
||
}
|
||
|
||
function codeExecToolLabel(name) {
|
||
const map = { bash: "Bash", view: "Read", str_replace: "Edit", create: "Create", insert: "Insert", present_files: "Present", present_server: "Server" };
|
||
return map[name] || name;
|
||
}
|
||
|
||
function codeExecToolLabelClass(name) {
|
||
if (name === "bash") return "bash";
|
||
if (name === "view" || name === "insert") return "file";
|
||
if (name === "create") return "create";
|
||
if (name === "str_replace") return "edit";
|
||
if (name === "present_files") return "present";
|
||
if (name === "present_server") return "server";
|
||
return "file";
|
||
}
|
||
|
||
function renderCodeExecToolUse(block, resultBlock) {
|
||
const name = block.name || "";
|
||
const input = block.input || {};
|
||
const label = codeExecToolLabel(name);
|
||
const labelClass = codeExecToolLabelClass(name);
|
||
let detail = "";
|
||
|
||
if (name === "bash") {
|
||
detail = `<div class="code-exec-command"><span class="prompt">$</span>${escapeHtml(input.command || "")}</div>`;
|
||
} else if (name === "view") {
|
||
const range = Array.isArray(input.view_range) && input.view_range.length === 2
|
||
? ` (lines ${input.view_range[0] + 1}-${input.view_range[1]})`
|
||
: "";
|
||
detail = `<div class="code-exec-file-info">${icons.file}<span class="code-exec-file-path">${escapeHtml(input.path || "")}${range}</span></div>`;
|
||
} else if (name === "str_replace") {
|
||
detail = `<div class="code-exec-file-info">${icons.file}<span class="code-exec-file-path">${escapeHtml(input.path || "")}</span></div>`;
|
||
} else if (name === "create") {
|
||
detail = `<div class="code-exec-file-info">${icons.file}<span class="code-exec-file-path">Create: ${escapeHtml(input.path || "")}</span></div>`;
|
||
} else if (name === "insert") {
|
||
detail = `<div class="code-exec-file-info">${icons.file}<span class="code-exec-file-path">${escapeHtml(input.path || "")} at line ${input.insert_line}</span></div>`;
|
||
} else if (name === "present_files") {
|
||
const files = Array.isArray(input.files) ? input.files : [];
|
||
detail = `<div class="code-exec-present-files">${files.map(f => `<div class="code-exec-present-file"><span class="file-icon">${icons.file}</span><span class="file-name">${escapeHtml(typeof f === "string" ? f : f.path || f.name || "")}</span></div>`).join("")}</div>`;
|
||
} else if (name === "present_server") {
|
||
const port = input.port != null ? `:${input.port}` : "";
|
||
const service = input.service_name || "App";
|
||
const initialPath = input.initial_path ? ` → ${input.initial_path}` : "";
|
||
detail = `<div class="code-exec-file-info">${icons.globe}<span class="code-exec-file-path">${escapeHtml(service)}${escapeHtml(port)}${escapeHtml(initialPath)}</span></div>`;
|
||
}
|
||
|
||
// If we have a result block, render the result content in the same body
|
||
let resultHtml = "";
|
||
if (resultBlock) {
|
||
const rawText = extractToolResultText(resultBlock.content);
|
||
if (name === "bash") {
|
||
const result = parseBashResult(rawText);
|
||
const parts = [];
|
||
if (result.stdout) {
|
||
parts.push(`<div class="code-exec-terminal">${escapeHtml(result.stdout)}</div>`);
|
||
}
|
||
if (result.stderr) {
|
||
parts.push(`<div class="code-exec-terminal is-stderr">${escapeHtml(result.stderr)}</div>`);
|
||
}
|
||
if (result.exitCode != null || result.duration != null) {
|
||
parts.push(`<div class="code-exec-result-meta">
|
||
${result.exitCode != null ? `<span class="exit-code ${result.exitCode === 0 ? "ok" : "error"}">Exit: ${result.exitCode}</span>` : ""}
|
||
${result.duration != null ? `<span>${result.duration}ms</span>` : ""}
|
||
</div>`);
|
||
}
|
||
resultHtml = parts.join("");
|
||
} else if (name === "str_replace") {
|
||
resultHtml = renderDiffView(rawText);
|
||
} else if (name === "present_files") {
|
||
const content = resultBlock.content;
|
||
if (Array.isArray(content)) {
|
||
const fileItems = content.filter(b => b?.type === "local_resource");
|
||
const textItems = content.filter(b => b?.type === "text" || typeof b === "string");
|
||
const textHtml = textItems.map(b => escapeHtml(typeof b === "string" ? b : (b.text || ""))).join(" ");
|
||
if (fileItems.length > 0) {
|
||
const base = (state.privateGptBaseUrl || DEFAULT_BASE_URL).replace(/\/+$/, "");
|
||
const chatId = activeChat()?.id;
|
||
resultHtml = `<div class="code-exec-present-files">${fileItems.map(f => {
|
||
const ext = f.file_path ? "." + f.file_path.split(".").pop() : "";
|
||
const fileName = f.name + ext;
|
||
if (f.file_id && chatId) {
|
||
const url = `${base}/v1/files/${encodeURIComponent(f.file_id)}/content?scope_id=${encodeURIComponent(chatId)}`;
|
||
return `<a class="code-exec-download" href="${escapeAttr(url)}" download="${escapeAttr(fileName)}" target="_blank">${icons.download}<span class="code-exec-file-name">${escapeHtml(fileName)}</span><span class="code-exec-mime">${escapeHtml(f.mime_type || "")}</span></a>`;
|
||
}
|
||
return `<div class="code-exec-present-file"><span class="file-icon">${icons.file}</span><span class="file-name">${escapeHtml(fileName)}</span></div>`;
|
||
}).join("")}</div>${textHtml ? `<div class="code-exec-terminal" style="margin-top:8px">${textHtml}</div>` : ""}`;
|
||
} else {
|
||
resultHtml = textHtml ? `<div class="code-exec-terminal">${textHtml}</div>` : "";
|
||
}
|
||
} else {
|
||
resultHtml = rawText ? `<div class="code-exec-terminal">${escapeHtml(rawText)}</div>` : "";
|
||
}
|
||
} else if (name === "present_server") {
|
||
const content = resultBlock.content;
|
||
if (Array.isArray(content)) {
|
||
const linkItems = content.filter(b => b?.type === "resource_link");
|
||
const textItems = content.filter(b => b?.type === "text" || typeof b === "string");
|
||
const textHtml = textItems.map(b => escapeHtml(typeof b === "string" ? b : (b.text || ""))).join(" ");
|
||
if (linkItems.length > 0) {
|
||
resultHtml = `<div class="code-exec-present-files">${linkItems.map(l => {
|
||
const url = l.uri || "";
|
||
const svc = l.name || "App";
|
||
const desc = l.description ? escapeHtml(l.description) : "";
|
||
if (url) {
|
||
return `<a class="code-exec-server-link" href="${escapeAttr(url)}" target="_blank" rel="noopener noreferrer">${icons.globe}<span class="code-exec-server-name">${escapeHtml(svc)}</span><span class="code-exec-server-url">${escapeHtml(url)}</span></a>${desc ? `<div class="code-exec-terminal" style="margin-top:6px">${desc}</div>` : ""}`;
|
||
}
|
||
return `<div class="code-exec-present-file"><span class="file-icon">${icons.globe}</span><span class="file-name">${escapeHtml(svc)}</span></div>`;
|
||
}).join("")}</div>${textHtml ? `<div class="code-exec-terminal" style="margin-top:8px">${textHtml}</div>` : ""}`;
|
||
} else {
|
||
resultHtml = textHtml ? `<div class="code-exec-terminal">${textHtml}</div>` : "";
|
||
}
|
||
} else {
|
||
resultHtml = rawText ? `<div class="code-exec-terminal">${escapeHtml(rawText)}</div>` : "";
|
||
}
|
||
} else {
|
||
resultHtml = renderFileView(rawText);
|
||
}
|
||
if (resultHtml) {
|
||
detail += `<div style="border-top:1px solid rgba(255,255,255,0.06);margin-top:10px;padding-top:10px;">${resultHtml}</div>`;
|
||
}
|
||
}
|
||
|
||
const summaryLabel = name === "bash" ? (input.command ? input.command.split("\n")[0].slice(0, 60) + (input.command.length > 60 ? "…" : "") : "") : name === "present_server" ? `${input.service_name || "App"}${input.port != null ? `:${input.port}` : ""}` : (input.path || "");
|
||
return `<details class="code-exec-block"${resultBlock ? " open" : ""}><summary><span class="code-exec-tool-label ${labelClass}">${label}</span>${escapeHtml(summaryLabel)}</summary><div class="code-exec-body">${detail}</div></details>`;
|
||
}
|
||
|
||
function extractToolResultText(content) {
|
||
if (typeof content === "string") return content;
|
||
if (Array.isArray(content)) {
|
||
return content.map(item => {
|
||
if (typeof item === "string") return item;
|
||
if (item?.type === "text") return item.text || "";
|
||
return "";
|
||
}).filter(Boolean).join("");
|
||
}
|
||
return JSON.stringify(content, null, 2);
|
||
}
|
||
|
||
function parseBashResult(text) {
|
||
const exitMatch = text.match(/exit_code:\s*(\d+)/);
|
||
const stdoutMatch = text.match(/stdout:\s*([\s\S]*?)(?:\n(?:stderr:|exit_code:|execution_time)|$)/);
|
||
const stderrMatch = text.match(/stderr:\s*([\s\S]*?)(?:\n(?:exit_code:|execution_time:|$)|$)/);
|
||
const timeMatch = text.match(/execution_time_ms:\s*(\d+)/);
|
||
return {
|
||
exitCode: exitMatch ? parseInt(exitMatch[1]) : null,
|
||
stdout: stdoutMatch ? stdoutMatch[1].trim() : "",
|
||
stderr: stderrMatch ? stderrMatch[1].trim() : "",
|
||
duration: timeMatch ? parseInt(timeMatch[1]) : null
|
||
};
|
||
}
|
||
|
||
function renderFileView(text) {
|
||
if (!text || !text.trim()) return `<div class="code-exec-terminal">(empty)</div>`;
|
||
const lines = text.split("\n");
|
||
let hasLineNumbers = false;
|
||
for (let i = 0; i < Math.min(lines.length, 5); i++) {
|
||
if (/^\s*\d+[\s|:]\t/.test(lines[i]) || /^\s*\d+\s{2,}/.test(lines[i])) {
|
||
hasLineNumbers = true;
|
||
break;
|
||
}
|
||
}
|
||
if (hasLineNumbers) {
|
||
const rows = lines.map(line => {
|
||
const match = line.match(/^(\s*\d+)\s+(.*)/);
|
||
if (match) {
|
||
return `<tr><td class="line-number">${escapeHtml(match[1])}</td><td class="line-text">${escapeHtml(match[2])}</td></tr>`;
|
||
}
|
||
return `<tr><td class="line-number"></td><td class="line-text">${escapeHtml(line)}</td></tr>`;
|
||
});
|
||
return `<div class="code-exec-file-content"><table>${rows.join("")}</table></div>`;
|
||
}
|
||
return `<div class="code-exec-terminal">${escapeHtml(text)}</div>`;
|
||
}
|
||
|
||
function renderDiffView(text) {
|
||
if (!text || !text.trim()) return `<div class="code-exec-terminal">(no changes)</div>`;
|
||
const lines = text.split("\n");
|
||
const html = lines.map(line => {
|
||
if (line.startsWith("---") || line.startsWith("+++") || line.startsWith("@@")) {
|
||
return `<span style="color:var(--blue)">${escapeHtml(line)}</span>`;
|
||
}
|
||
if (line.startsWith("-")) {
|
||
return `<span class="diff-removed">${escapeHtml(line)}</span>`;
|
||
}
|
||
if (line.startsWith("+")) {
|
||
return `<span class="diff-added">${escapeHtml(line)}</span>`;
|
||
}
|
||
return escapeHtml(line);
|
||
}).join("\n");
|
||
return `<div class="code-exec-diff">${html}</div>`;
|
||
}
|
||
|
||
function blocksToHtml(blocks, fallback = "", citationContext = buildCitationContext(blocks), { streaming = false } = {}) {
|
||
if (!Array.isArray(blocks)) return renderMarkdownWithCitations(fallback || "", citationContext);
|
||
|
||
// Pair code exec tool_use blocks with their subsequent tool_result for combined rendering
|
||
const paired = [];
|
||
let i = 0;
|
||
while (i < blocks.length) {
|
||
const block = blocks[i];
|
||
if ((block?.type === "tool_use" || block?.type === "server_tool_use") && isCodeExecTool(block.name)) {
|
||
const nextBlock = i + 1 < blocks.length ? blocks[i + 1] : null;
|
||
if (nextBlock?.type === "tool_result" && nextBlock.tool_use_id === block.id) {
|
||
paired.push({ toolUse: block, toolResult: nextBlock });
|
||
i += 2;
|
||
continue;
|
||
}
|
||
}
|
||
paired.push({ block, index: i });
|
||
i += 1;
|
||
}
|
||
|
||
const parts = paired.map(item => {
|
||
if (item.block) {
|
||
const block = item.block;
|
||
const idx = item.index;
|
||
if (typeof block === "string") return renderMarkdownWithCitations(block, citationContext);
|
||
if (block.type === "text") {
|
||
return renderMarkdownWithCitations(block.text || "", citationContext, block.citations || []);
|
||
}
|
||
if (block.type === "thinking") {
|
||
const thinking = block.thinking || "";
|
||
const isLast = idx === blocks.length - 1;
|
||
const isActiveStream = streaming && isLast;
|
||
const openAttr = isActiveStream ? " open" : "";
|
||
const label = isActiveStream ? "Thinking…" : "Thinking";
|
||
const preHtml = isActiveStream
|
||
? `<div class="thinking-pre-wrap"><pre>${escapeHtml(thinking)}</pre></div>`
|
||
: `<pre>${escapeHtml(thinking)}</pre>`;
|
||
return `<details class="thinking-block"${openAttr}><summary>${label}</summary>${preHtml}</details>`;
|
||
}
|
||
if (block.type === "image") {
|
||
return renderImageSource(block.source);
|
||
}
|
||
if (block.type === "tool_use" || block.type === "server_tool_use") {
|
||
if (isCodeExecTool(block.name)) {
|
||
return renderCodeExecToolUse(block);
|
||
}
|
||
return `<details class="tool-call"><summary>Tool requested: ${escapeHtml(block.name)}</summary><pre>${escapeHtml(JSON.stringify(block.input || {}, null, 2))}</pre></details>`;
|
||
}
|
||
if (block.type === "tool_result") {
|
||
const body = renderToolResultContent(block.content);
|
||
return `<details class="tool-call"><summary>Tool result</summary>${body}</details>`;
|
||
}
|
||
if (block.type === "search_result") {
|
||
return `<details class="tool-call"><summary>${escapeHtml(block.title || "Search result")}</summary><pre>${escapeHtml(block.source || JSON.stringify(block, null, 2))}</pre></details>`;
|
||
}
|
||
if (block.type === "source") {
|
||
return `<details class="tool-call"><summary>Source context</summary><pre>${escapeHtml(JSON.stringify(block, null, 2))}</pre></details>`;
|
||
}
|
||
return `<details class="tool-call"><summary>${escapeHtml(block.type || "Block")}</summary><pre>${escapeHtml(JSON.stringify(block, null, 2))}</pre></details>`;
|
||
}
|
||
|
||
// Combined tool_use + tool_result for code exec tools
|
||
if (item.toolUse && item.toolResult) {
|
||
return renderCodeExecToolUse(item.toolUse, item.toolResult);
|
||
}
|
||
return "";
|
||
});
|
||
|
||
if (streaming && blocks.length > 0) {
|
||
const lastType = blocks.at(-1)?.type;
|
||
if (lastType !== "text" && lastType !== "thinking") {
|
||
parts.push(`<div class="stream-working">Working…</div>`);
|
||
}
|
||
}
|
||
return parts.join("");
|
||
}
|
||
|
||
function buildCitationContext(blocks) {
|
||
const context = { sources: [], sourceById: new Map(), explicitCitations: [] };
|
||
collectCitationSources(blocks, context);
|
||
return context;
|
||
}
|
||
|
||
function collectCitationSources(blocks, context) {
|
||
if (!Array.isArray(blocks)) return;
|
||
blocks.forEach(block => {
|
||
if (!block || typeof block !== "object") return;
|
||
if (block.type === "source" && Array.isArray(block.sources)) {
|
||
block.sources.forEach((source, index) => addCitationSource(context, source, index));
|
||
}
|
||
if (block.type === "text" && Array.isArray(block.citations)) {
|
||
context.explicitCitations.push(...block.citations);
|
||
}
|
||
if (block.type === "tool_result") {
|
||
collectToolResultCitationSources(block.content, context);
|
||
if (Array.isArray(block.content)) collectCitationSources(block.content, context);
|
||
}
|
||
});
|
||
}
|
||
|
||
function addCitationSource(context, source, index) {
|
||
context.sources.push(source);
|
||
const ids = [
|
||
source.id,
|
||
source.source_id,
|
||
source.node_id,
|
||
source.document?.artifact,
|
||
source.document?.doc_metadata?.file_name,
|
||
source.metadata?.source_id,
|
||
source.metadata?.node_id
|
||
].filter(Boolean);
|
||
ids.forEach(id => {
|
||
addSourceAlias(context, id, source);
|
||
});
|
||
addSourceAlias(context, index, source);
|
||
}
|
||
|
||
function addSourceAlias(context, id, source) {
|
||
const aliases = citationIdAliases(id);
|
||
aliases.forEach(alias => {
|
||
if (alias && !context.sourceById.has(alias)) context.sourceById.set(alias, source);
|
||
});
|
||
}
|
||
|
||
function citationIdAliases(id) {
|
||
if (id == null) return [];
|
||
const raw = String(id);
|
||
const trimmed = raw.trim();
|
||
const unbracketed = trimmed.replace(/^\[|\]$/g, "");
|
||
return Array.from(new Set([raw, trimmed, unbracketed, `[${unbracketed}]`].filter(Boolean)));
|
||
}
|
||
|
||
function collectToolResultCitationSources(content, context) {
|
||
const textBlocks = [];
|
||
if (typeof content === "string") {
|
||
textBlocks.push(content);
|
||
} else if (Array.isArray(content)) {
|
||
content.forEach(item => {
|
||
if (typeof item === "string") textBlocks.push(item);
|
||
if (item && typeof item === "object" && item.type === "text" && typeof item.text === "string") textBlocks.push(item.text);
|
||
});
|
||
}
|
||
textBlocks.forEach(text => {
|
||
const parsed = parsePossiblyJson(text);
|
||
const documents = Array.isArray(parsed) ? parsed : parsed ? [parsed] : [];
|
||
documents.forEach(document => {
|
||
if (!document || typeof document !== "object" || !Array.isArray(document.nodes)) return;
|
||
document.nodes.forEach((node, index) => {
|
||
if (!node || typeof node !== "object") return;
|
||
addCitationSource(context, {
|
||
id: node.id,
|
||
node_id: node.id,
|
||
object: "tool_result.node",
|
||
text: node.content || "",
|
||
content: node.content || "",
|
||
document: {
|
||
artifact: document.artifact_id || document.filename || "",
|
||
doc_metadata: {
|
||
file_name: document.filename || document.artifact_id || ""
|
||
}
|
||
},
|
||
metadata: {
|
||
node_id: node.id,
|
||
artifact_id: document.artifact_id,
|
||
filename: document.filename
|
||
}
|
||
}, index);
|
||
});
|
||
});
|
||
});
|
||
}
|
||
|
||
function parsePossiblyJson(value) {
|
||
if (typeof value !== "string") return null;
|
||
try {
|
||
return JSON.parse(value);
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function renderMarkdownWithCitations(text, citationContext, explicitCitations = []) {
|
||
const markers = new Map();
|
||
const prepared = String(text || "").replace(/<citation\b([^>]*)\/>|<citation\b([^>]*)>[\s\S]*?<\/citation>/gi, (_match, selfClosingAttrs, pairedAttrs) => {
|
||
const attrsText = selfClosingAttrs || pairedAttrs || "";
|
||
const attrs = parseCitationAttrs(attrsText);
|
||
const detail = resolveCitationDetail(attrs, citationContext, explicitCitations);
|
||
const label = citationMarkerLabel(attrs);
|
||
const id = `citation-${generateUUID()}`;
|
||
runtime.citations.set(id, { label, attrs, ...detail });
|
||
const marker = `\uE000${id}\uE001`;
|
||
markers.set(marker, `<button class="citation-button" type="button" data-citation-ref="${escapeAttr(id)}" aria-label="Open citation ${label}">${label}</button>`);
|
||
return marker;
|
||
});
|
||
let html = renderMarkdown(prepared);
|
||
markers.forEach((button, marker) => {
|
||
html = html.split(marker).join(button);
|
||
});
|
||
return html;
|
||
}
|
||
|
||
function citationMarkerLabel(attrs) {
|
||
const index = Number(attrs.index);
|
||
if (Number.isFinite(index)) return String(index + 1);
|
||
return String(runtime.citations.size + 1);
|
||
}
|
||
|
||
function parseCitationAttrs(attrsText) {
|
||
const attrs = {};
|
||
const re = /([:\w-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g;
|
||
let match;
|
||
while ((match = re.exec(attrsText || ""))) {
|
||
attrs[match[1]] = match[2] ?? match[3] ?? "";
|
||
}
|
||
return attrs;
|
||
}
|
||
|
||
function resolveCitationDetail(attrs, citationContext, explicitCitations = []) {
|
||
const mergedExplicit = [...(explicitCitations || []), ...(citationContext.explicitCitations || [])];
|
||
const explicit = mergedExplicit.find(citation =>
|
||
sameCitationValue(citation.id, attrs.id) ||
|
||
sameCitationValue(citation.source_id, attrs.source_id) ||
|
||
sameCitationValue(citation.artifact_id, attrs.artifact_id)
|
||
) || mergedExplicit[Number(attrs.index)];
|
||
const source = findCitationSource(attrs, citationContext);
|
||
return {
|
||
citation: explicit || null,
|
||
source: source || null,
|
||
title: citationTitle(attrs, explicit, source),
|
||
excerpt: citationExcerpt(explicit, source),
|
||
raw: { attrs, citation: explicit || null, source: source || null }
|
||
};
|
||
}
|
||
|
||
function findCitationSource(attrs, citationContext) {
|
||
const candidates = [
|
||
attrs.source_id,
|
||
attrs.id,
|
||
attrs.artifact_id,
|
||
attrs.index
|
||
].filter(value => value != null && value !== "");
|
||
for (const value of candidates) {
|
||
for (const alias of citationIdAliases(value)) {
|
||
const source = citationContext.sourceById.get(alias);
|
||
if (source) return source;
|
||
}
|
||
}
|
||
if (attrs.artifact_id) {
|
||
return citationContext.sources.find(source =>
|
||
source.document?.artifact === attrs.artifact_id ||
|
||
source.document?.doc_metadata?.file_name === attrs.artifact_id
|
||
);
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function sameCitationValue(left, right) {
|
||
return left != null && right != null && String(left) === String(right);
|
||
}
|
||
|
||
function citationTitle(attrs, citation, source) {
|
||
return source?.document?.doc_metadata?.file_name ||
|
||
source?.document?.artifact ||
|
||
source?.title ||
|
||
citation?.artifact_id ||
|
||
attrs.artifact_id ||
|
||
attrs.source_id ||
|
||
`Citation`;
|
||
}
|
||
|
||
function citationExcerpt(citation, source) {
|
||
return source?.content ||
|
||
source?.text ||
|
||
citation?.text ||
|
||
citation?.excerpt ||
|
||
citation?.content ||
|
||
"";
|
||
}
|
||
|
||
function renderMarkdown(markdown) {
|
||
const text = String(markdown || "");
|
||
const segments = [];
|
||
const fence = /```([\s\S]*?)```/g;
|
||
let cursor = 0;
|
||
let match;
|
||
while ((match = fence.exec(text))) {
|
||
if (match.index > cursor) segments.push(renderMarkdownText(text.slice(cursor, match.index)));
|
||
segments.push(`<pre><code>${escapeHtml(match[1].replace(/^\w+\n/, ""))}</code></pre>`);
|
||
cursor = match.index + match[0].length;
|
||
}
|
||
if (cursor < text.length) segments.push(renderMarkdownText(text.slice(cursor)));
|
||
return `<div class="markdown">${segments.join("")}</div>`;
|
||
}
|
||
|
||
function renderMarkdownText(text) {
|
||
const lines = text.replace(/\r\n/g, "\n").split("\n");
|
||
const html = [];
|
||
let list = null;
|
||
const closeList = () => {
|
||
if (list) {
|
||
html.push(`</${list}>`);
|
||
list = null;
|
||
}
|
||
};
|
||
for (let index = 0; index < lines.length; index += 1) {
|
||
const raw = lines[index];
|
||
const line = raw.trimEnd();
|
||
if (!line.trim()) {
|
||
closeList();
|
||
continue;
|
||
}
|
||
const nextLine = lines[index + 1]?.trimEnd() || "";
|
||
if (isMarkdownTableHeader(line, nextLine)) {
|
||
closeList();
|
||
const header = parseMarkdownTableRow(line);
|
||
const alignments = parseMarkdownTableAlignments(nextLine);
|
||
const rows = [];
|
||
index += 2;
|
||
while (index < lines.length) {
|
||
const rowLine = lines[index].trimEnd();
|
||
if (!rowLine.trim() || !rowLine.includes("|")) break;
|
||
const row = parseMarkdownTableRow(rowLine);
|
||
if (row.length < 2) break;
|
||
rows.push(row);
|
||
index += 1;
|
||
}
|
||
index -= 1;
|
||
html.push(renderMarkdownTable(header, alignments, rows));
|
||
continue;
|
||
}
|
||
const bullet = line.match(/^\s*[-*]\s+(.+)$/);
|
||
const ordered = line.match(/^\s*\d+[.)]\s+(.+)$/);
|
||
const heading = line.match(/^(#{1,3})\s+(.+)$/);
|
||
if (bullet) {
|
||
if (list !== "ul") { closeList(); html.push("<ul>"); list = "ul"; }
|
||
html.push(`<li>${inlineMarkdown(bullet[1])}</li>`);
|
||
} else if (ordered) {
|
||
if (list !== "ol") { closeList(); html.push("<ol>"); list = "ol"; }
|
||
html.push(`<li>${inlineMarkdown(ordered[1])}</li>`);
|
||
} else if (heading) {
|
||
closeList();
|
||
const level = Math.min(3, heading[1].length + 2);
|
||
html.push(`<h${level}>${inlineMarkdown(heading[2])}</h${level}>`);
|
||
} else {
|
||
closeList();
|
||
html.push(`<p>${inlineMarkdown(line)}</p>`);
|
||
}
|
||
}
|
||
closeList();
|
||
return html.join("");
|
||
}
|
||
|
||
function isMarkdownTableHeader(headerLine, dividerLine) {
|
||
const divider = parseMarkdownTableRow(dividerLine);
|
||
return headerLine.includes("|") &&
|
||
parseMarkdownTableRow(headerLine).length >= 2 &&
|
||
/^:?-{3,}:?$/.test(divider[0] || "") &&
|
||
divider.every(cell => /^:?-{3,}:?$/.test(cell));
|
||
}
|
||
|
||
function parseMarkdownTableRow(line) {
|
||
let trimmed = String(line || "").trim();
|
||
if (trimmed.startsWith("|")) trimmed = trimmed.slice(1);
|
||
if (trimmed.endsWith("|")) trimmed = trimmed.slice(0, -1);
|
||
return trimmed.split("|").map(cell => cell.trim());
|
||
}
|
||
|
||
function parseMarkdownTableAlignments(line) {
|
||
return parseMarkdownTableRow(line).map(cell => {
|
||
const left = cell.startsWith(":");
|
||
const right = cell.endsWith(":");
|
||
if (left && right) return "center";
|
||
if (right) return "right";
|
||
return "left";
|
||
});
|
||
}
|
||
|
||
function renderMarkdownTable(header, alignments, rows) {
|
||
const cellStyle = index => alignments[index] && alignments[index] !== "left"
|
||
? ` style="text-align:${alignments[index]}"`
|
||
: "";
|
||
const columnCount = header.length;
|
||
const renderCells = (cells, tag) => Array.from({ length: columnCount }, (_unused, index) =>
|
||
`<${tag}${cellStyle(index)}>${inlineMarkdown(cells[index] || "")}</${tag}>`
|
||
).join("");
|
||
return `
|
||
<div class="markdown-table-scroll">
|
||
<table>
|
||
<thead><tr>${renderCells(header, "th")}</tr></thead>
|
||
<tbody>${rows.map(row => `<tr>${renderCells(row, "td")}</tr>`).join("")}</tbody>
|
||
</table>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function inlineMarkdown(value) {
|
||
let text = escapeHtml(value);
|
||
text = text.replace(/`([^`]+)`/g, "<code>$1</code>");
|
||
text = text.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
|
||
text = text.replace(/\*([^*]+)\*/g, "<em>$1</em>");
|
||
text = text.replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, '<a href="$2" target="_blank" rel="noreferrer">$1</a>');
|
||
return text;
|
||
}
|
||
|
||
function renderDebugger() {
|
||
const events = getDebugEvents().filter(event => event.type === "api:call");
|
||
const list = $("#debugList");
|
||
const detail = $("#debugDetail");
|
||
if (!list || !detail) return;
|
||
if (!events.length) {
|
||
list.innerHTML = '<div class="empty-state" style="min-height:140px;">No live debug events for this session.</div>';
|
||
detail.innerHTML = '<div class="empty-state">Send a message or run a tool to see request details.</div>';
|
||
return;
|
||
}
|
||
if (!runtime.selectedDebugId || !events.some(event => event.id === runtime.selectedDebugId)) {
|
||
runtime.selectedDebugId = events.at(-1).id;
|
||
}
|
||
list.innerHTML = "";
|
||
events.forEach(event => {
|
||
const button = document.createElement("button");
|
||
button.type = "button";
|
||
button.className = `debug-event ${event.id === runtime.selectedDebugId ? "active" : ""}`;
|
||
button.innerHTML = `
|
||
<div class="debug-event-title"><span>${escapeHtml(debugTitle(event))}</span><span>${escapeHtml(new Date(event.timestamp).toLocaleTimeString())}</span></div>
|
||
<div class="debug-event-meta">${escapeHtml(debugMeta(event))}</div>
|
||
`;
|
||
button.addEventListener("click", () => {
|
||
runtime.selectedDebugId = event.id;
|
||
renderDebugger();
|
||
});
|
||
list.appendChild(button);
|
||
});
|
||
const event = events.find(item => item.id === runtime.selectedDebugId) || events.at(-1);
|
||
detail.innerHTML = renderDebugDetail(event);
|
||
$$("[data-copy]", detail).forEach(button => {
|
||
button.addEventListener("click", () => copyText(button.dataset.copy, button.dataset.copyLabel || "Copied"));
|
||
});
|
||
}
|
||
|
||
function debugTitle(event) {
|
||
const prefix = event.error ? "Error" : event.status ? String(event.status) : "Pending";
|
||
return `${prefix} ${event.method || "GET"} ${event.path || ""}`;
|
||
}
|
||
|
||
function debugMeta(event) {
|
||
if (event.durationMs != null) return `${event.durationMs}ms`;
|
||
if (!event.status && !event.error) return "Pending";
|
||
if (event.url) return event.url;
|
||
return event.type;
|
||
}
|
||
|
||
function renderDebugDetail(event) {
|
||
const request = event.request ? JSON.stringify(event.request, null, 2) : "";
|
||
const response = event.response ? JSON.stringify(event.response, null, 2) : "";
|
||
const headers = event.headers ? JSON.stringify(event.headers, null, 2) : "";
|
||
return `
|
||
<div class="debug-detail-head">
|
||
<div>
|
||
<h2>${escapeHtml(debugTitle(event))}</h2>
|
||
<p class="subtle">${escapeHtml(event.method || "")} ${escapeHtml(event.url || "")} ${event.durationMs != null ? "· " + event.durationMs + "ms" : ""}</p>
|
||
</div>
|
||
</div>
|
||
${event.message ? `<div class="code-box"><pre>${escapeHtml(event.message)}</pre></div>` : ""}
|
||
${headers ? codeSection("Headers", headers) : ""}
|
||
${event.error ? codeSection("Error", JSON.stringify(event.error, null, 2)) : ""}
|
||
${request ? codeSection("Request", request) : ""}
|
||
${response ? codeSection("Response", response) : ""}
|
||
`;
|
||
}
|
||
|
||
function codeSection(title, content, copyLabel = "Copy") {
|
||
return `
|
||
<div class="code-block">
|
||
<div class="code-block-header">
|
||
<span>${escapeHtml(title)}</span>
|
||
<button class="ghost-button" type="button" data-copy="${escapeAttr(content)}" data-copy-label="${escapeAttr(copyLabel)}">${escapeHtml(copyLabel)}</button>
|
||
</div>
|
||
<div class="code-box"><pre>${escapeHtml(content)}</pre></div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function apiErrorMessage(error) {
|
||
const body = error?.body;
|
||
if (body) {
|
||
if (typeof body.detail === "string" && body.detail) return body.detail;
|
||
const explanation = body?.error?.detail?.explanation;
|
||
if (typeof explanation === "string" && explanation) return explanation;
|
||
const msg = body?.error?.message;
|
||
if (typeof msg === "string" && msg) return msg;
|
||
}
|
||
return error?.message ?? "Unknown error";
|
||
}
|
||
|
||
async function apiFetch(path, { method = "GET", body, headers = {} } = {}, debugMeta = {}) {
|
||
const base = (state.privateGptBaseUrl || DEFAULT_BASE_URL).replace(/\/+$/, "");
|
||
const url = `${base}${path}`;
|
||
const requestHeaders = { ...headers };
|
||
const isGet = method.toUpperCase() === "GET";
|
||
if (body !== undefined) requestHeaders["Content-Type"] = "application/json";
|
||
if (isGet) {
|
||
requestHeaders["Cache-Control"] = "no-cache";
|
||
requestHeaders.Pragma = "no-cache";
|
||
}
|
||
const authHeader = createBasicAuthHeader(state.privateGptUsername, state.privateGptPassword);
|
||
if (authHeader) requestHeaders.Authorization = authHeader;
|
||
const request = body !== undefined ? JSON.parse(JSON.stringify(body)) : undefined;
|
||
const redactedHeaders = redactHeaders(requestHeaders);
|
||
const MAX_RETRIES = 2;
|
||
let lastError;
|
||
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
||
if (attempt > 0) {
|
||
await new Promise(resolve => setTimeout(resolve, 600 * attempt));
|
||
}
|
||
const debugEvent = addDebugEvent("api:call", {
|
||
method, path, url, headers: redactedHeaders, request,
|
||
...(attempt > 0 ? { message: `Retry ${attempt}/${MAX_RETRIES}` } : {}),
|
||
...debugMeta
|
||
});
|
||
const started = performance.now();
|
||
try {
|
||
const response = await fetch(url, {
|
||
method,
|
||
headers: requestHeaders,
|
||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||
cache: isGet ? "no-store" : "default"
|
||
});
|
||
const text = await response.text();
|
||
const parsed = text ? safeJson(text) : null;
|
||
const durationMs = Math.round(performance.now() - started);
|
||
if (!response.ok) {
|
||
const error = { status: response.status, statusText: response.statusText, body: parsed ?? text };
|
||
updateDebugEvent(debugEvent?.id, { response: parsed, error, status: response.status, durationMs });
|
||
const err = Object.assign(new Error(`${response.status} ${response.statusText}`), error);
|
||
if (response.status < 500) throw err;
|
||
lastError = err;
|
||
continue;
|
||
}
|
||
updateDebugEvent(debugEvent?.id, { response: parsed, status: response.status, durationMs });
|
||
return parsed;
|
||
} catch (error) {
|
||
if (error.status && error.status < 500) throw error;
|
||
if (!error.status) {
|
||
updateDebugEvent(debugEvent?.id, { error: { message: error.message }, durationMs: Math.round(performance.now() - started) });
|
||
}
|
||
lastError = error;
|
||
}
|
||
}
|
||
throw lastError;
|
||
}
|
||
|
||
async function apiStreamFetch(path, { method = "POST", body, headers = {} } = {}, debugMeta = {}, onDelta) {
|
||
const base = (state.privateGptBaseUrl || DEFAULT_BASE_URL).replace(/\/+$/, "");
|
||
const url = `${base}${path}`;
|
||
const requestHeaders = { "Content-Type": "application/json", ...headers };
|
||
const authHeader = createBasicAuthHeader(state.privateGptUsername, state.privateGptPassword);
|
||
if (authHeader) requestHeaders.Authorization = authHeader;
|
||
const redactedHeaders = redactHeaders(requestHeaders);
|
||
const debugEvent = addDebugEvent("api:call", { method, path, url, headers: redactedHeaders, request: body, ...debugMeta });
|
||
const started = performance.now();
|
||
const response = await fetch(url, {
|
||
method,
|
||
headers: requestHeaders,
|
||
body: body !== undefined ? JSON.stringify(body) : undefined
|
||
});
|
||
if (!response.ok) {
|
||
const text = await response.text();
|
||
const parsed = text ? safeJson(text) : null;
|
||
const durationMs = Math.round(performance.now() - started);
|
||
const error = { status: response.status, statusText: response.statusText, body: parsed ?? text };
|
||
updateDebugEvent(debugEvent?.id, { response: parsed, error, status: response.status, durationMs });
|
||
throw Object.assign(new Error(`${response.status} ${response.statusText}`), error);
|
||
}
|
||
const reader = response.body.getReader();
|
||
const decoder = new TextDecoder();
|
||
let buffer = "";
|
||
const messageData = { id: null, role: "assistant", content: [], stop_reason: null };
|
||
try {
|
||
while (true) {
|
||
const { done, value } = await reader.read();
|
||
if (done) break;
|
||
buffer += decoder.decode(value, { stream: true });
|
||
const lines = buffer.split("\n");
|
||
buffer = lines.pop() ?? "";
|
||
for (const line of lines) {
|
||
if (!line.startsWith("data: ")) continue;
|
||
const dataStr = line.slice(6).trim();
|
||
if (dataStr === "[DONE]") continue;
|
||
const event = safeJson(dataStr);
|
||
if (!event || typeof event !== "object") continue;
|
||
if (event.type === "message_start") {
|
||
messageData.id = event.message?.id || messageData.id;
|
||
messageData.role = event.message?.role || "assistant";
|
||
} else if (event.type === "content_block_start") {
|
||
const idx = event.index ?? 0;
|
||
while (messageData.content.length <= idx) messageData.content.push(null);
|
||
messageData.content[idx] = { ...event.content_block };
|
||
} else if (event.type === "content_block_delta") {
|
||
const idx = event.index ?? 0;
|
||
const block = messageData.content[idx];
|
||
if (block?.type === "text" && event.delta?.type === "text_delta") {
|
||
block.text = (block.text || "") + event.delta.text;
|
||
onDelta?.(messageData.content.filter(Boolean));
|
||
} else if (block?.type === "thinking" && event.delta?.type === "thinking_delta") {
|
||
block.thinking = (block.thinking || "") + event.delta.thinking;
|
||
onDelta?.(messageData.content.filter(Boolean));
|
||
} else if (block?.type === "tool_use" && event.delta?.type === "input_json_delta") {
|
||
const delta = event.delta;
|
||
if (delta.partial_json_obj && Object.keys(delta.partial_json_obj).length > 0) {
|
||
block.input = delta.partial_json_obj;
|
||
} else if (delta.partial_json) {
|
||
block._inputJsonBuf = (block._inputJsonBuf || "") + delta.partial_json;
|
||
try { block.input = JSON.parse(block._inputJsonBuf); } catch {}
|
||
}
|
||
onDelta?.(messageData.content.filter(Boolean));
|
||
}
|
||
} else if (event.type === "message_delta") {
|
||
messageData.stop_reason = event.delta?.stop_reason || messageData.stop_reason;
|
||
} else if (event.type === "error") {
|
||
throw Object.assign(new Error(event.error?.message || "Stream error"), { body: event });
|
||
}
|
||
}
|
||
}
|
||
} finally {
|
||
reader.releaseLock();
|
||
}
|
||
messageData.content = messageData.content.filter(Boolean).map(b => {
|
||
if (b._inputJsonBuf !== undefined) { const { _inputJsonBuf, ...rest } = b; return rest; }
|
||
return b;
|
||
});
|
||
const durationMs = Math.round(performance.now() - started);
|
||
updateDebugEvent(debugEvent?.id, { response: messageData, status: response.status, durationMs });
|
||
return messageData;
|
||
}
|
||
|
||
async function apiFormFetch(path, formData, { method = "POST", headers = {} } = {}, debugMeta = {}) {
|
||
const base = (state.privateGptBaseUrl || DEFAULT_BASE_URL).replace(/\/+$/, "");
|
||
const url = `${base}${path}`;
|
||
const requestHeaders = { ...headers };
|
||
const authHeader = createBasicAuthHeader(state.privateGptUsername, state.privateGptPassword);
|
||
if (authHeader) requestHeaders.Authorization = authHeader;
|
||
const request = {};
|
||
formData.forEach((value, key) => {
|
||
request[key] = value instanceof File ? { name: value.name, size: value.size, type: value.type } : value;
|
||
});
|
||
const redactedHeaders = redactHeaders(requestHeaders);
|
||
const debugEvent = addDebugEvent("api:call", { method, path, url, headers: redactedHeaders, request, ...debugMeta });
|
||
const started = performance.now();
|
||
try {
|
||
const response = await fetch(url, {
|
||
method,
|
||
headers: requestHeaders,
|
||
body: formData
|
||
});
|
||
const text = await response.text();
|
||
const parsed = text ? safeJson(text) : null;
|
||
const durationMs = Math.round(performance.now() - started);
|
||
if (!response.ok) {
|
||
const error = { status: response.status, statusText: response.statusText, body: parsed ?? text };
|
||
updateDebugEvent(debugEvent?.id, { response: parsed, error, status: response.status, durationMs });
|
||
throw Object.assign(new Error(`${response.status} ${response.statusText}`), error);
|
||
}
|
||
updateDebugEvent(debugEvent?.id, { response: parsed, status: response.status, durationMs });
|
||
return parsed;
|
||
} catch (error) {
|
||
if (!error.status) {
|
||
updateDebugEvent(debugEvent?.id, { error: { message: error.message }, durationMs: Math.round(performance.now() - started) });
|
||
}
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
async function loadModels() {
|
||
const progress = toast("Testing PrivateGPT connection...", "loading", { persistent: true });
|
||
try {
|
||
const result = await apiFetch("/v1/models", {}, { label: "Load models" });
|
||
state.models = Array.isArray(result?.data) ? result.data : [];
|
||
if (!state.models.some(model => model.id === state.selectedModel)) {
|
||
state.selectedModel = state.models[0]?.id ?? null;
|
||
}
|
||
saveState();
|
||
render();
|
||
progress.update("Connected to PrivateGPT.", "ok");
|
||
} catch (error) {
|
||
progress.update(`Could not load models: ${error.message}`, "error");
|
||
}
|
||
}
|
||
|
||
async function refreshDocuments(options = {}) {
|
||
const { showToast = true, throwOnError = false } = options;
|
||
const progress = showToast ? toast("Refreshing documents...", "loading", { persistent: true }) : null;
|
||
try {
|
||
runtime.docs = [];
|
||
state.context.documents.knownArtifacts = [];
|
||
renderDocuments();
|
||
const collection = encodeURIComponent(state.context.documents.defaultCollection || "pgpt_collection");
|
||
const result = await apiFetch(`/v1/artifacts/list?collection=${collection}&_ts=${Date.now()}`, {}, { label: "List artifacts" });
|
||
runtime.docs = Array.isArray(result?.data) ? result.data : [];
|
||
state.context.documents.knownArtifacts = runtime.docs;
|
||
saveState();
|
||
renderDocuments();
|
||
progress?.update("Documents refreshed.", "ok");
|
||
return runtime.docs;
|
||
} catch (error) {
|
||
progress?.update(`Could not list documents: ${error.message}`, "error");
|
||
if (!progress) toast(`Could not list documents: ${error.message}`, "error");
|
||
if (throwOnError) throw error;
|
||
return [];
|
||
}
|
||
}
|
||
|
||
async function refreshSkills(options = {}) {
|
||
const { showToast = true, throwOnError = false } = options;
|
||
const collection = state.context.documents.defaultCollection || "pgpt_collection";
|
||
const progress = showToast ? toast("Refreshing skills...", "loading", { persistent: true }) : null;
|
||
try {
|
||
state.context.skills = [];
|
||
renderSkills();
|
||
const result = await apiFetch(`/v1/skills?collection=${encodeURIComponent(collection)}&_ts=${Date.now()}`, {}, { label: "List skills" });
|
||
state.context.skills = Array.isArray(result?.data) ? result.data : [];
|
||
saveState();
|
||
renderSkills();
|
||
progress?.update("Skills refreshed.", "ok");
|
||
return state.context.skills;
|
||
} catch (error) {
|
||
progress?.update(`Could not list skills: ${error.message}`, "error");
|
||
if (!progress) toast(`Could not list skills: ${error.message}`, "error");
|
||
if (throwOnError) throw error;
|
||
return [];
|
||
}
|
||
}
|
||
|
||
async function switchActiveCollection(nextCollection, options = {}) {
|
||
const {
|
||
showToast = true,
|
||
throwOnError = false,
|
||
forceRefresh = false
|
||
} = options;
|
||
const normalized = (nextCollection || "").trim() || "pgpt_collection";
|
||
const changed = normalized !== (state.context.documents.defaultCollection || "pgpt_collection");
|
||
if (!changed && !forceRefresh) return true;
|
||
|
||
state.context.documents.defaultCollection = normalized;
|
||
runtime.docs = [];
|
||
state.context.documents.knownArtifacts = [];
|
||
state.context.skills = [];
|
||
saveState();
|
||
render();
|
||
|
||
const progress = showToast ? toast("Refreshing context for collection...", "loading", { persistent: true }) : null;
|
||
const [documentsResult, skillsResult] = await Promise.allSettled([
|
||
refreshDocuments({ showToast: false, throwOnError }),
|
||
refreshSkills({ showToast: false, throwOnError })
|
||
]);
|
||
|
||
const failures = [documentsResult, skillsResult].filter(result => result.status === "rejected");
|
||
normalizeChatContextSelections();
|
||
render();
|
||
if (!failures.length) {
|
||
progress?.update("Context refreshed for the selected collection.", "ok");
|
||
return true;
|
||
}
|
||
|
||
const detail = failures
|
||
.map(result => result.reason?.message || "Unknown error")
|
||
.filter(Boolean)
|
||
.join(" ");
|
||
progress?.update(`Collection changed, but some context failed to refresh: ${detail}`, "error");
|
||
if (throwOnError) throw failures[0].reason;
|
||
return false;
|
||
}
|
||
|
||
async function deleteSkill(skillId) {
|
||
const skill = state.context.skills.find(item => item.id === skillId);
|
||
if (!skill || !confirm(`Delete ${skill.display_title || "this skill"}?`)) return;
|
||
const progress = toast("Deleting skill...", "loading", { persistent: true });
|
||
try {
|
||
await apiFetch(`/v1/skills/${encodeURIComponent(skillId)}?collection=${encodeURIComponent(skill.collection || state.context.documents.defaultCollection || "pgpt_collection")}`, {
|
||
method: "DELETE"
|
||
}, { label: "Delete skill" });
|
||
state.context.skills = state.context.skills.filter(item => item.id !== skillId);
|
||
saveState();
|
||
renderSkills();
|
||
progress.update("Skill deleted.", "ok");
|
||
} catch (error) {
|
||
progress.update(`Delete failed: ${error.message}`, "error");
|
||
}
|
||
}
|
||
|
||
function deleteMessage(chatId, messageId) {
|
||
const chat = state.chats.find(c => c.id === chatId);
|
||
if (!chat) return;
|
||
const idx = chat.messages.findIndex(m => m.id === messageId);
|
||
if (idx < 0) return;
|
||
chat.messages = chat.messages.slice(0, idx);
|
||
touchChat(chat);
|
||
saveState();
|
||
renderMessages();
|
||
}
|
||
|
||
function retryFromMessage(chatId, messageId) {
|
||
const chat = state.chats.find(c => c.id === chatId);
|
||
if (!chat || runtime.sendingChatIds.has(chatId)) return;
|
||
const idx = chat.messages.findIndex(m => m.id === messageId);
|
||
if (idx < 0) return;
|
||
chat.messages = chat.messages.slice(0, idx);
|
||
touchChat(chat);
|
||
saveState();
|
||
renderMessages();
|
||
runtime.sendingChatIds.add(chatId);
|
||
render();
|
||
requestAssistant(chat, 0).finally(() => {
|
||
runtime.sendingChatIds.delete(chatId);
|
||
touchChat(chat);
|
||
saveState();
|
||
render();
|
||
});
|
||
}
|
||
|
||
async function uploadFiles(files) {
|
||
if (!files.length) return;
|
||
const collection = state.context.documents.defaultCollection || "pgpt_collection";
|
||
const progress = toast(`Uploading ${files.length} file${files.length === 1 ? "" : "s"}...`, "loading", { persistent: true });
|
||
let uploaded = 0;
|
||
let failed = 0;
|
||
for (const file of files) {
|
||
const artifact = `${sanitizeId(file.name.replace(/\.[^.]+$/, ""))}-${Date.now()}`;
|
||
try {
|
||
progress.update(`Uploading ${file.name}...`, "loading", { autoClose: false });
|
||
const base64 = await fileToBase64(file);
|
||
await apiFetch("/v1/artifacts/ingest", {
|
||
method: "POST",
|
||
body: {
|
||
artifact,
|
||
collection,
|
||
input: { type: "file", value: base64 },
|
||
metadata: { file_name: file.name }
|
||
}
|
||
}, { label: `Ingest ${file.name}` });
|
||
uploaded += 1;
|
||
} catch (error) {
|
||
failed += 1;
|
||
toast(`Upload failed for ${file.name}: ${error.message}`, "error");
|
||
}
|
||
}
|
||
try {
|
||
progress.update("Refreshing document list...", "loading", { autoClose: false });
|
||
await refreshDocuments({ showToast: false, throwOnError: true });
|
||
if (failed) {
|
||
progress.update(`Uploaded ${uploaded} file${uploaded === 1 ? "" : "s"}; ${failed} failed.`, "error");
|
||
} else {
|
||
progress.update(`Uploaded ${uploaded} file${uploaded === 1 ? "" : "s"}.`, "ok");
|
||
}
|
||
} catch (error) {
|
||
progress.update(`Uploaded ${uploaded} file${uploaded === 1 ? "" : "s"}, but refresh failed: ${error.message}`, "error");
|
||
}
|
||
}
|
||
|
||
async function uploadToSession(files) {
|
||
if (!files.length) return;
|
||
const chat = activeChat();
|
||
if (!chat || !chat.settings.enabledCodeExecution) return;
|
||
const base = (state.privateGptBaseUrl || DEFAULT_BASE_URL).replace(/\/+$/, "");
|
||
const progress = toast(`Uploading ${files.length} file${files.length === 1 ? "" : "s"} to session...`, "loading", { persistent: true });
|
||
let uploaded = 0, failed = 0;
|
||
for (const file of files) {
|
||
try {
|
||
progress.update(`Uploading ${file.name}...`, "loading", { autoClose: false });
|
||
const formData = new FormData();
|
||
formData.append("file", file);
|
||
const headers = {};
|
||
const authHeader = createBasicAuthHeader(state.privateGptUsername, state.privateGptPassword);
|
||
if (authHeader) headers.Authorization = authHeader;
|
||
const res = await fetch(`${base}/v1/files?scope_id=${encodeURIComponent(chat.id)}`, {
|
||
method: "POST",
|
||
headers,
|
||
body: formData
|
||
});
|
||
if (!res.ok) { failed++; continue; }
|
||
uploaded++;
|
||
} catch { failed++; }
|
||
}
|
||
if (failed > 0) {
|
||
progress.update(`Uploaded ${uploaded} file(s); ${failed} failed.`, "error");
|
||
} else {
|
||
progress.update(`Uploaded ${uploaded} file(s) to session. The model can access them in the workspace.`, "ok");
|
||
}
|
||
}
|
||
|
||
async function deleteDocument(artifact) {
|
||
if (!confirm(`Delete ${artifact}?`)) return;
|
||
const progress = toast("Deleting document...", "loading", { persistent: true });
|
||
try {
|
||
await apiFetch("/v1/artifacts/delete", {
|
||
method: "POST",
|
||
body: {
|
||
collection: state.context.documents.defaultCollection || "pgpt_collection",
|
||
artifact
|
||
}
|
||
}, { label: "Delete artifact" });
|
||
runtime.docs = runtime.docs.filter(doc => doc.artifact !== artifact);
|
||
state.context.documents.knownArtifacts = (state.context.documents.knownArtifacts || []).filter(doc => doc.artifact !== artifact);
|
||
saveState();
|
||
renderDocuments();
|
||
progress.update("Document deleted.", "ok");
|
||
} catch (error) {
|
||
progress.update(`Delete failed: ${error.message}`, "error");
|
||
}
|
||
}
|
||
|
||
async function sendChat(text) {
|
||
const chat = activeChat();
|
||
if (!chat || runtime.sendingChatIds.has(chat.id)) return;
|
||
const trimmed = text.trim();
|
||
if (!trimmed) return;
|
||
runtime.sendingChatIds.add(chat.id);
|
||
chat.messages.push({
|
||
id: generateUUID(),
|
||
role: "user",
|
||
text: trimmed,
|
||
contentBlocks: [{ type: "text", text: trimmed }],
|
||
apiMessage: { role: "user", content: trimmed },
|
||
createdAt: new Date().toISOString()
|
||
});
|
||
if (!chat.title || chat.title === "New chat") chat.title = titleFromText(trimmed);
|
||
touchChat(chat);
|
||
saveState();
|
||
$("#composerInput").value = "";
|
||
render();
|
||
try {
|
||
await requestAssistant(chat, 0);
|
||
} catch (error) {
|
||
toast(`Chat failed: ${apiErrorMessage(error)}`, "error");
|
||
} finally {
|
||
runtime.sendingChatIds.delete(chat.id);
|
||
touchChat(chat);
|
||
saveState();
|
||
render();
|
||
}
|
||
}
|
||
|
||
async function requestAssistant(chat, depth, mergeIntoMessage = null) {
|
||
const body = buildChatBody(chat);
|
||
|
||
// When merging into a parent message, update it in-place; otherwise create a new streaming message.
|
||
let displayMessage;
|
||
let prefixBlocks = [];
|
||
|
||
if (mergeIntoMessage) {
|
||
displayMessage = mergeIntoMessage;
|
||
prefixBlocks = [...mergeIntoMessage.contentBlocks];
|
||
mergeIntoMessage.streaming = true;
|
||
touchChat(chat);
|
||
renderMessages();
|
||
} else {
|
||
const streamingId = generateUUID();
|
||
displayMessage = {
|
||
id: streamingId,
|
||
role: "assistant",
|
||
text: "",
|
||
contentBlocks: [{ type: "text", text: "" }],
|
||
apiMessage: { role: "assistant", content: "" },
|
||
createdAt: new Date().toISOString(),
|
||
streaming: true
|
||
};
|
||
chat.messages.push(displayMessage);
|
||
touchChat(chat);
|
||
renderMessages();
|
||
}
|
||
|
||
const displayId = displayMessage.id;
|
||
let streamingTextEl = null;
|
||
|
||
try {
|
||
const response = await apiStreamFetch("/v1/messages", { method: "POST", body }, { label: "Chat message" }, (blocks) => {
|
||
const allBlocks = [...prefixBlocks, ...blocks];
|
||
displayMessage.contentBlocks = allBlocks;
|
||
displayMessage.text = allBlocks.filter(b => b?.type === "text").map(b => b.text).join("");
|
||
if (!mergeIntoMessage) displayMessage.apiMessage = { role: "assistant", content: blocks };
|
||
if (!streamingTextEl) {
|
||
streamingTextEl = document.querySelector(`[data-message-id="${displayId}"] .message-text`);
|
||
}
|
||
if (streamingTextEl) {
|
||
const citationContext = buildCitationContext(allBlocks);
|
||
streamingTextEl.innerHTML = blocksToHtml(allBlocks, displayMessage.text, citationContext, { streaming: true });
|
||
// Scroll thinking pre to bottom so latest lines are always visible
|
||
const thinkingPre = streamingTextEl.querySelector(".thinking-pre-wrap pre");
|
||
if (thinkingPre) thinkingPre.scrollTop = thinkingPre.scrollHeight;
|
||
const wrap = document.querySelector(`[data-message-id="${displayId}"]`)?.closest(".messages");
|
||
if (wrap) { wrap.scrollTop = wrap.scrollHeight; updateMessagesFade(); }
|
||
}
|
||
});
|
||
|
||
const responseBlocks = Array.isArray(response?.content) ? response.content : [{ type: "text", text: responseText(response) }];
|
||
const allDisplayBlocks = [...prefixBlocks, ...responseBlocks];
|
||
|
||
if (mergeIntoMessage) {
|
||
// Update display in-place; add a hidden message to carry API history for future turns.
|
||
mergeIntoMessage.contentBlocks = allDisplayBlocks;
|
||
mergeIntoMessage.text = allDisplayBlocks.filter(b => b?.type === "text").map(b => b.text).join("");
|
||
mergeIntoMessage.streaming = false;
|
||
chat.messages.push({
|
||
id: response?.id || generateUUID(),
|
||
role: "assistant",
|
||
hidden: true,
|
||
text: responseText(response),
|
||
contentBlocks: responseBlocks,
|
||
apiMessage: { role: "assistant", content: responseBlocks },
|
||
createdAt: new Date().toISOString()
|
||
});
|
||
} else {
|
||
const finalMessage = {
|
||
id: displayMessage.id,
|
||
role: "assistant",
|
||
text: responseText(response),
|
||
contentBlocks: responseBlocks,
|
||
apiMessage: { role: "assistant", content: responseBlocks },
|
||
raw: response,
|
||
createdAt: displayMessage.createdAt
|
||
};
|
||
const idx = chat.messages.findIndex(m => m.id === displayMessage.id);
|
||
if (idx >= 0) chat.messages[idx] = finalMessage;
|
||
displayMessage = finalMessage;
|
||
}
|
||
|
||
touchChat(chat);
|
||
saveState();
|
||
renderMessages();
|
||
|
||
// Execute any browser custom tool calls and merge results into the same display block.
|
||
const toolUses = responseBlocks.filter(block => block?.type === "tool_use");
|
||
for (const toolUse of toolUses) {
|
||
addDebugEvent("tool_use:received", { name: toolUse.name, data: toolUse });
|
||
const customTool = featureEnabled("customTools")
|
||
? state.context.customTools.find(t => t.name === toolUse.name && chat.settings.enabledCustomTools.includes(t.id))
|
||
: null;
|
||
if (!customTool || depth >= 3) continue;
|
||
const resultBlock = await executeCustomTool(customTool, toolUse, chat);
|
||
// Append tool_result to display only; keep apiMessage unchanged for API history.
|
||
displayMessage.contentBlocks = [...displayMessage.contentBlocks, resultBlock];
|
||
displayMessage.text = displayMessage.contentBlocks.filter(b => b?.type === "text").map(b => b.text).join("");
|
||
// Hidden message carries the tool_result for the API.
|
||
chat.messages.push({
|
||
id: generateUUID(),
|
||
role: "tool",
|
||
hidden: true,
|
||
text: typeof resultBlock.content === "string" ? resultBlock.content : JSON.stringify(resultBlock.content),
|
||
contentBlocks: [resultBlock],
|
||
apiMessage: { role: "user", content: [resultBlock] },
|
||
createdAt: new Date().toISOString()
|
||
});
|
||
touchChat(chat);
|
||
saveState();
|
||
renderMessages();
|
||
addDebugEvent("chat:follow_up_request", { message: `Sending result for ${toolUse.name}.`, data: resultBlock });
|
||
await requestAssistant(chat, depth + 1, displayMessage);
|
||
}
|
||
} catch (error) {
|
||
if (mergeIntoMessage) {
|
||
mergeIntoMessage.streaming = false;
|
||
mergeIntoMessage.error = apiErrorMessage(error);
|
||
} else {
|
||
// Use displayMessage.id (not displayId) because displayMessage may have been reassigned to finalMessage.
|
||
const idx = chat.messages.findIndex(m => m.id === displayMessage.id);
|
||
if (idx >= 0) {
|
||
const msg = chat.messages[idx];
|
||
msg.streaming = false;
|
||
msg.error = apiErrorMessage(error);
|
||
const hasRealContent = msg.text || msg.contentBlocks.some(b => b?.type !== "text" || b.text);
|
||
if (!hasRealContent) {
|
||
msg.contentBlocks = [];
|
||
msg.apiMessage = null; // excluded by sanitizeMessages — no empty assistant in history
|
||
}
|
||
}
|
||
}
|
||
touchChat(chat);
|
||
saveState();
|
||
renderMessages();
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
function sanitizeMessages(chatMessages) {
|
||
const raw = chatMessages.map(m => m.apiMessage).filter(Boolean);
|
||
|
||
// Split assistant messages that mix tool_use + tool_result into separate messages
|
||
const expanded = [];
|
||
for (const msg of raw) {
|
||
if (msg.role === "assistant" && Array.isArray(msg.content)) {
|
||
let buffer = [];
|
||
for (const block of msg.content) {
|
||
if (block?.type === "tool_result") {
|
||
if (buffer.length) { expanded.push({ role: "assistant", content: buffer }); buffer = []; }
|
||
expanded.push({ role: "assistant", content: [block] });
|
||
} else {
|
||
buffer.push(block);
|
||
}
|
||
}
|
||
if (buffer.length) expanded.push({ role: "assistant", content: buffer });
|
||
} else {
|
||
expanded.push({ role: msg.role, content: msg.content });
|
||
}
|
||
}
|
||
|
||
// Remove messages with empty content
|
||
const hasContent = msg => {
|
||
const c = msg.content;
|
||
if (c === null || c === undefined) return false;
|
||
if (typeof c === "string") return c.trim().length > 0;
|
||
if (Array.isArray(c)) {
|
||
if (!c.length) return false;
|
||
return c.some(b => {
|
||
if (!b || typeof b !== "object") return false;
|
||
if (b.type === "text") return (b.text || "").length > 0;
|
||
return true; // tool_use, tool_result, thinking — keep even if input is empty
|
||
});
|
||
}
|
||
return true;
|
||
};
|
||
|
||
const nonEmpty = expanded.filter(hasContent);
|
||
|
||
// Merge consecutive same-role messages
|
||
const merged = [];
|
||
for (const msg of nonEmpty) {
|
||
const prev = merged.at(-1);
|
||
if (prev && prev.role === msg.role) {
|
||
const toBlocks = c => Array.isArray(c) ? c : (c ? [{ type: "text", text: String(c) }] : []);
|
||
prev.content = [...toBlocks(prev.content), ...toBlocks(msg.content)];
|
||
} else {
|
||
merged.push({ role: msg.role, content: msg.content });
|
||
}
|
||
}
|
||
|
||
return merged;
|
||
}
|
||
|
||
function buildChatBody(chat) {
|
||
const hasDocs = normalizeDocumentBackedSettings(chat);
|
||
const documentsEnabled = hasDocs && Boolean(chat.settings.enabledDocuments);
|
||
const tabularEnabled = hasDocs && Boolean(chat.settings.enabledTabular);
|
||
const tools = [];
|
||
const toolContext = [];
|
||
if (documentsEnabled) {
|
||
tools.push({ name: "semantic_search", type: "semantic_search_v1" });
|
||
toolContext.push({
|
||
type: "ingested_artifact",
|
||
context_filter: {
|
||
collection: state.context.documents.defaultCollection || "pgpt_collection",
|
||
artifacts: []
|
||
}
|
||
});
|
||
}
|
||
if (tabularEnabled) {
|
||
tools.push({ name: "tabular_analysis", type: "tabular_analysis_v1" });
|
||
if (!documentsEnabled) {
|
||
toolContext.push({
|
||
type: "ingested_artifact",
|
||
context_filter: {
|
||
collection: state.context.documents.defaultCollection || "pgpt_collection",
|
||
artifacts: []
|
||
}
|
||
});
|
||
}
|
||
}
|
||
if (chat.settings.enabledCodeExecution) {
|
||
tools.push({ name: "code_execution", type: "code_execution_v1" });
|
||
}
|
||
if (featureEnabled("web") && chat.settings.enabledWeb) {
|
||
tools.push({ name: "web_search", type: "web_search_v1" });
|
||
tools.push({ name: "web_fetch", type: "web_fetch_v1" });
|
||
}
|
||
if (featureEnabled("databases")) {
|
||
selectedItems(state.context.databases, chat.settings.enabledDatabases).forEach(db => {
|
||
toolContext.push({
|
||
type: "sql_database",
|
||
connection_string: db.connection_string,
|
||
schemas: parseCsv(db.schemas),
|
||
ssl: Boolean(db.ssl),
|
||
enable_tables: db.enable_tables !== false,
|
||
enable_views: db.enable_views !== false,
|
||
enable_functions: db.enable_functions !== false,
|
||
enable_procedures: db.enable_procedures !== false,
|
||
description: db.description || ""
|
||
});
|
||
tools.push({ name: "database_query", type: "database_query_v1" });
|
||
});
|
||
}
|
||
if (featureEnabled("customTools")) {
|
||
selectedItems(state.context.customTools, chat.settings.enabledCustomTools).forEach(tool => {
|
||
let inputSchema = {};
|
||
try { inputSchema = JSON.parse(tool.input_schema_json || "{}"); } catch {}
|
||
tools.push({
|
||
name: tool.name,
|
||
description: tool.description || "",
|
||
inputSchema
|
||
});
|
||
});
|
||
}
|
||
const mcpServers = featureEnabled("mcp")
|
||
? selectedItems(state.context.mcpServers, chat.settings.enabledMcpServers).map(item => {
|
||
try { return JSON.parse(item.server_config_json || "{}"); } catch { return null; }
|
||
}).filter(Boolean)
|
||
: [];
|
||
const selectedSkillIds = featureEnabled("skills")
|
||
? selectedItems(state.context.skills, chat.settings.enabledSkills)
|
||
.filter(skill => skill.collection || skill.latest_version || skill.source)
|
||
.map(skill => skill.id)
|
||
.filter(Boolean)
|
||
: [];
|
||
if (selectedSkillIds.length) {
|
||
tools.push({ name: "skills", type: "skills_v1" });
|
||
toolContext.push({
|
||
type: "skill",
|
||
skill_filter: {
|
||
collection: state.context.documents.defaultCollection || "pgpt_collection",
|
||
skill_or_version_ids: selectedSkillIds
|
||
}
|
||
});
|
||
}
|
||
const systemText = combinedSystemPrompt();
|
||
const body = {
|
||
model: state.selectedModel,
|
||
messages: sanitizeMessages(chat.messages),
|
||
tools,
|
||
tool_context: toolContext,
|
||
mcp_servers: mcpServers,
|
||
stream: true,
|
||
max_tokens: 4096,
|
||
thinking: { enabled: state.enableThinking !== false && Boolean(chat.settings.enabledThinking) }
|
||
};
|
||
if (chat.settings.enabledCodeExecution) {
|
||
body.container = chat.id;
|
||
}
|
||
const citationsEnabled = Boolean(documentsEnabled && state.useCitations !== false);
|
||
const extensions = state.extensions || [];
|
||
const hasBuiltinTools = tools.some(t => t.type);
|
||
const thinkingEnabled = state.enableThinking !== false && Boolean(chat.settings.enabledThinking);
|
||
const defaultPrompt = state.useDefaultPrompt !== false;
|
||
if (systemText || citationsEnabled || extensions.length > 0 || hasBuiltinTools || thinkingEnabled) {
|
||
body.system = {
|
||
citations: { enabled: citationsEnabled },
|
||
extensions,
|
||
prompt: {
|
||
tools: defaultPrompt && hasBuiltinTools,
|
||
citations: defaultPrompt && citationsEnabled,
|
||
thinking: defaultPrompt && thinkingEnabled
|
||
}
|
||
};
|
||
if (systemText) body.system.text = systemText;
|
||
}
|
||
return body;
|
||
}
|
||
|
||
async function executeCustomTool(tool, toolUse, chat) {
|
||
addDebugEvent("custom_tool:execute_start", { message: `Executing ${tool.name}`, data: toolUse.input });
|
||
try {
|
||
const handle = compileHandler(tool.javascript_handler);
|
||
const logs = [];
|
||
const result = await handle(toolUse.input || {}, {
|
||
fetch: window.fetch.bind(window),
|
||
localStorage: window.localStorage,
|
||
privateGptBaseUrl: state.privateGptBaseUrl,
|
||
currentChatId: chat.id,
|
||
currentCollection: state.context.documents.defaultCollection || "pgpt_collection",
|
||
log: (message, data) => {
|
||
logs.push({ message, data });
|
||
addDebugEvent("custom_tool:log", { message, data });
|
||
}
|
||
});
|
||
const content = normalizeToolResult(result);
|
||
addDebugEvent("custom_tool:execute_success", { message: `Executed ${tool.name}`, data: { result, logs } });
|
||
return { type: "tool_result", tool_use_id: toolUse.id, content, is_error: false };
|
||
} catch (error) {
|
||
addDebugEvent("custom_tool:execute_error", { message: error.message, error: { message: error.message, stack: error.stack } });
|
||
return { type: "tool_result", tool_use_id: toolUse.id, content: error.message, is_error: true };
|
||
}
|
||
}
|
||
|
||
function compileHandler(source) {
|
||
const factory = new Function(`${source}; return handle;`);
|
||
const handle = factory();
|
||
if (typeof handle !== "function") throw new Error("Handler must define function handle(input, context).");
|
||
return handle;
|
||
}
|
||
|
||
function normalizeToolResult(result) {
|
||
if (typeof result === "string") return result;
|
||
if (result && typeof result === "object" && typeof result.text === "string") return result.text;
|
||
return JSON.stringify(result ?? null);
|
||
}
|
||
|
||
async function testCustomTool(id) {
|
||
const tool = state.context.customTools.find(item => item.id === id);
|
||
if (!tool) return;
|
||
try {
|
||
const input = JSON.parse(tool.test_input_json || "{}");
|
||
const handle = compileHandler(tool.javascript_handler);
|
||
const result = await handle(input, {
|
||
fetch: window.fetch.bind(window),
|
||
localStorage: window.localStorage,
|
||
privateGptBaseUrl: state.privateGptBaseUrl,
|
||
currentChatId: state.activeChatId,
|
||
currentCollection: state.context.documents.defaultCollection || "pgpt_collection",
|
||
log: console.log
|
||
});
|
||
tool.last_test_result = JSON.stringify(result, null, 2);
|
||
saveState();
|
||
renderCustomTools();
|
||
toast(`${tool.name} test passed.`, "ok");
|
||
} catch (error) {
|
||
tool.last_test_result = error.stack || error.message;
|
||
saveState();
|
||
renderCustomTools();
|
||
toast(`${tool.name} test failed: ${error.message}`, "error");
|
||
}
|
||
}
|
||
|
||
function openDatabaseModal(id) {
|
||
const item = state.context.databases.find(db => db.id === id) || {
|
||
id: generateUUID(),
|
||
name: "",
|
||
connection_string: "",
|
||
description: "",
|
||
schemas: "",
|
||
ssl: false,
|
||
enable_tables: true,
|
||
enable_views: true,
|
||
enable_functions: true,
|
||
enable_procedures: true
|
||
};
|
||
openModal("Database", `
|
||
<div class="form-grid">
|
||
${inputHtml("Name", "name", item.name)}
|
||
${inputHtml("Connection string", "connection_string", item.connection_string)}
|
||
${inputHtml("Description", "description", item.description)}
|
||
${inputHtml("Schemas", "schemas", item.schemas, "Optional comma-separated list")}
|
||
<div class="split-grid">
|
||
<label class="toggle-row"><span>SSL</span><input name="ssl" type="checkbox" ${item.ssl ? "checked" : ""}></label>
|
||
<label class="toggle-row"><span>Tables</span><input name="enable_tables" type="checkbox" ${item.enable_tables !== false ? "checked" : ""}></label>
|
||
<label class="toggle-row"><span>Views</span><input name="enable_views" type="checkbox" ${item.enable_views !== false ? "checked" : ""}></label>
|
||
<label class="toggle-row"><span>Functions</span><input name="enable_functions" type="checkbox" ${item.enable_functions !== false ? "checked" : ""}></label>
|
||
<label class="toggle-row"><span>Procedures</span><input name="enable_procedures" type="checkbox" ${item.enable_procedures !== false ? "checked" : ""}></label>
|
||
</div>
|
||
</div>
|
||
`, form => {
|
||
const next = { ...item, ...formValues(form, ["ssl", "enable_tables", "enable_views", "enable_functions", "enable_procedures"]) };
|
||
delete next.enabled;
|
||
upsert(state.context.databases, next);
|
||
saveState();
|
||
closeModal();
|
||
render();
|
||
});
|
||
}
|
||
|
||
function openMcpModal(id) {
|
||
const item = state.context.mcpServers.find(x => x.id === id) || {
|
||
id: generateUUID(),
|
||
name: "",
|
||
server_config_json: JSON.stringify({ name: "mcp", url: "http://localhost:3000/mcp" }, null, 2)
|
||
};
|
||
openModal("MCP server", `
|
||
<div class="form-grid">
|
||
${inputHtml("Name", "name", item.name)}
|
||
${textareaHtml("Server config JSON", "server_config_json", item.server_config_json)}
|
||
</div>
|
||
`, form => {
|
||
try { JSON.parse(form.server_config_json || "{}"); } catch (error) { toast(`Invalid JSON: ${error.message}`, "error"); return; }
|
||
const next = { ...item, ...formValues(form) };
|
||
delete next.enabled;
|
||
upsert(state.context.mcpServers, next);
|
||
saveState();
|
||
closeModal();
|
||
render();
|
||
});
|
||
}
|
||
|
||
function openSkillModal(id) {
|
||
const existing = state.context.skills.find(x => x.id === id) || null;
|
||
const collection = state.context.documents.defaultCollection || "pgpt_collection";
|
||
const defaultSkillMd = existing
|
||
? `---\nname: ${slugify(existing.display_title || "skill")}\ndescription: "Update this skill description."\n---\n\nDescribe the updated skill instructions here.\n`
|
||
: "---\nname: my-skill\ndescription: \"Describe when to use this skill.\"\n---\n\nWrite the skill instructions here.\n";
|
||
openModal(existing ? "New skill version" : "Skill", `
|
||
<div class="form-grid">
|
||
${existing ? "" : inputHtml("Display title", "display_title", "")}
|
||
${existing ? "" : `
|
||
<div class="split-grid">
|
||
<label class="toggle-row"><span>Lazy loading</span><input name="loading" type="checkbox" checked></label>
|
||
<label class="toggle-row"><span>Readonly</span><input name="readonly" type="checkbox"></label>
|
||
</div>
|
||
`}
|
||
${textareaHtml("SKILL.md", "skill_md", defaultSkillMd, "textarea-tall")}
|
||
</div>
|
||
`, async form => {
|
||
const normalized = formValues(form, ["loading", "readonly"]);
|
||
const skillMd = (normalized.skill_md || "").trim();
|
||
if (!skillMd) {
|
||
toast("SKILL.md is required.", "error");
|
||
return;
|
||
}
|
||
const payload = new FormData();
|
||
payload.append("collection", collection);
|
||
payload.append("files", new File([skillMd], "SKILL.md", { type: "text/markdown" }));
|
||
const progress = toast(existing ? "Creating skill version..." : "Creating skill...", "loading", { persistent: true });
|
||
try {
|
||
if (existing) {
|
||
await apiFormFetch(`/v1/skills/${encodeURIComponent(existing.id)}/versions`, payload, {}, { label: "Create skill version" });
|
||
} else {
|
||
const displayTitle = (normalized.display_title || "").trim();
|
||
if (!displayTitle) {
|
||
progress.remove();
|
||
toast("Display title is required.", "error");
|
||
return;
|
||
}
|
||
payload.append("display_title", displayTitle);
|
||
payload.append("loading", normalized.loading ? "lazy" : "eager");
|
||
payload.append("readonly", normalized.readonly ? "true" : "false");
|
||
await apiFormFetch("/v1/skills", payload, {}, { label: "Create skill" });
|
||
}
|
||
closeModal();
|
||
await refreshSkills({ showToast: false, throwOnError: true });
|
||
render();
|
||
progress.update(existing ? "Skill version created." : "Skill created.", "ok");
|
||
} catch (error) {
|
||
progress.update(`Could not save skill: ${error.message}`, "error");
|
||
}
|
||
});
|
||
}
|
||
|
||
function openCustomToolModal(id) {
|
||
const item = state.context.customTools.find(x => x.id === id) || {
|
||
...defaultCustomTool(),
|
||
name: "",
|
||
description: ""
|
||
};
|
||
openModal("Custom tool", `
|
||
<div class="form-grid">
|
||
${inputHtml("Name", "name", item.name)}
|
||
${inputHtml("Description", "description", item.description)}
|
||
${textareaHtml("Input schema JSON", "input_schema_json", item.input_schema_json)}
|
||
${textareaHtml("JavaScript handler", "javascript_handler", item.javascript_handler)}
|
||
${textareaHtml("Test input JSON", "test_input_json", item.test_input_json)}
|
||
${item.last_test_result ? `<div class="code-box"><pre>${escapeHtml(item.last_test_result)}</pre></div>` : ""}
|
||
</div>
|
||
`, form => {
|
||
try { JSON.parse(form.input_schema_json || "{}"); JSON.parse(form.test_input_json || "{}"); } catch (error) { toast(`Invalid JSON: ${error.message}`, "error"); return; }
|
||
const next = { ...item, ...formValues(form) };
|
||
delete next.enabled;
|
||
upsert(state.context.customTools, next);
|
||
saveState();
|
||
closeModal();
|
||
render();
|
||
});
|
||
}
|
||
|
||
function openModal(title, body, onSave) {
|
||
const card = $("#modalCard");
|
||
card.innerHTML = `
|
||
<div class="modal-head">
|
||
<div><h2>${escapeHtml(title)}</h2><p class="subtle">Saved locally in this browser.</p></div>
|
||
<button class="icon-button" type="button" data-close-modal aria-label="Close">×</button>
|
||
</div>
|
||
<form id="modalForm">
|
||
${body}
|
||
<div class="panel-actions" style="justify-content:flex-end;margin-top:16px;">
|
||
<button class="ghost-button" type="button" data-close-modal>Cancel</button>
|
||
<button class="primary-button" type="submit">Save</button>
|
||
</div>
|
||
</form>
|
||
`;
|
||
$("#modalBackdrop").classList.add("open");
|
||
$$("[data-close-modal]", card).forEach(button => button.addEventListener("click", closeModal));
|
||
$("#modalForm").addEventListener("submit", event => {
|
||
event.preventDefault();
|
||
const data = Object.fromEntries(new FormData(event.currentTarget).entries());
|
||
onSave(data);
|
||
});
|
||
}
|
||
|
||
function openConfirmModal(title, message, onConfirm) {
|
||
const card = $("#modalCard");
|
||
card.innerHTML = `
|
||
<div class="modal-head">
|
||
<div><h2>${escapeHtml(title)}</h2></div>
|
||
<button class="icon-button" type="button" data-close-modal aria-label="Close">×</button>
|
||
</div>
|
||
<p class="subtle" style="margin:0 0 20px;">${message}</p>
|
||
<div class="panel-actions" style="justify-content:flex-end;">
|
||
<button class="ghost-button" type="button" data-close-modal>Cancel</button>
|
||
<button class="primary-button danger-button" type="button" id="confirmModalBtn">Delete</button>
|
||
</div>
|
||
`;
|
||
$("#modalBackdrop").classList.add("open");
|
||
$$("[data-close-modal]", card).forEach(button => button.addEventListener("click", closeModal));
|
||
$("#confirmModalBtn").addEventListener("click", onConfirm);
|
||
}
|
||
|
||
function openCitationModal(citationId) {
|
||
const detail = runtime.citations.get(citationId);
|
||
if (!detail) return;
|
||
const rows = [
|
||
["Document", detail.title],
|
||
["Citation id", detail.attrs?.id],
|
||
["Source id", detail.attrs?.source_id],
|
||
["Artifact", detail.attrs?.artifact_id],
|
||
["Index", detail.attrs?.index],
|
||
["Score", detail.source?.score != null ? String(detail.source.score) : ""],
|
||
["Content type", detail.source?.content_type]
|
||
].filter(([, value]) => value != null && value !== "");
|
||
const card = $("#modalCard");
|
||
card.innerHTML = `
|
||
<div class="modal-head">
|
||
<div>
|
||
<h2>Citation ${escapeHtml(detail.label)}</h2>
|
||
<p class="subtle">${escapeHtml(detail.title || "Source")}</p>
|
||
</div>
|
||
<button class="icon-button" type="button" data-close-modal aria-label="Close">×</button>
|
||
</div>
|
||
<div class="citation-meta">
|
||
${rows.map(([label, value]) => `
|
||
<div class="citation-meta-row">
|
||
<strong>${escapeHtml(label)}</strong>
|
||
<span>${escapeHtml(value)}</span>
|
||
</div>
|
||
`).join("")}
|
||
</div>
|
||
<div class="citation-excerpt">${escapeHtml(detail.excerpt || "No excerpt was provided for this citation.")}</div>
|
||
<details class="tool-call" style="margin-top:12px;">
|
||
<summary>Raw citation metadata</summary>
|
||
<pre>${escapeHtml(JSON.stringify(detail.raw, null, 2))}</pre>
|
||
</details>
|
||
`;
|
||
$("#modalBackdrop").classList.add("open");
|
||
$$("[data-close-modal]", card).forEach(button => button.addEventListener("click", closeModal));
|
||
}
|
||
|
||
function openProductionNoticeModal() {
|
||
const card = $("#modalCard");
|
||
card.innerHTML = `
|
||
<div class="modal-head">
|
||
<div>
|
||
<h2>This demonstrator is not intended for Production use</h2>
|
||
<p class="subtle">This workbench is a local demonstrator for PrivateGPT.</p>
|
||
</div>
|
||
<button class="icon-button" type="button" data-close-modal aria-label="Close">×</button>
|
||
</div>
|
||
<p class="subtle">
|
||
It is useful for trying API capabilities, debugging requests, and exploring local AI workflows, but it should not be published as a production application.
|
||
</p>
|
||
<ul class="risk-list">
|
||
<li><strong>Browser storage is not secure secret storage.</strong> The PrivateGPT bearer token, chats, context configuration, and settings are saved in localStorage for convenience.</li>
|
||
<li><strong>There is no application-level access control.</strong> Anyone with access to the page can use the configured API endpoint, token, tools, documents, and model access.</li>
|
||
<li><strong>Debugging data is intentionally visible.</strong> The API Debugger can show prompts, document excerpts, headers, metadata, requests, and responses during demos or screen shares.</li>
|
||
<li><strong>Custom tools run browser JavaScript.</strong> Only use trusted code, and do not expose this UI without a reviewed deployment model.</li>
|
||
</ul>
|
||
<p class="subtle" style="margin-top:14px;">
|
||
If you are looking for an enterprise-ready private AI platform, visit <a href="https://zylon.ai" target="_blank" rel="noreferrer">Zylon</a> or <a href="https://cal.com/zylon/demo?source=privategptui" target="_blank" rel="noreferrer">book a demo with our team</a>. Zylon is an enterprise AI platform delivering on-premise generative AI infrastructure for regulated industries, enabling secure deployment without external cloud dependencies.
|
||
</p>
|
||
`;
|
||
$("#modalBackdrop").classList.add("open");
|
||
$$("[data-close-modal]", card).forEach(button => button.addEventListener("click", closeModal));
|
||
}
|
||
|
||
function closeModal() {
|
||
$("#modalBackdrop").classList.remove("open");
|
||
$("#modalCard").innerHTML = "";
|
||
}
|
||
|
||
function inputHtml(label, name, value = "", help = "") {
|
||
return `<div class="field"><label>${escapeHtml(label)}</label><input name="${escapeAttr(name)}" value="${escapeAttr(value || "")}">${help ? `<small>${escapeHtml(help)}</small>` : ""}</div>`;
|
||
}
|
||
|
||
function slugify(value) {
|
||
return String(value || "skill")
|
||
.trim()
|
||
.toLowerCase()
|
||
.replace(/[^a-z0-9]+/g, "-")
|
||
.replace(/^-+|-+$/g, "") || "skill";
|
||
}
|
||
|
||
function textareaHtml(label, name, value = "", className = "") {
|
||
const classAttr = className ? ` class="${escapeAttr(className)}"` : "";
|
||
return `<div class="field"><label>${escapeHtml(label)}</label><textarea name="${escapeAttr(name)}"${classAttr} spellcheck="false">${escapeHtml(value || "")}</textarea></div>`;
|
||
}
|
||
|
||
function formValues(formData, checkboxNames = []) {
|
||
const data = { ...formData };
|
||
checkboxNames.forEach(name => data[name] = data[name] === "on");
|
||
return data;
|
||
}
|
||
|
||
function updateMessagesFade() {
|
||
const el = $("#messages");
|
||
if (!el) return;
|
||
const st = el.scrollTop;
|
||
const max = el.scrollHeight - el.clientHeight;
|
||
el.style.setProperty("--fade-top-stop", st > 6 ? "28px" : "0px");
|
||
el.style.setProperty("--fade-bot-stop", max - st > 6 ? "28px" : "0px");
|
||
}
|
||
|
||
function updateChatListFade() {
|
||
const wrap = $("#chatListWrap");
|
||
if (!wrap) return;
|
||
const st = wrap.scrollTop;
|
||
const max = wrap.scrollHeight - wrap.clientHeight;
|
||
wrap.style.setProperty("--fade-top-stop", st > 6 ? "22px" : "0px");
|
||
wrap.style.setProperty("--fade-bot-stop", max - st > 6 ? "22px" : "0px");
|
||
}
|
||
|
||
function upsert(items, item) {
|
||
const index = items.findIndex(existing => existing.id === item.id);
|
||
if (index >= 0) items[index] = item;
|
||
else items.push(item);
|
||
}
|
||
|
||
function readSettingsConnectionForm() {
|
||
return {
|
||
baseUrl: $("#privateGptBaseUrl").value.trim() || DEFAULT_BASE_URL,
|
||
username: $("#privateGptUsername").value.trim(),
|
||
password: $("#privateGptPassword").value,
|
||
systemPrompt: $("#systemPrompt").value.trim(),
|
||
useCitations: $("#useCitations").checked,
|
||
useDefaultPrompt: $("#useDefaultPrompt").checked,
|
||
enableThinking: $("#enableThinking").checked,
|
||
enableCodeExecution: $("#enableCodeExecution").checked,
|
||
extensions: $("#extensions").value.split(",").map(s => s.trim()).filter(Boolean),
|
||
collection: $("#defaultCollection").value.trim() || "pgpt_collection"
|
||
};
|
||
}
|
||
|
||
function readOnboardingConnectionForm() {
|
||
return {
|
||
baseUrl: $("#onboardingBaseUrl").value.trim() || DEFAULT_BASE_URL,
|
||
username: $("#onboardingUsername").value.trim(),
|
||
password: $("#onboardingPassword").value,
|
||
collection: $("#onboardingCollection").value.trim() || "pgpt_collection"
|
||
};
|
||
}
|
||
|
||
function validateConnectionFields({ username, password }, { userSelector, passwordSelector } = {}) {
|
||
if ((username && !password) || (!username && password)) {
|
||
toast("Enter both username and password for HTTP Basic auth.", "error");
|
||
(!username ? $(userSelector) : $(passwordSelector))?.focus();
|
||
return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
function applyConnectionSettings(values) {
|
||
state.privateGptBaseUrl = values.baseUrl || DEFAULT_BASE_URL;
|
||
state.privateGptUsername = values.username || "";
|
||
state.privateGptPassword = values.password || "";
|
||
if (typeof values.systemPrompt === "string") state.systemPrompt = values.systemPrompt;
|
||
if (typeof values.useCitations === "boolean") state.useCitations = values.useCitations;
|
||
if (typeof values.useDefaultPrompt === "boolean") state.useDefaultPrompt = values.useDefaultPrompt;
|
||
if (typeof values.enableThinking === "boolean") state.enableThinking = values.enableThinking;
|
||
if (typeof values.enableCodeExecution === "boolean") state.enableCodeExecution = values.enableCodeExecution;
|
||
if (Array.isArray(values.extensions)) state.extensions = values.extensions;
|
||
if (typeof values.collection === "string") state.context.documents.defaultCollection = values.collection || "pgpt_collection";
|
||
}
|
||
|
||
function saveAppearanceSettingsFrom(prefix) {
|
||
applyAppearanceState(readAppearanceForm(prefix));
|
||
}
|
||
|
||
async function runOnboardingCheck() {
|
||
const values = readOnboardingConnectionForm();
|
||
if (!validateConnectionFields(values, { userSelector: "#onboardingUsername", passwordSelector: "#onboardingPassword" })) {
|
||
setOnboardingCheckError("Enter both username and password for HTTP Basic auth.");
|
||
return;
|
||
}
|
||
applyConnectionSettings(values);
|
||
state.onboarding.step = 1;
|
||
state.onboarding.lastCheck = null;
|
||
runtime.onboardingCheckRunning = true;
|
||
saveState();
|
||
render();
|
||
const check = {
|
||
ok: false,
|
||
summary: "",
|
||
testedAt: new Date().toISOString(),
|
||
steps: []
|
||
};
|
||
try {
|
||
const modelsResult = await apiFetch("/v1/models", {}, { label: "Onboarding: load models" });
|
||
state.models = Array.isArray(modelsResult?.data) ? modelsResult.data : [];
|
||
if (!state.models.some(model => model.id === state.selectedModel)) {
|
||
state.selectedModel = state.models[0]?.id ?? null;
|
||
}
|
||
check.steps.push({
|
||
label: "Models",
|
||
detail: `${state.models.length} model${state.models.length === 1 ? "" : "s"} loaded from ${state.privateGptBaseUrl}.`,
|
||
ok: true
|
||
});
|
||
|
||
const collection = encodeURIComponent(state.context.documents.defaultCollection || "pgpt_collection");
|
||
const docsResult = await apiFetch(`/v1/artifacts/list?collection=${collection}`, {}, { label: "Onboarding: list artifacts" });
|
||
runtime.docs = Array.isArray(docsResult?.data) ? docsResult.data : [];
|
||
state.context.documents.knownArtifacts = runtime.docs;
|
||
check.steps.push({
|
||
label: "Artifacts",
|
||
detail: `${runtime.docs.length} artifact${runtime.docs.length === 1 ? "" : "s"} visible in collection ${state.context.documents.defaultCollection || "pgpt_collection"}.`,
|
||
ok: true
|
||
});
|
||
|
||
const skillsResult = await apiFetch(`/v1/skills?collection=${collection}`, {}, { label: "Onboarding: list skills" });
|
||
state.context.skills = Array.isArray(skillsResult?.data) ? skillsResult.data : [];
|
||
check.steps.push({
|
||
label: "Skills",
|
||
detail: `${state.context.skills.length} skill${state.context.skills.length === 1 ? "" : "s"} visible for the active collection.`,
|
||
ok: true
|
||
});
|
||
check.ok = true;
|
||
check.summary = "Connection and collection checks passed.";
|
||
state.onboarding.lastCheck = check;
|
||
state.onboarding.step = 2;
|
||
saveState();
|
||
render();
|
||
toast("PrivateGPT connection verified.", "ok");
|
||
} catch (error) {
|
||
const failedLabel = ["Models", "Artifacts", "Skills"][check.steps.length] || "Connection";
|
||
check.steps.push({
|
||
label: failedLabel,
|
||
detail: explainApiError(error),
|
||
ok: false
|
||
});
|
||
check.summary = `Could not verify ${failedLabel.toLowerCase()}: ${explainApiError(error)}`;
|
||
state.onboarding.lastCheck = check;
|
||
saveState();
|
||
render();
|
||
toast(onboardingErrorToastMessage(check, check.summary), "error");
|
||
} finally {
|
||
runtime.onboardingCheckRunning = false;
|
||
renderOnboarding();
|
||
}
|
||
}
|
||
|
||
async function generateAppearanceFromBrief(prefix) {
|
||
const form = readAppearanceForm(prefix);
|
||
const brief = form.brief.trim();
|
||
if (!brief) {
|
||
setAppearanceGeneratorStatus(prefix, "Add a short description first.");
|
||
$(`#${appearanceFormIds(prefix).brief}`)?.focus();
|
||
return;
|
||
}
|
||
if (runtime.appearanceGeneration.activePrefix) return;
|
||
runtime.appearanceGeneration.activePrefix = prefix;
|
||
setAppearanceGeneratorStatus(prefix, "Generating a theme from your brief...");
|
||
renderOnboarding();
|
||
try {
|
||
const response = await apiFetch("/v1/messages", {
|
||
method: "POST",
|
||
body: {
|
||
model: state.selectedModel,
|
||
messages: [
|
||
{
|
||
role: "user",
|
||
content: [{ type: "text", text: appearanceGeneratorPrompt(brief) }]
|
||
}
|
||
],
|
||
tools: [],
|
||
tool_context: [],
|
||
mcp_servers: [],
|
||
stream: false,
|
||
max_tokens: 1200,
|
||
thinking: { enabled: false },
|
||
system: {
|
||
use_default_prompt: false,
|
||
text: "You are a product theming assistant. Return strict JSON only, with no markdown."
|
||
}
|
||
}
|
||
}, { label: `Generate appearance (${prefix})` });
|
||
const parsed = extractJsonObject(responseText(response));
|
||
const suggestion = normalizeAppearanceSuggestion(parsed, brief);
|
||
applyAppearanceToForm(prefix, suggestion);
|
||
if (prefix === "onboarding") updateOnboardingPreview();
|
||
if (prefix === "appearance") {
|
||
applyAppearanceState(suggestion);
|
||
render();
|
||
}
|
||
setAppearanceGeneratorStatus(prefix, "Theme generated. Review the result and adjust if needed.");
|
||
} catch (error) {
|
||
setAppearanceGeneratorStatus(prefix, `Could not generate theme: ${apiErrorMessage(error)}`);
|
||
toast(`Theme generation failed: ${apiErrorMessage(error)}`, "error");
|
||
} finally {
|
||
runtime.appearanceGeneration.activePrefix = null;
|
||
setAppearanceGeneratorStatus(prefix, runtime.appearanceGeneration.statusByPrefix[prefix] || "");
|
||
}
|
||
}
|
||
|
||
function finishOnboarding() {
|
||
saveAppearanceSettingsFrom("onboarding");
|
||
state.onboarding.completed = true;
|
||
state.onboarding.appearanceSkipped = false;
|
||
state.onboarding.step = 2;
|
||
saveState();
|
||
render();
|
||
toast("Workbench setup saved.", "ok");
|
||
}
|
||
|
||
function restartOnboarding() {
|
||
state.onboarding.completed = false;
|
||
state.onboarding.step = 1;
|
||
saveState();
|
||
render();
|
||
}
|
||
|
||
function bindEvents() {
|
||
$("#contextNav").addEventListener("click", () => { runtime.view = "context"; render(); });
|
||
$("#apiDebuggerNav").addEventListener("click", () => { runtime.view = "apiDebugger"; render(); });
|
||
$("#settingsNav").addEventListener("click", () => { runtime.view = "settings"; render(); });
|
||
$("#productionNoticeButton").addEventListener("click", openProductionNoticeModal);
|
||
$("#newChatButton").addEventListener("click", createChat);
|
||
$("#saveConnectionButton").addEventListener("click", () => { void saveConnectionSettings(); });
|
||
$("#testConnectionButton").addEventListener("click", async () => {
|
||
if (!await saveConnectionSettings()) return;
|
||
await loadModels();
|
||
});
|
||
$("#restartOnboardingButton").addEventListener("click", restartOnboarding);
|
||
$("#clearLocalDataButton").addEventListener("click", clearLocalData);
|
||
$("#refreshSkillsButton").addEventListener("click", () => refreshSkills());
|
||
$("#togglePasswordButton").addEventListener("click", () => {
|
||
const input = $("#privateGptPassword");
|
||
input.type = input.type === "password" ? "text" : "password";
|
||
});
|
||
$("#toggleOnboardingPasswordButton").addEventListener("click", () => {
|
||
const input = $("#onboardingPassword");
|
||
input.type = input.type === "password" ? "text" : "password";
|
||
});
|
||
$("#defaultCollection").addEventListener("change", async event => {
|
||
await switchActiveCollection(event.target.value, { showToast: false });
|
||
});
|
||
$("#onboardingCheckButton").addEventListener("click", runOnboardingCheck);
|
||
$("#onboardingStepPill1").addEventListener("click", () => {
|
||
state.onboarding.step = 1;
|
||
saveState();
|
||
render();
|
||
});
|
||
$("#onboardingStepPill2").addEventListener("click", () => {
|
||
if (!state.onboarding?.lastCheck?.ok) return;
|
||
state.onboarding.step = 2;
|
||
saveState();
|
||
render();
|
||
});
|
||
$("#onboardingGenerateButton").addEventListener("click", () => generateAppearanceFromBrief("onboarding"));
|
||
$("#appearanceGenerateButton").addEventListener("click", () => generateAppearanceFromBrief("appearance"));
|
||
$("#onboardingFinishButton").addEventListener("click", finishOnboarding);
|
||
[
|
||
...Object.values(appearanceFormIds("appearance")).flatMap(value => typeof value === "string" ? [value] : Object.values(value)),
|
||
...Object.values(appearanceFormIds("onboarding")).flatMap(value => typeof value === "string" ? [value] : Object.values(value))
|
||
].forEach(id => {
|
||
const node = $(`#${id}`);
|
||
if (!node) return;
|
||
node.addEventListener("input", () => {
|
||
if (id.startsWith("onboarding")) updateOnboardingPreview();
|
||
if (id === appearanceFormIds("onboarding").brief) renderOnboarding();
|
||
});
|
||
node.addEventListener("change", () => {
|
||
if (id.startsWith("onboarding")) updateOnboardingPreview();
|
||
if (id === appearanceFormIds("onboarding").brief) renderOnboarding();
|
||
});
|
||
});
|
||
$("#refreshDocsButton").addEventListener("click", refreshDocuments);
|
||
$("#uploadDocsButton").addEventListener("click", () => $("#fileUploadInput").click());
|
||
$("#fileUploadInput").addEventListener("change", event => {
|
||
uploadFiles(Array.from(event.target.files || []));
|
||
event.target.value = "";
|
||
});
|
||
$("#refreshSessionFilesButton").addEventListener("click", () => refreshSessionFiles());
|
||
$("#uploadSessionContextButton").addEventListener("click", () => $("#sessionContextFileInput").click());
|
||
$("#sessionContextFileInput").addEventListener("change", async event => {
|
||
await uploadToSession(Array.from(event.target.files || []));
|
||
event.target.value = "";
|
||
refreshSessionFiles({ showToast: false });
|
||
});
|
||
$$("[data-open-modal]").forEach(button => {
|
||
button.addEventListener("click", () => {
|
||
const type = button.dataset.openModal;
|
||
if (type === "database") openDatabaseModal();
|
||
if (type === "mcp") openMcpModal();
|
||
if (type === "skill") openSkillModal();
|
||
if (type === "customTool") openCustomToolModal();
|
||
});
|
||
});
|
||
$("#composer").addEventListener("submit", event => {
|
||
event.preventDefault();
|
||
sendChat($("#composerInput").value);
|
||
});
|
||
$("#composerInput").addEventListener("keydown", event => {
|
||
if (event.key === "Enter" && !event.shiftKey) {
|
||
event.preventDefault();
|
||
if (state.selectedModel) sendChat(event.currentTarget.value);
|
||
}
|
||
});
|
||
$("#composerInput").addEventListener("input", event => {
|
||
event.target.style.height = "auto";
|
||
event.target.style.height = `${Math.min(event.target.scrollHeight, 180)}px`;
|
||
});
|
||
$("#modelSelectBtn").addEventListener("click", event => {
|
||
event.stopPropagation();
|
||
const dropdown = $("#modelDropdown");
|
||
const isOpen = dropdown.classList.contains("open");
|
||
$("#toolsMenu").classList.remove("open");
|
||
if (!isOpen) {
|
||
const rect = event.currentTarget.getBoundingClientRect();
|
||
dropdown.style.position = "fixed";
|
||
dropdown.style.left = rect.left + "px";
|
||
dropdown.style.bottom = (window.innerHeight - rect.top + 8) + "px";
|
||
dropdown.style.top = "auto";
|
||
document.body.appendChild(dropdown);
|
||
}
|
||
dropdown.classList.toggle("open", !isOpen);
|
||
$("#modelSelectWrap").classList.toggle("open", !isOpen);
|
||
});
|
||
$("#refreshModelsButton").addEventListener("click", loadModels);
|
||
$("#toolsMenuButton").addEventListener("click", event => {
|
||
event.stopPropagation();
|
||
closeModelDropdown();
|
||
const menu = $("#toolsMenu");
|
||
const isOpen = menu.classList.contains("open");
|
||
if (!isOpen) {
|
||
const rect = event.currentTarget.getBoundingClientRect();
|
||
menu.style.position = "fixed";
|
||
menu.style.left = rect.left + "px";
|
||
menu.style.bottom = (window.innerHeight - rect.top + 8) + "px";
|
||
menu.style.top = "auto";
|
||
document.body.appendChild(menu);
|
||
}
|
||
menu.classList.toggle("open", !isOpen);
|
||
});
|
||
$("#thinkingToggle").addEventListener("click", () => {
|
||
const chat = activeChat();
|
||
if (!chat) return;
|
||
chat.settings.enabledThinking = !chat.settings.enabledThinking;
|
||
touchChat(chat);
|
||
saveState();
|
||
renderThinkingChip();
|
||
});
|
||
document.addEventListener("click", event => {
|
||
const citationButton = event.target.closest("[data-citation-ref]");
|
||
if (citationButton) {
|
||
event.preventDefault();
|
||
openCitationModal(citationButton.dataset.citationRef);
|
||
return;
|
||
}
|
||
const msgActionBtn = event.target.closest("[data-action='retry-message'],[data-action='delete-message']");
|
||
if (msgActionBtn) {
|
||
const { action, chatId, messageId } = msgActionBtn.dataset;
|
||
if (action === "retry-message") retryFromMessage(chatId, messageId);
|
||
else if (action === "delete-message") deleteMessage(chatId, messageId);
|
||
return;
|
||
}
|
||
if (!event.target.closest(".tool-menu-wrap") && !event.target.closest(".menu-panel")) $("#toolsMenu").classList.remove("open");
|
||
if (!event.target.closest(".model-select-wrap") && !event.target.closest(".model-dropdown")) closeModelDropdown();
|
||
});
|
||
$("#clearDebuggerButton").addEventListener("click", () => {
|
||
runtime.debugEvents = [];
|
||
runtime.selectedDebugId = null;
|
||
renderDebugger();
|
||
});
|
||
$("#modalBackdrop").addEventListener("click", event => {
|
||
if (event.target === $("#modalBackdrop")) closeModal();
|
||
});
|
||
document.addEventListener("keydown", event => {
|
||
if (event.key !== "Escape") return;
|
||
if ($("#modalBackdrop").classList.contains("open")) { closeModal(); return; }
|
||
if ($("#modelDropdown")?.classList.contains("open")) { closeModelDropdown(); return; }
|
||
if ($("#toolsMenu").classList.contains("open")) { $("#toolsMenu").classList.remove("open"); return; }
|
||
});
|
||
|
||
// Browser back/forward restores the view
|
||
window.addEventListener("hashchange", () => { restoreFromHash(); render(); });
|
||
|
||
// Scroll fades for the chat list
|
||
$("#chatListWrap").addEventListener("scroll", updateChatListFade, { passive: true });
|
||
updateChatListFade();
|
||
|
||
// Scroll fades for the messages list
|
||
$("#messages").addEventListener("scroll", updateMessagesFade, { passive: true });
|
||
updateMessagesFade();
|
||
}
|
||
|
||
async function saveConnectionSettings() {
|
||
const values = readSettingsConnectionForm();
|
||
if (!validateConnectionFields(values, { userSelector: "#privateGptUsername", passwordSelector: "#privateGptPassword" })) return false;
|
||
applyConnectionSettings(values);
|
||
saveAppearanceSettingsFrom("appearance");
|
||
saveState();
|
||
render();
|
||
await switchActiveCollection(values.collection, { showToast: false, forceRefresh: true });
|
||
toast("Settings saved.", "ok");
|
||
return true;
|
||
}
|
||
|
||
function clearLocalData() {
|
||
openConfirmModal(
|
||
"Clear local workbench data?",
|
||
"This removes this workbench's saved chats, settings, token, context, and preferences from this browser only.",
|
||
() => {
|
||
localStorage.removeItem(STORAGE_KEY);
|
||
state = defaultState();
|
||
runtime.view = "context";
|
||
runtime.contextTab = "documents";
|
||
state.models = [];
|
||
runtime.docs = [];
|
||
runtime.debugEvents = [];
|
||
runtime.citations = new Map();
|
||
runtime.selectedDebugId = null;
|
||
runtime.onboardingCheckRunning = false;
|
||
runtime.sendingChatIds.clear();
|
||
closeModal();
|
||
render();
|
||
toast("Local workbench data cleared.", "ok");
|
||
}
|
||
);
|
||
}
|
||
|
||
async function loadGitHubStars() {
|
||
try {
|
||
const response = await fetch("https://api.github.com/repos/zylon-ai/private-gpt", {
|
||
headers: { Accept: "application/vnd.github+json" }
|
||
});
|
||
if (!response.ok) throw new Error(`${response.status} ${response.statusText}`);
|
||
const data = await response.json();
|
||
runtime.githubStars = data.stargazers_count;
|
||
renderGitHubStars();
|
||
} catch {
|
||
renderGitHubStars();
|
||
}
|
||
}
|
||
|
||
function touchChat(chat) {
|
||
chat.updatedAt = new Date().toISOString();
|
||
}
|
||
|
||
function selectedItems(items, ids) {
|
||
const selected = new Set(ids || []);
|
||
return items.filter(item => selected.has(item.id));
|
||
}
|
||
|
||
function responseText(response) {
|
||
if (!response) return "";
|
||
if (typeof response === "string") return response;
|
||
if (Array.isArray(response.content)) {
|
||
return response.content.map(block => block?.text || "").filter(Boolean).join("\\n");
|
||
}
|
||
return JSON.stringify(response, null, 2);
|
||
}
|
||
|
||
function fileToBase64(file) {
|
||
return new Promise((resolve, reject) => {
|
||
const reader = new FileReader();
|
||
reader.onload = () => resolve(String(reader.result).split(",")[1] || "");
|
||
reader.onerror = () => reject(reader.error);
|
||
reader.readAsDataURL(file);
|
||
});
|
||
}
|
||
|
||
function sanitizeId(value) {
|
||
return value.toLowerCase().replace(/[^a-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 120) || "artifact";
|
||
}
|
||
|
||
function titleFromText(text) {
|
||
return text.replace(/\s+/g, " ").trim().slice(0, 44) || "New chat";
|
||
}
|
||
|
||
function parseCsv(value) {
|
||
const items = String(value || "").split(",").map(item => item.trim()).filter(Boolean);
|
||
return items.length ? items : null;
|
||
}
|
||
|
||
function compactNumber(value) {
|
||
const number = Number(value);
|
||
if (!Number.isFinite(number)) return "Stars";
|
||
return new Intl.NumberFormat(undefined, {
|
||
notation: number >= 1000 ? "compact" : "standard",
|
||
maximumFractionDigits: 1
|
||
}).format(number);
|
||
}
|
||
|
||
function compactConnection(value = "") {
|
||
try {
|
||
const url = new URL(value);
|
||
return `${url.protocol}//${url.hostname}${url.port ? ":" + url.port : ""}`;
|
||
} catch {
|
||
return value ? value.slice(0, 80) : "Database";
|
||
}
|
||
}
|
||
|
||
function configSummary(json) {
|
||
try {
|
||
const parsed = JSON.parse(json || "{}");
|
||
return parsed.url || parsed.name || "Configured";
|
||
} catch {
|
||
return "Invalid JSON";
|
||
}
|
||
}
|
||
|
||
function safeJson(text) {
|
||
try { return JSON.parse(text); } catch { return text; }
|
||
}
|
||
|
||
function redactHeaders(headers) {
|
||
return Object.fromEntries(Object.entries(headers || {}).map(([key, value]) => {
|
||
if (/authorization|token|api[-_]?key|cookie/i.test(key)) return [key, "<redacted>"];
|
||
return [key, value];
|
||
}));
|
||
}
|
||
|
||
async function copyText(text, label = "Copied") {
|
||
try {
|
||
await navigator.clipboard.writeText(text);
|
||
toast(label, "ok");
|
||
} catch {
|
||
toast("Copy failed.", "error");
|
||
}
|
||
}
|
||
|
||
function escapeHtml(value) {
|
||
return String(value ?? "").replace(/[&<>"']/g, char => ({
|
||
"&": "&",
|
||
"<": "<",
|
||
">": ">",
|
||
'"': """,
|
||
"'": "'"
|
||
})[char]);
|
||
}
|
||
|
||
function escapeAttr(value) {
|
||
return escapeHtml(value).replace(/`/g, "`");
|
||
}
|
||
|
||
bindEvents();
|
||
restoreFromHash();
|
||
if (!state.chats.length) createChat();
|
||
else render();
|
||
refreshSkills({ showToast: false });
|
||
loadGitHubStars();
|
||
</script>
|
||
</body>
|
||
</html>
|