Files
privateGPT/ui/index.html
Javier Martinez 21d42fd97a feat: code execution v4 (#2295)
* 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 commit a2110f94a8.

* 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 commit f8ee460af2)

* fix: worker config

* test: remove flaky chat cancellation assertion

(cherry picked from commit 1115ff2349)

# 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>
2026-07-15 13:05:23 +02:00

7575 lines
276 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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 => ({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#039;"
})[char]);
}
function escapeAttr(value) {
return escapeHtml(value).replace(/`/g, "&#096;");
}
bindEvents();
restoreFromHash();
if (!state.chats.length) createChat();
else render();
refreshSkills({ showToast: false });
loadGitHubStars();
</script>
</body>
</html>