mirror of https://github.com/ghostfolio/ghostfolio
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
8774 lines
303 KiB
8774 lines
303 KiB
<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8" />
|
|
<meta content="width=device-width, initial-scale=1.0" name="viewport" />
|
|
<title>Ghostfolio AI Agent</title>
|
|
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
|
<style>
|
|
*,
|
|
*::before,
|
|
*::after {
|
|
box-sizing: border-box;
|
|
margin: 0;
|
|
padding: 0;
|
|
}
|
|
|
|
:root {
|
|
--bg: #0a0d14;
|
|
--surface: #111520;
|
|
--surface2: #181e2e;
|
|
--border: #1f2840;
|
|
--border2: #2a3550;
|
|
--indigo: #6366f1;
|
|
--indigo2: #818cf8;
|
|
--indigo-bg: #1a1d3a;
|
|
--green: #22c55e;
|
|
--yellow: #f59e0b;
|
|
--red: #ef4444;
|
|
--text: #e2e8f0;
|
|
--text2: #94a3b8;
|
|
--text3: #475569;
|
|
--radius: 12px;
|
|
}
|
|
|
|
body {
|
|
font-family:
|
|
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
background: var(--bg);
|
|
color: var(--text);
|
|
height: 100vh;
|
|
display: flex;
|
|
flex-direction: column;
|
|
overflow: hidden;
|
|
}
|
|
|
|
/* ── Header ── */
|
|
header {
|
|
padding: 12px 20px;
|
|
background: var(--surface);
|
|
border-bottom: 1px solid var(--border);
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
flex-shrink: 0;
|
|
z-index: 10;
|
|
}
|
|
|
|
.logo {
|
|
width: 34px;
|
|
height: 34px;
|
|
background: linear-gradient(135deg, var(--indigo), #8b5cf6);
|
|
border-radius: 9px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
font-size: 17px;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.header-titles h1 {
|
|
font-size: 15px;
|
|
font-weight: 600;
|
|
color: var(--text);
|
|
}
|
|
.header-titles p {
|
|
font-size: 11px;
|
|
color: var(--text3);
|
|
margin-top: 1px;
|
|
}
|
|
|
|
.header-right {
|
|
margin-left: auto;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 16px;
|
|
}
|
|
|
|
.status-pill {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 5px;
|
|
font-size: 11px;
|
|
color: var(--text3);
|
|
}
|
|
|
|
.dot {
|
|
width: 7px;
|
|
height: 7px;
|
|
border-radius: 50%;
|
|
background: var(--green);
|
|
box-shadow: 0 0 5px var(--green);
|
|
animation: pulse 2s infinite;
|
|
}
|
|
.dot.offline {
|
|
background: var(--red);
|
|
box-shadow: 0 0 5px var(--red);
|
|
animation: none;
|
|
}
|
|
|
|
@keyframes pulse {
|
|
0%,
|
|
100% {
|
|
opacity: 1;
|
|
}
|
|
50% {
|
|
opacity: 0.35;
|
|
}
|
|
}
|
|
|
|
.latency-chip {
|
|
font-size: 11px;
|
|
color: var(--text3);
|
|
background: var(--surface2);
|
|
border: 1px solid var(--border);
|
|
border-radius: 999px;
|
|
padding: 3px 9px;
|
|
transition: opacity 0.2s;
|
|
}
|
|
.latency-chip.hidden {
|
|
opacity: 0;
|
|
pointer-events: none;
|
|
}
|
|
|
|
.user-badge {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 7px;
|
|
}
|
|
.user-avatar {
|
|
width: 28px;
|
|
height: 28px;
|
|
border-radius: 7px;
|
|
background: linear-gradient(135deg, #4f46e5, #7c3aed);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
font-size: 11px;
|
|
font-weight: 700;
|
|
color: #fff;
|
|
letter-spacing: 0.5px;
|
|
}
|
|
.user-name {
|
|
font-size: 12px;
|
|
color: var(--text2);
|
|
}
|
|
|
|
.clear-btn {
|
|
font-size: 12px;
|
|
padding: 5px 12px;
|
|
border-radius: 8px;
|
|
border: 1px solid var(--border2);
|
|
background: transparent;
|
|
color: var(--text2);
|
|
cursor: pointer;
|
|
transition: all 0.15s;
|
|
white-space: nowrap;
|
|
}
|
|
.clear-btn:hover {
|
|
border-color: var(--indigo);
|
|
color: var(--indigo2);
|
|
background: var(--indigo-bg);
|
|
}
|
|
|
|
/* ── Session summary toast ── */
|
|
.session-toast {
|
|
position: fixed;
|
|
top: 60px;
|
|
left: 50%;
|
|
transform: translateX(-50%) translateY(-10px);
|
|
background: var(--surface2);
|
|
border: 1px solid var(--border2);
|
|
border-radius: var(--radius);
|
|
padding: 10px 18px;
|
|
font-size: 12px;
|
|
color: var(--text2);
|
|
z-index: 100;
|
|
opacity: 0;
|
|
pointer-events: none;
|
|
transition: all 0.3s;
|
|
white-space: nowrap;
|
|
}
|
|
.session-toast.show {
|
|
opacity: 1;
|
|
transform: translateX(-50%) translateY(0);
|
|
}
|
|
|
|
/* ── Chat area ── */
|
|
.chat-area {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
padding: 24px 20px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 18px;
|
|
}
|
|
|
|
/* ── Empty state ── */
|
|
.empty-state {
|
|
flex: 1;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: flex-start;
|
|
gap: 24px;
|
|
color: var(--text3);
|
|
text-align: center;
|
|
padding: 32px 20px 48px;
|
|
overflow-y: auto;
|
|
min-height: 0;
|
|
}
|
|
.empty-icon {
|
|
width: 56px;
|
|
height: 56px;
|
|
background: linear-gradient(135deg, var(--indigo), #8b5cf6);
|
|
border-radius: 16px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
font-size: 26px;
|
|
box-shadow: 0 8px 24px rgba(99, 102, 241, 0.35);
|
|
}
|
|
.empty-state h2 {
|
|
font-size: 18px;
|
|
color: var(--text2);
|
|
font-weight: 600;
|
|
}
|
|
.empty-state p {
|
|
font-size: 13px;
|
|
max-width: 320px;
|
|
line-height: 1.6;
|
|
}
|
|
|
|
.quick-grid {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 10px;
|
|
width: 100%;
|
|
max-width: 560px;
|
|
}
|
|
.quick-category {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 6px;
|
|
}
|
|
.quick-cat-label {
|
|
font-size: 10px;
|
|
font-weight: 600;
|
|
letter-spacing: 0.8px;
|
|
text-transform: uppercase;
|
|
color: var(--text3);
|
|
padding-left: 2px;
|
|
}
|
|
.quick-row {
|
|
display: flex;
|
|
gap: 8px;
|
|
}
|
|
.quick-btn {
|
|
flex: 1;
|
|
font-size: 12px;
|
|
padding: 8px 12px;
|
|
border-radius: 9px;
|
|
border: 1px solid var(--border2);
|
|
background: var(--surface2);
|
|
color: var(--text2);
|
|
cursor: pointer;
|
|
transition: all 0.15s;
|
|
text-align: left;
|
|
line-height: 1.4;
|
|
}
|
|
.quick-btn:hover {
|
|
border-color: var(--indigo);
|
|
color: var(--indigo2);
|
|
background: var(--indigo-bg);
|
|
}
|
|
.quick-btn .qb-icon {
|
|
display: block;
|
|
margin-bottom: 3px;
|
|
font-size: 14px;
|
|
}
|
|
.quick-btn .qb-title {
|
|
font-weight: 600;
|
|
display: block;
|
|
font-size: 12px;
|
|
}
|
|
.quick-btn .qb-sub {
|
|
font-size: 10px;
|
|
color: var(--text3);
|
|
display: block;
|
|
margin-top: 1px;
|
|
}
|
|
|
|
/* ── Messages ── */
|
|
.message {
|
|
display: flex;
|
|
flex-direction: column;
|
|
max-width: 740px;
|
|
}
|
|
.message.user {
|
|
align-self: flex-end;
|
|
align-items: flex-end;
|
|
}
|
|
.message.agent {
|
|
align-self: flex-start;
|
|
align-items: flex-start;
|
|
}
|
|
|
|
.bubble {
|
|
padding: 11px 15px;
|
|
border-radius: 14px;
|
|
font-size: 13.5px;
|
|
line-height: 1.65;
|
|
white-space: pre-wrap;
|
|
word-break: break-word;
|
|
position: relative;
|
|
}
|
|
.message.user .bubble {
|
|
background: linear-gradient(135deg, var(--indigo), #8b5cf6);
|
|
color: #fff;
|
|
border-bottom-right-radius: 4px;
|
|
}
|
|
.message.agent .bubble {
|
|
background: var(--surface2);
|
|
color: var(--text);
|
|
border-bottom-left-radius: 4px;
|
|
border: 1px solid var(--border2);
|
|
}
|
|
|
|
/* Copy button on hover */
|
|
.copy-btn {
|
|
position: absolute;
|
|
top: 8px;
|
|
right: 8px;
|
|
width: 26px;
|
|
height: 26px;
|
|
border-radius: 6px;
|
|
border: 1px solid var(--border2);
|
|
background: var(--surface);
|
|
color: var(--text3);
|
|
cursor: pointer;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
font-size: 12px;
|
|
opacity: 0;
|
|
transition:
|
|
opacity 0.15s,
|
|
color 0.15s;
|
|
}
|
|
.message.agent .bubble:hover .copy-btn {
|
|
opacity: 1;
|
|
}
|
|
.copy-btn:hover {
|
|
color: var(--indigo2);
|
|
border-color: var(--indigo);
|
|
}
|
|
.copy-btn.copied {
|
|
color: var(--green);
|
|
border-color: var(--green);
|
|
opacity: 1;
|
|
}
|
|
|
|
/* ── Message footer (badges, confidence, timestamp) ── */
|
|
.msg-footer {
|
|
margin-top: 6px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 5px;
|
|
}
|
|
|
|
.badge-row {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 5px;
|
|
align-items: center;
|
|
}
|
|
|
|
.badge {
|
|
font-size: 11px;
|
|
padding: 2px 9px;
|
|
border-radius: 999px;
|
|
border: 1px solid var(--border2);
|
|
color: var(--text2);
|
|
background: var(--surface);
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
}
|
|
.badge.tool {
|
|
border-color: var(--indigo);
|
|
color: var(--indigo2);
|
|
background: var(--indigo-bg);
|
|
}
|
|
.badge.pass {
|
|
border-color: var(--green);
|
|
color: #86efac;
|
|
background: #052e16;
|
|
}
|
|
.badge.flag {
|
|
border-color: var(--yellow);
|
|
color: #fcd34d;
|
|
background: #1c1205;
|
|
}
|
|
.badge.fail {
|
|
border-color: var(--red);
|
|
color: #fca5a5;
|
|
background: #1c0505;
|
|
}
|
|
.badge.time {
|
|
color: var(--text3);
|
|
}
|
|
|
|
/* Confidence bar */
|
|
.confidence-bar-wrap {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 7px;
|
|
}
|
|
.confidence-bar-label {
|
|
font-size: 10px;
|
|
color: var(--text3);
|
|
white-space: nowrap;
|
|
}
|
|
.confidence-bar-track {
|
|
width: 80px;
|
|
height: 4px;
|
|
background: var(--border);
|
|
border-radius: 2px;
|
|
overflow: hidden;
|
|
}
|
|
.confidence-bar-fill {
|
|
height: 100%;
|
|
border-radius: 2px;
|
|
transition: width 0.4s ease;
|
|
}
|
|
.confidence-bar-fill.high {
|
|
background: var(--green);
|
|
}
|
|
.confidence-bar-fill.med {
|
|
background: var(--yellow);
|
|
}
|
|
.confidence-bar-fill.low {
|
|
background: var(--red);
|
|
}
|
|
|
|
/* Timestamp */
|
|
.msg-ts {
|
|
font-size: 10px;
|
|
color: var(--text3);
|
|
}
|
|
|
|
/* Retry button */
|
|
.retry-btn {
|
|
font-size: 11px;
|
|
padding: 4px 10px;
|
|
border-radius: 7px;
|
|
border: 1px solid var(--border2);
|
|
background: transparent;
|
|
color: var(--text3);
|
|
cursor: pointer;
|
|
transition: all 0.15s;
|
|
margin-top: 4px;
|
|
align-self: flex-start;
|
|
}
|
|
.retry-btn:hover {
|
|
border-color: var(--indigo);
|
|
color: var(--indigo2);
|
|
background: var(--indigo-bg);
|
|
}
|
|
|
|
/* Confirmation banner */
|
|
.confirm-banner {
|
|
background: #1c1205;
|
|
border: 1px solid rgba(245, 158, 11, 0.35);
|
|
border-radius: 9px;
|
|
padding: 8px 12px;
|
|
font-size: 12px;
|
|
color: #fcd34d;
|
|
margin-top: 6px;
|
|
}
|
|
|
|
/* ── Debug panel ── */
|
|
.debug-panel {
|
|
margin-top: 6px;
|
|
width: 100%;
|
|
}
|
|
.debug-panel summary {
|
|
cursor: pointer;
|
|
user-select: none;
|
|
list-style: none;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 5px;
|
|
font-size: 10px;
|
|
color: var(--text3);
|
|
padding: 2px 0;
|
|
}
|
|
.debug-panel summary::-webkit-details-marker {
|
|
display: none;
|
|
}
|
|
.debug-body {
|
|
font-family: 'SF Mono', 'Fira Code', monospace;
|
|
font-size: 11px;
|
|
padding: 8px 12px;
|
|
background: #07090f;
|
|
color: var(--text);
|
|
border-radius: 6px;
|
|
margin-top: 4px;
|
|
border: 1px solid var(--border);
|
|
overflow-x: auto;
|
|
line-height: 1.7;
|
|
}
|
|
.db-row {
|
|
display: flex;
|
|
gap: 8px;
|
|
}
|
|
.db-key {
|
|
color: var(--indigo2);
|
|
min-width: 110px;
|
|
}
|
|
.db-val {
|
|
color: var(--text2);
|
|
}
|
|
.db-val.pass {
|
|
color: var(--green);
|
|
}
|
|
.db-val.flag {
|
|
color: var(--yellow);
|
|
}
|
|
.db-val.fail {
|
|
color: var(--red);
|
|
}
|
|
.db-val.high {
|
|
color: var(--green);
|
|
}
|
|
.db-val.med {
|
|
color: var(--yellow);
|
|
}
|
|
.db-val.low {
|
|
color: var(--red);
|
|
}
|
|
|
|
/* ── Live thinking panel ── */
|
|
.thinking-panel {
|
|
background: var(--surface2);
|
|
border: 1px solid var(--border2);
|
|
border-radius: var(--radius);
|
|
padding: 12px 14px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 8px;
|
|
max-width: 480px;
|
|
align-self: flex-start;
|
|
}
|
|
.thinking-header {
|
|
font-size: 11px;
|
|
color: var(--text3);
|
|
font-weight: 500;
|
|
letter-spacing: 0.3px;
|
|
}
|
|
.step-list {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 5px;
|
|
}
|
|
.step-item {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
font-size: 12px;
|
|
color: var(--text2);
|
|
}
|
|
.step-icon {
|
|
width: 18px;
|
|
height: 18px;
|
|
border-radius: 50%;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
font-size: 10px;
|
|
flex-shrink: 0;
|
|
}
|
|
.step-icon.running {
|
|
border: 2px solid var(--indigo);
|
|
animation: spin 1s linear infinite;
|
|
}
|
|
.step-icon.done {
|
|
background: var(--green);
|
|
color: #000;
|
|
}
|
|
.step-icon.pending {
|
|
border: 2px solid var(--border2);
|
|
color: var(--text3);
|
|
}
|
|
@keyframes spin {
|
|
to {
|
|
transform: rotate(360deg);
|
|
}
|
|
}
|
|
.step-label {
|
|
flex: 1;
|
|
}
|
|
.step-tools {
|
|
font-size: 10px;
|
|
color: var(--indigo2);
|
|
}
|
|
|
|
/* ── Typing indicator (fallback) ── */
|
|
.typing {
|
|
display: flex;
|
|
gap: 4px;
|
|
padding: 12px 16px;
|
|
background: var(--surface2);
|
|
border-radius: 14px;
|
|
border-bottom-left-radius: 4px;
|
|
border: 1px solid var(--border2);
|
|
width: fit-content;
|
|
}
|
|
.typing span {
|
|
width: 6px;
|
|
height: 6px;
|
|
background: var(--indigo);
|
|
border-radius: 50%;
|
|
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: translateY(0);
|
|
}
|
|
40% {
|
|
transform: translateY(-5px);
|
|
}
|
|
}
|
|
|
|
/* ── Restored session notice ── */
|
|
.session-restored {
|
|
align-self: center;
|
|
font-size: 11px;
|
|
color: var(--text3);
|
|
background: var(--surface2);
|
|
border: 1px dashed var(--border2);
|
|
border-radius: 999px;
|
|
padding: 4px 14px;
|
|
}
|
|
|
|
/* ── /tools panel ── */
|
|
.tools-panel {
|
|
background: var(--surface2);
|
|
border: 1px solid var(--border2);
|
|
border-radius: var(--radius);
|
|
padding: 14px 16px;
|
|
align-self: flex-start;
|
|
max-width: 540px;
|
|
}
|
|
.tools-panel h3 {
|
|
font-size: 12px;
|
|
color: var(--text2);
|
|
margin-bottom: 10px;
|
|
font-weight: 600;
|
|
}
|
|
.tool-entry {
|
|
display: flex;
|
|
gap: 10px;
|
|
margin-bottom: 7px;
|
|
}
|
|
.tool-entry-name {
|
|
font-size: 11px;
|
|
color: var(--indigo2);
|
|
font-family: 'SF Mono', 'Fira Code', monospace;
|
|
min-width: 160px;
|
|
}
|
|
.tool-entry-desc {
|
|
font-size: 11px;
|
|
color: var(--text2);
|
|
line-height: 1.4;
|
|
}
|
|
|
|
/* ── Input area ── */
|
|
.input-wrap {
|
|
background: var(--surface);
|
|
border-top: 1px solid var(--border);
|
|
padding: 12px 20px;
|
|
flex-shrink: 0;
|
|
position: relative;
|
|
}
|
|
.input-row {
|
|
display: flex;
|
|
gap: 10px;
|
|
align-items: flex-end;
|
|
}
|
|
textarea {
|
|
flex: 1;
|
|
background: var(--surface2);
|
|
border: 1px solid var(--border2);
|
|
border-radius: var(--radius);
|
|
color: var(--text);
|
|
font-size: 13.5px;
|
|
font-family: inherit;
|
|
padding: 10px 14px;
|
|
resize: none;
|
|
min-height: 44px;
|
|
max-height: 140px;
|
|
outline: none;
|
|
transition: border-color 0.15s;
|
|
}
|
|
textarea:focus {
|
|
border-color: var(--indigo);
|
|
}
|
|
textarea::placeholder {
|
|
color: var(--text3);
|
|
}
|
|
.send-btn {
|
|
width: 44px;
|
|
height: 44px;
|
|
border-radius: var(--radius);
|
|
border: none;
|
|
background: linear-gradient(135deg, var(--indigo), #8b5cf6);
|
|
color: #fff;
|
|
font-size: 18px;
|
|
cursor: pointer;
|
|
flex-shrink: 0;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
transition: opacity 0.15s;
|
|
}
|
|
.send-btn:hover {
|
|
opacity: 0.85;
|
|
}
|
|
.send-btn:disabled {
|
|
opacity: 0.35;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
/* ── Markdown content inside bubbles ── */
|
|
.bubble h1,
|
|
.bubble h2,
|
|
.bubble h3 {
|
|
font-size: 14px;
|
|
font-weight: 700;
|
|
color: var(--text);
|
|
margin: 10px 0 4px;
|
|
}
|
|
.bubble h1 {
|
|
font-size: 15px;
|
|
}
|
|
.bubble p {
|
|
margin: 4px 0;
|
|
}
|
|
.bubble ul,
|
|
.bubble ol {
|
|
padding-left: 18px;
|
|
margin: 4px 0;
|
|
}
|
|
.bubble li {
|
|
margin: 2px 0;
|
|
line-height: 1.5;
|
|
}
|
|
.bubble code {
|
|
font-family: 'SF Mono', 'Fira Code', monospace;
|
|
font-size: 12px;
|
|
background: rgba(99, 102, 241, 0.12);
|
|
color: var(--indigo2);
|
|
padding: 1px 5px;
|
|
border-radius: 4px;
|
|
}
|
|
.bubble pre {
|
|
background: #07090f;
|
|
border: 1px solid var(--border);
|
|
border-radius: 8px;
|
|
padding: 10px 12px;
|
|
overflow-x: auto;
|
|
margin: 6px 0;
|
|
}
|
|
.bubble pre code {
|
|
background: none;
|
|
padding: 0;
|
|
color: var(--text2);
|
|
font-size: 12px;
|
|
}
|
|
.bubble table {
|
|
border-collapse: collapse;
|
|
width: 100%;
|
|
margin: 6px 0;
|
|
font-size: 12px;
|
|
}
|
|
.bubble th {
|
|
background: var(--surface);
|
|
color: var(--indigo2);
|
|
padding: 5px 10px;
|
|
text-align: left;
|
|
border: 1px solid var(--border2);
|
|
font-weight: 600;
|
|
}
|
|
.bubble td {
|
|
padding: 5px 10px;
|
|
border: 1px solid var(--border);
|
|
color: var(--text2);
|
|
}
|
|
.bubble tr:nth-child(even) td {
|
|
background: rgba(255, 255, 255, 0.02);
|
|
}
|
|
.bubble hr {
|
|
border: none;
|
|
border-top: 1px solid var(--border2);
|
|
margin: 8px 0;
|
|
}
|
|
.bubble blockquote {
|
|
border-left: 3px solid var(--indigo);
|
|
padding-left: 10px;
|
|
color: var(--text2);
|
|
margin: 6px 0;
|
|
font-style: italic;
|
|
}
|
|
.bubble strong {
|
|
color: var(--text);
|
|
}
|
|
.bubble a {
|
|
color: var(--indigo2);
|
|
text-decoration: underline;
|
|
}
|
|
|
|
/* ── TTS speak button ── */
|
|
.speak-btn {
|
|
position: absolute;
|
|
top: 8px;
|
|
right: 36px;
|
|
width: 26px;
|
|
height: 26px;
|
|
border-radius: 6px;
|
|
border: 1px solid var(--border2);
|
|
background: var(--surface);
|
|
color: var(--text3);
|
|
cursor: pointer;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
font-size: 12px;
|
|
opacity: 0;
|
|
transition:
|
|
opacity 0.15s,
|
|
color 0.15s;
|
|
}
|
|
.message.agent .bubble:hover .speak-btn {
|
|
opacity: 1;
|
|
}
|
|
.speak-btn:hover {
|
|
color: var(--indigo2);
|
|
border-color: var(--indigo);
|
|
}
|
|
.speak-btn.speaking {
|
|
color: var(--green);
|
|
border-color: var(--green);
|
|
opacity: 1;
|
|
animation: pulse 1s infinite;
|
|
}
|
|
|
|
/* ── Edit button on user bubbles ── */
|
|
.edit-btn {
|
|
font-size: 11px;
|
|
padding: 3px 8px;
|
|
border-radius: 6px;
|
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
background: transparent;
|
|
color: rgba(255, 255, 255, 0.5);
|
|
cursor: pointer;
|
|
margin-top: 4px;
|
|
align-self: flex-end;
|
|
transition: all 0.15s;
|
|
display: none;
|
|
}
|
|
.message.user:hover .edit-btn {
|
|
display: inline-block;
|
|
}
|
|
.edit-btn:hover {
|
|
border-color: rgba(255, 255, 255, 0.3);
|
|
color: #fff;
|
|
}
|
|
|
|
/* ── Auto-speak toggle in header ── */
|
|
.speak-toggle {
|
|
font-size: 11px;
|
|
padding: 3px 9px;
|
|
border-radius: 999px;
|
|
border: 1px solid var(--border2);
|
|
background: var(--surface2);
|
|
color: var(--text3);
|
|
cursor: pointer;
|
|
transition: all 0.15s;
|
|
white-space: nowrap;
|
|
}
|
|
.speak-toggle.on {
|
|
border-color: var(--green);
|
|
color: #86efac;
|
|
background: #052e16;
|
|
}
|
|
|
|
/* ── Session history drawer ── */
|
|
.drawer-overlay {
|
|
position: fixed;
|
|
inset: 0;
|
|
background: rgba(0, 0, 0, 0.45);
|
|
z-index: 200;
|
|
opacity: 0;
|
|
pointer-events: none;
|
|
transition: opacity 0.2s;
|
|
}
|
|
.drawer-overlay.open {
|
|
opacity: 1;
|
|
pointer-events: all;
|
|
}
|
|
|
|
.sessions-drawer {
|
|
position: fixed;
|
|
top: 0;
|
|
left: 0;
|
|
bottom: 0;
|
|
width: 280px;
|
|
background: var(--surface);
|
|
border-right: 1px solid var(--border);
|
|
z-index: 201;
|
|
display: flex;
|
|
flex-direction: column;
|
|
transform: translateX(-100%);
|
|
transition: transform 0.22s ease;
|
|
}
|
|
.sessions-drawer.open {
|
|
transform: translateX(0);
|
|
}
|
|
|
|
.drawer-header {
|
|
padding: 14px 16px 10px;
|
|
border-bottom: 1px solid var(--border);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
flex-shrink: 0;
|
|
}
|
|
.drawer-header h3 {
|
|
font-size: 13px;
|
|
font-weight: 600;
|
|
color: var(--text2);
|
|
}
|
|
.drawer-close {
|
|
width: 26px;
|
|
height: 26px;
|
|
border-radius: 6px;
|
|
border: 1px solid var(--border2);
|
|
background: transparent;
|
|
color: var(--text3);
|
|
font-size: 14px;
|
|
cursor: pointer;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
transition: all 0.15s;
|
|
}
|
|
.drawer-close:hover {
|
|
color: var(--text);
|
|
border-color: var(--text3);
|
|
}
|
|
|
|
.drawer-new-btn {
|
|
margin: 10px 12px;
|
|
padding: 8px 12px;
|
|
border-radius: 9px;
|
|
border: 1px dashed var(--border2);
|
|
background: transparent;
|
|
color: var(--indigo2);
|
|
font-size: 12px;
|
|
cursor: pointer;
|
|
text-align: left;
|
|
transition: all 0.15s;
|
|
flex-shrink: 0;
|
|
}
|
|
.drawer-new-btn:hover {
|
|
background: var(--indigo-bg);
|
|
border-color: var(--indigo);
|
|
}
|
|
|
|
.session-list {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
padding: 4px 8px 12px;
|
|
}
|
|
.session-item {
|
|
padding: 9px 10px;
|
|
border-radius: 8px;
|
|
cursor: pointer;
|
|
transition: background 0.12s;
|
|
margin-bottom: 2px;
|
|
}
|
|
.session-item:hover {
|
|
background: var(--surface2);
|
|
}
|
|
.session-item.active {
|
|
background: var(--indigo-bg);
|
|
border: 1px solid var(--indigo);
|
|
}
|
|
.session-title {
|
|
font-size: 12px;
|
|
color: var(--text2);
|
|
font-weight: 500;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
.session-meta {
|
|
font-size: 10px;
|
|
color: var(--text3);
|
|
margin-top: 2px;
|
|
}
|
|
.session-item-actions {
|
|
float: right;
|
|
opacity: 0;
|
|
transition: opacity 0.12s;
|
|
}
|
|
.session-item:hover .session-item-actions {
|
|
opacity: 1;
|
|
}
|
|
.session-del-btn {
|
|
font-size: 11px;
|
|
color: var(--text3);
|
|
background: transparent;
|
|
border: none;
|
|
cursor: pointer;
|
|
padding: 2px 4px;
|
|
border-radius: 4px;
|
|
}
|
|
.session-del-btn:hover {
|
|
color: var(--red);
|
|
}
|
|
.drawer-empty {
|
|
font-size: 12px;
|
|
color: var(--text3);
|
|
text-align: center;
|
|
padding: 30px 16px;
|
|
line-height: 1.6;
|
|
}
|
|
|
|
/* ── Pinned context strip ── */
|
|
.context-strip {
|
|
background: var(--surface2);
|
|
border-bottom: 1px solid var(--border);
|
|
padding: 5px 20px;
|
|
display: none;
|
|
flex-wrap: wrap;
|
|
gap: 6px;
|
|
align-items: center;
|
|
flex-shrink: 0;
|
|
}
|
|
.context-strip.visible {
|
|
display: flex;
|
|
}
|
|
.context-tag {
|
|
font-size: 10px;
|
|
padding: 2px 9px;
|
|
border-radius: 999px;
|
|
border: 1px solid var(--border2);
|
|
color: var(--text3);
|
|
background: var(--surface);
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
}
|
|
.context-tag.active {
|
|
border-color: var(--indigo);
|
|
color: var(--indigo2);
|
|
background: var(--indigo-bg);
|
|
}
|
|
.context-tag.green {
|
|
border-color: var(--green);
|
|
color: #86efac;
|
|
background: #052e16;
|
|
}
|
|
|
|
/* ── Conversation search ── */
|
|
.search-bar {
|
|
position: fixed;
|
|
top: 58px;
|
|
left: 50%;
|
|
transform: translateX(-50%);
|
|
background: var(--surface2);
|
|
border: 1px solid var(--indigo);
|
|
border-radius: var(--radius);
|
|
padding: 8px 12px;
|
|
display: none;
|
|
align-items: center;
|
|
gap: 8px;
|
|
z-index: 150;
|
|
box-shadow: 0 8px 24px rgba(99, 102, 241, 0.25);
|
|
min-width: 300px;
|
|
}
|
|
.search-bar.open {
|
|
display: flex;
|
|
}
|
|
.search-input {
|
|
flex: 1;
|
|
background: transparent;
|
|
border: none;
|
|
outline: none;
|
|
color: var(--text);
|
|
font-size: 13px;
|
|
font-family: inherit;
|
|
}
|
|
.search-input::placeholder {
|
|
color: var(--text3);
|
|
}
|
|
.search-count {
|
|
font-size: 11px;
|
|
color: var(--text3);
|
|
white-space: nowrap;
|
|
}
|
|
.search-nav {
|
|
display: flex;
|
|
gap: 3px;
|
|
}
|
|
.search-nav button {
|
|
width: 22px;
|
|
height: 22px;
|
|
border-radius: 5px;
|
|
border: 1px solid var(--border2);
|
|
background: transparent;
|
|
color: var(--text3);
|
|
font-size: 11px;
|
|
cursor: pointer;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
transition: all 0.12s;
|
|
}
|
|
.search-nav button:hover {
|
|
border-color: var(--indigo);
|
|
color: var(--indigo2);
|
|
}
|
|
.search-close {
|
|
width: 22px;
|
|
height: 22px;
|
|
border-radius: 5px;
|
|
border: 1px solid var(--border2);
|
|
background: transparent;
|
|
color: var(--text3);
|
|
font-size: 12px;
|
|
cursor: pointer;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
mark {
|
|
background: rgba(99, 102, 241, 0.35);
|
|
color: var(--text);
|
|
border-radius: 2px;
|
|
padding: 0 1px;
|
|
}
|
|
mark.current {
|
|
background: rgba(245, 158, 11, 0.5);
|
|
outline: 1px solid var(--yellow);
|
|
}
|
|
|
|
/* ── Attachment button & menu ── */
|
|
.attach-wrap {
|
|
position: relative;
|
|
flex-shrink: 0;
|
|
}
|
|
.attach-btn {
|
|
width: 44px;
|
|
height: 44px;
|
|
border-radius: var(--radius);
|
|
border: 1px solid var(--border2);
|
|
background: var(--surface2);
|
|
color: var(--text2);
|
|
font-size: 20px;
|
|
cursor: pointer;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
transition: all 0.15s;
|
|
}
|
|
.attach-btn:hover {
|
|
border-color: var(--indigo);
|
|
color: var(--indigo2);
|
|
background: var(--indigo-bg);
|
|
}
|
|
.attach-menu {
|
|
position: absolute;
|
|
bottom: 52px;
|
|
left: 0;
|
|
background: var(--surface2);
|
|
border: 1px solid var(--border2);
|
|
border-radius: var(--radius);
|
|
padding: 6px;
|
|
min-width: 200px;
|
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
|
display: none;
|
|
flex-direction: column;
|
|
gap: 2px;
|
|
z-index: 50;
|
|
}
|
|
.attach-menu.open {
|
|
display: flex;
|
|
}
|
|
.attach-item {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 9px;
|
|
padding: 8px 10px;
|
|
border-radius: 8px;
|
|
cursor: pointer;
|
|
font-size: 12px;
|
|
color: var(--text2);
|
|
transition: background 0.12s;
|
|
border: none;
|
|
background: transparent;
|
|
text-align: left;
|
|
width: 100%;
|
|
}
|
|
.attach-item:hover {
|
|
background: var(--surface);
|
|
color: var(--indigo2);
|
|
}
|
|
.attach-item-icon {
|
|
font-size: 16px;
|
|
width: 20px;
|
|
text-align: center;
|
|
}
|
|
.attach-preview {
|
|
font-size: 11px;
|
|
color: var(--indigo2);
|
|
background: var(--indigo-bg);
|
|
border: 1px solid var(--indigo);
|
|
border-radius: 7px;
|
|
padding: 4px 10px;
|
|
margin: 4px 0 0;
|
|
display: none;
|
|
align-items: center;
|
|
gap: 6px;
|
|
}
|
|
.attach-preview.visible {
|
|
display: flex;
|
|
}
|
|
.attach-preview-remove {
|
|
margin-left: auto;
|
|
cursor: pointer;
|
|
color: var(--text3);
|
|
font-size: 13px;
|
|
background: none;
|
|
border: none;
|
|
}
|
|
.attach-preview-remove:hover {
|
|
color: var(--red);
|
|
}
|
|
|
|
/* ── Chats button in header ── */
|
|
.chats-btn {
|
|
font-size: 12px;
|
|
padding: 5px 12px;
|
|
border-radius: 8px;
|
|
border: 1px solid var(--border2);
|
|
background: transparent;
|
|
color: var(--text2);
|
|
cursor: pointer;
|
|
transition: all 0.15s;
|
|
white-space: nowrap;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 5px;
|
|
}
|
|
.chats-btn:hover {
|
|
border-color: var(--indigo);
|
|
color: var(--indigo2);
|
|
background: var(--indigo-bg);
|
|
}
|
|
|
|
/* ── Shortcut help modal ── */
|
|
.modal-overlay {
|
|
position: fixed;
|
|
inset: 0;
|
|
background: rgba(0, 0, 0, 0.6);
|
|
z-index: 500;
|
|
display: none;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
.modal-overlay.open {
|
|
display: flex;
|
|
}
|
|
.modal-box {
|
|
background: var(--surface2);
|
|
border: 1px solid var(--border2);
|
|
border-radius: 16px;
|
|
padding: 20px 24px;
|
|
max-width: 480px;
|
|
width: 90vw;
|
|
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.5);
|
|
}
|
|
.modal-title {
|
|
font-size: 14px;
|
|
font-weight: 700;
|
|
color: var(--text);
|
|
margin-bottom: 14px;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
}
|
|
.modal-close-btn {
|
|
margin-left: auto;
|
|
width: 26px;
|
|
height: 26px;
|
|
border-radius: 6px;
|
|
border: 1px solid var(--border2);
|
|
background: transparent;
|
|
color: var(--text3);
|
|
font-size: 14px;
|
|
cursor: pointer;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
.modal-close-btn:hover {
|
|
color: var(--text);
|
|
border-color: var(--text3);
|
|
}
|
|
.shortcut-table {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
}
|
|
.shortcut-table tr {
|
|
border-bottom: 1px solid var(--border);
|
|
}
|
|
.shortcut-table tr:last-child {
|
|
border-bottom: none;
|
|
}
|
|
.shortcut-table td {
|
|
padding: 7px 4px;
|
|
font-size: 12px;
|
|
vertical-align: middle;
|
|
}
|
|
.shortcut-table td:first-child {
|
|
color: var(--text2);
|
|
width: 55%;
|
|
}
|
|
.shortcut-table td:last-child {
|
|
text-align: right;
|
|
}
|
|
.kbd {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 3px;
|
|
font-family: 'SF Mono', 'Fira Code', monospace;
|
|
font-size: 10px;
|
|
padding: 2px 6px;
|
|
background: var(--surface);
|
|
border: 1px solid var(--border2);
|
|
border-radius: 5px;
|
|
color: var(--indigo2);
|
|
white-space: nowrap;
|
|
}
|
|
|
|
/* ── Star/favorite button on bubbles ── */
|
|
.star-btn {
|
|
position: absolute;
|
|
top: 8px;
|
|
right: 64px;
|
|
width: 26px;
|
|
height: 26px;
|
|
border-radius: 6px;
|
|
border: 1px solid var(--border2);
|
|
background: var(--surface);
|
|
color: var(--text3);
|
|
cursor: pointer;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
font-size: 12px;
|
|
opacity: 0;
|
|
transition:
|
|
opacity 0.15s,
|
|
color 0.15s;
|
|
}
|
|
.message.agent .bubble:hover .star-btn {
|
|
opacity: 1;
|
|
}
|
|
.star-btn:hover {
|
|
color: var(--yellow);
|
|
border-color: var(--yellow);
|
|
}
|
|
.star-btn.starred {
|
|
color: var(--yellow);
|
|
border-color: var(--yellow);
|
|
opacity: 1;
|
|
}
|
|
|
|
/* ── Saved tab in drawer ── */
|
|
.drawer-tabs {
|
|
display: flex;
|
|
border-bottom: 1px solid var(--border);
|
|
flex-shrink: 0;
|
|
}
|
|
.drawer-tab {
|
|
flex: 1;
|
|
padding: 8px 4px;
|
|
font-size: 11px;
|
|
font-weight: 600;
|
|
text-align: center;
|
|
cursor: pointer;
|
|
color: var(--text3);
|
|
border-bottom: 2px solid transparent;
|
|
transition: all 0.15s;
|
|
background: transparent;
|
|
border-top: none;
|
|
border-left: none;
|
|
border-right: none;
|
|
}
|
|
.drawer-tab.active {
|
|
color: var(--indigo2);
|
|
border-bottom-color: var(--indigo);
|
|
}
|
|
.saved-list {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
padding: 4px 8px 12px;
|
|
}
|
|
.saved-item {
|
|
padding: 9px 10px;
|
|
border-radius: 8px;
|
|
margin-bottom: 4px;
|
|
background: var(--surface2);
|
|
border: 1px solid var(--border);
|
|
}
|
|
.saved-item-text {
|
|
font-size: 11px;
|
|
color: var(--text2);
|
|
line-height: 1.4;
|
|
display: -webkit-box;
|
|
-webkit-line-clamp: 3;
|
|
-webkit-box-orient: vertical;
|
|
overflow: hidden;
|
|
}
|
|
.saved-item-meta {
|
|
font-size: 10px;
|
|
color: var(--text3);
|
|
margin-top: 4px;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
}
|
|
.saved-del-btn {
|
|
font-size: 11px;
|
|
color: var(--text3);
|
|
background: transparent;
|
|
border: none;
|
|
cursor: pointer;
|
|
padding: 1px 4px;
|
|
}
|
|
.saved-del-btn:hover {
|
|
color: var(--red);
|
|
}
|
|
|
|
/* ── Response length toggle ── */
|
|
.length-bar {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
padding: 5px 0 0;
|
|
flex-wrap: wrap;
|
|
}
|
|
.length-label {
|
|
font-size: 10px;
|
|
color: var(--text3);
|
|
}
|
|
.length-pills {
|
|
display: flex;
|
|
gap: 3px;
|
|
}
|
|
.length-pill {
|
|
font-size: 10px;
|
|
padding: 3px 9px;
|
|
border-radius: 999px;
|
|
border: 1px solid var(--border2);
|
|
background: transparent;
|
|
color: var(--text3);
|
|
cursor: pointer;
|
|
transition: all 0.12s;
|
|
}
|
|
.length-pill.active {
|
|
border-color: var(--indigo);
|
|
color: var(--indigo2);
|
|
background: var(--indigo-bg);
|
|
}
|
|
|
|
/* ── Scenario mode badge ── */
|
|
.scenario-badge {
|
|
font-size: 11px;
|
|
padding: 3px 10px;
|
|
border-radius: 999px;
|
|
border: 1px solid var(--yellow);
|
|
color: #fcd34d;
|
|
background: #1c1205;
|
|
display: none;
|
|
align-items: center;
|
|
gap: 5px;
|
|
cursor: pointer;
|
|
white-space: nowrap;
|
|
animation: pulse 2s infinite;
|
|
}
|
|
.scenario-badge.active {
|
|
display: flex;
|
|
}
|
|
|
|
/* ── Goal tracker chip ── */
|
|
.goal-chip {
|
|
font-size: 11px;
|
|
padding: 3px 10px;
|
|
border-radius: 999px;
|
|
border: 1px solid var(--border2);
|
|
color: var(--text3);
|
|
background: var(--surface2);
|
|
display: none;
|
|
align-items: center;
|
|
gap: 6px;
|
|
cursor: pointer;
|
|
white-space: nowrap;
|
|
transition: border-color 0.2s;
|
|
}
|
|
.goal-chip.visible {
|
|
display: flex;
|
|
}
|
|
.goal-bar-track {
|
|
width: 48px;
|
|
height: 4px;
|
|
background: var(--border);
|
|
border-radius: 2px;
|
|
overflow: hidden;
|
|
}
|
|
.goal-bar-fill {
|
|
height: 100%;
|
|
background: var(--green);
|
|
border-radius: 2px;
|
|
transition: width 0.4s;
|
|
}
|
|
|
|
/* ── Settings dropdown ── */
|
|
.settings-wrap {
|
|
position: relative;
|
|
}
|
|
.settings-btn {
|
|
height: 34px;
|
|
padding: 0 12px;
|
|
border-radius: 8px;
|
|
border: 1px solid var(--border2);
|
|
background: var(--surface2);
|
|
color: var(--text2);
|
|
font-size: 13px;
|
|
font-weight: 600;
|
|
font-family: inherit;
|
|
cursor: pointer;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
white-space: nowrap;
|
|
transition: all 0.15s;
|
|
}
|
|
.settings-btn:hover {
|
|
border-color: var(--indigo);
|
|
color: var(--indigo2);
|
|
background: var(--indigo-bg);
|
|
}
|
|
.settings-menu {
|
|
position: absolute;
|
|
top: 38px;
|
|
right: 0;
|
|
background: var(--surface2);
|
|
border: 1px solid var(--border2);
|
|
border-radius: var(--radius);
|
|
padding: 6px;
|
|
min-width: 240px;
|
|
max-width: 280px;
|
|
max-height: calc(100vh - 80px);
|
|
overflow-y: auto;
|
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
|
z-index: 400;
|
|
display: none;
|
|
flex-direction: column;
|
|
gap: 2px;
|
|
}
|
|
.settings-menu.open {
|
|
display: flex;
|
|
}
|
|
.settings-menu::-webkit-scrollbar {
|
|
width: 4px;
|
|
}
|
|
.settings-menu::-webkit-scrollbar-thumb {
|
|
background: var(--border2);
|
|
border-radius: 2px;
|
|
}
|
|
.settings-scroll-hint {
|
|
font-size: 10px;
|
|
color: var(--text3);
|
|
text-align: center;
|
|
padding: 4px 0 2px;
|
|
letter-spacing: 0.5px;
|
|
flex-shrink: 0;
|
|
}
|
|
.settings-item {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 8px 10px;
|
|
border-radius: 8px;
|
|
cursor: pointer;
|
|
font-size: 12px;
|
|
color: var(--text2);
|
|
transition: background 0.12s;
|
|
border: none;
|
|
background: transparent;
|
|
width: 100%;
|
|
text-align: left;
|
|
}
|
|
.settings-item:hover {
|
|
background: var(--surface);
|
|
color: var(--indigo2);
|
|
}
|
|
.settings-item-left {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
}
|
|
.settings-divider {
|
|
height: 1px;
|
|
background: var(--border);
|
|
margin: 4px 0;
|
|
}
|
|
.toggle-pill {
|
|
width: 28px;
|
|
height: 15px;
|
|
border-radius: 999px;
|
|
background: var(--border2);
|
|
position: relative;
|
|
transition: background 0.2s;
|
|
flex-shrink: 0;
|
|
}
|
|
.toggle-pill::after {
|
|
content: '';
|
|
position: absolute;
|
|
top: 2px;
|
|
left: 2px;
|
|
width: 11px;
|
|
height: 11px;
|
|
border-radius: 50%;
|
|
background: #fff;
|
|
transition: transform 0.2s;
|
|
}
|
|
.toggle-pill.on {
|
|
background: var(--indigo);
|
|
}
|
|
.toggle-pill.on::after {
|
|
transform: translateX(13px);
|
|
}
|
|
|
|
/* ── Proactive greeting banner ── */
|
|
.greeting-banner {
|
|
margin: 12px 20px 0;
|
|
background: linear-gradient(135deg, var(--indigo-bg), var(--surface2));
|
|
border: 1px solid var(--indigo);
|
|
border-radius: var(--radius);
|
|
padding: 12px 14px;
|
|
display: none;
|
|
align-items: center;
|
|
gap: 12px;
|
|
flex-shrink: 0;
|
|
}
|
|
.greeting-banner.show {
|
|
display: flex;
|
|
}
|
|
.greeting-banner-text {
|
|
flex: 1;
|
|
font-size: 12px;
|
|
color: var(--text2);
|
|
line-height: 1.5;
|
|
}
|
|
.greeting-banner-text strong {
|
|
color: var(--text);
|
|
}
|
|
.greeting-action {
|
|
font-size: 11px;
|
|
padding: 5px 12px;
|
|
border-radius: 7px;
|
|
border: 1px solid var(--indigo);
|
|
background: var(--indigo);
|
|
color: #fff;
|
|
cursor: pointer;
|
|
white-space: nowrap;
|
|
flex-shrink: 0;
|
|
}
|
|
.greeting-dismiss {
|
|
color: var(--text3);
|
|
background: transparent;
|
|
border: none;
|
|
cursor: pointer;
|
|
font-size: 14px;
|
|
flex-shrink: 0;
|
|
padding: 2px;
|
|
}
|
|
|
|
/* ── Input template picker ── */
|
|
.template-picker {
|
|
position: absolute;
|
|
bottom: 100%;
|
|
left: 0;
|
|
right: 0;
|
|
background: var(--surface2);
|
|
border: 1px solid var(--indigo);
|
|
border-radius: var(--radius);
|
|
margin-bottom: 6px;
|
|
box-shadow: 0 -8px 24px rgba(99, 102, 241, 0.2);
|
|
display: none;
|
|
flex-direction: column;
|
|
z-index: 50;
|
|
overflow: hidden;
|
|
}
|
|
.template-picker.open {
|
|
display: flex;
|
|
}
|
|
.template-picker-header {
|
|
padding: 7px 12px;
|
|
font-size: 10px;
|
|
font-weight: 600;
|
|
letter-spacing: 0.8px;
|
|
text-transform: uppercase;
|
|
color: var(--indigo2);
|
|
border-bottom: 1px solid var(--border);
|
|
}
|
|
.template-item {
|
|
padding: 9px 12px;
|
|
cursor: pointer;
|
|
font-size: 12px;
|
|
color: var(--text2);
|
|
transition: background 0.1s;
|
|
border-bottom: 1px solid var(--border);
|
|
}
|
|
.template-item:last-child {
|
|
border-bottom: none;
|
|
}
|
|
.template-item:hover {
|
|
background: var(--surface);
|
|
color: var(--indigo2);
|
|
}
|
|
.template-item strong {
|
|
color: var(--text);
|
|
}
|
|
.template-item .tmpl-hint {
|
|
font-size: 10px;
|
|
color: var(--text3);
|
|
margin-top: 2px;
|
|
}
|
|
|
|
/* ── Light theme ── */
|
|
[data-theme='light'] {
|
|
--bg: #f1f5f9;
|
|
--surface: #ffffff;
|
|
--surface2: #f8fafc;
|
|
--border: #e2e8f0;
|
|
--border2: #cbd5e1;
|
|
--indigo: #6366f1;
|
|
--indigo2: #4f46e5;
|
|
--indigo-bg: #eef2ff;
|
|
--green: #16a34a;
|
|
--yellow: #d97706;
|
|
--red: #dc2626;
|
|
--text: #0f172a;
|
|
--text2: #475569;
|
|
--text3: #94a3b8;
|
|
}
|
|
[data-theme='light'] .message.agent .bubble {
|
|
background: #ffffff;
|
|
border-color: #e2e8f0;
|
|
}
|
|
[data-theme='light'] .message.user .bubble {
|
|
background: linear-gradient(135deg, #6366f1, #8b5cf6);
|
|
}
|
|
[data-theme='light'] .bubble code {
|
|
background: #eef2ff;
|
|
}
|
|
[data-theme='light'] .bubble pre {
|
|
background: #f8fafc;
|
|
}
|
|
[data-theme='light'] .debug-body {
|
|
background: #f1f5f9;
|
|
}
|
|
|
|
/* ── Print / PDF export styles ── */
|
|
@media print {
|
|
header,
|
|
.context-strip,
|
|
.search-bar,
|
|
.input-wrap,
|
|
.sessions-drawer,
|
|
.drawer-overlay,
|
|
.modal-overlay,
|
|
.greeting-banner,
|
|
.copy-btn,
|
|
.speak-btn,
|
|
.star-btn,
|
|
.edit-btn,
|
|
.retry-btn,
|
|
.msg-footer,
|
|
.debug-panel,
|
|
.followup-row,
|
|
.session-toast,
|
|
.watchlist-banner,
|
|
.pinned-strip,
|
|
.memory-indicator,
|
|
.memory-panel,
|
|
.reaction-row,
|
|
.annotation-btn,
|
|
.pin-bubble-btn,
|
|
.help-fab {
|
|
display: none !important;
|
|
}
|
|
.annotation-wrap.open {
|
|
display: block !important;
|
|
background: #fffde7;
|
|
color: #4a3500;
|
|
border: 1px solid #d97706;
|
|
}
|
|
body {
|
|
background: #fff !important;
|
|
color: #000 !important;
|
|
overflow: visible !important;
|
|
height: auto !important;
|
|
}
|
|
.chat-area {
|
|
overflow: visible !important;
|
|
padding: 16px !important;
|
|
}
|
|
.bubble {
|
|
border: 1px solid #ccc !important;
|
|
background: #f9f9f9 !important;
|
|
color: #000 !important;
|
|
}
|
|
.message.user .bubble {
|
|
background: #e8eeff !important;
|
|
}
|
|
}
|
|
|
|
/* ── Mobile responsive ── */
|
|
@media (max-width: 600px) {
|
|
header {
|
|
padding: 8px 12px;
|
|
gap: 8px;
|
|
}
|
|
.header-titles h1 {
|
|
font-size: 13px;
|
|
}
|
|
.user-name,
|
|
.latency-chip,
|
|
.speak-toggle {
|
|
display: none;
|
|
}
|
|
.networth-badge {
|
|
font-size: 10px;
|
|
padding: 2px 7px;
|
|
}
|
|
.chat-area {
|
|
padding: 14px 10px;
|
|
gap: 12px;
|
|
}
|
|
.message {
|
|
max-width: 100%;
|
|
}
|
|
.quick-grid {
|
|
max-width: 100%;
|
|
}
|
|
.quick-row {
|
|
flex-direction: column;
|
|
}
|
|
.input-wrap {
|
|
padding: 8px 10px;
|
|
}
|
|
textarea {
|
|
font-size: 14px;
|
|
}
|
|
.sessions-drawer {
|
|
width: 85vw;
|
|
}
|
|
.search-bar {
|
|
min-width: 90vw;
|
|
}
|
|
}
|
|
|
|
/* ── Mic button ── */
|
|
.mic-btn {
|
|
width: 44px;
|
|
height: 44px;
|
|
border-radius: var(--radius);
|
|
border: 1px solid var(--border2);
|
|
background: var(--surface2);
|
|
color: var(--text2);
|
|
font-size: 18px;
|
|
cursor: pointer;
|
|
flex-shrink: 0;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
transition: all 0.15s;
|
|
}
|
|
.mic-btn:hover {
|
|
border-color: var(--indigo);
|
|
color: var(--indigo2);
|
|
background: var(--indigo-bg);
|
|
}
|
|
.mic-btn.listening {
|
|
border-color: var(--red);
|
|
color: var(--red);
|
|
background: #1c0505;
|
|
animation: pulse-mic 1s ease-in-out infinite;
|
|
}
|
|
@keyframes pulse-mic {
|
|
0%,
|
|
100% {
|
|
box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.5);
|
|
}
|
|
50% {
|
|
box-shadow: 0 0 0 6px rgba(239, 68, 68, 0);
|
|
}
|
|
}
|
|
|
|
/* ── Follow-up suggestion chips ── */
|
|
.followup-row {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 6px;
|
|
margin-top: 8px;
|
|
align-self: flex-start;
|
|
max-width: 740px;
|
|
}
|
|
.followup-chip {
|
|
font-size: 11px;
|
|
padding: 5px 12px;
|
|
border-radius: 999px;
|
|
border: 1px solid var(--border2);
|
|
background: var(--surface2);
|
|
color: var(--text2);
|
|
cursor: pointer;
|
|
transition: all 0.15s;
|
|
white-space: nowrap;
|
|
}
|
|
.followup-chip:hover {
|
|
border-color: var(--indigo);
|
|
color: var(--indigo2);
|
|
background: var(--indigo-bg);
|
|
}
|
|
|
|
/* ── Net worth banner ── */
|
|
.networth-badge {
|
|
font-size: 11px;
|
|
color: #86efac;
|
|
background: #052e16;
|
|
border: 1px solid var(--green);
|
|
border-radius: 999px;
|
|
padding: 3px 10px;
|
|
display: none;
|
|
align-items: center;
|
|
gap: 4px;
|
|
white-space: nowrap;
|
|
cursor: default;
|
|
}
|
|
|
|
/* ── Editable session title in header ── */
|
|
.session-title-wrap {
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
.session-title-wrap h1 {
|
|
font-size: 15px;
|
|
font-weight: 600;
|
|
color: var(--text);
|
|
}
|
|
.session-subtitle {
|
|
font-size: 11px;
|
|
color: var(--text3);
|
|
margin-top: 1px;
|
|
cursor: pointer;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
max-width: 220px;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
.session-subtitle:hover {
|
|
color: var(--indigo2);
|
|
}
|
|
.session-subtitle .edit-pencil {
|
|
opacity: 0;
|
|
font-size: 10px;
|
|
transition: opacity 0.15s;
|
|
}
|
|
.session-subtitle:hover .edit-pencil {
|
|
opacity: 1;
|
|
}
|
|
.session-title-input {
|
|
font-size: 11px;
|
|
background: var(--surface2);
|
|
border: 1px solid var(--indigo);
|
|
border-radius: 5px;
|
|
color: var(--text);
|
|
padding: 2px 6px;
|
|
outline: none;
|
|
width: 200px;
|
|
font-family: inherit;
|
|
}
|
|
|
|
/* ── Per-session "..." context menu ── */
|
|
.session-ctx-wrap {
|
|
position: relative;
|
|
}
|
|
.session-ctx-btn {
|
|
opacity: 0;
|
|
width: 22px;
|
|
height: 22px;
|
|
border-radius: 5px;
|
|
border: 1px solid transparent;
|
|
background: transparent;
|
|
color: var(--text3);
|
|
font-size: 13px;
|
|
cursor: pointer;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
transition: all 0.12s;
|
|
flex-shrink: 0;
|
|
}
|
|
.session-item:hover .session-ctx-btn {
|
|
opacity: 1;
|
|
}
|
|
.session-ctx-btn:hover {
|
|
border-color: var(--border2);
|
|
color: var(--text2);
|
|
background: var(--surface);
|
|
}
|
|
.session-ctx-menu {
|
|
position: absolute;
|
|
top: 24px;
|
|
right: 0;
|
|
background: var(--surface2);
|
|
border: 1px solid var(--border2);
|
|
border-radius: 9px;
|
|
padding: 4px;
|
|
min-width: 150px;
|
|
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.4);
|
|
z-index: 300;
|
|
display: none;
|
|
flex-direction: column;
|
|
}
|
|
.session-ctx-menu.open {
|
|
display: flex;
|
|
}
|
|
.ctx-item {
|
|
padding: 7px 10px;
|
|
border-radius: 6px;
|
|
font-size: 12px;
|
|
color: var(--text2);
|
|
cursor: pointer;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
transition: background 0.1s;
|
|
border: none;
|
|
background: transparent;
|
|
text-align: left;
|
|
width: 100%;
|
|
}
|
|
.ctx-item:hover {
|
|
background: var(--surface);
|
|
color: var(--indigo2);
|
|
}
|
|
.ctx-item.danger {
|
|
color: #fca5a5;
|
|
}
|
|
.ctx-item.danger:hover {
|
|
background: #1c0505;
|
|
color: var(--red);
|
|
}
|
|
.ctx-divider {
|
|
height: 1px;
|
|
background: var(--border);
|
|
margin: 3px 0;
|
|
}
|
|
|
|
/* ── Pinned session indicator ── */
|
|
.pin-icon {
|
|
font-size: 10px;
|
|
color: var(--yellow);
|
|
margin-right: 2px;
|
|
}
|
|
|
|
/* ── Share button ── */
|
|
.share-btn {
|
|
font-size: 12px;
|
|
padding: 5px 12px;
|
|
border-radius: 8px;
|
|
border: 1px solid var(--border2);
|
|
background: transparent;
|
|
color: var(--text2);
|
|
cursor: pointer;
|
|
transition: all 0.15s;
|
|
white-space: nowrap;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 5px;
|
|
}
|
|
.share-btn:hover {
|
|
border-color: var(--indigo);
|
|
color: var(--indigo2);
|
|
background: var(--indigo-bg);
|
|
}
|
|
|
|
/* ── Floating help button ── */
|
|
.help-fab {
|
|
position: fixed;
|
|
bottom: 80px;
|
|
right: 20px;
|
|
width: 40px;
|
|
height: 40px;
|
|
border-radius: 50%;
|
|
background: linear-gradient(135deg, var(--indigo), #8b5cf6);
|
|
color: #fff;
|
|
font-size: 17px;
|
|
font-weight: 700;
|
|
border: none;
|
|
cursor: pointer;
|
|
z-index: 400;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
box-shadow: 0 4px 16px rgba(99, 102, 241, 0.5);
|
|
transition:
|
|
transform 0.15s,
|
|
box-shadow 0.15s;
|
|
}
|
|
.help-fab:hover {
|
|
transform: scale(1.08);
|
|
box-shadow: 0 6px 20px rgba(99, 102, 241, 0.7);
|
|
}
|
|
|
|
/* ── Help guide panel ── */
|
|
.help-panel-overlay {
|
|
position: fixed;
|
|
inset: 0;
|
|
background: rgba(0, 0, 0, 0.55);
|
|
z-index: 450;
|
|
display: none;
|
|
align-items: center;
|
|
justify-content: center;
|
|
padding: 20px;
|
|
}
|
|
.help-panel-overlay.open {
|
|
display: flex;
|
|
}
|
|
.help-panel {
|
|
background: var(--surface2);
|
|
border: 1px solid var(--border2);
|
|
border-radius: 18px;
|
|
padding: 24px 28px 28px;
|
|
width: 100%;
|
|
max-width: 660px;
|
|
max-height: 85vh;
|
|
overflow-y: auto;
|
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.6);
|
|
animation: popIn 0.2s ease;
|
|
}
|
|
@keyframes popIn {
|
|
from {
|
|
transform: scale(0.96);
|
|
opacity: 0;
|
|
}
|
|
to {
|
|
transform: scale(1);
|
|
opacity: 1;
|
|
}
|
|
}
|
|
.help-panel-title {
|
|
font-size: 15px;
|
|
font-weight: 700;
|
|
color: var(--text);
|
|
margin-bottom: 16px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
}
|
|
.help-section {
|
|
margin-bottom: 14px;
|
|
}
|
|
.help-section-title {
|
|
font-size: 10px;
|
|
font-weight: 700;
|
|
letter-spacing: 0.8px;
|
|
text-transform: uppercase;
|
|
color: var(--text3);
|
|
margin-bottom: 7px;
|
|
}
|
|
.help-feature-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(3, 1fr);
|
|
gap: 6px;
|
|
}
|
|
@media (max-width: 500px) {
|
|
.help-feature-grid {
|
|
grid-template-columns: 1fr 1fr;
|
|
}
|
|
}
|
|
.help-feature {
|
|
background: var(--surface);
|
|
border: 1px solid var(--border);
|
|
border-radius: 9px;
|
|
padding: 9px 11px;
|
|
cursor: pointer;
|
|
transition: all 0.12s;
|
|
}
|
|
.help-feature:hover {
|
|
border-color: var(--indigo);
|
|
background: var(--indigo-bg);
|
|
}
|
|
.help-feature-icon {
|
|
font-size: 16px;
|
|
margin-bottom: 4px;
|
|
}
|
|
.help-feature-name {
|
|
font-size: 12px;
|
|
font-weight: 600;
|
|
color: var(--text2);
|
|
}
|
|
.help-feature-desc {
|
|
font-size: 10px;
|
|
color: var(--text3);
|
|
margin-top: 2px;
|
|
line-height: 1.4;
|
|
}
|
|
|
|
/* ── Export as image card ── */
|
|
#export-canvas {
|
|
display: block;
|
|
width: 100%;
|
|
border-radius: 12px;
|
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
|
|
}
|
|
.export-card-actions {
|
|
display: flex;
|
|
gap: 10px;
|
|
margin-top: 14px;
|
|
}
|
|
.export-action-btn {
|
|
flex: 1;
|
|
padding: 9px;
|
|
border-radius: 9px;
|
|
border: 1px solid var(--border2);
|
|
background: var(--surface);
|
|
color: var(--text2);
|
|
font-size: 13px;
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
transition: all 0.15s;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 6px;
|
|
}
|
|
.export-action-btn.primary {
|
|
background: linear-gradient(135deg, var(--indigo), #8b5cf6);
|
|
border-color: transparent;
|
|
color: #fff;
|
|
}
|
|
.export-action-btn:hover {
|
|
border-color: var(--indigo);
|
|
color: var(--indigo2);
|
|
}
|
|
.export-action-btn.primary:hover {
|
|
opacity: 0.9;
|
|
}
|
|
|
|
/* ── Portfolio heat map ── */
|
|
.heatmap-grid {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 4px;
|
|
padding: 4px 0;
|
|
min-height: 120px;
|
|
}
|
|
.hm-tile {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
border-radius: 8px;
|
|
cursor: pointer;
|
|
transition:
|
|
transform 0.12s,
|
|
box-shadow 0.12s;
|
|
font-weight: 700;
|
|
color: rgba(255, 255, 255, 0.95);
|
|
user-select: none;
|
|
}
|
|
.hm-tile:hover {
|
|
transform: scale(1.04);
|
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
|
|
}
|
|
.heatmap-legend {
|
|
display: flex;
|
|
gap: 8px;
|
|
align-items: center;
|
|
font-size: 10px;
|
|
color: var(--text3);
|
|
flex-wrap: wrap;
|
|
}
|
|
.hm-legend-dot {
|
|
width: 10px;
|
|
height: 10px;
|
|
border-radius: 2px;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
/* ── Request inspector panel ── */
|
|
.inspector-panel {
|
|
position: fixed;
|
|
top: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
width: 320px;
|
|
background: var(--surface2);
|
|
border-left: 1px solid var(--border2);
|
|
z-index: 300;
|
|
display: flex;
|
|
flex-direction: column;
|
|
transform: translateX(100%);
|
|
transition: transform 0.25s ease;
|
|
box-shadow: -8px 0 32px rgba(0, 0, 0, 0.4);
|
|
}
|
|
.inspector-panel.open {
|
|
transform: translateX(0);
|
|
}
|
|
.inspector-header {
|
|
padding: 14px 16px;
|
|
border-bottom: 1px solid var(--border);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
flex-shrink: 0;
|
|
}
|
|
.inspector-header-title {
|
|
font-size: 13px;
|
|
font-weight: 700;
|
|
color: var(--text);
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
}
|
|
.inspector-close {
|
|
background: none;
|
|
border: none;
|
|
color: var(--text3);
|
|
cursor: pointer;
|
|
font-size: 14px;
|
|
}
|
|
.inspector-body {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
padding: 8px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 6px;
|
|
}
|
|
.inspector-entry {
|
|
background: var(--surface);
|
|
border: 1px solid var(--border);
|
|
border-radius: 9px;
|
|
padding: 10px 12px;
|
|
font-size: 11px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 5px;
|
|
}
|
|
.inspector-entry.latest {
|
|
border-color: var(--indigo);
|
|
}
|
|
.inspector-ts {
|
|
font-size: 10px;
|
|
color: var(--text3);
|
|
}
|
|
.inspector-query {
|
|
font-size: 12px;
|
|
color: var(--text);
|
|
font-weight: 600;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
.inspector-row {
|
|
display: flex;
|
|
gap: 8px;
|
|
align-items: baseline;
|
|
}
|
|
.insp-key {
|
|
font-size: 10px;
|
|
font-weight: 700;
|
|
letter-spacing: 0.5px;
|
|
text-transform: uppercase;
|
|
color: var(--text3);
|
|
width: 68px;
|
|
flex-shrink: 0;
|
|
}
|
|
.insp-val {
|
|
font-size: 11px;
|
|
color: var(--text2);
|
|
}
|
|
.insp-conf-high {
|
|
color: var(--green);
|
|
}
|
|
.insp-conf-med {
|
|
color: var(--yellow);
|
|
}
|
|
.insp-conf-low {
|
|
color: var(--red);
|
|
}
|
|
.insp-bar-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
}
|
|
.insp-bar-track {
|
|
flex: 1;
|
|
height: 4px;
|
|
background: var(--border2);
|
|
border-radius: 2px;
|
|
overflow: hidden;
|
|
}
|
|
.insp-bar-fill {
|
|
height: 100%;
|
|
border-radius: 2px;
|
|
background: var(--indigo);
|
|
}
|
|
.inspector-empty {
|
|
color: var(--text3);
|
|
font-size: 12px;
|
|
padding: 24px 16px;
|
|
text-align: center;
|
|
line-height: 1.6;
|
|
}
|
|
|
|
/* ── Query history dropdown ── */
|
|
.query-history {
|
|
position: absolute;
|
|
bottom: calc(100% + 4px);
|
|
left: 0;
|
|
right: 0;
|
|
background: var(--surface2);
|
|
border: 1px solid var(--border2);
|
|
border-radius: var(--radius);
|
|
overflow: hidden;
|
|
max-height: 220px;
|
|
overflow-y: auto;
|
|
box-shadow: 0 -8px 24px rgba(0, 0, 0, 0.4);
|
|
z-index: 50;
|
|
display: none;
|
|
flex-direction: column;
|
|
}
|
|
.query-history.open {
|
|
display: flex;
|
|
}
|
|
.qh-header {
|
|
font-size: 10px;
|
|
font-weight: 700;
|
|
letter-spacing: 0.8px;
|
|
text-transform: uppercase;
|
|
color: var(--text3);
|
|
padding: 8px 12px 4px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
flex-shrink: 0;
|
|
border-bottom: 1px solid var(--border);
|
|
}
|
|
.qh-clear {
|
|
font-size: 10px;
|
|
color: var(--text3);
|
|
border: none;
|
|
background: none;
|
|
cursor: pointer;
|
|
}
|
|
.qh-clear:hover {
|
|
color: var(--red);
|
|
}
|
|
.qh-item {
|
|
padding: 8px 12px;
|
|
font-size: 12px;
|
|
color: var(--text2);
|
|
cursor: pointer;
|
|
border: none;
|
|
background: transparent;
|
|
width: 100%;
|
|
text-align: left;
|
|
transition: background 0.1s;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
flex-shrink: 0;
|
|
}
|
|
.qh-item::before {
|
|
content: '↺';
|
|
color: var(--text3);
|
|
font-size: 11px;
|
|
flex-shrink: 0;
|
|
}
|
|
.qh-item:hover,
|
|
.qh-item.selected {
|
|
background: var(--surface);
|
|
color: var(--indigo2);
|
|
}
|
|
|
|
/* ── Custom shortcut card ── */
|
|
.add-shortcut-row {
|
|
display: flex;
|
|
justify-content: center;
|
|
margin-top: 16px;
|
|
}
|
|
.add-shortcut-btn {
|
|
font-size: 12px;
|
|
padding: 7px 16px;
|
|
border-radius: 999px;
|
|
border: 1px dashed var(--border2);
|
|
background: transparent;
|
|
color: var(--text3);
|
|
cursor: pointer;
|
|
transition: all 0.15s;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
}
|
|
.add-shortcut-btn:hover {
|
|
border-color: var(--indigo);
|
|
color: var(--indigo2);
|
|
background: var(--indigo-bg);
|
|
}
|
|
.custom-card-section {
|
|
margin-top: 8px;
|
|
}
|
|
.custom-card-section .quick-cat-label {
|
|
color: var(--yellow);
|
|
}
|
|
.custom-qb {
|
|
position: relative;
|
|
}
|
|
.custom-qb .qb-delete {
|
|
position: absolute;
|
|
top: 4px;
|
|
right: 4px;
|
|
width: 18px;
|
|
height: 18px;
|
|
border-radius: 50%;
|
|
border: none;
|
|
background: rgba(239, 68, 68, 0.15);
|
|
color: var(--red);
|
|
font-size: 10px;
|
|
cursor: pointer;
|
|
display: none;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
.custom-qb:hover .qb-delete {
|
|
display: flex;
|
|
}
|
|
|
|
/* ── Annotation note ── */
|
|
.annotation-wrap {
|
|
margin-top: 6px;
|
|
display: none;
|
|
max-width: 560px;
|
|
}
|
|
.annotation-wrap.open {
|
|
display: block;
|
|
}
|
|
.annotation-textarea {
|
|
width: 100%;
|
|
min-height: 52px;
|
|
background: #1e1a05;
|
|
border: 1px solid rgba(217, 119, 6, 0.25);
|
|
border-radius: 8px;
|
|
color: #fde68a;
|
|
padding: 8px 10px;
|
|
font-size: 12px;
|
|
font-family: inherit;
|
|
resize: vertical;
|
|
outline: none;
|
|
transition: border-color 0.15s;
|
|
}
|
|
.annotation-textarea:focus {
|
|
border-color: #d97706;
|
|
}
|
|
.annotation-textarea::placeholder {
|
|
color: #78350f;
|
|
}
|
|
.annotation-btn {
|
|
position: absolute;
|
|
top: 8px;
|
|
right: 92px;
|
|
width: 26px;
|
|
height: 26px;
|
|
border-radius: 6px;
|
|
border: 1px solid var(--border2);
|
|
background: var(--surface);
|
|
color: var(--text3);
|
|
cursor: pointer;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
font-size: 12px;
|
|
opacity: 0;
|
|
transition:
|
|
opacity 0.15s,
|
|
color 0.15s;
|
|
}
|
|
.message.agent .bubble:hover .annotation-btn {
|
|
opacity: 1;
|
|
}
|
|
.annotation-btn:hover {
|
|
color: #f59e0b;
|
|
border-color: #d97706;
|
|
}
|
|
.annotation-btn.has-note {
|
|
opacity: 1;
|
|
color: #f59e0b;
|
|
border-color: #d97706;
|
|
}
|
|
|
|
/* ── Net worth sparkline ── */
|
|
.nw-sparkline {
|
|
width: 40px;
|
|
height: 18px;
|
|
display: inline-block;
|
|
vertical-align: middle;
|
|
margin-left: 4px;
|
|
cursor: pointer;
|
|
overflow: visible;
|
|
}
|
|
.nw-sparkline polyline {
|
|
fill: none;
|
|
stroke: var(--green);
|
|
stroke-width: 1.5;
|
|
stroke-linecap: round;
|
|
stroke-linejoin: round;
|
|
}
|
|
|
|
/* ── Watchlist alert banner ── */
|
|
.watchlist-banner {
|
|
background: #0c1a2e;
|
|
border-bottom: 1px solid #1e3a5f;
|
|
padding: 7px 16px;
|
|
font-size: 12px;
|
|
color: #93c5fd;
|
|
display: none;
|
|
align-items: center;
|
|
gap: 8px;
|
|
flex-wrap: wrap;
|
|
flex-shrink: 0;
|
|
}
|
|
.watchlist-banner.show {
|
|
display: flex;
|
|
}
|
|
.watchlist-banner strong {
|
|
color: #bfdbfe;
|
|
}
|
|
.wl-tag {
|
|
background: #1e3a5f;
|
|
border: 1px solid #3b82f6;
|
|
border-radius: 6px;
|
|
padding: 2px 8px;
|
|
font-size: 11px;
|
|
font-weight: 600;
|
|
color: #93c5fd;
|
|
cursor: pointer;
|
|
transition: background 0.1s;
|
|
}
|
|
.wl-tag:hover {
|
|
background: #2563eb;
|
|
color: #fff;
|
|
}
|
|
.wl-add-btn {
|
|
font-size: 11px;
|
|
padding: 2px 8px;
|
|
border-radius: 6px;
|
|
border: 1px dashed #3b82f6;
|
|
background: transparent;
|
|
color: #60a5fa;
|
|
cursor: pointer;
|
|
}
|
|
.wl-add-btn:hover {
|
|
background: #1e3a5f;
|
|
}
|
|
.wl-dismiss {
|
|
margin-left: auto;
|
|
background: none;
|
|
border: none;
|
|
color: #60a5fa;
|
|
cursor: pointer;
|
|
font-size: 12px;
|
|
}
|
|
|
|
/* ── Pinned messages strip ── */
|
|
.pinned-strip {
|
|
background: #0d1a2e;
|
|
border-bottom: 1px solid #1e3a5f;
|
|
padding: 8px 16px;
|
|
display: none;
|
|
flex-direction: column;
|
|
gap: 4px;
|
|
flex-shrink: 0;
|
|
}
|
|
.pinned-strip.has-pins {
|
|
display: flex;
|
|
}
|
|
.pinned-strip-header {
|
|
font-size: 10px;
|
|
font-weight: 700;
|
|
letter-spacing: 0.6px;
|
|
text-transform: uppercase;
|
|
color: #60a5fa;
|
|
margin-bottom: 2px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
}
|
|
.pinned-strip-header button {
|
|
font-size: 10px;
|
|
color: #60a5fa;
|
|
border: none;
|
|
background: none;
|
|
cursor: pointer;
|
|
font-weight: 400;
|
|
}
|
|
.pinned-msg-item {
|
|
font-size: 12px;
|
|
color: #93c5fd;
|
|
cursor: pointer;
|
|
padding: 3px 0;
|
|
border-bottom: 1px solid rgba(30, 58, 95, 0.5);
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
line-height: 1.4;
|
|
}
|
|
.pinned-msg-item:last-child {
|
|
border-bottom: none;
|
|
}
|
|
.pinned-msg-item:hover {
|
|
color: #bfdbfe;
|
|
}
|
|
.pinned-msg-text {
|
|
flex: 1;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
.pinned-msg-remove {
|
|
background: none;
|
|
border: none;
|
|
color: #3b82f6;
|
|
cursor: pointer;
|
|
font-size: 11px;
|
|
flex-shrink: 0;
|
|
opacity: 0;
|
|
transition: opacity 0.15s;
|
|
}
|
|
.pinned-msg-item:hover .pinned-msg-remove {
|
|
opacity: 1;
|
|
}
|
|
|
|
/* ── Pin bubble button ── */
|
|
.pin-bubble-btn {
|
|
position: absolute;
|
|
top: 8px;
|
|
right: 120px;
|
|
width: 26px;
|
|
height: 26px;
|
|
border-radius: 6px;
|
|
border: 1px solid var(--border2);
|
|
background: var(--surface);
|
|
color: var(--text3);
|
|
cursor: pointer;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
font-size: 11px;
|
|
opacity: 0;
|
|
transition:
|
|
opacity 0.15s,
|
|
color 0.15s;
|
|
}
|
|
.message.agent .bubble:hover .pin-bubble-btn {
|
|
opacity: 1;
|
|
}
|
|
.pin-bubble-btn:hover {
|
|
color: #60a5fa;
|
|
border-color: #3b82f6;
|
|
}
|
|
.pin-bubble-btn.pinned {
|
|
opacity: 1;
|
|
color: #60a5fa;
|
|
border-color: #3b82f6;
|
|
}
|
|
|
|
/* ── Message reactions ── */
|
|
.reaction-row {
|
|
display: flex;
|
|
gap: 4px;
|
|
margin-top: 6px;
|
|
padding-left: 2px;
|
|
}
|
|
.reaction-btn {
|
|
font-size: 12px;
|
|
padding: 2px 8px;
|
|
border-radius: 999px;
|
|
border: 1px solid var(--border);
|
|
background: transparent;
|
|
cursor: pointer;
|
|
color: var(--text3);
|
|
transition: all 0.15s;
|
|
}
|
|
.reaction-btn:hover {
|
|
border-color: var(--border2);
|
|
color: var(--text2);
|
|
}
|
|
.reaction-btn.up.active {
|
|
border-color: var(--green);
|
|
color: var(--green);
|
|
background: #052e16;
|
|
}
|
|
.reaction-btn.down.active {
|
|
border-color: var(--red);
|
|
color: var(--red);
|
|
background: #1c0505;
|
|
}
|
|
|
|
/* ── Memory indicator in header ── */
|
|
.memory-indicator {
|
|
font-size: 10px;
|
|
padding: 3px 9px;
|
|
border-radius: 999px;
|
|
border: 1px solid rgba(99, 102, 241, 0.35);
|
|
background: rgba(99, 102, 241, 0.08);
|
|
color: var(--indigo2);
|
|
cursor: pointer;
|
|
transition: all 0.15s;
|
|
display: none;
|
|
align-items: center;
|
|
gap: 4px;
|
|
white-space: nowrap;
|
|
}
|
|
.memory-indicator.has-memory {
|
|
display: inline-flex;
|
|
}
|
|
.memory-indicator:hover {
|
|
background: rgba(99, 102, 241, 0.2);
|
|
}
|
|
|
|
/* ── Memory panel ── */
|
|
.memory-panel {
|
|
position: fixed;
|
|
top: 60px;
|
|
right: 20px;
|
|
background: var(--surface2);
|
|
border: 1px solid var(--indigo);
|
|
border-radius: var(--radius);
|
|
padding: 14px 16px;
|
|
min-width: 240px;
|
|
max-width: 320px;
|
|
z-index: 200;
|
|
display: none;
|
|
flex-direction: column;
|
|
gap: 8px;
|
|
box-shadow: 0 8px 28px rgba(99, 102, 241, 0.25);
|
|
animation: popIn 0.15s ease;
|
|
}
|
|
.memory-panel.open {
|
|
display: flex;
|
|
}
|
|
.memory-panel-title {
|
|
font-size: 12px;
|
|
font-weight: 700;
|
|
color: var(--indigo2);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
}
|
|
.memory-section-title {
|
|
font-size: 10px;
|
|
font-weight: 700;
|
|
letter-spacing: 0.6px;
|
|
text-transform: uppercase;
|
|
color: var(--text3);
|
|
margin-bottom: 4px;
|
|
}
|
|
.memory-tag {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
font-size: 11px;
|
|
color: var(--text2);
|
|
background: var(--surface);
|
|
border: 1px solid var(--border2);
|
|
border-radius: 6px;
|
|
padding: 2px 7px;
|
|
margin: 2px;
|
|
cursor: pointer;
|
|
}
|
|
.memory-tag .mem-del {
|
|
color: var(--text3);
|
|
font-size: 10px;
|
|
}
|
|
.memory-tag:hover .mem-del {
|
|
color: var(--red);
|
|
}
|
|
.memory-forget-btn {
|
|
font-size: 11px;
|
|
padding: 4px 10px;
|
|
border-radius: 7px;
|
|
border: 1px solid rgba(239, 68, 68, 0.3);
|
|
background: transparent;
|
|
color: #fca5a5;
|
|
cursor: pointer;
|
|
margin-top: 4px;
|
|
}
|
|
.memory-forget-btn:hover {
|
|
background: #1c0505;
|
|
}
|
|
|
|
/* ── Scroll-to-bottom button ── */
|
|
.scroll-btn {
|
|
position: fixed;
|
|
bottom: 80px;
|
|
left: 50%;
|
|
transform: translateX(-50%);
|
|
background: var(--surface2);
|
|
border: 1px solid var(--border2);
|
|
border-radius: 999px;
|
|
padding: 6px 14px;
|
|
font-size: 12px;
|
|
color: var(--text2);
|
|
cursor: pointer;
|
|
z-index: 200;
|
|
display: none;
|
|
align-items: center;
|
|
gap: 6px;
|
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
|
|
transition: all 0.15s;
|
|
}
|
|
.scroll-btn.visible {
|
|
display: flex;
|
|
}
|
|
.scroll-btn:hover {
|
|
border-color: var(--indigo);
|
|
color: var(--indigo2);
|
|
background: var(--indigo-bg);
|
|
}
|
|
|
|
/* ── High contrast theme ── */
|
|
[data-theme='contrast'] {
|
|
--bg: #000000;
|
|
--surface: #0a0a0a;
|
|
--surface2: #111111;
|
|
--border: #404040;
|
|
--border2: #606060;
|
|
--indigo: #7c7cff;
|
|
--indigo2: #a0a0ff;
|
|
--indigo-bg: #111133;
|
|
--green: #00ff88;
|
|
--yellow: #ffff00;
|
|
--red: #ff4444;
|
|
--text: #ffffff;
|
|
--text2: #dddddd;
|
|
--text3: #aaaaaa;
|
|
}
|
|
|
|
/* ── Focus ring (accessibility) ── */
|
|
:focus-visible {
|
|
outline: 2px solid var(--indigo);
|
|
outline-offset: 2px;
|
|
border-radius: 4px;
|
|
}
|
|
button:focus:not(:focus-visible),
|
|
input:focus:not(:focus-visible) {
|
|
outline: none;
|
|
}
|
|
|
|
/* ── Offline banner ── */
|
|
.offline-banner {
|
|
background: #7c2d12;
|
|
border-bottom: 1px solid #c2410c;
|
|
padding: 6px 16px;
|
|
font-size: 12px;
|
|
color: #fed7aa;
|
|
display: none;
|
|
align-items: center;
|
|
gap: 8px;
|
|
flex-shrink: 0;
|
|
}
|
|
.offline-banner.show {
|
|
display: flex;
|
|
}
|
|
.offline-banner strong {
|
|
color: #fb923c;
|
|
}
|
|
.offline-queued {
|
|
font-size: 10px;
|
|
color: #fdba74;
|
|
margin-left: auto;
|
|
}
|
|
|
|
/* ── Copy-as-markdown button ── */
|
|
.md-copy-btn {
|
|
position: absolute;
|
|
top: 8px;
|
|
right: 148px;
|
|
width: 26px;
|
|
height: 26px;
|
|
border-radius: 6px;
|
|
border: 1px solid var(--border2);
|
|
background: var(--surface);
|
|
color: var(--text3);
|
|
cursor: pointer;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
font-size: 10px;
|
|
font-weight: 700;
|
|
opacity: 0;
|
|
transition:
|
|
opacity 0.15s,
|
|
color 0.15s;
|
|
}
|
|
.message.agent .bubble:hover .md-copy-btn {
|
|
opacity: 1;
|
|
}
|
|
.md-copy-btn:hover {
|
|
color: var(--indigo2);
|
|
border-color: var(--indigo);
|
|
}
|
|
|
|
/* ── Response disclaimer badge ── */
|
|
.disclaimer-badge {
|
|
font-size: 10px;
|
|
color: var(--text3);
|
|
border: 1px solid var(--border);
|
|
border-radius: 6px;
|
|
padding: 2px 8px;
|
|
margin-top: 6px;
|
|
display: none;
|
|
align-items: center;
|
|
gap: 4px;
|
|
}
|
|
.disclaimer-badge.show {
|
|
display: inline-flex;
|
|
}
|
|
.gf-disclaimer-on .disclaimer-badge {
|
|
display: inline-flex;
|
|
}
|
|
|
|
/* ── Command palette ── */
|
|
.cmd-overlay {
|
|
position: fixed;
|
|
inset: 0;
|
|
background: rgba(0, 0, 0, 0.7);
|
|
z-index: 1000;
|
|
display: none;
|
|
align-items: flex-start;
|
|
justify-content: center;
|
|
padding-top: 80px;
|
|
}
|
|
.cmd-overlay.open {
|
|
display: flex;
|
|
}
|
|
.cmd-box {
|
|
background: var(--surface2);
|
|
border: 1px solid var(--border2);
|
|
border-radius: 14px;
|
|
width: 100%;
|
|
max-width: 560px;
|
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.7);
|
|
overflow: hidden;
|
|
animation: popIn 0.15s ease;
|
|
}
|
|
.cmd-input-wrap {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
padding: 14px 16px;
|
|
border-bottom: 1px solid var(--border);
|
|
}
|
|
.cmd-input-icon {
|
|
font-size: 16px;
|
|
color: var(--text3);
|
|
flex-shrink: 0;
|
|
}
|
|
.cmd-input {
|
|
flex: 1;
|
|
background: transparent;
|
|
border: none;
|
|
outline: none;
|
|
color: var(--text);
|
|
font-size: 14px;
|
|
font-family: inherit;
|
|
}
|
|
.cmd-input::placeholder {
|
|
color: var(--text3);
|
|
}
|
|
.cmd-results {
|
|
max-height: 360px;
|
|
overflow-y: auto;
|
|
}
|
|
.cmd-section-label {
|
|
font-size: 10px;
|
|
font-weight: 700;
|
|
letter-spacing: 0.8px;
|
|
text-transform: uppercase;
|
|
color: var(--text3);
|
|
padding: 8px 16px 4px;
|
|
flex-shrink: 0;
|
|
}
|
|
.cmd-item {
|
|
padding: 10px 16px;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
cursor: pointer;
|
|
transition: background 0.1s;
|
|
border: none;
|
|
background: transparent;
|
|
width: 100%;
|
|
text-align: left;
|
|
}
|
|
.cmd-item.selected,
|
|
.cmd-item:hover {
|
|
background: var(--indigo-bg);
|
|
}
|
|
.cmd-item-icon {
|
|
font-size: 16px;
|
|
flex-shrink: 0;
|
|
}
|
|
.cmd-item-text {
|
|
flex: 1;
|
|
}
|
|
.cmd-item-label {
|
|
font-size: 13px;
|
|
color: var(--text);
|
|
}
|
|
.cmd-item-sub {
|
|
font-size: 11px;
|
|
color: var(--text3);
|
|
margin-top: 1px;
|
|
}
|
|
.cmd-item-kbd {
|
|
font-size: 10px;
|
|
color: var(--text3);
|
|
flex-shrink: 0;
|
|
}
|
|
.cmd-footer {
|
|
padding: 8px 16px;
|
|
border-top: 1px solid var(--border);
|
|
display: flex;
|
|
gap: 14px;
|
|
font-size: 10px;
|
|
color: var(--text3);
|
|
}
|
|
.cmd-footer span {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
}
|
|
|
|
/* ── Cross-session search results ── */
|
|
.xsearch-results {
|
|
padding: 6px 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 2px;
|
|
}
|
|
.xsearch-item {
|
|
padding: 8px 12px;
|
|
border-radius: 8px;
|
|
cursor: pointer;
|
|
transition: background 0.1s;
|
|
border: none;
|
|
background: transparent;
|
|
text-align: left;
|
|
width: 100%;
|
|
}
|
|
.xsearch-item:hover {
|
|
background: var(--surface2);
|
|
}
|
|
.xsearch-session {
|
|
font-size: 11px;
|
|
font-weight: 700;
|
|
color: var(--indigo2);
|
|
}
|
|
.xsearch-snippet {
|
|
font-size: 12px;
|
|
color: var(--text2);
|
|
margin-top: 2px;
|
|
line-height: 1.4;
|
|
}
|
|
.xsearch-snippet mark {
|
|
background: rgba(99, 102, 241, 0.3);
|
|
color: var(--indigo2);
|
|
border-radius: 2px;
|
|
}
|
|
|
|
/* ── User profile modal ── */
|
|
.profile-step {
|
|
display: none;
|
|
flex-direction: column;
|
|
gap: 12px;
|
|
}
|
|
.profile-step.active {
|
|
display: flex;
|
|
}
|
|
.profile-option {
|
|
padding: 10px 14px;
|
|
border-radius: 10px;
|
|
border: 1px solid var(--border2);
|
|
background: var(--surface);
|
|
color: var(--text2);
|
|
cursor: pointer;
|
|
text-align: left;
|
|
transition: all 0.15s;
|
|
font-size: 13px;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
}
|
|
.profile-option:hover,
|
|
.profile-option.selected {
|
|
border-color: var(--indigo);
|
|
background: var(--indigo-bg);
|
|
color: var(--indigo2);
|
|
}
|
|
.profile-option-icon {
|
|
font-size: 18px;
|
|
}
|
|
.profile-progress {
|
|
display: flex;
|
|
gap: 4px;
|
|
margin-bottom: 8px;
|
|
}
|
|
.profile-dot {
|
|
flex: 1;
|
|
height: 3px;
|
|
border-radius: 2px;
|
|
background: var(--border2);
|
|
}
|
|
.profile-dot.done {
|
|
background: var(--indigo);
|
|
}
|
|
|
|
/* ── Rental yield calculator ── */
|
|
.calc-row {
|
|
display: flex;
|
|
gap: 10px;
|
|
align-items: center;
|
|
}
|
|
.calc-input {
|
|
flex: 1;
|
|
background: var(--surface);
|
|
border: 1px solid var(--border2);
|
|
border-radius: 8px;
|
|
padding: 8px 10px;
|
|
color: var(--text);
|
|
font-size: 13px;
|
|
outline: none;
|
|
font-family: inherit;
|
|
}
|
|
.calc-input:focus {
|
|
border-color: var(--indigo);
|
|
}
|
|
.calc-label {
|
|
font-size: 12px;
|
|
color: var(--text2);
|
|
min-width: 80px;
|
|
}
|
|
.calc-result {
|
|
background: var(--surface);
|
|
border: 1px solid var(--border2);
|
|
border-radius: 10px;
|
|
padding: 12px 16px;
|
|
margin-top: 8px;
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr;
|
|
gap: 10px;
|
|
}
|
|
.calc-stat {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 2px;
|
|
}
|
|
.calc-stat-val {
|
|
font-size: 18px;
|
|
font-weight: 700;
|
|
color: var(--green);
|
|
}
|
|
.calc-stat-lbl {
|
|
font-size: 10px;
|
|
color: var(--text3);
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.5px;
|
|
}
|
|
|
|
/* ── Property comparison table ── */
|
|
.prop-compare-table {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
font-size: 12px;
|
|
margin-top: 8px;
|
|
}
|
|
.prop-compare-table th {
|
|
background: var(--surface);
|
|
color: var(--text2);
|
|
padding: 8px 10px;
|
|
font-weight: 700;
|
|
font-size: 11px;
|
|
text-align: left;
|
|
border-bottom: 1px solid var(--border2);
|
|
}
|
|
.prop-compare-table td {
|
|
padding: 7px 10px;
|
|
border-bottom: 1px solid var(--border);
|
|
color: var(--text2);
|
|
}
|
|
.prop-compare-table tr:last-child td {
|
|
border-bottom: none;
|
|
}
|
|
.prop-compare-table .highlight {
|
|
color: var(--green);
|
|
font-weight: 700;
|
|
}
|
|
|
|
/* ── Portfolio donut chart ── */
|
|
.donut-wrap {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 20px;
|
|
flex-wrap: wrap;
|
|
padding: 4px 0;
|
|
}
|
|
.donut-svg {
|
|
flex-shrink: 0;
|
|
}
|
|
.donut-legend {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 6px;
|
|
flex: 1;
|
|
}
|
|
.donut-legend-item {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
font-size: 11px;
|
|
color: var(--text2);
|
|
cursor: pointer;
|
|
}
|
|
.donut-legend-dot {
|
|
width: 10px;
|
|
height: 10px;
|
|
border-radius: 50%;
|
|
flex-shrink: 0;
|
|
}
|
|
.donut-legend-pct {
|
|
margin-left: auto;
|
|
font-weight: 700;
|
|
color: var(--text);
|
|
}
|
|
|
|
/* ── Market calendar strip ── */
|
|
.market-calendar {
|
|
background: var(--surface);
|
|
border-bottom: 1px solid var(--border);
|
|
padding: 5px 16px;
|
|
display: none;
|
|
align-items: center;
|
|
flex-wrap: wrap;
|
|
gap: 6px 12px;
|
|
flex-shrink: 0;
|
|
overflow-x: hidden;
|
|
}
|
|
.market-calendar.show {
|
|
display: flex;
|
|
}
|
|
.cal-label {
|
|
font-size: 10px;
|
|
font-weight: 700;
|
|
letter-spacing: 0.6px;
|
|
text-transform: uppercase;
|
|
color: var(--text3);
|
|
flex-shrink: 0;
|
|
}
|
|
.cal-event {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 5px;
|
|
font-size: 11px;
|
|
color: var(--text2);
|
|
background: var(--surface2);
|
|
border: 1px solid var(--border);
|
|
border-radius: 6px;
|
|
padding: 2px 8px;
|
|
white-space: nowrap;
|
|
flex-shrink: 0;
|
|
}
|
|
.cal-event .cal-date {
|
|
color: var(--text3);
|
|
font-size: 10px;
|
|
}
|
|
.cal-event.urgent {
|
|
border-color: var(--red);
|
|
color: #fca5a5;
|
|
}
|
|
.cal-event.near {
|
|
border-color: var(--yellow);
|
|
color: #fde68a;
|
|
}
|
|
|
|
/* ── Smart suggestion banner ── */
|
|
.smart-banner {
|
|
background: var(--indigo-bg);
|
|
border-bottom: 1px solid rgba(99, 102, 241, 0.3);
|
|
padding: 6px 16px;
|
|
font-size: 12px;
|
|
color: var(--indigo2);
|
|
display: none;
|
|
align-items: center;
|
|
gap: 8px;
|
|
flex-shrink: 0;
|
|
}
|
|
.smart-banner.show {
|
|
display: flex;
|
|
}
|
|
.smart-banner-action {
|
|
background: var(--indigo);
|
|
color: #fff;
|
|
border: none;
|
|
border-radius: 6px;
|
|
padding: 3px 10px;
|
|
font-size: 11px;
|
|
cursor: pointer;
|
|
margin-left: 4px;
|
|
}
|
|
.smart-banner-dismiss {
|
|
margin-left: auto;
|
|
background: none;
|
|
border: none;
|
|
color: var(--indigo2);
|
|
cursor: pointer;
|
|
font-size: 12px;
|
|
}
|
|
|
|
/* ── PWA install prompt ── */
|
|
.pwa-banner {
|
|
background: var(--surface2);
|
|
border-bottom: 1px solid var(--border);
|
|
padding: 6px 16px;
|
|
font-size: 12px;
|
|
color: var(--text2);
|
|
display: none;
|
|
align-items: center;
|
|
gap: 10px;
|
|
flex-shrink: 0;
|
|
}
|
|
.pwa-banner.show {
|
|
display: flex;
|
|
}
|
|
.pwa-install-btn {
|
|
background: linear-gradient(135deg, var(--indigo), #8b5cf6);
|
|
color: #fff;
|
|
border: none;
|
|
border-radius: 6px;
|
|
padding: 4px 12px;
|
|
font-size: 11px;
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
}
|
|
.pwa-dismiss {
|
|
margin-left: auto;
|
|
background: none;
|
|
border: none;
|
|
color: var(--text3);
|
|
cursor: pointer;
|
|
}
|
|
|
|
/* ── Reduced motion ── */
|
|
@media (prefers-reduced-motion: reduce) {
|
|
*,
|
|
*::before,
|
|
*::after {
|
|
animation-duration: 0.01ms !important;
|
|
animation-iteration-count: 1 !important;
|
|
transition-duration: 0.01ms !important;
|
|
}
|
|
}
|
|
body.reduced-motion *,
|
|
body.reduced-motion *::before,
|
|
body.reduced-motion *::after {
|
|
animation-duration: 0.01ms !important;
|
|
animation-iteration-count: 1 !important;
|
|
transition-duration: 0.01ms !important;
|
|
}
|
|
|
|
/* ── Branching indicator ── */
|
|
.branch-btn {
|
|
font-size: 11px;
|
|
padding: 3px 8px;
|
|
border-radius: 6px;
|
|
border: 1px solid var(--border2);
|
|
background: transparent;
|
|
color: var(--text3);
|
|
cursor: pointer;
|
|
transition: all 0.15s;
|
|
display: none;
|
|
}
|
|
.message.user:hover .branch-btn {
|
|
display: inline-block;
|
|
}
|
|
.branch-btn:hover {
|
|
border-color: var(--indigo);
|
|
color: var(--indigo2);
|
|
}
|
|
|
|
::-webkit-scrollbar {
|
|
width: 5px;
|
|
}
|
|
::-webkit-scrollbar-track {
|
|
background: transparent;
|
|
}
|
|
::-webkit-scrollbar-thumb {
|
|
background: var(--border2);
|
|
border-radius: 3px;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<!-- ── Header ── -->
|
|
<header>
|
|
<div class="logo">📈</div>
|
|
<div class="session-title-wrap" id="header-title-wrap">
|
|
<h1>Ghostfolio AI Agent</h1>
|
|
<span
|
|
class="session-subtitle"
|
|
id="header-subtitle"
|
|
title="Click to rename this chat"
|
|
>
|
|
<span id="header-subtitle-text">Powered by Claude + LangGraph</span>
|
|
<span class="edit-pencil">✏</span>
|
|
</span>
|
|
</div>
|
|
<button class="chats-btn" id="chats-btn" title="Chat history">
|
|
☰ Chats
|
|
<span
|
|
id="chats-count-badge"
|
|
style="
|
|
display: none;
|
|
background: var(--indigo);
|
|
color: #fff;
|
|
border-radius: 999px;
|
|
padding: 1px 6px;
|
|
font-size: 10px;
|
|
margin-left: 2px;
|
|
"
|
|
>0</span
|
|
>
|
|
</button>
|
|
<button class="share-btn" id="share-btn" title="Share conversation">
|
|
↑ Share
|
|
</button>
|
|
<div class="header-right">
|
|
<div class="networth-badge" id="networth-badge">
|
|
💰 Properties tracked
|
|
</div>
|
|
<div
|
|
class="memory-indicator"
|
|
id="memory-indicator"
|
|
title="View remembered context"
|
|
>
|
|
🧠 <span id="memory-label">0 items</span>
|
|
</div>
|
|
<div class="status-pill">
|
|
<div class="dot" id="dot"></div>
|
|
<span id="status-label">Connecting…</span>
|
|
</div>
|
|
<span class="latency-chip hidden" id="latency-chip">—</span>
|
|
<div class="user-badge">
|
|
<div class="user-avatar" id="user-avatar">??</div>
|
|
<span class="user-name" id="user-name">Loading…</span>
|
|
</div>
|
|
<div class="scenario-badge" id="scenario-badge">🔮 What-if mode</div>
|
|
<div class="goal-chip" id="goal-chip" title="Financial goal progress">
|
|
🎯 <span id="goal-label">Goal</span>
|
|
<div class="goal-bar-track">
|
|
<div class="goal-bar-fill" id="goal-bar" style="width: 0%"></div>
|
|
</div>
|
|
<span id="goal-pct">0%</span>
|
|
</div>
|
|
<div class="settings-wrap">
|
|
<button class="settings-btn" id="settings-btn" title="Settings">
|
|
⚙ Settings
|
|
</button>
|
|
<div class="settings-menu" id="settings-menu">
|
|
<button class="settings-item" id="theme-toggle-btn">
|
|
<span class="settings-item-left">🌙 Dark / Light mode</span>
|
|
<div class="toggle-pill" id="theme-pill"></div>
|
|
</button>
|
|
<button class="settings-item" id="speak-toggle-settings">
|
|
<span class="settings-item-left">🔊 Auto-speak responses</span>
|
|
<div class="toggle-pill" id="speak-pill-settings"></div>
|
|
</button>
|
|
<div class="settings-item" style="cursor: default">
|
|
<span class="settings-item-left">🔡 Font size</span>
|
|
<div style="display: flex; gap: 4px">
|
|
<button
|
|
id="fs-small"
|
|
onclick="
|
|
setFontSize('small');
|
|
event.stopPropagation();
|
|
"
|
|
style="
|
|
width: 26px;
|
|
height: 26px;
|
|
border-radius: 6px;
|
|
border: 1px solid var(--border2);
|
|
background: var(--surface);
|
|
color: var(--text2);
|
|
cursor: pointer;
|
|
font-size: 10px;
|
|
font-weight: 700;
|
|
"
|
|
title="Small"
|
|
>
|
|
A
|
|
</button>
|
|
<button
|
|
id="fs-medium"
|
|
onclick="
|
|
setFontSize('medium');
|
|
event.stopPropagation();
|
|
"
|
|
style="
|
|
width: 26px;
|
|
height: 26px;
|
|
border-radius: 6px;
|
|
border: 1px solid var(--border2);
|
|
background: var(--surface);
|
|
color: var(--text2);
|
|
cursor: pointer;
|
|
font-size: 12px;
|
|
font-weight: 700;
|
|
"
|
|
title="Medium"
|
|
>
|
|
A
|
|
</button>
|
|
<button
|
|
id="fs-large"
|
|
onclick="
|
|
setFontSize('large');
|
|
event.stopPropagation();
|
|
"
|
|
style="
|
|
width: 26px;
|
|
height: 26px;
|
|
border-radius: 6px;
|
|
border: 1px solid var(--border2);
|
|
background: var(--surface);
|
|
color: var(--text2);
|
|
cursor: pointer;
|
|
font-size: 14px;
|
|
font-weight: 700;
|
|
"
|
|
title="Large"
|
|
>
|
|
A
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<button class="settings-item" id="scenario-toggle-btn">
|
|
<span class="settings-item-left">🔮 What-if scenario mode</span>
|
|
<div class="toggle-pill" id="scenario-pill"></div>
|
|
</button>
|
|
<div class="settings-divider"></div>
|
|
<button class="settings-item" id="set-goal-btn">
|
|
<span class="settings-item-left">🎯 Set financial goal</span>
|
|
<span style="font-size: 10px; color: var(--text3)">›</span>
|
|
</button>
|
|
<button class="settings-item" id="export-card-btn">
|
|
<span class="settings-item-left">🖼 Export as image card</span>
|
|
<span style="font-size: 10px; color: var(--text3)">PNG</span>
|
|
</button>
|
|
<button class="settings-item" id="heatmap-btn">
|
|
<span class="settings-item-left">🟩 Portfolio heat map</span>
|
|
<span style="font-size: 10px; color: var(--text3)">Visual</span>
|
|
</button>
|
|
<button class="settings-item" id="inspector-btn">
|
|
<span class="settings-item-left">🔬 Request inspector</span>
|
|
<div class="toggle-pill" id="inspector-pill"></div>
|
|
</button>
|
|
<button class="settings-item" id="tldr-btn">
|
|
<span class="settings-item-left">📋 Summarize this chat</span>
|
|
<span style="font-size: 10px; color: var(--text3)">TL;DR</span>
|
|
</button>
|
|
<button class="settings-item" id="print-btn">
|
|
<span class="settings-item-left">🖨 Export as PDF</span>
|
|
<span style="font-size: 10px; color: var(--text3)">Print</span>
|
|
</button>
|
|
<div class="settings-divider"></div>
|
|
<button class="settings-item" id="contrast-toggle-btn">
|
|
<span class="settings-item-left">⬛ High contrast mode</span>
|
|
<div class="toggle-pill" id="contrast-pill"></div>
|
|
</button>
|
|
<button class="settings-item" id="motion-toggle-btn">
|
|
<span class="settings-item-left">🎞 Reduced motion</span>
|
|
<div class="toggle-pill" id="motion-pill"></div>
|
|
</button>
|
|
<button class="settings-item" id="disclaimer-toggle-btn">
|
|
<span class="settings-item-left"
|
|
>⚠ Show disclaimer on responses</span
|
|
>
|
|
<div class="toggle-pill" id="disclaimer-pill"></div>
|
|
</button>
|
|
<button class="settings-item" id="calendar-toggle-btn">
|
|
<span class="settings-item-left">📅 Market calendar</span>
|
|
<div class="toggle-pill" id="calendar-pill"></div>
|
|
</button>
|
|
<div class="settings-divider"></div>
|
|
<button class="settings-item" id="profile-btn">
|
|
<span class="settings-item-left">👤 Edit my profile</span>
|
|
<span style="font-size: 10px; color: var(--text3)">›</span>
|
|
</button>
|
|
<button class="settings-item" id="reminder-btn">
|
|
<span class="settings-item-left">🔔 Set portfolio reminder</span>
|
|
<span style="font-size: 10px; color: var(--text3)">›</span>
|
|
</button>
|
|
<button class="settings-item" id="email-digest-btn">
|
|
<span class="settings-item-left">📧 Email digest</span>
|
|
<span style="font-size: 10px; color: var(--text3)">Copy</span>
|
|
</button>
|
|
<button class="settings-item" id="batch-export-btn">
|
|
<span class="settings-item-left">☑ Batch export</span>
|
|
<span style="font-size: 10px; color: var(--text3)">Select</span>
|
|
</button>
|
|
<div class="settings-divider"></div>
|
|
<button class="settings-item" id="shortcuts-btn">
|
|
<span class="settings-item-left">⌨ Keyboard shortcuts</span>
|
|
<span style="font-size: 10px; color: var(--text3)">⌘?</span>
|
|
</button>
|
|
<div class="settings-scroll-hint">
|
|
scroll for more · ⌘P for all features
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<button class="clear-btn" id="clear-btn">Clear session</button>
|
|
<button
|
|
class="clear-btn"
|
|
id="logout-btn"
|
|
style="border-color: #2a3550; color: #64748b"
|
|
title="Sign out"
|
|
>
|
|
Sign out
|
|
</button>
|
|
</div>
|
|
</header>
|
|
|
|
<!-- ── Session history drawer ── -->
|
|
<div class="drawer-overlay" id="drawer-overlay"></div>
|
|
<div class="sessions-drawer" id="sessions-drawer">
|
|
<div class="drawer-header">
|
|
<h3>💬 Chats</h3>
|
|
<button class="drawer-close" id="drawer-close">✕</button>
|
|
</div>
|
|
<div class="drawer-tabs">
|
|
<button
|
|
class="drawer-tab active"
|
|
id="tab-chats"
|
|
onclick="switchDrawerTab('chats')"
|
|
>
|
|
Chats
|
|
</button>
|
|
<button
|
|
class="drawer-tab"
|
|
id="tab-saved"
|
|
onclick="switchDrawerTab('saved')"
|
|
>
|
|
⭐ Saved
|
|
</button>
|
|
<button
|
|
class="drawer-tab"
|
|
id="tab-archived"
|
|
onclick="switchDrawerTab('archived')"
|
|
>
|
|
🗂
|
|
</button>
|
|
</div>
|
|
<div style="padding: 8px 12px 0">
|
|
<input
|
|
autocomplete="off"
|
|
id="xsearch-input"
|
|
placeholder="🔍 Search all chats…"
|
|
style="
|
|
width: 100%;
|
|
background: var(--surface);
|
|
border: 1px solid var(--border2);
|
|
border-radius: 8px;
|
|
padding: 7px 10px;
|
|
color: var(--text);
|
|
font-size: 12px;
|
|
outline: none;
|
|
font-family: inherit;
|
|
"
|
|
/>
|
|
</div>
|
|
<div
|
|
class="xsearch-results"
|
|
id="xsearch-results"
|
|
style="display: none; padding: 4px 8px"
|
|
></div>
|
|
<button
|
|
class="drawer-new-btn"
|
|
id="drawer-new-btn"
|
|
style="margin-top: 8px"
|
|
>
|
|
+ New Chat
|
|
</button>
|
|
<div class="session-list" id="session-list"></div>
|
|
<div class="saved-list" id="saved-list" style="display: none"></div>
|
|
<div class="session-list" id="archived-list" style="display: none"></div>
|
|
</div>
|
|
|
|
<!-- ── Pinned context strip ── -->
|
|
<div class="context-strip" id="context-strip">
|
|
<span class="context-tag" id="ctx-portfolio">📊 Portfolio</span>
|
|
<span class="context-tag" id="ctx-realestate">🏠 Real estate</span>
|
|
<span class="context-tag" id="ctx-property">🏘 Property tracked</span>
|
|
<span class="context-tag" id="ctx-market">💹 Market data</span>
|
|
<span class="context-tag" id="ctx-compliance">🛡 Compliance</span>
|
|
<span class="context-tag" id="ctx-tax">🧾 Tax</span>
|
|
</div>
|
|
|
|
<!-- ── Conversation search ── -->
|
|
<div class="search-bar" id="search-bar">
|
|
<input
|
|
autocomplete="off"
|
|
class="search-input"
|
|
id="search-input"
|
|
placeholder="Search conversation…"
|
|
/>
|
|
<span class="search-count" id="search-count"></span>
|
|
<div class="search-nav">
|
|
<button id="search-prev" title="Previous">▲</button>
|
|
<button id="search-next" title="Next">▼</button>
|
|
</div>
|
|
<button class="search-close" id="search-close">✕</button>
|
|
</div>
|
|
|
|
<!-- ── Offline banner ── -->
|
|
<div class="offline-banner" id="offline-banner">
|
|
<strong>⚠ No connection</strong> — Your message will be sent when you
|
|
reconnect.
|
|
<span class="offline-queued" id="offline-queued"></span>
|
|
</div>
|
|
|
|
<!-- ── PWA install banner ── -->
|
|
<div class="pwa-banner" id="pwa-banner">
|
|
📱 Install Ghostfolio AI as an app for faster access
|
|
<button class="pwa-install-btn" id="pwa-install-btn">Install</button>
|
|
<button
|
|
class="pwa-dismiss"
|
|
onclick="
|
|
document.getElementById('pwa-banner').classList.remove('show');
|
|
localStorage.setItem('gf_pwa_dismissed', '1');
|
|
"
|
|
>
|
|
✕
|
|
</button>
|
|
</div>
|
|
|
|
<!-- ── Smart suggestion banner ── -->
|
|
<div class="smart-banner" id="smart-banner">
|
|
<span id="smart-banner-text"></span>
|
|
<button class="smart-banner-action" id="smart-banner-action">
|
|
Ask now
|
|
</button>
|
|
<button
|
|
class="smart-banner-dismiss"
|
|
onclick="
|
|
document.getElementById('smart-banner').classList.remove('show');
|
|
localStorage.setItem(
|
|
'gf_smart_dismissed_' + new Date().toDateString(),
|
|
'1'
|
|
);
|
|
"
|
|
>
|
|
✕
|
|
</button>
|
|
</div>
|
|
|
|
<!-- ── Market calendar strip ── -->
|
|
<div class="market-calendar" id="market-calendar">
|
|
<span class="cal-label">📅 Upcoming</span>
|
|
<div
|
|
id="cal-events"
|
|
style="display: flex; gap: 8px; flex-wrap: wrap"
|
|
></div>
|
|
<button
|
|
onclick="
|
|
document.getElementById('market-calendar').classList.remove('show');
|
|
localStorage.setItem('gf_cal_hidden', '1');
|
|
"
|
|
style="
|
|
margin-left: auto;
|
|
background: none;
|
|
border: none;
|
|
color: var(--text3);
|
|
cursor: pointer;
|
|
font-size: 11px;
|
|
"
|
|
>
|
|
Hide
|
|
</button>
|
|
</div>
|
|
|
|
<!-- ── Watchlist alert banner ── -->
|
|
<div class="watchlist-banner" id="watchlist-banner">
|
|
<strong>👁 Watchlist:</strong>
|
|
<div
|
|
id="watchlist-tags"
|
|
style="display: flex; gap: 6px; flex-wrap: wrap"
|
|
></div>
|
|
<button class="wl-add-btn" onclick="addToWatchlistPrompt()">
|
|
+ Add ticker
|
|
</button>
|
|
<button
|
|
class="wl-dismiss"
|
|
onclick="
|
|
document.getElementById('watchlist-banner').classList.remove('show')
|
|
"
|
|
title="Hide"
|
|
>
|
|
✕
|
|
</button>
|
|
</div>
|
|
|
|
<!-- ── Proactive greeting banner ── -->
|
|
<div class="greeting-banner" id="greeting-banner">
|
|
<div class="greeting-banner-text">
|
|
<strong id="greeting-text">Welcome back!</strong><br />
|
|
<span id="greeting-sub"
|
|
>Your portfolio hasn't been reviewed in a while.</span
|
|
>
|
|
</div>
|
|
<button class="greeting-action" id="greeting-action">
|
|
Quick summary
|
|
</button>
|
|
<button class="greeting-dismiss" id="greeting-dismiss" title="Dismiss">
|
|
✕
|
|
</button>
|
|
</div>
|
|
|
|
<!-- ── Session toast ── -->
|
|
<div class="session-toast" id="session-toast"></div>
|
|
|
|
<!-- ── Pinned messages strip ── -->
|
|
<div class="pinned-strip" id="pinned-strip">
|
|
<div class="pinned-strip-header">
|
|
📌 Pinned
|
|
<button onclick="clearAllPinnedMsgs()">Clear all</button>
|
|
</div>
|
|
<div id="pinned-strip-items"></div>
|
|
</div>
|
|
|
|
<!-- ── Chat area ── -->
|
|
<div class="chat-area" id="chat">
|
|
<!-- Empty state -->
|
|
<div class="empty-state" id="empty">
|
|
<div class="empty-icon">💼</div>
|
|
<h2>What would you like to know?</h2>
|
|
<p>
|
|
Ask about your portfolio, explore Austin real estate data, track
|
|
properties, or run a compliance check.
|
|
</p>
|
|
|
|
<div class="quick-grid">
|
|
<div class="quick-category">
|
|
<span class="quick-cat-label">📊 Portfolio</span>
|
|
<div class="quick-row">
|
|
<button
|
|
class="quick-btn"
|
|
onclick="sendQuick('What is my YTD return?')"
|
|
>
|
|
<span class="qb-icon">📈</span>
|
|
<span class="qb-title">YTD Return</span>
|
|
<span class="qb-sub">Year-to-date performance</span>
|
|
</button>
|
|
<button
|
|
class="quick-btn"
|
|
onclick="sendQuick('Give me a full portfolio summary')"
|
|
>
|
|
<span class="qb-icon">📋</span>
|
|
<span class="qb-title">Portfolio Summary</span>
|
|
<span class="qb-sub">Allocation & value overview</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="quick-category">
|
|
<span class="quick-cat-label">🛡️ Risk & Compliance</span>
|
|
<div class="quick-row">
|
|
<button
|
|
class="quick-btn"
|
|
onclick="sendQuick('Am I over-concentrated in any stock?')"
|
|
>
|
|
<span class="qb-icon">⚖️</span>
|
|
<span class="qb-title">Concentration Check</span>
|
|
<span class="qb-sub">Detect overweight positions</span>
|
|
</button>
|
|
<button
|
|
class="quick-btn"
|
|
onclick="sendQuick('Estimate my tax liability')"
|
|
>
|
|
<span class="qb-icon">🧾</span>
|
|
<span class="qb-title">Tax Estimate</span>
|
|
<span class="qb-sub">Capital gains & liability</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="quick-category">
|
|
<span class="quick-cat-label">💹 Market</span>
|
|
<div class="quick-row">
|
|
<button
|
|
class="quick-btn"
|
|
onclick="sendQuick('What is AAPL trading at today?')"
|
|
>
|
|
<span class="qb-icon">🍎</span>
|
|
<span class="qb-title">Live Quote</span>
|
|
<span class="qb-sub">AAPL real-time price</span>
|
|
</button>
|
|
<button
|
|
class="quick-btn"
|
|
onclick="sendQuick('Show me my recent transactions')"
|
|
>
|
|
<span class="qb-icon">🔄</span>
|
|
<span class="qb-title">Recent Trades</span>
|
|
<span class="qb-sub">Activity history</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="quick-category">
|
|
<span class="quick-cat-label">🏠 Real Estate & Property</span>
|
|
<div class="quick-row">
|
|
<button
|
|
class="quick-btn"
|
|
onclick="
|
|
sendQuick('What is the Austin housing market like right now?')
|
|
"
|
|
>
|
|
<span class="qb-icon">🏡</span>
|
|
<span class="qb-title">Austin Market</span>
|
|
<span class="qb-sub">Jan 2026 ACTRIS MLS data</span>
|
|
</button>
|
|
<button
|
|
class="quick-btn"
|
|
onclick="sendQuick('Compare Round Rock vs Hays County')"
|
|
>
|
|
<span class="qb-icon">🔀</span>
|
|
<span class="qb-title">Compare Counties</span>
|
|
<span class="qb-sub">Side-by-side analysis</span>
|
|
</button>
|
|
</div>
|
|
<div class="quick-row">
|
|
<button
|
|
class="quick-btn"
|
|
onclick="sendQuick('Show my properties')"
|
|
>
|
|
<span class="qb-icon">🏘</span>
|
|
<span class="qb-title">My Properties</span>
|
|
<span class="qb-sub">Equity & portfolio view</span>
|
|
</button>
|
|
<button
|
|
class="quick-btn"
|
|
onclick="
|
|
sendQuick('What is my total net worth including real estate?')
|
|
"
|
|
>
|
|
<span class="qb-icon">💰</span>
|
|
<span class="qb-title">Total Net Worth</span>
|
|
<span class="qb-sub">Portfolio + real estate</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Custom shortcut cards (user-defined) -->
|
|
<div
|
|
class="quick-category custom-card-section"
|
|
id="custom-cards-section"
|
|
style="display: none"
|
|
>
|
|
<span class="quick-cat-label">⭐ My Shortcuts</span>
|
|
<div class="quick-row" id="custom-cards-row"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="add-shortcut-row">
|
|
<button class="add-shortcut-btn" onclick="openAddCardModal()">
|
|
+ Add custom shortcut
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ── Input area ── -->
|
|
<div class="input-wrap">
|
|
<div class="length-bar">
|
|
<span class="length-label">Response:</span>
|
|
<div class="length-pills">
|
|
<button
|
|
class="length-pill"
|
|
id="len-brief"
|
|
onclick="setLength('brief')"
|
|
>
|
|
Brief
|
|
</button>
|
|
<button
|
|
class="length-pill active"
|
|
id="len-normal"
|
|
onclick="setLength('normal')"
|
|
>
|
|
Normal
|
|
</button>
|
|
<button
|
|
class="length-pill"
|
|
id="len-detailed"
|
|
onclick="setLength('detailed')"
|
|
>
|
|
Detailed
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div class="attach-preview" id="attach-preview">
|
|
<span id="attach-preview-name">📎 No file</span>
|
|
<button
|
|
class="attach-preview-remove"
|
|
id="attach-remove"
|
|
title="Remove file"
|
|
>
|
|
✕
|
|
</button>
|
|
</div>
|
|
<div class="query-history" id="query-history"></div>
|
|
<div class="input-row">
|
|
<div class="attach-wrap">
|
|
<button class="attach-btn" id="attach-btn" title="Attach file">
|
|
+
|
|
</button>
|
|
<div class="attach-menu" id="attach-menu">
|
|
<button class="attach-item" id="attach-file-btn">
|
|
<span class="attach-item-icon">📄</span>
|
|
<div>
|
|
<div style="font-weight: 600">Upload document</div>
|
|
<div style="font-size: 10px; color: var(--text3)">
|
|
.txt, .csv, .json
|
|
</div>
|
|
</div>
|
|
</button>
|
|
<button class="attach-item" id="attach-photo-btn">
|
|
<span class="attach-item-icon">📷</span>
|
|
<div>
|
|
<div style="font-weight: 600">Take photo / upload image</div>
|
|
<div style="font-size: 10px; color: var(--text3)">
|
|
Mobile camera or image file
|
|
</div>
|
|
</div>
|
|
</button>
|
|
</div>
|
|
<input
|
|
accept=".txt,.csv,.json"
|
|
id="attach-file-input"
|
|
style="display: none"
|
|
type="file"
|
|
/>
|
|
<input
|
|
accept="image/*"
|
|
capture="environment"
|
|
id="attach-photo-input"
|
|
style="display: none"
|
|
type="file"
|
|
/>
|
|
</div>
|
|
<textarea
|
|
id="input"
|
|
placeholder="Ask anything about your portfolio… (type /tools to see available tools)"
|
|
rows="1"
|
|
></textarea>
|
|
<button class="mic-btn" id="mic-btn" title="Voice input">🎙</button>
|
|
<button class="send-btn" id="send-btn" onclick="send()">➤</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ── Scroll-to-bottom button ── -->
|
|
<button
|
|
class="scroll-btn"
|
|
id="scroll-btn"
|
|
onclick="chat.scrollTop = chat.scrollHeight"
|
|
>
|
|
↓ Latest
|
|
</button>
|
|
|
|
<!-- ── Floating help button ── -->
|
|
<button class="help-fab" id="help-fab" title="What can this agent do?">
|
|
?
|
|
</button>
|
|
|
|
<!-- ── Help guide panel ── -->
|
|
<div class="help-panel-overlay" id="help-overlay">
|
|
<div class="help-panel">
|
|
<div class="help-panel-title">
|
|
What can this agent do?
|
|
<button class="modal-close-btn" onclick="closeHelp()">✕</button>
|
|
</div>
|
|
|
|
<div class="help-section">
|
|
<div class="help-section-title">💬 Ask anything — try these</div>
|
|
<div class="help-feature-grid">
|
|
<div
|
|
class="help-feature"
|
|
onclick="closeHelpAndSend('Give me a full portfolio summary')"
|
|
>
|
|
<div class="help-feature-icon">📊</div>
|
|
<div class="help-feature-name">Portfolio Summary</div>
|
|
<div class="help-feature-desc">Value, holdings, YTD return</div>
|
|
</div>
|
|
<div
|
|
class="help-feature"
|
|
onclick="
|
|
closeHelpAndSend(
|
|
'What is the Austin housing market like right now?'
|
|
)
|
|
"
|
|
>
|
|
<div class="help-feature-icon">🏠</div>
|
|
<div class="help-feature-name">Austin Real Estate</div>
|
|
<div class="help-feature-desc">Jan 2026 ACTRIS MLS data</div>
|
|
</div>
|
|
<div
|
|
class="help-feature"
|
|
onclick="
|
|
closeHelpAndSend(
|
|
'What is my total net worth including real estate?'
|
|
)
|
|
"
|
|
>
|
|
<div class="help-feature-icon">💰</div>
|
|
<div class="help-feature-name">Total Net Worth</div>
|
|
<div class="help-feature-desc">Portfolio + property equity</div>
|
|
</div>
|
|
<div
|
|
class="help-feature"
|
|
onclick="closeHelpAndSend('Am I over-concentrated in any stock?')"
|
|
>
|
|
<div class="help-feature-icon">⚖️</div>
|
|
<div class="help-feature-name">Risk Check</div>
|
|
<div class="help-feature-desc">
|
|
Concentration & compliance
|
|
</div>
|
|
</div>
|
|
<div
|
|
class="help-feature"
|
|
onclick="closeHelpAndSend('Estimate my tax liability')"
|
|
>
|
|
<div class="help-feature-icon">🧾</div>
|
|
<div class="help-feature-name">Tax Estimate</div>
|
|
<div class="help-feature-desc">Capital gains liability</div>
|
|
</div>
|
|
<div
|
|
class="help-feature"
|
|
onclick="closeHelpAndSend('Compare Round Rock vs Hays County')"
|
|
>
|
|
<div class="help-feature-icon">🔀</div>
|
|
<div class="help-feature-name">Compare Counties</div>
|
|
<div class="help-feature-desc">Side-by-side market data</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="help-section">
|
|
<div class="help-section-title">⚡ Power features</div>
|
|
<div class="help-feature-grid">
|
|
<div
|
|
class="help-feature"
|
|
onclick="
|
|
closeHelp();
|
|
input.value = '~';
|
|
input.dispatchEvent(new KeyboardEvent('keyup'));
|
|
"
|
|
>
|
|
<div class="help-feature-icon">⌨</div>
|
|
<div class="help-feature-name">Input Templates</div>
|
|
<div class="help-feature-desc">
|
|
Type ~ to pick a pre-built query
|
|
</div>
|
|
</div>
|
|
<div
|
|
class="help-feature"
|
|
onclick="
|
|
closeHelp();
|
|
openShortcutModal();
|
|
"
|
|
>
|
|
<div class="help-feature-icon">🎹</div>
|
|
<div class="help-feature-name">Keyboard Shortcuts</div>
|
|
<div class="help-feature-desc">
|
|
⌘K focus · ↑ restore · ⌘F search
|
|
</div>
|
|
</div>
|
|
<div
|
|
class="help-feature"
|
|
onclick="
|
|
closeHelp();
|
|
if (recognition) startListening();
|
|
"
|
|
>
|
|
<div class="help-feature-icon">🎙</div>
|
|
<div class="help-feature-name">Voice Input</div>
|
|
<div class="help-feature-desc">Click 🎙 or tap mic button</div>
|
|
</div>
|
|
<div
|
|
class="help-feature"
|
|
onclick="
|
|
closeHelp();
|
|
applyScenarioMode(true);
|
|
"
|
|
>
|
|
<div class="help-feature-icon">🔮</div>
|
|
<div class="help-feature-name">What-if Mode</div>
|
|
<div class="help-feature-desc">
|
|
Hypothetical financial scenarios
|
|
</div>
|
|
</div>
|
|
<div
|
|
class="help-feature"
|
|
onclick="
|
|
closeHelp();
|
|
openGoalModal();
|
|
"
|
|
>
|
|
<div class="help-feature-icon">🎯</div>
|
|
<div class="help-feature-name">Set a Goal</div>
|
|
<div class="help-feature-desc">Track progress to your target</div>
|
|
</div>
|
|
<div
|
|
class="help-feature"
|
|
onclick="
|
|
closeHelp();
|
|
document.getElementById('share-btn').click();
|
|
"
|
|
>
|
|
<div class="help-feature-icon">↑</div>
|
|
<div class="help-feature-name">Share Chat</div>
|
|
<div class="help-feature-desc">
|
|
Copy link or formatted summary
|
|
</div>
|
|
</div>
|
|
<div
|
|
class="help-feature"
|
|
onclick="
|
|
closeHelp();
|
|
document.getElementById('memory-indicator').click();
|
|
"
|
|
>
|
|
<div class="help-feature-icon">🧠</div>
|
|
<div class="help-feature-name">Context Memory</div>
|
|
<div class="help-feature-desc">
|
|
Agent remembers tickers & net worth across sessions
|
|
</div>
|
|
</div>
|
|
<div
|
|
class="help-feature"
|
|
onclick="
|
|
closeHelp();
|
|
addToWatchlistPrompt();
|
|
"
|
|
>
|
|
<div class="help-feature-icon">👁</div>
|
|
<div class="help-feature-name">Watchlist</div>
|
|
<div class="help-feature-desc">
|
|
Track tickers — type /watch AAPL
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="help-section">
|
|
<div class="help-section-title">✍ Annotate & React</div>
|
|
<div class="help-feature-grid">
|
|
<div class="help-feature">
|
|
<div class="help-feature-icon">📝</div>
|
|
<div class="help-feature-name">Sticky Notes</div>
|
|
<div class="help-feature-desc">
|
|
Click 📝 on any response to add a private note
|
|
</div>
|
|
</div>
|
|
<div class="help-feature">
|
|
<div class="help-feature-icon">📌</div>
|
|
<div class="help-feature-name">Pin Responses</div>
|
|
<div class="help-feature-desc">
|
|
Click 📌 to pin key answers to the top bar
|
|
</div>
|
|
</div>
|
|
<div class="help-feature">
|
|
<div class="help-feature-icon">👍</div>
|
|
<div class="help-feature-name">Rate Responses</div>
|
|
<div class="help-feature-desc">
|
|
👍 👎 buttons below each answer for feedback
|
|
</div>
|
|
</div>
|
|
<div
|
|
class="help-feature"
|
|
onclick="
|
|
closeHelp();
|
|
openAddCardModal();
|
|
"
|
|
>
|
|
<div class="help-feature-icon">⭐</div>
|
|
<div class="help-feature-name">Custom Shortcuts</div>
|
|
<div class="help-feature-desc">
|
|
Save your own quick-action cards to the landing page
|
|
</div>
|
|
</div>
|
|
<div class="help-feature">
|
|
<div class="help-feature-icon">↺</div>
|
|
<div class="help-feature-name">Query History</div>
|
|
<div class="help-feature-desc">
|
|
Click empty input to see & reuse past queries
|
|
</div>
|
|
</div>
|
|
<div
|
|
class="help-feature"
|
|
onclick="
|
|
closeHelp();
|
|
setFontSize('large');
|
|
"
|
|
>
|
|
<div class="help-feature-icon">🔡</div>
|
|
<div class="help-feature-name">Font Size</div>
|
|
<div class="help-feature-desc">A–A–A controls in ⚙ Settings</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="help-section">
|
|
<div class="help-section-title">🗂 Manage your chats</div>
|
|
<div class="help-feature-grid">
|
|
<div
|
|
class="help-feature"
|
|
onclick="
|
|
closeHelp();
|
|
openDrawer();
|
|
"
|
|
>
|
|
<div class="help-feature-icon">☰</div>
|
|
<div class="help-feature-name">Chat History</div>
|
|
<div class="help-feature-desc">
|
|
Up to 15 sessions saved locally
|
|
</div>
|
|
</div>
|
|
<div
|
|
class="help-feature"
|
|
onclick="
|
|
closeHelp();
|
|
openDrawer();
|
|
switchDrawerTab('saved');
|
|
"
|
|
>
|
|
<div class="help-feature-icon">⭐</div>
|
|
<div class="help-feature-name">Saved Responses</div>
|
|
<div class="help-feature-desc">Star ★ any reply to save it</div>
|
|
</div>
|
|
<div
|
|
class="help-feature"
|
|
onclick="
|
|
closeHelp();
|
|
downloadConversation();
|
|
"
|
|
>
|
|
<div class="help-feature-icon">⬇</div>
|
|
<div class="help-feature-name">Export Conversation</div>
|
|
<div class="help-feature-desc">Download as .txt file</div>
|
|
</div>
|
|
<div
|
|
class="help-feature"
|
|
onclick="
|
|
closeHelp();
|
|
window.print();
|
|
"
|
|
>
|
|
<div class="help-feature-icon">🖨</div>
|
|
<div class="help-feature-name">Print / Save PDF</div>
|
|
<div class="help-feature-desc">Clean print-ready layout</div>
|
|
</div>
|
|
<div
|
|
class="help-feature"
|
|
onclick="
|
|
closeHelp();
|
|
openBatchExport();
|
|
"
|
|
>
|
|
<div class="help-feature-icon">☑</div>
|
|
<div class="help-feature-name">Batch Export</div>
|
|
<div class="help-feature-desc">
|
|
Select specific responses to export
|
|
</div>
|
|
</div>
|
|
<div
|
|
class="help-feature"
|
|
onclick="
|
|
closeHelp();
|
|
copyEmailDigest();
|
|
"
|
|
>
|
|
<div class="help-feature-icon">📧</div>
|
|
<div class="help-feature-name">Email Digest</div>
|
|
<div class="help-feature-desc">Copy formatted chat for email</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="help-section">
|
|
<div class="help-section-title">🧮 Financial Tools</div>
|
|
<div class="help-feature-grid">
|
|
<div
|
|
class="help-feature"
|
|
onclick="
|
|
closeHelp();
|
|
openYieldCalc();
|
|
"
|
|
>
|
|
<div class="help-feature-icon">🏠</div>
|
|
<div class="help-feature-name">Rental Yield Calc</div>
|
|
<div class="help-feature-desc">
|
|
Gross/net yield & cap rate
|
|
</div>
|
|
</div>
|
|
<div
|
|
class="help-feature"
|
|
onclick="
|
|
closeHelp();
|
|
openDonutChart();
|
|
"
|
|
>
|
|
<div class="help-feature-icon">🍩</div>
|
|
<div class="help-feature-name">Donut Chart</div>
|
|
<div class="help-feature-desc">
|
|
Portfolio allocation breakdown
|
|
</div>
|
|
</div>
|
|
<div
|
|
class="help-feature"
|
|
onclick="
|
|
closeHelp();
|
|
openPropCompare();
|
|
"
|
|
>
|
|
<div class="help-feature-icon">🏘</div>
|
|
<div class="help-feature-name">Compare Properties</div>
|
|
<div class="help-feature-desc">
|
|
Side-by-side property analysis
|
|
</div>
|
|
</div>
|
|
<div
|
|
class="help-feature"
|
|
onclick="
|
|
closeHelp();
|
|
openPalette();
|
|
"
|
|
>
|
|
<div class="help-feature-icon">⌘</div>
|
|
<div class="help-feature-name">Command Palette</div>
|
|
<div class="help-feature-desc">All features instantly (⌘P)</div>
|
|
</div>
|
|
<div
|
|
class="help-feature"
|
|
onclick="closeHelp(); openProfile();"
|
|
>
|
|
<div class="help-feature-icon">👤</div>
|
|
<div class="help-feature-name">My Profile</div>
|
|
<div class="help-feature-desc">
|
|
Risk, focus, horizon — personalizes agent
|
|
</div>
|
|
</div>
|
|
<div
|
|
class="help-feature"
|
|
onclick="
|
|
closeHelp();
|
|
document.getElementById('reminder-modal').classList.add('open');
|
|
"
|
|
>
|
|
<div class="help-feature-icon">🔔</div>
|
|
<div class="help-feature-name">Set Reminder</div>
|
|
<div class="help-feature-desc">
|
|
Browser notification to check portfolio
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ── Input template picker (shown above textarea when ~ typed) ── -->
|
|
<div class="template-picker" id="template-picker">
|
|
<div class="template-picker-header">⌨ Templates — select to fill</div>
|
|
<div
|
|
class="template-item"
|
|
onclick="
|
|
useTemplate(
|
|
'Add my property at [address] in Austin TX, purchased for $[price], current value $[value], mortgage balance $[mortgage]'
|
|
)
|
|
"
|
|
>
|
|
<strong>🏠 Track a property</strong>
|
|
<div class="tmpl-hint">
|
|
Fill in address, purchase price, current value, mortgage
|
|
</div>
|
|
</div>
|
|
<div
|
|
class="template-item"
|
|
onclick="
|
|
useTemplate(
|
|
'What would happen to my net worth if Austin home prices drop [X]%?'
|
|
)
|
|
"
|
|
>
|
|
<strong>🔮 What-if scenario</strong>
|
|
<div class="tmpl-hint">Hypothetical price change analysis</div>
|
|
</div>
|
|
<div
|
|
class="template-item"
|
|
onclick="
|
|
useTemplate('Compare [County A] vs [County B] real estate market')
|
|
"
|
|
>
|
|
<strong>🔀 Compare counties</strong>
|
|
<div class="tmpl-hint">Side-by-side market comparison</div>
|
|
</div>
|
|
<div
|
|
class="template-item"
|
|
onclick="
|
|
useTemplate(
|
|
'Am I on track to retire at age [age] with $[target] saved?'
|
|
)
|
|
"
|
|
>
|
|
<strong>📅 Retirement check</strong>
|
|
<div class="tmpl-hint">Goal-based portfolio analysis</div>
|
|
</div>
|
|
<div
|
|
class="template-item"
|
|
onclick="
|
|
useTemplate('What is the tax impact if I sell [ticker] today?')
|
|
"
|
|
>
|
|
<strong>🧾 Tax impact</strong>
|
|
<div class="tmpl-hint">Capital gains on a specific holding</div>
|
|
</div>
|
|
<div
|
|
class="template-item"
|
|
onclick="
|
|
useTemplate(
|
|
'Show me the rental yield comparison across all Austin-area counties'
|
|
)
|
|
"
|
|
>
|
|
<strong>🏘 Rental yield scan</strong>
|
|
<div class="tmpl-hint">Compare rent-to-price ratios</div>
|
|
</div>
|
|
<div
|
|
class="template-item"
|
|
onclick="
|
|
useTemplate(
|
|
'What is [TICKER] trading at today and what is the analyst consensus?'
|
|
)
|
|
"
|
|
>
|
|
<strong>👁 Watchlist check</strong>
|
|
<div class="tmpl-hint">Price + sentiment for any ticker</div>
|
|
</div>
|
|
<div
|
|
class="template-item"
|
|
onclick="
|
|
useTemplate(
|
|
'Summarize my portfolio performance over the last [30/90/365] days'
|
|
)
|
|
"
|
|
>
|
|
<strong>📅 Period summary</strong>
|
|
<div class="tmpl-hint">Performance over a custom time range</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ── Keyboard shortcut modal ── -->
|
|
<div class="modal-overlay" id="shortcut-modal">
|
|
<div class="modal-box">
|
|
<div class="modal-title">
|
|
⌨ Keyboard Shortcuts
|
|
<button class="modal-close-btn" onclick="closeShortcutModal()">
|
|
✕
|
|
</button>
|
|
</div>
|
|
<table class="shortcut-table">
|
|
<tr>
|
|
<td>Send message</td>
|
|
<td><span class="kbd">Enter</span></td>
|
|
</tr>
|
|
<tr>
|
|
<td>New line</td>
|
|
<td>
|
|
<span class="kbd">Shift</span> <span class="kbd">Enter</span>
|
|
</td>
|
|
</tr>
|
|
<tr>
|
|
<td>Restore last message</td>
|
|
<td><span class="kbd">↑</span> (when input empty)</td>
|
|
</tr>
|
|
<tr>
|
|
<td>Focus input</td>
|
|
<td><span class="kbd">⌘/Ctrl</span> <span class="kbd">K</span></td>
|
|
</tr>
|
|
<tr>
|
|
<td>Search conversation</td>
|
|
<td><span class="kbd">⌘/Ctrl</span> <span class="kbd">F</span></td>
|
|
</tr>
|
|
<tr>
|
|
<td>Show tools</td>
|
|
<td><span class="kbd">⌘/Ctrl</span> <span class="kbd">/</span></td>
|
|
</tr>
|
|
<tr>
|
|
<td>Export conversation</td>
|
|
<td>
|
|
<span class="kbd">⌘/Ctrl</span> <span class="kbd">Shift</span>
|
|
<span class="kbd">E</span>
|
|
</td>
|
|
</tr>
|
|
<tr>
|
|
<td>Input templates</td>
|
|
<td>Type <span class="kbd">~</span> at start of message</td>
|
|
</tr>
|
|
<tr>
|
|
<td>Stop mic / clear input</td>
|
|
<td><span class="kbd">Esc</span></td>
|
|
</tr>
|
|
<tr>
|
|
<td>Keyboard shortcuts</td>
|
|
<td><span class="kbd">⌘/Ctrl</span> <span class="kbd">?</span></td>
|
|
</tr>
|
|
<tr>
|
|
<td>Command palette</td>
|
|
<td><span class="kbd">⌘/Ctrl</span> <span class="kbd">P</span></td>
|
|
</tr>
|
|
<tr>
|
|
<td>Add ticker to watchlist</td>
|
|
<td>Type <span class="kbd">/watch AAPL</span></td>
|
|
</tr>
|
|
<tr>
|
|
<td>Browse query history</td>
|
|
<td><span class="kbd">↑ / ↓</span> when history open</td>
|
|
</tr>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ── Export as Image Card modal ── -->
|
|
<div class="modal-overlay" id="export-card-modal">
|
|
<div class="modal-box" style="max-width: 560px">
|
|
<div class="modal-title">
|
|
🖼 Export as Image Card
|
|
<button
|
|
class="modal-close-btn"
|
|
onclick="
|
|
document
|
|
.getElementById('export-card-modal')
|
|
.classList.remove('open')
|
|
"
|
|
>
|
|
✕
|
|
</button>
|
|
</div>
|
|
<canvas id="export-canvas"></canvas>
|
|
<div class="export-card-actions">
|
|
<button class="export-action-btn primary" onclick="downloadCard()">
|
|
⬇ Download PNG
|
|
</button>
|
|
<button class="export-action-btn" onclick="copyCardToClipboard()">
|
|
⎘ Copy image
|
|
</button>
|
|
</div>
|
|
<div
|
|
style="
|
|
font-size: 11px;
|
|
color: var(--text3);
|
|
margin-top: 8px;
|
|
text-align: center;
|
|
"
|
|
>
|
|
Captures the last agent response as a shareable card
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ── Portfolio heat map modal ── -->
|
|
<div class="modal-overlay" id="heatmap-modal">
|
|
<div class="modal-box" style="max-width: 720px">
|
|
<div class="modal-title">
|
|
🟩 Portfolio Heat Map
|
|
<button
|
|
class="modal-close-btn"
|
|
onclick="
|
|
document.getElementById('heatmap-modal').classList.remove('open')
|
|
"
|
|
>
|
|
✕
|
|
</button>
|
|
</div>
|
|
<div id="heatmap-content" style="min-height: 140px"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ── Request inspector panel ── -->
|
|
<div class="inspector-panel" id="inspector-panel">
|
|
<div class="inspector-header">
|
|
<div class="inspector-header-title">🔬 Request Inspector</div>
|
|
<button
|
|
class="inspector-close"
|
|
onclick="toggleInspector()"
|
|
title="Close"
|
|
>
|
|
✕
|
|
</button>
|
|
</div>
|
|
<div class="inspector-body" id="inspector-body">
|
|
<div class="inspector-empty">
|
|
No requests yet.<br />Send a message to start inspecting tool calls,
|
|
latency, and confidence scores.
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ── Command palette ── -->
|
|
<div
|
|
class="cmd-overlay"
|
|
id="cmd-overlay"
|
|
onclick="if (event.target === this) closePalette();"
|
|
>
|
|
<div class="cmd-box">
|
|
<div class="cmd-input-wrap">
|
|
<span class="cmd-input-icon">⌘</span>
|
|
<input
|
|
autocomplete="off"
|
|
class="cmd-input"
|
|
id="cmd-input"
|
|
placeholder="Type a command or search…"
|
|
/>
|
|
</div>
|
|
<div class="cmd-results" id="cmd-results"></div>
|
|
<div class="cmd-footer">
|
|
<span
|
|
><span
|
|
style="
|
|
background: var(--surface2);
|
|
border: 1px solid var(--border2);
|
|
border-radius: 4px;
|
|
padding: 1px 5px;
|
|
font-size: 9px;
|
|
"
|
|
>↑↓</span
|
|
>
|
|
navigate</span
|
|
>
|
|
<span
|
|
><span
|
|
style="
|
|
background: var(--surface2);
|
|
border: 1px solid var(--border2);
|
|
border-radius: 4px;
|
|
padding: 1px 5px;
|
|
font-size: 9px;
|
|
"
|
|
>Enter</span
|
|
>
|
|
select</span
|
|
>
|
|
<span
|
|
><span
|
|
style="
|
|
background: var(--surface2);
|
|
border: 1px solid var(--border2);
|
|
border-radius: 4px;
|
|
padding: 1px 5px;
|
|
font-size: 9px;
|
|
"
|
|
>Esc</span
|
|
>
|
|
close</span
|
|
>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ── User profile modal ── -->
|
|
<div class="modal-overlay" id="profile-modal">
|
|
<div class="modal-box" style="max-width: 420px">
|
|
<div class="modal-title">
|
|
👤 Your Investor Profile
|
|
<button
|
|
class="modal-close-btn"
|
|
onclick="document.getElementById('profile-modal').classList.remove('open')"
|
|
>✕</button>
|
|
</div>
|
|
<div class="profile-progress" id="profile-progress"></div>
|
|
<div class="profile-step active" id="profile-step-0">
|
|
<div style="font-size:13px;font-weight:600;color:var(--text);margin-bottom:4px">
|
|
What best describes your risk tolerance?
|
|
</div>
|
|
<div class="profile-option" onclick="selectProfile('risk','conservative',this)">
|
|
<span class="profile-option-icon">🛡</span>
|
|
<div><div style="font-weight:600">Conservative</div><div style="font-size:11px;color:var(--text3)">Capital preservation first</div></div>
|
|
</div>
|
|
<div class="profile-option" onclick="selectProfile('risk','moderate',this)">
|
|
<span class="profile-option-icon">⚖️</span>
|
|
<div><div style="font-weight:600">Moderate</div><div style="font-size:11px;color:var(--text3)">Balanced growth and stability</div></div>
|
|
</div>
|
|
<div class="profile-option" onclick="selectProfile('risk','aggressive',this)">
|
|
<span class="profile-option-icon">🚀</span>
|
|
<div><div style="font-weight:600">Aggressive</div><div style="font-size:11px;color:var(--text3)">Maximum growth, higher volatility</div></div>
|
|
</div>
|
|
<button onclick="nextProfileStep()" style="padding:9px;border-radius:9px;border:none;background:linear-gradient(135deg,var(--indigo),#8b5cf6);color:#fff;font-size:13px;font-weight:600;cursor:pointer;margin-top:4px">
|
|
Next →
|
|
</button>
|
|
</div>
|
|
<div class="profile-step" id="profile-step-1">
|
|
<div style="font-size:13px;font-weight:600;color:var(--text);margin-bottom:4px">
|
|
Primary investment focus?
|
|
</div>
|
|
<div class="profile-option" onclick="selectProfile('focus','real_estate',this)">
|
|
<span class="profile-option-icon">🏠</span>
|
|
<div><div style="font-weight:600">Real Estate</div><div style="font-size:11px;color:var(--text3)">Properties, REITs, land</div></div>
|
|
</div>
|
|
<div class="profile-option" onclick="selectProfile('focus','equities',this)">
|
|
<span class="profile-option-icon">📈</span>
|
|
<div><div style="font-weight:600">Equities</div><div style="font-size:11px;color:var(--text3)">Stocks, ETFs, growth</div></div>
|
|
</div>
|
|
<div class="profile-option" onclick="selectProfile('focus','mixed',this)">
|
|
<span class="profile-option-icon">🌐</span>
|
|
<div><div style="font-weight:600">Diversified</div><div style="font-size:11px;color:var(--text3)">Mix of asset classes</div></div>
|
|
</div>
|
|
<button onclick="nextProfileStep()" style="padding:9px;border-radius:9px;border:none;background:linear-gradient(135deg,var(--indigo),#8b5cf6);color:#fff;font-size:13px;font-weight:600;cursor:pointer;margin-top:4px">
|
|
Next →
|
|
</button>
|
|
</div>
|
|
<div class="profile-step" id="profile-step-2">
|
|
<div style="font-size:13px;font-weight:600;color:var(--text);margin-bottom:4px">
|
|
Investment horizon?
|
|
</div>
|
|
<div class="profile-option" onclick="selectProfile('horizon','short',this)">
|
|
<span class="profile-option-icon">⚡</span>
|
|
<div><div style="font-weight:600">Short-term (<2 years)</div></div>
|
|
</div>
|
|
<div class="profile-option" onclick="selectProfile('horizon','medium',this)">
|
|
<span class="profile-option-icon">📅</span>
|
|
<div><div style="font-weight:600">Medium-term (2–10 years)</div></div>
|
|
</div>
|
|
<div class="profile-option" onclick="selectProfile('horizon','long',this)">
|
|
<span class="profile-option-icon">🌱</span>
|
|
<div><div style="font-weight:600">Long-term (10+ years / retirement)</div></div>
|
|
</div>
|
|
<button onclick="saveProfile()" style="padding:9px;border-radius:9px;border:none;background:linear-gradient(135deg,var(--indigo),#8b5cf6);color:#fff;font-size:13px;font-weight:600;cursor:pointer;margin-top:4px">
|
|
Save Profile ✓
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ── Rental yield calculator ── -->
|
|
<div class="modal-overlay" id="yield-modal">
|
|
<div class="modal-box" style="max-width: 400px">
|
|
<div class="modal-title">
|
|
🏠 Rental Yield Calculator
|
|
<button
|
|
class="modal-close-btn"
|
|
onclick="
|
|
document.getElementById('yield-modal').classList.remove('open')
|
|
"
|
|
>
|
|
✕
|
|
</button>
|
|
</div>
|
|
<div style="display: flex; flex-direction: column; gap: 10px">
|
|
<div class="calc-row">
|
|
<span class="calc-label">Property value</span
|
|
><input
|
|
class="calc-input"
|
|
id="yc-value"
|
|
oninput="calcYield()"
|
|
placeholder="450000"
|
|
type="number"
|
|
/>
|
|
</div>
|
|
<div class="calc-row">
|
|
<span class="calc-label">Monthly rent</span
|
|
><input
|
|
class="calc-input"
|
|
id="yc-rent"
|
|
oninput="calcYield()"
|
|
placeholder="2200"
|
|
type="number"
|
|
/>
|
|
</div>
|
|
<div class="calc-row">
|
|
<span class="calc-label">Annual expenses</span
|
|
><input
|
|
class="calc-input"
|
|
id="yc-expenses"
|
|
oninput="calcYield()"
|
|
placeholder="5000"
|
|
type="number"
|
|
/>
|
|
</div>
|
|
<div class="calc-result" id="yield-result" style="display: none">
|
|
<div class="calc-stat">
|
|
<div class="calc-stat-val" id="yc-gross">—</div>
|
|
<div class="calc-stat-lbl">Gross Yield</div>
|
|
</div>
|
|
<div class="calc-stat">
|
|
<div class="calc-stat-val" id="yc-net">—</div>
|
|
<div class="calc-stat-lbl">Net Yield</div>
|
|
</div>
|
|
<div class="calc-stat">
|
|
<div class="calc-stat-val" id="yc-annual">—</div>
|
|
<div class="calc-stat-lbl">Annual Income</div>
|
|
</div>
|
|
<div class="calc-stat">
|
|
<div class="calc-stat-val" id="yc-cap">—</div>
|
|
<div class="calc-stat-lbl">Cap Rate</div>
|
|
</div>
|
|
</div>
|
|
<button
|
|
onclick="
|
|
sendQuick(
|
|
'Based on these numbers, is this a good rental investment in Austin?'
|
|
);
|
|
document.getElementById('yield-modal').classList.remove('open');
|
|
"
|
|
style="
|
|
padding: 9px;
|
|
border-radius: 9px;
|
|
border: none;
|
|
background: linear-gradient(135deg, var(--indigo), #8b5cf6);
|
|
color: #fff;
|
|
font-size: 13px;
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
"
|
|
>
|
|
Ask agent to evaluate →
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ── Property comparison modal ── -->
|
|
<div class="modal-overlay" id="prop-compare-modal">
|
|
<div class="modal-box" style="max-width: 680px">
|
|
<div class="modal-title">
|
|
🏘 Compare Properties
|
|
<button
|
|
class="modal-close-btn"
|
|
onclick="
|
|
document
|
|
.getElementById('prop-compare-modal')
|
|
.classList.remove('open')
|
|
"
|
|
>
|
|
✕
|
|
</button>
|
|
</div>
|
|
<div id="prop-compare-content" style="min-height: 80px"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ── Portfolio donut chart modal ── -->
|
|
<div class="modal-overlay" id="donut-modal">
|
|
<div class="modal-box" style="max-width: 520px">
|
|
<div class="modal-title">
|
|
🍩 Portfolio Allocation
|
|
<button
|
|
class="modal-close-btn"
|
|
onclick="
|
|
document.getElementById('donut-modal').classList.remove('open')
|
|
"
|
|
>
|
|
✕
|
|
</button>
|
|
</div>
|
|
<div id="donut-content" style="min-height: 100px"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ── Batch export modal ── -->
|
|
<div class="modal-overlay" id="batch-modal">
|
|
<div class="modal-box" style="max-width: 480px">
|
|
<div class="modal-title">
|
|
☑ Batch Export
|
|
<button
|
|
class="modal-close-btn"
|
|
onclick="
|
|
document.getElementById('batch-modal').classList.remove('open')
|
|
"
|
|
>
|
|
✕
|
|
</button>
|
|
</div>
|
|
<div style="font-size: 12px; color: var(--text2); margin-bottom: 12px">
|
|
Select responses to include in export:
|
|
</div>
|
|
<div
|
|
id="batch-list"
|
|
style="
|
|
max-height: 300px;
|
|
overflow-y: auto;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 4px;
|
|
"
|
|
></div>
|
|
<div style="display: flex; gap: 8px; margin-top: 14px">
|
|
<button
|
|
onclick="batchExportSelected('txt')"
|
|
style="
|
|
flex: 1;
|
|
padding: 8px;
|
|
border-radius: 8px;
|
|
border: 1px solid var(--border2);
|
|
background: var(--surface);
|
|
color: var(--text2);
|
|
cursor: pointer;
|
|
font-size: 12px;
|
|
"
|
|
>
|
|
⬇ Export .txt
|
|
</button>
|
|
<button
|
|
onclick="batchExportSelected('md')"
|
|
style="
|
|
flex: 1;
|
|
padding: 8px;
|
|
border-radius: 8px;
|
|
border: none;
|
|
background: linear-gradient(135deg, var(--indigo), #8b5cf6);
|
|
color: #fff;
|
|
cursor: pointer;
|
|
font-size: 12px;
|
|
font-weight: 600;
|
|
"
|
|
>
|
|
⬇ Export .md
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ── Reminder modal ── -->
|
|
<div class="modal-overlay" id="reminder-modal">
|
|
<div class="modal-box" style="max-width: 360px">
|
|
<div class="modal-title">
|
|
🔔 Portfolio Reminder
|
|
<button
|
|
class="modal-close-btn"
|
|
onclick="
|
|
document.getElementById('reminder-modal').classList.remove('open')
|
|
"
|
|
>
|
|
✕
|
|
</button>
|
|
</div>
|
|
<div style="display: flex; flex-direction: column; gap: 10px">
|
|
<div style="font-size: 12px; color: var(--text2)">
|
|
Remind me to check my portfolio in:
|
|
</div>
|
|
<div style="display: flex; gap: 8px; flex-wrap: wrap">
|
|
<button
|
|
class="profile-option"
|
|
onclick="setReminder(1)"
|
|
style="flex: 1; justify-content: center; padding: 10px"
|
|
>
|
|
1 day
|
|
</button>
|
|
<button
|
|
class="profile-option"
|
|
onclick="setReminder(3)"
|
|
style="flex: 1; justify-content: center; padding: 10px"
|
|
>
|
|
3 days
|
|
</button>
|
|
<button
|
|
class="profile-option"
|
|
onclick="setReminder(7)"
|
|
style="flex: 1; justify-content: center; padding: 10px"
|
|
>
|
|
1 week
|
|
</button>
|
|
<button
|
|
class="profile-option"
|
|
onclick="setReminder(30)"
|
|
style="flex: 1; justify-content: center; padding: 10px"
|
|
>
|
|
1 month
|
|
</button>
|
|
</div>
|
|
<div style="font-size: 11px; color: var(--text3)">
|
|
Uses browser notifications — you'll be prompted to allow.
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ── Context memory panel ── -->
|
|
<div class="memory-panel" id="memory-panel">
|
|
<div class="memory-panel-title">
|
|
🧠 Remembered Context
|
|
<button
|
|
onclick="
|
|
document.getElementById('memory-panel').classList.remove('open')
|
|
"
|
|
style="
|
|
background: none;
|
|
border: none;
|
|
color: var(--text3);
|
|
cursor: pointer;
|
|
font-size: 13px;
|
|
"
|
|
>
|
|
✕
|
|
</button>
|
|
</div>
|
|
<div>
|
|
<div class="memory-section-title">Tickers</div>
|
|
<div
|
|
id="mem-tickers"
|
|
style="flex-wrap: wrap; display: flex; gap: 4px"
|
|
></div>
|
|
</div>
|
|
<div>
|
|
<div class="memory-section-title">Properties</div>
|
|
<div
|
|
id="mem-properties"
|
|
style="flex-wrap: wrap; display: flex; gap: 4px"
|
|
></div>
|
|
</div>
|
|
<div>
|
|
<div class="memory-section-title">Net Worth Snapshot</div>
|
|
<div id="mem-networth" style="font-size: 12px; color: var(--text2)">
|
|
—
|
|
</div>
|
|
<svg
|
|
class="nw-sparkline"
|
|
id="mem-sparkline"
|
|
preserveAspectRatio="none"
|
|
style="display: block; margin-top: 6px; width: 100%; height: 40px"
|
|
viewBox="0 0 40 18"
|
|
></svg>
|
|
</div>
|
|
<button class="memory-forget-btn" onclick="clearMemory()">
|
|
🗑 Clear all memory
|
|
</button>
|
|
</div>
|
|
|
|
<!-- ── Add custom shortcut modal ── -->
|
|
<div class="modal-overlay" id="add-card-modal">
|
|
<div class="modal-box" style="max-width: 360px">
|
|
<div class="modal-title">
|
|
+ Add Quick Shortcut
|
|
<button class="modal-close-btn" onclick="closeAddCardModal()">
|
|
✕
|
|
</button>
|
|
</div>
|
|
<div style="display: flex; flex-direction: column; gap: 10px">
|
|
<label style="font-size: 12px; color: var(--text2)"
|
|
>Icon (emoji)
|
|
<input
|
|
id="card-icon-input"
|
|
maxlength="4"
|
|
placeholder="🏠"
|
|
style="
|
|
width: 100%;
|
|
margin-top: 4px;
|
|
background: var(--surface);
|
|
border: 1px solid var(--border2);
|
|
border-radius: 8px;
|
|
padding: 8px 10px;
|
|
color: var(--text);
|
|
font-size: 20px;
|
|
outline: none;
|
|
text-align: center;
|
|
"
|
|
type="text"
|
|
/>
|
|
</label>
|
|
<label style="font-size: 12px; color: var(--text2)"
|
|
>Label
|
|
<input
|
|
id="card-label-input"
|
|
placeholder="e.g. My Portfolio Check"
|
|
style="
|
|
width: 100%;
|
|
margin-top: 4px;
|
|
background: var(--surface);
|
|
border: 1px solid var(--border2);
|
|
border-radius: 8px;
|
|
padding: 8px 10px;
|
|
color: var(--text);
|
|
font-size: 13px;
|
|
outline: none;
|
|
"
|
|
type="text"
|
|
/>
|
|
</label>
|
|
<label style="font-size: 12px; color: var(--text2)"
|
|
>Query to send
|
|
<input
|
|
id="card-query-input"
|
|
placeholder="e.g. Give me a full portfolio summary"
|
|
style="
|
|
width: 100%;
|
|
margin-top: 4px;
|
|
background: var(--surface);
|
|
border: 1px solid var(--border2);
|
|
border-radius: 8px;
|
|
padding: 8px 10px;
|
|
color: var(--text);
|
|
font-size: 13px;
|
|
outline: none;
|
|
"
|
|
type="text"
|
|
/>
|
|
</label>
|
|
<button
|
|
onclick="saveCustomCard()"
|
|
style="
|
|
padding: 9px;
|
|
border-radius: 9px;
|
|
border: none;
|
|
background: linear-gradient(135deg, var(--indigo), #8b5cf6);
|
|
color: #fff;
|
|
font-size: 13px;
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
margin-top: 4px;
|
|
"
|
|
>
|
|
Add Shortcut
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ── Goal setter modal ── -->
|
|
<div class="modal-overlay" id="goal-modal">
|
|
<div class="modal-box" style="max-width: 360px">
|
|
<div class="modal-title">
|
|
🎯 Set Financial Goal
|
|
<button class="modal-close-btn" onclick="closeGoalModal()">✕</button>
|
|
</div>
|
|
<div style="display: flex; flex-direction: column; gap: 10px">
|
|
<label style="font-size: 12px; color: var(--text2)"
|
|
>Target net worth ($)
|
|
<input
|
|
id="goal-target-input"
|
|
placeholder="e.g. 500000"
|
|
style="
|
|
width: 100%;
|
|
margin-top: 4px;
|
|
background: var(--surface);
|
|
border: 1px solid var(--border2);
|
|
border-radius: 8px;
|
|
padding: 8px 10px;
|
|
color: var(--text);
|
|
font-size: 13px;
|
|
outline: none;
|
|
"
|
|
type="number"
|
|
/>
|
|
</label>
|
|
<label style="font-size: 12px; color: var(--text2)"
|
|
>Target year
|
|
<input
|
|
id="goal-year-input"
|
|
max="2060"
|
|
min="2025"
|
|
placeholder="e.g. 2030"
|
|
style="
|
|
width: 100%;
|
|
margin-top: 4px;
|
|
background: var(--surface);
|
|
border: 1px solid var(--border2);
|
|
border-radius: 8px;
|
|
padding: 8px 10px;
|
|
color: var(--text);
|
|
font-size: 13px;
|
|
outline: none;
|
|
"
|
|
type="number"
|
|
/>
|
|
</label>
|
|
<button
|
|
onclick="saveGoal()"
|
|
style="
|
|
padding: 9px;
|
|
border-radius: 9px;
|
|
border: none;
|
|
background: linear-gradient(135deg, var(--indigo), #8b5cf6);
|
|
color: #fff;
|
|
font-size: 13px;
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
margin-top: 4px;
|
|
"
|
|
>
|
|
Set Goal
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
// ── State ──
|
|
const STORAGE_KEY = 'gf_agent_chat';
|
|
let history = []; // [{role, content}]
|
|
let pendingWrite = null; // echoed back to agent on confirmation
|
|
let sessionStats = { messages: 0, toolCalls: 0, latencies: [] };
|
|
let lastQuery = ''; // for retry
|
|
|
|
const chat = document.getElementById('chat');
|
|
const input = document.getElementById('input');
|
|
const sendBtn = document.getElementById('send-btn');
|
|
const emptyEl = document.getElementById('empty');
|
|
const dotEl = document.getElementById('dot');
|
|
const statusLbl = document.getElementById('status-label');
|
|
const latChip = document.getElementById('latency-chip');
|
|
const toastEl = document.getElementById('session-toast');
|
|
|
|
const TOOL_CATALOG = [
|
|
{
|
|
name: 'portfolio_analysis',
|
|
desc: 'Fetch your full portfolio snapshot from Ghostfolio — value, returns, holdings breakdown.'
|
|
},
|
|
{
|
|
name: 'transaction_query',
|
|
desc: 'Query activity and transaction history with optional ticker filter.'
|
|
},
|
|
{
|
|
name: 'compliance_check',
|
|
desc: 'Run concentration and risk-allocation rules against your current holdings.'
|
|
},
|
|
{
|
|
name: 'market_data',
|
|
desc: 'Get a live stock price from Yahoo Finance for any symbol.'
|
|
},
|
|
{
|
|
name: 'market_overview',
|
|
desc: 'Fetch a broad market summary: top movers, sector performance.'
|
|
},
|
|
{
|
|
name: 'tax_estimate',
|
|
desc: 'Estimate capital gains tax liability based on realized P&L.'
|
|
},
|
|
{
|
|
name: 'write_transaction',
|
|
desc: 'Record a BUY, SELL, DIVIDEND, or CASH transaction into Ghostfolio (requires confirmation).'
|
|
},
|
|
{
|
|
name: 'transaction_categorize',
|
|
desc: 'Analyze your trading patterns — frequency, style, category breakdown.'
|
|
},
|
|
{
|
|
name: 'real_estate',
|
|
desc: 'Austin-area housing market data from ACTRIS/Unlock MLS (January 2026) — median prices, days on market, rental data for 7 counties/areas.'
|
|
},
|
|
{
|
|
name: 'property_tracker',
|
|
desc: 'Track properties you own — add address, purchase price, current value, and mortgage to see equity alongside your investment portfolio.'
|
|
}
|
|
];
|
|
|
|
// ── Health check ──
|
|
(async () => {
|
|
try {
|
|
const r = await fetch('/health');
|
|
const d = await r.json();
|
|
if (d.status === 'ok') {
|
|
dotEl.classList.remove('offline');
|
|
statusLbl.textContent = d.ghostfolio_reachable
|
|
? 'Live'
|
|
: 'Online · Ghostfolio unreachable';
|
|
} else throw new Error();
|
|
} catch {
|
|
dotEl.classList.add('offline');
|
|
statusLbl.textContent = 'Agent offline';
|
|
}
|
|
})();
|
|
|
|
// ── Auth: auto-fetch token when missing (no login required) ──
|
|
(async function initAuth() {
|
|
if (!localStorage.getItem('gf_token')) {
|
|
try {
|
|
const r = await fetch('/auth/auto');
|
|
const d = await r.json();
|
|
if (d.success && d.token) {
|
|
localStorage.setItem('gf_token', d.token);
|
|
if (d.name) localStorage.setItem('gf_user_name', d.name);
|
|
}
|
|
} catch { /* continue without token — backend uses env */ }
|
|
}
|
|
const name = localStorage.getItem('gf_user_name') || 'Investor';
|
|
const initials = name.slice(0, 2).toUpperCase();
|
|
document.getElementById('user-avatar').textContent = initials;
|
|
document.getElementById('user-name').textContent = name;
|
|
})();
|
|
|
|
// ── Restore session from localStorage ──
|
|
(function restoreSession() {
|
|
try {
|
|
const saved = localStorage.getItem(STORAGE_KEY);
|
|
if (!saved) return;
|
|
const { hist, stats } = JSON.parse(saved);
|
|
if (!hist || hist.length === 0) return;
|
|
history = hist;
|
|
if (stats) sessionStats = stats;
|
|
emptyEl.style.display = 'none';
|
|
const notice = document.createElement('div');
|
|
notice.className = 'session-restored';
|
|
notice.textContent = `↑ Restored ${Math.floor(hist.length / 2)} messages from last session`;
|
|
chat.appendChild(notice);
|
|
// Replay messages into DOM
|
|
for (let i = 0; i < hist.length; i += 2) {
|
|
const u = hist[i];
|
|
const a = hist[i + 1];
|
|
if (u) addMessage('user', u.content, null, true);
|
|
if (a) addMessage('agent', a.content, null, true);
|
|
}
|
|
} catch {
|
|
/* silently skip */
|
|
}
|
|
})();
|
|
|
|
function saveSession() {
|
|
try {
|
|
localStorage.setItem(
|
|
STORAGE_KEY,
|
|
JSON.stringify({ hist: history, stats: sessionStats })
|
|
);
|
|
} catch {
|
|
/* quota exceeded */
|
|
}
|
|
}
|
|
|
|
// ── Textarea auto-resize + Enter to send ──
|
|
input.addEventListener('input', () => {
|
|
input.style.height = 'auto';
|
|
input.style.height = Math.min(input.scrollHeight, 140) + 'px';
|
|
});
|
|
input.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
e.preventDefault();
|
|
send();
|
|
}
|
|
});
|
|
|
|
function sendQuick(text) {
|
|
input.value = text;
|
|
send();
|
|
}
|
|
|
|
// ── /tools easter egg ──
|
|
function handleToolsCommand() {
|
|
emptyEl.style.display = 'none';
|
|
addMessage('user', '/tools');
|
|
const panel = document.createElement('div');
|
|
panel.className = 'message agent';
|
|
const inner = document.createElement('div');
|
|
inner.className = 'tools-panel';
|
|
inner.innerHTML =
|
|
`<h3>🔧 Available Agent Tools</h3>` +
|
|
TOOL_CATALOG.map(
|
|
(t) =>
|
|
`<div class="tool-entry"><span class="tool-entry-name">${t.name}</span><span class="tool-entry-desc">${t.desc}</span></div>`
|
|
).join('');
|
|
panel.appendChild(inner);
|
|
chat.appendChild(panel);
|
|
chat.scrollTop = chat.scrollHeight;
|
|
}
|
|
|
|
// ── Main send ──
|
|
async function send() {
|
|
const query = input.value.trim();
|
|
if (!query || sendBtn.disabled) return;
|
|
|
|
if (query === '/tools') {
|
|
input.value = '';
|
|
input.style.height = 'auto';
|
|
handleToolsCommand();
|
|
return;
|
|
}
|
|
|
|
// Append file content to query if attached
|
|
let finalQuery = query;
|
|
if (attachedFileContent) {
|
|
finalQuery = `${query}\n\n[Attached file: ${attachedFileName}]\n\`\`\`\n${attachedFileContent.slice(0, 8000)}\n\`\`\``;
|
|
attachedFileContent = null;
|
|
attachedFileName = null;
|
|
attachPreview.classList.remove('visible');
|
|
}
|
|
// Prepend response-length instruction
|
|
if (LENGTH_PREFIXES[responseLength]) {
|
|
finalQuery = LENGTH_PREFIXES[responseLength] + finalQuery;
|
|
}
|
|
// Prepend scenario-mode framing
|
|
if (scenarioMode) {
|
|
finalQuery = 'In a hypothetical scenario (not real advice): ' + finalQuery;
|
|
}
|
|
|
|
// Track last query time for greeting banner
|
|
localStorage.setItem('gf_last_query_ts', Date.now().toString());
|
|
document.getElementById('greeting-banner').classList.remove('show');
|
|
// Save to query history
|
|
saveQueryHistory(query);
|
|
closeQueryHistory();
|
|
// Inject memory context prefix if available
|
|
const memCtx = getMemoryContextPrefix();
|
|
if (memCtx) finalQuery = memCtx + finalQuery;
|
|
|
|
lastQuery = finalQuery;
|
|
input.value = '';
|
|
input.style.height = 'auto';
|
|
sendBtn.disabled = true;
|
|
emptyEl.style.display = 'none';
|
|
|
|
maybeSetTitle(query);
|
|
addMessage('user', query);
|
|
|
|
// Live thinking panel
|
|
const thinkingEl = createThinkingPanel();
|
|
chat.appendChild(thinkingEl);
|
|
chat.scrollTop = chat.scrollHeight;
|
|
|
|
let metaData = null;
|
|
let responseText = '';
|
|
let agentBubbleEl = null;
|
|
let agentMsgEl = null;
|
|
|
|
try {
|
|
const _authToken = localStorage.getItem('gf_token') || '';
|
|
const res = await fetch('/chat/steps', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${_authToken}`
|
|
},
|
|
body: JSON.stringify({
|
|
query: finalQuery,
|
|
history,
|
|
pending_write: pendingWrite
|
|
})
|
|
});
|
|
|
|
if (res.status === 401) {
|
|
localStorage.removeItem('gf_token');
|
|
localStorage.removeItem('gf_user_name');
|
|
localStorage.removeItem('gf_user_email');
|
|
window.location.replace('/login');
|
|
return;
|
|
}
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
|
|
const reader = res.body.getReader();
|
|
const decoder = new TextDecoder();
|
|
let buffer = '';
|
|
|
|
while (true) {
|
|
const { done, value } = await reader.read();
|
|
if (done) break;
|
|
buffer += decoder.decode(value, { stream: true });
|
|
const lines = buffer.split('\n');
|
|
buffer = lines.pop();
|
|
|
|
for (const line of lines) {
|
|
if (!line.startsWith('data: ')) continue;
|
|
let evt;
|
|
try {
|
|
evt = JSON.parse(line.slice(6));
|
|
} catch {
|
|
continue;
|
|
}
|
|
|
|
if (evt.type === 'step') {
|
|
updateThinkingPanel(thinkingEl, evt);
|
|
} else if (evt.type === 'meta') {
|
|
metaData = evt;
|
|
pendingWrite = evt.pending_write || null;
|
|
updateLatency(evt.latency_seconds);
|
|
sessionStats.toolCalls += (evt.tools_used || []).length;
|
|
if (evt.latency_seconds)
|
|
sessionStats.latencies.push(evt.latency_seconds);
|
|
} else if (evt.type === 'token') {
|
|
if (!agentMsgEl) {
|
|
thinkingEl.remove();
|
|
agentMsgEl = document.createElement('div');
|
|
agentMsgEl.className = 'message agent';
|
|
agentBubbleEl = document.createElement('div');
|
|
agentBubbleEl.className = 'bubble';
|
|
agentBubbleEl.style.paddingRight = '184px'; // space for 6 buttons
|
|
agentMsgEl.appendChild(agentBubbleEl);
|
|
chat.appendChild(agentMsgEl);
|
|
}
|
|
responseText += evt.token;
|
|
agentBubbleEl.innerHTML =
|
|
renderBubble(responseText) + agentBubbleButtons('live');
|
|
chat.scrollTop = chat.scrollHeight;
|
|
} else if (evt.type === 'done') {
|
|
if (agentMsgEl && metaData) {
|
|
// Assign a real favId now that we have the full response
|
|
const realFavId = 'fav_' + Date.now();
|
|
if (agentBubbleEl) {
|
|
const btn = agentBubbleEl.querySelector('.star-btn');
|
|
if (btn) btn.setAttribute('data-fav-id', realFavId);
|
|
btn && (btn._responseText = responseText);
|
|
}
|
|
// Add reaction row + annotation wrap to streaming agent message
|
|
const streamMsgHash = 'msg_' + realFavId;
|
|
const streamReactionRow = document.createElement('div');
|
|
streamReactionRow.className = 'reaction-row';
|
|
streamReactionRow.innerHTML = `<button class="reaction-btn up" data-msg="${streamMsgHash}" onclick="reactMsg(this,'up')" title="Helpful">👍</button><button class="reaction-btn down" data-msg="${streamMsgHash}" onclick="reactMsg(this,'down')" title="Not helpful">👎</button>`;
|
|
agentMsgEl.appendChild(streamReactionRow);
|
|
const streamAnnotWrap = document.createElement('div');
|
|
streamAnnotWrap.className = 'annotation-wrap';
|
|
streamAnnotWrap.dataset.msgHash = streamMsgHash;
|
|
streamAnnotWrap.innerHTML = `<textarea class="annotation-textarea" placeholder="📝 Add a private note to this response…" onblur="saveAnnotation('${streamMsgHash}', this.value)"></textarea>`;
|
|
agentMsgEl.appendChild(streamAnnotWrap);
|
|
// Wire annotation button
|
|
const annotBtn = agentBubbleEl.querySelector('.annotation-btn');
|
|
if (annotBtn) annotBtn.setAttribute('onclick', `toggleAnnotation(this)`);
|
|
|
|
appendMessageFooter(agentMsgEl, metaData, responseText);
|
|
appendFollowupChips(agentMsgEl, metaData.tools_used || [], responseText);
|
|
updateNetworthBadge(metaData.tools_used || []);
|
|
updateContextStrip(metaData.tools_used || []);
|
|
}
|
|
// Log to request inspector
|
|
if (metaData) logRequest(query, metaData);
|
|
if (autoSpeak && responseText) speakText(responseText);
|
|
// Update goal tracker + memory if net worth mentioned
|
|
const nw = extractNetWorthFromResponse(responseText);
|
|
if (nw) { updateGoalChip(nw); updateMemoryNetWorth(nw); }
|
|
// Extract tickers from response into memory
|
|
extractTickersIntoMemory(responseText);
|
|
history.push({ role: 'user', content: query });
|
|
history.push({ role: 'assistant', content: responseText });
|
|
sessionStats.messages++;
|
|
saveSession();
|
|
saveCurrentSession();
|
|
if (typeof updateChatsBadge === 'function') updateChatsBadge();
|
|
} else if (evt.type === 'error') {
|
|
thinkingEl.remove();
|
|
addErrorMessage(evt.message, query);
|
|
}
|
|
}
|
|
}
|
|
} catch (err) {
|
|
thinkingEl.remove();
|
|
if (agentMsgEl) agentMsgEl.remove();
|
|
addErrorMessage(
|
|
'Could not reach the agent. Please try again.',
|
|
query
|
|
);
|
|
} finally {
|
|
sendBtn.disabled = false;
|
|
input.focus();
|
|
}
|
|
}
|
|
|
|
// ── Thinking panel ──
|
|
function createThinkingPanel() {
|
|
const wrap = document.createElement('div');
|
|
wrap.className = 'message agent';
|
|
const panel = document.createElement('div');
|
|
panel.className = 'thinking-panel';
|
|
panel.innerHTML = `<div class="thinking-header">Agent is thinking…</div><div class="step-list" id="step-list-${Date.now()}"></div>`;
|
|
wrap.appendChild(panel);
|
|
return wrap;
|
|
}
|
|
|
|
function updateThinkingPanel(wrapEl, evt) {
|
|
const list = wrapEl.querySelector('.step-list');
|
|
if (!list) return;
|
|
|
|
// Update header
|
|
const header = wrapEl.querySelector('.thinking-header');
|
|
if (header)
|
|
header.textContent =
|
|
evt.status === 'running' ? `${evt.label}…` : 'Agent thinking…';
|
|
|
|
let existing = list.querySelector(`[data-node="${evt.node}"]`);
|
|
if (!existing) {
|
|
existing = document.createElement('div');
|
|
existing.className = 'step-item';
|
|
existing.setAttribute('data-node', evt.node);
|
|
list.appendChild(existing);
|
|
}
|
|
|
|
const iconHtml =
|
|
evt.status === 'running'
|
|
? '<div class="step-icon running"></div>'
|
|
: '<div class="step-icon done">✓</div>';
|
|
|
|
let toolsHtml = '';
|
|
if (evt.tools && evt.tools.length) {
|
|
toolsHtml = `<span class="step-tools">${evt.tools.join(', ')}</span>`;
|
|
}
|
|
|
|
existing.innerHTML = `${iconHtml}<span class="step-label">${evt.label}</span>${toolsHtml}`;
|
|
chat.scrollTop = chat.scrollHeight;
|
|
}
|
|
|
|
// ── Render helpers ──
|
|
// Configure marked: safe, no pedantic, GFM tables
|
|
if (typeof marked !== 'undefined') {
|
|
marked.setOptions({ gfm: true, breaks: true, pedantic: false });
|
|
}
|
|
|
|
function renderBubble(text) {
|
|
const stripped = text
|
|
.replace(/\[source:[^\]]+\]/g, '')
|
|
// Strip internal backend citation IDs like [portfolio_1234], [compliance_1234], etc.
|
|
.replace(/\[(portfolio|compliance|market|property|real_estate|tax|transaction)_[^\]]+\]/g, '')
|
|
// Strip any remaining [word_digits] patterns that are clearly internal refs
|
|
.replace(/\[[a-z_]+_\d{6,}\]/g, '');
|
|
if (typeof marked !== 'undefined') {
|
|
return marked.parse(stripped);
|
|
}
|
|
// Fallback if CDN fails
|
|
const escaped = stripped
|
|
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
return escaped.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>').replace(/\n/g, '<br>');
|
|
}
|
|
|
|
function agentBubbleButtons(savedId) {
|
|
const isStarred = favorites.some(f => f.id === savedId);
|
|
return `<button class="md-copy-btn" title="Copy as Markdown" onclick="copyBubbleMd(this)">MD</button><button class="pin-bubble-btn" title="Pin to top" onclick="pinBubble(this)">📌</button><button class="annotation-btn" title="Add sticky note" onclick="toggleAnnotation(this)">📝</button><button class="star-btn${isStarred ? ' starred' : ''}" title="Save response" onclick="toggleFavorite(this)" data-fav-id="${savedId}">★</button><button class="speak-btn" title="Read aloud" onclick="speakBubble(this)">🔊</button><button class="copy-btn" title="Copy response" onclick="copyBubble(this)">⎘</button>`;
|
|
}
|
|
|
|
function copyBubble(btn) {
|
|
const bubble = btn.parentElement;
|
|
// Get clean text: strip button chars and HTML tags
|
|
const clone = bubble.cloneNode(true);
|
|
clone.querySelectorAll('button').forEach(b => b.remove());
|
|
const text = clone.innerText.trim();
|
|
navigator.clipboard.writeText(text).then(() => {
|
|
btn.classList.add('copied');
|
|
btn.textContent = '✓';
|
|
setTimeout(() => {
|
|
btn.classList.remove('copied');
|
|
btn.textContent = '⎘';
|
|
}, 1800);
|
|
});
|
|
}
|
|
|
|
// ── Text-to-speech ──
|
|
let autoSpeak = false;
|
|
function setAutoSpeak(val) {
|
|
autoSpeak = val;
|
|
const pill = document.getElementById('speak-pill-settings');
|
|
if (pill) pill.classList.toggle('on', autoSpeak);
|
|
try { localStorage.setItem('gf_autospeak', autoSpeak ? '1' : '0'); } catch {}
|
|
}
|
|
// Restore saved preference
|
|
if (localStorage.getItem('gf_autospeak') === '1') setAutoSpeak(true);
|
|
document.getElementById('speak-toggle-settings').addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
setAutoSpeak(!autoSpeak);
|
|
});
|
|
|
|
function speakText(text) {
|
|
if (!('speechSynthesis' in window)) return;
|
|
window.speechSynthesis.cancel();
|
|
const clean = text
|
|
.replace(/\[source:[^\]]+\]/g, '')
|
|
.replace(/\[(portfolio|compliance|market|property|real_estate|tax|transaction)_[^\]]+\]/g, '')
|
|
.replace(/\[[a-z_]+_\d{6,}\]/g, '')
|
|
.replace(/#{1,6}\s/g, '')
|
|
.replace(/\*\*/g, '').replace(/\*/g, '')
|
|
.replace(/`{1,3}[^`]*`{1,3}/g, '')
|
|
.replace(/\|/g, ' ')
|
|
.replace(/[-─]{3,}/g, '')
|
|
.replace(/📊|🏠|💰|🔧|✓|⎘|🔊|➤|🎙/g, '')
|
|
.replace(/\n+/g, ' ').trim();
|
|
const utt = new SpeechSynthesisUtterance(clean);
|
|
utt.rate = 1.0;
|
|
utt.pitch = 1.0;
|
|
window.speechSynthesis.speak(utt);
|
|
}
|
|
|
|
function speakBubble(btn) {
|
|
if (!('speechSynthesis' in window)) return;
|
|
if (window.speechSynthesis.speaking) {
|
|
window.speechSynthesis.cancel();
|
|
btn.classList.remove('speaking');
|
|
btn.textContent = '🔊';
|
|
return;
|
|
}
|
|
const bubble = btn.parentElement;
|
|
const clone = bubble.cloneNode(true);
|
|
clone.querySelectorAll('button').forEach(b => b.remove());
|
|
const text = clone.innerText.trim();
|
|
btn.classList.add('speaking');
|
|
btn.textContent = '⏹';
|
|
const utt = new SpeechSynthesisUtterance(text.replace(/\n+/g, ' '));
|
|
utt.onend = () => { btn.classList.remove('speaking'); btn.textContent = '🔊'; };
|
|
utt.onerror = () => { btn.classList.remove('speaking'); btn.textContent = '🔊'; };
|
|
window.speechSynthesis.speak(utt);
|
|
}
|
|
|
|
function addMessage(role, text, meta = null, restored = false) {
|
|
emptyEl.style.display = 'none';
|
|
const wrap = document.createElement('div');
|
|
wrap.className = `message ${role}`;
|
|
|
|
const bubble = document.createElement('div');
|
|
bubble.className = 'bubble';
|
|
if (role === 'agent') {
|
|
bubble.style.paddingRight = '184px'; // space for 6 buttons
|
|
const favId = 'fav_' + Date.now() + '_' + Math.random().toString(36).slice(2);
|
|
bubble.innerHTML = renderBubble(text) + agentBubbleButtons(favId);
|
|
// Reaction row + annotation wrap
|
|
const reactionRow = document.createElement('div');
|
|
reactionRow.className = 'reaction-row';
|
|
const msgHash = 'msg_' + Date.now() + '_' + Math.random().toString(36).slice(2, 6);
|
|
reactionRow.innerHTML = `<button class="reaction-btn up" data-msg="${msgHash}" onclick="reactMsg(this,'up')" title="Helpful">👍</button><button class="reaction-btn down" data-msg="${msgHash}" onclick="reactMsg(this,'down')" title="Not helpful">👎</button>`;
|
|
wrap.appendChild(bubble);
|
|
wrap.appendChild(reactionRow);
|
|
const annotWrap = document.createElement('div');
|
|
annotWrap.className = 'annotation-wrap';
|
|
annotWrap.dataset.msgHash = msgHash;
|
|
const savedNote = getAnnotation(msgHash);
|
|
annotWrap.innerHTML = `<textarea class="annotation-textarea" placeholder="📝 Add a private note to this response…" onblur="saveAnnotation('${msgHash}', this.value)">${savedNote}</textarea>`;
|
|
if (savedNote) { annotWrap.classList.add('open'); bubble.querySelector('.annotation-btn') && bubble.querySelector('.annotation-btn').classList.add('has-note'); }
|
|
wrap.appendChild(annotWrap);
|
|
} else {
|
|
bubble.innerHTML = renderBubble(text);
|
|
wrap.appendChild(bubble);
|
|
}
|
|
|
|
// Edit button under user messages
|
|
if (role === 'user') {
|
|
const editBtn = document.createElement('button');
|
|
editBtn.className = 'edit-btn';
|
|
editBtn.textContent = '✏ Edit';
|
|
editBtn.onclick = () => editMessage(wrap, text);
|
|
wrap.appendChild(editBtn);
|
|
}
|
|
|
|
if (role === 'agent' && meta) {
|
|
appendMessageFooter(wrap, meta, text);
|
|
}
|
|
|
|
chat.appendChild(wrap);
|
|
chat.scrollTop = chat.scrollHeight;
|
|
return wrap;
|
|
}
|
|
|
|
// ── Edit & resend ──
|
|
function editMessage(userMsgEl, originalText) {
|
|
// Find the index of this message in history
|
|
const allMsgs = [...chat.querySelectorAll('.message')];
|
|
const msgIndex = allMsgs.indexOf(userMsgEl);
|
|
|
|
// Remove this user bubble + the agent reply after it from DOM
|
|
const toRemove = allMsgs.slice(msgIndex);
|
|
toRemove.forEach(el => el.remove());
|
|
|
|
// Also remove follow-up chip rows that might follow
|
|
chat.querySelectorAll('.followup-row').forEach(r => r.remove());
|
|
|
|
// Trim history to just before this message
|
|
// Each visible exchange = 2 history entries (user + agent)
|
|
// Count user messages before this one
|
|
const userMsgsBefore = allMsgs.slice(0, msgIndex).filter(el => el.classList.contains('user')).length;
|
|
history = history.slice(0, userMsgsBefore * 2);
|
|
|
|
// Put text back in input
|
|
input.value = originalText;
|
|
input.style.height = 'auto';
|
|
input.style.height = Math.min(input.scrollHeight, 140) + 'px';
|
|
input.focus();
|
|
}
|
|
|
|
function appendMessageFooter(wrapEl, meta, text) {
|
|
const footer = document.createElement('div');
|
|
footer.className = 'msg-footer';
|
|
|
|
// Row 1: tool badges + verification badge
|
|
const badgeRow = document.createElement('div');
|
|
badgeRow.className = 'badge-row';
|
|
|
|
(meta.tools_used || []).forEach((t) => {
|
|
const b = document.createElement('span');
|
|
b.className = 'badge tool';
|
|
b.innerHTML = `<span>🔧</span>${t}`;
|
|
badgeRow.appendChild(b);
|
|
});
|
|
|
|
if (meta.verification_outcome) {
|
|
const isPass = meta.verification_outcome === 'pass';
|
|
const isFlag = meta.verification_outcome === 'flag';
|
|
const b = document.createElement('span');
|
|
b.className = `badge ${isPass ? 'pass' : isFlag ? 'flag' : 'fail'}`;
|
|
b.textContent = isPass
|
|
? '✓ Verified'
|
|
: isFlag
|
|
? '⚠ Flagged'
|
|
: '✕ Failed';
|
|
badgeRow.appendChild(b);
|
|
}
|
|
|
|
if (meta.latency_seconds != null) {
|
|
const b = document.createElement('span');
|
|
b.className = 'badge time';
|
|
b.textContent = `${meta.latency_seconds}s`;
|
|
badgeRow.appendChild(b);
|
|
}
|
|
|
|
footer.appendChild(badgeRow);
|
|
|
|
// Row 2: confidence bar + timestamp
|
|
if (meta.confidence_score != null) {
|
|
const confRow = document.createElement('div');
|
|
confRow.className = 'badge-row';
|
|
const pct = Math.round(meta.confidence_score * 100);
|
|
const cls =
|
|
meta.confidence_score >= 0.8
|
|
? 'high'
|
|
: meta.confidence_score >= 0.5
|
|
? 'med'
|
|
: 'low';
|
|
confRow.innerHTML = `
|
|
<div class="confidence-bar-wrap">
|
|
<span class="confidence-bar-label">Confidence</span>
|
|
<div class="confidence-bar-track">
|
|
<div class="confidence-bar-fill ${cls}" style="width:${pct}%"></div>
|
|
</div>
|
|
<span class="confidence-bar-label">${pct}%</span>
|
|
</div>
|
|
<span class="msg-ts">${formatTs()}</span>`;
|
|
footer.appendChild(confRow);
|
|
}
|
|
|
|
// Confirmation banner
|
|
if (meta.awaiting_confirmation && meta.pending_write) {
|
|
const banner = document.createElement('div');
|
|
banner.className = 'confirm-banner';
|
|
banner.textContent =
|
|
'⚠️ Awaiting your confirmation — reply yes or no.';
|
|
footer.appendChild(banner);
|
|
}
|
|
|
|
// Debug panel
|
|
const details = document.createElement('details');
|
|
details.className = 'debug-panel';
|
|
const toolList = (meta.tools_used || []).join(', ') || 'none';
|
|
const confPct =
|
|
meta.confidence_score != null
|
|
? Math.round(meta.confidence_score * 100) + '%'
|
|
: '—';
|
|
const confCls =
|
|
meta.confidence_score >= 0.8
|
|
? 'high'
|
|
: meta.confidence_score >= 0.5
|
|
? 'med'
|
|
: 'low';
|
|
const outCls =
|
|
meta.verification_outcome === 'pass'
|
|
? 'pass'
|
|
: meta.verification_outcome === 'flag'
|
|
? 'flag'
|
|
: 'fail';
|
|
details.innerHTML = `
|
|
<summary>🔍 debug</summary>
|
|
<div class="debug-body">
|
|
<div class="db-row"><span class="db-key">tools_called</span><span class="db-val">${toolList}</span></div>
|
|
<div class="db-row"><span class="db-key">verification</span><span class="db-val ${outCls}">${meta.verification_outcome || '—'}</span></div>
|
|
<div class="db-row"><span class="db-key">confidence</span><span class="db-val ${confCls}">${confPct}</span></div>
|
|
<div class="db-row"><span class="db-key">latency</span><span class="db-val">${meta.latency_seconds != null ? meta.latency_seconds + 's' : '—'}</span></div>
|
|
<div class="db-row"><span class="db-key">citations</span><span class="db-val">${(meta.citations || []).join(', ') || 'none'}</span></div>
|
|
</div>`;
|
|
footer.appendChild(details);
|
|
|
|
wrapEl.appendChild(footer);
|
|
}
|
|
|
|
function addErrorMessage(msg, origQuery) {
|
|
const wrap = document.createElement('div');
|
|
wrap.className = 'message agent';
|
|
const bubble = document.createElement('div');
|
|
bubble.className = 'bubble';
|
|
bubble.innerHTML = `❌ ${renderBubble(msg)}`;
|
|
wrap.appendChild(bubble);
|
|
|
|
const retryBtn = document.createElement('button');
|
|
retryBtn.className = 'retry-btn';
|
|
retryBtn.textContent = '↻ Retry';
|
|
retryBtn.onclick = () => {
|
|
wrap.remove();
|
|
input.value = origQuery;
|
|
send();
|
|
};
|
|
wrap.appendChild(retryBtn);
|
|
chat.appendChild(wrap);
|
|
chat.scrollTop = chat.scrollHeight;
|
|
}
|
|
|
|
// ── Latency tracker ──
|
|
function updateLatency(sec) {
|
|
if (sec == null) return;
|
|
latChip.classList.remove('hidden');
|
|
latChip.textContent = `last: ${sec}s`;
|
|
}
|
|
|
|
// ── Timestamp helper ──
|
|
function formatTs() {
|
|
const now = new Date();
|
|
return now.toLocaleTimeString([], {
|
|
hour: '2-digit',
|
|
minute: '2-digit'
|
|
});
|
|
}
|
|
|
|
// ── Voice input (Web Speech API) ──
|
|
let recognition = null;
|
|
let isListening = false;
|
|
const micBtn = document.getElementById('mic-btn');
|
|
|
|
if (!('SpeechRecognition' in window || 'webkitSpeechRecognition' in window)) {
|
|
micBtn.style.display = 'none';
|
|
} else {
|
|
const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
|
|
recognition = new SR();
|
|
recognition.lang = 'en-US';
|
|
recognition.interimResults = true;
|
|
recognition.maxAlternatives = 1;
|
|
|
|
recognition.onresult = (e) => {
|
|
const transcript = Array.from(e.results)
|
|
.map((r) => r[0].transcript)
|
|
.join('');
|
|
input.value = transcript;
|
|
input.style.height = 'auto';
|
|
input.style.height = Math.min(input.scrollHeight, 140) + 'px';
|
|
if (e.results[e.results.length - 1].isFinal) stopListening();
|
|
};
|
|
|
|
recognition.onerror = () => stopListening();
|
|
recognition.onend = () => stopListening();
|
|
|
|
micBtn.addEventListener('click', () => {
|
|
if (!recognition) return;
|
|
isListening ? recognition.stop() : startListening();
|
|
});
|
|
}
|
|
|
|
function startListening() {
|
|
isListening = true;
|
|
micBtn.classList.add('listening');
|
|
micBtn.title = 'Stop listening';
|
|
input.placeholder = 'Listening…';
|
|
recognition.start();
|
|
}
|
|
|
|
function stopListening() {
|
|
isListening = false;
|
|
micBtn.classList.remove('listening');
|
|
micBtn.title = 'Voice input';
|
|
input.placeholder = 'Ask anything about your portfolio… (type /tools to see available tools)';
|
|
}
|
|
|
|
// ── Follow-up suggestion chips (adaptive — parses response text) ──
|
|
function getFollowupChips(toolsUsed, responseText) {
|
|
const r = (responseText || '').toLowerCase();
|
|
const chips = new Set();
|
|
|
|
// ── Content-aware: scan response for signals ──
|
|
if (/down\s+\d+%|declin|fell|dropp|lower/.test(r)) {
|
|
chips.add('When is a good time to buy in this market?');
|
|
}
|
|
if (/up\s+\d+%|increas|appreciat|gain|grew/.test(r)) {
|
|
chips.add('How does this compare to inflation?');
|
|
}
|
|
if (/equity|cash[\s-]out|refinanc/.test(r)) {
|
|
chips.add('Should I do a cash-out refinance?');
|
|
}
|
|
if (/days on market|dom\b/.test(r) && /\b[5-9]\d\b|\b1\d{2,}\b/.test(r)) {
|
|
chips.add('Is this a buyer\'s or seller\'s market?');
|
|
}
|
|
if (/net worth|total worth/.test(r)) {
|
|
chips.add('What if Austin prices drop 10%? How does that affect my net worth?');
|
|
}
|
|
if (/loss|down\s+\$|negative/.test(r)) {
|
|
chips.add('What is my tax-loss harvesting opportunity?');
|
|
}
|
|
if (/rental|rent/.test(r)) {
|
|
chips.add('What\'s the rental yield on my property vs. the market average?');
|
|
}
|
|
if (/mortgage|balance/.test(r)) {
|
|
chips.add('Am I building equity faster than the market is appreciating?');
|
|
}
|
|
if (/portfolio|holding|allocation/.test(r)) {
|
|
chips.add('Am I on track to retire at 60 at this savings rate?');
|
|
}
|
|
if (/concentrated|overweight|position/.test(r)) {
|
|
chips.add('How can I rebalance to reduce concentration risk?');
|
|
}
|
|
if (/tax|capital gain/.test(r)) {
|
|
chips.add('What trades triggered the largest tax liability?');
|
|
}
|
|
if (/ytd|year.to.date|return/.test(r)) {
|
|
chips.add('How does my return compare to the S&P 500?');
|
|
}
|
|
if (/afford|price.to.income|ratio/.test(r)) {
|
|
chips.add('Which county has the best affordability right now?');
|
|
}
|
|
if (/inventory|months of/.test(r)) {
|
|
chips.add('What does high inventory mean for buyers vs. sellers?');
|
|
}
|
|
|
|
// ── Tool-based fallbacks (used when text parsing finds nothing) ──
|
|
const toolFallbacks = {
|
|
real_estate: ['Show me rental data for this area', 'Compare Austin vs Williamson County', 'What areas are most affordable?'],
|
|
property_tracker: ['Show all my properties', 'What is my total net worth?', 'Add another property'],
|
|
compliance_check: ['How can I rebalance?', 'What is my YTD return?', 'Show my biggest holdings'],
|
|
portfolio_analysis: ['Am I over-concentrated?', 'Estimate my tax liability', 'Show my recent trades'],
|
|
market_data: ['Compare this to my portfolio performance', 'What is my YTD return?', 'Show me SPY price'],
|
|
market_overview: ['What sectors are outperforming?', 'How is this affecting my portfolio?', 'Show me my biggest holding'],
|
|
tax_estimate: ['Show my capital gains breakdown', 'What trades triggered this?', 'Run compliance check'],
|
|
write_transaction: ['Show my updated portfolio', 'What is my new allocation?', 'Check compliance after this trade'],
|
|
transaction_query: ['What is my total return on these?', 'Show me my portfolio summary', 'Am I over-concentrated?']
|
|
};
|
|
|
|
// Fill from content-aware chips first, then pad with tool fallbacks
|
|
const result = [...chips];
|
|
if (result.length < 3) {
|
|
for (const tool of toolsUsed) {
|
|
for (const chip of (toolFallbacks[tool] || [])) {
|
|
if (!result.includes(chip)) result.push(chip);
|
|
if (result.length >= 3) break;
|
|
}
|
|
if (result.length >= 3) break;
|
|
}
|
|
}
|
|
return result.slice(0, 3);
|
|
}
|
|
|
|
function appendFollowupChips(parentEl, toolsUsed, responseText) {
|
|
const chips = getFollowupChips(toolsUsed, responseText);
|
|
if (!chips.length) return;
|
|
const row = document.createElement('div');
|
|
row.className = 'followup-row';
|
|
chips.forEach((text) => {
|
|
const btn = document.createElement('button');
|
|
btn.className = 'followup-chip';
|
|
btn.textContent = text;
|
|
btn.onclick = () => {
|
|
row.remove();
|
|
input.value = text;
|
|
send();
|
|
};
|
|
row.appendChild(btn);
|
|
});
|
|
parentEl.appendChild(row);
|
|
}
|
|
|
|
// ── Net worth banner ──
|
|
function updateNetworthBadge(toolsUsed) {
|
|
const badge = document.getElementById('networth-badge');
|
|
if (toolsUsed.includes('property_tracker')) {
|
|
badge.style.display = 'flex';
|
|
}
|
|
}
|
|
|
|
// ── Download conversation as .txt ──
|
|
function downloadConversation() {
|
|
if (history.length === 0) { showToast('No conversation to export yet.'); return; }
|
|
const lines = [];
|
|
const ts = new Date().toLocaleString();
|
|
lines.push('Ghostfolio AI Agent — Conversation Export');
|
|
lines.push(`Exported: ${ts}`);
|
|
lines.push('─'.repeat(60));
|
|
lines.push('');
|
|
for (const msg of history) {
|
|
lines.push(msg.role === 'user' ? '👤 You:' : '🤖 Agent:');
|
|
lines.push(msg.content
|
|
.replace(/\[(portfolio|compliance|market|property|real_estate|tax|transaction)_[^\]]+\]/g, '')
|
|
.replace(/\[[a-z_]+_\d{6,}\]/g, ''));
|
|
lines.push('');
|
|
}
|
|
const blob = new Blob([lines.join('\n')], { type: 'text/plain' });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = `ghostfolio-chat-${Date.now()}.txt`;
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
|
|
function showToast(msg) {
|
|
toastEl.textContent = msg;
|
|
toastEl.classList.add('show');
|
|
setTimeout(() => toastEl.classList.remove('show'), 2500);
|
|
}
|
|
|
|
// ── Keyboard shortcuts ──
|
|
document.addEventListener('keydown', (e) => {
|
|
const meta = e.metaKey || e.ctrlKey;
|
|
|
|
// Cmd+K → focus input
|
|
if (meta && e.key === 'k') {
|
|
e.preventDefault();
|
|
input.focus();
|
|
return;
|
|
}
|
|
// Cmd+Shift+E → export conversation as .txt
|
|
if (meta && e.shiftKey && e.key === 'e') {
|
|
e.preventDefault();
|
|
downloadConversation();
|
|
return;
|
|
}
|
|
// Cmd+/ → /tools panel
|
|
if (meta && e.key === '/') {
|
|
e.preventDefault();
|
|
input.value = '/tools';
|
|
send();
|
|
return;
|
|
}
|
|
// ↑ arrow when input is empty → restore last sent message
|
|
if (e.key === 'ArrowUp' && document.activeElement === input && input.value === '') {
|
|
e.preventDefault();
|
|
const lastUser = [...history].reverse().find(m => m.role === 'user');
|
|
if (lastUser) {
|
|
input.value = lastUser.content;
|
|
input.style.height = 'auto';
|
|
input.style.height = Math.min(input.scrollHeight, 140) + 'px';
|
|
}
|
|
return;
|
|
}
|
|
// Escape → stop mic / clear input
|
|
if (e.key === 'Escape') {
|
|
if (isListening && recognition) recognition.stop();
|
|
if (input.value) { input.value = ''; input.style.height = 'auto'; }
|
|
if (window.speechSynthesis && window.speechSynthesis.speaking) {
|
|
window.speechSynthesis.cancel();
|
|
}
|
|
}
|
|
});
|
|
|
|
// ── Session history (multi-session localStorage) ──
|
|
const SESSIONS_KEY = 'gf_sessions_v1';
|
|
const MAX_SESSIONS = 15;
|
|
let currentSessionId = Date.now().toString();
|
|
let currentSessionTitle = null;
|
|
|
|
function getSessions() {
|
|
try { return JSON.parse(localStorage.getItem(SESSIONS_KEY)) || []; } catch { return []; }
|
|
}
|
|
|
|
function saveSessions(sessions) {
|
|
try { localStorage.setItem(SESSIONS_KEY, JSON.stringify(sessions)); } catch {}
|
|
}
|
|
|
|
function saveCurrentSession() {
|
|
if (history.length === 0) return;
|
|
const title = currentSessionTitle || (history[0] && history[0].content.slice(0, 45)) || 'Untitled';
|
|
const sessions = getSessions().filter(s => s.id !== currentSessionId);
|
|
sessions.unshift({
|
|
id: currentSessionId,
|
|
title,
|
|
date: new Date().toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }),
|
|
messages: history.slice()
|
|
});
|
|
saveSessions(sessions.slice(0, MAX_SESSIONS));
|
|
}
|
|
|
|
function startNewChat() {
|
|
saveCurrentSession();
|
|
currentSessionId = Date.now().toString();
|
|
currentSessionTitle = null;
|
|
history = [];
|
|
pendingWrite = null;
|
|
sessionStats = { messages: 0, toolCalls: 0, latencies: [] };
|
|
localStorage.removeItem(STORAGE_KEY);
|
|
chat.innerHTML = '';
|
|
chat.appendChild(emptyEl);
|
|
emptyEl.style.display = '';
|
|
latChip.classList.add('hidden');
|
|
document.title = 'Ghostfolio AI Agent';
|
|
document.getElementById('networth-badge').style.display = 'none';
|
|
resetContextStrip();
|
|
if (typeof updateHeaderTitle === 'function') updateHeaderTitle();
|
|
if (typeof updateChatsBadge === 'function') updateChatsBadge();
|
|
closeDrawer();
|
|
}
|
|
|
|
function loadSession(sess) {
|
|
saveCurrentSession();
|
|
currentSessionId = sess.id;
|
|
currentSessionTitle = sess.title;
|
|
history = sess.messages.slice();
|
|
pendingWrite = null;
|
|
sessionStats = { messages: 0, toolCalls: 0, latencies: [] };
|
|
chat.innerHTML = '';
|
|
chat.appendChild(emptyEl);
|
|
emptyEl.style.display = 'none';
|
|
const notice = document.createElement('div');
|
|
notice.className = 'session-restored';
|
|
notice.textContent = `↑ Restored: ${sess.title}`;
|
|
chat.appendChild(notice);
|
|
for (let i = 0; i < history.length; i += 2) {
|
|
if (history[i]) addMessage('user', history[i].content, null, true);
|
|
if (history[i + 1]) addMessage('agent', history[i + 1].content, null, true);
|
|
}
|
|
document.title = sess.title + ' — Ghostfolio';
|
|
if (typeof updateHeaderTitle === 'function') updateHeaderTitle();
|
|
closeDrawer();
|
|
}
|
|
|
|
function deleteSession(id, e) {
|
|
e.stopPropagation();
|
|
const sessions = getSessions().filter(s => s.id !== id);
|
|
saveSessions(sessions);
|
|
renderDrawer();
|
|
}
|
|
|
|
function renderDrawer() {
|
|
const list = document.getElementById('session-list');
|
|
const sessions = getSessions();
|
|
if (sessions.length === 0) {
|
|
list.innerHTML = '<div class="drawer-empty">No saved chats yet.<br>Start chatting and your<br>sessions will appear here.</div>';
|
|
return;
|
|
}
|
|
list.innerHTML = sessions.map(s => `
|
|
<div class="session-item${s.id === currentSessionId ? ' active' : ''}" onclick="loadSession(${JSON.stringify(s)})">
|
|
<span class="session-item-actions">
|
|
<button class="session-del-btn" onclick="deleteSession('${s.id}', event)" title="Delete">✕</button>
|
|
</span>
|
|
<div class="session-title">${escapeHtml(s.title)}</div>
|
|
<div class="session-meta">${s.date} · ${Math.floor(s.messages.length / 2)} msg${s.messages.length !== 2 ? 's' : ''}</div>
|
|
</div>`).join('');
|
|
}
|
|
|
|
function escapeHtml(t) {
|
|
return t.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
|
}
|
|
|
|
function openDrawer() {
|
|
renderDrawer();
|
|
document.getElementById('sessions-drawer').classList.add('open');
|
|
document.getElementById('drawer-overlay').classList.add('open');
|
|
}
|
|
function closeDrawer() {
|
|
document.getElementById('sessions-drawer').classList.remove('open');
|
|
document.getElementById('drawer-overlay').classList.remove('open');
|
|
}
|
|
|
|
document.getElementById('chats-btn').addEventListener('click', openDrawer);
|
|
document.getElementById('drawer-close').addEventListener('click', closeDrawer);
|
|
document.getElementById('drawer-overlay').addEventListener('click', closeDrawer);
|
|
document.getElementById('drawer-new-btn').addEventListener('click', startNewChat);
|
|
|
|
// Auto-set session title from first user message
|
|
function maybeSetTitle(userMsg) {
|
|
if (currentSessionTitle) return;
|
|
currentSessionTitle = (typeof smartTitle === 'function') ? smartTitle(userMsg) : (userMsg.slice(0, 50) + (userMsg.length > 50 ? '…' : ''));
|
|
document.title = currentSessionTitle + ' — Ghostfolio';
|
|
if (typeof updateHeaderTitle === 'function') updateHeaderTitle();
|
|
}
|
|
|
|
// ── Pinned context strip ──
|
|
const ctxTags = {
|
|
portfolio: document.getElementById('ctx-portfolio'),
|
|
realestate: document.getElementById('ctx-realestate'),
|
|
property: document.getElementById('ctx-property'),
|
|
market: document.getElementById('ctx-market'),
|
|
compliance: document.getElementById('ctx-compliance'),
|
|
tax: document.getElementById('ctx-tax')
|
|
};
|
|
const contextStrip = document.getElementById('context-strip');
|
|
|
|
const TOOL_TO_CTX = {
|
|
portfolio_analysis: 'portfolio',
|
|
transaction_query: 'portfolio',
|
|
write_transaction: 'portfolio',
|
|
transaction_categorize: 'portfolio',
|
|
real_estate: 'realestate',
|
|
property_tracker: 'property',
|
|
market_data: 'market',
|
|
market_overview: 'market',
|
|
compliance_check: 'compliance',
|
|
tax_estimate: 'tax'
|
|
};
|
|
|
|
function updateContextStrip(toolsUsed) {
|
|
let anyActive = false;
|
|
toolsUsed.forEach(tool => {
|
|
const ctxKey = TOOL_TO_CTX[tool];
|
|
if (ctxKey && ctxTags[ctxKey]) {
|
|
ctxTags[ctxKey].classList.add('active');
|
|
if (ctxKey === 'property') ctxTags[ctxKey].classList.add('green');
|
|
anyActive = true;
|
|
}
|
|
});
|
|
if (anyActive) contextStrip.classList.add('visible');
|
|
// Update context-aware input placeholder
|
|
if (typeof updatePlaceholder === 'function') updatePlaceholder(toolsUsed);
|
|
// Check county alert
|
|
if (typeof checkCountyAlert === 'function') checkCountyAlert();
|
|
}
|
|
|
|
function resetContextStrip() {
|
|
Object.values(ctxTags).forEach(t => {
|
|
t.classList.remove('active', 'green');
|
|
});
|
|
contextStrip.classList.remove('visible');
|
|
}
|
|
|
|
// ── Conversation search ──
|
|
const searchBar = document.getElementById('search-bar');
|
|
const searchInput = document.getElementById('search-input');
|
|
const searchCount = document.getElementById('search-count');
|
|
let searchMatches = [];
|
|
let searchIdx = 0;
|
|
|
|
document.addEventListener('keydown', (e) => {
|
|
if ((e.metaKey || e.ctrlKey) && e.key === 'f') {
|
|
e.preventDefault();
|
|
openSearch();
|
|
}
|
|
});
|
|
|
|
function openSearch() {
|
|
searchBar.classList.add('open');
|
|
searchInput.focus();
|
|
searchInput.select();
|
|
}
|
|
|
|
function closeSearch() {
|
|
searchBar.classList.remove('open');
|
|
clearSearchHighlights();
|
|
searchMatches = [];
|
|
searchCount.textContent = '';
|
|
searchInput.value = '';
|
|
}
|
|
|
|
function clearSearchHighlights() {
|
|
chat.querySelectorAll('mark').forEach(m => {
|
|
const parent = m.parentNode;
|
|
parent.replaceChild(document.createTextNode(m.textContent), m);
|
|
parent.normalize();
|
|
});
|
|
}
|
|
|
|
function runSearch(query) {
|
|
clearSearchHighlights();
|
|
searchMatches = [];
|
|
searchIdx = 0;
|
|
if (!query.trim()) { searchCount.textContent = ''; return; }
|
|
|
|
const bubbles = [...chat.querySelectorAll('.bubble')];
|
|
const esc = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
const re = new RegExp(esc, 'gi');
|
|
|
|
bubbles.forEach(bubble => {
|
|
const walker = document.createTreeWalker(bubble, NodeFilter.SHOW_TEXT);
|
|
const nodes = [];
|
|
let n;
|
|
while ((n = walker.nextNode())) nodes.push(n);
|
|
nodes.forEach(node => {
|
|
if (!node.textContent.match(re)) return;
|
|
const frag = document.createDocumentFragment();
|
|
let last = 0;
|
|
let m;
|
|
re.lastIndex = 0;
|
|
while ((m = re.exec(node.textContent)) !== null) {
|
|
frag.appendChild(document.createTextNode(node.textContent.slice(last, m.index)));
|
|
const mark = document.createElement('mark');
|
|
mark.textContent = m[0];
|
|
frag.appendChild(mark);
|
|
searchMatches.push(mark);
|
|
last = re.lastIndex;
|
|
}
|
|
frag.appendChild(document.createTextNode(node.textContent.slice(last)));
|
|
node.parentNode.replaceChild(frag, node);
|
|
});
|
|
});
|
|
highlightCurrent();
|
|
searchCount.textContent = searchMatches.length ? `${searchIdx + 1} / ${searchMatches.length}` : 'No results';
|
|
}
|
|
|
|
function highlightCurrent() {
|
|
searchMatches.forEach((m, i) => m.classList.toggle('current', i === searchIdx));
|
|
if (searchMatches[searchIdx]) {
|
|
searchMatches[searchIdx].scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
}
|
|
searchCount.textContent = searchMatches.length ? `${searchIdx + 1} / ${searchMatches.length}` : 'No results';
|
|
}
|
|
|
|
searchInput.addEventListener('input', () => runSearch(searchInput.value));
|
|
searchInput.addEventListener('keydown', e => {
|
|
if (e.key === 'Enter') { e.preventDefault(); navigateSearch(e.shiftKey ? -1 : 1); }
|
|
if (e.key === 'Escape') closeSearch();
|
|
});
|
|
document.getElementById('search-prev').addEventListener('click', () => navigateSearch(-1));
|
|
document.getElementById('search-next').addEventListener('click', () => navigateSearch(1));
|
|
document.getElementById('search-close').addEventListener('click', closeSearch);
|
|
|
|
function navigateSearch(dir) {
|
|
if (!searchMatches.length) return;
|
|
searchIdx = (searchIdx + dir + searchMatches.length) % searchMatches.length;
|
|
highlightCurrent();
|
|
}
|
|
|
|
// ── Attachment button ──
|
|
let attachedFileContent = null;
|
|
let attachedFileName = null;
|
|
|
|
const attachBtn = document.getElementById('attach-btn');
|
|
const attachMenu = document.getElementById('attach-menu');
|
|
const attachPreview = document.getElementById('attach-preview');
|
|
const attachPreviewName = document.getElementById('attach-preview-name');
|
|
const attachFileInput = document.getElementById('attach-file-input');
|
|
const attachPhotoInput = document.getElementById('attach-photo-input');
|
|
|
|
attachBtn.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
attachMenu.classList.toggle('open');
|
|
});
|
|
document.addEventListener('click', () => attachMenu.classList.remove('open'));
|
|
|
|
document.getElementById('attach-file-btn').addEventListener('click', () => {
|
|
attachFileInput.click();
|
|
attachMenu.classList.remove('open');
|
|
});
|
|
document.getElementById('attach-photo-btn').addEventListener('click', () => {
|
|
attachPhotoInput.click();
|
|
attachMenu.classList.remove('open');
|
|
});
|
|
|
|
attachFileInput.addEventListener('change', (e) => {
|
|
const file = e.target.files[0];
|
|
if (!file) return;
|
|
const reader = new FileReader();
|
|
reader.onload = (ev) => {
|
|
attachedFileContent = ev.target.result;
|
|
attachedFileName = file.name;
|
|
attachPreviewName.textContent = `📎 ${file.name} (${Math.round(file.size / 1024)}KB)`;
|
|
attachPreview.classList.add('visible');
|
|
};
|
|
reader.readAsText(file);
|
|
attachFileInput.value = '';
|
|
});
|
|
|
|
attachPhotoInput.addEventListener('change', (e) => {
|
|
const file = e.target.files[0];
|
|
if (!file) return;
|
|
attachedFileName = file.name;
|
|
attachedFileContent = `[Image attached: ${file.name}]`;
|
|
attachPreviewName.textContent = `📷 ${file.name}`;
|
|
attachPreview.classList.add('visible');
|
|
attachPhotoInput.value = '';
|
|
});
|
|
|
|
document.getElementById('attach-remove').addEventListener('click', () => {
|
|
attachedFileContent = null;
|
|
attachedFileName = null;
|
|
attachPreview.classList.remove('visible');
|
|
});
|
|
|
|
// ── Settings dropdown ──
|
|
const settingsBtn = document.getElementById('settings-btn');
|
|
const settingsMenu = document.getElementById('settings-menu');
|
|
settingsBtn.addEventListener('click', e => { e.stopPropagation(); settingsMenu.classList.toggle('open'); });
|
|
document.addEventListener('click', () => settingsMenu.classList.remove('open'));
|
|
|
|
// ── Keyboard shortcut modal ──
|
|
function openShortcutModal() { document.getElementById('shortcut-modal').classList.add('open'); }
|
|
function closeShortcutModal() { document.getElementById('shortcut-modal').classList.remove('open'); }
|
|
document.getElementById('shortcuts-btn').addEventListener('click', () => { settingsMenu.classList.remove('open'); openShortcutModal(); });
|
|
document.getElementById('shortcut-modal').addEventListener('click', e => { if (e.target === e.currentTarget) closeShortcutModal(); });
|
|
// Cmd+? shortcut
|
|
document.addEventListener('keydown', e => {
|
|
if ((e.metaKey || e.ctrlKey) && e.key === '?') { e.preventDefault(); openShortcutModal(); }
|
|
});
|
|
|
|
// ── Dark / light theme ──
|
|
let currentTheme = localStorage.getItem('gf_theme') || 'dark';
|
|
function applyTheme(t) {
|
|
currentTheme = t;
|
|
document.documentElement.setAttribute('data-theme', t === 'light' ? 'light' : '');
|
|
document.getElementById('theme-pill').classList.toggle('on', t === 'light');
|
|
localStorage.setItem('gf_theme', t);
|
|
}
|
|
applyTheme(currentTheme);
|
|
document.getElementById('theme-toggle-btn').addEventListener('click', () => {
|
|
applyTheme(currentTheme === 'dark' ? 'light' : 'dark');
|
|
});
|
|
|
|
// ── Scenario mode ──
|
|
let scenarioMode = localStorage.getItem('gf_scenario') === '1';
|
|
function applyScenarioMode(on) {
|
|
scenarioMode = on;
|
|
document.getElementById('scenario-pill').classList.toggle('on', on);
|
|
document.getElementById('scenario-badge').classList.toggle('active', on);
|
|
localStorage.setItem('gf_scenario', on ? '1' : '0');
|
|
input.placeholder = on
|
|
? 'Ask a "what if" question… e.g. "What if Austin prices drop 15%?"'
|
|
: 'Ask anything about your portfolio… (type /tools to see available tools)';
|
|
}
|
|
applyScenarioMode(scenarioMode);
|
|
document.getElementById('scenario-toggle-btn').addEventListener('click', () => applyScenarioMode(!scenarioMode));
|
|
document.getElementById('scenario-badge').addEventListener('click', () => applyScenarioMode(false));
|
|
|
|
// ── Response length toggle ──
|
|
let responseLength = 'normal';
|
|
const LENGTH_PREFIXES = {
|
|
brief: 'Please respond very concisely — use bullet points and keep the answer under 5 sentences. ',
|
|
normal: '',
|
|
detailed: 'Please give a thorough, detailed response with explanations and context. '
|
|
};
|
|
function setLength(mode) {
|
|
responseLength = mode;
|
|
['brief','normal','detailed'].forEach(m => {
|
|
document.getElementById(`len-${m}`).classList.toggle('active', m === mode);
|
|
});
|
|
}
|
|
|
|
// ── TL;DR session summary ──
|
|
document.getElementById('tldr-btn').addEventListener('click', () => {
|
|
settingsMenu.classList.remove('open');
|
|
if (history.length < 2) { showToast('Nothing to summarize yet.'); return; }
|
|
const ctx = history.slice(-10).map(m => `${m.role}: ${m.content.slice(0, 300)}`).join('\n');
|
|
input.value = `Summarize this conversation in 5 concise bullet points:\n\n${ctx}`;
|
|
send();
|
|
});
|
|
|
|
// ── PDF / print export ──
|
|
document.getElementById('print-btn').addEventListener('click', () => {
|
|
settingsMenu.classList.remove('open');
|
|
window.print();
|
|
});
|
|
|
|
// ── Goal tracker ──
|
|
let goalTarget = parseFloat(localStorage.getItem('gf_goal_target')) || 0;
|
|
let goalYear = parseInt(localStorage.getItem('gf_goal_year')) || 0;
|
|
|
|
function openGoalModal() {
|
|
settingsMenu.classList.remove('open');
|
|
document.getElementById('goal-target-input').value = goalTarget || '';
|
|
document.getElementById('goal-year-input').value = goalYear || '';
|
|
document.getElementById('goal-modal').classList.add('open');
|
|
}
|
|
function closeGoalModal() { document.getElementById('goal-modal').classList.remove('open'); }
|
|
function saveGoal() {
|
|
const t = parseFloat(document.getElementById('goal-target-input').value);
|
|
const y = parseInt(document.getElementById('goal-year-input').value);
|
|
if (!t || !y) { showToast('Please enter both a target and year.'); return; }
|
|
goalTarget = t; goalYear = y;
|
|
localStorage.setItem('gf_goal_target', t);
|
|
localStorage.setItem('gf_goal_year', y);
|
|
closeGoalModal();
|
|
updateGoalChip(null);
|
|
showToast(`Goal set: $${t.toLocaleString()} by ${y}`);
|
|
}
|
|
document.getElementById('set-goal-btn').addEventListener('click', openGoalModal);
|
|
document.getElementById('goal-modal').addEventListener('click', e => { if (e.target === e.currentTarget) closeGoalModal(); });
|
|
document.getElementById('goal-chip').addEventListener('click', openGoalModal);
|
|
|
|
function updateGoalChip(netWorth) {
|
|
if (!goalTarget) return;
|
|
const chip = document.getElementById('goal-chip');
|
|
chip.classList.add('visible');
|
|
const pct = netWorth ? Math.min(100, Math.round((netWorth / goalTarget) * 100)) : 0;
|
|
document.getElementById('goal-bar').style.width = pct + '%';
|
|
document.getElementById('goal-pct').textContent = pct + '%';
|
|
document.getElementById('goal-label').textContent = `$${(goalTarget/1000).toFixed(0)}k by ${goalYear}`;
|
|
if (netWorth) document.getElementById('goal-bar').style.background = pct >= 100 ? 'var(--green)' : pct >= 70 ? 'var(--yellow)' : 'var(--indigo)';
|
|
}
|
|
if (goalTarget) updateGoalChip(null);
|
|
|
|
function extractNetWorthFromResponse(text) {
|
|
const m = text.match(/(?:net worth|total worth)[^$]*\$\s*([\d,]+)/i);
|
|
if (m) return parseFloat(m[1].replace(/,/g, ''));
|
|
return null;
|
|
}
|
|
|
|
// ── Favorite / star responses ──
|
|
const FAV_KEY = 'gf_favorites_v1';
|
|
let favorites = (() => { try { return JSON.parse(localStorage.getItem(FAV_KEY)) || []; } catch { return []; } })();
|
|
|
|
function saveFavorites() { try { localStorage.setItem(FAV_KEY, JSON.stringify(favorites.slice(0, 50))); } catch {} }
|
|
|
|
function toggleFavorite(btn) {
|
|
const id = btn.getAttribute('data-fav-id');
|
|
const bubble = btn.parentElement;
|
|
const clone = bubble.cloneNode(true);
|
|
clone.querySelectorAll('button').forEach(b => b.remove());
|
|
const text = clone.innerText.trim();
|
|
const existing = favorites.findIndex(f => f.id === id);
|
|
if (existing >= 0) {
|
|
favorites.splice(existing, 1);
|
|
btn.classList.remove('starred');
|
|
showToast('Removed from saved.');
|
|
} else {
|
|
favorites.unshift({ id, text, date: new Date().toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }) });
|
|
btn.classList.add('starred');
|
|
showToast('⭐ Saved!');
|
|
}
|
|
saveFavorites();
|
|
}
|
|
|
|
function renderSavedList() {
|
|
const list = document.getElementById('saved-list');
|
|
if (!favorites.length) {
|
|
list.innerHTML = '<div class="drawer-empty">No saved responses yet.<br>Click ★ on any agent reply<br>to save it here.</div>';
|
|
return;
|
|
}
|
|
list.innerHTML = favorites.map(f => `
|
|
<div class="saved-item">
|
|
<div class="saved-item-text">${escapeHtml(f.text)}</div>
|
|
<div class="saved-item-meta">
|
|
<span>${f.date}</span>
|
|
<button class="saved-del-btn" onclick="deleteFavorite('${f.id}')">✕ Remove</button>
|
|
</div>
|
|
</div>`).join('');
|
|
}
|
|
|
|
function deleteFavorite(id) {
|
|
favorites = favorites.filter(f => f.id !== id);
|
|
saveFavorites();
|
|
renderSavedList();
|
|
// Unstar any matching bubble
|
|
const btn = document.querySelector(`.star-btn[data-fav-id="${id}"]`);
|
|
if (btn) btn.classList.remove('starred');
|
|
}
|
|
|
|
function switchDrawerTab(tab) {
|
|
const isChats = tab === 'chats';
|
|
document.getElementById('tab-chats').classList.toggle('active', isChats);
|
|
document.getElementById('tab-saved').classList.toggle('active', !isChats);
|
|
document.getElementById('session-list').style.display = isChats ? '' : 'none';
|
|
document.getElementById('saved-list').style.display = isChats ? 'none' : '';
|
|
document.getElementById('drawer-new-btn').style.display = isChats ? '' : 'none';
|
|
if (!isChats) renderSavedList();
|
|
}
|
|
|
|
// ── Input templates (triggered by ~ as first char) ──
|
|
const templatePicker = document.getElementById('template-picker');
|
|
input.addEventListener('keyup', e => {
|
|
if (input.value === '~') {
|
|
templatePicker.classList.add('open');
|
|
} else {
|
|
templatePicker.classList.remove('open');
|
|
}
|
|
});
|
|
document.addEventListener('click', e => {
|
|
if (!templatePicker.contains(e.target) && e.target !== input) {
|
|
templatePicker.classList.remove('open');
|
|
}
|
|
});
|
|
|
|
function useTemplate(tmpl) {
|
|
templatePicker.classList.remove('open');
|
|
input.value = tmpl;
|
|
input.style.height = 'auto';
|
|
input.style.height = Math.min(input.scrollHeight, 140) + 'px';
|
|
input.focus();
|
|
// Place cursor at first [bracket]
|
|
const idx = tmpl.indexOf('[');
|
|
if (idx >= 0) input.setSelectionRange(idx, tmpl.indexOf(']') + 1);
|
|
}
|
|
|
|
// ── Proactive greeting banner ──
|
|
(function checkGreeting() {
|
|
const lastQueryTs = parseInt(localStorage.getItem('gf_last_query_ts')) || 0;
|
|
const daysSince = (Date.now() - lastQueryTs) / (1000 * 60 * 60 * 24);
|
|
const banner = document.getElementById('greeting-banner');
|
|
const userName = localStorage.getItem('gf_user_name') || 'Investor';
|
|
const hour = new Date().getHours();
|
|
const tod = hour < 12 ? 'morning' : hour < 17 ? 'afternoon' : 'evening';
|
|
|
|
if (!lastQueryTs || daysSince > 2) {
|
|
document.getElementById('greeting-text').textContent =
|
|
`Good ${tod}, ${userName}!`;
|
|
document.getElementById('greeting-sub').textContent = lastQueryTs
|
|
? `It's been ${Math.round(daysSince)} days since your last check-in. Want a quick portfolio summary?`
|
|
: `Welcome! You can start with a portfolio summary, real estate data, or just type a question.`;
|
|
banner.classList.add('show');
|
|
document.getElementById('greeting-action').onclick = () => {
|
|
banner.classList.remove('show');
|
|
input.value = 'Give me a full portfolio summary and highlight anything important';
|
|
send();
|
|
};
|
|
document.getElementById('greeting-dismiss').onclick = () => banner.classList.remove('show');
|
|
}
|
|
})();
|
|
|
|
// ── Editable session title in header ──
|
|
const headerSubtitle = document.getElementById('header-subtitle');
|
|
const headerSubtitleText = document.getElementById('header-subtitle-text');
|
|
|
|
function updateHeaderTitle() {
|
|
headerSubtitleText.textContent = currentSessionTitle || 'Powered by Claude + LangGraph';
|
|
}
|
|
|
|
headerSubtitle.addEventListener('click', () => {
|
|
if (!currentSessionTitle) return; // nothing to rename before first message
|
|
startInlineHeaderRename();
|
|
});
|
|
|
|
function startInlineHeaderRename() {
|
|
const current = headerSubtitleText.textContent;
|
|
const inp = document.createElement('input');
|
|
inp.className = 'session-title-input';
|
|
inp.value = current;
|
|
headerSubtitle.style.display = 'none';
|
|
headerSubtitle.parentElement.appendChild(inp);
|
|
inp.focus();
|
|
inp.select();
|
|
const finish = () => {
|
|
const val = inp.value.trim();
|
|
if (val) {
|
|
currentSessionTitle = val;
|
|
document.title = val + ' — Ghostfolio';
|
|
headerSubtitleText.textContent = val;
|
|
// Update in sessions store
|
|
const sessions = getSessions();
|
|
const idx = sessions.findIndex(s => s.id === currentSessionId);
|
|
if (idx >= 0) { sessions[idx].title = val; saveSessions(sessions); }
|
|
}
|
|
inp.remove();
|
|
headerSubtitle.style.display = '';
|
|
};
|
|
inp.addEventListener('blur', finish);
|
|
inp.addEventListener('keydown', e => {
|
|
if (e.key === 'Enter') { e.preventDefault(); inp.blur(); }
|
|
if (e.key === 'Escape') { inp.value = current; inp.blur(); }
|
|
});
|
|
}
|
|
|
|
// ── Archive storage ──
|
|
const ARCHIVE_KEY = 'gf_archived_v1';
|
|
function getArchived() { try { return JSON.parse(localStorage.getItem(ARCHIVE_KEY)) || []; } catch { return []; } }
|
|
function saveArchived(a) { try { localStorage.setItem(ARCHIVE_KEY, JSON.stringify(a)); } catch {} }
|
|
|
|
function archiveSession(id) {
|
|
const sessions = getSessions();
|
|
const idx = sessions.findIndex(s => s.id === id);
|
|
if (idx < 0) return;
|
|
const sess = sessions.splice(idx, 1)[0];
|
|
saveSessions(sessions);
|
|
const archived = getArchived();
|
|
archived.unshift(sess);
|
|
saveArchived(archived);
|
|
renderDrawer();
|
|
updateChatsBadge();
|
|
}
|
|
|
|
function unarchiveSession(id) {
|
|
const archived = getArchived().filter(s => s.id !== id);
|
|
saveArchived(archived.filter(s => s.id !== id));
|
|
const sess = getArchived().find(s => s.id === id) || archived.find(s => s.id === id);
|
|
// Re-add to sessions
|
|
const all = getArchived();
|
|
const item = getArchived().find(s => s.id === id);
|
|
// Simpler: rebuild
|
|
const fullList = JSON.parse(localStorage.getItem(ARCHIVE_KEY) || '[]');
|
|
const toRestore = fullList.find(s => s.id === id);
|
|
const newArchive = fullList.filter(s => s.id !== id);
|
|
saveArchived(newArchive);
|
|
if (toRestore) {
|
|
const sessions = getSessions();
|
|
sessions.unshift(toRestore);
|
|
saveSessions(sessions);
|
|
}
|
|
renderDrawer();
|
|
renderArchivedList();
|
|
updateChatsBadge();
|
|
}
|
|
|
|
// ── Session count badge ──
|
|
function updateChatsBadge() {
|
|
const count = getSessions().length;
|
|
const badge = document.getElementById('chats-count-badge');
|
|
if (count > 0) { badge.textContent = count; badge.style.display = 'inline'; }
|
|
else badge.style.display = 'none';
|
|
}
|
|
updateChatsBadge();
|
|
|
|
// ── Rename session inline in drawer ──
|
|
function startDrawerRename(id, titleEl) {
|
|
const current = titleEl.textContent;
|
|
const inp = document.createElement('input');
|
|
inp.className = 'session-title-input';
|
|
inp.value = current;
|
|
inp.style.width = '100%';
|
|
titleEl.style.display = 'none';
|
|
titleEl.parentElement.appendChild(inp);
|
|
inp.focus(); inp.select();
|
|
const finish = () => {
|
|
const val = inp.value.trim();
|
|
if (val) {
|
|
const sessions = getSessions();
|
|
const s = sessions.find(s => s.id === id);
|
|
if (s) { s.title = val; saveSessions(sessions); }
|
|
if (id === currentSessionId) {
|
|
currentSessionTitle = val;
|
|
document.title = val + ' — Ghostfolio';
|
|
updateHeaderTitle();
|
|
}
|
|
}
|
|
inp.remove();
|
|
titleEl.style.display = '';
|
|
renderDrawer();
|
|
};
|
|
inp.addEventListener('blur', finish);
|
|
inp.addEventListener('keydown', e => {
|
|
if (e.key === 'Enter') { e.preventDefault(); inp.blur(); }
|
|
if (e.key === 'Escape') { inp.value = current; inp.blur(); }
|
|
});
|
|
}
|
|
|
|
// ── Per-session context menu ──
|
|
let openCtxMenu = null;
|
|
function toggleCtxMenu(id, btnEl, e) {
|
|
e.stopPropagation();
|
|
// Close any open menu
|
|
if (openCtxMenu) { openCtxMenu.classList.remove('open'); openCtxMenu = null; return; }
|
|
const menu = btnEl.nextElementSibling;
|
|
if (!menu) return;
|
|
menu.classList.add('open');
|
|
openCtxMenu = menu;
|
|
// Close on outside click
|
|
const close = (ev) => {
|
|
if (!menu.contains(ev.target)) {
|
|
menu.classList.remove('open');
|
|
openCtxMenu = null;
|
|
document.removeEventListener('click', close);
|
|
}
|
|
};
|
|
setTimeout(() => document.addEventListener('click', close), 0);
|
|
}
|
|
|
|
// ── Pin logic ──
|
|
const PINS_KEY = 'gf_pinned_v1';
|
|
function getPinned() { try { return JSON.parse(localStorage.getItem(PINS_KEY)) || []; } catch { return []; } }
|
|
function savePinned(p) { try { localStorage.setItem(PINS_KEY, JSON.stringify(p)); } catch {} }
|
|
|
|
function togglePin(id) {
|
|
const pinned = getPinned();
|
|
const idx = pinned.indexOf(id);
|
|
if (idx >= 0) pinned.splice(idx, 1);
|
|
else pinned.unshift(id);
|
|
savePinned(pinned);
|
|
renderDrawer();
|
|
}
|
|
|
|
// ── Re-render drawer with full per-session menus ──
|
|
function renderDrawer() {
|
|
const list = document.getElementById('session-list');
|
|
const rawSessions = getSessions();
|
|
const pinned = getPinned();
|
|
// Sort: pinned first, then by date
|
|
const sessions = [
|
|
...rawSessions.filter(s => pinned.includes(s.id)),
|
|
...rawSessions.filter(s => !pinned.includes(s.id))
|
|
];
|
|
if (sessions.length === 0) {
|
|
list.innerHTML = '<div class="drawer-empty">No saved chats yet.<br>Start chatting and your<br>sessions will appear here.</div>';
|
|
updateChatsBadge();
|
|
return;
|
|
}
|
|
list.innerHTML = sessions.map(s => {
|
|
const isPinned = pinned.includes(s.id);
|
|
return `
|
|
<div class="session-item${s.id === currentSessionId ? ' active' : ''}" onclick="loadSessionById('${s.id}')">
|
|
<div style="display:flex;align-items:flex-start;gap:4px">
|
|
<div style="flex:1;min-width:0">
|
|
${isPinned ? '<span class="pin-icon">📌</span>' : ''}
|
|
<div class="session-title" id="stitle-${s.id}">${escapeHtml(s.title)}</div>
|
|
<div class="session-meta">${s.date} · ${Math.floor(s.messages.length / 2)} msgs</div>
|
|
</div>
|
|
<div class="session-ctx-wrap" onclick="event.stopPropagation()">
|
|
<button class="session-ctx-btn" onclick="toggleCtxMenu('${s.id}', this, event)" title="Options">···</button>
|
|
<div class="session-ctx-menu">
|
|
<button class="ctx-item" onclick="startDrawerRename('${s.id}', document.getElementById('stitle-${s.id}'))">✏ Rename</button>
|
|
<button class="ctx-item" onclick="togglePin('${s.id}')">${isPinned ? '📌 Unpin' : '📌 Pin to top'}</button>
|
|
<button class="ctx-item" onclick="shareSession('${s.id}')">↑ Share</button>
|
|
<div class="ctx-divider"></div>
|
|
<button class="ctx-item" onclick="archiveSession('${s.id}')">🗂 Archive</button>
|
|
<button class="ctx-item danger" onclick="deleteSession('${s.id}', event)">🗑 Delete</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>`;
|
|
}).join('');
|
|
updateChatsBadge();
|
|
// Add swipe-to-archive on mobile
|
|
list.querySelectorAll('.session-item').forEach(el => {
|
|
const onclickAttr = el.getAttribute('onclick') || '';
|
|
const m = onclickAttr.match(/loadSessionById\('([^']+)'\)/);
|
|
if (m && typeof addSwipeToArchive === 'function') addSwipeToArchive(el, m[1]);
|
|
});
|
|
}
|
|
|
|
// Load session by ID (used by new renderDrawer)
|
|
function loadSessionById(id) {
|
|
const sessions = getSessions();
|
|
const sess = sessions.find(s => s.id === id);
|
|
if (sess) loadSession(sess);
|
|
}
|
|
|
|
function renderArchivedList() {
|
|
const list = document.getElementById('archived-list');
|
|
const archived = getArchived();
|
|
if (!archived.length) {
|
|
list.innerHTML = '<div class="drawer-empty">No archived chats.<br>Archive a chat from its ··· menu.</div>';
|
|
return;
|
|
}
|
|
list.innerHTML = archived.map(s => `
|
|
<div class="session-item" style="opacity:0.75">
|
|
<div class="session-title">${escapeHtml(s.title)}</div>
|
|
<div class="session-meta">${s.date}</div>
|
|
<div style="display:flex;gap:6px;margin-top:4px">
|
|
<button class="ctx-item" style="font-size:11px;padding:3px 7px" onclick="unarchiveSession('${s.id}')">↩ Restore</button>
|
|
</div>
|
|
</div>`).join('');
|
|
}
|
|
|
|
// Update switchDrawerTab to handle archive tab
|
|
const _origSwitchDrawerTab = switchDrawerTab;
|
|
function switchDrawerTab(tab) {
|
|
['chats','saved','archived'].forEach(t => {
|
|
const tabEl = document.getElementById(`tab-${t}`);
|
|
if (tabEl) tabEl.classList.toggle('active', t === tab);
|
|
});
|
|
document.getElementById('session-list').style.display = tab === 'chats' ? '' : 'none';
|
|
document.getElementById('saved-list').style.display = tab === 'saved' ? '' : 'none';
|
|
document.getElementById('archived-list').style.display = tab === 'archived' ? '' : 'none';
|
|
document.getElementById('drawer-new-btn').style.display = tab === 'chats' ? '' : 'none';
|
|
if (tab === 'saved') renderSavedList();
|
|
if (tab === 'archived') renderArchivedList();
|
|
}
|
|
|
|
// ── Share conversation ──
|
|
function shareSession(id) {
|
|
const sessions = getSessions();
|
|
const sess = sessions.find(s => s.id === id) || { title: currentSessionTitle, messages: history };
|
|
shareConversation(sess);
|
|
}
|
|
|
|
document.getElementById('share-btn').addEventListener('click', () => {
|
|
if (history.length === 0) { showToast('Nothing to share yet.'); return; }
|
|
shareConversation({ title: currentSessionTitle || 'Ghostfolio Chat', messages: history });
|
|
});
|
|
|
|
function shareConversation(sess) {
|
|
const lines = [];
|
|
lines.push(`💼 ${sess.title || 'Ghostfolio Chat'}`);
|
|
lines.push(`Shared from Ghostfolio AI Agent · ${new Date().toLocaleDateString()}`);
|
|
lines.push('─'.repeat(50));
|
|
lines.push('');
|
|
for (const msg of sess.messages) {
|
|
lines.push(msg.role === 'user' ? '👤 You:' : '🤖 Agent:');
|
|
lines.push(msg.content.slice(0, 500) + (msg.content.length > 500 ? '…' : ''));
|
|
lines.push('');
|
|
}
|
|
const text = lines.join('\n');
|
|
// Try clipboard first
|
|
if (navigator.clipboard) {
|
|
navigator.clipboard.writeText(text).then(() => {
|
|
showToast('📋 Conversation copied — paste into Slack, email, or notes!');
|
|
});
|
|
}
|
|
// Also encode as URL hash for shareable link
|
|
try {
|
|
const encoded = btoa(encodeURIComponent(text.slice(0, 3000)));
|
|
const url = window.location.origin + window.location.pathname + '#share=' + encoded;
|
|
showToast('📋 Copied! Share link also in console.');
|
|
console.log('Share link:', url);
|
|
} catch {}
|
|
}
|
|
|
|
// ── Help panel ──
|
|
document.getElementById('help-fab').addEventListener('click', openHelp);
|
|
document.getElementById('help-overlay').addEventListener('click', e => { if (e.target === e.currentTarget) closeHelp(); });
|
|
|
|
function openHelp() { document.getElementById('help-overlay').classList.add('open'); }
|
|
function closeHelp() { document.getElementById('help-overlay').classList.remove('open'); }
|
|
function closeHelpAndSend(query) {
|
|
closeHelp();
|
|
input.value = query;
|
|
send();
|
|
}
|
|
|
|
// ── Query History ──
|
|
const QH_KEY = 'gf_query_history';
|
|
const QH_MAX = 20;
|
|
const queryHistoryEl = document.getElementById('query-history');
|
|
let qhSelectedIdx = -1;
|
|
|
|
function getQueryHistory() {
|
|
try { return JSON.parse(localStorage.getItem(QH_KEY)) || []; } catch { return []; }
|
|
}
|
|
function saveQueryHistory(q) {
|
|
if (!q || q.startsWith('/')) return;
|
|
let hist = getQueryHistory().filter(h => h !== q);
|
|
hist.unshift(q);
|
|
if (hist.length > QH_MAX) hist = hist.slice(0, QH_MAX);
|
|
try { localStorage.setItem(QH_KEY, JSON.stringify(hist)); } catch {}
|
|
}
|
|
function renderQueryHistory() {
|
|
const hist = getQueryHistory();
|
|
if (!hist.length) { queryHistoryEl.classList.remove('open'); return; }
|
|
qhSelectedIdx = -1;
|
|
queryHistoryEl.innerHTML =
|
|
`<div class="qh-header"><span>Recent queries</span><button class="qh-clear" onclick="clearQueryHistory()">Clear</button></div>` +
|
|
hist.map((q, i) => `<button class="qh-item" onclick="useHistoryQuery(${i})">${escapeHtml(q.slice(0, 80))}</button>`).join('');
|
|
queryHistoryEl.classList.add('open');
|
|
}
|
|
function closeQueryHistory() { queryHistoryEl.classList.remove('open'); qhSelectedIdx = -1; }
|
|
function clearQueryHistory() {
|
|
try { localStorage.removeItem(QH_KEY); } catch {}
|
|
closeQueryHistory();
|
|
}
|
|
function useHistoryQuery(idx) {
|
|
const hist = getQueryHistory();
|
|
if (hist[idx]) { input.value = hist[idx]; input.style.height = 'auto'; input.style.height = Math.min(input.scrollHeight, 140) + 'px'; }
|
|
closeQueryHistory();
|
|
input.focus();
|
|
}
|
|
|
|
// Show history on focus if input is empty
|
|
input.addEventListener('focus', () => {
|
|
if (!input.value.trim()) renderQueryHistory();
|
|
});
|
|
input.addEventListener('input', () => {
|
|
if (input.value.trim()) closeQueryHistory();
|
|
else renderQueryHistory();
|
|
});
|
|
document.addEventListener('click', (e) => {
|
|
if (!queryHistoryEl.contains(e.target) && e.target !== input) closeQueryHistory();
|
|
});
|
|
// Arrow key navigation in history dropdown
|
|
input.addEventListener('keydown', (e) => {
|
|
if (!queryHistoryEl.classList.contains('open')) return;
|
|
const items = [...queryHistoryEl.querySelectorAll('.qh-item')];
|
|
if (!items.length) return;
|
|
if (e.key === 'ArrowDown') {
|
|
e.preventDefault();
|
|
qhSelectedIdx = Math.min(qhSelectedIdx + 1, items.length - 1);
|
|
items.forEach((it, i) => it.classList.toggle('selected', i === qhSelectedIdx));
|
|
} else if (e.key === 'ArrowUp') {
|
|
e.preventDefault();
|
|
qhSelectedIdx = Math.max(qhSelectedIdx - 1, -1);
|
|
items.forEach((it, i) => it.classList.toggle('selected', i === qhSelectedIdx));
|
|
if (qhSelectedIdx === -1) input.value = '';
|
|
} else if (e.key === 'Enter' && qhSelectedIdx >= 0) {
|
|
e.stopImmediatePropagation();
|
|
useHistoryQuery(qhSelectedIdx);
|
|
} else if (e.key === 'Escape') {
|
|
closeQueryHistory();
|
|
}
|
|
}, true);
|
|
|
|
// ── Custom Quick-Action Cards ──
|
|
const CARDS_KEY = 'gf_custom_cards_v1';
|
|
function getCustomCards() { try { return JSON.parse(localStorage.getItem(CARDS_KEY)) || []; } catch { return []; } }
|
|
function saveCustomCards(cards) { try { localStorage.setItem(CARDS_KEY, JSON.stringify(cards)); } catch {} }
|
|
|
|
function renderCustomCards() {
|
|
const cards = getCustomCards();
|
|
const section = document.getElementById('custom-cards-section');
|
|
const row = document.getElementById('custom-cards-row');
|
|
if (!cards.length) { section.style.display = 'none'; return; }
|
|
section.style.display = '';
|
|
row.innerHTML = cards.map((c, i) => `
|
|
<div class="custom-qb">
|
|
<button class="quick-btn" onclick="sendQuick(${JSON.stringify(c.query)})">
|
|
<span class="qb-icon">${escapeHtml(c.icon || '⭐')}</span>
|
|
<span class="qb-title">${escapeHtml(c.label)}</span>
|
|
<span class="qb-sub">${escapeHtml(c.query.slice(0, 40))}…</span>
|
|
</button>
|
|
<button class="qb-delete" onclick="deleteCustomCard(${i});event.stopPropagation()" title="Remove">✕</button>
|
|
</div>`).join('');
|
|
}
|
|
|
|
function openAddCardModal() {
|
|
document.getElementById('add-card-modal').classList.add('open');
|
|
document.getElementById('card-icon-input').value = '';
|
|
document.getElementById('card-label-input').value = '';
|
|
document.getElementById('card-query-input').value = '';
|
|
setTimeout(() => document.getElementById('card-icon-input').focus(), 80);
|
|
}
|
|
function closeAddCardModal() { document.getElementById('add-card-modal').classList.remove('open'); }
|
|
document.getElementById('add-card-modal').addEventListener('click', e => { if (e.target === e.currentTarget) closeAddCardModal(); });
|
|
|
|
function saveCustomCard() {
|
|
const icon = document.getElementById('card-icon-input').value.trim() || '⭐';
|
|
const label = document.getElementById('card-label-input').value.trim();
|
|
const query = document.getElementById('card-query-input').value.trim();
|
|
if (!label || !query) { showToast('Please fill in both label and query.'); return; }
|
|
const cards = getCustomCards();
|
|
cards.push({ icon, label, query });
|
|
saveCustomCards(cards);
|
|
closeAddCardModal();
|
|
renderCustomCards();
|
|
showToast('✅ Shortcut added!');
|
|
}
|
|
function deleteCustomCard(idx) {
|
|
const cards = getCustomCards();
|
|
cards.splice(idx, 1);
|
|
saveCustomCards(cards);
|
|
renderCustomCards();
|
|
}
|
|
|
|
// Initial render of custom cards
|
|
renderCustomCards();
|
|
|
|
// ── Annotations / Sticky Notes ──
|
|
const ANNOT_KEY = 'gf_annotations_v1';
|
|
function getAnnotations() { try { return JSON.parse(localStorage.getItem(ANNOT_KEY)) || {}; } catch { return {}; } }
|
|
function getAnnotation(hash) { return getAnnotations()[hash] || ''; }
|
|
function saveAnnotation(hash, text) {
|
|
const annots = getAnnotations();
|
|
if (text.trim()) annots[hash] = text.trim();
|
|
else delete annots[hash];
|
|
try { localStorage.setItem(ANNOT_KEY, JSON.stringify(annots)); } catch {}
|
|
// Update button state
|
|
const btn = document.querySelector(`.annotation-btn[data-hash="${hash}"]`);
|
|
if (btn) btn.classList.toggle('has-note', !!text.trim());
|
|
}
|
|
function toggleAnnotation(btn) {
|
|
const wrap = btn.closest('.message').querySelector('.annotation-wrap');
|
|
if (!wrap) return;
|
|
wrap.classList.toggle('open');
|
|
if (wrap.classList.contains('open')) {
|
|
const ta = wrap.querySelector('.annotation-textarea');
|
|
if (ta) ta.focus();
|
|
}
|
|
}
|
|
|
|
// ── Pinned Messages in Chat ──
|
|
const PINNED_MSGS_KEY = 'gf_pinned_msgs_v1';
|
|
function getPinnedMsgs() { try { return JSON.parse(localStorage.getItem(PINNED_MSGS_KEY)) || []; } catch { return []; } }
|
|
function savePinnedMsgs(p) { try { localStorage.setItem(PINNED_MSGS_KEY, JSON.stringify(p)); } catch {} }
|
|
|
|
function pinBubble(btn) {
|
|
const bubble = btn.parentElement;
|
|
const clone = bubble.cloneNode(true);
|
|
clone.querySelectorAll('button').forEach(b => b.remove());
|
|
const text = clone.innerText.trim().slice(0, 120);
|
|
const id = 'pin_' + Date.now();
|
|
|
|
const pins = getPinnedMsgs();
|
|
if (pins.length >= 5) { showToast('Max 5 pinned messages. Remove one first.'); return; }
|
|
// Mark element with id so we can scroll to it
|
|
btn.closest('.message').dataset.pinId = id;
|
|
pins.unshift({ id, text, date: new Date().toLocaleDateString() });
|
|
savePinnedMsgs(pins);
|
|
renderPinnedStrip();
|
|
btn.classList.add('pinned');
|
|
showToast('📌 Pinned to top!');
|
|
}
|
|
|
|
function removePinnedMsg(id) {
|
|
const pins = getPinnedMsgs().filter(p => p.id !== id);
|
|
savePinnedMsgs(pins);
|
|
renderPinnedStrip();
|
|
// Unpin button if bubble is visible
|
|
const el = chat.querySelector(`[data-pin-id="${id}"] .pin-bubble-btn`);
|
|
if (el) el.classList.remove('pinned');
|
|
}
|
|
|
|
function clearAllPinnedMsgs() {
|
|
savePinnedMsgs([]);
|
|
renderPinnedStrip();
|
|
chat.querySelectorAll('.pin-bubble-btn.pinned').forEach(b => b.classList.remove('pinned'));
|
|
}
|
|
|
|
function renderPinnedStrip() {
|
|
const pins = getPinnedMsgs();
|
|
const strip = document.getElementById('pinned-strip');
|
|
const items = document.getElementById('pinned-strip-items');
|
|
if (!pins.length) { strip.classList.remove('has-pins'); return; }
|
|
strip.classList.add('has-pins');
|
|
items.innerHTML = pins.map(p => `
|
|
<div class="pinned-msg-item" onclick="scrollToPinnedMsg('${p.id}')">
|
|
<span class="pinned-msg-text">📌 ${escapeHtml(p.text)}</span>
|
|
<button class="pinned-msg-remove" onclick="removePinnedMsg('${p.id}');event.stopPropagation()" title="Unpin">✕</button>
|
|
</div>`).join('');
|
|
}
|
|
|
|
function scrollToPinnedMsg(id) {
|
|
const el = chat.querySelector(`[data-pin-id="${id}"]`);
|
|
if (el) { el.scrollIntoView({ behavior: 'smooth', block: 'center' }); el.style.outline = '2px solid #3b82f6'; setTimeout(() => el.style.outline = '', 1800); }
|
|
}
|
|
|
|
// Initial render
|
|
renderPinnedStrip();
|
|
|
|
// ── Message Reactions ──
|
|
const REACTIONS_KEY = 'gf_reactions_v1';
|
|
function getReactions() { try { return JSON.parse(localStorage.getItem(REACTIONS_KEY)) || {}; } catch { return {}; } }
|
|
function saveReactions(r) { try { localStorage.setItem(REACTIONS_KEY, JSON.stringify(r)); } catch {} }
|
|
|
|
function reactMsg(btn, type) {
|
|
const msgId = btn.dataset.msg;
|
|
const reactions = getReactions();
|
|
const current = reactions[msgId];
|
|
const wrap = btn.closest('.reaction-row');
|
|
if (!wrap) return;
|
|
const upBtn = wrap.querySelector('.reaction-btn.up');
|
|
const downBtn = wrap.querySelector('.reaction-btn.down');
|
|
if (current === type) {
|
|
// Toggle off
|
|
delete reactions[msgId];
|
|
upBtn.classList.remove('active');
|
|
downBtn.classList.remove('active');
|
|
} else {
|
|
reactions[msgId] = type;
|
|
upBtn.classList.toggle('active', type === 'up');
|
|
downBtn.classList.toggle('active', type === 'down');
|
|
if (type === 'down') showToast('Thanks for the feedback! We\'ll use this to improve.');
|
|
}
|
|
saveReactions(reactions);
|
|
}
|
|
|
|
// ── Net Worth Timeline / Sparkline ──
|
|
const NW_TIMELINE_KEY = 'gf_nw_timeline_v1';
|
|
function getNWTimeline() { try { return JSON.parse(localStorage.getItem(NW_TIMELINE_KEY)) || []; } catch { return []; } }
|
|
function saveNWTimeline(t) { try { localStorage.setItem(NW_TIMELINE_KEY, JSON.stringify(t)); } catch {} }
|
|
|
|
function updateMemoryNetWorth(value) {
|
|
const timeline = getNWTimeline();
|
|
const last = timeline[timeline.length - 1];
|
|
if (last && last.value === value) return; // no change
|
|
timeline.push({ value, date: new Date().toLocaleDateString(), ts: Date.now() });
|
|
if (timeline.length > 30) timeline.splice(0, timeline.length - 30);
|
|
saveNWTimeline(timeline);
|
|
renderSparkline();
|
|
updateMemoryPanel();
|
|
}
|
|
|
|
function renderSparkline() {
|
|
const timeline = getNWTimeline();
|
|
const svg = document.getElementById('mem-sparkline');
|
|
if (!svg || timeline.length < 2) { if (svg) svg.innerHTML = ''; return; }
|
|
const vals = timeline.map(t => t.value);
|
|
const min = Math.min(...vals);
|
|
const max = Math.max(...vals);
|
|
const range = max - min || 1;
|
|
const W = 100, H = 38;
|
|
const pts = vals.map((v, i) => {
|
|
const x = (i / (vals.length - 1)) * W;
|
|
const y = H - ((v - min) / range) * (H - 4) - 2;
|
|
return `${x.toFixed(1)},${y.toFixed(1)}`;
|
|
}).join(' ');
|
|
const trend = vals[vals.length - 1] >= vals[0];
|
|
svg.setAttribute('viewBox', `0 0 ${W} ${H}`);
|
|
svg.innerHTML = `<polyline points="${pts}" style="fill:none;stroke:${trend ? '#22c55e' : '#ef4444'};stroke-width:2;stroke-linecap:round;stroke-linejoin:round"/>`;
|
|
}
|
|
|
|
// ── Context Memory ──
|
|
const MEMORY_KEY = 'gf_memory_v2';
|
|
function getMemory() { try { return JSON.parse(localStorage.getItem(MEMORY_KEY)) || { tickers: [], properties: [], netWorth: null }; } catch { return { tickers: [], properties: [], netWorth: null }; } }
|
|
function saveMemory(m) { try { localStorage.setItem(MEMORY_KEY, JSON.stringify(m)); } catch {} }
|
|
|
|
function extractTickersIntoMemory(text) {
|
|
const found = text.match(/\b([A-Z]{2,5})\b/g) || [];
|
|
const known = ['I', 'A', 'IN', 'IS', 'IT', 'ON', 'OR', 'BY', 'AT', 'AN', 'TO', 'OF', 'FOR', 'AND', 'THE', 'NOT', 'BUT', 'MLS', 'YTD', 'ALL', 'IRA', 'ETF', 'APR', 'APY', 'TAX', 'SEC', 'IRS', 'FDIC', 'PDF', 'TTS', 'URL', 'API', 'USD', 'GBP', 'EUR', 'JAN', 'FEB', 'MAR', 'APR', 'MAY', 'JUN', 'JUL', 'AUG', 'SEP', 'OCT', 'NOV', 'DEC'];
|
|
const filtered = [...new Set(found.filter(t => !known.includes(t) && t.length >= 2))];
|
|
if (!filtered.length) return;
|
|
const mem = getMemory();
|
|
const updated = [...new Set([...mem.tickers, ...filtered])].slice(0, 20);
|
|
if (updated.length !== mem.tickers.length) {
|
|
mem.tickers = updated;
|
|
saveMemory(mem);
|
|
updateMemoryPanel();
|
|
}
|
|
}
|
|
|
|
function getMemoryContextPrefix() {
|
|
const mem = getMemory();
|
|
const parts = [];
|
|
if (mem.tickers.length) parts.push(`Tickers I mentioned before: ${mem.tickers.slice(0, 8).join(', ')}.`);
|
|
if (mem.netWorth) parts.push(`My last known net worth: $${mem.netWorth.toLocaleString()}.`);
|
|
try {
|
|
const p = JSON.parse(localStorage.getItem('gf_user_profile_v1') || '{}');
|
|
if (p.risk) parts.push(`My risk profile: ${p.risk}, focus: ${p.focus || 'mixed'}, horizon: ${p.horizon || 'medium'}.`);
|
|
} catch {}
|
|
if (!parts.length) return '';
|
|
return `[Context: ${parts.join(' ')}] `;
|
|
}
|
|
|
|
function updateMemoryPanel() {
|
|
const mem = getMemory();
|
|
const total = mem.tickers.length + (mem.netWorth ? 1 : 0) + mem.properties.length;
|
|
const indicator = document.getElementById('memory-indicator');
|
|
const label = document.getElementById('memory-label');
|
|
if (total > 0) {
|
|
indicator.classList.add('has-memory');
|
|
label.textContent = `${total} item${total !== 1 ? 's' : ''} memorized`;
|
|
} else {
|
|
indicator.classList.remove('has-memory');
|
|
}
|
|
// Update panel contents
|
|
const tickerEl = document.getElementById('mem-tickers');
|
|
const nwEl = document.getElementById('mem-networth');
|
|
if (tickerEl) tickerEl.innerHTML = mem.tickers.map(t =>
|
|
`<span class="memory-tag">${t} <span class="mem-del" onclick="removeMemTicker('${t}')" title="Forget">✕</span></span>`
|
|
).join('');
|
|
if (nwEl) nwEl.textContent = mem.netWorth ? `$${mem.netWorth.toLocaleString()}` : '—';
|
|
renderSparkline();
|
|
}
|
|
|
|
function removeMemTicker(ticker) {
|
|
const mem = getMemory();
|
|
mem.tickers = mem.tickers.filter(t => t !== ticker);
|
|
saveMemory(mem);
|
|
updateMemoryPanel();
|
|
}
|
|
|
|
function clearMemory() {
|
|
saveMemory({ tickers: [], properties: [], netWorth: null });
|
|
saveNWTimeline([]);
|
|
updateMemoryPanel();
|
|
document.getElementById('memory-panel').classList.remove('open');
|
|
showToast('🗑 Memory cleared.');
|
|
}
|
|
|
|
// Toggle memory panel
|
|
document.getElementById('memory-indicator').addEventListener('click', () => {
|
|
document.getElementById('memory-panel').classList.toggle('open');
|
|
});
|
|
document.addEventListener('click', (e) => {
|
|
const panel = document.getElementById('memory-panel');
|
|
const indicator = document.getElementById('memory-indicator');
|
|
if (!panel.contains(e.target) && !indicator.contains(e.target)) panel.classList.remove('open');
|
|
});
|
|
|
|
// Initialize memory on load
|
|
updateMemoryPanel();
|
|
|
|
// ── Watchlist ──
|
|
const WATCHLIST_KEY = 'gf_watchlist_v1';
|
|
function getWatchlist() { try { return JSON.parse(localStorage.getItem(WATCHLIST_KEY)) || []; } catch { return []; } }
|
|
function saveWatchlist(w) { try { localStorage.setItem(WATCHLIST_KEY, JSON.stringify(w)); } catch {} }
|
|
|
|
function addToWatchlist(symbol) {
|
|
const wl = getWatchlist();
|
|
if (!wl.includes(symbol)) { wl.push(symbol); saveWatchlist(wl); }
|
|
renderWatchlistBanner();
|
|
showToast(`👁 ${symbol} added to watchlist`);
|
|
}
|
|
|
|
function removeFromWatchlist(symbol) {
|
|
saveWatchlist(getWatchlist().filter(s => s !== symbol));
|
|
renderWatchlistBanner();
|
|
}
|
|
|
|
function addToWatchlistPrompt() {
|
|
const sym = prompt('Enter ticker symbol (e.g. AAPL, TSLA):');
|
|
if (sym) addToWatchlist(sym.toUpperCase().trim());
|
|
}
|
|
|
|
function renderWatchlistBanner() {
|
|
const wl = getWatchlist();
|
|
const banner = document.getElementById('watchlist-banner');
|
|
const tagsEl = document.getElementById('watchlist-tags');
|
|
if (!wl.length) { banner.classList.remove('show'); return; }
|
|
banner.classList.add('show');
|
|
tagsEl.innerHTML = wl.map(s =>
|
|
`<span class="wl-tag" onclick="sendQuick('What is ${s} trading at today?')" title="Check ${s}">${s}
|
|
<span style="margin-left:4px;font-size:10px;color:#60a5fa;cursor:pointer" onclick="removeFromWatchlist('${s}');event.stopPropagation()" title="Remove">✕</span>
|
|
</span>`
|
|
).join('');
|
|
}
|
|
|
|
// Auto-detect tickers mentioned in conversation and offer to add to watchlist
|
|
// (Called when user hovers a ticker tag in memory panel — no-op for now)
|
|
|
|
// Show watchlist banner on load
|
|
renderWatchlistBanner();
|
|
|
|
// Handle /watch SYMBOL command
|
|
const _origSend = send;
|
|
input.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
const val = input.value.trim();
|
|
const watchMatch = val.match(/^\/watch\s+([A-Za-z]{1,5})$/i);
|
|
if (watchMatch) {
|
|
e.preventDefault();
|
|
e.stopImmediatePropagation();
|
|
const sym = watchMatch[1].toUpperCase();
|
|
addToWatchlist(sym);
|
|
input.value = '';
|
|
input.style.height = 'auto';
|
|
addMessage('user', `/watch ${sym}`);
|
|
const msg = addMessage('agent', `✅ **${sym}** added to your watchlist. It will appear in the top bar. Click it anytime to check the current price.`);
|
|
return;
|
|
}
|
|
}
|
|
}, true);
|
|
|
|
// ── Export as Image Card ──
|
|
function cardRoundRect(ctx, x, y, w, h, r) {
|
|
const radii = typeof r === 'number' ? [r, r, r, r] : r;
|
|
ctx.beginPath();
|
|
ctx.moveTo(x + radii[0], y);
|
|
ctx.lineTo(x + w - radii[1], y);
|
|
ctx.quadraticCurveTo(x + w, y, x + w, y + radii[1]);
|
|
ctx.lineTo(x + w, y + h - radii[2]);
|
|
ctx.quadraticCurveTo(x + w, y + h, x + w - radii[2], y + h);
|
|
ctx.lineTo(x + radii[3], y + h);
|
|
ctx.quadraticCurveTo(x, y + h, x, y + h - radii[3]);
|
|
ctx.lineTo(x, y + radii[0]);
|
|
ctx.quadraticCurveTo(x, y, x + radii[0], y);
|
|
ctx.closePath();
|
|
}
|
|
|
|
function cardWrapText(ctx, text, maxWidth) {
|
|
const lines = [];
|
|
for (const para of text.split('\n')) {
|
|
const words = para.split(' ');
|
|
let line = '';
|
|
for (const word of words) {
|
|
const test = line ? line + ' ' + word : word;
|
|
if (ctx.measureText(test).width > maxWidth && line) {
|
|
lines.push(line);
|
|
line = word;
|
|
} else {
|
|
line = test;
|
|
}
|
|
}
|
|
if (line) lines.push(line);
|
|
}
|
|
return lines;
|
|
}
|
|
|
|
function renderExportCard() {
|
|
const lastAgent = [...history].reverse().find(m => m.role === 'assistant');
|
|
if (!lastAgent) { showToast('No response to export yet.'); return null; }
|
|
|
|
const canvas = document.getElementById('export-canvas');
|
|
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
|
const W = 560, H = 340;
|
|
canvas.width = W * dpr;
|
|
canvas.height = H * dpr;
|
|
canvas.style.width = W + 'px';
|
|
canvas.style.height = H + 'px';
|
|
const ctx = canvas.getContext('2d');
|
|
ctx.scale(dpr, dpr);
|
|
|
|
// Background
|
|
ctx.fillStyle = '#0a0d14';
|
|
cardRoundRect(ctx, 0, 0, W, H, 16);
|
|
ctx.fill();
|
|
|
|
// Gradient top accent bar
|
|
const grad = ctx.createLinearGradient(0, 0, W, 0);
|
|
grad.addColorStop(0, '#6366f1');
|
|
grad.addColorStop(1, '#8b5cf6');
|
|
ctx.fillStyle = grad;
|
|
cardRoundRect(ctx, 0, 0, W, 5, [16, 16, 0, 0]);
|
|
ctx.fill();
|
|
|
|
// Subtle dot grid background
|
|
ctx.fillStyle = 'rgba(99,102,241,0.04)';
|
|
for (let gx = 20; gx < W; gx += 20) {
|
|
for (let gy = 20; gy < H; gy += 20) {
|
|
ctx.beginPath();
|
|
ctx.arc(gx, gy, 1, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
}
|
|
}
|
|
|
|
// Logo badge
|
|
const logGrad = ctx.createLinearGradient(20, 18, 54, 52);
|
|
logGrad.addColorStop(0, '#6366f1');
|
|
logGrad.addColorStop(1, '#8b5cf6');
|
|
ctx.fillStyle = logGrad;
|
|
cardRoundRect(ctx, 20, 18, 34, 34, 8);
|
|
ctx.fill();
|
|
ctx.fillStyle = '#ffffff';
|
|
ctx.font = '18px sans-serif';
|
|
ctx.textAlign = 'center';
|
|
ctx.fillText('📈', 37, 40);
|
|
|
|
// Title + date
|
|
ctx.fillStyle = '#e2e8f0';
|
|
ctx.font = 'bold 13px -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif';
|
|
ctx.textAlign = 'left';
|
|
ctx.fillText('Ghostfolio AI Agent', 64, 32);
|
|
ctx.fillStyle = '#475569';
|
|
ctx.font = '11px sans-serif';
|
|
ctx.fillText(currentSessionTitle || new Date().toLocaleDateString(), 64, 48);
|
|
|
|
// Divider
|
|
ctx.strokeStyle = '#1f2840';
|
|
ctx.lineWidth = 1;
|
|
ctx.beginPath();
|
|
ctx.moveTo(20, 62);
|
|
ctx.lineTo(W - 20, 62);
|
|
ctx.stroke();
|
|
|
|
// Strip markdown and internal citation IDs from content
|
|
const stripped = lastAgent.content
|
|
.replace(/\[source:[^\]]+\]/g, '')
|
|
.replace(/\[(portfolio|compliance|market|property|real_estate|tax|transaction)_[^\]]+\]/g, '')
|
|
.replace(/\[[a-z_]+_\d{6,}\]/g, '')
|
|
.replace(/#{1,6}\s+/g, '').replace(/\*\*(.+?)\*\*/g, '$1').replace(/\*(.+?)\*/g, '$1')
|
|
.replace(/`{1,3}[^`]*`{1,3}/g, '').replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
|
|
.replace(/\|[^\n]+\|/g, '').replace(/[-─]{3,}/g, '')
|
|
.replace(/\n{3,}/g, '\n\n').trim();
|
|
|
|
ctx.fillStyle = '#94a3b8';
|
|
ctx.font = '12px -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif';
|
|
const lines = cardWrapText(ctx, stripped, W - 48);
|
|
const maxLines = 14;
|
|
let y = 82;
|
|
for (let i = 0; i < Math.min(lines.length, maxLines); i++) {
|
|
// Bold first line
|
|
if (i === 0) { ctx.fillStyle = '#cbd5e1'; ctx.font = 'bold 12px sans-serif'; }
|
|
else { ctx.fillStyle = '#94a3b8'; ctx.font = '12px sans-serif'; }
|
|
ctx.textAlign = 'left';
|
|
ctx.fillText(lines[i], 24, y);
|
|
y += 17;
|
|
}
|
|
if (lines.length > maxLines) {
|
|
ctx.fillStyle = '#334155';
|
|
ctx.font = 'italic 11px sans-serif';
|
|
ctx.fillText(`… ${lines.length - maxLines} more lines`, 24, y);
|
|
}
|
|
|
|
// Footer bar
|
|
const footerGrad = ctx.createLinearGradient(0, H - 36, 0, H);
|
|
footerGrad.addColorStop(0, 'transparent');
|
|
footerGrad.addColorStop(1, '#0a0d14');
|
|
ctx.fillStyle = footerGrad;
|
|
ctx.fillRect(0, H - 36, W, 36);
|
|
|
|
ctx.fillStyle = '#334155';
|
|
ctx.font = '10px sans-serif';
|
|
ctx.textAlign = 'center';
|
|
ctx.fillText('ghostfolio.ai · Powered by Claude + LangGraph', W / 2, H - 10);
|
|
|
|
return canvas;
|
|
}
|
|
|
|
function openExportCard() {
|
|
const modal = document.getElementById('export-card-modal');
|
|
const canvas = renderExportCard();
|
|
if (!canvas) return;
|
|
modal.classList.add('open');
|
|
}
|
|
|
|
function downloadCard() {
|
|
const canvas = document.getElementById('export-canvas');
|
|
const a = document.createElement('a');
|
|
a.download = 'ghostfolio-insight-' + Date.now() + '.png';
|
|
a.href = canvas.toDataURL('image/png');
|
|
a.click();
|
|
showToast('🖼 PNG downloaded!');
|
|
}
|
|
|
|
async function copyCardToClipboard() {
|
|
const canvas = document.getElementById('export-canvas');
|
|
canvas.toBlob(async (blob) => {
|
|
try {
|
|
await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]);
|
|
showToast('🖼 Image card copied to clipboard!');
|
|
} catch {
|
|
showToast('💡 Download the PNG instead — clipboard image API not supported in this browser.');
|
|
}
|
|
});
|
|
}
|
|
|
|
document.getElementById('export-card-btn').addEventListener('click', () => {
|
|
document.getElementById('settings-menu').classList.remove('open');
|
|
openExportCard();
|
|
});
|
|
document.getElementById('export-card-modal').addEventListener('click', e => {
|
|
if (e.target === e.currentTarget) e.currentTarget.classList.remove('open');
|
|
});
|
|
|
|
// ── Portfolio Heat Map ──
|
|
function retColor(ret) {
|
|
if (ret > 20) return '#15803d';
|
|
if (ret > 10) return '#16a34a';
|
|
if (ret > 5) return '#22c55e';
|
|
if (ret > 0) return '#4ade80';
|
|
if (ret > -5) return '#f87171';
|
|
if (ret > -10) return '#ef4444';
|
|
if (ret > -20) return '#dc2626';
|
|
return '#b91c1c';
|
|
}
|
|
|
|
function extractHoldingsFromHistory() {
|
|
const holdings = [];
|
|
const seen = new Set();
|
|
for (const msg of [...history].reverse()) {
|
|
if (msg.role !== 'assistant') continue;
|
|
// Parse markdown table rows: | TICKER | ... | weight% | return% |
|
|
const rows = msg.content.match(/\|\s*([A-Z]{2,5})\s*\|[^\n]+/g) || [];
|
|
for (const row of rows) {
|
|
const parts = row.split('|').map(p => p.trim()).filter(Boolean);
|
|
const ticker = parts[0];
|
|
if (!ticker || !/^[A-Z]{2,5}$/.test(ticker) || seen.has(ticker)) continue;
|
|
const pcts = row.match(/([-+]?\d+\.?\d*)%/g) || [];
|
|
const weight = pcts[0] ? Math.abs(parseFloat(pcts[0])) : (8 + Math.random() * 8);
|
|
const ret = pcts[1] ? parseFloat(pcts[1]) : (Math.random() * 30 - 10);
|
|
holdings.push({ ticker, weight, ret });
|
|
seen.add(ticker);
|
|
}
|
|
if (holdings.length >= 3) break;
|
|
}
|
|
// Fall back to memory tickers for a demo heat map
|
|
if (!holdings.length) {
|
|
const tickers = getMemory().tickers.slice(0, 12);
|
|
tickers.forEach(t => {
|
|
if (!seen.has(t)) {
|
|
holdings.push({ ticker: t, weight: 6 + Math.random() * 14, ret: (Math.random() * 40) - 15 });
|
|
seen.add(t);
|
|
}
|
|
});
|
|
}
|
|
return holdings;
|
|
}
|
|
|
|
function openHeatmap() {
|
|
const modal = document.getElementById('heatmap-modal');
|
|
const content = document.getElementById('heatmap-content');
|
|
const holdings = extractHoldingsFromHistory();
|
|
|
|
if (!holdings.length) {
|
|
content.innerHTML = `
|
|
<div style="text-align:center;padding:36px;color:var(--text2)">
|
|
<div style="font-size:36px;margin-bottom:14px">📊</div>
|
|
<div style="font-size:14px;font-weight:600;margin-bottom:8px;color:var(--text)">No holdings data yet</div>
|
|
<div style="font-size:12px;color:var(--text3);margin-bottom:20px">Ask for a full portfolio summary — the agent will return your holdings in a table the heat map can read.</div>
|
|
<button onclick="document.getElementById('heatmap-modal').classList.remove('open');sendQuick('Give me a full portfolio summary with all holdings and their YTD returns')"
|
|
style="padding:9px 20px;border-radius:9px;border:none;background:linear-gradient(135deg,var(--indigo),#8b5cf6);color:#fff;font-size:13px;font-weight:600;cursor:pointer">
|
|
Get Portfolio Summary →
|
|
</button>
|
|
</div>`;
|
|
} else {
|
|
const maxW = Math.max(...holdings.map(h => h.weight));
|
|
const tiles = [...holdings].sort((a, b) => b.weight - a.weight).map(h => {
|
|
const sz = Math.round(60 + (h.weight / maxW) * 90);
|
|
const color = retColor(h.ret);
|
|
const retStr = h.ret >= 0 ? `+${h.ret.toFixed(1)}%` : `${h.ret.toFixed(1)}%`;
|
|
const fs = sz > 100 ? 13 : sz > 70 ? 11 : 10;
|
|
return `<div class="hm-tile" style="width:${sz}px;height:${Math.round(sz * 0.7)}px;background:${color}" title="${h.ticker}: weight ${h.weight.toFixed(1)}%, return ${retStr}" onclick="document.getElementById('heatmap-modal').classList.remove('open');sendQuick('What is ${h.ticker} trading at today?')">
|
|
<div style="font-size:${fs}px">${h.ticker}</div>
|
|
<div style="font-size:${Math.max(9, fs - 2)}px;opacity:0.85">${retStr}</div>
|
|
${sz > 80 ? `<div style="font-size:9px;opacity:0.6">${h.weight.toFixed(1)}%</div>` : ''}
|
|
</div>`;
|
|
}).join('');
|
|
|
|
const source = holdings.some(h => h.ret !== Math.round(h.ret)) ? 'Extracted from last portfolio response' : 'Demo — based on tickers in memory (returns estimated)';
|
|
content.innerHTML = `
|
|
<div style="margin-bottom:10px;display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap">
|
|
<div style="font-size:11px;color:var(--text3)">${source}</div>
|
|
<div class="heatmap-legend">
|
|
<div class="hm-legend-dot" style="background:#15803d"></div><span>+20%+</span>
|
|
<div class="hm-legend-dot" style="background:#22c55e"></div><span>+5%</span>
|
|
<div class="hm-legend-dot" style="background:#f87171"></div><span>−5%</span>
|
|
<div class="hm-legend-dot" style="background:#b91c1c"></div><span>−20%+</span>
|
|
</div>
|
|
</div>
|
|
<div class="heatmap-grid">${tiles}</div>
|
|
<div style="margin-top:10px;font-size:11px;color:var(--text3)">Size = portfolio weight · Click any tile to check price</div>`;
|
|
}
|
|
modal.classList.add('open');
|
|
}
|
|
|
|
document.getElementById('heatmap-btn').addEventListener('click', () => {
|
|
document.getElementById('settings-menu').classList.remove('open');
|
|
openHeatmap();
|
|
});
|
|
document.getElementById('heatmap-modal').addEventListener('click', e => {
|
|
if (e.target === e.currentTarget) e.currentTarget.classList.remove('open');
|
|
});
|
|
|
|
// ── Request Inspector ──
|
|
let inspectorOpen = false;
|
|
const requestLog = [];
|
|
|
|
function logRequest(query, meta) {
|
|
requestLog.unshift({
|
|
query: query.replace(/^\[Context:[^\]]+\]\s*/,'').slice(0, 100),
|
|
tools: (meta.tools_used || []).join(', ') || 'none',
|
|
latency: meta.latency_seconds,
|
|
confidence: meta.confidence_score,
|
|
verification: meta.verification_outcome,
|
|
citations: (meta.citations || []).length,
|
|
ts: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })
|
|
});
|
|
if (requestLog.length > 12) requestLog.pop();
|
|
if (inspectorOpen) renderInspector();
|
|
}
|
|
|
|
function renderInspector() {
|
|
const body = document.getElementById('inspector-body');
|
|
if (!requestLog.length) {
|
|
body.innerHTML = '<div class="inspector-empty">No requests yet.<br>Send a message to start inspecting.</div>';
|
|
return;
|
|
}
|
|
body.innerHTML = requestLog.map((r, i) => {
|
|
const confPct = r.confidence != null ? Math.round(r.confidence * 100) : null;
|
|
const confCls = confPct >= 80 ? 'high' : confPct >= 50 ? 'med' : 'low';
|
|
const latBar = r.latency != null ? `<div class="insp-bar-row"><div class="insp-bar-track"><div class="insp-bar-fill" style="width:${Math.min(100, r.latency * 20)}%"></div></div><span style="font-size:10px;color:var(--text3)">${r.latency}s</span></div>` : '';
|
|
return `
|
|
<div class="inspector-entry${i === 0 ? ' latest' : ''}">
|
|
<div class="inspector-ts">${r.ts}${i === 0 ? ' · <span style="color:var(--indigo2);font-weight:700">latest</span>' : ''}</div>
|
|
<div class="inspector-query" title="${escapeHtml(r.query)}">${escapeHtml(r.query)}</div>
|
|
<div class="inspector-row"><span class="insp-key">tools</span><span class="insp-val">${r.tools}</span></div>
|
|
${latBar ? `<div class="inspector-row"><span class="insp-key">latency</span><span style="flex:1">${latBar}</span></div>` : ''}
|
|
${confPct != null ? `<div class="inspector-row"><span class="insp-key">confidence</span><span class="insp-val insp-conf-${confCls}">${confPct}%</span></div>` : ''}
|
|
<div class="inspector-row"><span class="insp-key">verify</span><span class="insp-val">${r.verification || '—'}</span></div>
|
|
${r.citations ? `<div class="inspector-row"><span class="insp-key">citations</span><span class="insp-val">${r.citations}</span></div>` : ''}
|
|
</div>`;
|
|
}).join('');
|
|
}
|
|
|
|
function toggleInspector() {
|
|
inspectorOpen = !inspectorOpen;
|
|
document.getElementById('inspector-panel').classList.toggle('open', inspectorOpen);
|
|
document.getElementById('inspector-pill').classList.toggle('on', inspectorOpen);
|
|
if (inspectorOpen) renderInspector();
|
|
}
|
|
|
|
document.getElementById('inspector-btn').addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
document.getElementById('settings-menu').classList.remove('open');
|
|
toggleInspector();
|
|
});
|
|
|
|
// ── Scroll-to-bottom button ──
|
|
const scrollBtn = document.getElementById('scroll-btn');
|
|
chat.addEventListener('scroll', () => {
|
|
const atBottom = chat.scrollHeight - chat.scrollTop - chat.clientHeight < 80;
|
|
scrollBtn.classList.toggle('visible', !atBottom && chat.scrollHeight > chat.clientHeight + 200);
|
|
});
|
|
|
|
// ── Copy as Markdown ──
|
|
function copyBubbleMd(btn) {
|
|
const bubble = btn.parentElement;
|
|
const clone = bubble.cloneNode(true);
|
|
clone.querySelectorAll('button').forEach(b => b.remove());
|
|
const text = clone.innerText.trim();
|
|
navigator.clipboard.writeText(text).then(() => {
|
|
const orig = btn.textContent;
|
|
btn.textContent = '✓';
|
|
btn.style.color = 'var(--green)';
|
|
setTimeout(() => { btn.textContent = orig; btn.style.color = ''; }, 1600);
|
|
});
|
|
}
|
|
|
|
// ── High Contrast Mode ──
|
|
let contrastOn = localStorage.getItem('gf_contrast') === '1';
|
|
function applyContrast(val) {
|
|
contrastOn = val;
|
|
if (val) {
|
|
document.documentElement.setAttribute('data-theme', 'contrast');
|
|
} else {
|
|
const saved = localStorage.getItem('gf_theme');
|
|
if (saved) document.documentElement.setAttribute('data-theme', saved);
|
|
else document.documentElement.removeAttribute('data-theme');
|
|
}
|
|
document.getElementById('contrast-pill').classList.toggle('on', val);
|
|
try { localStorage.setItem('gf_contrast', val ? '1' : '0'); } catch {}
|
|
}
|
|
if (contrastOn) applyContrast(true);
|
|
document.getElementById('contrast-toggle-btn').addEventListener('click', e => { e.stopPropagation(); applyContrast(!contrastOn); });
|
|
|
|
// ── Reduced Motion ──
|
|
let motionReduced = localStorage.getItem('gf_reduced_motion') === '1' || window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
function applyReducedMotion(val) {
|
|
motionReduced = val;
|
|
document.body.classList.toggle('reduced-motion', val);
|
|
document.getElementById('motion-pill').classList.toggle('on', val);
|
|
try { localStorage.setItem('gf_reduced_motion', val ? '1' : '0'); } catch {}
|
|
}
|
|
if (motionReduced) applyReducedMotion(true);
|
|
document.getElementById('motion-toggle-btn').addEventListener('click', e => { e.stopPropagation(); applyReducedMotion(!motionReduced); });
|
|
|
|
// ── Response Disclaimer ──
|
|
let disclaimerOn = localStorage.getItem('gf_disclaimer') === '1';
|
|
function applyDisclaimer(val) {
|
|
disclaimerOn = val;
|
|
document.body.classList.toggle('gf-disclaimer-on', val);
|
|
document.getElementById('disclaimer-pill').classList.toggle('on', val);
|
|
try { localStorage.setItem('gf_disclaimer', val ? '1' : '0'); } catch {}
|
|
}
|
|
if (disclaimerOn) applyDisclaimer(true);
|
|
document.getElementById('disclaimer-toggle-btn').addEventListener('click', e => { e.stopPropagation(); applyDisclaimer(!disclaimerOn); });
|
|
|
|
// Inject disclaimer badge into agent messages when mode is on
|
|
const _origAddMsg = addMessage;
|
|
// Patch: disclaimer badge appended inside addMessage agent path (we add it after reaction row)
|
|
|
|
// ── Auto-title Improvement (heuristic) ──
|
|
function smartTitle(query) {
|
|
// Topic extraction heuristics
|
|
const q = query.toLowerCase();
|
|
if (/portfolio|holdings|allocation|ytd|return/.test(q)) return 'Portfolio Review';
|
|
if (/austin|travis|round rock|cedar park|hays|williamson|bastrop|caldwell/.test(q)) return 'Austin Real Estate';
|
|
if (/net worth|total worth/.test(q)) return 'Net Worth Check';
|
|
if (/tax|capital gains|liability/.test(q)) return 'Tax Planning';
|
|
if (/rebalance|rebalancing/.test(q)) return 'Rebalancing Analysis';
|
|
if (/compliance|concentration|overweight/.test(q)) return 'Compliance Check';
|
|
if (/nvidia|aapl|apple|microsoft|tesla|alphabet|amazon/.test(q)) {
|
|
const m = query.match(/\b([A-Z]{2,5}|nvidia|apple|microsoft|tesla|alphabet|amazon)\b/i);
|
|
return m ? m[0].toUpperCase() + ' Analysis' : 'Stock Analysis';
|
|
}
|
|
if (/property|house|home|rental|mortgage|equity/.test(q)) return 'Property Planning';
|
|
if (/scenario|what.?if|hypothetical/.test(q)) return 'What-If Scenario';
|
|
// Fallback: capitalize first 4 words
|
|
const words = query.trim().split(/\s+/).slice(0, 4).map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
|
|
return words.length > 40 ? words.slice(0, 40) + '…' : words;
|
|
}
|
|
|
|
// Smart title is applied by patching the call inside send() — see smartTitle() above
|
|
|
|
// ── Context-Aware Input Placeholder ──
|
|
const PLACEHOLDERS = {
|
|
real_estate: 'Ask about Austin housing market, compare counties, or add a property…',
|
|
property_tracker: 'Ask about your properties, equity, or total net worth…',
|
|
portfolio_analysis: 'Try: rebalance suggestions, tax harvest opportunities…',
|
|
market_data: 'Ask about any ticker — price, analyst consensus, YTD…',
|
|
compliance_check: 'Ask about concentration risk, compliance rules…',
|
|
tax_estimate: 'Try: what if I sell [ticker] today? Tax impact?',
|
|
default: 'Ask anything about your portfolio… (type /tools or ~ for templates)'
|
|
};
|
|
function updatePlaceholder(toolsUsed) {
|
|
if (!toolsUsed || !toolsUsed.length) return;
|
|
const tool = toolsUsed[0];
|
|
const ph = PLACEHOLDERS[tool] || PLACEHOLDERS.default;
|
|
input.placeholder = ph;
|
|
}
|
|
|
|
// ── Offline Detection + Message Queue ──
|
|
let isOffline = false;
|
|
let messageQueue = [];
|
|
const offlineBanner = document.getElementById('offline-banner');
|
|
const offlineQueued = document.getElementById('offline-queued');
|
|
|
|
function setOffline(val) {
|
|
isOffline = val;
|
|
offlineBanner.classList.toggle('show', val);
|
|
if (!val && messageQueue.length) {
|
|
const queued = messageQueue.shift();
|
|
offlineQueued.textContent = '';
|
|
showToast('🌐 Reconnected — sending your queued message…');
|
|
setTimeout(() => { input.value = queued; send(); }, 500);
|
|
}
|
|
}
|
|
|
|
window.addEventListener('offline', () => setOffline(true));
|
|
window.addEventListener('online', () => setOffline(false));
|
|
|
|
// Poll for server reachability every 8s when offline
|
|
setInterval(async () => {
|
|
if (!isOffline) return;
|
|
try {
|
|
const r = await fetch('/health', { method: 'HEAD', signal: AbortSignal.timeout(3000) });
|
|
if (r.ok) setOffline(false);
|
|
} catch {}
|
|
}, 8000);
|
|
|
|
// ── Smart Time-Based Suggestions ──
|
|
(function checkSmartSuggestion() {
|
|
const key = 'gf_smart_dismissed_' + new Date().toDateString();
|
|
if (localStorage.getItem(key)) return;
|
|
const lastQueryTs = parseInt(localStorage.getItem('gf_last_query_ts')) || 0;
|
|
const daysSince = (Date.now() - lastQueryTs) / (1000 * 60 * 60 * 24);
|
|
const d = new Date();
|
|
const dow = d.getDay(); // 0=Sun 1=Mon
|
|
const month = d.getMonth() + 1; // 1-12
|
|
const hour = d.getHours();
|
|
let suggestion = null;
|
|
|
|
if (dow === 1 && hour >= 8 && hour <= 10) suggestion = { text: '📅 Monday morning — check your weekly portfolio performance?', query: 'How did my portfolio perform this week vs the market?' };
|
|
else if (month >= 3 && month <= 4) suggestion = { text: '🧾 Tax season — have you estimated your capital gains liability?', query: 'Estimate my capital gains tax liability for this year' };
|
|
else if (daysSince > 7) suggestion = { text: '📊 It\'s been over a week — want a quick portfolio summary?', query: 'Give me a brief portfolio summary with anything important' };
|
|
else if (month === 2 || month === 8) suggestion = { text: '🏠 Austin MLS data is fresh — check the market?', query: 'What is the Austin housing market like right now?' };
|
|
|
|
if (suggestion) {
|
|
const banner = document.getElementById('smart-banner');
|
|
document.getElementById('smart-banner-text').textContent = suggestion.text;
|
|
document.getElementById('smart-banner-action').onclick = () => {
|
|
banner.classList.remove('show');
|
|
input.value = suggestion.query;
|
|
send();
|
|
};
|
|
banner.classList.add('show');
|
|
}
|
|
})();
|
|
|
|
// ── Market Calendar Strip ──
|
|
(function initCalendar() {
|
|
if (localStorage.getItem('gf_cal_hidden')) return;
|
|
const today = new Date();
|
|
const y = today.getFullYear();
|
|
const events = [
|
|
{ label: 'Q1 Earnings Season', date: new Date(y, 3, 15), type: 'near' },
|
|
{ label: 'Q2 Earnings Season', date: new Date(y, 6, 15), type: 'near' },
|
|
{ label: 'Q3 Earnings Season', date: new Date(y, 9, 15), type: 'near' },
|
|
{ label: 'Q4 Earnings Season', date: new Date(y, 0, 15), type: 'near' },
|
|
{ label: 'Tax Filing Deadline', date: new Date(y, 3, 15), type: 'urgent' },
|
|
{ label: 'Tax Extension Deadline', date: new Date(y, 9, 15), type: 'urgent' },
|
|
{ label: 'FOMC Meeting', date: new Date(y, 2, 18), type: '' },
|
|
{ label: 'FOMC Meeting', date: new Date(y, 5, 11), type: '' },
|
|
{ label: 'FOMC Meeting', date: new Date(y, 8, 17), type: '' },
|
|
{ label: 'FOMC Meeting', date: new Date(y, 11, 10), type: '' },
|
|
];
|
|
const upcoming = events
|
|
.filter(e => e.date >= today)
|
|
.sort((a, b) => a.date - b.date)
|
|
.slice(0, 5);
|
|
if (!upcoming.length) return;
|
|
const fmt = d => d.toLocaleDateString([], { month: 'short', day: 'numeric' });
|
|
document.getElementById('cal-events').innerHTML = upcoming.map(e =>
|
|
`<div class="cal-event${e.type ? ' ' + e.type : ''}"><span>${e.label}</span><span class="cal-date">${fmt(e.date)}</span></div>`
|
|
).join('');
|
|
document.getElementById('calendar-pill').classList.add('on');
|
|
document.getElementById('market-calendar').classList.add('show');
|
|
localStorage.removeItem('gf_cal_hidden'); // always re-show unless hidden this session
|
|
})();
|
|
|
|
let calendarVisible = !localStorage.getItem('gf_cal_hidden');
|
|
document.getElementById('calendar-toggle-btn').addEventListener('click', e => {
|
|
e.stopPropagation();
|
|
calendarVisible = !calendarVisible;
|
|
document.getElementById('market-calendar').classList.toggle('show', calendarVisible);
|
|
document.getElementById('calendar-pill').classList.toggle('on', calendarVisible);
|
|
if (!calendarVisible) localStorage.setItem('gf_cal_hidden', '1');
|
|
else localStorage.removeItem('gf_cal_hidden');
|
|
});
|
|
|
|
// ── Command Palette ──
|
|
const CMD_OVERLAY = document.getElementById('cmd-overlay');
|
|
const CMD_INPUT = document.getElementById('cmd-input');
|
|
const CMD_RESULTS = document.getElementById('cmd-results');
|
|
let cmdIdx = -1;
|
|
let cmdFiltered = [];
|
|
|
|
const CMD_LIST = [
|
|
{ icon: '📊', label: 'Portfolio Summary', sub: 'Full holdings, allocation, YTD', action: () => sendQuick('Give me a full portfolio summary'), kbd: '' },
|
|
{ icon: '🏠', label: 'Austin Market', sub: 'Jan 2026 ACTRIS MLS data', action: () => sendQuick('What is the Austin housing market like right now?') },
|
|
{ icon: '💰', label: 'Total Net Worth', sub: 'Portfolio + real estate', action: () => sendQuick('What is my total net worth including real estate?') },
|
|
{ icon: '🧾', label: 'Tax Estimate', sub: 'Capital gains liability', action: () => sendQuick('Estimate my tax liability') },
|
|
{ icon: '⚖️', label: 'Risk Check', sub: 'Concentration & compliance', action: () => sendQuick('Am I over-concentrated in any stock?') },
|
|
{ icon: '🏘', label: 'My Properties', sub: 'Tracked real estate portfolio', action: () => sendQuick('Show my properties') },
|
|
{ icon: '🟩', label: 'Heat Map', sub: 'Portfolio visualization', action: () => openHeatmap() },
|
|
{ icon: '🍩', label: 'Donut Chart', sub: 'Allocation breakdown', action: () => openDonutChart() },
|
|
{ icon: '🏠', label: 'Rental Yield Calc', sub: 'Gross/net yield calculator', action: () => document.getElementById('yield-modal').classList.add('open') },
|
|
{ icon: '🏘', label: 'Compare Properties', sub: 'Side-by-side comparison', action: () => openPropCompare() },
|
|
{ icon: '🖼', label: 'Export as Image', sub: 'Download PNG card', action: () => openExportCard() },
|
|
{ icon: '🔬', label: 'Request Inspector', sub: 'Toggle dev panel', action: () => toggleInspector() },
|
|
{ icon: '⭐', label: 'Add Custom Shortcut', sub: 'Save a quick action', action: () => openAddCardModal() },
|
|
{ icon: '📌', label: 'Pinned Messages', sub: 'View all pins', action: () => { openDrawer(); switchDrawerTab('chats'); } },
|
|
{ icon: '☰', label: 'Chat History', sub: 'Browse all sessions', action: () => openDrawer() },
|
|
{ icon: '📧', label: 'Email Digest', sub: 'Copy formatted conversation', action: () => copyEmailDigest() },
|
|
{ icon: '☑', label: 'Batch Export', sub: 'Select & export responses', action: () => openBatchExport() },
|
|
{ icon: '🔔', label: 'Set Reminder', sub: 'Browser notification', action: () => document.getElementById('reminder-modal').classList.add('open') },
|
|
{ icon: '👤', label: 'Edit Profile', sub: 'Risk tolerance, focus, horizon', action: () => openProfile() },
|
|
{ icon: '🌙', label: 'Toggle Dark Mode', sub: '', action: () => document.getElementById('theme-toggle-btn').click() },
|
|
{ icon: '⬛', label: 'High Contrast', sub: '', action: () => document.getElementById('contrast-toggle-btn').click() },
|
|
{ icon: '🔡', label: 'Font: Small', sub: '', action: () => setFontSize('small') },
|
|
{ icon: '🔡', label: 'Font: Medium', sub: '', action: () => setFontSize('medium') },
|
|
{ icon: '🔡', label: 'Font: Large', sub: '', action: () => setFontSize('large') },
|
|
{ icon: '🔮', label: 'What-if Mode', sub: 'Toggle scenario mode', action: () => document.getElementById('scenario-toggle-btn').click() },
|
|
{ icon: '📋', label: 'Summarize Chat', sub: 'TL;DR of this session', action: () => document.getElementById('tldr-btn').click() },
|
|
{ icon: '⌨', label: 'Keyboard Shortcuts', sub: '', action: () => openShortcutModal(), kbd: '⌘?' },
|
|
{ icon: '+', label: 'New Chat', sub: 'Start fresh session', action: () => startNewChat() },
|
|
{ icon: '🔍', label: 'Search Conversation', sub: 'Find in current chat', action: () => openSearch(), kbd: '⌘F' },
|
|
];
|
|
|
|
function openPalette() {
|
|
CMD_OVERLAY.classList.add('open');
|
|
CMD_INPUT.value = '';
|
|
CMD_INPUT.focus();
|
|
renderPalette('');
|
|
}
|
|
function closePalette() { CMD_OVERLAY.classList.remove('open'); cmdIdx = -1; }
|
|
function renderPalette(q) {
|
|
const ql = q.toLowerCase();
|
|
cmdFiltered = q ? CMD_LIST.filter(c => c.label.toLowerCase().includes(ql) || (c.sub || '').toLowerCase().includes(ql)) : CMD_LIST;
|
|
cmdIdx = -1;
|
|
CMD_RESULTS.innerHTML = cmdFiltered.length ? cmdFiltered.map((c, i) => `
|
|
<button class="cmd-item" onclick="runCmd(${i})">
|
|
<span class="cmd-item-icon">${c.icon}</span>
|
|
<span class="cmd-item-text">
|
|
<div class="cmd-item-label">${c.label}</div>
|
|
${c.sub ? `<div class="cmd-item-sub">${c.sub}</div>` : ''}
|
|
</span>
|
|
${c.kbd ? `<span class="cmd-item-kbd">${c.kbd}</span>` : ''}
|
|
</button>`).join('')
|
|
: `<div style="padding:20px;text-align:center;color:var(--text3);font-size:12px">No commands match "${escapeHtml(q)}"</div>`;
|
|
}
|
|
function runCmd(idx) {
|
|
const cmd = cmdFiltered[idx];
|
|
if (cmd) { closePalette(); setTimeout(() => cmd.action(), 50); }
|
|
}
|
|
CMD_INPUT.addEventListener('input', () => renderPalette(CMD_INPUT.value));
|
|
CMD_INPUT.addEventListener('keydown', e => {
|
|
const items = CMD_RESULTS.querySelectorAll('.cmd-item');
|
|
if (e.key === 'ArrowDown') { e.preventDefault(); cmdIdx = Math.min(cmdIdx + 1, items.length - 1); items.forEach((it, i) => it.classList.toggle('selected', i === cmdIdx)); items[cmdIdx] && items[cmdIdx].scrollIntoView({ block: 'nearest' }); }
|
|
else if (e.key === 'ArrowUp') { e.preventDefault(); cmdIdx = Math.max(cmdIdx - 1, 0); items.forEach((it, i) => it.classList.toggle('selected', i === cmdIdx)); items[cmdIdx] && items[cmdIdx].scrollIntoView({ block: 'nearest' }); }
|
|
else if (e.key === 'Enter') { e.preventDefault(); if (cmdIdx >= 0) runCmd(cmdIdx); else if (cmdFiltered.length) runCmd(0); }
|
|
else if (e.key === 'Escape') { e.stopPropagation(); closePalette(); }
|
|
});
|
|
// ⌘P to open palette
|
|
document.addEventListener('keydown', e => {
|
|
if ((e.metaKey || e.ctrlKey) && e.key === 'p') { e.preventDefault(); openPalette(); }
|
|
});
|
|
|
|
// ── Cross-Session Search ──
|
|
const xSearchInput = document.getElementById('xsearch-input');
|
|
const xSearchResults = document.getElementById('xsearch-results');
|
|
xSearchInput.addEventListener('input', () => {
|
|
const q = xSearchInput.value.trim().toLowerCase();
|
|
if (!q) { xSearchResults.style.display = 'none'; document.getElementById('session-list').style.display = ''; return; }
|
|
const sessions = getSessions();
|
|
const results = [];
|
|
for (const sess of sessions) {
|
|
for (const msg of sess.messages || []) {
|
|
if (msg.content.toLowerCase().includes(q)) {
|
|
const idx = msg.content.toLowerCase().indexOf(q);
|
|
const start = Math.max(0, idx - 30);
|
|
const snippet = '…' + msg.content.slice(start, idx + q.length + 50).replace(new RegExp(q, 'gi'), m => `<mark>${m}</mark>`) + '…';
|
|
results.push({ sess, snippet });
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
document.getElementById('session-list').style.display = 'none';
|
|
if (!results.length) {
|
|
xSearchResults.style.display = 'block';
|
|
xSearchResults.innerHTML = `<div style="padding:12px;font-size:12px;color:var(--text3)">No results across ${sessions.length} chat${sessions.length !== 1 ? 's' : ''}.</div>`;
|
|
return;
|
|
}
|
|
xSearchResults.style.display = 'block';
|
|
xSearchResults.innerHTML = results.map(r =>
|
|
`<button class="xsearch-item" onclick="loadSessionById('${r.sess.id}');closeDrawer()">
|
|
<div class="xsearch-session">${escapeHtml(r.sess.title)} · ${r.sess.date}</div>
|
|
<div class="xsearch-snippet">${r.snippet}</div>
|
|
</button>`
|
|
).join('');
|
|
});
|
|
xSearchInput.addEventListener('blur', () => {
|
|
if (!xSearchInput.value.trim()) { xSearchResults.style.display = 'none'; document.getElementById('session-list').style.display = ''; }
|
|
});
|
|
|
|
// ── User Profile ──
|
|
const PROFILE_KEY = 'gf_user_profile_v1';
|
|
let profileData = {};
|
|
let profileStep = 0;
|
|
const PROFILE_STEPS = ['risk', 'focus', 'horizon'];
|
|
const PROFILE_STEPS_TOTAL = 3;
|
|
|
|
function getProfile() { try { return JSON.parse(localStorage.getItem(PROFILE_KEY)) || {}; } catch { return {}; } }
|
|
function saveProfileData() { try { localStorage.setItem(PROFILE_KEY, JSON.stringify(profileData)); } catch {} }
|
|
|
|
function openProfile() {
|
|
profileData = getProfile();
|
|
profileStep = 0;
|
|
document.getElementById('profile-modal').classList.add('open');
|
|
updateProfileProgress();
|
|
PROFILE_STEPS.forEach((_, i) => {
|
|
const step = document.getElementById(`profile-step-${i}`);
|
|
step.classList.toggle('active', i === 0);
|
|
});
|
|
['risk', 'focus', 'horizon'].forEach(field => {
|
|
document.querySelectorAll(`[onclick*="selectProfile('${field}'"]`).forEach(btn => btn.classList.remove('selected'));
|
|
if (profileData[field]) {
|
|
const el = document.querySelector(`[onclick*="selectProfile('${field}','${profileData[field]}'"]`);
|
|
if (el) el.classList.add('selected');
|
|
}
|
|
});
|
|
}
|
|
|
|
function updateProfileProgress() {
|
|
const prog = document.getElementById('profile-progress');
|
|
prog.innerHTML = PROFILE_STEPS.map((_, i) => `<div class="profile-dot${i <= profileStep ? ' done' : ''}"></div>`).join('');
|
|
}
|
|
|
|
function selectProfile(field, value, el) {
|
|
profileData[field] = value;
|
|
el.closest('.profile-step').querySelectorAll('.profile-option').forEach(b => b.classList.remove('selected'));
|
|
el.classList.add('selected');
|
|
}
|
|
|
|
function nextProfileStep() {
|
|
if (profileStep < PROFILE_STEPS_TOTAL - 1) {
|
|
document.getElementById(`profile-step-${profileStep}`).classList.remove('active');
|
|
profileStep++;
|
|
document.getElementById(`profile-step-${profileStep}`).classList.add('active');
|
|
updateProfileProgress();
|
|
}
|
|
}
|
|
|
|
function saveProfile() {
|
|
saveProfileData();
|
|
document.getElementById('profile-modal').classList.remove('open');
|
|
showToast('✅ Profile saved! The agent will personalize responses accordingly.');
|
|
updateMemoryPanel();
|
|
}
|
|
|
|
function getProfileContextPrefix() {
|
|
const p = getProfile();
|
|
if (!p.risk) return '';
|
|
return `[My profile: risk=${p.risk}, focus=${p.focus || 'mixed'}, horizon=${p.horizon || 'medium'}] `;
|
|
}
|
|
|
|
document.getElementById('profile-btn').addEventListener('click', () => { document.getElementById('settings-menu').classList.remove('open'); openProfile(); });
|
|
|
|
// ── Rental Yield Calculator ──
|
|
function calcYield() {
|
|
const val = parseFloat(document.getElementById('yc-value').value) || 0;
|
|
const rent = parseFloat(document.getElementById('yc-rent').value) || 0;
|
|
const exp = parseFloat(document.getElementById('yc-expenses').value) || 0;
|
|
if (!val || !rent) { document.getElementById('yield-result').style.display = 'none'; return; }
|
|
const annual = rent * 12;
|
|
const gross = (annual / val) * 100;
|
|
const net = ((annual - exp) / val) * 100;
|
|
const cap = net; // simplified cap rate ≈ net yield for this context
|
|
document.getElementById('yc-gross').textContent = gross.toFixed(2) + '%';
|
|
document.getElementById('yc-net').textContent = net.toFixed(2) + '%';
|
|
document.getElementById('yc-annual').textContent = '$' + annual.toLocaleString();
|
|
document.getElementById('yc-cap').textContent = cap.toFixed(2) + '%';
|
|
document.getElementById('yield-result').style.display = 'grid';
|
|
}
|
|
['yield-modal','donut-modal','prop-compare-modal','batch-modal','reminder-modal','profile-modal'].forEach(id => {
|
|
document.getElementById(id).addEventListener('click', e => { if (e.target === e.currentTarget) e.currentTarget.classList.remove('open'); });
|
|
});
|
|
|
|
// Add yield calc and rental calc to Settings/Cmd palette
|
|
function openYieldCalc() { document.getElementById('yield-modal').classList.add('open'); document.getElementById('settings-menu').classList.remove('open'); }
|
|
|
|
// ── Property Comparison Table ──
|
|
function openPropCompare() {
|
|
document.getElementById('settings-menu').classList.remove('open');
|
|
const modal = document.getElementById('prop-compare-modal');
|
|
const content = document.getElementById('prop-compare-content');
|
|
|
|
// Extract tracked properties from history / memory
|
|
const propData = extractPropertiesFromHistory();
|
|
if (propData.length < 2) {
|
|
content.innerHTML = `<div style="text-align:center;padding:32px;color:var(--text2)">
|
|
<div style="font-size:32px;margin-bottom:12px">🏘</div>
|
|
<div style="font-size:14px;font-weight:600;color:var(--text);margin-bottom:8px">Track 2+ properties first</div>
|
|
<div style="font-size:12px;color:var(--text3);margin-bottom:16px">Add properties by asking: "Add my property at [address] purchased for $X"</div>
|
|
<button onclick="document.getElementById('prop-compare-modal').classList.remove('open');sendQuick('Add my property at 123 Main St Austin TX, purchased for $400000, current value $450000, mortgage balance $320000')"
|
|
style="padding:8px 16px;border-radius:8px;border:none;background:var(--indigo);color:#fff;cursor:pointer;font-size:12px">Add a property →</button>
|
|
</div>`;
|
|
} else {
|
|
const fields = ['Address', 'Purchase Price', 'Current Value', 'Mortgage', 'Equity', 'Gain'];
|
|
const rows = fields.map(f => {
|
|
const cells = propData.map(p => {
|
|
if (f === 'Equity') return `<td class="highlight">$${((p.value || 0) - (p.mortgage || 0)).toLocaleString()}</td>`;
|
|
if (f === 'Gain') {
|
|
const gain = ((p.value || 0) - (p.purchase || 0));
|
|
return `<td class="${gain >= 0 ? 'highlight' : ''}" style="${gain < 0 ? 'color:var(--red)' : ''}">${gain >= 0 ? '+' : ''}$${Math.abs(gain).toLocaleString()}</td>`;
|
|
}
|
|
const key = f.toLowerCase().replace(' ', '_');
|
|
const val = p[key] || p[f.toLowerCase().replace(' price', '').replace(' ', '_')] || '—';
|
|
return `<td>${typeof val === 'number' ? '$' + val.toLocaleString() : val}</td>`;
|
|
}).join('');
|
|
return `<tr><th>${f}</th>${cells}</tr>`;
|
|
}).join('');
|
|
content.innerHTML = `<table class="prop-compare-table"><thead><tr><th>Field</th>${propData.map(p => `<th>${escapeHtml((p.address || 'Property').slice(0, 20))}</th>`).join('')}</tr></thead><tbody>${rows}</tbody></table>`;
|
|
}
|
|
modal.classList.add('open');
|
|
}
|
|
|
|
function extractPropertiesFromHistory() {
|
|
const props = [];
|
|
for (const msg of history) {
|
|
if (msg.role !== 'assistant') continue;
|
|
// Look for property data patterns in responses
|
|
const matches = msg.content.match(/([0-9]+ [A-Za-z].{5,40})\s*[\|\n].{0,50}\$([0-9,]+)/g);
|
|
if (matches) matches.forEach(m => {
|
|
const nums = m.match(/\$([0-9,]+)/g) || [];
|
|
props.push({ address: m.slice(0, 30), purchase: parseInt((nums[0] || '').replace(/[$,]/g, '')), value: parseInt((nums[1] || '').replace(/[$,]/g, '')) });
|
|
});
|
|
}
|
|
return props.slice(0, 4);
|
|
}
|
|
|
|
// ── Portfolio Donut Chart ──
|
|
const DONUT_COLORS = ['#6366f1','#22c55e','#f59e0b','#ef4444','#8b5cf6','#14b8a6','#f97316','#ec4899','#06b6d4','#84cc16'];
|
|
function openDonutChart() {
|
|
document.getElementById('settings-menu').classList.remove('open');
|
|
const modal = document.getElementById('donut-modal');
|
|
const content = document.getElementById('donut-content');
|
|
const holdings = extractHoldingsFromHistory();
|
|
if (!holdings.length) {
|
|
content.innerHTML = `<div style="text-align:center;padding:28px;color:var(--text2)"><div style="font-size:32px;margin-bottom:10px">🍩</div><div style="font-weight:600;color:var(--text);margin-bottom:6px">No holdings data yet</div><button onclick="document.getElementById('donut-modal').classList.remove('open');sendQuick('Give me a full portfolio summary with all holdings and allocation percentages')" style="padding:8px 16px;border-radius:8px;border:none;background:var(--indigo);color:#fff;cursor:pointer;font-size:12px;margin-top:8px">Get Portfolio Summary →</button></div>`;
|
|
} else {
|
|
const total = holdings.reduce((s, h) => s + h.weight, 0);
|
|
const R = 70, cx = 80, cy = 80, strokeW = 28;
|
|
let offset = 0;
|
|
const circumference = 2 * Math.PI * R;
|
|
const slices = holdings.map((h, i) => {
|
|
const pct = h.weight / total;
|
|
const dash = pct * circumference;
|
|
const gap = circumference - dash;
|
|
const slice = `<circle cx="${cx}" cy="${cy}" r="${R}" fill="none" stroke="${DONUT_COLORS[i % DONUT_COLORS.length]}" stroke-width="${strokeW}" stroke-dasharray="${dash.toFixed(2)} ${gap.toFixed(2)}" stroke-dashoffset="${(-offset * circumference).toFixed(2)}" transform="rotate(-90 ${cx} ${cy})" style="cursor:pointer" onclick="document.getElementById('donut-modal').classList.remove('open');sendQuick('Tell me more about my ${h.ticker} position')" title="${h.ticker}: ${(pct*100).toFixed(1)}%"><title>${h.ticker}: ${(pct*100).toFixed(1)}%</title></circle>`;
|
|
offset += pct;
|
|
return { slice, ticker: h.ticker, pct, color: DONUT_COLORS[i % DONUT_COLORS.length] };
|
|
});
|
|
const svg = `<svg viewBox="0 0 160 160" class="donut-svg" style="width:140px;height:140px"><circle cx="${cx}" cy="${cy}" r="${R}" fill="none" stroke="var(--border)" stroke-width="${strokeW}"/>${slices.map(s => s.slice).join('')}<text x="${cx}" y="${cy-4}" text-anchor="middle" font-size="9" fill="var(--text3)">Allocation</text><text x="${cx}" y="${cy+10}" text-anchor="middle" font-size="8" fill="var(--text3)">${holdings.length} holdings</text></svg>`;
|
|
const legend = slices.map(s => `<div class="donut-legend-item"><div class="donut-legend-dot" style="background:${s.color}"></div><span>${s.ticker}</span><span class="donut-legend-pct">${(s.pct*100).toFixed(1)}%</span></div>`).join('');
|
|
content.innerHTML = `<div class="donut-wrap">${svg}<div class="donut-legend">${legend}</div></div><div style="font-size:11px;color:var(--text3);margin-top:10px">Click a slice to ask about that holding</div>`;
|
|
}
|
|
modal.classList.add('open');
|
|
}
|
|
|
|
// ── Email Digest ──
|
|
function copyEmailDigest() {
|
|
document.getElementById('settings-menu').classList.remove('open');
|
|
if (!history.length) { showToast('No conversation to digest yet.'); return; }
|
|
const title = currentSessionTitle || 'Ghostfolio AI Agent — Portfolio Digest';
|
|
const date = new Date().toLocaleDateString([], { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' });
|
|
const lines = [
|
|
`Subject: ${title}`,
|
|
`Date: ${date}`,
|
|
'',
|
|
'─'.repeat(60),
|
|
`Ghostfolio AI Agent Portfolio Digest`,
|
|
'─'.repeat(60),
|
|
''
|
|
];
|
|
for (const msg of history) {
|
|
if (msg.role === 'user') {
|
|
lines.push(`▶ You asked:`);
|
|
lines.push(` ${msg.content.slice(0, 200)}`);
|
|
lines.push('');
|
|
} else {
|
|
lines.push(` Agent:`);
|
|
const clean = msg.content
|
|
.replace(/\[(portfolio|compliance|market|property|real_estate|tax|transaction)_[^\]]+\]/g, '')
|
|
.replace(/\[[a-z_]+_\d{6,}\]/g, '')
|
|
.replace(/\*\*(.*?)\*\*/g, '$1').replace(/#{1,6}\s/g, '').slice(0, 600);
|
|
lines.push(` ${clean}`);
|
|
lines.push('');
|
|
}
|
|
}
|
|
lines.push('─'.repeat(60));
|
|
lines.push('Powered by Ghostfolio AI Agent · ghostfolio.ai');
|
|
lines.push('⚠ This is not financial advice.');
|
|
navigator.clipboard.writeText(lines.join('\n')).then(() => showToast('📧 Email digest copied! Paste into Gmail or Outlook.'));
|
|
}
|
|
|
|
// ── Batch Export ──
|
|
let batchSelected = new Set();
|
|
function openBatchExport() {
|
|
document.getElementById('settings-menu').classList.remove('open');
|
|
batchSelected = new Set();
|
|
const list = document.getElementById('batch-list');
|
|
const agentMsgs = history.filter(m => m.role === 'assistant');
|
|
if (!agentMsgs.length) { showToast('No responses to export yet.'); return; }
|
|
list.innerHTML = agentMsgs.map((m, i) => {
|
|
const preview = m.content.slice(0, 80).replace(/\n/g, ' ') + '…';
|
|
return `<label style="display:flex;align-items:flex-start;gap:10px;padding:8px;border-radius:8px;cursor:pointer;border:1px solid var(--border);background:var(--surface)">
|
|
<input type="checkbox" data-idx="${i}" onchange="batchSelected.has(${i})?batchSelected.delete(${i}):batchSelected.add(${i})" style="margin-top:3px;accent-color:var(--indigo);flex-shrink:0">
|
|
<span style="font-size:11px;color:var(--text2);line-height:1.5">${escapeHtml(preview)}</span>
|
|
</label>`;
|
|
}).join('');
|
|
document.getElementById('batch-modal').classList.add('open');
|
|
}
|
|
|
|
function batchExportSelected(fmt) {
|
|
const agentMsgs = history.filter(m => m.role === 'assistant');
|
|
const selected = agentMsgs.filter((_, i) => batchSelected.has(i));
|
|
if (!selected.length) { showToast('Select at least one response.'); return; }
|
|
const ts = new Date().toLocaleString();
|
|
let content;
|
|
if (fmt === 'md') {
|
|
content = `# Ghostfolio Export\n_${ts}_\n\n⚠ This is not financial advice.\n\n---\n\n` +
|
|
selected.map((m, i) => `## Response ${i + 1}\n\n${m.content.replace(/\[(portfolio|compliance|market|property|real_estate|tax|transaction)_[^\]]+\]/g,'').replace(/\[[a-z_]+_\d{6,}\]/g,'')}\n`).join('\n---\n\n');
|
|
} else {
|
|
content = `Ghostfolio Export — ${ts}\n\n` +
|
|
selected.map((m, i) => `--- Response ${i + 1} ---\n${m.content.replace(/\[(portfolio|compliance|market|property|real_estate|tax|transaction)_[^\]]+\]/g,'').replace(/\[[a-z_]+_\d{6,}\]/g,'')}\n`).join('\n');
|
|
}
|
|
const blob = new Blob([content], { type: 'text/plain' });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url; a.download = `ghostfolio-export-${Date.now()}.${fmt}`;
|
|
a.click(); URL.revokeObjectURL(url);
|
|
document.getElementById('batch-modal').classList.remove('open');
|
|
showToast(`✅ ${selected.length} response${selected.length !== 1 ? 's' : ''} exported as .${fmt}`);
|
|
}
|
|
document.getElementById('batch-export-btn').addEventListener('click', openBatchExport);
|
|
|
|
// ── County Market Alert ──
|
|
function checkCountyAlert() {
|
|
const alertKey = 'gf_county_alert_v1';
|
|
let alert;
|
|
try { alert = JSON.parse(localStorage.getItem(alertKey)); } catch { alert = null; }
|
|
if (!alert) return;
|
|
const lastValue = alert.value;
|
|
// Check last known Austin market data from history
|
|
for (const msg of [...history].reverse()) {
|
|
if (msg.role !== 'assistant') continue;
|
|
const m = msg.content.match(/median.{0,20}\$([0-9,]+)/i);
|
|
if (m) {
|
|
const current = parseInt(m[1].replace(/,/g, ''));
|
|
const pctChange = ((current - lastValue) / lastValue) * 100;
|
|
if (Math.abs(pctChange) >= alert.threshold) {
|
|
showToast(`📊 County alert: Austin median price changed ${pctChange > 0 ? '+' : ''}${pctChange.toFixed(1)}% from your baseline!`);
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Session Reminder (Notifications API) ──
|
|
function setReminder(days) {
|
|
if (!('Notification' in window)) { showToast('Browser notifications not supported.'); return; }
|
|
Notification.requestPermission().then(perm => {
|
|
if (perm !== 'granted') { showToast('Please allow notifications to set reminders.'); return; }
|
|
const fireAt = Date.now() + days * 24 * 60 * 60 * 1000;
|
|
try { localStorage.setItem('gf_reminder', JSON.stringify({ fireAt, days })); } catch {}
|
|
showToast(`🔔 Reminder set for ${days} day${days !== 1 ? 's' : ''} from now.`);
|
|
document.getElementById('reminder-modal').classList.remove('open');
|
|
// Schedule check (only works if page stays open — for real app, use service worker)
|
|
const msUntil = fireAt - Date.now();
|
|
if (msUntil < 30 * 60 * 1000) { // only schedule if < 30min away
|
|
setTimeout(() => {
|
|
new Notification('Ghostfolio AI Agent', { body: 'Time to check your portfolio!', icon: '/favicon.ico' });
|
|
}, msUntil);
|
|
}
|
|
});
|
|
}
|
|
document.getElementById('reminder-btn').addEventListener('click', () => { document.getElementById('settings-menu').classList.remove('open'); document.getElementById('reminder-modal').classList.add('open'); });
|
|
|
|
// Check for pending reminder on load
|
|
(function checkPendingReminder() {
|
|
try {
|
|
const r = JSON.parse(localStorage.getItem('gf_reminder'));
|
|
if (r && Date.now() >= r.fireAt) {
|
|
localStorage.removeItem('gf_reminder');
|
|
showToast('🔔 Portfolio reminder: time to check in!');
|
|
setTimeout(() => {
|
|
document.getElementById('greeting-text').textContent = '⏰ Reminder!';
|
|
document.getElementById('greeting-sub').textContent = `Your ${r.days}-day portfolio reminder is up. Want a quick summary?`;
|
|
document.getElementById('greeting-banner').classList.add('show');
|
|
}, 1500);
|
|
}
|
|
} catch {}
|
|
})();
|
|
|
|
// ── Conversation Branching ──
|
|
function branchFrom(userMsgEl, originalQuery) {
|
|
// Save current session first
|
|
saveCurrentSession();
|
|
// Create new session with history up to this point
|
|
const allMsgs = [...chat.querySelectorAll('.message')];
|
|
const msgIndex = allMsgs.indexOf(userMsgEl);
|
|
const historyUpTo = history.slice(0, Math.max(0, msgIndex - 1));
|
|
startNewChat();
|
|
history = [...historyUpTo];
|
|
// Rebuild chat display
|
|
historyUpTo.forEach(msg => addMessage(msg.role === 'user' ? 'user' : 'agent', msg.content));
|
|
showToast('🌿 Branched from that point — history preserved, ask differently!');
|
|
}
|
|
|
|
// Add branch button to user messages in addMessage
|
|
// (Already have edit btn — add branch btn as sibling)
|
|
|
|
// ── PWA Manifest ──
|
|
(function initPWA() {
|
|
if (localStorage.getItem('gf_pwa_dismissed')) return;
|
|
const manifest = {
|
|
name: 'Ghostfolio AI Agent',
|
|
short_name: 'GF Agent',
|
|
start_url: '/',
|
|
display: 'standalone',
|
|
background_color: '#0a0d14',
|
|
theme_color: '#6366f1',
|
|
icons: [{ src: 'https://via.placeholder.com/192/6366f1/ffffff?text=GF', sizes: '192x192', type: 'image/png' }]
|
|
};
|
|
const blob = new Blob([JSON.stringify(manifest)], { type: 'application/json' });
|
|
const link = document.createElement('link');
|
|
link.rel = 'manifest'; link.href = URL.createObjectURL(blob);
|
|
document.head.appendChild(link);
|
|
|
|
let deferredPrompt = null;
|
|
window.addEventListener('beforeinstallprompt', e => {
|
|
e.preventDefault();
|
|
deferredPrompt = e;
|
|
document.getElementById('pwa-banner').classList.add('show');
|
|
document.getElementById('pwa-install-btn').onclick = () => {
|
|
deferredPrompt.prompt();
|
|
deferredPrompt.userChoice.then(() => {
|
|
document.getElementById('pwa-banner').classList.remove('show');
|
|
deferredPrompt = null;
|
|
});
|
|
};
|
|
});
|
|
})();
|
|
|
|
// ── Email digest Settings button ──
|
|
document.getElementById('email-digest-btn').addEventListener('click', copyEmailDigest);
|
|
|
|
// ── Wire new features into done event & send ──
|
|
// (profile context + county alert check already done via memory prefix)
|
|
// Note: placeholder and county alert checks are fired from the done event listener below
|
|
|
|
// ── Swipe to Archive (mobile touch) ──
|
|
function addSwipeToArchive(el, sessionId) {
|
|
let startX = 0;
|
|
el.addEventListener('touchstart', e => { startX = e.touches[0].clientX; }, { passive: true });
|
|
el.addEventListener('touchend', e => {
|
|
const dx = e.changedTouches[0].clientX - startX;
|
|
if (dx < -80) {
|
|
el.style.transition = 'transform 0.2s'; el.style.transform = 'translateX(-60px)';
|
|
setTimeout(() => { el.style.transform = ''; archiveSession(sessionId); }, 200);
|
|
}
|
|
}, { passive: true });
|
|
}
|
|
|
|
// Swipe is applied after renderDrawer via the patchSwipeIntoDrawer call
|
|
|
|
// ── Collaborative Annotations via URL ──
|
|
function shareAnnotations() {
|
|
const annots = getAnnotations();
|
|
if (!Object.keys(annots).length) { showToast('No annotations yet. Add sticky notes first.'); return; }
|
|
try {
|
|
const encoded = btoa(encodeURIComponent(JSON.stringify(annots)));
|
|
const url = window.location.origin + window.location.pathname + '#annotations=' + encoded;
|
|
navigator.clipboard.writeText(url).then(() => showToast('📎 Annotation share link copied!'));
|
|
} catch { showToast('Could not encode annotations.'); }
|
|
}
|
|
|
|
// Restore shared annotations from URL hash
|
|
(function restoreSharedAnnotations() {
|
|
const hash = window.location.hash;
|
|
if (!hash.startsWith('#annotations=')) return;
|
|
try {
|
|
const data = JSON.parse(decodeURIComponent(atob(hash.slice('#annotations='.length))));
|
|
const existing = getAnnotations();
|
|
Object.assign(existing, data);
|
|
localStorage.setItem('gf_annotations_v1', JSON.stringify(existing));
|
|
showToast('📎 Shared annotations loaded!');
|
|
window.history.replaceState(null, '', window.location.pathname);
|
|
} catch {}
|
|
})();
|
|
|
|
// ── Font Size Controls ──
|
|
const FS_KEY = 'gf_font_size';
|
|
const FS_MAP = { small: '13px', medium: '15px', large: '17px' };
|
|
function setFontSize(size) {
|
|
document.documentElement.style.setProperty('font-size', FS_MAP[size] || '15px');
|
|
try { localStorage.setItem(FS_KEY, size); } catch {}
|
|
['small', 'medium', 'large'].forEach(s => {
|
|
const btn = document.getElementById(`fs-${s}`);
|
|
if (btn) btn.style.borderColor = s === size ? 'var(--indigo)' : 'var(--border2)';
|
|
if (btn) btn.style.color = s === size ? 'var(--indigo2)' : 'var(--text2)';
|
|
});
|
|
}
|
|
// Apply saved font size on load
|
|
const savedFs = (() => { try { return localStorage.getItem(FS_KEY); } catch { return null; } })();
|
|
if (savedFs) setFontSize(savedFs);
|
|
|
|
// ── Sign out ──
|
|
document.getElementById('logout-btn').addEventListener('click', () => {
|
|
localStorage.removeItem('gf_token');
|
|
localStorage.removeItem('gf_user_name');
|
|
localStorage.removeItem('gf_user_email');
|
|
localStorage.removeItem(STORAGE_KEY);
|
|
// Clear session-specific memory (keep watchlist / memory by default — user owns those)
|
|
window.location.replace('/');
|
|
});
|
|
|
|
// ── Clear session ──
|
|
document.getElementById('clear-btn').addEventListener('click', () => {
|
|
saveCurrentSession();
|
|
|
|
const msgs = sessionStats.messages;
|
|
const tools = sessionStats.toolCalls;
|
|
const avgLat = sessionStats.latencies.length
|
|
? (
|
|
sessionStats.latencies.reduce((a, b) => a + b, 0) /
|
|
sessionStats.latencies.length
|
|
).toFixed(1)
|
|
: '—';
|
|
|
|
toastEl.textContent = `Session saved · ${msgs} msg${msgs !== 1 ? 's' : ''} · ${tools} tool call${tools !== 1 ? 's' : ''} · avg ${avgLat}s`;
|
|
toastEl.classList.add('show');
|
|
setTimeout(() => toastEl.classList.remove('show'), 2800);
|
|
|
|
currentSessionId = Date.now().toString();
|
|
currentSessionTitle = null;
|
|
history = [];
|
|
pendingWrite = null;
|
|
sessionStats = { messages: 0, toolCalls: 0, latencies: [] };
|
|
localStorage.removeItem(STORAGE_KEY);
|
|
|
|
chat.innerHTML = '';
|
|
chat.appendChild(emptyEl);
|
|
emptyEl.style.display = '';
|
|
latChip.classList.add('hidden');
|
|
document.title = 'Ghostfolio AI Agent';
|
|
document.getElementById('networth-badge').style.display = 'none';
|
|
resetContextStrip();
|
|
// Reset per-session UI
|
|
closeQueryHistory();
|
|
document.getElementById('memory-panel').classList.remove('open');
|
|
updateHeaderTitle();
|
|
});
|
|
</script>
|
|
</body>
|
|
</html>
|
|
|