Initial commit
This commit is contained in:
BIN
server/web/.DS_Store
vendored
Normal file
BIN
server/web/.DS_Store
vendored
Normal file
Binary file not shown.
1
server/web/__init__.py
Normal file
1
server/web/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# aide web package
|
||||
BIN
server/web/__pycache__/__init__.cpython-314.pyc
Normal file
BIN
server/web/__pycache__/__init__.cpython-314.pyc
Normal file
Binary file not shown.
BIN
server/web/__pycache__/routes.cpython-314.pyc
Normal file
BIN
server/web/__pycache__/routes.cpython-314.pyc
Normal file
Binary file not shown.
BIN
server/web/__pycache__/themes.cpython-314.pyc
Normal file
BIN
server/web/__pycache__/themes.cpython-314.pyc
Normal file
Binary file not shown.
2744
server/web/routes.py
Normal file
2744
server/web/routes.py
Normal file
File diff suppressed because it is too large
Load Diff
BIN
server/web/static/.DS_Store
vendored
Normal file
BIN
server/web/static/.DS_Store
vendored
Normal file
Binary file not shown.
4997
server/web/static/app.js
Normal file
4997
server/web/static/app.js
Normal file
File diff suppressed because it is too large
Load Diff
BIN
server/web/static/icon.png
Normal file
BIN
server/web/static/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
BIN
server/web/static/logo.png
Normal file
BIN
server/web/static/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.5 MiB |
BIN
server/web/static/logo_oai.png
Normal file
BIN
server/web/static/logo_oai.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 MiB |
611
server/web/static/style.css
Normal file
611
server/web/static/style.css
Normal file
@@ -0,0 +1,611 @@
|
||||
:root {
|
||||
--bg: #0f1117;
|
||||
--bg2: #1a1d27;
|
||||
--bg3: #22263a;
|
||||
--border: #2e3249;
|
||||
--text: #e2e4ef;
|
||||
--text-dim: #7b82a8;
|
||||
--accent: #6c8ef5;
|
||||
--accent-dim: #3d5099;
|
||||
--green: #4caf7d;
|
||||
--red: #e05252;
|
||||
--yellow: #e0a632;
|
||||
--radius: 8px;
|
||||
--font: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
--mono: "SF Mono", "Fira Code", monospace;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
html, body { height: 100%; overflow: hidden; }
|
||||
|
||||
body {
|
||||
font-family: var(--font);
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
display: flex;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* ── Sidebar ── */
|
||||
.sidebar {
|
||||
width: 200px;
|
||||
min-width: 200px;
|
||||
background: var(--bg2);
|
||||
border-right: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 16px 0 4px;
|
||||
}
|
||||
|
||||
.sidebar-logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 0 16px 20px;
|
||||
}
|
||||
|
||||
.sidebar-logo-img {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
object-fit: contain;
|
||||
border-radius: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-logo-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.sidebar-logo-name {
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.sidebar-logo-version {
|
||||
color: var(--text-dim);
|
||||
font-weight: 400;
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.8px;
|
||||
text-transform: uppercase;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.sidebar-logo-app {
|
||||
font-size: 10px;
|
||||
color: var(--text-dim);
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.8px;
|
||||
text-transform: uppercase;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 20px;
|
||||
color: var(--text-dim);
|
||||
text-decoration: none;
|
||||
transition: all 0.15s;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
background: none;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.nav-item:hover { background: var(--bg3); color: var(--text); }
|
||||
.nav-item.active { background: var(--bg3); color: var(--accent); }
|
||||
.nav-item svg { width: 16px; height: 16px; flex-shrink: 0; }
|
||||
|
||||
.sidebar-bottom { margin-top: auto; padding: 4px 0 0; border-top: 1px solid var(--border); }
|
||||
|
||||
#pause-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 8px 12px;
|
||||
padding: 6px 14px;
|
||||
width: calc(100% - 24px);
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid rgba(224, 166, 50, 0.35);
|
||||
background: rgba(224, 166, 50, 0.08);
|
||||
color: var(--yellow);
|
||||
font-size: 13px;
|
||||
font-family: var(--font);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
text-align: left;
|
||||
}
|
||||
#pause-btn:hover { background: rgba(224, 166, 50, 0.18); border-color: rgba(224, 166, 50, 0.55); }
|
||||
#pause-btn.paused {
|
||||
border-color: rgba(76, 175, 125, 0.35);
|
||||
background: rgba(76, 175, 125, 0.08);
|
||||
color: var(--green);
|
||||
}
|
||||
#pause-btn.paused:hover { background: rgba(76, 175, 125, 0.18); border-color: rgba(76, 175, 125, 0.55); }
|
||||
|
||||
.sidebar-user {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 6px 12px; margin-top: 2px; font-size: 11px; color: var(--muted);
|
||||
}
|
||||
.sidebar-user-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 120px; }
|
||||
.sidebar-user-logout { color: var(--muted); display: flex; align-items: center; flex-shrink: 0; opacity: 0.7; transition: opacity 0.15s; }
|
||||
.sidebar-user-logout:hover { opacity: 1; color: var(--accent); }
|
||||
|
||||
/* ── Personality setup nag banner ── */
|
||||
.nag-banner {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 8px 20px; background: #7c5a00; color: #fde68a;
|
||||
font-size: 13px; gap: 12px; flex-shrink: 0;
|
||||
}
|
||||
.nag-link { color: #fde68a; font-weight: 600; text-decoration: underline; }
|
||||
.nag-link:hover { color: #fff; }
|
||||
.nag-dismiss {
|
||||
background: none; border: none; color: #fde68a; font-size: 18px;
|
||||
cursor: pointer; line-height: 1; padding: 0 4px; opacity: 0.8;
|
||||
}
|
||||
.nag-dismiss:hover { opacity: 1; }
|
||||
|
||||
/* ── Main content ── */
|
||||
.main { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
|
||||
|
||||
/* ── Chat ── */
|
||||
.chat-container { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
|
||||
|
||||
.chat-messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.message { display: flex; flex-direction: column; max-width: 760px; }
|
||||
.message.user { align-self: flex-end; align-items: flex-end; }
|
||||
.message.assistant { align-self: flex-start; align-items: flex-start; }
|
||||
|
||||
.message-bubble {
|
||||
padding: 12px 16px;
|
||||
border-radius: var(--radius);
|
||||
line-height: 1.7;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.message.user .message-bubble {
|
||||
background: var(--accent-dim);
|
||||
color: var(--text);
|
||||
border-bottom-right-radius: 2px;
|
||||
}
|
||||
|
||||
.message.assistant .message-bubble {
|
||||
background: var(--bg2);
|
||||
border: 1px solid var(--border);
|
||||
border-bottom-left-radius: 2px;
|
||||
}
|
||||
|
||||
.message-meta {
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
padding: 4px 4px 0;
|
||||
}
|
||||
|
||||
/* Tool indicators */
|
||||
.tool-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 12px;
|
||||
background: var(--bg2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
align-self: flex-start;
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
.tool-indicator .dot {
|
||||
width: 8px; height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
animation: pulse 1s infinite;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tool-indicator.done .dot { background: var(--green); animation: none; }
|
||||
.tool-indicator.error .dot { background: var(--red); animation: none; }
|
||||
|
||||
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } }
|
||||
|
||||
/* Confirmation modal */
|
||||
.modal-overlay {
|
||||
position: fixed; inset: 0;
|
||||
background: rgba(0,0,0,0.6);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal-overlay.hidden { display: none; }
|
||||
|
||||
.modal {
|
||||
background: var(--bg2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 24px;
|
||||
max-width: 480px;
|
||||
width: 90%;
|
||||
}
|
||||
|
||||
.modal h3 { margin-bottom: 8px; color: var(--yellow); font-size: 15px; }
|
||||
.modal .modal-desc {
|
||||
background: var(--bg3);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
padding: 10px 12px;
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
margin: 12px 0 20px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.modal-buttons { display: flex; gap: 10px; justify-content: flex-end; }
|
||||
|
||||
/* Input bar */
|
||||
.chat-input-wrap {
|
||||
padding: 16px 24px;
|
||||
border-top: 1px solid var(--border);
|
||||
background: var(--bg);
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.chat-input {
|
||||
flex: 1;
|
||||
background: var(--bg2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
color: var(--text);
|
||||
font-family: var(--font);
|
||||
font-size: 14px;
|
||||
padding: 10px 14px;
|
||||
resize: none;
|
||||
min-height: 42px;
|
||||
max-height: 200px;
|
||||
line-height: 1.5;
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.chat-input:focus { border-color: var(--accent-dim); }
|
||||
|
||||
/* ── Buttons ── */
|
||||
.btn {
|
||||
padding: 8px 16px;
|
||||
border-radius: var(--radius);
|
||||
border: none;
|
||||
font-size: 13px;
|
||||
font-family: var(--font);
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.btn:hover { opacity: 0.85; }
|
||||
.btn:disabled { opacity: 0.4; cursor: default; }
|
||||
|
||||
.btn-primary { background: var(--accent); color: #fff; }
|
||||
.btn-danger { background: var(--red); color: #fff; }
|
||||
.btn-ghost { background: var(--bg3); color: var(--text); border: 1px solid var(--border); }
|
||||
.btn-small { padding: 4px 10px; font-size: 12px; }
|
||||
|
||||
/* ── Generic page content ── */
|
||||
.page { padding: 32px; overflow-y: auto; flex: 1; }
|
||||
.page h1 { font-size: 20px; margin-bottom: 24px; }
|
||||
|
||||
/* Tables */
|
||||
.table-wrap { overflow-x: auto; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
th, td { padding: 10px 12px; text-align: left; border-bottom: 1px solid var(--border); }
|
||||
th { color: var(--text-dim); font-weight: 500; font-size: 11px; text-transform: uppercase; }
|
||||
tr:hover td { background: var(--bg2); }
|
||||
|
||||
/* Forms */
|
||||
.form-group { margin-bottom: 16px; }
|
||||
.form-group label { display: block; font-size: 12px; color: var(--text-dim); margin-bottom: 6px; }
|
||||
.form-input {
|
||||
width: 100%;
|
||||
background: var(--bg2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
color: var(--text);
|
||||
font-family: var(--font);
|
||||
font-size: 13px;
|
||||
padding: 8px 12px;
|
||||
outline: none;
|
||||
}
|
||||
.form-input:focus { border-color: var(--accent-dim); }
|
||||
|
||||
/* Badges */
|
||||
.badge {
|
||||
padding: 2px 8px;
|
||||
border-radius: 99px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.badge-green { background: rgba(76,175,125,0.15); color: var(--green); }
|
||||
.badge-red { background: rgba(224,82,82,0.15); color: var(--red); }
|
||||
.badge-blue { background: rgba(108,142,245,0.15); color: var(--accent); }
|
||||
|
||||
/* Status bar */
|
||||
.status-bar {
|
||||
padding: 6px 24px;
|
||||
background: var(--bg2);
|
||||
border-top: 1px solid var(--border);
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 6px; height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--green);
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.status-dot.offline { background: var(--red); }
|
||||
.status-dot.paused { background: var(--yellow); }
|
||||
|
||||
/* Scrollbars */
|
||||
::-webkit-scrollbar { width: 6px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
|
||||
|
||||
/* Typing indicator */
|
||||
.typing { display: flex; gap: 4px; align-items: center; padding: 4px 0; }
|
||||
.typing span {
|
||||
width: 6px; height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-dim);
|
||||
animation: bounce 1.2s infinite;
|
||||
}
|
||||
.typing span:nth-child(2) { animation-delay: 0.2s; }
|
||||
.typing span:nth-child(3) { animation-delay: 0.4s; }
|
||||
@keyframes bounce { 0%, 80%, 100% { transform: scale(0.6); } 40% { transform: scale(1); } }
|
||||
|
||||
/* Filter bar */
|
||||
.filter-bar { display: flex; gap: 10px; margin-bottom: 20px; flex-wrap: wrap; align-items: flex-end; }
|
||||
.filter-bar .form-input { width: auto; }
|
||||
|
||||
/* Tab buttons */
|
||||
.tab-btn {
|
||||
padding: 8px 20px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
color: var(--text-dim);
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
margin-bottom: -1px;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
}
|
||||
.tab-btn.active { color: var(--text); border-bottom-color: var(--accent); }
|
||||
.tab-btn:hover:not(.active) { color: var(--text); }
|
||||
|
||||
/* Metadata cards */
|
||||
.meta-card { background: var(--bg2); border: 1px solid var(--border); border-radius: var(--radius); padding: 12px 16px; }
|
||||
.meta-label { font-size: 11px; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 4px; }
|
||||
.meta-value { font-size: 14px; font-weight: 500; word-break: break-all; }
|
||||
|
||||
/* Pagination */
|
||||
.pagination { display: flex; gap: 6px; justify-content: flex-end; margin-top: 16px; }
|
||||
.page-btn {
|
||||
padding: 4px 10px;
|
||||
background: var(--bg2);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-dim);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
}
|
||||
.page-btn.active { background: var(--accent-dim); color: var(--text); border-color: var(--accent-dim); }
|
||||
.page-btn:hover:not(.active) { border-color: var(--accent-dim); }
|
||||
|
||||
/* ── Settings page ───────────────────────────────────────────────────────── */
|
||||
.settings-section-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
margin-bottom: 12px;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
|
||||
/* ── Model picker modal list items ────────────────────────────────────────── */
|
||||
.mp-item { padding: 7px 14px; font-size: 13px; cursor: pointer; border-radius: 4px; color: var(--text-dim); }
|
||||
.mp-item:hover, .mp-item.mp-focused { background: var(--bg3); color: var(--text); }
|
||||
.mp-item.mp-selected { color: var(--accent); }
|
||||
|
||||
/* ── Help page ────────────────────────────────────────────────────────────── */
|
||||
.help-layout {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.help-toc {
|
||||
width: 220px;
|
||||
min-width: 220px;
|
||||
padding: 24px 16px;
|
||||
border-right: 1px solid var(--border);
|
||||
background: var(--bg2);
|
||||
overflow-y: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.toc-list {
|
||||
list-style: none;
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.toc-list li { margin-bottom: 2px; }
|
||||
|
||||
.toc-list a {
|
||||
display: block;
|
||||
padding: 4px 8px;
|
||||
color: var(--text-dim);
|
||||
text-decoration: none;
|
||||
border-radius: 4px;
|
||||
transition: color 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
.toc-list a:hover { color: var(--text); background: var(--bg3); }
|
||||
.toc-list a.toc-active { color: var(--accent); background: rgba(108,142,245,0.08); }
|
||||
|
||||
.toc-list ul {
|
||||
list-style: none;
|
||||
padding-left: 12px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.toc-list ul a { font-size: 12px; }
|
||||
|
||||
.help-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 32px 40px;
|
||||
max-width: 900px;
|
||||
}
|
||||
|
||||
.help-content section {
|
||||
margin-bottom: 48px;
|
||||
scroll-margin-top: 32px;
|
||||
}
|
||||
|
||||
.help-content h1 {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.help-content h2 {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
margin-top: 24px;
|
||||
margin-bottom: 10px;
|
||||
color: var(--text);
|
||||
scroll-margin-top: 32px;
|
||||
}
|
||||
|
||||
.help-content h3 {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
margin-top: 20px;
|
||||
margin-bottom: 8px;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.help-content p { margin-bottom: 10px; line-height: 1.7; }
|
||||
|
||||
.help-content ul, .help-content ol {
|
||||
padding-left: 20px;
|
||||
margin-bottom: 10px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.help-content li { margin-bottom: 4px; }
|
||||
|
||||
.help-content dl { margin-bottom: 12px; }
|
||||
.help-content dt { font-weight: 600; margin-top: 10px; color: var(--text); }
|
||||
.help-content dd { padding-left: 16px; color: var(--text-dim); }
|
||||
|
||||
.help-content pre {
|
||||
background: var(--bg3);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 12px 16px;
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
overflow-x: auto;
|
||||
margin: 10px 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.help-content code {
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
background: var(--bg3);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 3px;
|
||||
padding: 1px 5px;
|
||||
}
|
||||
|
||||
.help-content pre code {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
.help-content a { color: var(--accent); text-decoration: none; }
|
||||
.help-content a:hover { text-decoration: underline; }
|
||||
|
||||
.help-content kbd {
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
background: var(--bg3);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 3px;
|
||||
padding: 1px 5px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.help-note {
|
||||
background: rgba(108,142,245,0.08);
|
||||
border: 1px solid rgba(108,142,245,0.25);
|
||||
border-radius: var(--radius);
|
||||
padding: 10px 14px;
|
||||
margin-bottom: 16px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* Help API table — more compact than the default table */
|
||||
.help-api-table { font-size: 12px; margin-bottom: 16px; }
|
||||
.help-api-table th { font-size: 11px; }
|
||||
.help-api-table td { padding: 7px 12px; vertical-align: top; }
|
||||
|
||||
/* HTTP method badges */
|
||||
.http-get { color: var(--green); font-weight: 700; font-family: var(--mono); font-size: 11px; }
|
||||
.http-post { color: var(--accent); font-weight: 700; font-family: var(--mono); font-size: 11px; }
|
||||
.http-put { color: var(--yellow); font-weight: 700; font-family: var(--mono); font-size: 11px; }
|
||||
.http-del { color: var(--red); font-weight: 700; font-family: var(--mono); font-size: 11px; }
|
||||
|
||||
/* ── Prompt mode toggle ───────────────────────────────────────────────────── */
|
||||
.prompt-mode-toggle { display: flex; border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; }
|
||||
.pm-btn { flex: 1; padding: 6px 8px; font-size: 12px; border: none; border-right: 1px solid var(--border); background: var(--bg2); color: var(--text-dim); cursor: pointer; transition: background 0.15s, color 0.15s; }
|
||||
.pm-btn:last-child { border-right: none; }
|
||||
.pm-btn.active { background: var(--accent); color: #fff; }
|
||||
.pm-btn:hover:not(.active) { background: var(--bg3); color: var(--text); }
|
||||
290
server/web/templates/admin_users.html
Normal file
290
server/web/templates/admin_users.html
Normal file
@@ -0,0 +1,290 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Users — {{ brand_name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page" style="overflow-y:auto">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:24px">
|
||||
<h1 style="margin:0">User Management</h1>
|
||||
<button class="btn btn-primary" onclick="openCreateModal()">+ Add User</button>
|
||||
</div>
|
||||
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Username</th>
|
||||
<th>Email</th>
|
||||
<th>Role</th>
|
||||
<th>Status</th>
|
||||
<th>MFA</th>
|
||||
<th>Created</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="users-tbody">
|
||||
<tr><td colspan="7" style="color:var(--text-dim);text-align:center;padding:24px">Loading…</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create/Edit user modal -->
|
||||
<div class="modal-overlay" id="user-modal" style="display:none">
|
||||
<div class="modal" style="max-width:420px;width:100%">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:20px">
|
||||
<h3 style="margin:0;color:var(--text)" id="modal-title">Add User</h3>
|
||||
<button onclick="closeUserModal()" style="background:none;border:none;color:var(--text-dim);cursor:pointer;font-size:20px;line-height:1;padding:2px 6px;border-radius:4px;transition:color 0.15s" onmouseover="this.style.color='var(--text)'" onmouseout="this.style.color='var(--text-dim)'">×</button>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Username</label>
|
||||
<input type="text" id="f-username" class="form-input" autocomplete="off">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Email</label>
|
||||
<input type="email" id="f-email" class="form-input" autocomplete="off" placeholder="user@example.com">
|
||||
</div>
|
||||
<div class="form-group" id="pw-field">
|
||||
<label id="pw-label">Password</label>
|
||||
<input type="password" id="f-password" class="form-input" autocomplete="new-password" placeholder="min 8 characters">
|
||||
</div>
|
||||
<div class="form-group" id="pw-confirm-field">
|
||||
<label>Confirm password</label>
|
||||
<input type="password" id="f-confirm" class="form-input" autocomplete="new-password">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Role</label>
|
||||
<select id="f-role" class="form-input">
|
||||
<option value="user">User</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group" id="active-field" style="display:none">
|
||||
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;font-size:13px;color:var(--text)">
|
||||
<input type="checkbox" id="f-active"> Active
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-group" id="admin-keys-field" style="display:none">
|
||||
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;font-size:13px;color:var(--text)">
|
||||
<input type="checkbox" id="f-admin-keys"> Allow access to admin API keys
|
||||
</label>
|
||||
<div style="font-size:11px;color:var(--text-dim);margin-top:4px;margin-left:22px">
|
||||
Grants full access to Anthropic, OpenRouter, and OpenAI using the system API keys.
|
||||
</div>
|
||||
</div>
|
||||
<div id="modal-error" style="display:none;color:var(--red);font-size:12px;margin-bottom:12px;padding:8px 10px;background:rgba(224,82,82,0.08);border-radius:var(--radius);border:1px solid rgba(224,82,82,0.2)"></div>
|
||||
|
||||
<div class="modal-buttons">
|
||||
<button class="btn btn-ghost" onclick="closeUserModal()">Cancel</button>
|
||||
<button class="btn btn-primary" id="modal-save-btn" onclick="saveUser()">Create</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Change own password modal -->
|
||||
<div class="modal-overlay" id="pw-modal" style="display:none">
|
||||
<div class="modal" style="max-width:380px;width:100%">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:20px">
|
||||
<h3 style="margin:0;color:var(--text)">Change Password</h3>
|
||||
<button onclick="document.getElementById('pw-modal').style.display='none'" style="background:none;border:none;color:var(--text-dim);cursor:pointer;font-size:20px;line-height:1;padding:2px 6px;border-radius:4px;transition:color 0.15s" onmouseover="this.style.color='var(--text)'" onmouseout="this.style.color='var(--text-dim)'">×</button>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Current password</label>
|
||||
<input type="password" id="pw-current" class="form-input" autocomplete="current-password">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>New password</label>
|
||||
<input type="password" id="pw-new" class="form-input" autocomplete="new-password" placeholder="min 8 characters">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Confirm new password</label>
|
||||
<input type="password" id="pw-new2" class="form-input" autocomplete="new-password">
|
||||
</div>
|
||||
<div id="pw-modal-error" style="display:none;color:var(--red);font-size:12px;margin-bottom:12px;padding:8px 10px;background:rgba(224,82,82,0.08);border-radius:var(--radius);border:1px solid rgba(224,82,82,0.2)"></div>
|
||||
|
||||
<div class="modal-buttons">
|
||||
<button class="btn btn-ghost" onclick="document.getElementById('pw-modal').style.display='none'">Cancel</button>
|
||||
<button class="btn btn-primary" onclick="changeOwnPassword()">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script>
|
||||
const ME = {{ current_user.id | tojson }};
|
||||
let _editUserId = null;
|
||||
const _usersMap = {};
|
||||
|
||||
const _JSON_HEADERS = { 'Content-Type': 'application/json' };
|
||||
|
||||
function esc(s) {
|
||||
return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
}
|
||||
|
||||
async function loadUsers() {
|
||||
const tbody = document.getElementById('users-tbody');
|
||||
try {
|
||||
const resp = await fetch('/api/users');
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
tbody.innerHTML = `<tr><td colspan="7" style="color:var(--red);text-align:center;padding:24px">Error ${resp.status}: ${esc(err.detail || resp.statusText)}</td></tr>`;
|
||||
return;
|
||||
}
|
||||
const users = await resp.json();
|
||||
if (!Array.isArray(users) || !users.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="7" style="color:var(--text-dim);text-align:center;padding:24px">No users.</td></tr>';
|
||||
return;
|
||||
}
|
||||
users.forEach(u => { _usersMap[u.id] = u; });
|
||||
tbody.innerHTML = users.map(u => `
|
||||
<tr>
|
||||
<td style="font-weight:500">${esc(u.username)}${u.id === ME ? ' <span class="badge badge-blue" style="font-size:10px">you</span>' : ''}</td>
|
||||
<td style="color:var(--text-dim)">${esc(u.email || '—')}</td>
|
||||
<td><span class="badge ${u.role === 'admin' ? 'badge-blue' : ''}" style="${u.role !== 'admin' ? 'background:var(--bg3);color:var(--text-dim)' : ''}">${esc(u.role)}</span></td>
|
||||
<td><span class="status-dot${u.is_active ? '' : ' offline'}" style="margin-right:6px"></span>${u.is_active ? 'Active' : 'Inactive'}</td>
|
||||
<td>${u.mfa_enabled ? '<span class="badge badge-green" style="font-size:11px">On</span>' : '<span style="color:var(--text-dim)">—</span>'}</td>
|
||||
<td style="color:var(--text-dim)">${formatDateShort(u.created_at)}</td>
|
||||
<td style="display:flex;gap:6px;align-items:center">
|
||||
<button class="btn btn-ghost" style="padding:4px 10px;font-size:12px" onclick="openEditModal('${esc(u.id)}')">Edit</button>
|
||||
${u.id !== ME
|
||||
? `<button class="btn btn-danger" style="padding:4px 10px;font-size:12px" onclick="deleteUser('${esc(u.id)}','${esc(u.username)}')">Delete</button>`
|
||||
: `<button class="btn btn-ghost" style="padding:4px 10px;font-size:12px" onclick="openPwModal()">Change password</button>`}
|
||||
${u.mfa_enabled && u.id !== ME ? `<button class="btn btn-ghost" style="padding:4px 10px;font-size:12px;color:var(--yellow)" onclick="clearUserMfa('${esc(u.id)}','${esc(u.username)}')">Clear MFA</button>` : ''}
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
} catch (e) {
|
||||
tbody.innerHTML = '<tr><td colspan="7" style="color:var(--red);text-align:center;padding:24px">Failed to load users.</td></tr>';
|
||||
}
|
||||
}
|
||||
|
||||
function openCreateModal() {
|
||||
_editUserId = null;
|
||||
document.getElementById('modal-title').textContent = 'Add User';
|
||||
document.getElementById('modal-save-btn').textContent = 'Create';
|
||||
document.getElementById('f-username').value = '';
|
||||
document.getElementById('f-email').value = '';
|
||||
document.getElementById('f-password').value = '';
|
||||
document.getElementById('f-confirm').value = '';
|
||||
document.getElementById('f-role').value = 'user';
|
||||
document.getElementById('f-username').disabled = false;
|
||||
document.getElementById('pw-field').style.display = '';
|
||||
document.getElementById('pw-label').textContent = 'Password';
|
||||
document.getElementById('pw-confirm-field').style.display = '';
|
||||
document.getElementById('active-field').style.display = 'none';
|
||||
document.getElementById('admin-keys-field').style.display = 'none';
|
||||
document.getElementById('modal-error').style.display = 'none';
|
||||
document.getElementById('user-modal').style.display = 'flex';
|
||||
document.getElementById('f-username').focus();
|
||||
}
|
||||
|
||||
function openEditModal(userId) {
|
||||
const u = _usersMap[userId];
|
||||
if (!u) return;
|
||||
_editUserId = u.id;
|
||||
document.getElementById('modal-title').textContent = 'Edit User';
|
||||
document.getElementById('modal-save-btn').textContent = 'Save';
|
||||
document.getElementById('f-username').value = u.username;
|
||||
document.getElementById('f-username').disabled = true;
|
||||
document.getElementById('f-email').value = u.email || '';
|
||||
document.getElementById('f-password').value = '';
|
||||
document.getElementById('f-confirm').value = '';
|
||||
document.getElementById('f-role').value = u.role;
|
||||
document.getElementById('pw-field').style.display = '';
|
||||
document.getElementById('pw-label').textContent = 'New password (leave blank to keep)';
|
||||
document.getElementById('pw-confirm-field').style.display = '';
|
||||
document.getElementById('active-field').style.display = '';
|
||||
document.getElementById('f-active').checked = u.is_active;
|
||||
document.getElementById('admin-keys-field').style.display = u.role === 'admin' ? 'none' : '';
|
||||
document.getElementById('f-admin-keys').checked = false;
|
||||
if (u.role !== 'admin') {
|
||||
fetch(`/api/users/${userId}/admin-keys`)
|
||||
.then(r => r.json())
|
||||
.then(d => { document.getElementById('f-admin-keys').checked = d.enabled; })
|
||||
.catch(() => {});
|
||||
}
|
||||
document.getElementById('modal-error').style.display = 'none';
|
||||
document.getElementById('user-modal').style.display = 'flex';
|
||||
}
|
||||
|
||||
function closeUserModal() {
|
||||
document.getElementById('user-modal').style.display = 'none';
|
||||
}
|
||||
|
||||
function showModalErr(msg) {
|
||||
const el = document.getElementById('modal-error');
|
||||
el.textContent = msg;
|
||||
el.style.display = '';
|
||||
}
|
||||
|
||||
async function saveUser() {
|
||||
document.getElementById('modal-error').style.display = 'none';
|
||||
if (_editUserId) {
|
||||
const body = { role: document.getElementById('f-role').value, is_active: document.getElementById('f-active').checked, email: document.getElementById('f-email').value.trim() };
|
||||
const pw = document.getElementById('f-password').value;
|
||||
if (pw) {
|
||||
if (pw !== document.getElementById('f-confirm').value) { showModalErr('Passwords do not match.'); return; }
|
||||
if (pw.length < 8) { showModalErr('Password must be at least 8 characters.'); return; }
|
||||
body.password = pw;
|
||||
}
|
||||
const resp = await fetch(`/api/users/${_editUserId}`, { method: 'PUT', headers: _JSON_HEADERS, body: JSON.stringify(body) });
|
||||
if (!resp.ok) { const d = await resp.json(); showModalErr(d.detail || 'Error saving user.'); return; }
|
||||
// Save admin key access flag (only meaningful for non-admin users)
|
||||
if (document.getElementById('admin-keys-field').style.display !== 'none') {
|
||||
await fetch(`/api/users/${_editUserId}/admin-keys`, {
|
||||
method: 'POST', headers: _JSON_HEADERS,
|
||||
body: JSON.stringify({ enabled: document.getElementById('f-admin-keys').checked }),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const username = document.getElementById('f-username').value.trim();
|
||||
const email = document.getElementById('f-email').value.trim();
|
||||
const password = document.getElementById('f-password').value;
|
||||
const confirm = document.getElementById('f-confirm').value;
|
||||
if (!username) { showModalErr('Username is required.'); return; }
|
||||
if (!email || !email.includes('@')) { showModalErr('A valid email address is required.'); return; }
|
||||
if (password.length < 8) { showModalErr('Password must be at least 8 characters.'); return; }
|
||||
if (password !== confirm) { showModalErr('Passwords do not match.'); return; }
|
||||
const body = { username, email, password, role: document.getElementById('f-role').value };
|
||||
const resp = await fetch('/api/users', { method: 'POST', headers: _JSON_HEADERS, body: JSON.stringify(body) });
|
||||
if (!resp.ok) { const d = await resp.json(); showModalErr(d.detail || 'Error creating user.'); return; }
|
||||
}
|
||||
closeUserModal();
|
||||
loadUsers();
|
||||
}
|
||||
|
||||
async function deleteUser(id, name) {
|
||||
if (!confirm(`Delete user "${name}"? This cannot be undone.`)) return;
|
||||
const resp = await fetch(`/api/users/${id}`, { method: 'DELETE' });
|
||||
if (!resp.ok) { alert('Failed to delete user.'); return; }
|
||||
loadUsers();
|
||||
}
|
||||
|
||||
function openPwModal() {
|
||||
document.getElementById('pw-current').value = '';
|
||||
document.getElementById('pw-new').value = '';
|
||||
document.getElementById('pw-new2').value = '';
|
||||
document.getElementById('pw-modal-error').style.display = 'none';
|
||||
document.getElementById('pw-modal').style.display = 'flex';
|
||||
document.getElementById('pw-current').focus();
|
||||
}
|
||||
|
||||
async function changeOwnPassword() {
|
||||
const current = document.getElementById('pw-current').value;
|
||||
const pw = document.getElementById('pw-new').value;
|
||||
const pw2 = document.getElementById('pw-new2').value;
|
||||
const errEl = document.getElementById('pw-modal-error');
|
||||
errEl.style.display = 'none';
|
||||
if (pw.length < 8) { errEl.textContent = 'Password must be at least 8 characters.'; errEl.style.display = ''; return; }
|
||||
if (pw !== pw2) { errEl.textContent = 'Passwords do not match.'; errEl.style.display = ''; return; }
|
||||
const resp = await fetch('/api/users/me/password', { method: 'POST', headers: _JSON_HEADERS, body: JSON.stringify({ current_password: current, new_password: pw }) });
|
||||
if (!resp.ok) { const d = await resp.json(); errEl.textContent = d.detail || 'Error changing password.'; errEl.style.display = ''; return; }
|
||||
document.getElementById('pw-modal').style.display = 'none';
|
||||
showFlash('Password changed.');
|
||||
}
|
||||
|
||||
loadUsers();
|
||||
</script>
|
||||
{% endblock %}
|
||||
175
server/web/templates/agent_detail.html
Normal file
175
server/web/templates/agent_detail.html
Normal file
@@ -0,0 +1,175 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ agent_name }} — Agent Detail{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page" id="agent-detail-container">
|
||||
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:24px">
|
||||
<div>
|
||||
<a href="/agents" onclick="event.preventDefault();navigateTo('/agents')"
|
||||
style="color:var(--text-dim);font-size:13px;text-decoration:none">← Agents</a>
|
||||
<h1 id="d-name" style="margin-top:8px;margin-bottom:4px">Loading…</h1>
|
||||
<div id="d-description" style="color:var(--text-dim);font-size:13px"></div>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px">
|
||||
<button class="btn btn-ghost" onclick="editCurrentAgent()">Edit</button>
|
||||
<button class="btn btn-primary" onclick="runCurrentAgent()">▶ Run now</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Metadata grid -->
|
||||
<section style="
|
||||
display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));
|
||||
gap:16px;margin-bottom:28px
|
||||
">
|
||||
<div class="meta-card">
|
||||
<div class="meta-label">Model</div>
|
||||
<div id="d-model" class="meta-value"></div>
|
||||
</div>
|
||||
<div class="meta-card">
|
||||
<div class="meta-label">Schedule</div>
|
||||
<div id="d-schedule" class="meta-value"></div>
|
||||
</div>
|
||||
<div class="meta-card">
|
||||
<div class="meta-label">Sub-agents</div>
|
||||
<div id="d-subagents" class="meta-value"></div>
|
||||
</div>
|
||||
<div class="meta-card">
|
||||
<div class="meta-label">Created by</div>
|
||||
<div id="d-created-by" class="meta-value"></div>
|
||||
</div>
|
||||
<div class="meta-card">
|
||||
<div class="meta-label">Created at</div>
|
||||
<div id="d-created-at" class="meta-value"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Prompt -->
|
||||
<section style="margin-bottom:28px">
|
||||
<h2 style="font-size:14px;color:var(--text-dim);font-weight:500;margin-bottom:8px;text-transform:uppercase;letter-spacing:0.5px">
|
||||
Prompt
|
||||
</h2>
|
||||
<pre id="d-prompt" style="
|
||||
background:var(--bg2);border:1px solid var(--border);border-radius:var(--radius);
|
||||
padding:12px;font-family:inherit;font-size:13px;white-space:pre-wrap;margin:0
|
||||
"></pre>
|
||||
</section>
|
||||
|
||||
<!-- Token summary -->
|
||||
<section style="margin-bottom:28px">
|
||||
<h2 style="font-size:14px;color:var(--text-dim);font-weight:500;margin-bottom:8px;text-transform:uppercase;letter-spacing:0.5px">
|
||||
Token Usage
|
||||
</h2>
|
||||
<div id="d-total-tokens" style="font-size:13px;color:var(--text-dim)">Loading…</div>
|
||||
</section>
|
||||
|
||||
<!-- Run history -->
|
||||
<section>
|
||||
<h2 style="font-size:14px;color:var(--text-dim);font-weight:500;margin-bottom:12px;text-transform:uppercase;letter-spacing:0.5px">
|
||||
Run History
|
||||
</h2>
|
||||
<div class="table-wrap">
|
||||
<table id="detail-runs-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Run ID</th>
|
||||
<th>Started</th>
|
||||
<th>Duration</th>
|
||||
<th>Status</th>
|
||||
<th>Input tokens</th>
|
||||
<th>Output tokens</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td colspan="7" style="text-align:center;color:var(--text-dim)">Loading…</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Run detail modal -->
|
||||
<div class="modal-overlay" id="run-detail-modal" style="display:none" onclick="if(event.target===this)closeRunDetail()">
|
||||
<div class="modal" style="max-width:680px;width:100%;position:relative">
|
||||
<button onclick="closeRunDetail()" style="position:absolute;top:14px;right:14px;background:none;border:none;color:var(--text-dim);font-size:18px;cursor:pointer;line-height:1;padding:2px 6px" title="Close">✕</button>
|
||||
<div style="display:flex;align-items:center;gap:12px;margin-bottom:16px;padding-right:32px">
|
||||
<h3 style="margin:0">Run Detail</h3>
|
||||
<span id="rd-status-badge" class="badge"></span>
|
||||
<span id="rd-meta" style="font-size:12px;color:var(--text-dim)"></span>
|
||||
</div>
|
||||
<div class="modal-body" style="padding:0 0 16px">
|
||||
<div id="rd-error" style="display:none;margin-bottom:16px;padding:10px 14px;background:rgba(220,60,60,0.1);border:1px solid rgba(220,60,60,0.3);border-radius:var(--radius);color:var(--danger);font-size:13px;white-space:pre-wrap"></div>
|
||||
<div id="rd-result-wrap">
|
||||
<div style="font-size:12px;color:var(--text-dim);margin-bottom:6px;font-weight:500;text-transform:uppercase;letter-spacing:0.5px">Result</div>
|
||||
<pre id="rd-result" style="background:var(--bg2);border:1px solid var(--border);border-radius:var(--radius);padding:12px;font-family:inherit;font-size:13px;white-space:pre-wrap;margin:0;max-height:400px;overflow-y:auto"></pre>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;padding-top:12px;border-top:1px solid var(--border)">
|
||||
<button id="rd-audit-btn" class="btn btn-ghost" onclick="closeRunDetail();navigateTo(document.getElementById('rd-audit-link').href)">View in Audit Log</button>
|
||||
<button class="btn btn-ghost" onclick="closeRunDetail()">Close</button>
|
||||
</div>
|
||||
<a id="rd-audit-link" href="/audit" style="display:none"></a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit modal (reuse agent modal markup with inline) -->
|
||||
<div class="modal-overlay hidden" id="agent-modal">
|
||||
<div class="modal" style="max-width:560px;width:100%">
|
||||
<h3 id="agent-modal-title">Edit Agent</h3>
|
||||
<input type="hidden" id="a-id">
|
||||
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px">
|
||||
<div class="form-group">
|
||||
<label>Name</label>
|
||||
<input type="text" id="a-name" class="form-input" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Model</label>
|
||||
<select id="a-model" class="form-input"></select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Description</label>
|
||||
<input type="text" id="a-desc" class="form-input">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Prompt</label>
|
||||
<textarea id="a-prompt" class="form-input" rows="5" style="resize:vertical"></textarea>
|
||||
</div>
|
||||
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px">
|
||||
<div class="form-group">
|
||||
<label>Schedule</label>
|
||||
<input type="text" id="a-schedule" class="form-input"
|
||||
placeholder="0 8 * * *" oninput="updateAgentCronPreview(this.value)">
|
||||
<div id="a-cron-preview" style="font-size:11px;color:var(--text-dim);margin-top:4px"></div>
|
||||
</div>
|
||||
<div class="form-group" style="display:flex;flex-direction:column;justify-content:center;gap:8px;padding-top:18px">
|
||||
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
|
||||
<input type="checkbox" id="a-subagents">
|
||||
<span>Can create sub-agents</span>
|
||||
</label>
|
||||
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
|
||||
<input type="checkbox" id="a-enabled" checked>
|
||||
<span>Enabled</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-buttons">
|
||||
<button class="btn btn-ghost" onclick="closeAgentModal()">Cancel</button>
|
||||
<button class="btn btn-primary" onclick="saveAgentAndReload()">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script>
|
||||
window.AGENT_ID = "{{ agent_id }}";
|
||||
</script>
|
||||
{% endblock %}
|
||||
182
server/web/templates/agents.html
Normal file
182
server/web/templates/agents.html
Normal file
@@ -0,0 +1,182 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ agent_name }} — Agents{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page" id="agents-container">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:24px">
|
||||
<h1>Agents</h1>
|
||||
<div style="display:flex;gap:8px">
|
||||
<button class="btn btn-ghost" onclick="showTemplatesModal()">Browse Templates</button>
|
||||
<button class="btn btn-primary" onclick="showAgentModal(null)">+ New Agent</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div style="display:flex;gap:0;border-bottom:1px solid var(--border);margin-bottom:24px">
|
||||
<button class="tab-btn active" id="tab-agents" onclick="switchTab('agents')">Agents</button>
|
||||
<button class="tab-btn" id="tab-status" onclick="switchTab('status')">Status</button>
|
||||
</div>
|
||||
|
||||
<!-- ── Agents tab ── -->
|
||||
<div id="pane-agents">
|
||||
<div class="table-wrap">
|
||||
<table id="agents-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Model</th>
|
||||
<th>Schedule</th>
|
||||
<th>Sub-agents</th>
|
||||
<th>Status</th>
|
||||
<th>Last run</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td colspan="7" style="text-align:center;color:var(--text-dim)">Loading…</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Status tab ── -->
|
||||
<div id="pane-status" style="display:none">
|
||||
<div style="display:flex;gap:12px;align-items:center;margin-bottom:16px;flex-wrap:wrap">
|
||||
<label style="font-size:13px;color:var(--text-dim)">Timeframe:</label>
|
||||
<select id="status-since" onchange="loadAgentRuns()" style="
|
||||
background:var(--bg2);border:1px solid var(--border);color:var(--text);
|
||||
border-radius:4px;padding:4px 10px;font-size:13px;cursor:pointer
|
||||
">
|
||||
<option value="today">Today</option>
|
||||
<option value="7d" selected>Last 7 days</option>
|
||||
<option value="30d">Last 30 days</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table id="runs-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Agent</th>
|
||||
<th>Started</th>
|
||||
<th>Duration</th>
|
||||
<th>Status</th>
|
||||
<th>Input tokens</th>
|
||||
<th>Output tokens</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td colspan="7" style="text-align:center;color:var(--text-dim)">Loading…</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div id="runs-totals" style="font-size:12px;color:var(--text-dim);margin-top:8px;text-align:right"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Agent modal ── -->
|
||||
<div class="modal-overlay hidden" id="agent-modal">
|
||||
<div class="modal" style="max-width:560px;width:100%">
|
||||
<h3 id="agent-modal-title">New Agent</h3>
|
||||
<input type="hidden" id="a-id">
|
||||
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px">
|
||||
<div class="form-group">
|
||||
<label>Name</label>
|
||||
<input type="text" id="a-name" class="form-input" placeholder="My agent" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Model</label>
|
||||
<select id="a-model" class="form-input"></select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Description <span style="color:var(--text-dim)">(optional)</span></label>
|
||||
<input type="text" id="a-desc" class="form-input" placeholder="What does this agent do?">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Prompt</label>
|
||||
<textarea id="a-prompt" class="form-input" rows="5"
|
||||
placeholder="Instructions for the agent…" style="resize:vertical"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Prompt mode</label>
|
||||
<div class="prompt-mode-toggle">
|
||||
<button type="button" class="pm-btn active" data-mode="combined" onclick="setPromptMode('combined')">Combined</button>
|
||||
<button type="button" class="pm-btn" data-mode="system_only" onclick="setPromptMode('system_only')">System only</button>
|
||||
<button type="button" class="pm-btn" data-mode="agent_only" onclick="setPromptMode('agent_only')">Agent only</button>
|
||||
</div>
|
||||
<input type="hidden" id="a-prompt-mode" value="combined">
|
||||
<div id="a-pm-hint" style="font-size:11px;color:var(--text-dim);margin-top:6px"></div>
|
||||
</div>
|
||||
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px">
|
||||
<div class="form-group">
|
||||
<label>Schedule <span style="color:var(--text-dim)">(cron, optional)</span></label>
|
||||
<input type="text" id="a-schedule" class="form-input"
|
||||
placeholder="0 8 * * *" oninput="updateAgentCronPreview(this.value)">
|
||||
<div id="a-cron-preview" style="font-size:11px;color:var(--text-dim);margin-top:4px"></div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Max tool calls <span style="color:var(--text-dim)" id="a-mtc-hint">(default)</span></label>
|
||||
<input type="number" id="a-max-tool-calls" class="form-input" min="1" max="200"
|
||||
placeholder="Use system default" oninput="updateMtcHint()">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Allowed Tools <span style="color:var(--text-dim)">(none checked = all tools)</span></label>
|
||||
<div id="agent-tool-list" style="display:flex;gap:12px;flex-wrap:wrap;padding:8px 0">
|
||||
<span style="color:var(--text-dim);font-size:12px">Loading tools…</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="display:flex;gap:16px">
|
||||
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
|
||||
<input type="checkbox" id="a-subagents">
|
||||
<span>Can create sub-agents</span>
|
||||
</label>
|
||||
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
|
||||
<input type="checkbox" id="a-enabled" checked>
|
||||
<span>Enabled</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="modal-buttons">
|
||||
<button class="btn btn-ghost" onclick="closeAgentModal()">Cancel</button>
|
||||
<button class="btn btn-primary" onclick="saveAgent()">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Run result modal ── -->
|
||||
<div class="modal-overlay hidden" id="agent-run-modal">
|
||||
<div class="modal">
|
||||
<h3>Agent run started</h3>
|
||||
<div id="agent-run-output" style="
|
||||
background:var(--bg);border:1px solid var(--border);border-radius:var(--radius);
|
||||
padding:12px;font-family:monospace;font-size:12px;white-space:pre-wrap;
|
||||
max-height:300px;overflow-y:auto;margin:12px 0
|
||||
">—</div>
|
||||
<div class="modal-buttons">
|
||||
<button class="btn btn-primary" onclick="closeAgentRunModal()">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Templates modal ── -->
|
||||
<div class="modal-overlay hidden" id="templates-modal">
|
||||
<div class="modal" style="max-width:720px;width:100%">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:20px">
|
||||
<h3>Agent Templates</h3>
|
||||
<button class="btn btn-ghost" onclick="closeTemplatesModal()">✕</button>
|
||||
</div>
|
||||
<div id="templates-list" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:12px;max-height:60vh;overflow-y:auto">
|
||||
<div style="color:var(--text-dim);font-size:13px">Loading…</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
97
server/web/templates/audit.html
Normal file
97
server/web/templates/audit.html
Normal file
@@ -0,0 +1,97 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ agent_name }} — Audit Log{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:20px">
|
||||
<h1 style="margin:0">Audit Log</h1>
|
||||
<div style="display:flex;gap:8px">
|
||||
<button class="btn btn-ghost btn-small" onclick="clearAuditLog(30)">Delete >30 days</button>
|
||||
<button class="btn btn-ghost btn-small" onclick="clearAuditLog(90)">Delete >90 days</button>
|
||||
<button class="btn btn-danger btn-small" onclick="clearAuditLog(0)">Empty log</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="filter-bar">
|
||||
<div class="form-group" style="margin:0">
|
||||
<label>From</label>
|
||||
<input type="text" id="filter-start" class="form-input" placeholder="dd.mm.yyyy HH:MM" style="width:160px">
|
||||
</div>
|
||||
<div class="form-group" style="margin:0">
|
||||
<label>To</label>
|
||||
<input type="text" id="filter-end" class="form-input" placeholder="dd.mm.yyyy HH:MM" style="width:160px">
|
||||
</div>
|
||||
<div class="form-group" style="margin:0">
|
||||
<label>Tool</label>
|
||||
<input type="text" id="filter-tool" class="form-input" placeholder="e.g. email">
|
||||
</div>
|
||||
<div class="form-group" style="margin:0">
|
||||
<label>Session ID</label>
|
||||
<input type="text" id="filter-session" class="form-input" placeholder="prefix…">
|
||||
</div>
|
||||
<div class="form-group" style="margin:0;display:flex;align-items:flex-end;gap:8px">
|
||||
<label style="display:flex;align-items:center;gap:6px;cursor:pointer">
|
||||
<input type="checkbox" id="filter-confirmed"> Confirmed only
|
||||
</label>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;align-items:flex-end">
|
||||
<button class="btn btn-primary" id="filter-btn">Filter</button>
|
||||
<button class="btn btn-ghost" id="filter-reset">Reset</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Run filter banner (shown when navigating from agent run detail) -->
|
||||
<div id="audit-task-filter-banner" style="display:none;margin-bottom:12px;padding:8px 14px;background:rgba(var(--accent-rgb,100,140,255),0.1);border:1px solid var(--border);border-radius:var(--radius);font-size:13px;color:var(--text-dim)">
|
||||
Filtered by run ID — click <strong>Reset</strong> to clear.
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<div class="table-wrap">
|
||||
<table id="audit-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Timestamp</th>
|
||||
<th>Tool</th>
|
||||
<th>Arguments</th>
|
||||
<th>Result</th>
|
||||
<th>Confirmed</th>
|
||||
<th>Session</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td colspan="6" style="text-align:center;color:var(--text-dim)">Loading…</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div class="pagination" id="audit-pagination"></div>
|
||||
</div>
|
||||
|
||||
<!-- Audit entry detail modal -->
|
||||
<div class="modal-overlay" id="audit-detail-modal" style="display:none" onclick="if(event.target===this)closeAuditDetail()">
|
||||
<div class="modal" style="max-width:680px;width:100%">
|
||||
<div class="modal-header">
|
||||
<h3 id="audit-detail-tool" style="font-family:monospace;font-size:15px"></h3>
|
||||
<button class="btn btn-ghost btn-small" onclick="closeAuditDetail()">✕</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px 24px;margin-bottom:16px;font-size:13px">
|
||||
<div><span style="color:var(--text-dim)">Timestamp</span><br><span id="audit-detail-ts"></span></div>
|
||||
<div><span style="color:var(--text-dim)">Session</span><br><code id="audit-detail-session"></code></div>
|
||||
<div><span style="color:var(--text-dim)">Confirmed</span><br><span id="audit-detail-confirmed"></span></div>
|
||||
<div><span style="color:var(--text-dim)">Task ID</span><br><code id="audit-detail-task" style="font-size:11px;word-break:break-all"></code></div>
|
||||
</div>
|
||||
<div style="margin-bottom:12px">
|
||||
<div style="font-size:11px;text-transform:uppercase;letter-spacing:.06em;color:var(--text-dim);margin-bottom:6px;font-weight:600">Arguments</div>
|
||||
<pre id="audit-detail-args" style="background:var(--bg2);border:1px solid var(--border);border-radius:var(--radius);padding:10px 14px;font-size:12px;overflow-x:auto;white-space:pre-wrap;word-break:break-all;max-height:240px;overflow-y:auto;margin:0"></pre>
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-size:11px;text-transform:uppercase;letter-spacing:.06em;color:var(--text-dim);margin-bottom:6px;font-weight:600">Result</div>
|
||||
<pre id="audit-detail-result" style="background:var(--bg2);border:1px solid var(--border);border-radius:var(--radius);padding:10px 14px;font-size:12px;overflow-x:auto;white-space:pre-wrap;word-break:break-all;max-height:240px;overflow-y:auto;margin:0"></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
105
server/web/templates/base.html
Normal file
105
server/web/templates/base.html
Normal file
@@ -0,0 +1,105 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}{{ brand_name }}{% endblock %}</title>
|
||||
<link rel="icon" type="image/png" href="/static/icon.png">
|
||||
<link rel="stylesheet" href="/static/style.css?v={{ sv }}">
|
||||
{% if theme_css %}<style>{{ theme_css | safe }}</style>{% endif %}
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- ── Sidebar ── -->
|
||||
<nav class="sidebar">
|
||||
<div class="sidebar-logo">
|
||||
<img src="{{ logo_url }}" alt="logo" class="sidebar-logo-img">
|
||||
<div class="sidebar-logo-text">
|
||||
<div class="sidebar-logo-name">{{ brand_name }}</div>
|
||||
<div class="sidebar-logo-app">oAI-Web <span class="sidebar-logo-version">v1.0</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a class="nav-item" data-page="/" href="/">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
|
||||
Chat
|
||||
</a>
|
||||
<a class="nav-item" data-page="/chats" href="/chats">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/><line x1="9" y1="10" x2="15" y2="10"/><line x1="9" y1="14" x2="13" y2="14"/></svg>
|
||||
Chats
|
||||
</a>
|
||||
<a class="nav-item" data-page="/agents" href="/agents">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="8" r="4"/><path d="M20 21a8 8 0 1 0-16 0"/><circle cx="19" cy="6" r="3"/><circle cx="5" cy="6" r="3"/></svg>
|
||||
Agents
|
||||
</a>
|
||||
<a class="nav-item" data-page="/models" href="/models">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="4" y="4" width="16" height="16" rx="2"/><rect x="9" y="9" width="6" height="6"/><line x1="9" y1="1" x2="9" y2="4"/><line x1="15" y1="1" x2="15" y2="4"/><line x1="9" y1="20" x2="9" y2="23"/><line x1="15" y1="20" x2="15" y2="23"/><line x1="20" y1="9" x2="23" y2="9"/><line x1="20" y1="14" x2="23" y2="14"/><line x1="1" y1="9" x2="4" y2="9"/><line x1="1" y1="14" x2="4" y2="14"/></svg>
|
||||
Models
|
||||
</a>
|
||||
<a class="nav-item" data-page="/files" href="/files">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
|
||||
Files
|
||||
</a>
|
||||
<a class="nav-item" data-page="/audit" href="/audit">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>
|
||||
Audit Log
|
||||
</a>
|
||||
<a class="nav-item" data-page="/help" href="/help">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
|
||||
Help
|
||||
</a>
|
||||
{% if current_user and current_user.is_admin %}
|
||||
<a class="nav-item" data-page="/admin/users" href="/admin/users">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
|
||||
Users
|
||||
</a>
|
||||
{% endif %}
|
||||
<a class="nav-item" data-page="/settings" href="/settings">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
|
||||
Settings
|
||||
</a>
|
||||
|
||||
<div class="sidebar-bottom">
|
||||
{% if current_user and current_user.is_admin %}
|
||||
<button id="pause-btn" onclick="togglePause()" title="Pause agent">
|
||||
<span class="btn-icon">⏸</span>
|
||||
<span class="btn-label">Pause</span>
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if current_user %}
|
||||
<div class="sidebar-user">
|
||||
<span class="sidebar-user-name" title="{{ current_user.username }}">{{ current_user.username }}</span>
|
||||
<a href="/logout" class="sidebar-user-logout" title="Sign out">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
|
||||
</a>
|
||||
</div>
|
||||
<div class="sidebar-logo-app">
|
||||
<span style="padding-left: 10%;">© 2026 Rune Olsen</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- ── Main column (nag + content) ── -->
|
||||
<div style="flex:1;display:flex;flex-direction:column;overflow:hidden">
|
||||
|
||||
{% if needs_personality_setup %}
|
||||
<div class="nag-banner" id="nag-banner">
|
||||
<span>Your profile isn't set up yet — the assistant doesn't know who you are.
|
||||
<a href="/settings?tab=personality" class="nag-link">Set it up now</a>
|
||||
</span>
|
||||
<button class="nag-dismiss" onclick="dismissNag()" title="Dismiss">×</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- ── Main content ── -->
|
||||
<main class="main" id="main">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
</div>
|
||||
|
||||
<script>window.AGENT_NAME = "{{ agent_name }}";</script>
|
||||
<script src="/static/app.js?v={{ sv }}"></script>
|
||||
{% block extra_scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
94
server/web/templates/chat.html
Normal file
94
server/web/templates/chat.html
Normal file
@@ -0,0 +1,94 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ agent_name }} — Chat{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="chat-container">
|
||||
|
||||
<!-- Messages -->
|
||||
<div class="chat-messages" id="chat-messages">
|
||||
<div class="message assistant">
|
||||
<div class="message-bubble">
|
||||
Hello. What can I help you with?
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Image attachment preview strip -->
|
||||
<div id="attach-preview" style="display:none;padding:6px 12px 2px;gap:8px;flex-wrap:wrap;
|
||||
align-items:center;border-top:1px solid var(--border)"></div>
|
||||
|
||||
<!-- Input bar -->
|
||||
<div class="chat-input-wrap">
|
||||
<input id="img-file-input" type="file" multiple
|
||||
accept="image/jpeg,image/png,image/gif,image/webp,image/avif,application/pdf"
|
||||
style="display:none">
|
||||
<button id="attach-btn" class="btn btn-ghost" title="Attach file(s)"
|
||||
onclick="document.getElementById('img-file-input').click()"
|
||||
style="display:none;padding:6px 8px;font-size:16px;line-height:1">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||
width="16" height="16" style="vertical-align:-2px">
|
||||
<path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 18 8.84l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48"/>
|
||||
</svg>
|
||||
</button>
|
||||
<textarea
|
||||
id="chat-input"
|
||||
class="chat-input"
|
||||
placeholder="Message {{ agent_name }}… (Enter to send, Shift+Enter for newline)"
|
||||
rows="1"
|
||||
autofocus
|
||||
></textarea>
|
||||
<button class="btn btn-primary" id="send-btn">Send</button>
|
||||
<button class="btn btn-ghost" id="clear-btn" title="Clear conversation">✕</button>
|
||||
</div>
|
||||
|
||||
<!-- Status bar -->
|
||||
<div class="status-bar">
|
||||
<span class="status-dot" id="status-dot"></span>
|
||||
<span id="status-label">Connecting…</span>
|
||||
<span style="margin-left:auto;display:flex;align-items:center;gap:8px">
|
||||
<span id="model-caps" style="display:flex;gap:4px;align-items:center"></span>
|
||||
<button id="model-btn" onclick="openModelPicker()" title="Change model" style="
|
||||
background:var(--bg2);border:1px solid var(--border);color:var(--text-dim);
|
||||
border-radius:4px;padding:2px 10px 2px 8px;font-size:11px;cursor:pointer;
|
||||
max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
|
||||
font-family:var(--font);
|
||||
">Loading…</button>
|
||||
<span style="color:var(--text-dim);font-size:11px">Session: <code>{{ session_id[:8] }}</code></span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Model picker modal -->
|
||||
<div class="modal-overlay hidden" id="model-picker-modal" onclick="if(event.target===this)closeModelPicker()">
|
||||
<div class="modal" style="max-width:480px;width:90%;display:flex;flex-direction:column;max-height:75vh;padding:20px">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:14px">
|
||||
<h3 style="font-size:15px;font-weight:600">Select Model</h3>
|
||||
<button class="btn btn-ghost" style="padding:3px 8px;font-size:12px" onclick="closeModelPicker()">✕</button>
|
||||
</div>
|
||||
<input id="model-picker-search" type="text" class="form-input"
|
||||
placeholder="Search models…" autocomplete="off"
|
||||
style="margin-bottom:10px;flex-shrink:0"
|
||||
oninput="_renderModelPicker(this.value)">
|
||||
<div id="model-picker-list" style="overflow-y:auto;flex:1;margin:0 -4px"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Confirmation modal -->
|
||||
<div class="modal-overlay hidden" id="confirm-modal">
|
||||
<div class="modal">
|
||||
<h3>⚠ Confirm action: <span id="confirm-tool-name"></span></h3>
|
||||
<div class="modal-desc" id="confirm-description"></div>
|
||||
<div class="modal-buttons">
|
||||
<button class="btn btn-ghost" onclick="respondConfirm(false)">Deny</button>
|
||||
<button class="btn btn-primary" onclick="respondConfirm(true)">Approve</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script>
|
||||
window.SESSION_ID = "{{ session_id }}";
|
||||
</script>
|
||||
{% endblock %}
|
||||
243
server/web/templates/chats.html
Normal file
243
server/web/templates/chats.html
Normal file
@@ -0,0 +1,243 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Chats — {{ agent_name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page">
|
||||
|
||||
<!-- Header -->
|
||||
<div style="display:flex;align-items:center;gap:10px;margin-bottom:20px;flex-wrap:wrap">
|
||||
<h2 style="font-size:18px;font-weight:600;flex:1;min-width:120px">Chat History</h2>
|
||||
<input id="chats-search" type="text" class="form-input" placeholder="Search…"
|
||||
style="width:220px;height:34px;padding:5px 12px;font-size:13px"
|
||||
oninput="chatsSearch(this.value)">
|
||||
<button class="btn btn-ghost btn-small" id="sel-all-btn" onclick="toggleSelectAll()" style="display:none">Select all</button>
|
||||
<button class="btn btn-ghost btn-small" id="del-sel-btn" onclick="deleteSelected()"
|
||||
style="display:none;color:var(--danger,#dc3c3c)">Delete selected (<span id="sel-count">0</span>)</button>
|
||||
<button class="btn btn-ghost btn-small" id="del-all-btn" onclick="deleteAll()"
|
||||
style="color:var(--danger,#dc3c3c)">Delete all</button>
|
||||
<a class="btn btn-primary btn-small" href="/"
|
||||
onclick="localStorage.removeItem('current_session_id')">+ New Chat</a>
|
||||
</div>
|
||||
|
||||
<!-- List -->
|
||||
<div id="chats-list"><p style="color:var(--text-dim)">Loading…</p></div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div id="chats-pagination" style="display:flex;justify-content:center;gap:8px;margin-top:20px"></div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Rename modal -->
|
||||
<div class="modal-overlay hidden" id="rename-modal" onclick="if(event.target===this)closeRenameModal()">
|
||||
<div class="modal" style="max-width:420px;width:90%">
|
||||
<h3 style="font-size:15px;font-weight:600;margin-bottom:12px">Rename chat</h3>
|
||||
<input id="rename-input" type="text" class="form-input" style="margin-bottom:14px"
|
||||
placeholder="Chat title" maxlength="120">
|
||||
<div class="modal-buttons">
|
||||
<button class="btn btn-ghost" onclick="closeRenameModal()">Cancel</button>
|
||||
<button class="btn btn-primary" onclick="submitRename()">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script>
|
||||
let _chatsPage = 1;
|
||||
let _chatsQ = "";
|
||||
let _renameId = null;
|
||||
let _allIds = []; // ids on the current page
|
||||
let _selected = new Set();
|
||||
let _chatMap = {}; // id -> { id, title } for current page
|
||||
|
||||
function escHtml(s) {
|
||||
return String(s).replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""");
|
||||
}
|
||||
|
||||
/* ── Load & render ─────────────────────────────────────────────────────── */
|
||||
|
||||
async function loadChats(page) {
|
||||
page = page || _chatsPage;
|
||||
_chatsPage = page;
|
||||
_selected.clear();
|
||||
updateSelectionUI();
|
||||
const list = document.getElementById("chats-list");
|
||||
list.innerHTML = "<p style='color:var(--text-dim)'>Loading…</p>";
|
||||
try {
|
||||
const params = new URLSearchParams({ page, per_page: 40 });
|
||||
if (_chatsQ) params.set("q", _chatsQ);
|
||||
const r = await fetch("/api/conversations?" + params);
|
||||
if (!r.ok) throw new Error(await r.text());
|
||||
renderChats(await r.json());
|
||||
} catch(e) {
|
||||
list.innerHTML = `<p style='color:var(--danger)'>Failed to load: ${e.message}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
function chatsSearch(q) {
|
||||
_chatsQ = q;
|
||||
_chatsPage = 1;
|
||||
clearTimeout(chatsSearch._t);
|
||||
chatsSearch._t = setTimeout(() => loadChats(1), 300);
|
||||
}
|
||||
|
||||
function renderChats(data) {
|
||||
const list = document.getElementById("chats-list");
|
||||
_allIds = data.conversations.map(c => c.id);
|
||||
_chatMap = {};
|
||||
data.conversations.forEach(c => { _chatMap[c.id] = { id: c.id, title: c.title || "Untitled chat" }; });
|
||||
|
||||
if (!data.conversations.length) {
|
||||
list.innerHTML = "<p style='color:var(--text-dim)'>No saved chats yet. Start a conversation and it will appear here automatically.</p>";
|
||||
document.getElementById("chats-pagination").innerHTML = "";
|
||||
document.getElementById("sel-all-btn").style.display = "none";
|
||||
return;
|
||||
}
|
||||
|
||||
document.getElementById("sel-all-btn").style.display = "";
|
||||
|
||||
list.innerHTML = data.conversations.map(c => {
|
||||
const title = c.title || "Untitled chat";
|
||||
const date = c.ended_at ? formatDate(c.ended_at) : "—";
|
||||
const turns = Math.floor((c.message_count || 0) / 2);
|
||||
const model = c.model
|
||||
? `<span style="font-size:11px;color:var(--text-dim);background:var(--bg2);border:1px solid var(--border);border-radius:3px;padding:1px 6px;white-space:nowrap">${escHtml(c.model)}</span>`
|
||||
: "";
|
||||
const turnLabel = turns
|
||||
? `<span style="font-size:12px;color:var(--text-dim)">${turns} turn${turns !== 1 ? "s" : ""}</span>`
|
||||
: "";
|
||||
return `
|
||||
<div class="chat-row" data-id="${c.id}"
|
||||
style="display:flex;align-items:center;gap:12px;padding:12px 14px;
|
||||
border:1px solid var(--border);border-radius:var(--radius);
|
||||
margin-bottom:8px;background:var(--bg1)">
|
||||
<input type="checkbox" class="chat-chk" data-id="${c.id}"
|
||||
style="flex-shrink:0;width:15px;height:15px;cursor:pointer;accent-color:var(--accent)"
|
||||
onchange="onCheckChange(this)">
|
||||
<div style="flex:1;min-width:0">
|
||||
<div style="font-size:14px;font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;margin-bottom:3px"
|
||||
title="${escHtml(title)}">${escHtml(title)}</div>
|
||||
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
|
||||
<span style="font-size:12px;color:var(--text-dim)">${date}</span>
|
||||
${turnLabel}
|
||||
${model}
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:6px;flex-shrink:0">
|
||||
<a class="btn btn-ghost btn-small" href="/?session=${c.id}"
|
||||
data-id="${c.id}" onclick="localStorage.setItem('current_session_id',this.dataset.id)">Open</a>
|
||||
<button class="btn btn-ghost btn-small"
|
||||
data-id="${c.id}" onclick="openRenameModal(this.dataset.id)">Rename</button>
|
||||
<button class="btn btn-ghost btn-small" style="color:var(--danger,#dc3c3c)"
|
||||
data-id="${c.id}" onclick="deleteSingle(this.dataset.id)">Delete</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join("");
|
||||
|
||||
// Pagination
|
||||
const pages = Math.ceil(data.total / data.per_page);
|
||||
const pg = document.getElementById("chats-pagination");
|
||||
if (pages <= 1) { pg.innerHTML = ""; return; }
|
||||
let html = "";
|
||||
for (let i = 1; i <= pages; i++) {
|
||||
html += `<button class="btn btn-ghost btn-small${i === _chatsPage ? " btn-active" : ""}"
|
||||
onclick="loadChats(${i})">${i}</button>`;
|
||||
}
|
||||
pg.innerHTML = html;
|
||||
}
|
||||
|
||||
/* ── Selection ─────────────────────────────────────────────────────────── */
|
||||
|
||||
function onCheckChange(cb) {
|
||||
if (cb.checked) _selected.add(cb.dataset.id);
|
||||
else _selected.delete(cb.dataset.id);
|
||||
updateSelectionUI();
|
||||
}
|
||||
|
||||
function toggleSelectAll() {
|
||||
const allSelected = _allIds.every(id => _selected.has(id));
|
||||
if (allSelected) {
|
||||
_selected.clear();
|
||||
} else {
|
||||
_allIds.forEach(id => _selected.add(id));
|
||||
}
|
||||
document.querySelectorAll(".chat-chk").forEach(cb => {
|
||||
cb.checked = _selected.has(cb.dataset.id);
|
||||
});
|
||||
updateSelectionUI();
|
||||
}
|
||||
|
||||
function updateSelectionUI() {
|
||||
const n = _selected.size;
|
||||
const allSelected = _allIds.length > 0 && _allIds.every(id => _selected.has(id));
|
||||
document.getElementById("sel-all-btn").textContent = allSelected ? "Deselect all" : "Select all";
|
||||
const delSelBtn = document.getElementById("del-sel-btn");
|
||||
delSelBtn.style.display = n > 0 ? "" : "none";
|
||||
document.getElementById("sel-count").textContent = n;
|
||||
}
|
||||
|
||||
/* ── Delete actions ────────────────────────────────────────────────────── */
|
||||
|
||||
async function deleteSingle(id) {
|
||||
const title = (_chatMap[id] || {}).title || id;
|
||||
if (!confirm(`Delete "${title}"?\nThis cannot be undone.`)) return;
|
||||
const r = await fetch("/api/conversations/" + id, { method: "DELETE" });
|
||||
if (r.ok) { _selected.delete(id); loadChats(); }
|
||||
else alert("Failed to delete.");
|
||||
}
|
||||
|
||||
async function deleteSelected() {
|
||||
const ids = [..._selected];
|
||||
if (!ids.length) return;
|
||||
if (!confirm(`Delete ${ids.length} selected chat${ids.length !== 1 ? "s" : ""}?\nThis cannot be undone.`)) return;
|
||||
await Promise.all(ids.map(id => fetch("/api/conversations/" + id, { method: "DELETE" })));
|
||||
_selected.clear();
|
||||
loadChats();
|
||||
}
|
||||
|
||||
async function deleteAll() {
|
||||
if (!confirm("Delete ALL saved chats?\nThis cannot be undone.")) return;
|
||||
// Fetch all IDs (no pagination limit) then delete
|
||||
const r = await fetch("/api/conversations?per_page=10000");
|
||||
const data = await r.json();
|
||||
await Promise.all(data.conversations.map(c => fetch("/api/conversations/" + c.id, { method: "DELETE" })));
|
||||
loadChats();
|
||||
}
|
||||
|
||||
/* ── Rename ────────────────────────────────────────────────────────────── */
|
||||
|
||||
function openRenameModal(id) {
|
||||
const chat = _chatMap[id];
|
||||
if (!chat) return;
|
||||
_renameId = id;
|
||||
document.getElementById("rename-input").value = chat.title;
|
||||
document.getElementById("rename-modal").classList.remove("hidden");
|
||||
setTimeout(() => document.getElementById("rename-input").select(), 50);
|
||||
}
|
||||
|
||||
function closeRenameModal() {
|
||||
document.getElementById("rename-modal").classList.add("hidden");
|
||||
_renameId = null;
|
||||
}
|
||||
|
||||
async function submitRename() {
|
||||
if (!_renameId) return;
|
||||
const title = document.getElementById("rename-input").value.trim();
|
||||
if (!title) return;
|
||||
const r = await fetch("/api/conversations/" + _renameId, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ title }),
|
||||
});
|
||||
if (r.ok) { closeRenameModal(); loadChats(); }
|
||||
else alert("Failed to rename.");
|
||||
}
|
||||
|
||||
document.getElementById("rename-input").addEventListener("keydown", e => {
|
||||
if (e.key === "Enter") submitRename();
|
||||
if (e.key === "Escape") closeRenameModal();
|
||||
});
|
||||
|
||||
loadChats();
|
||||
</script>
|
||||
{% endblock %}
|
||||
61
server/web/templates/files.html
Normal file
61
server/web/templates/files.html
Normal file
@@ -0,0 +1,61 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ agent_name }} — Files{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page">
|
||||
|
||||
<!-- Header -->
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:20px;gap:16px;flex-wrap:wrap">
|
||||
<h1 style="margin:0">Files</h1>
|
||||
<button id="dl-zip-btn" class="btn btn-primary btn-small" onclick="fileBrowserDownloadZip()"
|
||||
style="display:none">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||
width="14" height="14" style="vertical-align:-2px;margin-right:4px">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||
<polyline points="7 10 12 15 17 10"/>
|
||||
<line x1="12" y1="15" x2="12" y2="3"/>
|
||||
</svg>
|
||||
Download folder as ZIP
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Breadcrumb -->
|
||||
<div id="file-breadcrumb"
|
||||
style="display:flex;align-items:center;gap:4px;flex-wrap:wrap;
|
||||
font-size:13px;margin-bottom:16px;color:var(--text-dim)">
|
||||
</div>
|
||||
|
||||
<!-- No folder configured -->
|
||||
<div id="file-no-folder" style="display:none;padding:40px 0;text-align:center;color:var(--text-dim)">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"
|
||||
width="48" height="48" style="margin-bottom:12px;opacity:0.4"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
|
||||
<div style="font-size:15px;margin-bottom:6px">No files folder configured</div>
|
||||
<div style="font-size:13px">Ask your administrator to set up a files folder for your account.</div>
|
||||
</div>
|
||||
|
||||
<!-- File table -->
|
||||
<div id="file-table-wrap" class="table-wrap" style="display:none">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:32px"></th>
|
||||
<th>Name</th>
|
||||
<th style="width:100px;text-align:right">Size</th>
|
||||
<th style="width:170px">Modified</th>
|
||||
<th style="width:140px"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="file-tbody">
|
||||
<tr><td colspan="5" style="text-align:center;color:var(--text-dim)">Loading…</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Empty folder -->
|
||||
<div id="file-empty" style="display:none;padding:32px 0;text-align:center;color:var(--text-dim);font-size:13px">
|
||||
This folder is empty.
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
945
server/web/templates/help.html
Normal file
945
server/web/templates/help.html
Normal file
@@ -0,0 +1,945 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ agent_name }} - Help{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="help-layout">
|
||||
|
||||
<!-- ── Left: TOC sidebar ─────────────────────────────────────────────── -->
|
||||
<aside class="help-toc">
|
||||
<input
|
||||
type="search"
|
||||
class="form-input"
|
||||
placeholder="Search docs…"
|
||||
oninput="helpSearch(this.value)"
|
||||
style="margin-bottom:16px;font-size:12px;"
|
||||
>
|
||||
<ul class="toc-list">
|
||||
<li><a href="#getting-started">Getting Started</a></li>
|
||||
<li>
|
||||
<a href="#chat">Chat Interface</a>
|
||||
<ul>
|
||||
<li><a href="#chat-attachments">File Attachments</a></li>
|
||||
<li><a href="#chat-model-picker">Model Picker</a></li>
|
||||
<li><a href="#chat-badges">Capability Badges</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li><a href="#files">Files</a></li>
|
||||
<li>
|
||||
<a href="#agents">Agents</a>
|
||||
<ul>
|
||||
<li><a href="#agents-creating">Creating Agents</a></li>
|
||||
<li><a href="#agents-schedule">Scheduling</a></li>
|
||||
<li><a href="#agents-prompt-modes">Prompt Modes</a></li>
|
||||
<li><a href="#agents-tools">Tool Restrictions</a></li>
|
||||
<li><a href="#agents-subagents">Sub-agents</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li><a href="#mcp">MCP Servers</a></li>
|
||||
<li>
|
||||
<a href="#settings">Settings</a>
|
||||
<ul>
|
||||
{% if current_user and current_user.is_admin %}
|
||||
<li><a href="#settings-general">General</a></li>
|
||||
<li><a href="#settings-whitelists">Whitelists</a></li>
|
||||
<li><a href="#settings-credentials">Credentials</a></li>
|
||||
{% endif %}
|
||||
<li><a href="#settings-inbox">Inbox</a></li>
|
||||
<li><a href="#settings-emailaccounts">Email Accounts</a></li>
|
||||
<li><a href="#settings-telegram">Telegram</a></li>
|
||||
<li><a href="#settings-profile">Profile</a></li>
|
||||
{% if not (current_user and current_user.is_admin) %}
|
||||
<li><a href="#settings-caldav">CalDAV</a></li>
|
||||
{% endif %}
|
||||
<li><a href="#settings-personality">Personality</a></li>
|
||||
<li><a href="#settings-brain">2nd Brain</a></li>
|
||||
<li><a href="#settings-mcp">MCP Servers</a></li>
|
||||
{% if current_user and current_user.is_admin %}
|
||||
<li><a href="#settings-security">Security</a></li>
|
||||
<li><a href="#settings-branding">Branding</a></li>
|
||||
<li><a href="#settings-apikey">API Key</a></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</li>
|
||||
{% if current_user and current_user.is_admin %}
|
||||
<li><a href="#user-management">User Management</a></li>
|
||||
<li><a href="#credentials">Credential Key Reference</a></li>
|
||||
{% endif %}
|
||||
<li><a href="#api">REST API Reference</a></li>
|
||||
<li><a href="#security">Security Model</a></li>
|
||||
<li><a href="#messaging">Telegram & Email Inbox</a></li>
|
||||
</ul>
|
||||
</aside>
|
||||
|
||||
<!-- ── Right: content ────────────────────────────────────────────────── -->
|
||||
<div class="help-content">
|
||||
|
||||
<!-- ── 1. Getting Started ──────────────────────────────────────────── -->
|
||||
<section id="getting-started" data-section>
|
||||
<h1>Getting Started</h1>
|
||||
|
||||
<h2>What is oAI-Web?</h2>
|
||||
<p>
|
||||
oAI-Web (agent name: <strong>{{ agent_name }}</strong>) is a secure, self-hosted personal AI agent
|
||||
built on the Claude API with full tool-use support. It runs on your home server and exposes a clean
|
||||
web interface for use inside your local network. The agent can read email, browse the web, manage
|
||||
calendar events, read and write files, send push notifications, generate images, and more — all via
|
||||
a structured tool-use loop with optional confirmation prompts before side-effects.
|
||||
</p>
|
||||
|
||||
<h2>System Requirements</h2>
|
||||
<ul>
|
||||
<li>An API key for at least one AI provider: <strong>Anthropic</strong> or <strong>OpenRouter</strong></li>
|
||||
<li>Python 3.12+ (or Docker)</li>
|
||||
<li><strong>PostgreSQL</strong> (with asyncpg) — the main application database</li>
|
||||
<li>PostgreSQL + <strong>pgvector</strong> extension only required if you use the <em>2nd Brain</em> feature (can be the same server)</li>
|
||||
</ul>
|
||||
|
||||
<h2>First-Time Setup Checklist</h2>
|
||||
<ol>
|
||||
<li>Copy <code>.env.example</code> to <code>.env</code> and set <code>ANTHROPIC_API_KEY</code> (or <code>OPENROUTER_API_KEY</code>), <code>AIDE_DB_URL</code> (PostgreSQL connection string), and <code>DB_MASTER_PASSWORD</code></li>
|
||||
<li>Start the server: <code>python -m uvicorn server.main:app --host 0.0.0.0 --port 8080 --reload</code></li>
|
||||
<li>On first boot with zero users, you are redirected to <code>/setup</code> to create the first admin account</li>
|
||||
<li>Open <a href="/settings">Settings</a> → <strong>Credentials</strong> and add any additional credentials (CalDAV, email, Pushover, etc.)</li>
|
||||
<li>Add email recipients via <strong>Settings → Whitelists → Email Whitelist</strong></li>
|
||||
<li>Add filesystem directories via <strong>Settings → Whitelists → Filesystem Sandbox</strong> — the agent cannot touch any path outside these directories</li>
|
||||
<li>Optionally set <code>system:users_base_folder</code> in Credentials to enable per-user file storage (e.g. <code>/data/users</code>)</li>
|
||||
<li>Optionally configure email accounts and Telegram via their respective Settings tabs</li>
|
||||
</ol>
|
||||
|
||||
<h2>Key Concepts</h2>
|
||||
<dl>
|
||||
<dt>Agent</dt>
|
||||
<dd>A configured AI persona with a model, system prompt, optional schedule, and restricted tool set. Agents run headlessly — no confirmation prompts, results logged in run history.</dd>
|
||||
<dt>Tool</dt>
|
||||
<dd>A capability the AI can invoke: read a file, send an email, fetch a web page, generate an image, etc. Every tool call is logged in the Audit Log.</dd>
|
||||
<dt>Confirmation</dt>
|
||||
<dd>Before any side-effect tool (send email, write file, delete calendar event) executes in interactive chat, a modal asks you to approve or deny. Agents skip confirmations.</dd>
|
||||
<dt>Audit Log</dt>
|
||||
<dd>An append-only record of every tool call, its arguments, and outcome. Never auto-deleted unless you configure a retention period.</dd>
|
||||
<dt>Credential Store</dt>
|
||||
<dd>An AES-256-GCM encrypted key-value store in PostgreSQL. All secrets (API keys, passwords) live here — never in the agent's context window.</dd>
|
||||
<dt>User Folder</dt>
|
||||
<dd>When <code>system:users_base_folder</code> is set, each user gets a personal folder at <code>{base}/{username}/</code>. Agents and the Files page scope all file access to this folder automatically.</dd>
|
||||
</dl>
|
||||
|
||||
<h2>Quick Start</h2>
|
||||
<p>Navigate to the <a href="/">Chat</a> page, type a message and press <kbd>Enter</kbd>. The agent responds, uses tools as needed (you'll see spinning indicators), and may ask for confirmation before sending email or writing files.</p>
|
||||
</section>
|
||||
|
||||
<!-- ── 2. Chat Interface ───────────────────────────────────────────── -->
|
||||
<section id="chat" data-section>
|
||||
<h1>Chat Interface</h1>
|
||||
|
||||
<h2>Sending Messages</h2>
|
||||
<p>
|
||||
Press <kbd>Enter</kbd> to send. Use <kbd>Shift+Enter</kbd> for a newline within your message.
|
||||
The <strong>Clear History</strong> button (✕) in the status bar wipes the in-memory conversation for the current session — the agent starts fresh.
|
||||
</p>
|
||||
|
||||
<h2 id="chat-attachments">File Attachments</h2>
|
||||
<p>
|
||||
The <strong>paperclip button</strong> (📎) in the input bar opens a file picker. Only shown when the active model supports vision or documents. Supported formats:
|
||||
</p>
|
||||
<ul>
|
||||
<li><strong>Images</strong>: JPEG, PNG, GIF, WebP, AVIF — shown as thumbnails in the preview strip</li>
|
||||
<li><strong>PDF</strong>: shown as a file chip with the filename in the preview strip</li>
|
||||
</ul>
|
||||
<p>
|
||||
You can also <strong>paste images</strong> directly from the clipboard (Ctrl/Cmd+V in the chat input). Multiple files can be attached in one message. Remove any attachment by clicking the ✕ on its preview chip.
|
||||
</p>
|
||||
<p class="help-note">
|
||||
Attachments are sent inline with the message as base64-encoded data. Large files (especially PDFs) will increase the token count and cost of the request.
|
||||
</p>
|
||||
|
||||
<h2 id="chat-model-picker">Model Picker</h2>
|
||||
<p>
|
||||
The button in the status bar (bottom of the chat area) shows the currently active model. Click it to open a searchable modal listing all available models from all configured providers. Use arrow keys to navigate, <kbd>Enter</kbd> to select, <kbd>Esc</kbd> to close. Your selection is persisted in <code>localStorage</code> across page loads.
|
||||
</p>
|
||||
|
||||
<h2 id="chat-badges">Capability Badges</h2>
|
||||
<p>Small badges in the status bar show what the active model supports:</p>
|
||||
<ul>
|
||||
<li>🎨 <strong>Image Gen</strong> — can generate images (use via the <code>image_gen</code> tool in agents)</li>
|
||||
<li>👁 <strong>Vision</strong> — can read images and PDFs; the attachment button is shown</li>
|
||||
<li>🔧 <strong>Tools</strong> — supports tool/function calling</li>
|
||||
<li>🌐 <strong>Online</strong> — has live web access built in</li>
|
||||
</ul>
|
||||
|
||||
<h2>Tool Indicators</h2>
|
||||
<p>While the agent is working, small badges appear below each message:</p>
|
||||
<ul>
|
||||
<li><span style="color:var(--accent)">●</span> <strong>Pulsing blue</strong> — tool is currently running</li>
|
||||
<li><span style="color:var(--green)">●</span> <strong>Solid green</strong> — tool completed successfully</li>
|
||||
<li><span style="color:var(--red)">●</span> <strong>Solid red</strong> — tool failed or returned an error</li>
|
||||
</ul>
|
||||
|
||||
<h2>Confirmation Modal</h2>
|
||||
<p>
|
||||
When the agent wants to execute a side-effect tool (send email, write/delete a file, send a push notification), a yellow modal appears showing the tool name and arguments. Click <strong>Approve</strong> to proceed or <strong>Deny</strong> to block the action. The agent receives your decision and continues.
|
||||
</p>
|
||||
|
||||
<h2>Pausing the Agent</h2>
|
||||
<p>
|
||||
The <strong>Pause</strong> button in the sidebar is a global kill switch. While paused, no agent runs, scheduled tasks, inbox processing, or Telegram responses will execute. The button turns green and shows <strong>Resume</strong> when paused. Click it again to re-enable everything.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!-- ── 3. Files ────────────────────────────────────────────────────── -->
|
||||
<section id="files" data-section>
|
||||
<h1>Files</h1>
|
||||
<p>
|
||||
The <a href="/files">Files</a> page is a browser for your personal data folder (provisioned automatically when <code>system:users_base_folder</code> is configured by your admin). It lets you navigate, download, and delete files directly from the web UI.
|
||||
</p>
|
||||
|
||||
<h2>Browsing</h2>
|
||||
<ul>
|
||||
<li>Click a folder to enter it. Use the breadcrumb trail at the top to navigate back.</li>
|
||||
<li>Hidden files (names starting with <code>.</code>) are not shown.</li>
|
||||
<li>Columns show file size and last-modified timestamp.</li>
|
||||
</ul>
|
||||
|
||||
<h2>Downloading</h2>
|
||||
<ul>
|
||||
<li><strong>Download</strong> — downloads an individual file.</li>
|
||||
<li><strong>↓ ZIP</strong> — downloads an entire folder (and its contents) as a ZIP archive. The <strong>Download folder as ZIP</strong> button in the header always downloads the current folder.</li>
|
||||
</ul>
|
||||
|
||||
<h2>Deleting Files</h2>
|
||||
<p>
|
||||
A red <strong>Delete</strong> button appears next to downloadable files. Clicking it shows a confirmation dialog before the file is permanently removed. Deletion is instant and cannot be undone.
|
||||
</p>
|
||||
<p class="help-note">
|
||||
<strong>Protected files</strong>: files whose names start with <code>memory_</code> or <code>reasoning_</code> cannot be deleted from the UI. These are agent memory and decision logs maintained by email handling agents — deleting them would disrupt the agent's continuity.
|
||||
</p>
|
||||
|
||||
<h2>No Folder Configured?</h2>
|
||||
<p>
|
||||
If the Files page shows "No files folder configured", ask your administrator to set the <code>system:users_base_folder</code> credential to a base path (e.g. <code>/data/users</code>). Your personal folder at <code>{base}/{username}/</code> is created automatically.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!-- ── 4. Agents ───────────────────────────────────────────────────── -->
|
||||
<section id="agents" data-section>
|
||||
<h1>Agents</h1>
|
||||
<p>
|
||||
Agents are headless AI personas with a fixed system prompt, model, and optional cron schedule. Unlike interactive chat, agents run without confirmation modals — their allowed tools are declared at creation time. Results and token usage are logged per-run in the <a href="/agents">Agents</a> page.
|
||||
</p>
|
||||
<p class="help-note">
|
||||
Email handling agents (created automatically by Email Accounts setup) are hidden from the Agents list and Status tab. They are managed exclusively via <strong>Settings → Email Accounts</strong>.
|
||||
</p>
|
||||
|
||||
<h2 id="agents-creating">Creating an Agent</h2>
|
||||
<p>Click <strong>New Agent</strong> on the Agents page. Required fields:</p>
|
||||
<ul>
|
||||
<li><strong>Name</strong> — displayed in the UI and logs</li>
|
||||
<li><strong>Model</strong> — any model from a configured provider</li>
|
||||
<li><strong>Prompt</strong> — the agent's task description or system prompt (see Prompt Modes below)</li>
|
||||
</ul>
|
||||
<p>Optional fields:</p>
|
||||
<ul>
|
||||
<li><strong>Description</strong> — shown in the agent list for reference</li>
|
||||
<li><strong>Schedule</strong> — cron expression for automatic runs</li>
|
||||
<li><strong>Allowed Tools</strong> — restrict which tools the agent may use</li>
|
||||
<li><strong>Max Tool Calls</strong> — per-run limit (overrides the system default)</li>
|
||||
<li><strong>Sub-agents</strong> — toggle to allow this agent to create child agents</li>
|
||||
<li><strong>Prompt Mode</strong> — controls how the prompt is composed (see below)</li>
|
||||
</ul>
|
||||
|
||||
<h2 id="agents-schedule">Scheduling</h2>
|
||||
<p>Enter a cron expression in the <strong>Schedule</strong> field. The format is:</p>
|
||||
<pre>minute hour day-of-month month day-of-week</pre>
|
||||
<p>Examples:</p>
|
||||
<ul>
|
||||
<li><code>0 8 * * 1-5</code> — weekdays at 08:00</li>
|
||||
<li><code>*/15 * * * *</code> — every 15 minutes</li>
|
||||
<li><code>0 9 * * 1</code> — every Monday at 09:00</li>
|
||||
<li><code>30 18 * * *</code> — every day at 18:30</li>
|
||||
</ul>
|
||||
<p>
|
||||
Use the <strong>Enable / Disable</strong> toggle to pause a schedule without deleting the agent.
|
||||
The <strong>Run Now</strong> button triggers an immediate run regardless of schedule.
|
||||
</p>
|
||||
|
||||
<h2 id="agents-prompt-modes">Prompt Modes</h2>
|
||||
<p>Three modes control how the agent prompt is combined with the standard system prompt (SOUL.md + security rules):</p>
|
||||
<dl>
|
||||
<dt>Combined <em>(default)</em></dt>
|
||||
<dd>The agent prompt is prepended as the highest-priority instruction. The standard system prompt (SOUL.md, date/time, USER.md, security rules) is appended after. Best for most agents.</dd>
|
||||
<dt>System only</dt>
|
||||
<dd>The standard system prompt is used as-is; the agent prompt becomes the task message sent to the agent. Useful when you want {{ agent_name }}'s full personality but just need to specify a recurring task.</dd>
|
||||
<dt>Agent only</dt>
|
||||
<dd>The agent prompt <em>fully replaces</em> the system prompt — no SOUL.md, no security rules, no USER.md context. Use with caution. Suitable for specialized agents with a completely different persona.</dd>
|
||||
</dl>
|
||||
|
||||
<h2 id="agents-tools">Tool Restrictions</h2>
|
||||
<p>
|
||||
Leave <strong>Allowed Tools</strong> blank to give the agent access to all tools. Select specific tools to restrict — only those tool schemas are sent to the model, making it structurally impossible to use undeclared tools.
|
||||
</p>
|
||||
<p>
|
||||
MCP server tools appear as a single server-level toggle (e.g. <code>Gitea MCP</code>), which enables all tools from that server. Individual built-in tools are listed separately.
|
||||
</p>
|
||||
<p>Follow the <strong>least-privilege principle</strong>: give each agent only the tools it actually needs.</p>
|
||||
|
||||
<h2 id="agents-subagents">Sub-agents</h2>
|
||||
<p>
|
||||
Enable the <strong>Sub-agents</strong> toggle to give an agent access to the <code>create_subagent</code> tool. This allows the agent to spin up child agents to handle parallel or specialized tasks. Sub-agents run synchronously (the parent waits for the child to finish) and are logged separately in run history with <code>parent_agent_id</code> set.
|
||||
</p>
|
||||
|
||||
<h2>Image Generation in Agents</h2>
|
||||
<p>
|
||||
Agents can generate images using the <code>image_gen</code> tool. Important: the <strong>agent model must be a text/tool-use model</strong> (e.g. Claude Sonnet), not an image-generation model. The <code>image_gen</code> tool calls the image-gen model internally, saves the result to disk, and returns the file path. The default image-gen model is <code>openrouter:openai/gpt-5-image</code> — override via the <code>system:default_image_gen_model</code> credential.
|
||||
</p>
|
||||
<p>
|
||||
Generated images are saved to the agent's user folder. The file path is returned as the tool result so the agent can reference it.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!-- ── 5. MCP Servers ─────────────────────────────────────────────── -->
|
||||
<section id="mcp" data-section>
|
||||
<h1>MCP Servers</h1>
|
||||
<p>
|
||||
<strong>MCP (Model Context Protocol)</strong> is an open protocol for exposing tools to AI models over a network. oAI-Web can connect to external MCP servers and use their tools exactly like built-in tools. Tool names are namespaced as <code>mcp__{server}__{tool}</code>.
|
||||
</p>
|
||||
|
||||
<h2>Requirements for a Compatible MCP Server</h2>
|
||||
<p>To be compatible with oAI-Web, an MCP server must:</p>
|
||||
<ul>
|
||||
<li>Expose an SSE endpoint at <code>/sse</code></li>
|
||||
<li>Use <strong>SSE transport</strong> (not stdio)</li>
|
||||
<li>Be compatible with <code>mcp==1.26.*</code></li>
|
||||
<li>If built with Python FastMCP: use <code>uvicorn.run(mcp.sse_app(), host=..., port=...)</code> — <strong>not</strong> <code>mcp.run(host=..., port=...)</code> (the latter ignores <code>host</code>/<code>port</code> in mcp 1.26)</li>
|
||||
<li>If connecting from a non-localhost IP (e.g. <code>192.168.x.x</code>): disable DNS rebinding protection:
|
||||
<pre>from mcp.server.transport_security import TransportSecuritySettings
|
||||
mcp = FastMCP(
|
||||
"my-server",
|
||||
transport_security=TransportSecuritySettings(
|
||||
enable_dns_rebinding_protection=False
|
||||
),
|
||||
)</pre>
|
||||
Without this, the server rejects requests with a <code>421 Misdirected Request</code> error.
|
||||
</li>
|
||||
<li>oAI-Web connects per-call (open → use → close), <em>not</em> persistent — the server must handle this gracefully</li>
|
||||
</ul>
|
||||
|
||||
<h2>Adding an MCP Server</h2>
|
||||
<ol>
|
||||
<li>Go to <strong>Settings → MCP Servers</strong></li>
|
||||
<li>Click <strong>Add Server</strong></li>
|
||||
<li>Enter:
|
||||
<ul>
|
||||
<li><strong>Name</strong> — display name; also used for tool namespacing (slugified)</li>
|
||||
<li><strong>URL</strong> — full SSE endpoint, e.g. <code>http://192.168.1.72:8812/sse</code></li>
|
||||
<li><strong>Transport</strong> — select <code>sse</code></li>
|
||||
<li><strong>API Key</strong> — optional bearer token if the server requires authentication</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>Click <strong>Save</strong></li>
|
||||
</ol>
|
||||
<p>oAI-Web will immediately attempt to connect and discover tools. The tool count is shown in the server list.</p>
|
||||
|
||||
<h2>Tool Namespacing</h2>
|
||||
<p>
|
||||
A server named <code>Gitea MCP</code> (slugified: <code>gitea_mcp</code>) exposes tools as <code>mcp__gitea_mcp__list_repos</code>, <code>mcp__gitea_mcp__create_issue</code>, etc.
|
||||
In the agent tool picker, the entire server appears as a single toggle — enabling it grants access to all of its tools.
|
||||
</p>
|
||||
|
||||
<h2>Refreshing Tool Discovery</h2>
|
||||
<p>
|
||||
Click <strong>Refresh</strong> on any server in <strong>Settings → MCP Servers</strong> to re-discover tools without restarting oAI-Web. Useful after adding new tools to an MCP server.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!-- ── 6. Settings Walkthrough ────────────────────────────────────── -->
|
||||
<section id="settings" data-section>
|
||||
<h1>Settings</h1>
|
||||
|
||||
{% if current_user and current_user.is_admin %}
|
||||
<h2 id="settings-general">General <small style="font-weight:400;font-size:12px;color:var(--text-dim)">(Admin)</small></h2>
|
||||
<ul>
|
||||
<li><strong>Agent Control</strong>: Pause / Resume the global kill switch</li>
|
||||
<li><strong>Runtime Limits</strong>: Max Tool Calls (per run) and Max Autonomous Runs per Hour — stored in the credential store for live override without restart</li>
|
||||
<li><strong>Trusted Proxy IPs</strong>: Comma-separated IPs for <code>X-Forwarded-For</code> trust (requires restart)</li>
|
||||
<li><strong>Users Base Folder</strong>: Set <code>system:users_base_folder</code> to an absolute path (e.g. <code>/data/users</code>) to enable per-user file storage. Each user's folder at <code>{base}/{username}/</code> is created automatically.</li>
|
||||
<li><strong>Audit Log Retention</strong>: Set a retention period in days (0 = keep forever); manual clear available</li>
|
||||
</ul>
|
||||
|
||||
<h2 id="settings-whitelists">Whitelists <small style="font-weight:400;font-size:12px;color:var(--text-dim)">(Admin)</small></h2>
|
||||
<ul>
|
||||
<li><strong>Email Whitelist</strong>: Only addresses on this list can receive emails from {{ agent_name }}. Supports per-address daily send limits.</li>
|
||||
<li><strong>Web Whitelist (Tier 1)</strong>: Domains always accessible to the agent, regardless of session type. Subdomains are automatically included (e.g. <code>wikipedia.org</code> covers <code>en.wikipedia.org</code>).</li>
|
||||
<li><strong>Filesystem Sandbox</strong>: Absolute paths the agent may read/write. The agent cannot access any path outside these directories (unless it falls within a user's personal folder). Add directories before using filesystem tools.</li>
|
||||
</ul>
|
||||
|
||||
<h2 id="settings-credentials">Credentials <small style="font-weight:400;font-size:12px;color:var(--text-dim)">(Admin)</small></h2>
|
||||
<p>
|
||||
All secrets (API keys, passwords, app settings) are stored in an AES-256-GCM encrypted PostgreSQL table. Keys use a <code>namespace:key</code> convention. See the <a href="#credentials">Credential Key Reference</a> for a full list.
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
<h2 id="settings-inbox">Inbox</h2>
|
||||
<p>
|
||||
The Inbox tab manages <strong>trigger rules</strong> for the legacy global IMAP/SMTP account. For per-user or multi-account email handling, use the <strong>Email Accounts</strong> tab instead.
|
||||
</p>
|
||||
<ul>
|
||||
<li><strong>Trigger Rules</strong>: keyword phrases that, when matched in an incoming email subject/body, dispatch a specific agent and optionally send an auto-reply</li>
|
||||
<li>Matching is case-insensitive and order-independent — all tokens in the phrase must appear somewhere in the message</li>
|
||||
</ul>
|
||||
|
||||
<h2 id="settings-emailaccounts">Email Accounts</h2>
|
||||
<p>
|
||||
Email Accounts is the main email management area, separate from the legacy Inbox tab. Each account is independently configured with its own IMAP/SMTP credentials and an account type:
|
||||
</p>
|
||||
<dl>
|
||||
<dt>Trigger account</dt>
|
||||
<dd>Uses IMAP IDLE (instant push). Dispatches agents based on keyword trigger rules in incoming emails.</dd>
|
||||
<dt>Handling account</dt>
|
||||
<dd>Polls every 60 seconds. A dedicated AI agent reads and handles each email. The agent gets access to email, Telegram (optional), and filesystem tools scoped to the user's data folder.</dd>
|
||||
</dl>
|
||||
<p>For handling accounts, you can also configure:</p>
|
||||
<ul>
|
||||
<li><strong>Extra tools</strong>: optionally give the handling agent access to Telegram and/or Pushover for notifications</li>
|
||||
<li><strong>Telegram keyword</strong>: a short slug (e.g. <code>work</code>) that creates a <code>/work</code> Telegram command. Send <code>/work <message></code> to interact with the email agent via Telegram. Built-in sub-commands: <code>/work pause</code>, <code>/work resume</code>, <code>/work status</code></li>
|
||||
<li><strong>Pause / Resume</strong>: temporarily suspend a handling account without disabling it entirely. Also available via the Telegram <code>/keyword pause</code> command</li>
|
||||
</ul>
|
||||
<p class="help-note">
|
||||
The handling agent uses memory files (<code>memory_<username>.md</code>) and reasoning logs (<code>reasoning_<username>.md</code>) stored in the user's data folder to maintain continuity across email sessions. These files are visible in the Files page but cannot be deleted there.
|
||||
</p>
|
||||
|
||||
<h2 id="settings-telegram">Telegram</h2>
|
||||
<ul>
|
||||
<li><strong>Bot Token</strong>: your Telegram bot's API token (from @BotFather). Admins set a global token here; non-admin users can set their own per-user token in their Profile tab.</li>
|
||||
<li><strong>Chat ID Whitelist</strong>: only messages from listed chat IDs are processed</li>
|
||||
<li><strong>Default Agent</strong>: agent dispatched for messages that don't match any trigger rule</li>
|
||||
<li><strong>Trigger Rules</strong>: same keyword-matching logic as email inbox</li>
|
||||
</ul>
|
||||
|
||||
<h2 id="settings-profile">Profile</h2>
|
||||
<p>Available to all users. Contains:</p>
|
||||
<ul>
|
||||
<li><strong>Theme</strong>: choose from 7 built-in themes (Dark, Darker, Light, Nord, Solarized Dark, Gruvbox, Catppuccin Mocha). Applied immediately server-side with no flash of wrong theme.</li>
|
||||
<li><strong>Account Info</strong>: username and email (read-only); editable display name</li>
|
||||
<li><strong>Change Password</strong>: update your login password</li>
|
||||
<li><strong>Two-Factor Authentication (TOTP)</strong>: enable/disable TOTP-based MFA. On setup, a QR code is shown to scan with any authenticator app (e.g. Aegis, Google Authenticator). Once enabled, every login requires a 6-digit code.</li>
|
||||
<li><strong>Data Folder</strong>: shows the path of your auto-provisioned personal folder (set by admin via <code>system:users_base_folder</code>). This folder is where the Files page browses and where agent memory files are stored.</li>
|
||||
<li><strong>Telegram Bot Token</strong>: per-user Telegram bot token (optional). Overrides the global token for your sessions.</li>
|
||||
<li><strong>CalDAV</strong>: per-user CalDAV server, credentials, and calendar name. Overrides the global CalDAV config.</li>
|
||||
</ul>
|
||||
|
||||
{% if not (current_user and current_user.is_admin) %}
|
||||
<h2 id="settings-caldav">CalDAV</h2>
|
||||
<p>
|
||||
Configure your personal CalDAV connection under <strong>Settings → Profile → CalDAV</strong>. This overrides the global CalDAV config set by the admin. Fields: server URL, username, password, calendar name. Leave blank to inherit the global config.
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
<h2 id="settings-personality">Personality</h2>
|
||||
<p>
|
||||
Edit <strong>SOUL.md</strong> (agent identity, values, communication style) and <strong>USER.md</strong> (owner context: name, location, preferences) directly in the browser. Changes take effect immediately — no restart required.
|
||||
Both files are injected into every system prompt in order: SOUL.md → date/time → USER.md → security rules.
|
||||
</p>
|
||||
|
||||
<h2 id="settings-brain">2nd Brain</h2>
|
||||
<p>
|
||||
Requires PostgreSQL + pgvector (<code>BRAIN_DB_URL</code> env var). When connected, shows connection status, recent captured thoughts, and a manual capture form. The brain MCP server is exposed at <code>/brain-mcp/sse</code> and requires the <code>brain:mcp_key</code> credential for authentication.
|
||||
</p>
|
||||
|
||||
<h2 id="settings-mcp">MCP Servers</h2>
|
||||
<p>Add, edit, remove, enable/disable, and refresh external MCP servers. See the <a href="#mcp">MCP Servers</a> section for full setup details.</p>
|
||||
|
||||
{% if current_user and current_user.is_admin %}
|
||||
<h2 id="settings-security">Security <small style="font-weight:400;font-size:12px;color:var(--text-dim)">(Admin)</small></h2>
|
||||
<p>Five independently toggleable security options:</p>
|
||||
<ol>
|
||||
<li><strong>Enhanced Sanitization</strong>: strips extended prompt-injection patterns from all external content</li>
|
||||
<li><strong>Canary Token</strong>: a daily-rotating secret injected into the system prompt; if it appears in any tool argument, the call is blocked and you receive a Pushover alert</li>
|
||||
<li><strong>LLM Content Screening</strong>: after fetching web/email/file content, sends it to a cheap screening model for a SAFE/UNSAFE verdict before returning it to the agent</li>
|
||||
<li><strong>Output Validation</strong>: blocks email auto-replies from inbox sessions back to the trigger sender (prevents exfiltration loops)</li>
|
||||
<li><strong>Content Truncation</strong>: caps content length from web fetch, email, and file reads to configurable character limits</li>
|
||||
</ol>
|
||||
<p>See the <a href="#security">Security Model</a> section for the broader security architecture.</p>
|
||||
|
||||
<h2 id="settings-branding">Branding <small style="font-weight:400;font-size:12px;color:var(--text-dim)">(Admin)</small></h2>
|
||||
<p>
|
||||
Customize the sidebar brand name (default: <code>{{ agent_name }}</code>) and logo (default: <code>logo.png</code>). Upload a PNG/JPG/GIF/WebP/SVG logo (max 2 MB). Changes take effect immediately. Reset to defaults by clearing the name field or deleting the logo.
|
||||
</p>
|
||||
|
||||
<h2 id="settings-apikey">API Key <small style="font-weight:400;font-size:12px;color:var(--text-dim)">(Admin)</small></h2>
|
||||
<p>
|
||||
Protects the REST API for external programmatic access (scripts, home automations, other services, Swagger).
|
||||
The <strong>web UI always works without a key</strong> — a signed session cookie is set automatically on login.
|
||||
The API key is only required for:
|
||||
</p>
|
||||
<ul>
|
||||
<li>External tools and scripts calling <code>/api/*</code> directly</li>
|
||||
<li>Swagger UI (<a href="/docs">/docs</a>) — click <strong>Authorize</strong> and enter the key</li>
|
||||
</ul>
|
||||
<p>
|
||||
The raw key is shown <strong>once</strong> at generation time — copy it to your external tool. Only a SHA-256 hash is stored server-side. Regenerating invalidates the previous key immediately.
|
||||
</p>
|
||||
<p>
|
||||
Use header <code>X-API-Key: <key></code> or <code>Authorization: Bearer <key></code> in external requests.
|
||||
If no key is configured, the API is open (home-network default).
|
||||
</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<!-- ── 7. User Management ──────────────────────────────────────────── -->
|
||||
{% if current_user and current_user.is_admin %}
|
||||
<section id="user-management" data-section>
|
||||
<h1>User Management <small style="font-weight:400;font-size:14px;color:var(--text-dim)">(Admin)</small></h1>
|
||||
<p>
|
||||
oAI-Web supports multiple users with role-based access. Manage users at <a href="/admin/users">Admin → Users</a>.
|
||||
</p>
|
||||
|
||||
<h2>Roles</h2>
|
||||
<dl>
|
||||
<dt>Admin</dt>
|
||||
<dd>Full access to all settings, whitelists, credentials, and user management. Can see and manage all agents and audit logs across all users.</dd>
|
||||
<dt>User</dt>
|
||||
<dd>Access to chat, agents, files, and their own settings (Profile, CalDAV, Telegram, Email Accounts, MCP Servers). Cannot access system-wide whitelists, credentials, branding, or security settings.</dd>
|
||||
</dl>
|
||||
|
||||
<h2>Creating Users</h2>
|
||||
<ol>
|
||||
<li>Go to <a href="/admin/users">Admin → Users</a></li>
|
||||
<li>Click <strong>New User</strong></li>
|
||||
<li>Enter username, email, password, and select a role</li>
|
||||
<li>If <code>system:users_base_folder</code> is configured, a personal folder is created automatically at <code>{base}/{username}/</code></li>
|
||||
</ol>
|
||||
|
||||
<h2>MFA Management</h2>
|
||||
<p>
|
||||
Users set up their own TOTP in <strong>Settings → Profile → Two-Factor Authentication</strong>. As admin, you can clear any user's MFA from the Users page (useful if they lose their authenticator). The <strong>Clear MFA</strong> button resets their TOTP secret — they must set it up again on next login.
|
||||
</p>
|
||||
|
||||
<h2>User Filesystem Scoping</h2>
|
||||
<p>
|
||||
Non-admin users' agents automatically receive a <strong>scoped filesystem tool</strong> limited to their personal folder (<code>{base}/{username}/</code>). They cannot access paths outside their folder, even if those paths are in the global filesystem whitelist. Admin agents continue to use the global whitelist-based sandbox.
|
||||
</p>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
<!-- ── 8. Credential Key Reference ───────────────────────────────── -->
|
||||
{% if current_user and current_user.is_admin %}
|
||||
<section id="credentials" data-section>
|
||||
<h1>Credential Key Reference</h1>
|
||||
<p>All keys are stored in the encrypted credential store. System keys use the <code>system:</code> prefix.</p>
|
||||
|
||||
<div class="table-wrap">
|
||||
<table class="help-api-table">
|
||||
<thead><tr><th>Key</th><th>Description</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><code>system:paused</code></td><td>Kill switch — set to <code>"1"</code> to pause all agent activity</td></tr>
|
||||
<tr><td><code>system:max_tool_calls</code></td><td>Live override of MAX_TOOL_CALLS env var</td></tr>
|
||||
<tr><td><code>system:max_autonomous_runs_per_hour</code></td><td>Live override of MAX_AUTONOMOUS_RUNS_PER_HOUR</td></tr>
|
||||
<tr><td><code>system:audit_retention_days</code></td><td>Days to keep audit entries (0 = keep forever)</td></tr>
|
||||
<tr><td><code>system:trusted_proxy_ips</code></td><td>Comma-separated IPs for X-Forwarded-For trust (requires restart)</td></tr>
|
||||
<tr><td><code>system:users_base_folder</code></td><td>Base path for per-user folders (e.g. <code>/data/users</code>). Each user's folder is created at <code>{base}/{username}/</code>.</td></tr>
|
||||
<tr><td><code>system:default_image_gen_model</code></td><td>Model used by the <code>image_gen</code> tool (default: <code>openrouter:openai/gpt-5-image</code>)</td></tr>
|
||||
<tr><td><code>system:brand_name</code></td><td>Custom sidebar brand name</td></tr>
|
||||
<tr><td><code>system:brand_logo_filename</code></td><td>Custom sidebar logo filename in /static/</td></tr>
|
||||
<tr><td><code>system:security_sanitize_enhanced</code></td><td>Option 1: enhanced injection pattern sanitization</td></tr>
|
||||
<tr><td><code>system:security_canary_enabled</code></td><td>Option 2: canary token detection enabled</td></tr>
|
||||
<tr><td><code>system:canary_token</code></td><td>Auto-generated daily canary token (read-only)</td></tr>
|
||||
<tr><td><code>system:canary_rotated_at</code></td><td>Timestamp of last canary rotation (read-only)</td></tr>
|
||||
<tr><td><code>system:security_llm_screen_enabled</code></td><td>Option 3: LLM content screening enabled</td></tr>
|
||||
<tr><td><code>system:security_llm_screen_model</code></td><td>Model for LLM screening (default: google/gemini-flash-1.5)</td></tr>
|
||||
<tr><td><code>system:security_llm_screen_block</code></td><td>Option 3 block mode — block vs flag on UNSAFE verdict</td></tr>
|
||||
<tr><td><code>system:security_output_validation_enabled</code></td><td>Option 4: output validation for inbox sessions</td></tr>
|
||||
<tr><td><code>system:security_truncation_enabled</code></td><td>Option 5: content truncation</td></tr>
|
||||
<tr><td><code>system:security_max_web_chars</code></td><td>Max chars from web fetch (default: 20 000)</td></tr>
|
||||
<tr><td><code>system:security_max_email_chars</code></td><td>Max chars from email body (default: 6 000)</td></tr>
|
||||
<tr><td><code>system:security_max_file_chars</code></td><td>Max chars from file read (default: 20 000)</td></tr>
|
||||
<tr><td><code>system:security_max_subject_chars</code></td><td>Max chars of email subject (default: 200)</td></tr>
|
||||
<tr><td><code>telegram:bot_token</code></td><td>Global Telegram bot API token</td></tr>
|
||||
<tr><td><code>telegram:default_agent_id</code></td><td>UUID of agent for unmatched Telegram messages</td></tr>
|
||||
<tr><td><code>pushover_user_key</code></td><td>Pushover user key</td></tr>
|
||||
<tr><td><code>pushover_app_token</code></td><td>Pushover application token</td></tr>
|
||||
<tr><td><code>caldav_calendar_name</code></td><td>Optional CalDAV calendar name override (global)</td></tr>
|
||||
<tr><td><code>brain:mcp_key</code></td><td>2nd Brain MCP authentication key</td></tr>
|
||||
<tr><td><code>system:api_key_hash</code></td><td>SHA-256 hash of the external API key (raw key never stored)</td></tr>
|
||||
<tr><td><code>system:api_key_created_at</code></td><td>Timestamp of last API key generation</td></tr>
|
||||
<tr><td><code>system:session_secret</code></td><td>HMAC secret for signing web UI session cookies (auto-generated)</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
<!-- ── 9. REST API Reference ──────────────────────────────────────── -->
|
||||
<section id="api" data-section>
|
||||
<h1>REST API Reference</h1>
|
||||
<p>
|
||||
All endpoints are prefixed with <code>/api</code>. Responses are JSON.
|
||||
If an API key is configured (Settings → General → API Key), external requests must include
|
||||
<code>X-API-Key: <key></code>. The web UI is exempt via session cookie.
|
||||
</p>
|
||||
<p class="help-note">
|
||||
<strong>API Explorer:</strong> Browse and test endpoints interactively via Swagger UI at
|
||||
<a href="/docs" target="_blank"><code>/docs</code></a> or ReDoc at
|
||||
<a href="/redoc" target="_blank"><code>/redoc</code></a>.
|
||||
Click <strong>Authorize</strong> in Swagger and enter your API key to make authenticated calls.
|
||||
</p>
|
||||
|
||||
<h3>Credentials</h3>
|
||||
<div class="table-wrap">
|
||||
<table class="help-api-table">
|
||||
<thead><tr><th>Method</th><th>Path</th><th>Description</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><span class="http-get">GET</span></td><td><code>/api/credentials</code></td><td>List all credential keys (values not returned)</td></tr>
|
||||
<tr><td><span class="http-post">POST</span></td><td><code>/api/credentials</code></td><td>Set a credential <code>{key, value, description}</code></td></tr>
|
||||
<tr><td><span class="http-get">GET</span></td><td><code>/api/credentials/{key}</code></td><td>Get a single credential value</td></tr>
|
||||
<tr><td><span class="http-del">DELETE</span></td><td><code>/api/credentials/{key}</code></td><td>Delete a credential</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h3>Audit Log</h3>
|
||||
<div class="table-wrap">
|
||||
<table class="help-api-table">
|
||||
<thead><tr><th>Method</th><th>Path</th><th>Description</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><span class="http-get">GET</span></td><td><code>/api/audit</code></td><td>Paginated audit log; params: <code>start</code>, <code>end</code>, <code>tool</code>, <code>session_id</code>, <code>task_id</code>, <code>confirmed</code>, <code>page</code>, <code>page_size</code></td></tr>
|
||||
<tr><td><span class="http-del">DELETE</span></td><td><code>/api/audit</code></td><td>Delete audit entries older than <code>?older_than_days=N</code> (0 = all)</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h3>API Key</h3>
|
||||
<div class="table-wrap">
|
||||
<table class="help-api-table">
|
||||
<thead><tr><th>Method</th><th>Path</th><th>Description</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><span class="http-get">GET</span></td><td><code>/api/settings/api-key</code></td><td>Returns <code>{configured: bool, created_at}</code> — never returns the raw key</td></tr>
|
||||
<tr><td><span class="http-post">POST</span></td><td><code>/api/settings/api-key</code></td><td>Generate a new key — returns <code>{key}</code> once only; invalidates previous key</td></tr>
|
||||
<tr><td><span class="http-del">DELETE</span></td><td><code>/api/settings/api-key</code></td><td>Revoke the current key</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h3>Settings</h3>
|
||||
<div class="table-wrap">
|
||||
<table class="help-api-table">
|
||||
<thead><tr><th>Method</th><th>Path</th><th>Description</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><span class="http-get">GET</span></td><td><code>/api/settings/limits</code></td><td>Current runtime limits</td></tr>
|
||||
<tr><td><span class="http-post">POST</span></td><td><code>/api/settings/limits</code></td><td>Update <code>{max_tool_calls, max_autonomous_runs_per_hour}</code></td></tr>
|
||||
<tr><td><span class="http-get">GET</span></td><td><code>/api/settings/security</code></td><td>Current security option states</td></tr>
|
||||
<tr><td><span class="http-post">POST</span></td><td><code>/api/settings/security</code></td><td>Update security options</td></tr>
|
||||
<tr><td><span class="http-get">GET</span></td><td><code>/api/settings/branding</code></td><td>Current brand name and logo URL</td></tr>
|
||||
<tr><td><span class="http-post">POST</span></td><td><code>/api/settings/branding</code></td><td>Update <code>{brand_name}</code></td></tr>
|
||||
<tr><td><span class="http-post">POST</span></td><td><code>/api/settings/branding/logo/upload</code></td><td>Upload a logo file (multipart)</td></tr>
|
||||
<tr><td><span class="http-del">DELETE</span></td><td><code>/api/settings/branding/logo</code></td><td>Reset logo to default</td></tr>
|
||||
<tr><td><span class="http-get">GET</span></td><td><code>/api/settings/audit-retention</code></td><td>Current audit retention setting</td></tr>
|
||||
<tr><td><span class="http-post">POST</span></td><td><code>/api/settings/audit-retention</code></td><td>Update <code>{days}</code></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h3>Whitelists</h3>
|
||||
<div class="table-wrap">
|
||||
<table class="help-api-table">
|
||||
<thead><tr><th>Method</th><th>Path</th><th>Description</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><span class="http-get">GET</span></td><td><code>/api/email-whitelist</code></td><td>List whitelisted email recipients</td></tr>
|
||||
<tr><td><span class="http-post">POST</span></td><td><code>/api/email-whitelist</code></td><td>Add/update <code>{email, daily_limit}</code></td></tr>
|
||||
<tr><td><span class="http-del">DELETE</span></td><td><code>/api/email-whitelist/{email}</code></td><td>Remove a recipient</td></tr>
|
||||
<tr><td><span class="http-get">GET</span></td><td><code>/api/web-whitelist</code></td><td>List Tier-1 web domains</td></tr>
|
||||
<tr><td><span class="http-post">POST</span></td><td><code>/api/web-whitelist</code></td><td>Add <code>{domain, note}</code></td></tr>
|
||||
<tr><td><span class="http-del">DELETE</span></td><td><code>/api/web-whitelist/{domain}</code></td><td>Remove a domain</td></tr>
|
||||
<tr><td><span class="http-get">GET</span></td><td><code>/api/filesystem-whitelist</code></td><td>List sandbox directories</td></tr>
|
||||
<tr><td><span class="http-post">POST</span></td><td><code>/api/filesystem-whitelist</code></td><td>Add <code>{path, note}</code></td></tr>
|
||||
<tr><td><span class="http-del">DELETE</span></td><td><code>/api/filesystem-whitelist/{path}</code></td><td>Remove a directory</td></tr>
|
||||
<tr><td><span class="http-get">GET</span></td><td><code>/api/filesystem-browser</code></td><td>Server-side directory listing; param: <code>?path=</code></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h3>Agents & Runs</h3>
|
||||
<div class="table-wrap">
|
||||
<table class="help-api-table">
|
||||
<thead><tr><th>Method</th><th>Path</th><th>Description</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><span class="http-get">GET</span></td><td><code>/api/agents</code></td><td>List agents (excludes email handling agents)</td></tr>
|
||||
<tr><td><span class="http-post">POST</span></td><td><code>/api/agents</code></td><td>Create an agent</td></tr>
|
||||
<tr><td><span class="http-get">GET</span></td><td><code>/api/agents/{id}</code></td><td>Get agent details</td></tr>
|
||||
<tr><td><span class="http-put">PUT</span></td><td><code>/api/agents/{id}</code></td><td>Update agent</td></tr>
|
||||
<tr><td><span class="http-del">DELETE</span></td><td><code>/api/agents/{id}</code></td><td>Delete agent</td></tr>
|
||||
<tr><td><span class="http-post">POST</span></td><td><code>/api/agents/{id}/run</code></td><td>Trigger immediate run</td></tr>
|
||||
<tr><td><span class="http-post">POST</span></td><td><code>/api/agents/{id}/toggle</code></td><td>Enable / disable schedule</td></tr>
|
||||
<tr><td><span class="http-get">GET</span></td><td><code>/api/agents/{id}/runs</code></td><td>List runs for an agent</td></tr>
|
||||
<tr><td><span class="http-get">GET</span></td><td><code>/api/agent-runs</code></td><td>List recent runs across all agents (excludes email handlers)</td></tr>
|
||||
<tr><td><span class="http-get">GET</span></td><td><code>/api/agent-runs/{run_id}</code></td><td>Get a specific run including full result text</td></tr>
|
||||
<tr><td><span class="http-post">POST</span></td><td><code>/api/runs/{run_id}/stop</code></td><td>Stop a running agent</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h3>Models</h3>
|
||||
<div class="table-wrap">
|
||||
<table class="help-api-table">
|
||||
<thead><tr><th>Method</th><th>Path</th><th>Description</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><span class="http-get">GET</span></td><td><code>/api/models</code></td><td>Available model IDs + default</td></tr>
|
||||
<tr><td><span class="http-get">GET</span></td><td><code>/api/models/info</code></td><td>Full model metadata: name, context, pricing, capabilities</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h3>MCP Servers</h3>
|
||||
<div class="table-wrap">
|
||||
<table class="help-api-table">
|
||||
<thead><tr><th>Method</th><th>Path</th><th>Description</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><span class="http-get">GET</span></td><td><code>/api/mcp-servers</code></td><td>List all MCP servers</td></tr>
|
||||
<tr><td><span class="http-post">POST</span></td><td><code>/api/mcp-servers</code></td><td>Add a server</td></tr>
|
||||
<tr><td><span class="http-put">PUT</span></td><td><code>/api/mcp-servers/{id}</code></td><td>Update a server</td></tr>
|
||||
<tr><td><span class="http-del">DELETE</span></td><td><code>/api/mcp-servers/{id}</code></td><td>Remove a server</td></tr>
|
||||
<tr><td><span class="http-post">POST</span></td><td><code>/api/mcp-servers/{id}/toggle</code></td><td>Enable / disable a server</td></tr>
|
||||
<tr><td><span class="http-post">POST</span></td><td><code>/api/mcp-servers/{id}/refresh</code></td><td>Re-discover tools</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h3>Telegram & Inbox</h3>
|
||||
<div class="table-wrap">
|
||||
<table class="help-api-table">
|
||||
<thead><tr><th>Method</th><th>Path</th><th>Description</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><span class="http-get">GET</span></td><td><code>/api/telegram/config</code></td><td>Global Telegram bot config</td></tr>
|
||||
<tr><td><span class="http-post">POST</span></td><td><code>/api/telegram/config</code></td><td>Save bot token + default agent</td></tr>
|
||||
<tr><td><span class="http-get">GET</span></td><td><code>/api/telegram/whitelist</code></td><td>Chat ID whitelist</td></tr>
|
||||
<tr><td><span class="http-post">POST</span></td><td><code>/api/telegram/whitelist</code></td><td>Add chat ID</td></tr>
|
||||
<tr><td><span class="http-del">DELETE</span></td><td><code>/api/telegram/whitelist/{chat_id}</code></td><td>Remove chat ID</td></tr>
|
||||
<tr><td><span class="http-get">GET</span></td><td><code>/api/telegram/triggers</code></td><td>List trigger rules</td></tr>
|
||||
<tr><td><span class="http-post">POST</span></td><td><code>/api/telegram/triggers</code></td><td>Create trigger rule</td></tr>
|
||||
<tr><td><span class="http-del">DELETE</span></td><td><code>/api/telegram/triggers/{id}</code></td><td>Delete trigger rule</td></tr>
|
||||
<tr><td><span class="http-get">GET</span></td><td><code>/api/inbox/config</code></td><td>Legacy IMAP/SMTP configuration</td></tr>
|
||||
<tr><td><span class="http-post">POST</span></td><td><code>/api/inbox/config</code></td><td>Save legacy IMAP/SMTP credentials</td></tr>
|
||||
<tr><td><span class="http-get">GET</span></td><td><code>/api/inbox/triggers</code></td><td>List email trigger rules</td></tr>
|
||||
<tr><td><span class="http-post">POST</span></td><td><code>/api/inbox/triggers</code></td><td>Create email trigger rule</td></tr>
|
||||
<tr><td><span class="http-del">DELETE</span></td><td><code>/api/inbox/triggers/{id}</code></td><td>Delete email trigger rule</td></tr>
|
||||
<tr><td><span class="http-get">GET</span></td><td><code>/api/inbox/status</code></td><td>Status of all inbox listeners</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h3>Email Accounts</h3>
|
||||
<div class="table-wrap">
|
||||
<table class="help-api-table">
|
||||
<thead><tr><th>Method</th><th>Path</th><th>Description</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><span class="http-get">GET</span></td><td><code>/api/my/email-accounts</code></td><td>List user's email accounts</td></tr>
|
||||
<tr><td><span class="http-post">POST</span></td><td><code>/api/my/email-accounts</code></td><td>Create an email account</td></tr>
|
||||
<tr><td><span class="http-put">PUT</span></td><td><code>/api/my/email-accounts/{id}</code></td><td>Update email account</td></tr>
|
||||
<tr><td><span class="http-del">DELETE</span></td><td><code>/api/my/email-accounts/{id}</code></td><td>Delete email account</td></tr>
|
||||
<tr><td><span class="http-post">POST</span></td><td><code>/api/my/email-accounts/{id}/pause</code></td><td>Pause a handling account</td></tr>
|
||||
<tr><td><span class="http-post">POST</span></td><td><code>/api/my/email-accounts/{id}/resume</code></td><td>Resume a paused handling account</td></tr>
|
||||
<tr><td><span class="http-post">POST</span></td><td><code>/api/my/email-accounts/list-folders-preview</code></td><td>List IMAP folders using raw credentials (without saving an account)</td></tr>
|
||||
<tr><td><span class="http-get">GET</span></td><td><code>/api/my/email-accounts/available-extra-tools</code></td><td>Which notification tools are available for handling accounts</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h3>Files</h3>
|
||||
<div class="table-wrap">
|
||||
<table class="help-api-table">
|
||||
<thead><tr><th>Method</th><th>Path</th><th>Description</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><span class="http-get">GET</span></td><td><code>/api/my/files</code></td><td>List files/folders in the user's data folder; param: <code>?path=</code></td></tr>
|
||||
<tr><td><span class="http-del">DELETE</span></td><td><code>/api/my/files</code></td><td>Delete a file; param: <code>?path=</code>. Protected names (<code>memory_*</code>, <code>reasoning_*</code>) return 403.</td></tr>
|
||||
<tr><td><span class="http-get">GET</span></td><td><code>/api/my/files/download</code></td><td>Download a single file; param: <code>?path=</code></td></tr>
|
||||
<tr><td><span class="http-get">GET</span></td><td><code>/api/my/files/download-zip</code></td><td>Download a folder as ZIP; param: <code>?path=</code></td></tr>
|
||||
<tr><td><span class="http-get">GET</span></td><td><code>/api/my/data-folder</code></td><td>Return the user's provisioned data folder path</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h3>User Profile & Settings</h3>
|
||||
<div class="table-wrap">
|
||||
<table class="help-api-table">
|
||||
<thead><tr><th>Method</th><th>Path</th><th>Description</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><span class="http-get">GET</span></td><td><code>/api/my/profile</code></td><td>Get display name</td></tr>
|
||||
<tr><td><span class="http-post">POST</span></td><td><code>/api/my/profile</code></td><td>Update display name</td></tr>
|
||||
<tr><td><span class="http-get">GET</span></td><td><code>/api/my/theme</code></td><td>Get current theme</td></tr>
|
||||
<tr><td><span class="http-post">POST</span></td><td><code>/api/my/theme</code></td><td>Set theme <code>{theme_id}</code></td></tr>
|
||||
<tr><td><span class="http-get">GET</span></td><td><code>/api/my/mfa/status</code></td><td>Whether MFA is enabled for the current user</td></tr>
|
||||
<tr><td><span class="http-post">POST</span></td><td><code>/api/my/mfa/setup/begin</code></td><td>Start MFA setup — returns QR code PNG (base64) and provisioning URI</td></tr>
|
||||
<tr><td><span class="http-post">POST</span></td><td><code>/api/my/mfa/setup/confirm</code></td><td>Confirm setup with a valid TOTP code <code>{code}</code></td></tr>
|
||||
<tr><td><span class="http-del">DELETE</span></td><td><code>/api/my/mfa/disable</code></td><td>Disable MFA for the current user</td></tr>
|
||||
<tr><td><span class="http-get">GET</span></td><td><code>/api/my/caldav/config</code></td><td>Get per-user CalDAV config</td></tr>
|
||||
<tr><td><span class="http-post">POST</span></td><td><code>/api/my/caldav/config</code></td><td>Save per-user CalDAV credentials</td></tr>
|
||||
<tr><td><span class="http-del">DELETE</span></td><td><code>/api/my/caldav/config</code></td><td>Remove per-user CalDAV config (fall back to global)</td></tr>
|
||||
<tr><td><span class="http-get">GET</span></td><td><code>/api/my/telegram/whitelisted-chats</code></td><td>List Telegram chat IDs whitelisted for the current user</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h3>User Management <small style="color:var(--text-dim)">(Admin)</small></h3>
|
||||
<div class="table-wrap">
|
||||
<table class="help-api-table">
|
||||
<thead><tr><th>Method</th><th>Path</th><th>Description</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><span class="http-get">GET</span></td><td><code>/api/users</code></td><td>List all users</td></tr>
|
||||
<tr><td><span class="http-post">POST</span></td><td><code>/api/users</code></td><td>Create a user <code>{username, email, password, role}</code></td></tr>
|
||||
<tr><td><span class="http-put">PUT</span></td><td><code>/api/users/{id}</code></td><td>Update user (role, active status, etc.)</td></tr>
|
||||
<tr><td><span class="http-del">DELETE</span></td><td><code>/api/users/{id}</code></td><td>Delete a user</td></tr>
|
||||
<tr><td><span class="http-del">DELETE</span></td><td><code>/api/users/{id}/mfa</code></td><td>Clear a user's MFA secret (admin reset)</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h3>System Prompt, Tools & Control</h3>
|
||||
<div class="table-wrap">
|
||||
<table class="help-api-table">
|
||||
<thead><tr><th>Method</th><th>Path</th><th>Description</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><span class="http-get">GET</span></td><td><code>/api/system-prompt/soul</code></td><td>Read SOUL.md content</td></tr>
|
||||
<tr><td><span class="http-post">POST</span></td><td><code>/api/system-prompt/soul</code></td><td>Save SOUL.md content</td></tr>
|
||||
<tr><td><span class="http-get">GET</span></td><td><code>/api/system-prompt/user</code></td><td>Read USER.md content</td></tr>
|
||||
<tr><td><span class="http-post">POST</span></td><td><code>/api/system-prompt/user</code></td><td>Save USER.md content</td></tr>
|
||||
<tr><td><span class="http-get">GET</span></td><td><code>/api/tools</code></td><td>List all registered tools with schemas</td></tr>
|
||||
<tr><td><span class="http-post">POST</span></td><td><code>/api/pause</code></td><td>Pause all agent activity</td></tr>
|
||||
<tr><td><span class="http-post">POST</span></td><td><code>/api/resume</code></td><td>Resume agent activity</td></tr>
|
||||
<tr><td><span class="http-get">GET</span></td><td><code>/api/status</code></td><td>Pause state + pending confirmations</td></tr>
|
||||
<tr><td><span class="http-get">GET</span></td><td><code>/health</code></td><td>Health check</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── 10. Security Model ──────────────────────────────────────────── -->
|
||||
<section id="security" data-section>
|
||||
<h1>Security Model</h1>
|
||||
|
||||
<h2>Core Principle</h2>
|
||||
<p>
|
||||
<strong>External input is data, never instructions.</strong> Email body text, calendar content, web page content, and file contents are all passed as <em>tool results</em> — they are never injected into the system prompt where they could alter {{ agent_name }}'s instructions.
|
||||
</p>
|
||||
|
||||
<h2>Three DB-Managed Whitelists</h2>
|
||||
<ul>
|
||||
<li><strong>Email whitelist</strong> — {{ agent_name }} can only send email to addresses explicitly approved here</li>
|
||||
<li><strong>Web whitelist (Tier 1)</strong> — domains always accessible; subdomains included automatically</li>
|
||||
<li><strong>Filesystem sandbox</strong> — {{ agent_name }} can only read/write within declared directories (or a user's personal folder)</li>
|
||||
</ul>
|
||||
<p>Tier 2 web access (any URL) is only available in user-initiated chat sessions, never in autonomous agent runs.</p>
|
||||
|
||||
<h2>User Filesystem Isolation</h2>
|
||||
<p>
|
||||
Non-admin users' agents use a <strong>scoped filesystem tool</strong> restricted to their personal folder (<code>{base}/{username}/</code>). This is enforced at the tool level regardless of what the agent prompt says. Admin agents use the global whitelist-based sandbox as before.
|
||||
</p>
|
||||
|
||||
<h2>Confirmation Flow</h2>
|
||||
<p>
|
||||
In interactive chat, any tool with side effects (send email, write/delete files, send notifications, create/delete calendar events) triggers a confirmation modal. The agent pauses until you approve or deny. Agents running headlessly skip confirmations — their scope is declared at creation time.
|
||||
</p>
|
||||
|
||||
<h2>Five Security Options</h2>
|
||||
<ol>
|
||||
<li><strong>Enhanced Sanitization</strong> — removes known prompt-injection patterns from all external content before it reaches the agent</li>
|
||||
<li><strong>Canary Token</strong> — a daily-rotating secret in the system prompt; any tool call argument containing the canary is blocked and triggers a Pushover alert, detecting prompt-injection exfiltration attempts</li>
|
||||
<li><strong>LLM Content Screening</strong> — a cheap secondary model screens fetched content for malicious instructions; operates in flag or block mode</li>
|
||||
<li><strong>Output Validation</strong> — prevents inbox auto-reply loops by blocking outbound emails back to the triggering sender</li>
|
||||
<li><strong>Content Truncation</strong> — enforces maximum character limits on web fetch, email, and file content to limit the attack surface of large malicious documents</li>
|
||||
</ol>
|
||||
|
||||
<h2>Audit Log</h2>
|
||||
<p>
|
||||
Every tool call — arguments, result summary, confirmation status, session ID, task ID — is written to an append-only audit log. Logs are never auto-deleted unless you configure a retention period. View them at <a href="/audit">Audit Log</a>.
|
||||
</p>
|
||||
|
||||
<h2>Kill Switch</h2>
|
||||
<p>
|
||||
The <strong>Pause</strong> button in the sidebar immediately halts all agent activity: no new runs, no inbox processing, no Telegram responses. The <code>system:paused</code> credential stores the state and is checked before every operation. Individual email handling accounts can also be paused independently via their Telegram keyword command or the Email Accounts UI.
|
||||
</p>
|
||||
|
||||
<h2>No Credentials in Agent Context</h2>
|
||||
<p>
|
||||
API keys, passwords, and tokens are only accessed by the server-side tool implementations. The agent itself never sees a raw credential — it only receives structured results (e.g. a list of calendar events, a fetched page).
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!-- ── 11. Telegram & Email Inbox ──────────────────────────────────── -->
|
||||
<section id="messaging" data-section>
|
||||
<h1>Telegram & Email Inbox</h1>
|
||||
|
||||
<h2>Telegram</h2>
|
||||
<p>
|
||||
oAI-Web connects to Telegram via long-polling (no webhook required). Admin setup:
|
||||
</p>
|
||||
<ol>
|
||||
<li>Create a bot via @BotFather and copy the bot token</li>
|
||||
<li>Go to <strong>Settings → Telegram</strong> and save the bot token</li>
|
||||
<li>Add your Telegram chat ID to the whitelist (messages from unlisted IDs are silently dropped)</li>
|
||||
<li>Optionally set a default agent for messages that don't match any trigger rule</li>
|
||||
<li>Add trigger rules to route specific messages to specific agents</li>
|
||||
</ol>
|
||||
<p>
|
||||
Non-admin users can also set their own Telegram bot token under <strong>Settings → Profile → Telegram Bot Token</strong>. This creates a personal bot that routes to agents and email accounts scoped to that user.
|
||||
</p>
|
||||
<p>
|
||||
Each chat maintains its own conversation history (session ID: <code>telegram:{chat_id}</code>), persisted in memory and reset on server restart.
|
||||
</p>
|
||||
|
||||
<h2>Telegram Keyword Commands (Email Handling)</h2>
|
||||
<p>
|
||||
When an email handling account has a <strong>Telegram keyword</strong> set (e.g. <code>work</code>), Telegram messages starting with <code>/work</code> are routed directly to that email account's agent. This allows you to interact with the email agent via Telegram without any trigger rules.
|
||||
</p>
|
||||
<p>Built-in sub-commands (e.g. for keyword <code>work</code>):</p>
|
||||
<ul>
|
||||
<li><code>/work pause</code> — temporarily pause the email account's listener</li>
|
||||
<li><code>/work resume</code> — resume the listener</li>
|
||||
<li><code>/work status</code> — show the account's current status</li>
|
||||
<li><code>/work <any message></code> — pass the message to the handling agent</li>
|
||||
</ul>
|
||||
<p class="help-note">
|
||||
Only the Telegram chat ID associated with the email account can use its keyword commands. Other chat IDs are rejected.
|
||||
</p>
|
||||
|
||||
<h2>Email Inbox — Trigger Accounts</h2>
|
||||
<p>
|
||||
Trigger accounts use IMAP IDLE for instant push notification. When a new email arrives:
|
||||
</p>
|
||||
<ol>
|
||||
<li>Subject + body matched against trigger rules; the first match wins</li>
|
||||
<li>If matched, the corresponding agent is dispatched; an auto-reply is sent if configured</li>
|
||||
<li>If no trigger matches, the email is silently ignored (no default agent for trigger accounts)</li>
|
||||
</ol>
|
||||
<p>
|
||||
Sender whitelist check behavior:
|
||||
</p>
|
||||
<ul>
|
||||
<li>Whitelisted sender + matching trigger → agent runs, reply sent</li>
|
||||
<li>Whitelisted sender + no trigger → "no trigger word" reply</li>
|
||||
<li>Non-whitelisted sender + matching trigger → agent runs, but Output Validation blocks the reply</li>
|
||||
<li>Non-whitelisted sender + no trigger → <strong>silently dropped</strong> (reveals nothing to the sender)</li>
|
||||
</ul>
|
||||
|
||||
<h2>Email Inbox — Handling Accounts</h2>
|
||||
<p>
|
||||
Handling accounts poll every 60 seconds. A dedicated AI agent reads each new email and decides how to handle it. The agent has access to:
|
||||
</p>
|
||||
<ul>
|
||||
<li><strong>Email tool</strong> — list, read, mark as read, move, create folders</li>
|
||||
<li><strong>Filesystem tool</strong> — scoped to the user's data folder (if configured)</li>
|
||||
<li><strong>Memory files</strong> — <code>memory_<username>.md</code> (persistent notes) and <code>reasoning_<username>.md</code> (append-only decision log) are injected into each run</li>
|
||||
<li><strong>Telegram tool</strong> (optional) — bound to the account's associated chat ID; reply messages automatically include a <code>/keyword <reply></code> footer for easy follow-up</li>
|
||||
<li><strong>Pushover tool</strong> (optional, admin only)</li>
|
||||
</ul>
|
||||
|
||||
<h2>Trigger Rule Matching</h2>
|
||||
<p>
|
||||
Both Telegram and email inbox use the same trigger-matching algorithm:
|
||||
</p>
|
||||
<ul>
|
||||
<li><strong>Case-insensitive</strong> — <code>URGENT</code> matches <code>urgent</code></li>
|
||||
<li><strong>Order-independent</strong> — all tokens in the trigger phrase must appear somewhere in the message, but not necessarily in sequence</li>
|
||||
<li>Example: trigger phrase <code>daily report</code> matches <em>"Send me the report for the daily standup"</em> but also <em>"Daily summary report please"</em></li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
</div><!-- .help-content -->
|
||||
</div><!-- .help-layout -->
|
||||
{% endblock %}
|
||||
91
server/web/templates/login.html
Normal file
91
server/web/templates/login.html
Normal file
@@ -0,0 +1,91 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Login — {{ brand_name }}</title>
|
||||
<link rel="icon" type="image/png" href="/static/icon.png">
|
||||
<link rel="stylesheet" href="/static/style.css?v={{ sv }}">
|
||||
<style>
|
||||
body { display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; }
|
||||
.auth-card {
|
||||
background: var(--bg2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 2.5rem 2rem;
|
||||
width: 100%;
|
||||
max-width: 360px;
|
||||
}
|
||||
.auth-logo { text-align: center; margin-bottom: 1.5rem; }
|
||||
.auth-logo img { height: 48px; width: 48px; object-fit: contain; }
|
||||
.auth-logo-name { font-size: 1.25rem; font-weight: 600; margin-top: 0.5rem; color: var(--text); }
|
||||
.auth-logo-sub { font-size: 0.75rem; color: var(--text-dim); margin-top: 0.15rem; }
|
||||
.auth-error {
|
||||
background: rgba(224,82,82,0.1);
|
||||
border: 1px solid rgba(224,82,82,0.3);
|
||||
color: var(--red);
|
||||
padding: 0.6rem 0.9rem;
|
||||
border-radius: var(--radius);
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.auth-field { margin-bottom: 1rem; }
|
||||
.auth-field label { display: block; font-size: 0.8rem; color: var(--text-dim); margin-bottom: 0.35rem; }
|
||||
.auth-field input {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
background: var(--bg3);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
color: var(--text);
|
||||
padding: 0.6rem 0.75rem;
|
||||
font-size: 0.9rem;
|
||||
font-family: var(--font);
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.auth-field input:focus { border-color: var(--accent-dim); }
|
||||
.auth-btn {
|
||||
width: 100%;
|
||||
padding: 0.65rem;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
font-size: 0.9rem;
|
||||
font-family: var(--font);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
margin-top: 0.5rem;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.auth-btn:hover { opacity: 0.88; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="auth-card">
|
||||
<div class="auth-logo">
|
||||
<img src="{{ logo_url }}" alt="logo">
|
||||
<div class="auth-logo-name">{{ brand_name }}</div>
|
||||
<div class="auth-logo-sub">oAI-Web</div>
|
||||
</div>
|
||||
|
||||
{% if error %}
|
||||
<div class="auth-error">{{ error }}</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="/login">
|
||||
<input type="hidden" name="next" value="{{ next }}">
|
||||
<div class="auth-field">
|
||||
<label for="username">Username</label>
|
||||
<input type="text" id="username" name="username" autocomplete="username" autofocus required>
|
||||
</div>
|
||||
<div class="auth-field">
|
||||
<label for="password">Password</label>
|
||||
<input type="password" id="password" name="password" autocomplete="current-password" required>
|
||||
</div>
|
||||
<button type="submit" class="auth-btn">Sign in</button>
|
||||
</form>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
94
server/web/templates/mfa.html
Normal file
94
server/web/templates/mfa.html
Normal file
@@ -0,0 +1,94 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Two-Factor Auth — {{ brand_name }}</title>
|
||||
<link rel="icon" type="image/png" href="/static/icon.png">
|
||||
<link rel="stylesheet" href="/static/style.css?v={{ sv }}">
|
||||
<style>
|
||||
body { display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; }
|
||||
.auth-card {
|
||||
background: var(--bg2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 2.5rem 2rem;
|
||||
width: 100%;
|
||||
max-width: 360px;
|
||||
}
|
||||
.auth-logo { text-align: center; margin-bottom: 1.5rem; }
|
||||
.auth-logo img { height: 48px; width: 48px; object-fit: contain; }
|
||||
.auth-logo-name { font-size: 1.25rem; font-weight: 600; margin-top: 0.5rem; color: var(--text); }
|
||||
.auth-logo-sub { font-size: 0.75rem; color: var(--text-dim); margin-top: 0.15rem; }
|
||||
.auth-error {
|
||||
background: rgba(224,82,82,0.1);
|
||||
border: 1px solid rgba(224,82,82,0.3);
|
||||
color: var(--red);
|
||||
padding: 0.6rem 0.9rem;
|
||||
border-radius: var(--radius);
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.auth-field { margin-bottom: 1rem; }
|
||||
.auth-field label { display: block; font-size: 0.8rem; color: var(--text-dim); margin-bottom: 0.35rem; }
|
||||
.auth-field input {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
background: var(--bg3);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
color: var(--text);
|
||||
padding: 0.6rem 0.75rem;
|
||||
font-size: 1.2rem;
|
||||
letter-spacing: 0.2em;
|
||||
text-align: center;
|
||||
font-family: var(--font);
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.auth-field input:focus { border-color: var(--accent-dim); }
|
||||
.auth-btn {
|
||||
width: 100%;
|
||||
padding: 0.65rem;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
font-size: 0.9rem;
|
||||
font-family: var(--font);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
margin-top: 0.5rem;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.auth-btn:hover { opacity: 0.88; }
|
||||
.auth-hint { font-size: 0.75rem; color: var(--text-dim); text-align: center; margin-top: 1.25rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="auth-card">
|
||||
<div class="auth-logo">
|
||||
<img src="{{ logo_url }}" alt="logo">
|
||||
<div class="auth-logo-name">{{ brand_name }}</div>
|
||||
<div class="auth-logo-sub">oAI-Web</div>
|
||||
</div>
|
||||
|
||||
{% if error %}
|
||||
<div class="auth-error">{{ error }}</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="/login/mfa">
|
||||
<input type="hidden" name="next" value="{{ next }}">
|
||||
<div class="auth-field">
|
||||
<label for="code">Authenticator code</label>
|
||||
<input type="text" id="code" name="code"
|
||||
inputmode="numeric" maxlength="6" autocomplete="one-time-code"
|
||||
autofocus required placeholder="000000">
|
||||
</div>
|
||||
<button type="submit" class="auth-btn">Verify</button>
|
||||
</form>
|
||||
|
||||
<p class="auth-hint">Lost your authenticator? Contact your administrator.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
48
server/web/templates/models.html
Normal file
48
server/web/templates/models.html
Normal file
@@ -0,0 +1,48 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Models — {{ agent_name }}{% endblock %}
|
||||
{% block content %}
|
||||
<div class="page" id="models-container">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:24px">
|
||||
<h1>Models</h1>
|
||||
<div id="models-count" style="color:var(--text-dim);font-size:13px"></div>
|
||||
</div>
|
||||
|
||||
<!-- Access tier warning -->
|
||||
<div id="models-access-note" style="display:none;margin-bottom:20px;padding:13px 16px;background:rgba(224,166,50,0.08);border:1px solid rgba(224,166,50,0.35);border-left:4px solid var(--yellow);border-radius:var(--radius);font-size:13px;color:var(--text);line-height:1.6"></div>
|
||||
|
||||
<!-- Search + filter bar -->
|
||||
<div style="display:flex;gap:12px;align-items:center;flex-wrap:wrap;margin-bottom:20px">
|
||||
<input id="models-search" type="search" class="form-input" placeholder="Search models…"
|
||||
style="max-width:320px" oninput="filterModels()">
|
||||
<div id="models-filters" style="display:flex;gap:8px;flex-wrap:wrap"></div>
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<div class="table-wrap">
|
||||
<table id="models-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Model</th>
|
||||
<th>Context</th>
|
||||
<th id="th-input" style="cursor:pointer;user-select:none" onclick="sortModels('input')">Input / 1M</th>
|
||||
<th id="th-output" style="cursor:pointer;user-select:none" onclick="sortModels('output')">Output / 1M</th>
|
||||
<th>Capabilities</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td colspan="5" style="text-align:center;color:var(--text-dim)">Loading…</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Model detail modal -->
|
||||
<div class="modal-overlay hidden" id="model-modal" onclick="if(event.target===this)closeModelModal()">
|
||||
<div class="modal" style="max-width:640px;width:90%">
|
||||
<div id="model-modal-content"></div>
|
||||
<div class="modal-buttons" style="margin-top:20px">
|
||||
<button class="btn btn-primary" onclick="closeModelModal()">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
1733
server/web/templates/settings.html
Normal file
1733
server/web/templates/settings.html
Normal file
File diff suppressed because it is too large
Load Diff
105
server/web/templates/setup.html
Normal file
105
server/web/templates/setup.html
Normal file
@@ -0,0 +1,105 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>First-Time Setup — {{ brand_name }}</title>
|
||||
<link rel="icon" type="image/png" href="/static/icon.png">
|
||||
<link rel="stylesheet" href="/static/style.css?v={{ sv }}">
|
||||
<style>
|
||||
body { display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; }
|
||||
.auth-card {
|
||||
background: var(--bg2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 2.5rem 2rem;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
}
|
||||
.auth-logo { text-align: center; margin-bottom: 1.5rem; }
|
||||
.auth-logo img { height: 48px; width: 48px; object-fit: contain; }
|
||||
.auth-logo-name { font-size: 1.25rem; font-weight: 600; margin-top: 0.5rem; color: var(--text); }
|
||||
.auth-logo-sub { font-size: 0.75rem; color: var(--text-dim); margin-top: 0.15rem; }
|
||||
.setup-heading { font-size: 1rem; font-weight: 600; margin-bottom: 0.25rem; color: var(--text); }
|
||||
.setup-desc { font-size: 0.82rem; color: var(--text-dim); margin-bottom: 1.25rem; }
|
||||
.auth-error {
|
||||
background: rgba(224,82,82,0.1);
|
||||
border: 1px solid rgba(224,82,82,0.3);
|
||||
color: var(--red);
|
||||
padding: 0.6rem 0.9rem;
|
||||
border-radius: var(--radius);
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.auth-field { margin-bottom: 1rem; }
|
||||
.auth-field label { display: block; font-size: 0.8rem; color: var(--text-dim); margin-bottom: 0.35rem; }
|
||||
.auth-field input {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
background: var(--bg3);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
color: var(--text);
|
||||
padding: 0.6rem 0.75rem;
|
||||
font-size: 0.9rem;
|
||||
font-family: var(--font);
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.auth-field input:focus { border-color: var(--accent-dim); }
|
||||
.auth-btn {
|
||||
width: 100%;
|
||||
padding: 0.65rem;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
font-size: 0.9rem;
|
||||
font-family: var(--font);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
margin-top: 0.5rem;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.auth-btn:hover { opacity: 0.88; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="auth-card">
|
||||
<div class="auth-logo">
|
||||
<img src="{{ logo_url }}" alt="logo">
|
||||
<div class="auth-logo-name">{{ brand_name }}</div>
|
||||
<div class="auth-logo-sub">oAI-Web</div>
|
||||
</div>
|
||||
|
||||
<div class="setup-heading">Create admin account</div>
|
||||
<div class="setup-desc">No users exist yet. Create the first admin account to get started.</div>
|
||||
|
||||
{% if errors %}
|
||||
<div class="auth-error">
|
||||
{% for e in errors %}<div>{{ e }}</div>{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="/setup">
|
||||
<div class="auth-field">
|
||||
<label for="username">Username</label>
|
||||
<input type="text" id="username" name="username" value="{{ username }}" autocomplete="username" autofocus required>
|
||||
</div>
|
||||
<div class="auth-field">
|
||||
<label for="email">Email</label>
|
||||
<input type="email" id="email" name="email" value="{{ email }}" autocomplete="email" required>
|
||||
</div>
|
||||
<div class="auth-field">
|
||||
<label for="password">Password (min 8 characters)</label>
|
||||
<input type="password" id="password" name="password" autocomplete="new-password" required>
|
||||
</div>
|
||||
<div class="auth-field">
|
||||
<label for="confirm">Confirm password</label>
|
||||
<input type="password" id="confirm" name="confirm" autocomplete="new-password" required>
|
||||
</div>
|
||||
<button type="submit" class="auth-btn">Create account & sign in</button>
|
||||
</form>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
151
server/web/themes.py
Normal file
151
server/web/themes.py
Normal file
@@ -0,0 +1,151 @@
|
||||
"""
|
||||
web/themes.py — CSS variable themes for oAI-Web.
|
||||
|
||||
Each theme is a set of CSS variable overrides injected into base.html as a
|
||||
<style> block, overriding the :root defaults in style.css.
|
||||
Non-colour tokens (--radius, --font, --mono) are not overridden so they
|
||||
inherit from style.css.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
DEFAULT_THEME = "dark"
|
||||
|
||||
# Each entry: id → {label, preview (bg, accent), vars}
|
||||
THEMES: dict[str, dict] = {
|
||||
"dark": {
|
||||
"label": "Dark",
|
||||
"preview": {"bg": "#1a1d27", "accent": "#6c8ef5"},
|
||||
"vars": {
|
||||
"--bg": "#0f1117",
|
||||
"--bg2": "#1a1d27",
|
||||
"--bg3": "#22263a",
|
||||
"--border": "#2e3249",
|
||||
"--text": "#e2e4ef",
|
||||
"--text-dim": "#7b82a8",
|
||||
"--accent": "#6c8ef5",
|
||||
"--accent-dim": "#3d5099",
|
||||
"--green": "#4caf7d",
|
||||
"--red": "#e05252",
|
||||
"--yellow": "#e0a632",
|
||||
},
|
||||
},
|
||||
"darker": {
|
||||
"label": "Darker",
|
||||
"preview": {"bg": "#111111", "accent": "#6c8ef5"},
|
||||
"vars": {
|
||||
"--bg": "#000000",
|
||||
"--bg2": "#0d0d0d",
|
||||
"--bg3": "#1a1a1a",
|
||||
"--border": "#2a2a2a",
|
||||
"--text": "#e2e4ef",
|
||||
"--text-dim": "#6b7280",
|
||||
"--accent": "#6c8ef5",
|
||||
"--accent-dim": "#3d5099",
|
||||
"--green": "#4caf7d",
|
||||
"--red": "#e05252",
|
||||
"--yellow": "#e0a632",
|
||||
},
|
||||
},
|
||||
"light": {
|
||||
"label": "Light",
|
||||
"preview": {"bg": "#ffffff", "accent": "#4b6ef5"},
|
||||
"vars": {
|
||||
"--bg": "#f5f6fa",
|
||||
"--bg2": "#ffffff",
|
||||
"--bg3": "#eef0f7",
|
||||
"--border": "#d1d5e8",
|
||||
"--text": "#1a1d2e",
|
||||
"--text-dim": "#6b7280",
|
||||
"--accent": "#4b6ef5",
|
||||
"--accent-dim": "#a3b4fa",
|
||||
"--green": "#2e8a57",
|
||||
"--red": "#c0392b",
|
||||
"--yellow": "#b7791f",
|
||||
},
|
||||
},
|
||||
"nord": {
|
||||
"label": "Nord",
|
||||
"preview": {"bg": "#3b4252", "accent": "#88c0d0"},
|
||||
"vars": {
|
||||
"--bg": "#2e3440",
|
||||
"--bg2": "#3b4252",
|
||||
"--bg3": "#434c5e",
|
||||
"--border": "#4c566a",
|
||||
"--text": "#eceff4",
|
||||
"--text-dim": "#8a97b0",
|
||||
"--accent": "#88c0d0",
|
||||
"--accent-dim": "#4a7a8a",
|
||||
"--green": "#a3be8c",
|
||||
"--red": "#bf616a",
|
||||
"--yellow": "#ebcb8b",
|
||||
},
|
||||
},
|
||||
"solarized": {
|
||||
"label": "Solarized Dark",
|
||||
"preview": {"bg": "#073642", "accent": "#268bd2"},
|
||||
"vars": {
|
||||
"--bg": "#002b36",
|
||||
"--bg2": "#073642",
|
||||
"--bg3": "#0d4454",
|
||||
"--border": "#1a5566",
|
||||
"--text": "#839496",
|
||||
"--text-dim": "#586e75",
|
||||
"--accent": "#268bd2",
|
||||
"--accent-dim": "#1a5c8c",
|
||||
"--green": "#859900",
|
||||
"--red": "#dc322f",
|
||||
"--yellow": "#b58900",
|
||||
},
|
||||
},
|
||||
"gruvbox": {
|
||||
"label": "Gruvbox",
|
||||
"preview": {"bg": "#3c3836", "accent": "#d79921"},
|
||||
"vars": {
|
||||
"--bg": "#282828",
|
||||
"--bg2": "#3c3836",
|
||||
"--bg3": "#504945",
|
||||
"--border": "#665c54",
|
||||
"--text": "#ebdbb2",
|
||||
"--text-dim": "#928374",
|
||||
"--accent": "#458588",
|
||||
"--accent-dim": "#2d5b5e",
|
||||
"--green": "#98971a",
|
||||
"--red": "#cc241d",
|
||||
"--yellow": "#d79921",
|
||||
},
|
||||
},
|
||||
"catppuccin": {
|
||||
"label": "Catppuccin",
|
||||
"preview": {"bg": "#1e1e2e", "accent": "#cba6f7"},
|
||||
"vars": {
|
||||
"--bg": "#1e1e2e",
|
||||
"--bg2": "#181825",
|
||||
"--bg3": "#313244",
|
||||
"--border": "#45475a",
|
||||
"--text": "#cdd6f4",
|
||||
"--text-dim": "#6c7086",
|
||||
"--accent": "#cba6f7",
|
||||
"--accent-dim": "#6e4d9e",
|
||||
"--green": "#a6e3a1",
|
||||
"--red": "#f38ba8",
|
||||
"--yellow": "#f9e2af",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def get_theme_css(theme_id: str) -> str:
|
||||
"""Return a CSS :root override block for the given theme, or empty string for the default."""
|
||||
if theme_id == DEFAULT_THEME or theme_id not in THEMES:
|
||||
return ""
|
||||
vars = THEMES[theme_id]["vars"]
|
||||
lines = "\n".join(f" {k}: {v};" for k, v in vars.items())
|
||||
return f":root {{\n{lines}\n}}"
|
||||
|
||||
|
||||
def theme_list() -> list[dict]:
|
||||
"""Return theme metadata for the UI picker (no CSS vars)."""
|
||||
return [
|
||||
{"id": tid, "label": t["label"], "preview": t["preview"]}
|
||||
for tid, t in THEMES.items()
|
||||
]
|
||||
Reference in New Issue
Block a user