Merge lab into refactor/settings-shell-modules

This commit is contained in:
Alexandre Teixeira
2026-10-01 01:41:27 +01:00
171 changed files with 50872 additions and 48383 deletions
+1 -1
View File
@@ -37,7 +37,7 @@ import { UI_VIS_DEFAULT_OFF, resolveVisibility } from './js/ui_visibility.js?v=2
import tasksModule from './js/tasks.js?v=20260914taskmodel1';
import calendarModule from './js/calendar.js?v=20260914emailsource11';
import notesModule from './js/notes.js?v=20260911notesselectioncancel1';
import adminModule from './js/admin.js?v=20260921chatgptusage2';
import adminModule from './js/admin.js?v=20260929csssplit';
import settingsModule from './js/settings.js?v=20260912writingstyle3';
// Eagerly bind unified minimize/restore behavior across all tool modals.
import './js/modalManager.js';
+183
View File
@@ -0,0 +1,183 @@
/* 00-tokens - part of the former static/style.css.
*
* The stylesheet is split by area, but the split is mechanical: rules were
* moved verbatim and NOT regrouped. The numeric prefix is the load order, and
* that order is what preserves the cascade. A rule sits in the file its
* original position fell into, so an area's rules are not all in its file and
* a file is not only that area's rules.
*
* Do not reorder the <link> tags in static/index.html, and do not move a rule
* between files: with 1,151 redeclarations and 1,886 !important in this
* cascade, either can change which declaration wins.
*
* static/index.html and the PRECACHE list in static/sw.js must agree on this
* order. tests/test_static_stylesheet_manifest.py and
* tests/test_css_computed_style_snapshot.py fail if that drifts.
*/
/* ============================================ */
/* Odysseus UI — Consolidated Stylesheet */
/* ============================================ */
/* ── Variables ──
*
* Theme-public variables (override via theme.js or custom themes):
* Core: --bg, --fg, --panel, --border, --red
* Syntax: --hl-keyword, --hl-string, --hl-comment, --hl-function,
* --hl-number, --hl-builtin, --hl-variable, --hl-params,
* --hl-bg, --hl-fg
* Accents: --accent-primary, --accent-error (set by theme.js)
* Semantic: --color-error, --color-success, --color-warning, --color-danger,
* --color-accent, --color-muted, --color-muted-alt
*/
:root {
/* Core palette */
--bg: #282c34;
--fg: #9cdef2;
--panel: #111;
--border: #355a66;
--red: #e06c75;
/* Were `var(--green)` / `var(--warn)` — self-referential, so they
resolved to invalid and every site fell back to its own literal
(or, for sites with no fallback, painted as transparent/inherit).
Anchor them to real hex so the token layer actually works. */
--green: #50fa7b;
--warn: #f0ad4e;
/* Syntax highlighting */
--hl-bg: #1e2228;
--hl-fg: #9cdef2;
--hl-keyword: #c678dd;
--hl-string: #e5c07b;
--hl-comment: #828997;
--hl-function: #61afef;
--hl-number: #d19a66;
--hl-builtin: #56b6c2;
--hl-variable: #abb2bf;
--hl-params: #a8c0d4;
/* Semantic colors */
--color-error: #ff4444;
--color-error-light: #ff6666;
--color-success: #4caf50;
--color-warning: #f0ad4e;
--color-danger: #c0392b;
--color-recording: #ff3b30;
--color-recording-hover: #d63031;
--color-muted: #888;
--color-muted-alt: #6b7280;
--color-accent: #00aaff;
--color-agent-active: #00ff00;
--color-brand-blue: #3b82f6;
--color-blind-orange: #ff9800;
--color-save-green: var(--color-success);
--color-link-hover: #66c7ff;
--color-subheader: #6b8a94;
--select-bg: var(--bg);
--select-fg: var(--fg);
--select-option-bg: color-mix(in srgb, var(--panel) 74%, var(--bg));
--select-option-fg: var(--fg);
--select-option-active-bg: color-mix(in srgb, var(--accent, var(--red)) 24%, var(--panel));
/* Warm accent — used by the Goals/Today UI in Notes. Lives as a token so
themes can override without touching the goal CSS. */
--accent-warm: #d19a66;
}
:root.light {
--bg: #f5f5f5;
--fg: #2b2b2b;
--panel: #fff;
--border: #bbb;
--hl-bg: #f9f9f9;
--hl-fg: #2b2b2b;
--hl-keyword: #7928a1;
--hl-string: #986801;
--hl-comment: #6a737d;
--hl-function: #005cc5;
--hl-number: #986801;
--hl-builtin: #0070a0;
--hl-variable: #383a42;
--hl-params: #4a4f5c;
--select-bg: #eaeaea;
--select-fg: var(--fg);
--select-option-bg: var(--panel);
--select-option-fg: var(--fg);
--select-option-active-bg: color-mix(in srgb, var(--red) 16%, var(--panel));
}
/* ── Reset & Base ── */
* { box-sizing: border-box; }
html, body { overflow-x: hidden; height: 100%; margin: 0; overscroll-behavior: none; }
body {
background-color: var(--bg);
color: var(--fg);
/* Keep the base padding transition for older layout paths that still adjust
the body directly. Edge docks reserve workspace room on the flex panes
below so left + right docks can coexist without skewing the whole body. */
transition: padding-left 160ms cubic-bezier(0.22, 0.61, 0.36, 1),
padding-right 160ms cubic-bezier(0.22, 0.61, 0.36, 1);
font-family: var(--font-family, 'Fira Code', monospace);
display: flex;
height: 100%;
height: 100dvh; /* dynamic viewport height — adapts when mobile keyboard opens */
overflow: hidden;
}
/* Self-hosted Fira Code font */
@font-face { font-family: 'Fira Code'; font-weight: 300; font-style: normal; font-display: swap; src: url('/static/fonts/FiraCode-Light.woff2') format('woff2'); }
@font-face { font-family: 'Fira Code'; font-weight: 400; font-style: normal; font-display: swap; src: url('/static/fonts/FiraCode-Regular.woff2') format('woff2'); }
@font-face { font-family: 'Fira Code'; font-weight: 600; font-style: normal; font-display: swap; src: url('/static/fonts/FiraCode-SemiBold.woff2') format('woff2'); }
/* GohuFont is the default UI face across themes; keep the shipped font local
so the default is available before custom-font discovery completes. */
@font-face { font-family: 'GohuFont'; font-weight: 400; font-style: normal; font-display: swap; src: url('/static/fonts/custom/GohuFont.ttf') format('truetype'); }
/* Self-hosted OpenDyslexic — dyslexia-friendly accessibility font option (SIL OFL 1.1) */
@font-face { font-family: 'OpenDyslexic'; font-weight: 400; font-style: normal; font-display: swap; src: url('/static/fonts/OpenDyslexic-Regular.woff2') format('woff2'); }
@font-face { font-family: 'OpenDyslexic'; font-weight: 700; font-style: normal; font-display: swap; src: url('/static/fonts/OpenDyslexic-Bold.woff2') format('woff2'); }
/* Code block baseline */
pre, code, .hljs {
font-size: 11px;
line-height: 1.5;
}
/* Scrollbar styling */
@supports selector(::-webkit-scrollbar) {
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--panel);
}
::-webkit-scrollbar-thumb {
background-color: var(--red);
border-radius: 4px;
border: 2px solid var(--panel);
}
::-webkit-scrollbar-thumb:hover {
background-color: color-mix(in srgb, var(--red) 80%, white);
}
}
html {
scrollbar-color: var(--red) var(--panel);
scrollbar-width: thin;
}
/* Utility */
.red-text { color: var(--red); }
/* ── Density Overrides ── */
:root.density-compact { font-size: 13px; }
:root.density-compact .msg { padding: 6px 10px; margin-bottom: 4px; }
:root.density-compact .list-item { padding: 4px 8px; }
:root.density-compact .sidebar .section { padding: 0; }
:root.density-spacious { font-size: 16px; }
:root.density-spacious .msg { padding: 14px 18px; margin-bottom: 12px; }
:root.density-spacious .list-item { padding: 8px 12px; }
:root.density-spacious .sidebar .section { padding: 0; }
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+839
View File
@@ -0,0 +1,839 @@
/* 12-gallery - part of the former static/style.css.
*
* The stylesheet is split by area, but the split is mechanical: rules were
* moved verbatim and NOT regrouped. The numeric prefix is the load order, and
* that order is what preserves the cascade. A rule sits in the file its
* original position fell into, so an area's rules are not all in its file and
* a file is not only that area's rules.
*
* Do not reorder the <link> tags in static/index.html, and do not move a rule
* between files: with 1,151 redeclarations and 1,886 !important in this
* cascade, either can change which declaration wins.
*
* static/index.html and the PRECACHE list in static/sw.js must agree on this
* order. tests/test_static_stylesheet_manifest.py and
* tests/test_css_computed_style_snapshot.py fail if that drifts.
*/
.research-synapse.rs-phase-reading .rs-phase-chip,
.research-synapse.rs-phase-analyzing .rs-phase-chip { color: var(--gold, #d4a72c); }
.research-synapse.rs-phase-writing .rs-phase-chip { color: var(--color-success, #4caf50); }
@media (prefers-reduced-motion: reduce) {
.research-synapse .rs-live-dot,
.research-synapse .rs-pulse,
.research-synapse .rs-node-new,
.research-synapse .rs-node-expanded,
.research-synapse .rs-edge-firing { animation: none !important; }
}
/* ── Raw findings items (inside sources-style box) ── */
.finding-item {
display: flex;
flex-direction: column;
gap: 4px;
}
.finding-item .source-link {
border-radius: 6px 6px 0 0;
}
.finding-summary {
padding: 6px 8px 8px;
font-size: 0.82em;
line-height: 1.5;
color: var(--fg-dim, var(--fg));
opacity: 0.85;
border-bottom: 1px solid color-mix(in srgb, var(--fg) 6%, transparent);
margin-bottom: 4px;
white-space: pre-wrap;
word-break: break-word;
}
.finding-item:last-child .finding-summary {
border-bottom: none;
margin-bottom: 0;
}
/* ═══════════════════════════════════════════════════════════
Gallery Editor
═══════════════════════════════════════════════════════════ */
/* Tab bar */
.gallery-tabs {
display: flex;
gap: 0;
border-bottom: 1px solid var(--border);
padding: 0 16px;
background: var(--panel);
}
.gallery-tab {
padding: 8px 18px;
background: none;
border: none;
border-bottom: 2px solid transparent;
color: var(--fg);
opacity: 0.6;
cursor: pointer;
font-size: 13px;
font-weight: 500;
transition: opacity 0.15s, border-color 0.15s;
}
.gallery-tab:hover { opacity: 0.85; }
.gallery-tab.active {
opacity: 1;
border-bottom-color: var(--red);
}
/* Narrow modal tab strips should stay on one row. Resized docked windows can
be much narrower than the viewport, so this cannot live only in mobile media
queries. */
.cookbook-tabs,
.memory-tabs,
.admin-tabs,
.lib-tabs,
.gallery-tabs,
.preset-tabs {
flex-wrap: nowrap !important;
overflow-x: auto !important;
overflow-y: hidden;
-webkit-overflow-scrolling: touch;
overscroll-behavior-x: contain;
scrollbar-width: none;
}
.cookbook-tabs::-webkit-scrollbar,
.memory-tabs::-webkit-scrollbar,
.admin-tabs::-webkit-scrollbar,
.lib-tabs::-webkit-scrollbar,
.gallery-tabs::-webkit-scrollbar,
.preset-tabs::-webkit-scrollbar {
display: none;
}
.cookbook-tabs > *,
.memory-tabs > *,
.admin-tabs > *,
.lib-tabs > *,
.gallery-tabs > *,
.preset-tabs > * {
flex: 0 0 auto;
}
.cookbook-tab,
.memory-tab,
.admin-tab,
.lib-tab,
.gallery-tab,
.preset-tab {
display: inline-flex;
align-items: center;
justify-content: center;
white-space: nowrap;
line-height: 1;
}
.gallery-tab {
gap: 6px;
}
@container (max-width: 360px) {
.cookbook-tab:has(svg),
.memory-tab:has(svg),
.admin-tab:has(svg),
.lib-tab:has(svg),
.gallery-tab:has(svg),
.preset-tab:has(svg) {
width: 34px;
min-width: 34px;
padding-left: 0;
padding-right: 0;
font-size: 0;
}
.cookbook-tab:has(svg) svg,
.memory-tab:has(svg) svg,
.admin-tab:has(svg) svg,
.lib-tab:has(svg) svg,
.gallery-tab:has(svg) svg,
.preset-tab:has(svg) svg {
width: 14px;
height: 14px;
margin-right: 0 !important;
vertical-align: middle !important;
}
.memory-tab:has(svg) .memory-count,
.gallery-tab:has(svg) .gallery-tab-label,
.gallery-tab:has(svg) .gallery-tab-close,
.cookbook-tab:has(svg) .cookbook-tab-count,
.preset-tab:has(svg) .preset-count {
display: none !important;
}
}
/* Icon + label layout inside each tab. */
.gallery-tab {
display: inline-flex;
align-items: center;
gap: 6px;
}
.gallery-tab-icon {
display: inline-flex;
align-items: center;
opacity: 0.85;
}
.gallery-tab.active .gallery-tab-icon { opacity: 1; }
/* Close × on the Edit tab — appears on hover. */
.gallery-tab-close {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
margin-left: 2px;
border-radius: 4px;
opacity: 0;
transition: opacity 0.12s, background 0.12s;
/* Use a slightly larger glyph at a larger line-height so the × sits
visually centered — the U+00D7 character has uneven em-box metrics. */
font-size: 16px;
line-height: 16px;
font-weight: 500;
cursor: pointer;
padding-bottom: 2px;
box-sizing: border-box;
}
.gallery-tab:hover .gallery-tab-close,
.gallery-tab.active .gallery-tab-close { opacity: 0.65; }
/* Hide the close × entirely on the Edit tab when there's no edit open. */
.gallery-tab[data-tab="editor"]:not(.has-edit) .gallery-tab-close { display: none !important; }
.gallery-tab-close:hover { opacity: 1 !important; background: color-mix(in srgb, var(--red) 25%, transparent); color: var(--red); }
/* Inline rename input shown when the Edit tab is double-clicked. */
.gallery-tab-rename-input {
background: var(--bg);
border: 1px solid var(--red);
border-radius: 3px;
color: var(--fg);
font: inherit;
font-size: 13px;
padding: 1px 4px;
width: 140px;
outline: none;
}
/* Albums tab — grid of album cards with cover thumbnails. */
.gallery-albums-container {
padding: 12px 4px;
max-height: calc(92vh - 120px);
overflow-y: auto;
border: 2px dashed transparent;
border-radius: 8px;
transition: border-color 0.15s, background 0.15s;
}
.gallery-settings-container {
padding: 8px 4px;
max-height: calc(92vh - 120px);
overflow-y: auto;
}
.gallery-albums-container.gallery-dragover {
border-color: var(--red);
background: color-mix(in srgb, var(--red) 5%, transparent);
}
.gallery-albums-empty {
display: flex; flex-direction: column; align-items: center; gap: 12px;
padding: 48px 16px;
opacity: 0.7;
font-size: 13px;
}
.gallery-albums-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 12px;
}
.gallery-album-card {
position: relative;
display: flex;
flex-direction: column;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
cursor: pointer;
transition: transform 0.12s, border-color 0.12s, box-shadow 0.12s;
}
.gallery-album-card:hover {
transform: translateY(-2px);
border-color: var(--red);
box-shadow: 0 4px 14px color-mix(in srgb, var(--fg) 12%, transparent);
}
.gallery-album-menu-btn {
position: absolute;
top: 6px; right: 6px;
width: 24px; height: 24px;
display: flex; align-items: center; justify-content: center;
background: color-mix(in srgb, var(--bg) 70%, transparent);
color: var(--fg);
border: 1px solid color-mix(in srgb, var(--border) 60%, transparent);
border-radius: 6px;
cursor: pointer;
opacity: 0;
transition: opacity 0.12s, background 0.12s;
z-index: 2;
backdrop-filter: blur(4px);
/* Plain "…" text — the previous SVG was rendering as a flat black
block on some themes when its currentColor didn't contrast. */
font-size: 16px;
line-height: 1;
font-weight: 600;
padding: 0 0 4px;
}
.gallery-album-card:hover .gallery-album-menu-btn,
.gallery-album-menu-btn:focus-visible { opacity: 1; }
.gallery-album-menu-btn:hover { background: var(--bg); }
.gallery-album-menu-pop {
position: absolute;
top: 34px; right: 6px;
min-width: 140px;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 8px;
box-shadow: 0 4px 14px color-mix(in srgb, var(--fg) 18%, transparent);
display: flex; flex-direction: column;
padding: 4px;
z-index: 3;
}
.gallery-album-menu-pop[hidden] { display: none; }
.gallery-album-cover {
aspect-ratio: 4 / 3;
background: color-mix(in srgb, var(--fg) 5%, var(--bg));
display: flex; align-items: center; justify-content: center;
overflow: hidden;
}
.gallery-album-placeholder {
opacity: 0.4;
font-size: 40px;
display: flex; align-items: center; justify-content: center;
width: 100%; height: 100%;
}
.gallery-album-info {
padding: 8px 10px;
display: flex; flex-direction: column; gap: 2px;
}
.gallery-album-name {
font-size: 13px;
font-weight: 500;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.gallery-album-count {
font-size: 11px;
opacity: 0.55;
}
.gallery-album-card-add {
border-style: dashed;
opacity: 0.7;
}
.gallery-album-card-add:hover { opacity: 1; }
.gallery-album-action-tile {
background: color-mix(in srgb, var(--fg) 3%, var(--bg));
}
/* Edit-tab empty state — shown before any image is loaded. */
.gallery-editor-landing {
display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: 10px;
padding: 28px 16px;
flex: 1;
text-align: center;
color: var(--fg);
}
.gallery-editor-landing h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
}
.gallery-editor-heading {
display: inline-flex;
align-items: center;
gap: 7px;
}
.gallery-editor-heading > svg { opacity: 0.6; flex: 0 0 auto; }
/* "vision model" link in the AI-tagging description → opens Settings. */
.ge-vision-link {
color: var(--accent, var(--red));
text-decoration: underline;
text-underline-offset: 2px;
cursor: pointer;
}
.ge-vision-link:hover { opacity: 0.8; }
.ge-alpha-tag {
font-size: 9px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
vertical-align: middle;
padding: 1px 5px;
border-radius: 4px;
color: var(--accent, var(--red));
background: color-mix(in srgb, var(--accent, var(--red)) 15%, transparent);
position: relative;
top: -1px;
}
.gallery-editor-landing-actions {
display: flex; gap: 10px;
margin-top: 8px;
}
/* Bigger primary action buttons specifically inside the editor landing —
the gallery-wide .gallery-select-btn is a compact toolbar style which is
too small for the empty-state hero. */
.gallery-editor-landing-actions .gallery-select-btn {
padding: 7px 22px 17px;
font-size: 13px;
opacity: 0.85;
}
/* Visible template picker: every canvas ratio is inspectable without opening
a native menu, and its SVG outline previews the resulting canvas shape. */
.gallery-editor-template-picker {
width: min(760px, 96%);
margin-top: 10px;
}
.gallery-editor-template-picker h4 {
margin: 0 0 7px;
font-size: 11px;
opacity: 0.55;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.gallery-editor-template-options {
display: grid;
grid-template-columns: repeat(4, minmax(105px, 1fr));
gap: 6px;
}
.gallery-editor-template-option {
height: 72px;
min-width: 0;
padding: 6px 7px;
display: grid;
grid-template-rows: 28px auto auto;
justify-items: center;
align-items: center;
background: var(--panel);
color: var(--fg);
border: 1px solid var(--border);
border-radius: 5px;
font: inherit;
font-size: 11px;
cursor: pointer;
overflow: hidden;
}
.gallery-editor-template-option svg { color: var(--accent, var(--red)); }
.gallery-editor-template-option:hover,
.gallery-editor-template-option:focus-visible {
outline: none;
border-color: var(--accent, var(--red));
color: var(--accent, var(--red));
background: color-mix(in srgb, var(--accent, var(--red)) 7%, var(--panel));
}
.gallery-editor-template-option span {
width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 600;
}
.gallery-editor-template-option small {
width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 9px;
opacity: 0.52;
}
@media (max-width: 620px) {
.gallery-editor-landing {
justify-content: flex-start;
gap: 7px;
padding: 18px 12px;
overflow-y: auto;
}
.gallery-editor-landing-actions {
width: 100%;
gap: 6px;
justify-content: center;
}
.gallery-editor-landing-actions .gallery-select-btn {
flex: 1 1 0;
min-width: 0;
max-width: 180px;
padding: 6px 8px 10px;
font-size: 11px;
}
.gallery-editor-template-picker {
width: 100%;
margin-top: 4px;
}
.gallery-editor-template-options {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
/* The common square, widescreen, portrait, and Instagram presets are
enough to start on a phone. Keep the print and 4K presets on desktop. */
.gallery-editor-template-option:nth-child(n + 5) {
display: none;
}
}
/* Saved-drafts grid on the editor landing. Surfaces every persisted
in-progress project so the user can resume without re-opening the
original photo. Each card: thumbnail + name + last-saved hint + ×. */
.gallery-editor-drafts {
width: min(720px, 92%);
margin: 28px auto 0; /* centered */
padding-top: 18px;
border-top: 1px solid var(--border);
display: flex;
flex-direction: column;
gap: 10px;
}
.gallery-editor-drafts {
position: relative;
}
.gallery-editor-drafts-loading {
position: absolute;
inset: 30px 0 0 0;
z-index: 4;
display: flex;
align-items: center;
justify-content: center;
background: var(--panel);
border-radius: 6px;
min-height: 80px;
}
.gallery-editor-drafts-header {
display: flex;
align-items: center;
justify-content: center; /* center the title / search / select */
gap: 8px;
margin-bottom: 8px;
}
.gallery-editor-drafts-title {
margin: 0;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.06em;
opacity: 0.55;
font-weight: 600;
text-align: left;
flex-shrink: 0;
}
.gallery-editor-drafts-search {
flex: 1 1 auto;
min-width: 0;
max-width: none;
height: 26px; /* same thickness as the Select button */
box-sizing: border-box;
padding: 0 8px;
font-size: 12px;
background: var(--bg);
color: var(--fg);
border: 1px solid var(--border);
border-radius: 4px;
}
.gallery-editor-drafts-search:focus { outline: none; border-color: var(--red); }
/* The shared .gallery-select-btn has a -4px top margin + tall asymmetric padding
meant for the main gallery toolbar; in this header it makes the button a
different height / offset from the search box. Normalize so it lines up. */
.gallery-editor-drafts-header .gallery-select-btn {
margin-top: 0 !important;
height: 26px; /* match the search bar */
box-sizing: border-box;
padding: 0 10px;
font-size: 12px;
line-height: 1;
flex-shrink: 0;
}
.cookbook-official-filter {
display: inline-flex;
align-items: center;
gap: 4px;
flex: 0 1 auto;
min-width: 0;
max-width: 100%;
color: var(--fg);
font-size: 10px;
opacity: 0.72;
white-space: nowrap;
position: relative;
top: 1px;
}
.cookbook-official-filter > span:first-child {
position: relative;
top: 1px;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
font-size: 9px;
}
.cookbook-official-filter .toggle {
width: 26px;
height: 14px;
}
.cookbook-official-filter .toggle .slider:before {
width: 10px;
height: 10px;
left: 2px;
bottom: 2px;
}
.cookbook-official-filter .toggle input:checked + .slider:before {
transform: translateX(12px);
}
#gallery-editor-drafts-select { margin-left: auto; flex-shrink: 0; }
#gallery-editor-drafts-select.active {
background: color-mix(in srgb, var(--accent, var(--red)) 18%, transparent);
border-color: var(--accent, var(--red));
color: var(--accent, var(--red));
}
/* Graceful exit when a project card is deleted — fade + shrink before re-render. */
.gallery-editor-draft-card.gallery-draft-removing {
opacity: 0;
transform: scale(0.92);
transition: opacity 0.24s ease, transform 0.24s ease;
pointer-events: none;
}
/* Draft select dot uses the standard small .gallery-select-dot look (same as the
photo grid) — no oversized white-ring checkbox. */
.gallery-editor-drafts-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 10px;
}
.gallery-drafts-just-opened > .gallery-editor-draft-card {
animation: section-domino-in 0.36s cubic-bezier(0.22, 1.61, 0.36, 1) backwards;
}
.gallery-drafts-just-opened > :nth-child(1) { animation-delay: 0.02s; }
.gallery-drafts-just-opened > :nth-child(2) { animation-delay: 0.04s; }
.gallery-drafts-just-opened > :nth-child(3) { animation-delay: 0.06s; }
.gallery-drafts-just-opened > :nth-child(4) { animation-delay: 0.08s; }
.gallery-drafts-just-opened > :nth-child(5) { animation-delay: 0.10s; }
.gallery-drafts-just-opened > :nth-child(6) { animation-delay: 0.12s; }
.gallery-drafts-just-opened > :nth-child(7) { animation-delay: 0.14s; }
.gallery-drafts-just-opened > :nth-child(8) { animation-delay: 0.16s; }
.gallery-drafts-just-opened > :nth-child(9) { animation-delay: 0.18s; }
.gallery-drafts-just-opened > :nth-child(10) { animation-delay: 0.20s; }
.gallery-drafts-just-opened > :nth-child(11) { animation-delay: 0.22s; }
.gallery-drafts-just-opened > :nth-child(12) { animation-delay: 0.24s; }
.gallery-drafts-just-opened > :nth-child(13) { animation-delay: 0.26s; }
.gallery-drafts-just-opened > :nth-child(14) { animation-delay: 0.28s; }
.gallery-drafts-just-opened > :nth-child(15) { animation-delay: 0.30s; }
.gallery-drafts-just-opened > :nth-child(16) { animation-delay: 0.32s; }
.gallery-drafts-just-opened > :nth-child(17) { animation-delay: 0.34s; }
.gallery-drafts-just-opened > :nth-child(18) { animation-delay: 0.36s; }
.gallery-drafts-just-opened > :nth-child(19) { animation-delay: 0.38s; }
.gallery-drafts-just-opened > :nth-child(20) { animation-delay: 0.40s; }
.gallery-drafts-just-opened > :nth-child(n+21) { animation-delay: 0.42s; }
.gallery-drafts-view-toggle {
display: inline-flex;
position: relative;
top: 0;
height: 24px;
flex: 0 0 auto;
overflow: hidden;
border: 1px solid var(--border);
border-radius: 5px;
background: var(--bg);
}
.gallery-drafts-view-toggle button {
width: 25px;
height: 22px;
margin: 0;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
border: 0;
border-right: 1px solid var(--border);
background: transparent;
color: color-mix(in srgb, var(--fg) 55%, transparent);
cursor: pointer;
line-height: 0;
}
.gallery-drafts-view-toggle button:last-child { border-right: 0; }
.gallery-drafts-view-toggle button:hover { color: var(--fg); }
.gallery-drafts-view-toggle button.active {
height: 22px;
color: var(--accent, var(--red));
background: color-mix(in srgb, var(--accent, var(--red)) 12%, transparent);
}
.gallery-editor-drafts-grid.list-view .gallery-editor-draft-thumb {
width: 44px;
height: 44px;
aspect-ratio: auto;
}
.gallery-editor-drafts-grid.list-view .gallery-editor-draft-delete {
position: static;
opacity: 0.55;
background: transparent;
color: var(--fg);
}
.gallery-editor-draft-delete-trash { display: none; }
.gallery-editor-drafts-grid.list-view .gallery-editor-draft-delete-trash {
display: block;
width: 18px;
height: 18px;
position: relative;
top: -2px;
}
.gallery-editor-drafts-grid.list-view .gallery-editor-draft-delete:hover {
opacity: 1;
color: var(--color-error, #f44);
background: color-mix(in srgb, var(--color-error, #f44) 10%, transparent);
}
.gallery-editor-draft-card {
position: relative;
display: flex;
flex-direction: column;
gap: 4px;
padding: 6px;
border: 1px solid var(--border);
border-radius: 8px;
background: color-mix(in srgb, var(--panel) 70%, transparent);
cursor: pointer;
transition: border-color 0.15s, background 0.15s, transform 0.15s;
text-align: left;
}
.gallery-editor-draft-card:hover {
border-color: var(--accent, var(--red));
background: color-mix(in srgb, var(--panel) 90%, transparent);
}
.gallery-editor-draft-thumb {
width: 100%;
aspect-ratio: 1;
object-fit: cover;
border-radius: 4px;
background: var(--bg);
display: block;
}
.gallery-editor-draft-thumb-empty {
background: repeating-linear-gradient(
45deg,
color-mix(in srgb, var(--fg) 4%, transparent),
color-mix(in srgb, var(--fg) 4%, transparent) 6px,
transparent 6px,
transparent 12px
);
}
.gallery-editor-draft-info {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.gallery-editor-draft-name {
font-size: 12px;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.gallery-editor-draft-meta {
font-size: 10px;
opacity: 0.55;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.gallery-editor-draft-delete {
position: absolute;
top: -2px;
right: 8px;
width: 22px;
height: 22px;
border-radius: 50%;
border: none;
background: rgba(0, 0, 0, 0.55);
color: #fff;
font-size: 14px;
line-height: 1;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
opacity: 0;
transition: opacity 0.12s, background 0.12s;
}
.gallery-editor-draft-delete {
padding-bottom: 2px;
}
.gallery-editor-draft-delete:hover { background: var(--red); }
/* On very narrow phones, give project search its own row instead of
compressing the title and view controls into an overflowing header. */
@media (max-width: 360px) {
.gallery-editor-drafts-header {
flex-wrap: wrap;
justify-content: flex-start;
gap: 6px;
}
.gallery-editor-drafts-title {
flex: 1 1 auto;
}
.gallery-editor-drafts-search {
order: 10;
flex: 1 1 100%;
width: 100%;
max-width: none;
}
#gallery-editor-drafts-select {
margin-left: 0;
}
}
/* Editor layout */
.gallery-editor {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
height: 100%;
overflow: hidden;
/* Prevent the editor's own scroll/touch from chaining out to the
gallery modal or the page body — critical on mobile so touching
the canvas doesn't slide the whole page. */
overscroll-behavior: contain;
touch-action: pan-y;
}
/* Pin the topbar to the top of the editor so its action buttons remain
visible if the gallery modal body itself ends up scrolling. */
.ge-topbar { position: sticky; top: 0; z-index: 5; }
/* Top bar */
.ge-topbar {
display: flex;
flex-wrap: nowrap;
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: thin;
align-items: center;
justify-content: space-between;
padding: 6px 10px;
background: var(--panel);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
gap: 8px;
}
.ge-topbar-left, .ge-topbar-right {
display: flex;
flex: 0 0 auto;
align-items: center;
gap: 4px;
}
.ge-topbar-right > * {
flex-shrink: 0;
}
.ge-topbar .dropdown[popover] {
position: fixed;
margin: 0;
bottom: auto;
max-height: calc(100vh - 80px);
overflow-y: auto;
}
.ge-draft-status {
flex: 0 1 auto;
min-width: 0;
max-width: 92px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: color-mix(in srgb, var(--fg) 52%, transparent);
font-size: 10px;
line-height: 1;
transition: color 0.15s, opacity 0.15s;
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,22 +1,20 @@
/* Cookbook (including What Fits?), deep research, memory, settings and admin.
*
* Split out of style.css for maintainability, not for performance: this file
* is loaded eagerly from index.html immediately after style.css, so the rules
* land in the cascade in exactly the order they had inside the single file.
* Nothing here is lazy-loaded.
* This is an eagerly loaded tail segment of the app stylesheet cascade. The
* former static/style.css now lives in the numbered 00-17 fragments; these
* three pre-existing panel files follow those fragments without changing
* their relative order. Nothing here is lazy-loaded.
*
* LOAD ORDER IS PART OF THE CONTRACT. The <link> tags in static/index.html and
* the PRECACHE list in static/sw.js must both list the stylesheets in this
* order:
* 1. static/style.css
* LOAD ORDER IS PART OF THE CONTRACT. static/index.html and the PRECACHE list
* in static/sw.js must agree:
* 1. static/css/00-tokens.css through static/css/17-research.css,
* in numeric order
* 2. static/css/documents-gallery-editor.css
* 3. static/css/email-calendar-notes-tasks.css
* 4. static/css/cookbook-research-memory-settings.css
*
* Rules were moved verbatim. A rule stays in style.css when its selector group
* covers more than one panel, when the class is shared design language used by
* modules outside this panel, or when moving it would change which of two
* equally specific rules wins.
* Rules in this file retain their existing cascade position. Shared rules
* formerly kept in static/style.css now live in the numbered fragments.
*/
/* Cookbook already shows its own running/served-status dot
@@ -28,10 +26,6 @@
0%, 100% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.5); opacity: 0.7; }
}
@keyframes research-pulse {
0%, 100% { background: color-mix(in srgb, var(--red) 12%, transparent); }
50% { background: color-mix(in srgb, var(--red) 22%, transparent); }
}
#cookbook-gguf-delete-overlay {
background:rgba(0,0,0,0.5);
backdrop-filter:blur(4px);
+10 -12
View File
@@ -1,22 +1,20 @@
/* Document library and editor, image gallery, image editor.
*
* Split out of style.css for maintainability, not for performance: this file
* is loaded eagerly from index.html immediately after style.css, so the rules
* land in the cascade in exactly the order they had inside the single file.
* Nothing here is lazy-loaded.
* This is an eagerly loaded tail segment of the app stylesheet cascade. The
* former static/style.css now lives in the numbered 00-17 fragments; these
* three pre-existing panel files follow those fragments without changing
* their relative order. Nothing here is lazy-loaded.
*
* LOAD ORDER IS PART OF THE CONTRACT. The <link> tags in static/index.html and
* the PRECACHE list in static/sw.js must both list the stylesheets in this
* order:
* 1. static/style.css
* LOAD ORDER IS PART OF THE CONTRACT. static/index.html and the PRECACHE list
* in static/sw.js must agree:
* 1. static/css/00-tokens.css through static/css/17-research.css,
* in numeric order
* 2. static/css/documents-gallery-editor.css
* 3. static/css/email-calendar-notes-tasks.css
* 4. static/css/cookbook-research-memory-settings.css
*
* Rules were moved verbatim. A rule stays in style.css when its selector group
* covers more than one panel, when the class is shared design language used by
* modules outside this panel, or when moving it would change which of two
* equally specific rules wins.
* Rules in this file retain their existing cascade position. Shared rules
* formerly kept in static/style.css now live in the numbered fragments.
*/
/* Document library search-term highlight — clear, high-contrast. */
+10 -12
View File
@@ -1,22 +1,20 @@
/* Email, calendar, notes and tasks panels.
*
* Split out of style.css for maintainability, not for performance: this file
* is loaded eagerly from index.html immediately after style.css, so the rules
* land in the cascade in exactly the order they had inside the single file.
* Nothing here is lazy-loaded.
* This is an eagerly loaded tail segment of the app stylesheet cascade. The
* former static/style.css now lives in the numbered 00-17 fragments; these
* three pre-existing panel files follow those fragments without changing
* their relative order. Nothing here is lazy-loaded.
*
* LOAD ORDER IS PART OF THE CONTRACT. The <link> tags in static/index.html and
* the PRECACHE list in static/sw.js must both list the stylesheets in this
* order:
* 1. static/style.css
* LOAD ORDER IS PART OF THE CONTRACT. static/index.html and the PRECACHE list
* in static/sw.js must agree:
* 1. static/css/00-tokens.css through static/css/17-research.css,
* in numeric order
* 2. static/css/documents-gallery-editor.css
* 3. static/css/email-calendar-notes-tasks.css
* 4. static/css/cookbook-research-memory-settings.css
*
* Rules were moved verbatim. A rule stays in style.css when its selector group
* covers more than one panel, when the class is shared design language used by
* modules outside this panel, or when moving it would change which of two
* equally specific rules wins.
* Rules in this file retain their existing cascade position. Shared rules
* formerly kept in static/style.css now live in the numbered fragments.
*/
@keyframes email-notif-breathe {
+23 -6
View File
@@ -246,15 +246,32 @@
real request is discarded and the font fetched a second time. -->
<link rel="preload" as="font" type="font/woff2" crossorigin href="/static/fonts/FiraCode-Regular.woff2">
<link rel="preload" as="font" type="font/woff2" crossorigin href="/static/fonts/FiraCode-SemiBold.woff2">
<link rel="stylesheet" href="/static/style.css?v=20260921chatgptusage2">
<link rel="stylesheet" href="/static/css/00-tokens.css?v=20260929csssplit">
<link rel="stylesheet" href="/static/css/01-agent-chat.css?v=20260929csssplit">
<link rel="stylesheet" href="/static/css/02-compare.css?v=20260929csssplit">
<link rel="stylesheet" href="/static/css/03-agent-chat.css?v=20260929csssplit">
<link rel="stylesheet" href="/static/css/04-memory.css?v=20260929csssplit">
<link rel="stylesheet" href="/static/css/05-documents.css?v=20260929csssplit">
<link rel="stylesheet" href="/static/css/06-admin-settings.css?v=20260929csssplit">
<link rel="stylesheet" href="/static/css/07-documents.css?v=20260929csssplit">
<link rel="stylesheet" href="/static/css/08-skills.css?v=20260929csssplit">
<link rel="stylesheet" href="/static/css/09-gallery.css?v=20260929csssplit">
<link rel="stylesheet" href="/static/css/10-cookbook.css?v=20260929csssplit">
<link rel="stylesheet" href="/static/css/11-tasks.css?v=20260929csssplit">
<link rel="stylesheet" href="/static/css/12-gallery.css?v=20260929csssplit">
<link rel="stylesheet" href="/static/css/13-image-editor.css?v=20260929csssplit">
<link rel="stylesheet" href="/static/css/14-email.css?v=20260929csssplit">
<link rel="stylesheet" href="/static/css/15-notes.css?v=20260929csssplit">
<link rel="stylesheet" href="/static/css/16-calendar.css?v=20260929csssplit">
<link rel="stylesheet" href="/static/css/17-research.css?v=20260929csssplit">
<!-- Panel stylesheets split out of style.css for maintainability. They are
eager and their order is load-bearing: the cascade is the concatenation of
these files in this order, so they must stay in this order here and in the
PRECACHE list in sw.js. None of them is lazy-loaded. -->
<link rel="stylesheet" href="/static/css/documents-gallery-editor.css?v=20260923csssplit1">
<link rel="stylesheet" href="/static/css/email-calendar-notes-tasks.css?v=20260923csssplit2">
<link rel="stylesheet" href="/static/css/cookbook-research-memory-settings.css?v=20260923csssplit3">
<link rel="modulepreload" href="/static/app.js?v=20260921chatgptusage2">
<link rel="stylesheet" href="/static/css/documents-gallery-editor.css?v=20260929csssplit">
<link rel="stylesheet" href="/static/css/email-calendar-notes-tasks.css?v=20260929csssplit">
<link rel="stylesheet" href="/static/css/cookbook-research-memory-settings.css?v=20260929csssplit">
<link rel="modulepreload" href="/static/app.js?v=20260929csssplit">
<link rel="modulepreload" href="/static/js/chat.js?v=20260917toolttft1">
<link rel="modulepreload" href="/static/js/ui.js?v=20260916largetoolscroll1">
<link rel="modulepreload" href="/static/js/sessions.js">
@@ -2639,7 +2656,7 @@
<script type="module" src="/static/js/censor.js"></script>
<script type="module" src="/static/js/settings.js?v=20260912writingstyle3"></script>
<script type="module" src="/static/js/assistant.js?v=20260912firefoxjscleanup1"></script>
<script type="module" src="/static/app.js?v=20260921chatgptusage2"></script> <!-- app.js must be LAST -->
<script type="module" src="/static/app.js?v=20260929csssplit"></script> <!-- app.js must be LAST -->
<script type="module" src="/static/js/init.js?v=20260829chatstyle12"></script>
<script type="module" src="/static/js/a11y.js"></script>
<script nonce="{{CSP_NONCE}}">if('serviceWorker' in navigator){navigator.serviceWorker.register('/static/sw.js?v=20260916autoscroll1').catch(()=>{});}</script>
+5
View File
@@ -3142,6 +3142,7 @@ function initMcpForm() {
if (transport === 'stdio' && !command) { msg.textContent = 'Command is required for stdio'; msg.className = 'admin-error'; return; }
if (transport === 'sse' && !url) { msg.textContent = 'URL is required for SSE'; msg.className = 'admin-error'; return; }
try { JSON.parse(env); } catch { msg.textContent = 'Env must be valid JSON'; msg.className = 'admin-error'; return; }
try { JSON.parse(args); } catch { msg.textContent = 'Args must be valid JSON, e.g. ["-y", "pkg"]'; msg.className = 'admin-error'; return; }
const fd = new FormData();
fd.append('name', name); fd.append('transport', transport); fd.append('command', command); fd.append('args', args); fd.append('env', env); fd.append('url', url);
// If preset has oauthFile config, send credentials for file generation
@@ -3162,6 +3163,10 @@ function initMcpForm() {
try {
const res = await fetch('/api/mcp/servers', { method: 'POST', body: fd, credentials: 'same-origin' });
const data = await res.json();
if (!res.ok) {
msg.textContent = data.detail || `Failed (${res.status})`; msg.className = 'admin-error';
return;
}
if (data.needs_oauth) {
msg.innerHTML = `Added ${esc(name)} — <a href="/api/mcp/oauth/authorize/${data.id}" target="_blank" style="color:var(--red);font-weight:600;">Authorize with Google</a> to connect`;
msg.className = 'admin-success';
+17 -416
View File
@@ -4,6 +4,11 @@
import uiModule from './ui.js?v=20260916largetoolscroll1';
import searchModule from './search.js';
import { byId } from './settings/dom.js';
import { postSettings as _postSettings } from './settings/api.js';
import { initTtsSettings, initSttSettings } from './settings/speech.js';
import { initDocumentWritingStyle } from './settings/writingStyle.js';
import { initImageSettings } from './settings/imageModels.js';
import { initAgentSettings } from './settings/agent.js';
import { getSettingsRegistryIssues } from './settings/registry.js';
import { bindSettingsSearch } from './settings/search.js';
import { bindSettingsSidebar } from './settings/sidebar.js';
@@ -39,18 +44,6 @@ let _authPolicy = { password_min_length: 8 };
* Reads in this file deliberately stay direct fetches: this panel is the writer
* and edits what it reads, so it must see the authoritative state, not a cache.
*/
async function _postSettings(body) {
try {
return await fetch('/api/auth/settings', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
} finally {
invalidateSettings();
}
}
const el = byId;
function esc(s) { return uiModule.esc(s); }
@@ -535,56 +528,6 @@ async function initTeacherModel() {
}
/* ── Image Generation ── */
async function initImageSettings() {
const modelSel = el('set-imgModelSelect');
const qualSel = el('set-imgQualitySelect');
const msg = el('set-imgSettingsMsg');
const enabledToggle = el('set-imgEnabledToggle');
const configWrap = modelSel ? modelSel.closest('div[style*="flex-direction"]') : null;
try {
const endpointsRes = await fetch('/api/model-endpoints', { credentials: 'same-origin' });
const endpoints = await endpointsRes.json();
const imageModels = new Set();
(Array.isArray(endpoints) ? endpoints : []).forEach(endpoint => {
if (!endpoint.is_enabled || !endpoint.online || String(endpoint.model_type || '').toLowerCase() !== 'image') return;
(Array.isArray(endpoint.models) ? endpoint.models : []).forEach(modelId => {
if (modelId) imageModels.add(String(modelId));
});
});
sortModelIds(Array.from(imageModels)).forEach(mid => {
const opt = document.createElement('option');
opt.value = mid;
opt.textContent = mid;
modelSel.appendChild(opt);
});
} catch (e) { console.warn('Failed to load models for image settings', e); }
try {
const settingsRes = await fetch('/api/auth/settings', { credentials: 'same-origin' });
const settings = await settingsRes.json();
if (settings.image_model) modelSel.value = settings.image_model;
if (settings.image_quality) qualSel.value = settings.image_quality;
if (enabledToggle) enabledToggle.checked = settings.image_gen_enabled === true;
} catch (e) { console.warn('Failed to load settings', e); }
function syncImgDisabled() {
var off = enabledToggle && !enabledToggle.checked;
var card = enabledToggle ? enabledToggle.closest('.admin-card') : null;
if (card) card.style.opacity = off ? '0.45' : '';
if (configWrap) configWrap.style.pointerEvents = off ? 'none' : '';
}
syncImgDisabled();
async function saveSettings() {
try {
const res = await _postSettings({ image_gen_enabled: enabledToggle ? enabledToggle.checked : false, image_model: modelSel.value, image_quality: qualSel.value });
if (!res.ok) throw new Error(await res.text().catch(() => `HTTP ${res.status}`));
msg.textContent = 'Saved'; msg.style.color = 'var(--fg)'; setTimeout(() => { msg.textContent = ''; }, 2000);
} catch (e) { msg.textContent = 'Failed to save'; msg.style.color = 'var(--red)'; }
}
modelSel.addEventListener('change', saveSettings);
qualSel.addEventListener('change', saveSettings);
if (enabledToggle) enabledToggle.addEventListener('change', function() { syncImgDisabled(); saveSettings(); });
}
/* ── Vision ── */
async function initVisionSettings() {
@@ -666,246 +609,8 @@ async function initVisionSettings() {
/* ── Face Recognition ── */
/* ── Text to Speech ── */
async function initTtsSettings() {
var provSel = el('set-ttsProviderSelect');
var modelSelect = el('set-ttsModelSelect');
var modelInput = el('set-ttsModelInput');
var voiceSelect = el('set-ttsVoiceSelect');
var voiceInput = el('set-ttsVoiceInput');
var modelRow = el('set-ttsModelRow');
var voiceRow = el('set-ttsVoiceRow');
var speedSelect = el('set-ttsSpeedSelect');
var speedRow = el('set-ttsSpeedRow');
var ttsMsg = el('set-ttsSettingsMsg');
var ttsEnabledToggle = el('set-ttsEnabledToggle');
var ttsConfigWrap = provSel ? provSel.closest('div[style*="flex-direction"]') : null;
function isEndpoint() { return provSel.value.startsWith('endpoint:'); }
function getModel() { return isEndpoint() ? modelSelect.value : modelInput.value; }
function getVoice() { return isEndpoint() ? voiceSelect.value : voiceInput.value; }
function updateVisibility() {
var prov = provSel.value;
modelRow.style.display = prov.startsWith('endpoint:') ? 'flex' : 'none';
voiceRow.style.display = prov === 'disabled' ? 'none' : 'flex';
speedRow.style.display = prov === 'disabled' ? 'none' : 'flex';
if (isEndpoint()) {
modelSelect.style.display = ''; modelInput.style.display = 'none';
voiceSelect.style.display = ''; voiceInput.style.display = 'none';
} else {
modelSelect.style.display = 'none'; modelInput.style.display = '';
voiceSelect.style.display = 'none'; voiceInput.style.display = prov === 'disabled' ? 'none' : '';
}
}
var ttsKeywords = ['tts', 'audio'];
try {
var epRes = await fetch('/api/model-endpoints', { credentials: 'same-origin' });
var endpoints = await epRes.json();
endpoints.forEach(function(ep) {
if (!ep.is_enabled) return;
var hasTTS = (ep.models || []).some(m => ttsKeywords.some(kw => m.toLowerCase().includes(kw)));
if (!hasTTS) return;
var opt = document.createElement('option'); opt.value = 'endpoint:' + ep.id; opt.textContent = ep.name + ' (API)'; provSel.appendChild(opt);
});
} catch (e) { console.warn('Failed to load endpoints for TTS', e); }
try {
var settingsRes = await fetch('/api/auth/settings', { credentials: 'same-origin' });
var settings = await settingsRes.json();
if (settings.tts_provider) provSel.value = settings.tts_provider;
if (settings.tts_model) { modelSelect.value = settings.tts_model; modelInput.value = settings.tts_model; }
if (settings.tts_voice) { voiceSelect.value = settings.tts_voice; voiceInput.value = settings.tts_voice; }
if (settings.tts_speed) { speedSelect.value = settings.tts_speed; }
if (ttsEnabledToggle) ttsEnabledToggle.checked = settings.tts_enabled !== false;
} catch (e) { console.warn('Failed to load TTS settings', e); }
function syncTtsDisabled() {
var off = ttsEnabledToggle && !ttsEnabledToggle.checked;
var card = ttsEnabledToggle ? ttsEnabledToggle.closest('.admin-card') : null;
if (card) card.style.opacity = off ? '0.45' : '';
if (ttsConfigWrap) ttsConfigWrap.style.pointerEvents = off ? 'none' : '';
}
syncTtsDisabled();
updateVisibility();
async function saveTTS() {
try {
await _postSettings({ tts_enabled: ttsEnabledToggle ? ttsEnabledToggle.checked : true, tts_provider: provSel.value, tts_model: getModel() || 'tts-1', tts_voice: getVoice() || 'alloy', tts_speed: speedSelect.value || '1' });
ttsMsg.textContent = 'Saved'; ttsMsg.style.color = 'var(--fg)'; setTimeout(() => { ttsMsg.textContent = ''; }, 2000);
if (window.aiTTSManager) window.aiTTSManager.checkAvailability();
} catch (e) { ttsMsg.textContent = 'Failed to save'; ttsMsg.style.color = 'var(--red)'; }
}
async function saveAndClearCache() {
await saveTTS();
fetch('/api/tts/clear-cache', { method: 'POST', credentials: 'same-origin' }).catch(function(){});
}
provSel.addEventListener('change', function() {
var prov = provSel.value;
if (prov === 'local') voiceInput.value = 'af_heart';
else if (isEndpoint()) { voiceSelect.value = 'alloy'; modelSelect.value = 'tts-1'; }
else if (prov === 'browser') { voiceInput.value = ''; voiceInput.placeholder = 'OS default voice'; }
updateVisibility();
saveTTS();
});
modelSelect.addEventListener('change', saveAndClearCache);
modelInput.addEventListener('change', saveTTS);
voiceSelect.addEventListener('change', saveAndClearCache);
voiceInput.addEventListener('change', saveTTS);
speedSelect.addEventListener('change', saveAndClearCache);
if (ttsEnabledToggle) ttsEnabledToggle.addEventListener('change', function() { syncTtsDisabled(); saveTTS(); });
// Preview / test button
var previewBtn = el('set-ttsPreviewBtn');
if (previewBtn) {
var previewAudio = null;
var previewPlaying = false;
function resetPreview() { previewPlaying = false; previewBtn.textContent = 'Preview'; previewBtn.style.borderColor = ''; }
previewBtn.addEventListener('click', async function() {
if (previewPlaying) {
if (previewAudio) { previewAudio.pause(); previewAudio = null; }
window.speechSynthesis.cancel();
resetPreview(); return;
}
var prov = provSel.value;
if (prov === 'disabled') {
ttsMsg.textContent = 'Select a provider first'; ttsMsg.style.color = 'var(--red, #e55)';
setTimeout(function() { ttsMsg.textContent = ''; }, 2000); return;
}
var testText = 'Hello, this is a test of text to speech.';
previewPlaying = true; previewBtn.textContent = 'Loading...';
try {
if (prov === 'browser') {
if (!('speechSynthesis' in window)) throw new Error('Browser TTS not supported');
var utt = new SpeechSynthesisUtterance(testText);
var voiceVal = getVoice();
if (voiceVal) {
var voices = window.speechSynthesis.getVoices();
var target = voiceVal.toLowerCase();
var match = voices.find(function(v) { return v.name.toLowerCase() === target; }) ||
voices.find(function(v) { return v.name.toLowerCase().includes(target); });
if (match) utt.voice = match;
}
utt.rate = parseFloat(speedSelect.value) || 1;
previewBtn.textContent = 'Stop'; previewBtn.style.borderColor = 'var(--red, #e55)';
await new Promise(function(resolve, reject) {
utt.onend = resolve;
utt.onerror = function(e) { reject(new Error('Browser TTS: ' + e.error)); };
window.speechSynthesis.speak(utt);
});
} else {
var res = await fetch('/api/tts/synthesize', {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: testText, format: 'audio' })
});
if (!res.ok) { var err = await res.json().catch(function() { return {}; }); throw new Error(err.detail?.message || 'Synthesis failed'); }
var blob = await res.blob();
var url = URL.createObjectURL(blob);
previewAudio = new Audio(url);
previewBtn.textContent = 'Stop'; previewBtn.style.borderColor = 'var(--red, #e55)';
await new Promise(function(resolve, reject) {
previewAudio.onended = function() { URL.revokeObjectURL(url); previewAudio = null; resolve(); };
previewAudio.onerror = function() { URL.revokeObjectURL(url); previewAudio = null; reject(new Error('Playback failed')); };
previewAudio.play().catch(reject);
});
}
} catch (e) {
ttsMsg.textContent = 'Preview failed: ' + e.message; ttsMsg.style.color = 'var(--red, #e55)';
setTimeout(function() { ttsMsg.textContent = ''; }, 3000);
} finally {
resetPreview();
}
});
}
}
/* ── Speech to Text ── */
async function initSttSettings() {
var provSel = el('set-sttProviderSelect');
var modelSelect = el('set-sttModelSelect');
var modelInput = el('set-sttModelInput');
var modelRow = el('set-sttModelRow');
var langRow = el('set-sttLangRow');
var langInput = el('set-sttLangInput');
var sttMsg = el('set-sttSettingsMsg');
var sttEnabledToggle = el('set-sttEnabledToggle');
var sttConfigWrap = el('set-sttConfigWrap');
// STT was removed from AI Defaults — bail if the UI isn't present.
if (!provSel) return;
function isEndpoint() { return provSel.value.startsWith('endpoint:'); }
function getModel() { return isEndpoint() ? modelInput.value : modelSelect.value; }
function updateVisibility() {
var prov = provSel.value;
var showModel = prov === 'local' || prov.startsWith('endpoint:');
var showLang = prov !== 'disabled';
modelRow.style.display = showModel ? 'flex' : 'none';
langRow.style.display = showLang ? 'flex' : 'none';
if (isEndpoint()) {
modelSelect.style.display = 'none'; modelInput.style.display = '';
} else {
modelSelect.style.display = ''; modelInput.style.display = 'none';
}
}
function syncSttDisabled() {
var off = sttEnabledToggle && !sttEnabledToggle.checked;
var card = sttEnabledToggle ? sttEnabledToggle.closest('.admin-card') : null;
if (card) card.style.opacity = off ? '0.45' : '';
if (sttConfigWrap) sttConfigWrap.style.pointerEvents = off ? 'none' : '';
}
// Effective provider: if toggle is off, treat as disabled regardless of provider select
function effectiveProvider() {
if (sttEnabledToggle && !sttEnabledToggle.checked) return 'disabled';
return provSel.value;
}
// Add API endpoints that might support STT
try {
var epRes = await fetch('/api/model-endpoints', { credentials: 'same-origin' });
var endpoints = await epRes.json();
endpoints.forEach(function(ep) {
if (!ep.is_enabled) return;
var opt = document.createElement('option'); opt.value = 'endpoint:' + ep.id; opt.textContent = ep.name + ' (API)'; provSel.appendChild(opt);
});
} catch (e) { console.warn('Failed to load endpoints for STT', e); }
// Load saved settings
try {
var settingsRes = await fetch('/api/auth/settings', { credentials: 'same-origin' });
var settings = await settingsRes.json();
if (settings.stt_provider) provSel.value = settings.stt_provider;
if (settings.stt_model) { modelSelect.value = settings.stt_model; modelInput.value = settings.stt_model; }
if (settings.stt_language) langInput.value = settings.stt_language;
if (sttEnabledToggle) sttEnabledToggle.checked = settings.stt_enabled !== false;
} catch (e) { console.warn('Failed to load STT settings', e); }
syncSttDisabled();
updateVisibility();
async function saveSTT() {
try {
var enabled = sttEnabledToggle ? sttEnabledToggle.checked : false;
await _postSettings({ stt_enabled: enabled, stt_provider: provSel.value, stt_model: getModel() || 'base', stt_language: langInput.value.trim() });
sttMsg.textContent = 'Saved'; sttMsg.style.color = 'var(--fg)'; setTimeout(() => { sttMsg.textContent = ''; }, 2000);
// Notify voiceRecorder of effective provider and update send button icon
if (window.voiceRecorderModule) window.voiceRecorderModule._sttProvider = effectiveProvider();
if (window._updateSendBtnIcon) window._updateSendBtnIcon();
} catch (e) { sttMsg.textContent = 'Failed to save'; sttMsg.style.color = 'var(--red)'; }
}
provSel.addEventListener('change', function() { updateVisibility(); saveSTT(); });
modelSelect.addEventListener('change', saveSTT);
modelInput.addEventListener('change', saveSTT);
langInput.addEventListener('change', saveSTT);
if (sttEnabledToggle) sttEnabledToggle.addEventListener('change', function() { syncSttDisabled(); saveSTT(); });
}
/* ═══════════════════════════════════════════
SEARCH TAB
@@ -1427,56 +1132,6 @@ async function initResearchSearchSettings() {
}
/* ── Agent Settings (AI tab) ── */
async function initAgentSettings() {
var toolsInput = el('set-agentMaxTools');
var roundsInput = el('set-agentMaxRounds');
var supInput = el('set-agentSupervisorLadder');
var msg = el('set-agentMsg');
if (!toolsInput) return;
try {
var res = await fetch('/api/auth/settings', { credentials: 'same-origin' });
var settings = await res.json();
if (settings.agent_max_tool_calls) toolsInput.value = settings.agent_max_tool_calls;
if (roundsInput && settings.agent_max_rounds) roundsInput.value = settings.agent_max_rounds;
if (supInput) supInput.checked = !!settings.agent_supervisor_ladder;
} catch (e) {}
// Clamp + coerce a raw input to an int in [lo, hi]; falls back to `dflt`
// when blank/non-numeric. Mirrors the server-side validation.
function clampInt(raw, lo, hi, dflt) {
var n = parseInt(raw, 10);
if (isNaN(n)) return dflt;
return Math.max(lo, Math.min(n, hi));
}
async function save() {
var tools = clampInt(toolsInput.value, 0, 1000, 0);
var rounds = roundsInput ? clampInt(roundsInput.value, 1, 200, 20) : null;
toolsInput.value = tools; // reflect the clamped value
if (roundsInput) roundsInput.value = rounds;
var payload = { agent_max_tool_calls: tools };
if (rounds != null) payload.agent_max_rounds = rounds;
if (supInput) payload.agent_supervisor_ladder = !!supInput.checked;
try {
await _postSettings(payload);
msg.textContent = (tools > 0 ? 'Limit: ' + tools + ' tool calls' : 'Unlimited tool calls') +
(rounds != null ? ' · ' + rounds + ' steps/message' : '') +
(supInput && supInput.checked ? ' · supervisor on' : '');
msg.style.color = 'var(--fg)';
} catch (e) { msg.textContent = 'Failed to save'; msg.style.color = 'var(--red)'; }
}
toolsInput.addEventListener('change', save);
if (roundsInput) roundsInput.addEventListener('change', save);
if (supInput) supInput.addEventListener('change', save);
var cur = parseInt(toolsInput.value, 10) || 0;
var curR = roundsInput ? (parseInt(roundsInput.value, 10) || 20) : null;
msg.textContent = (cur > 0 ? 'Limit: ' + cur + ' tool calls' : 'Unlimited tool calls') +
(curR != null ? ' · ' + curR + ' steps/message' : '') +
(supInput && supInput.checked ? ' · supervisor on' : '');
}
/* ═══════════════════════════════════════════
APPEARANCE TAB
@@ -2129,69 +1784,6 @@ function initAll() {
initDocumentWritingStyle();
}
async function initDocumentWritingStyle() {
const styleEl = el('set-document-style');
const saveBtn = el('set-document-style-save');
const extractBtn = el('set-document-style-extract');
const fileEl = el('set-document-style-file');
const msg = el('set-document-style-msg');
if (!styleEl || !saveBtn) return;
try {
const res = await fetch('/api/auth/settings', { credentials: 'same-origin' });
const data = await res.json();
styleEl.value = String(data.document_writing_style || '');
} catch (_) {
if (msg) msg.textContent = 'Failed to load';
}
saveBtn.addEventListener('click', async () => {
if (msg) msg.textContent = 'Saving...';
try {
const res = await _postSettings({ document_writing_style: styleEl.value });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
if (msg) msg.textContent = '✓ Saved';
} catch (_) {
if (msg) msg.textContent = 'Failed to save';
}
setTimeout(() => { if (msg) msg.textContent = ''; }, 3000);
});
extractBtn?.addEventListener('click', () => fileEl?.click());
fileEl?.addEventListener('change', async () => {
const file = fileEl.files?.[0];
if (!file) return;
extractBtn.disabled = true;
let whirlpool = null;
if (msg) {
msg.replaceChildren();
try {
const spinner = window.spinnerModule || (await import('./spinner.js')).default;
whirlpool = spinner.createWhirlpool(14);
whirlpool.element.style.cssText = 'display:inline-flex;width:14px;height:14px;margin-right:7px;';
const label = document.createElement('span');
label.textContent = 'Analyzing...';
msg.append(whirlpool.element, label);
} catch (_) {
msg.textContent = 'Analyzing...';
}
}
try {
const body = new FormData();
body.append('file', file);
const res = await fetch('/api/auth/settings/document-style/extract', {
method: 'POST', credentials: 'same-origin', body,
});
const data = await res.json().catch(() => ({}));
if (!res.ok || !data.style) throw new Error(data.detail || data.error || 'Extraction failed');
styleEl.value = String(data.style);
if (msg) msg.textContent = '✓ Extracted — review and save';
} catch (error) {
if (msg) msg.textContent = error.message || 'Extraction failed';
} finally {
whirlpool?.destroy?.();
extractBtn.disabled = false;
fileEl.value = '';
}
});
}
function notifyIntegrationsChanged() {
try {
@@ -5035,7 +4627,11 @@ async function initUnifiedIntegrations() {
fd.append('transport', transport);
if (transport === 'stdio') {
fd.append('command', el('uf-mcp-cmd').value);
let args = '[]'; try { args = JSON.stringify(JSON.parse(el('uf-mcp-args').value || '[]')); } catch (_) {}
// Unlike env below, an unparseable args value is not silently
// defaulted: it would spawn the subprocess with an empty argv.
let args;
try { args = JSON.stringify(JSON.parse(el('uf-mcp-args').value || '[]')); }
catch (_) { el('uf-mcp-msg').textContent = 'Args must be valid JSON, e.g. ["-y", "pkg"]'; return; }
let env = '{}'; try { env = JSON.stringify(JSON.parse(el('uf-mcp-env').value || '{}')); } catch (_) {}
fd.append('args', args);
fd.append('env', env);
@@ -5057,7 +4653,12 @@ async function initUnifiedIntegrations() {
} else if (r.ok) {
el('uf-mcp-msg').textContent = 'Saved'; formEl.style.display = 'none'; await renderList();
} else {
el('uf-mcp-msg').textContent = `Failed (${r.status})`;
// Surface the server's reason. The Args validation above rejects
// unparseable JSON, but `"x"` and `{}` parse and are refused by
// routes/mcp/mcp_routes.py with a message naming the expected
// shape; a bare status code sends the user looking in the wrong
// place. Matches what admin.js shows for the same endpoint.
el('uf-mcp-msg').textContent = data.detail || `Failed (${r.status})`;
}
} catch (_) { el('uf-mcp-msg').textContent = 'Failed'; }
finally { _setBtnLoading(saveBtn, false, _origLabel); if (cancelBtn) cancelBtn.disabled = false; }
@@ -5573,7 +5174,7 @@ async function initUnifiedIntegrations() {
═══════════════════════════════════════════ */
export function open(tab) { _shell.open(tab); }
export function close() { _shell.close(); }
export function syncAdminVisibility() { _shell.syncAdminVisibility(); }
function syncAdminVisibility() { _shell.syncAdminVisibility(); }
handleSettingsOauthReturn({ openSettings: open });
+58
View File
@@ -0,0 +1,58 @@
// Agent settings panel.
// Extracted verbatim from static/js/settings.js. Behaviour is unchanged: the
// functions moved, their bodies did not. settings.js imports them and calls
// them from the same places, so load order and init sequence are untouched.
import { byId as el } from './dom.js';
import { postSettings as _postSettings } from './api.js';
export async function initAgentSettings() {
var toolsInput = el('set-agentMaxTools');
var roundsInput = el('set-agentMaxRounds');
var supInput = el('set-agentSupervisorLadder');
var msg = el('set-agentMsg');
if (!toolsInput) return;
try {
var res = await fetch('/api/auth/settings', { credentials: 'same-origin' });
var settings = await res.json();
if (settings.agent_max_tool_calls) toolsInput.value = settings.agent_max_tool_calls;
if (roundsInput && settings.agent_max_rounds) roundsInput.value = settings.agent_max_rounds;
if (supInput) supInput.checked = !!settings.agent_supervisor_ladder;
} catch (e) {}
// Clamp + coerce a raw input to an int in [lo, hi]; falls back to `dflt`
// when blank/non-numeric. Mirrors the server-side validation.
function clampInt(raw, lo, hi, dflt) {
var n = parseInt(raw, 10);
if (isNaN(n)) return dflt;
return Math.max(lo, Math.min(n, hi));
}
async function save() {
var tools = clampInt(toolsInput.value, 0, 1000, 0);
var rounds = roundsInput ? clampInt(roundsInput.value, 1, 200, 20) : null;
toolsInput.value = tools; // reflect the clamped value
if (roundsInput) roundsInput.value = rounds;
var payload = { agent_max_tool_calls: tools };
if (rounds != null) payload.agent_max_rounds = rounds;
if (supInput) payload.agent_supervisor_ladder = !!supInput.checked;
try {
await _postSettings(payload);
msg.textContent = (tools > 0 ? 'Limit: ' + tools + ' tool calls' : 'Unlimited tool calls') +
(rounds != null ? ' · ' + rounds + ' steps/message' : '') +
(supInput && supInput.checked ? ' · supervisor on' : '');
msg.style.color = 'var(--fg)';
} catch (e) { msg.textContent = 'Failed to save'; msg.style.color = 'var(--red)'; }
}
toolsInput.addEventListener('change', save);
if (roundsInput) roundsInput.addEventListener('change', save);
if (supInput) supInput.addEventListener('change', save);
var cur = parseInt(toolsInput.value, 10) || 0;
var curR = roundsInput ? (parseInt(roundsInput.value, 10) || 20) : null;
msg.textContent = (cur > 0 ? 'Limit: ' + cur + ' tool calls' : 'Unlimited tool calls') +
(curR != null ? ' · ' + curR + ' steps/message' : '') +
(supInput && supInput.checked ? ' · supervisor on' : '');
}
+16
View File
@@ -0,0 +1,16 @@
// Lifted verbatim from static/js/settings.js so extracted panels can post
// settings without importing the module they were extracted from.
import { invalidateSettings } from '../appConfig.js';
export async function postSettings(body) {
try {
return await fetch('/api/auth/settings', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
} finally {
invalidateSettings();
}
}
+59
View File
@@ -0,0 +1,59 @@
// Image-model settings panel.
// Extracted verbatim from static/js/settings.js. Behaviour is unchanged: the
// functions moved, their bodies did not. settings.js imports them and calls
// them from the same places, so load order and init sequence are untouched.
import { byId as el } from './dom.js';
import { postSettings as _postSettings } from './api.js';
import { sortModelIds } from '../modelSort.js';
export async function initImageSettings() {
const modelSel = el('set-imgModelSelect');
const qualSel = el('set-imgQualitySelect');
const msg = el('set-imgSettingsMsg');
const enabledToggle = el('set-imgEnabledToggle');
const configWrap = modelSel ? modelSel.closest('div[style*="flex-direction"]') : null;
try {
const endpointsRes = await fetch('/api/model-endpoints', { credentials: 'same-origin' });
const endpoints = await endpointsRes.json();
const imageModels = new Set();
(Array.isArray(endpoints) ? endpoints : []).forEach(endpoint => {
if (!endpoint.is_enabled || !endpoint.online || String(endpoint.model_type || '').toLowerCase() !== 'image') return;
(Array.isArray(endpoint.models) ? endpoint.models : []).forEach(modelId => {
if (modelId) imageModels.add(String(modelId));
});
});
sortModelIds(Array.from(imageModels)).forEach(mid => {
const opt = document.createElement('option');
opt.value = mid;
opt.textContent = mid;
modelSel.appendChild(opt);
});
} catch (e) { console.warn('Failed to load models for image settings', e); }
try {
const settingsRes = await fetch('/api/auth/settings', { credentials: 'same-origin' });
const settings = await settingsRes.json();
if (settings.image_model) modelSel.value = settings.image_model;
if (settings.image_quality) qualSel.value = settings.image_quality;
if (enabledToggle) enabledToggle.checked = settings.image_gen_enabled === true;
} catch (e) { console.warn('Failed to load settings', e); }
function syncImgDisabled() {
var off = enabledToggle && !enabledToggle.checked;
var card = enabledToggle ? enabledToggle.closest('.admin-card') : null;
if (card) card.style.opacity = off ? '0.45' : '';
if (configWrap) configWrap.style.pointerEvents = off ? 'none' : '';
}
syncImgDisabled();
async function saveSettings() {
try {
const res = await _postSettings({ image_gen_enabled: enabledToggle ? enabledToggle.checked : false, image_model: modelSel.value, image_quality: qualSel.value });
if (!res.ok) throw new Error(await res.text().catch(() => `HTTP ${res.status}`));
msg.textContent = 'Saved'; msg.style.color = 'var(--fg)'; setTimeout(() => { msg.textContent = ''; }, 2000);
} catch (e) { msg.textContent = 'Failed to save'; msg.style.color = 'var(--red)'; }
}
modelSel.addEventListener('change', saveSettings);
qualSel.addEventListener('change', saveSettings);
if (enabledToggle) enabledToggle.addEventListener('change', function() { syncImgDisabled(); saveSettings(); });
}
+247
View File
@@ -0,0 +1,247 @@
// Text-to-speech and speech-to-text settings panels.
// Extracted verbatim from static/js/settings.js. Behaviour is unchanged: the
// functions moved, their bodies did not. settings.js imports them and calls
// them from the same places, so load order and init sequence are untouched.
import { byId as el } from './dom.js';
import { postSettings as _postSettings } from './api.js';
export async function initTtsSettings() {
var provSel = el('set-ttsProviderSelect');
var modelSelect = el('set-ttsModelSelect');
var modelInput = el('set-ttsModelInput');
var voiceSelect = el('set-ttsVoiceSelect');
var voiceInput = el('set-ttsVoiceInput');
var modelRow = el('set-ttsModelRow');
var voiceRow = el('set-ttsVoiceRow');
var speedSelect = el('set-ttsSpeedSelect');
var speedRow = el('set-ttsSpeedRow');
var ttsMsg = el('set-ttsSettingsMsg');
var ttsEnabledToggle = el('set-ttsEnabledToggle');
var ttsConfigWrap = provSel ? provSel.closest('div[style*="flex-direction"]') : null;
function isEndpoint() { return provSel.value.startsWith('endpoint:'); }
function getModel() { return isEndpoint() ? modelSelect.value : modelInput.value; }
function getVoice() { return isEndpoint() ? voiceSelect.value : voiceInput.value; }
function updateVisibility() {
var prov = provSel.value;
modelRow.style.display = prov.startsWith('endpoint:') ? 'flex' : 'none';
voiceRow.style.display = prov === 'disabled' ? 'none' : 'flex';
speedRow.style.display = prov === 'disabled' ? 'none' : 'flex';
if (isEndpoint()) {
modelSelect.style.display = ''; modelInput.style.display = 'none';
voiceSelect.style.display = ''; voiceInput.style.display = 'none';
} else {
modelSelect.style.display = 'none'; modelInput.style.display = '';
voiceSelect.style.display = 'none'; voiceInput.style.display = prov === 'disabled' ? 'none' : '';
}
}
var ttsKeywords = ['tts', 'audio'];
try {
var epRes = await fetch('/api/model-endpoints', { credentials: 'same-origin' });
var endpoints = await epRes.json();
endpoints.forEach(function(ep) {
if (!ep.is_enabled) return;
var hasTTS = (ep.models || []).some(m => ttsKeywords.some(kw => m.toLowerCase().includes(kw)));
if (!hasTTS) return;
var opt = document.createElement('option'); opt.value = 'endpoint:' + ep.id; opt.textContent = ep.name + ' (API)'; provSel.appendChild(opt);
});
} catch (e) { console.warn('Failed to load endpoints for TTS', e); }
try {
var settingsRes = await fetch('/api/auth/settings', { credentials: 'same-origin' });
var settings = await settingsRes.json();
if (settings.tts_provider) provSel.value = settings.tts_provider;
if (settings.tts_model) { modelSelect.value = settings.tts_model; modelInput.value = settings.tts_model; }
if (settings.tts_voice) { voiceSelect.value = settings.tts_voice; voiceInput.value = settings.tts_voice; }
if (settings.tts_speed) { speedSelect.value = settings.tts_speed; }
if (ttsEnabledToggle) ttsEnabledToggle.checked = settings.tts_enabled !== false;
} catch (e) { console.warn('Failed to load TTS settings', e); }
function syncTtsDisabled() {
var off = ttsEnabledToggle && !ttsEnabledToggle.checked;
var card = ttsEnabledToggle ? ttsEnabledToggle.closest('.admin-card') : null;
if (card) card.style.opacity = off ? '0.45' : '';
if (ttsConfigWrap) ttsConfigWrap.style.pointerEvents = off ? 'none' : '';
}
syncTtsDisabled();
updateVisibility();
async function saveTTS() {
try {
await _postSettings({ tts_enabled: ttsEnabledToggle ? ttsEnabledToggle.checked : true, tts_provider: provSel.value, tts_model: getModel() || 'tts-1', tts_voice: getVoice() || 'alloy', tts_speed: speedSelect.value || '1' });
ttsMsg.textContent = 'Saved'; ttsMsg.style.color = 'var(--fg)'; setTimeout(() => { ttsMsg.textContent = ''; }, 2000);
if (window.aiTTSManager) window.aiTTSManager.checkAvailability();
} catch (e) { ttsMsg.textContent = 'Failed to save'; ttsMsg.style.color = 'var(--red)'; }
}
async function saveAndClearCache() {
await saveTTS();
fetch('/api/tts/clear-cache', { method: 'POST', credentials: 'same-origin' }).catch(function(){});
}
provSel.addEventListener('change', function() {
var prov = provSel.value;
if (prov === 'local') voiceInput.value = 'af_heart';
else if (isEndpoint()) { voiceSelect.value = 'alloy'; modelSelect.value = 'tts-1'; }
else if (prov === 'browser') { voiceInput.value = ''; voiceInput.placeholder = 'OS default voice'; }
updateVisibility();
saveTTS();
});
modelSelect.addEventListener('change', saveAndClearCache);
modelInput.addEventListener('change', saveTTS);
voiceSelect.addEventListener('change', saveAndClearCache);
voiceInput.addEventListener('change', saveTTS);
speedSelect.addEventListener('change', saveAndClearCache);
if (ttsEnabledToggle) ttsEnabledToggle.addEventListener('change', function() { syncTtsDisabled(); saveTTS(); });
// Preview / test button
var previewBtn = el('set-ttsPreviewBtn');
if (previewBtn) {
var previewAudio = null;
var previewPlaying = false;
function resetPreview() { previewPlaying = false; previewBtn.textContent = 'Preview'; previewBtn.style.borderColor = ''; }
previewBtn.addEventListener('click', async function() {
if (previewPlaying) {
if (previewAudio) { previewAudio.pause(); previewAudio = null; }
window.speechSynthesis.cancel();
resetPreview(); return;
}
var prov = provSel.value;
if (prov === 'disabled') {
ttsMsg.textContent = 'Select a provider first'; ttsMsg.style.color = 'var(--red, #e55)';
setTimeout(function() { ttsMsg.textContent = ''; }, 2000); return;
}
var testText = 'Hello, this is a test of text to speech.';
previewPlaying = true; previewBtn.textContent = 'Loading...';
try {
if (prov === 'browser') {
if (!('speechSynthesis' in window)) throw new Error('Browser TTS not supported');
var utt = new SpeechSynthesisUtterance(testText);
var voiceVal = getVoice();
if (voiceVal) {
var voices = window.speechSynthesis.getVoices();
var target = voiceVal.toLowerCase();
var match = voices.find(function(v) { return v.name.toLowerCase() === target; }) ||
voices.find(function(v) { return v.name.toLowerCase().includes(target); });
if (match) utt.voice = match;
}
utt.rate = parseFloat(speedSelect.value) || 1;
previewBtn.textContent = 'Stop'; previewBtn.style.borderColor = 'var(--red, #e55)';
await new Promise(function(resolve, reject) {
utt.onend = resolve;
utt.onerror = function(e) { reject(new Error('Browser TTS: ' + e.error)); };
window.speechSynthesis.speak(utt);
});
} else {
var res = await fetch('/api/tts/synthesize', {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: testText, format: 'audio' })
});
if (!res.ok) { var err = await res.json().catch(function() { return {}; }); throw new Error(err.detail?.message || 'Synthesis failed'); }
var blob = await res.blob();
var url = URL.createObjectURL(blob);
previewAudio = new Audio(url);
previewBtn.textContent = 'Stop'; previewBtn.style.borderColor = 'var(--red, #e55)';
await new Promise(function(resolve, reject) {
previewAudio.onended = function() { URL.revokeObjectURL(url); previewAudio = null; resolve(); };
previewAudio.onerror = function() { URL.revokeObjectURL(url); previewAudio = null; reject(new Error('Playback failed')); };
previewAudio.play().catch(reject);
});
}
} catch (e) {
ttsMsg.textContent = 'Preview failed: ' + e.message; ttsMsg.style.color = 'var(--red, #e55)';
setTimeout(function() { ttsMsg.textContent = ''; }, 3000);
} finally {
resetPreview();
}
});
}
}
export async function initSttSettings() {
var provSel = el('set-sttProviderSelect');
var modelSelect = el('set-sttModelSelect');
var modelInput = el('set-sttModelInput');
var modelRow = el('set-sttModelRow');
var langRow = el('set-sttLangRow');
var langInput = el('set-sttLangInput');
var sttMsg = el('set-sttSettingsMsg');
var sttEnabledToggle = el('set-sttEnabledToggle');
var sttConfigWrap = el('set-sttConfigWrap');
// STT was removed from AI Defaults — bail if the UI isn't present.
if (!provSel) return;
function isEndpoint() { return provSel.value.startsWith('endpoint:'); }
function getModel() { return isEndpoint() ? modelInput.value : modelSelect.value; }
function updateVisibility() {
var prov = provSel.value;
var showModel = prov === 'local' || prov.startsWith('endpoint:');
var showLang = prov !== 'disabled';
modelRow.style.display = showModel ? 'flex' : 'none';
langRow.style.display = showLang ? 'flex' : 'none';
if (isEndpoint()) {
modelSelect.style.display = 'none'; modelInput.style.display = '';
} else {
modelSelect.style.display = ''; modelInput.style.display = 'none';
}
}
function syncSttDisabled() {
var off = sttEnabledToggle && !sttEnabledToggle.checked;
var card = sttEnabledToggle ? sttEnabledToggle.closest('.admin-card') : null;
if (card) card.style.opacity = off ? '0.45' : '';
if (sttConfigWrap) sttConfigWrap.style.pointerEvents = off ? 'none' : '';
}
// Effective provider: if toggle is off, treat as disabled regardless of provider select
function effectiveProvider() {
if (sttEnabledToggle && !sttEnabledToggle.checked) return 'disabled';
return provSel.value;
}
// Add API endpoints that might support STT
try {
var epRes = await fetch('/api/model-endpoints', { credentials: 'same-origin' });
var endpoints = await epRes.json();
endpoints.forEach(function(ep) {
if (!ep.is_enabled) return;
var opt = document.createElement('option'); opt.value = 'endpoint:' + ep.id; opt.textContent = ep.name + ' (API)'; provSel.appendChild(opt);
});
} catch (e) { console.warn('Failed to load endpoints for STT', e); }
// Load saved settings
try {
var settingsRes = await fetch('/api/auth/settings', { credentials: 'same-origin' });
var settings = await settingsRes.json();
if (settings.stt_provider) provSel.value = settings.stt_provider;
if (settings.stt_model) { modelSelect.value = settings.stt_model; modelInput.value = settings.stt_model; }
if (settings.stt_language) langInput.value = settings.stt_language;
if (sttEnabledToggle) sttEnabledToggle.checked = settings.stt_enabled !== false;
} catch (e) { console.warn('Failed to load STT settings', e); }
syncSttDisabled();
updateVisibility();
async function saveSTT() {
try {
var enabled = sttEnabledToggle ? sttEnabledToggle.checked : false;
await _postSettings({ stt_enabled: enabled, stt_provider: provSel.value, stt_model: getModel() || 'base', stt_language: langInput.value.trim() });
sttMsg.textContent = 'Saved'; sttMsg.style.color = 'var(--fg)'; setTimeout(() => { sttMsg.textContent = ''; }, 2000);
// Notify voiceRecorder of effective provider and update send button icon
if (window.voiceRecorderModule) window.voiceRecorderModule._sttProvider = effectiveProvider();
if (window._updateSendBtnIcon) window._updateSendBtnIcon();
} catch (e) { sttMsg.textContent = 'Failed to save'; sttMsg.style.color = 'var(--red)'; }
}
provSel.addEventListener('change', function() { updateVisibility(); saveSTT(); });
modelSelect.addEventListener('change', saveSTT);
modelInput.addEventListener('change', saveSTT);
langInput.addEventListener('change', saveSTT);
if (sttEnabledToggle) sttEnabledToggle.addEventListener('change', function() { syncSttDisabled(); saveSTT(); });
}
+71
View File
@@ -0,0 +1,71 @@
// Document writing-style settings panel.
// Extracted verbatim from static/js/settings.js. Behaviour is unchanged: the
// functions moved, their bodies did not. settings.js imports them and calls
// them from the same places, so load order and init sequence are untouched.
import { byId as el } from './dom.js';
import { postSettings as _postSettings } from './api.js';
export async function initDocumentWritingStyle() {
const styleEl = el('set-document-style');
const saveBtn = el('set-document-style-save');
const extractBtn = el('set-document-style-extract');
const fileEl = el('set-document-style-file');
const msg = el('set-document-style-msg');
if (!styleEl || !saveBtn) return;
try {
const res = await fetch('/api/auth/settings', { credentials: 'same-origin' });
const data = await res.json();
styleEl.value = String(data.document_writing_style || '');
} catch (_) {
if (msg) msg.textContent = 'Failed to load';
}
saveBtn.addEventListener('click', async () => {
if (msg) msg.textContent = 'Saving...';
try {
const res = await _postSettings({ document_writing_style: styleEl.value });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
if (msg) msg.textContent = '✓ Saved';
} catch (_) {
if (msg) msg.textContent = 'Failed to save';
}
setTimeout(() => { if (msg) msg.textContent = ''; }, 3000);
});
extractBtn?.addEventListener('click', () => fileEl?.click());
fileEl?.addEventListener('change', async () => {
const file = fileEl.files?.[0];
if (!file) return;
extractBtn.disabled = true;
let whirlpool = null;
if (msg) {
msg.replaceChildren();
try {
const spinner = window.spinnerModule || (await import('../spinner.js')).default;
whirlpool = spinner.createWhirlpool(14);
whirlpool.element.style.cssText = 'display:inline-flex;width:14px;height:14px;margin-right:7px;';
const label = document.createElement('span');
label.textContent = 'Analyzing...';
msg.append(whirlpool.element, label);
} catch (_) {
msg.textContent = 'Analyzing...';
}
}
try {
const body = new FormData();
body.append('file', file);
const res = await fetch('/api/auth/settings/document-style/extract', {
method: 'POST', credentials: 'same-origin', body,
});
const data = await res.json().catch(() => ({}));
if (!res.ok || !data.style) throw new Error(data.detail || data.error || 'Extraction failed');
styleEl.value = String(data.style);
if (msg) msg.textContent = '✓ Extracted — review and save';
} catch (error) {
if (msg) msg.textContent = error.message || 'Extraction failed';
} finally {
whirlpool?.destroy?.();
extractBtn.disabled = false;
fileEl.value = '';
}
});
}
+1 -1
View File
@@ -316,7 +316,7 @@ class SmoothPad {
}
function _modal(innerHtml) {
// Match the app's standard .modal pattern (defined in static/style.css).
// Match the app's standard .modal pattern from the main stylesheet cascade.
const overlay = document.createElement('div');
overlay.className = 'modal sig-modal-overlay';
overlay.style.cssText = 'pointer-events:auto;background:rgba(0,0,0,0.45);z-index:10100;';
+4 -4
View File
@@ -106,7 +106,7 @@
<style>
@font-face { font-family: 'Fira Code'; font-weight: 400; font-style: normal; font-display: swap; src: url('static/fonts/FiraCode-Regular.woff2') format('woff2'); }
@font-face { font-family: 'Fira Code'; font-weight: 600; font-style: normal; font-display: swap; src: url('static/fonts/FiraCode-SemiBold.woff2') format('woff2'); }
/* Mirror the main app's :root defaults (static/style.css ~line 18) so an
/* Mirror the main app's :root defaults from the stylesheet cascade so an
uncustomized theme — or a fresh browser with no `odysseus-theme` in
localStorage — renders the login page in the same palette as the rest
of the app instead of a different greys+offwhite scheme. */
@@ -115,7 +115,7 @@
--red: #e06c75; --green: #50fa7b;
--bg-effect-intensity: 1;
}
/* Background patterns — mirrors static/style.css "Background Patterns"
/* Background patterns — mirrors the main app "Background Patterns" styles
block so the theme's chosen bgPattern renders on login too. The
canvas-based effects (rain, constellations, perlin-flow, petals,
sparkles, embers) are spawned by the deferred theme.js import below;
@@ -157,7 +157,7 @@
font-size: 2rem; font-weight: 600;
letter-spacing: 0.04em;
/* Same gradient as the main app's welcome-screen "Odysseus" title
(static/style.css #welcome-screen .welcome-name) so the brand
(the main cascade's #welcome-screen .welcome-name) so the brand
wordmark is consistent across login + first-launch screens. */
background: linear-gradient(135deg, var(--brand-color, var(--red)), color-mix(in srgb, var(--brand-color, var(--red)) 60%, var(--fg)));
-webkit-background-clip: text;
@@ -175,7 +175,7 @@
/* On touch devices keep inputs at >=16px so iOS Safari doesn't zoom the whole
page when a field is focused (it auto-zooms any focused input under 16px).
This page has its own inline styles, so it doesn't inherit the main app's
equivalent rule in static/style.css; mirror it here. !important also lifts
equivalent rule in the main stylesheet cascade; mirror it here. !important also lifts
the dynamically-inserted 2FA input, which pins font-size:14px inline. */
@media (hover: none) and (pointer: coarse) {
input:not(.remember-check) { font-size: 16px !important; }
-47530
View File
File diff suppressed because it is too large Load Diff
+23 -6
View File
@@ -39,11 +39,28 @@ const KATEX_FONTS = [
// exact URL the browser requests, query string included.
const PRECACHE = [
'/',
'/static/style.css?v=20260921chatgptusage2',
'/static/css/documents-gallery-editor.css?v=20260923csssplit1',
'/static/css/email-calendar-notes-tasks.css?v=20260923csssplit2',
'/static/css/cookbook-research-memory-settings.css?v=20260923csssplit3',
'/static/app.js?v=20260921chatgptusage2',
'/static/css/00-tokens.css?v=20260929csssplit',
'/static/css/01-agent-chat.css?v=20260929csssplit',
'/static/css/02-compare.css?v=20260929csssplit',
'/static/css/03-agent-chat.css?v=20260929csssplit',
'/static/css/04-memory.css?v=20260929csssplit',
'/static/css/05-documents.css?v=20260929csssplit',
'/static/css/06-admin-settings.css?v=20260929csssplit',
'/static/css/07-documents.css?v=20260929csssplit',
'/static/css/08-skills.css?v=20260929csssplit',
'/static/css/09-gallery.css?v=20260929csssplit',
'/static/css/10-cookbook.css?v=20260929csssplit',
'/static/css/11-tasks.css?v=20260929csssplit',
'/static/css/12-gallery.css?v=20260929csssplit',
'/static/css/13-image-editor.css?v=20260929csssplit',
'/static/css/14-email.css?v=20260929csssplit',
'/static/css/15-notes.css?v=20260929csssplit',
'/static/css/16-calendar.css?v=20260929csssplit',
'/static/css/17-research.css?v=20260929csssplit',
'/static/css/documents-gallery-editor.css?v=20260929csssplit',
'/static/css/email-calendar-notes-tasks.css?v=20260929csssplit',
'/static/css/cookbook-research-memory-settings.css?v=20260929csssplit',
'/static/app.js?v=20260929csssplit',
'/static/js/storage.js',
'/static/js/appConfig.js',
'/static/js/ui.js?v=20260916largetoolscroll1',
@@ -78,7 +95,7 @@ const PRECACHE = [
'/static/js/theme.js?v=20260911organsrain1',
'/static/js/censor.js',
'/static/js/settings.js?v=20260912writingstyle3',
'/static/js/admin.js?v=20260921chatgptusage2',
'/static/js/admin.js?v=20260929csssplit',
'/static/js/chatgptSubscriptionUsage.js',
'/static/js/init.js?v=20260829chatstyle12',
'/static/js/slashCommands.js?v=20260921chatgptusage1',