Files
Zportal-Wiki-VectorDB-Chat/zportal-chat.html
admin 4b4235ad7f Improve error messages for missing API keys and HTTP errors
Show clear guidance when provider has no key configured instead of
cryptic 401. Add friendly messages for 429/403 errors during streaming.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 11:15:38 +00:00

1488 lines
63 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>Z.ai Chat</title>
<style>
/* ── Odysseus Design Tokens ── */
:root {
--bg: #1a1b26;
--fg: #c0caf5;
--panel: #16161e;
--border: #292e42;
--red: #f7768e;
--accent: #7aa2f7;
--accent-secondary: #bb9af7;
--green: #9ece6a;
--warn: #e0af68;
--sidebar-bg: #13141f;
--input-bg: #1a1b26;
--user-bubble-bg: rgba(122,162,247,.08);
--ai-bubble-bg: var(--panel);
--bubble-border: var(--border);
--font-family: 'Inter', system-ui, -apple-system, sans-serif;
--chat-max: 800px;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; height: 100dvh; overflow: hidden; }
body {
background: var(--bg);
color: var(--fg);
font-family: var(--font-family);
display: flex;
}
/* ── Ecosystem Nav ── */
.eco-nav {
position: fixed; top: 0; left: 0; right: 0; z-index: 300;
height: 38px; background: var(--panel); border-bottom: 1px solid var(--border);
display: flex; align-items: center; padding: 0 16px; gap: 4px;
}
.eco-link { color: var(--fg); opacity: .55; text-decoration: none; font-size: 12px;
padding: 5px 10px; border-radius: 6px; transition: all .15s; display: flex; align-items: center; gap: 4px;
white-space: nowrap; border-bottom: none; }
.eco-link:hover { opacity: .85; background: rgba(198,202,245,.06); }
.eco-link.active { opacity: 1; color: var(--accent); }
.eco-link.brand { font-weight: 700; opacity: 1; padding: 5px 8px; }
/* ── Layout Shell ── */
.app-shell {
display: flex; width: 100%; height: 100%; padding-top: 38px;
}
/* ── Sidebar (Odysseus-style) ── */
.sidebar {
width: 252px; min-width: 252px; max-width: 400px;
background: var(--sidebar-bg); border-right: 1px solid var(--border);
display: flex; flex-direction: column; flex-shrink: 0;
transition: width .22s ease, opacity .2s ease, transform .3s cubic-bezier(.25,1,.5,1);
position: relative; z-index: 50;
}
.sidebar.collapsed { width: 0; min-width: 0; opacity: 0; overflow: hidden; }
.sidebar-header {
display: flex; align-items: center; justify-content: space-between;
padding: 14px 12px 10px; flex-shrink: 0; gap: 8px;
}
.sidebar-brand {
display: flex; align-items: center; gap: 8px; cursor: pointer;
flex: 1; min-width: 0;
}
.sidebar-brand-icon {
width: 28px; height: 28px; border-radius: 8px;
background: linear-gradient(135deg, var(--accent), var(--secondary));
display: flex; align-items: center; justify-content: center; font-size: 14px; flex-shrink: 0;
}
.sidebar-brand-title {
font-size: .95rem; font-weight: 700; color: var(--red);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
letter-spacing: .02em;
}
.sidebar-toggle-btn {
background: none; border: none; color: var(--fg); opacity: .45;
cursor: pointer; padding: 5px; border-radius: 6px; display: flex; align-items: center;
transition: opacity .15s, background .15s;
}
.sidebar-toggle-btn:hover { opacity: .75; background: rgba(198,202,245,.08); }
.sidebar-inner {
flex: 1; overflow-y: auto; overflow-x: hidden;
display: flex; flex-direction: column; gap: 2px;
padding: 4px 8px 8px; min-height: 0;
scrollbar-width: none;
}
.sidebar-inner::-webkit-scrollbar { display: none; }
.list-item {
display: flex; align-items: center; gap: 8px;
padding: 7px 10px; border-radius: 8px; cursor: pointer;
font-size: .82rem; color: var(--fg); opacity: .72;
transition: background .12s, opacity .12s;
white-space: nowrap; user-select: none; position: relative;
}
.list-item:hover { background: rgba(198,202,245,.07); opacity: .9; }
.list-item.active { background: rgba(247,118,142,.1); opacity: 1; }
.list-item svg { flex-shrink: 0; opacity: .55; }
.list-item:hover svg { opacity: .8; }
.list-item.active svg { opacity: 1; color: var(--red); }
.grow { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
.section { margin-top: 4px; }
.section-header-flex {
display: flex; align-items: center; gap: 6px;
padding: 6px 10px 4px; cursor: default;
font-size: .7rem; font-weight: 600; text-transform: uppercase;
letter-spacing: .08em; color: var(--fg); opacity: .35;
user-select: none;
}
.section-title { display: flex; align-items: center; gap: 5px; }
.section-icon { flex-shrink: 0; }
.provider-item { position: relative; }
.provider-item .p-actions {
display: none; position: absolute; right: 6px; top: 50%; transform: translateY(-50%); gap: 2px;
}
.provider-item:hover .p-actions { display: flex; }
.p-action-btn {
width: 22px; height: 22px; border: none; border-radius: 4px;
background: transparent; color: var(--fg); opacity: .35; font-size: 11px;
cursor: pointer; display: flex; align-items: center; justify-content: center;
transition: all .12s;
}
.p-action-btn:hover { opacity: .7; background: rgba(198,202,245,.1); }
.p-action-btn.p-del:hover { color: var(--red); opacity: 1; }
.p-check { color: var(--green); font-size: 11px; opacity: 0; transition: opacity .15s; }
.provider-item.active .p-check { opacity: 1; }
.sidebar-footer {
padding: 8px; border-top: 1px solid var(--border); flex-shrink: 0;
}
.new-provider-btn {
width: 100%; padding: 8px; border-radius: 8px;
border: 1px dashed var(--border); background: transparent;
color: var(--fg); opacity: .45; font-size: .8rem; cursor: pointer;
font-family: inherit; display: flex; align-items: center; justify-content: center; gap: 6px;
transition: all .15s;
}
.new-provider-btn:hover { opacity: .75; border-color: var(--red); color: var(--red); }
/* ── Main Chat Container ── */
.chat-container {
flex: 1; display: flex; flex-direction: column;
padding: 0 20px; overflow: hidden; position: relative;
min-height: 0; min-width: 0;
}
.chat-top-bar {
display: flex; align-items: center; justify-content: center;
flex-shrink: 0; position: relative; z-index: 2;
padding: 6px 0 2px; min-height: 28px;
}
.chat-meta-overlay {
font-size: .72rem; color: var(--fg); opacity: .35;
display: flex; align-items: center; gap: 6px;
}
.chat-meta-count { opacity: .25; }
.mobile-sidebar-btn {
display: none; background: none; border: none; color: var(--fg); opacity: .5;
cursor: pointer; padding: 4px 8px; font-size: 18px; position: absolute; left: 0;
}
/* ── Welcome Screen ── */
#welcome-screen {
position: absolute; top: 40%; left: 50%;
transform: translate(-50%, -50%);
display: flex; flex-direction: column; align-items: center;
text-align: center; pointer-events: none;
animation: welcome-enter .4s ease-out both;
transition: top .35s cubic-bezier(.34,1.56,.64,1), opacity .3s ease;
}
@keyframes welcome-enter { from { opacity: 0; transform: translate(-50%, -48%) scale(.97); } }
.welcome-name {
font-size: 2rem; font-weight: 700;
background: linear-gradient(135deg, var(--red), var(--accent));
-webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text;
letter-spacing: .03em; margin-bottom: 10px; display: flex; align-items: center; gap: 10px;
}
.welcome-logo { width: 2rem; height: 2rem; vertical-align: -.15em; color: var(--red); }
.welcome-sub {
font-size: .85rem; color: var(--fg); opacity: .5;
line-height: 1.5; max-width: 360px; margin-bottom: 28px;
}
#welcome-screen.hidden { display: none; }
.quick-grid {
display: flex; flex-wrap: wrap; gap: 6px; justify-content: center; max-width: 480px;
pointer-events: auto;
}
.quick-chip {
padding: 8px 14px; border-radius: 20px; border: 1px solid var(--border);
background: var(--panel); color: var(--fg); opacity: .6; font-size: .78rem;
cursor: pointer; transition: all .15s; font-family: inherit;
}
.quick-chip:hover { opacity: 1; border-color: var(--accent); color: var(--accent);
transform: translateY(-1px); box-shadow: 0 2px 12px rgba(122,162,247,.1); }
/* ── Chat History / Messages ── */
.chat-history {
flex: 1; overflow-y: auto; overflow-x: hidden;
margin-bottom: 8px; min-height: 0;
padding-left: max(0px, calc((100% - var(--chat-max)) / 2));
padding-right: max(12px, calc((100% - var(--chat-max)) / 2 + 12px));
scrollbar-width: thin; scrollbar-color: var(--border) transparent;
}
.chat-history::-webkit-scrollbar { width: 5px; }
.chat-history::-webkit-scrollbar-track { background: transparent; }
.chat-history::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
.msg {
margin: 8px 0; position: relative; display: flex; flex-direction: column;
width: fit-content; max-width: 85%; min-width: 80px;
border-radius: 12px; padding: 10px 14px; line-height: 1.5;
word-wrap: break-word; animation: msg-enter .3s ease-out both;
}
@keyframes msg-enter { from { opacity: 0; transform: translateY(8px); } }
.msg-user {
align-self: flex-end; margin-left: auto; margin-right: 8px;
background: var(--user-bubble-bg); border: 1px solid var(--bubble-border);
border-radius: 18px 18px 2px 18px;
}
.msg-ai {
align-self: flex-start; margin-right: auto; margin-left: 8px;
background: var(--ai-bubble-bg); border: 1px solid var(--bubble-border);
border-radius: 18px 18px 18px 2px;
}
.msg-error {
background: rgba(247,118,142,.08); border-color: rgba(247,118,142,.2);
color: var(--red);
}
.msg .role {
font-weight: 600; font-size: .72rem; margin-bottom: 5px;
display: flex; align-items: center; gap: 6px;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.msg-user .role { color: var(--fg); opacity: .55; }
.msg-ai .role { color: var(--accent); opacity: .8; }
.msg .role::before {
content: ''; width: 7px; height: 7px; border-radius: 50%;
background: var(--model-dot, rgba(198,202,245,.25)); flex-shrink: 0;
}
.msg-user .role::before { background: rgba(198,202,245,.35); }
.msg .body { width: 100%; white-space: normal; word-break: break-word;
font-size: .92em; line-height: 1.55; }
.msg-user .body { color: var(--fg); }
.msg-ai .body { color: var(--fg); opacity: .88; }
.msg .timestamp {
font-size: .65rem; color: var(--fg); opacity: .3;
margin-top: 5px; text-align: right;
}
.msg-user .timestamp { opacity: .45; }
.typing-indicator {
display: flex; align-items: center; gap: 6px;
padding: 12px 18px; font-size: .78rem; color: var(--fg); opacity: .35; font-style: italic;
}
.typing-dots { display: flex; gap: 3px; }
.typing-dots span {
width: 6px; height: 6px; border-radius: 50%; background: var(--fg); opacity: .3;
animation: typingBounce 1.4s infinite;
}
.typing-dots span:nth-child(2) { animation-delay: .2s; }
.typing-dots span:nth-child(3) { animation-delay: .4s; }
@keyframes typingBounce {
0%,60%,100% { transform: translateY(0); opacity: .3; }
30% { transform: translateY(-6px); opacity: .7; }
}
/* ── RAG Context Panel ── */
.rag-panel {
max-width: var(--chat-max); margin: 0 auto 8px;
border: 1px solid var(--border); border-radius: 10px;
background: var(--panel); overflow: hidden; display: none;
}
.rag-panel.open { display: block; }
.rag-header {
display: flex; align-items: center; justify-content: space-between;
padding: 8px 12px; cursor: pointer; font-size: .72rem; font-weight: 600;
color: var(--fg); opacity: .5; gap: 6px;
}
.rag-body { padding: 10px 12px; font-size: .75rem; color: var(--fg); opacity: .65;
line-height: 1.55; max-height: 180px; overflow-y: auto; }
.rag-section { margin-bottom: 8px; }
.rag-section:last-child { margin-bottom: 0; }
.rag-section-title {
font-size: .65rem; font-weight: 700; text-transform: uppercase;
letter-spacing: .05em; color: var(--fg); opacity: .35; margin-bottom: 3px;
}
.rag-source {
padding: 4px 8px; border-radius: 5px; background: rgba(198,202,245,.04);
margin-bottom: 3px; font-size: .72rem;
}
/* ── Input Bar (Odysseus-style unified input) ── */
.chat-input-bar {
background: var(--input-bg); border: 1px solid var(--border);
border-radius: 16px; padding: 10px 12px;
display: flex; flex-direction: column; gap: 6px;
max-width: 800px; margin: 0 auto; width: 100%;
transition: margin .3s ease, max-width .3s ease;
}
.chat-container.welcome-active .chat-input-bar {
margin-bottom: 28vh;
}
.chat-input-top { width: 100%; position: relative; }
.chat-input-bar textarea {
width: 100%; background: transparent; border: none; outline: none;
resize: none; font-size: .9rem; line-height: 1.5; color: var(--fg);
min-height: 24px; max-height: min(60vh, 500px); padding: 0;
font-family: inherit; transition: height .12s ease-out;
}
.chat-input-bar textarea::placeholder { color: var(--fg); opacity: .3; }
.model-picker-wrap {
position: absolute; top: 0; right: 0; z-index: 2;
}
.model-picker-btn {
background: none; border: 1px solid var(--border); border-radius: 8px;
color: var(--fg); opacity: .5; font-size: .72rem; cursor: pointer;
padding: 3px 8px; display: flex; align-items: center; gap: 4px;
transition: all .15s; font-family: inherit;
}
.model-picker-btn:hover { opacity: .8; border-color: var(--accent); color: var(--accent); }
.model-picker-menu {
position: absolute; top: 100%; right: 0; margin-top: 4px;
background: var(--panel); border: 1px solid var(--border); border-radius: 10px;
min-width: 220px; max-height: 300px; overflow-y: auto;
z-index: 100; display: none; box-shadow: 0 8px 32px rgba(0,0,0,.3);
}
.model-picker-menu.open { display: block; }
.model-picker-item {
padding: 8px 12px; font-size: .8rem; cursor: pointer; display: flex; align-items: center; gap: 8px;
color: var(--fg); opacity: .7; transition: all .1s;
}
.model-picker-item:hover { background: rgba(198,202,245,.07); opacity: 1; }
.model-picker-item.selected { background: rgba(247,118,142,.1); color: var(--red); opacity: 1; }
.model-picker-item .mp-icon { font-size: 14px; flex-shrink: 0; }
.model-picker-item .mp-info { flex: 1; min-width: 0; }
.model-picker-item .mp-name { font-weight: 500; }
.model-picker-item .mp-desc { font-size: .68rem; opacity: .45; overflow: hidden; text-overflow: ellipsis; }
.chat-input-bottom {
display: flex; justify-content: space-between; align-items: center; margin-top: 2px;
}
.chat-input-left { display: flex; gap: 4px; align-items: center; flex: 1; min-width: 0; }
.chat-input-right { display: flex; gap: 8px; align-items: center; flex-shrink: 0; }
.input-icon-btn {
background: none; border: none; color: var(--fg); opacity: .4;
cursor: pointer; padding: 5px; border-radius: 7px;
display: flex; align-items: center; justify-content: center;
transition: opacity .15s, background .15s;
}
.input-icon-btn:hover { opacity: .75; background: rgba(198,202,245,.07); }
.input-icon-btn.active { opacity: 1; color: var(--red); background: rgba(247,118,142,.1); }
.send-btn {
background: var(--red); color: #fff; border: none;
border-radius: 8px; min-width: 32px; width: 32px; height: 32px;
padding: 0; cursor: pointer; display: flex; align-items: center; justify-content: center;
transition: background .25s, transform .15s; flex-shrink: 0;
}
.send-btn:hover { background: color-mix(in srgb, var(--red) 80%, white); transform: scale(1.05); }
.send-btn:disabled { opacity: .35; cursor: not-allowed; transform: none; }
.send-btn.streaming { animation: siren-pulse 1.5s ease-in-out infinite; }
@keyframes siren-pulse {
0%,100% { transform: scale(1); } 50% { transform: scale(.88); }
}
.input-hint {
font-size: .65rem; color: var(--fg); opacity: .2; text-align: center;
padding: 2px 0 0;
}
/* ── Settings Modal (Odysseus modal style) ── */
.modal-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,.55);
z-index: 200; display: none; backdrop-filter: blur(4px);
align-items: center; justify-content: center;
}
.modal-overlay.open { display: flex; }
.modal-content {
background: var(--bg); border: 1px solid var(--border);
border-radius: 14px; width: 480px; max-width: 92vw; max-height: 80vh;
display: flex; flex-direction: column; overflow: hidden;
box-shadow: 0 16px 64px rgba(0,0,0,.4);
animation: modal-in .2s ease-out;
}
@keyframes modal-in { from { opacity: 0; transform: scale(.95) translateY(10px); } }
.modal-header {
display: flex; align-items: center; justify-content: space-between;
padding: 14px 18px; border-bottom: 1px solid var(--border); flex-shrink: 0;
}
.modal-header h4 { font-size: .88rem; font-weight: 600; display: flex; align-items: center; gap: 8px; }
.close-btn {
background: none; border: none; color: var(--fg); opacity: .4;
cursor: pointer; font-size: 16px; padding: 4px; transition: opacity .12s;
}
.close-btn:hover { opacity: .8; }
.modal-body { flex: 1; overflow-y: auto; padding: 18px; }
.modal-footer {
padding: 12px 18px; border-top: 1px solid var(--border);
display: flex; gap: 8px; align-items: center; flex-shrink: 0;
}
.form-group { margin-bottom: 14px; }
.form-group label {
display: block; font-size: .7rem; font-weight: 600; text-transform: uppercase;
letter-spacing: .05em; color: var(--fg); opacity: .4; margin-bottom: 5px;
}
.form-group input, .form-group select {
width: 100%; padding: 8px 10px; border: 1px solid var(--border);
border-radius: 7px; background: var(--panel); color: var(--fg);
font-size: .82rem; font-family: inherit; outline: none; transition: border-color .15s;
}
.form-group input:focus, .form-group select:focus { border-color: var(--accent); }
.form-group input::placeholder { color: var(--fg); opacity: .25; }
.btn {
padding: 7px 14px; border-radius: 7px; font-size: .78rem; cursor: pointer;
font-family: inherit; transition: all .15s; border: 1px solid transparent;
}
.btn-primary { background: var(--red); color: #fff; }
.btn-primary:hover { background: color-mix(in srgb, var(--red) 80%, white); }
.btn-ghost { background: transparent; color: var(--fg); opacity: .6; border-color: var(--border); }
.btn-ghost:hover { opacity: 1; border-color: var(--fg); }
.btn-danger { color: var(--red); border-color: rgba(247,118,142,.25); background: transparent; }
.btn-danger:hover { background: rgba(247,118,142,.08); }
/* ── Manage Provider Cards ── */
.manage-card {
display: flex; align-items: center; gap: 12px;
padding: 10px 12px; border-radius: 10px;
border: 1px solid var(--border); background: var(--panel);
margin-bottom: 6px; transition: border-color .15s;
}
.manage-card:hover { border-color: rgba(198,202,245,.15); }
.manage-card.active-card { border-color: rgba(247,118,142,.3); background: rgba(247,118,142,.04); }
.manage-card-icon {
width: 34px; height: 34px; border-radius: 8px;
background: rgba(198,202,245,.06); display: flex; align-items: center;
justify-content: center; font-size: 16px; flex-shrink: 0;
}
.manage-card-info { flex: 1; min-width: 0; }
.manage-card-name { font-size: .85rem; font-weight: 600; color: var(--fg); }
.manage-card-detail { font-size: .7rem; color: var(--fg); opacity: .4; margin-top: 2px;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.manage-card-badge {
font-size: .6rem; padding: 2px 6px; border-radius: 4px;
background: rgba(122,162,247,.1); color: var(--accent); font-weight: 600;
margin-left: 6px; vertical-align: middle;
}
.manage-card-actions { display: flex; gap: 4px; flex-shrink: 0; }
.manage-action-btn {
width: 28px; height: 28px; border: none; border-radius: 6px;
background: transparent; color: var(--fg); opacity: .35; font-size: 13px;
cursor: pointer; display: flex; align-items: center; justify-content: center;
transition: all .12s;
}
.manage-action-btn:hover { opacity: .8; background: rgba(198,202,245,.08); }
.manage-action-btn.ma-del:hover { color: var(--red); opacity: 1; }
.manage-action-btn.ma-select:hover { color: var(--green); opacity: 1; }
/* ── Mobile ── */
@media(max-width: 768px) {
.sidebar {
position: fixed; left: 0; top: 38px; bottom: 0; z-index: 150;
transform: translateX(-100%); box-shadow: 4px 0 30px rgba(0,0,0,.4);
width: 280px; min-width: 280px;
}
.sidebar.open { transform: translateX(0); }
.mobile-sidebar-btn { display: flex; }
.chat-container { padding: 0 12px; }
.welcome-name { font-size: 1.5rem; }
.quick-grid { flex-direction: column; }
.quick-chip { text-align: center; }
.chat-container.welcome-active .chat-input-bar { margin-bottom: 0; }
.modal-content { width: 95vw; }
}
@media(min-width: 769px) {
.sidebar-backdrop { display: none !important; }
}
.sidebar-backdrop {
position: fixed; inset: 0; background: rgba(0,0,0,.4);
z-index: 140; display: none;
}
.sidebar-backdrop.open { display: block; }
/* ── Session History ── */
.session-item { position: relative; }
.session-item .sess-del {
display: none; position: absolute; right: 6px; top: 50%; transform: translateY(-50%);
width: 20px; height: 20px; border: none; border-radius: 4px;
background: transparent; color: var(--fg); opacity: .3; font-size: 11px;
cursor: pointer; display: none; align-items: center; justify-content: center;
transition: all .12s;
}
.session-item:hover .sess-del { display: flex; }
.session-item .sess-del:hover { color: var(--red); opacity: 1; background: rgba(247,118,142,.08); }
.session-item .sess-preview { font-size: .7rem; opacity: .4; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 160px; }
/* ── Mode Toggle ── */
.mode-toggle { display: flex; border: 1px solid var(--border); border-radius: 7px; overflow: hidden; }
.mode-toggle-btn {
padding: 4px 10px; border: none; background: transparent; color: var(--fg); opacity: .4;
font-size: .7rem; cursor: pointer; font-family: inherit; font-weight: 500;
transition: all .15s;
}
.mode-toggle-btn:hover { opacity: .7; }
.mode-toggle-btn.active { opacity: 1; color: #fff; }
.mode-toggle-btn.active[data-mode="chat"] { background: var(--accent); }
.mode-toggle-btn.active[data-mode="code"] { background: var(--green); color: #111; }
.mode-toggle-btn.active[data-mode="brain"] { background: var(--accent-secondary); }
/* ── RAG Source Toggles ── */
.rag-toggles { display: flex; gap: 6px; padding: 8px 10px; }
.rag-toggle-chip {
display: flex; align-items: center; gap: 4px; padding: 4px 8px;
border-radius: 6px; border: 1px solid var(--border); background: transparent;
color: var(--fg); opacity: .45; font-size: .7rem; cursor: pointer;
font-family: inherit; transition: all .15s;
}
.rag-toggle-chip:hover { opacity: .7; }
.rag-toggle-chip.on { opacity: 1; border-color: var(--accent); background: rgba(122,162,247,.08); color: var(--accent); }
.rag-toggle-chip .dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; flex-shrink: 0; }
/* ── Message Action Buttons ── */
.msg-actions {
display: flex; gap: 2px; margin-top: 6px; opacity: 0; transition: opacity .15s;
}
.msg:hover .msg-actions { opacity: 1; }
.msg-action {
background: none; border: none; color: var(--fg); opacity: .35;
cursor: pointer; padding: 3px 6px; border-radius: 4px; font-size: .68rem;
font-family: inherit; display: flex; align-items: center; gap: 3px;
transition: all .12s;
}
.msg-action:hover { opacity: .8; background: rgba(198,202,245,.07); }
.msg-action.ma-save:hover { color: var(--green); opacity: 1; }
.msg-action.ma-redo:hover { color: var(--accent); opacity: 1; }
.msg-action.ma-copy:hover { color: var(--fg); opacity: 1; }
.msg-action svg { width: 12px; height: 12px; }
/* ── Save to Wiki Modal ── */
.save-toast {
position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%);
padding: 10px 20px; border-radius: 10px; background: var(--green); color: #111;
font-size: .82rem; font-weight: 600; z-index: 999; opacity: 0;
transition: opacity .3s, transform .3s; pointer-events: none;
}
.save-toast.show { opacity: 1; transform: translateX(-50%) translateY(-8px); }
</style>
</head>
<body>
<!-- Ecosystem Navigation -->
<nav class="eco-nav">
<a class="eco-link brand" href="/zportal">Z.AI</a>
<a class="eco-link" href="/zportal/wiki">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="13" height="13"><path d="M4 19.5A2.5 2.5 0 016.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 014 19.5v-15A2.5 2.5 0 016.5 2z"/></svg>Wiki
</a>
<a class="eco-link active" href="/zportal/chat">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="13" height="13"><path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/></svg>Chat
</a>
<a class="eco-link" href="/zportal/helpdesk/">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="13" height="13"><path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/></svg>Helpdesk
</a>
</nav>
<div class="app-shell">
<!-- ── Sidebar ── -->
<aside class="sidebar" id="sidebar">
<div class="sidebar-header">
<button class="sidebar-toggle-btn" id="sidebar-toggle" title="Toggle sidebar">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
</button>
<div class="sidebar-brand" id="sidebar-brand" title="New chat">
<div class="sidebar-brand-icon">&#x1F9E0;</div>
<span class="sidebar-brand-title">Z.ai Chat</span>
</div>
</div>
<div class="sidebar-inner">
<!-- New Chat -->
<div class="list-item" id="sidebar-new-chat" title="New conversation">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
<span class="grow">New Chat</span>
</div>
<!-- Session History -->
<div class="section" id="history-section">
<div class="section-header-flex">
<span class="section-title">
<svg class="section-icon" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
History
</span>
</div>
<div id="session-list"></div>
</div>
<!-- Providers Section -->
<div class="section" id="providers-section">
<div class="section-header-flex">
<span class="section-title">
<svg class="section-icon" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/></svg>
Providers
</span>
</div>
<div id="provider-list"></div>
</div>
<!-- Tools Section -->
<div class="section" id="tools-section">
<div class="section-header-flex">
<span class="section-title">
<svg class="section-icon" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.7 6.3a1 1 0 000 1.4l1.6 1.6a1 1 0 001.4 0l3.77-3.77a6 6 0 01-7.94 7.94l-6.91 6.91a2.12 2.12 0 01-3-3l6.91-6.91a6 6 0 017.94-7.94l-3.76 3.76z"/></svg>
Tools
</span>
</div>
<div class="list-item" id="tool-settings-btn" title="Provider Settings">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0;opacity:.5;"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 01-2.83 2.83l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 012.83-2.83l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 011-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z"/></svg>
<span class="grow">Settings</span>
</div>
<div class="list-item" id="tool-rag-btn" title="RAG Knowledge Sources">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="flex-shrink:0;opacity:.5;"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/></svg>
<span class="grow">Knowledge Base</span>
<span id="rag-status-dot" style="display:none;width:7px;height:7px;border-radius:50%;background:var(--green);flex-shrink:0;"></span>
</div>
<div class="rag-toggles" id="rag-toggles">
<button class="rag-toggle-chip on" id="rag-wiki-toggle" title="Toggle Wiki KB search"><span class="dot"></span>Wiki KB</button>
<button class="rag-toggle-chip on" id="rag-vector-toggle" title="Toggle VectorDB search"><span class="dot"></span>VectorDB</button>
</div>
</div>
</div>
<div class="sidebar-footer">
<button class="new-provider-btn" id="btn-add-provider">+ Add Custom Provider</button>
</div>
</aside>
<!-- Mobile sidebar backdrop -->
<div class="sidebar-backdrop" id="sidebar-backdrop"></div>
<!-- ── Main Chat Area ── -->
<main class="chat-container welcome-active" id="chat-container">
<div class="chat-top-bar">
<button class="mobile-sidebar-btn" id="mobile-sidebar-btn">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
</button>
<div class="chat-meta-overlay">
<span id="current-meta">Z.ai Wiki Assistant</span>
<span class="chat-meta-count" id="msg-count"></span>
</div>
</div>
<!-- Welcome Screen -->
<div id="welcome-screen">
<div class="welcome-name">
<svg class="welcome-logo" viewBox="0 0 32 32"><path d="M16 4L16 22L6 22Z" fill="currentColor"/><path d="M16 8L16 22L24 22Z" fill="currentColor" opacity=".6"/><path d="M4 24Q10 20 16 24Q22 28 28 24" stroke="currentColor" stroke-width="2.5" fill="none" stroke-linecap="round"/></svg>
Z.ai Chat
</div>
<div class="welcome-sub">Ask about Z.ai wiki knowledge, community discussions, API docs &mdash; powered by VectorDB RAG with multi-provider AI.</div>
<div class="quick-grid" id="quick-actions">
<button class="quick-chip" data-msg="What is the Z.ai Coding Plan?">Coding Plan</button>
<button class="quick-chip" data-msg="How do I become a Z.ai Ambassador?">Ambassador</button>
<button class="quick-chip" data-msg="What are common issues users face?">Common Issues</button>
<button class="quick-chip" data-msg="Explain the GLM model family">GLM Models</button>
<button class="quick-chip" data-msg="What community discussions are trending?">Trending</button>
<button class="quick-chip" data-msg="Help me find documentation about API usage">API Docs</button>
</div>
</div>
<!-- Messages -->
<div class="chat-history" id="chat-history"></div>
<!-- Typing Indicator -->
<div class="typing-indicator" id="typing-indicator" style="display:none">
<div class="typing-dots"><span></span><span></span><span></span></div>
thinking...
</div>
<!-- RAG Panel -->
<div class="rag-panel" id="rag-panel">
<div class="rag-header" id="rag-header">
<span>&#x1F50D; Knowledge Sources Used</span>
<span id="rag-chevron">&#9650;</span>
</div>
<div class="rag-body" id="rag-body"></div>
</div>
<!-- Input Bar -->
<div class="chat-input-bar" id="chat-input-bar">
<div class="chat-input-top">
<textarea id="message" placeholder="Message Z.ai Chat..." rows="1" autofocus></textarea>
<div class="model-picker-wrap" id="model-picker-wrap">
<button class="model-picker-btn" id="model-picker-btn" title="Switch Provider">
<span id="model-picker-label">Select...</span>
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round"><polyline points="6 15 12 9 18 15"/></svg>
</button>
<div class="model-picker-menu" id="model-picker-menu"></div>
</div>
</div>
<div class="chat-input-bottom">
<div class="chat-input-left">
<button class="input-icon-btn" id="rag-toggle-btn" title="Toggle RAG context">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/></svg>
</button>
</div>
<div class="chat-input-right">
<div class="mode-toggle" id="mode-toggle">
<button class="mode-toggle-btn active" data-mode="chat" title="Chat mode">Chat</button>
<button class="mode-toggle-btn" data-mode="code" title="Coding mode">Code</button>
<button class="mode-toggle-btn" data-mode="brain" title="Brainstorm mode">Brain</button>
</div>
<button class="send-btn" id="send-btn" title="Send message">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
</button>
</div>
</div>
<div class="input-hint">Press Enter to send &middot; Shift+Enter for newline &middot; RAG auto-injects Wiki KB + VectorDB context</div>
</div>
</main>
</div><!-- /app-shell -->
<!-- Manage Providers Modal -->
<div class="modal-overlay" id="manage-modal">
<div class="modal-content" style="width:540px">
<div class="modal-header">
<h4>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/></svg>
Manage Providers
</h4>
<button class="close-btn" id="manage-close">&times;</button>
</div>
<div class="modal-body" style="padding:10px 14px;">
<div id="manage-provider-list"></div>
</div>
<div class="modal-footer">
<button class="btn btn-primary" id="manage-add-btn">+ Add Provider</button>
<div style="flex:1"></div>
<button class="btn btn-ghost" id="manage-done">Done</button>
</div>
</div>
</div>
<!-- Settings (Add/Edit) Modal -->
<div class="modal-overlay" id="settings-modal">
<div class="modal-content">
<div class="modal-header">
<h4>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 01-2.83 2.83l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 012.83-2.83l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 011-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z"/></svg>
<span id="settings-title">Add Provider</span>
</h4>
<button class="close-btn" id="settings-close">&times;</button>
</div>
<div class="modal-body">
<div class="form-group">
<label>Provider Name *</label>
<input id="set-name" placeholder="My OpenAI Instance">
</div>
<div class="form-group">
<label>API Base URL *</label>
<input id="set-url" placeholder="https://api.openai.com/v1">
</div>
<div class="form-group">
<label>Model *</label>
<input id="set-model" placeholder="gpt-4o-mini">
</div>
<div class="form-group">
<label>API Key (optional)</label>
<input id="set-key" type="password" placeholder="sk-...">
</div>
<div class="form-group">
<label>Format</label>
<select id="set-format">
<option value="openai">OpenAI Compatible</option>
<option value="anthropic">Anthropic</option>
<option value="ollama">Ollama</option>
<option value="openrouter">OpenRouter</option>
</select>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-danger" id="settings-delete" style="display:none">Delete</button>
<div style="flex:1"></div>
<button class="btn btn-ghost" id="settings-cancel">Cancel</button>
<button class="btn btn-primary" id="settings-save">Save</button>
</div>
</div>
</div>
<script>
(function(){
'use strict';
const API = '/zportal/wiki/api/chat/';
const $ = id => document.getElementById(id);
let providers = [];
let activeProvider = null;
let history = [];
let streaming = false;
let editingProvider = null;
let ragEnabled = true;
let ragWikiEnabled = true;
let ragVectorEnabled = true;
let chatMode = 'chat';
// ── Session History (localStorage) ──
const STORE_KEY = 'zaichat_sessions';
const MAX_SESSIONS = 50;
let currentSessionId = null;
function loadSessions(){
try{ return JSON.parse(localStorage.getItem(STORE_KEY)||'[]'); }catch(e){ return []; }
}
function saveSessions(sessions){
localStorage.setItem(STORE_KEY, JSON.stringify(sessions));
}
function getSessionTitle(msg){
if(!msg) return 'Empty chat';
return msg.length > 40 ? msg.slice(0,40)+'...' : msg;
}
function currentSession(){
if(!currentSessionId) return null;
return loadSessions().find(s=>s.id===currentSessionId)||null;
}
function saveCurrentSession(){
if(!currentSessionId) return;
const sessions = loadSessions();
const idx = sessions.findIndex(s=>s.id===currentSessionId);
const data = {
id: currentSessionId,
title: getSessionTitle(history.length?history[0].content:''),
history: history,
provider: activeProvider?activeProvider.id:null,
mode: chatMode,
ragWiki: ragWikiEnabled,
ragVector: ragVectorEnabled,
updated: Date.now(),
};
if(idx>=0) sessions[idx]=data; else sessions.unshift(data);
// Trim old sessions
if(sessions.length>MAX_SESSIONS) sessions.length=MAX_SESSIONS;
saveSessions(sessions);
renderSessions();
}
function loadSession(id){
const sessions = loadSessions();
const sess = sessions.find(s=>s.id===id);
if(!sess) return;
currentSessionId = sess.id;
history = sess.history||[];
chatMode = sess.mode||'chat';
ragWikiEnabled = sess.ragWiki!==false;
ragVectorEnabled = sess.ragVector!==false;
// Restore mode toggle UI
document.querySelectorAll('.mode-toggle-btn').forEach(b=>{
b.classList.toggle('active', b.getAttribute('data-mode')===chatMode);
});
// Restore RAG toggle chips
$('rag-wiki-toggle').classList.toggle('on', ragWikiEnabled);
$('rag-vector-toggle').classList.toggle('on', ragVectorEnabled);
// Restore provider
if(sess.provider){
const p = providers.find(x=>x.id===sess.provider);
if(p) selectProvider(p);
}
// Render messages from history
const area=$('chat-history');
area.innerHTML='';
if(history.length){
hideWelcome();
history.forEach(m=>{
addMessage(m.content, m.role, m.role==='assistant'?{provider:activeProvider}:null);
});
} else {
showWelcome();
}
updateMsgCount();
renderSessions();
}
function deleteSession(id){
const sessions = loadSessions().filter(s=>s.id!==id);
saveSessions(sessions);
if(currentSessionId===id){
currentSessionId=null;
history=[];
$('chat-history').innerHTML='';
showWelcome();
updateMsgCount();
}
renderSessions();
}
function renderSessions(){
const list=$('session-list');
const sessions=loadSessions();
list.innerHTML='';
if(!sessions.length){
list.innerHTML='<div style="padding:4px 10px;font-size:.7rem;opacity:.25;">No sessions yet</div>';
return;
}
sessions.forEach(s=>{
const div=document.createElement('div');
div.className='list-item session-item'+(s.id===currentSessionId?' active':'');
const timeStr=new Date(s.updated).toLocaleDateString([],{month:'short',day:'numeric'})+' '+new Date(s.updated).toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'});
div.innerHTML=
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" style="flex-shrink:0;opacity:.4;"><path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/></svg>'+
'<div style="flex:1;min-width:0;">'+
'<div class="grow" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">'+esc(s.title)+'</div>'+
'<div class="sess-preview">'+esc(timeStr)+' &middot; '+(s.history?s.history.length:0)+' msgs</div>'+
'</div>'+
'<button class="sess-del" title="Delete session">&times;</button>';
div.addEventListener('click', function(e){
if(e.target.closest('.sess-del')){ e.stopPropagation(); deleteSession(s.id); return; }
loadSession(s.id);
closeSidebarMobile();
});
list.appendChild(div);
});
}
function esc(s){ if(!s) return ''; const d=document.createElement('div'); d.textContent=s; return d.innerHTML; }
function icon(p){ return p.icon || '\u2728'; }
function renderMd(text){
if(!text) return '';
// First escape HTML, then apply markdown formatting
let h = esc(text);
// Code blocks (```...```)
h = h.replace(/```(\w*)\n?([\s\S]*?)```/g, function(_, lang, code){
return '<pre style="background:rgba(0,0,0,.3);border-radius:8px;padding:10px 12px;margin:6px 0;overflow-x:auto;font-size:.82em;line-height:1.4;"><code>'+code.replace(/^\n/,'')+'</code></pre>';
});
// Inline code
h = h.replace(/`([^`]+)`/g, '<code style="background:rgba(198,202,245,.08);padding:1px 5px;border-radius:4px;font-size:.88em;">$1</code>');
// Bold
h = h.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
// Italic
h = h.replace(/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/g, '<em>$1</em>');
// Unordered lists
h = h.replace(/^- (.+)$/gm, '<li style="margin-left:16px;list-style:disc;">$1</li>');
h = h.replace(/(<li[^>]*>.*<\/li>\n?)+/g, '<ul style="margin:4px 0;padding-left:8px;">$&</ul>');
// Ordered lists
h = h.replace(/^\d+\. (.+)$/gm, '<li style="margin-left:16px;list-style:decimal;">$1</li>');
// Line breaks
h = h.replace(/\n/g, '<br>');
// Clean up <br> inside <pre>
h = h.replace(/<pre([^>]*)>([\s\S]*?)<\/pre>/g, function(m, attrs, code){
return '<pre'+attrs+'>'+code.replace(/<br>/g,'\n')+'</pre>';
});
return h;
}
// ── Sidebar Toggle ──
function toggleSidebar(){ const sb=$('sidebar'); sb.classList.toggle('collapsed'); }
function openSidebarMobile(){ $('sidebar').classList.add('open'); $('sidebar-backdrop').classList.add('close'); }
function closeSidebarMobile(){ $('sidebar').classList.remove('open'); $('sidebar-backdrop').classList.remove('close'); }
$('sidebar-toggle').addEventListener('click', toggleSidebar);
$('mobile-sidebar-btn').addEventListener('click', openSidebarMobile);
$('sidebar-backdrop').addEventListener('click', closeSidebarMobile);
// New Chat
$('sidebar-new-chat').addEventListener('click', function(){
currentSessionId = 'sess-'+Date.now();
history=[];
$('chat-history').innerHTML='';
$('rag-panel').classList.remove('open');
$('rag-body').innerHTML='';
$('chat-container').classList.add('welcome-active');
updateMsgCount();
renderSessions();
});
$('sidebar-brand').addEventListener('click', function(){
$('sidebar-new-chat').click();
});
// ── Render Providers (Sidebar List) ──
function renderProviders(filter){
const list=$('provider-list');
list.innerHTML='';
const q=(filter||'').toLowerCase();
providers.filter(p => p.name.toLowerCase().includes(q)||(p.description||'').toLowerCase().includes(q)).forEach(p=>{
const div=document.createElement('div');
div.className='list-item provider-item'+(activeProvider&&activeProvider.id===p.id?' active':'');
const isCustom=p.id&&p.id.startsWith('custom-');
const hasKey=!!p.api_key;
div.innerHTML=
'<span class="mp-icon">'+esc(icon(p))+'</span>'+
'<span class="grow">'+esc(p.name)+'</span>'+
'<span class="p-check">&#10003;</span>'+
'<div class="p-actions">'+
'<button class="p-action-btn p-edit" title="'+(isCustom?'Edit':hasKey?'Edit token':'Add token')+'">'+(hasKey?'&#9998;':'&#128273;')+'</button>'+
(isCustom?'<button class="p-action-btn p-del" title="Delete">&#128465;</button>':'')+
'</div>';
div.addEventListener('click', function(e){
if(e.target.closest('.p-edit')){ e.stopPropagation(); openSettings(p); return; }
if(e.target.closest('.p-del')){ e.stopPropagation(); deleteProvider(p); return; }
selectProvider(p);
});
list.appendChild(div);
});
}
// ── Model Picker (in Input Bar) ──
function renderModelPicker(){
const menu=$('model-picker-menu');
menu.innerHTML='';
providers.forEach(p=>{
const item=document.createElement('div');
item.className='model-picker-item'+(activeProvider&&activeProvider.id===p.id?' selected':'');
item.innerHTML=
'<span class="mp-icon">'+esc(icon(p))+'</span>'+
'<div class="mp-info"><div class="mp-name">'+esc(p.name)+'</div><div class="mp-desc">'+esc(p.description||p.model||'')+'</div></div>';
item.addEventListener('click', function(e){ e.stopPropagation(); selectProvider(p); menu.classList.remove('open'); });
menu.appendChild(item);
});
updateModelPickerLabel();
}
function updateModelPickerLabel(){
const label=$('model-picker-label');
if(activeProvider) label.textContent=icon(activeProvider)+' '+activeProvider.name;
else label.textContent='Select...';
}
function toggleModelPickerMenu(){
$('model-picker-menu').classList.toggle('open');
}
document.addEventListener('click', function(e){
if(!e.target.closest('#model-picker-wrap')) $('model-picker-menu').classList.remove('open');
});
$('model-picker-btn').addEventListener('click', function(e){ e.stopPropagation(); toggleModelPickerMenu(); });
function selectProvider(p){
activeProvider=p;
renderProviders();
renderModelPicker();
updateMeta();
closeSidebarMobile();
}
function updateMeta(){
const meta=$('current-meta');
if(activeProvider) meta.textContent=icon(activeProvider)+' '+activeProvider.name+' \u2014 Z.ai Wiki';
else meta.textContent='Z.ai Wiki Assistant';
}
// ── Welcome Screen ──
function showWelcome(){ $('chat-container').classList.add('welcome-active'); $('welcome-screen').classList.remove('hidden'); }
function hideWelcome(){ $('chat-container').classList.remove('welcome-active'); $('welcome-screen').classList.add('hidden'); }
// ── Message Rendering (Odysseus bubble style) ──
function addMessage(content, role, meta){
hideWelcome();
const area=$('chat-history');
const div=document.createElement('div');
const isUser=role==='user';
const isError=meta&&meta.error;
let cls='msg '+(isUser?'msg-user':(isError?'msg-error':'msg-ai'));
let roleHtml='';
if(isUser) roleHtml='<div class="role">You</div>';
else{
if(meta&&meta.provider)
roleHtml='<div class="role has-logo"><span style="display:inline-flex;align-items:center;gap:4px;">'+esc(icon(meta.provider))+' '+esc(meta.provider.name)+'</span></div>';
else
roleHtml='<div class="role">Z.ai Assistant</div>';
}
const bodyContent=isError?content:renderMd(content);
// Action buttons for AI messages
let actionsHtml='';
if(!isUser && !isError){
const lastUserMsg=(history.length>=1&&history[history.length-1].role==='user')?history[history.length-1].content:'';
actionsHtml='<div class="msg-actions">'+
'<button class="msg-action ma-copy" title="Copy"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>Copy</button>'+
'<button class="msg-action ma-redo" title="Regenerate"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 11-2.12-9.36L23 10"/></svg>Redo</button>'+
'<button class="msg-action ma-save" title="Save to Wiki KB"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M19 21H5a2 2 0 01-2-2V5a2 2 0 012-2h11l5 5v11a2 2 0 01-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>Save</button>'+
'</div>';
div.setAttribute('data-user-msg', lastUserMsg);
}
div.className=cls;
div.innerHTML=roleHtml+'<div class="body">'+bodyContent+'</div>'+actionsHtml+'<div class="timestamp">'+new Date().toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'})+'</div>';
// Wire action buttons
if(!isUser && !isError){
const bodyEl=div.querySelector('.body');
const copyBtn=div.querySelector('.ma-copy');
const redoBtn=div.querySelector('.ma-redo');
const saveBtn=div.querySelector('.ma-save');
if(copyBtn) copyBtn.addEventListener('click', function(){
copyMsg(bodyEl.textContent);
});
if(redoBtn) redoBtn.addEventListener('click', function(){
const userMsg=div.getAttribute('data-user-msg')||'';
// Remove this AI message div and the one before it (user)
const prev=div.previousElementSibling;
if(prev && prev.classList.contains('msg-user')) prev.remove();
div.remove();
redoMsg(userMsg);
});
if(saveBtn) saveBtn.addEventListener('click', function(){
const userMsg=div.getAttribute('data-user-msg')||'';
saveToWiki(userMsg, bodyEl.textContent);
});
}
area.appendChild(div);
area.scrollTop=area.scrollHeight;
updateMsgCount();
return div.querySelector('.body');
}
function updateMsgCount(){
const n=$('chat-history').children.length;
$('msg-count').textContent=n?' \u00B7 '+n+' msgs':'';
}
// ── Streaming State ──
function setStreaming(on){
streaming=on;
$('typing-indicator').style.display=on?'':'none';
const btn=$('send-btn');
btn.disabled=on;
if(on){ btn.classList.add('streaming'); btn.innerHTML='<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>'; }
else{ btn.classList.remove('streaming'); btn.innerHTML='<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>'; }
}
// ── Send Message (SSE Streaming) ──
async function sendMessage(text){
if(!text||!activeProvider||streaming) return;
// Check if provider has an API key
if(!activeProvider.api_key){
hideWelcome();
history.push({role:'user',content:text});
addMessage(text,'user',null);
addMessage('No API key configured for '+activeProvider.name+'. Click the key/edit icon next to the provider in the sidebar to add your API key, or select a provider that has one.','assistant',{error:true});
$('message').value='';
$('message').style.height='auto';
return;
}
if(!currentSessionId) currentSessionId='sess-'+Date.now();
history.push({role:'user',content:text});
addMessage(text,'user',null);
$('message').value='';
$('message').style.height='auto';
setStreaming(true);
try{
const resp=await fetch(API+'chat/message',{
method:'POST',
headers:{'Content-Type':'application/json'},
body:JSON.stringify({message:text,provider_id:activeProvider.id,history:history,rag_wiki:ragWikiEnabled,rag_vector:ragVectorEnabled,mode:chatMode})
});
const reader=resp.body.getReader();
const decoder=new TextDecoder();
let buf='', fullText='', bubbleEl=null;
let streamDone=false;
while(!streamDone){
const {done,value}=await reader.read();
if(value) buf+=decoder.decode(value,{stream:true});
if(done) break;
const lines=buf.split('\n');
buf=lines.pop();
for(const line of lines){
if(!line.startsWith('data: ')||!line.slice(5)) continue;
try{
const chunk=JSON.parse(line.slice(5));
if(chunk.type==='done'){ streamDone=true; break; }
if(chunk.type==='error'){
let errMsg=chunk.delta||'Unknown error';
if(errMsg.includes('HTTP 401')) errMsg='Authentication failed — the API key for this provider is missing or invalid. Open Settings to configure it.';
else if(errMsg.includes('HTTP 429')) errMsg='Rate limited — too many requests. Wait a moment and try again.';
else if(errMsg.includes('HTTP 403')) errMsg='Access denied — check your API key and permissions.';
if(!bubbleEl) bubbleEl=addMessage(errMsg,'assistant',{error:true});
else bubbleEl.innerHTML+=esc(errMsg);
continue;
}
if(chunk.type==='delta'){
if(!bubbleEl) bubbleEl=addMessage('','assistant',{provider:activeProvider});
fullText+=chunk.delta;
bubbleEl.innerHTML=renderMd(fullText);
bubbleEl.scrollIntoView({block:'nearest',behavior:'smooth'});
}else if(chunk.type==='tool'||chunk.type==='raw'){
if(!bubbleEl) bubbleEl=addMessage('','assistant',{provider:activeProvider});
bubbleEl.innerHTML+=renderMd(chunk.delta);
}
}catch(e){}
}
}
// Process remaining buffer
if(buf){
for(const line of buf.split('\n')){
if(!line.startsWith('data: ')) continue;
try{
const chunk=JSON.parse(line.slice(5));
if(chunk.type==='delta'&&chunk.delta){
if(!bubbleEl) bubbleEl=addMessage('','assistant',{provider:activeProvider});
fullText+=chunk.delta;
bubbleEl.innerHTML=renderMd(fullText);
}
}catch(e){}
}
}
history.push({role:'assistant',content:fullText||'(no response)'});
saveCurrentSession();
} catch(e){
addMessage('Connection error: '+e.message,'assistant',{error:true});
}
setStreaming(false);
}
// ── Input Handling ──
const textarea=$('message');
textarea.addEventListener('input', function(){
this.style.height='auto';
this.style.height=Math.min(this.scrollHeight,500)+'px';
});
textarea.addEventListener('keydown', function(e){
if(e.key==='Enter'&&!e.shiftKey){ e.preventDefault(); sendMessage(this.value.trim()); }
});
$('send-btn').addEventListener('click', function(){ sendMessage(textarea.value.trim()); });
// Quick actions
$('quick-actions').addEventListener('click', function(e){
const chip=e.target.closest('.quick-chip');
if(chip){ const msg=chip.getAttribute('data-msg'); if(msg) sendMessage(msg); }
});
// ── RAG Toggle ──
$('rag-toggle-btn').addEventListener('click', function(){
ragEnabled=!ragEnabled;
this.classList.toggle('active', ragEnabled);
$('rag-status-dot').style.display=ragEnabled?'':'none';
});
$('rag-header').addEventListener('click', function(){
const panel=$('rag-panel');
panel.classList.toggle('open');
$('rag-chevron').textContent=panel.classList.contains('open')?'\u25BC':'\u25B2';
});
// Initialize RAG button as active
$('rag-toggle-btn').classList.add('active');
$('rag-status-dot').style.display='';
// ── RAG Source Toggle Chips ──
$('rag-wiki-toggle').addEventListener('click', function(){
ragWikiEnabled=!ragWikiEnabled;
this.classList.toggle('on', ragWikiEnabled);
});
$('rag-vector-toggle').addEventListener('click', function(){
ragVectorEnabled=!ragVectorEnabled;
this.classList.toggle('on', ragVectorEnabled);
});
// ── Mode Toggle ──
document.querySelectorAll('.mode-toggle-btn').forEach(btn=>{
btn.addEventListener('click', function(){
chatMode=this.getAttribute('data-mode');
document.querySelectorAll('.mode-toggle-btn').forEach(b=>b.classList.remove('active'));
this.classList.add('active');
});
});
// ── Toast Notification ──
function showToast(msg, duration){
duration=duration||2500;
let toast=document.getElementById('save-toast');
if(!toast){
toast=document.createElement('div');
toast.className='save-toast';
toast.id='save-toast';
document.body.appendChild(toast);
}
toast.textContent=msg;
toast.classList.add('show');
setTimeout(()=>toast.classList.remove('show'), duration);
}
// ── Message Actions ──
function copyMsg(text){
navigator.clipboard.writeText(text).then(()=>showToast('Copied to clipboard'));
}
function redoMsg(userText){
// Remove last assistant message from history
if(history.length>=2 && history[history.length-1].role==='assistant'){
history.pop();
}
// Re-send the last user message
sendMessage(userText);
}
async function saveToWiki(question, answer){
try{
const resp=await fetch(API+'chat/save-to-wiki',{
method:'POST',
headers:{'Content-Type':'application/json'},
body:JSON.stringify({question:question, answer:answer})
});
const data=await resp.json();
if(data.ok) showToast('Saved to Wiki KB!');
else showToast('Save failed: '+(data.error||'unknown'));
}catch(e){
showToast('Save error: '+e.message);
}
}
// ── Manage Providers Modal ──
function openManageModal(){
renderManageList();
$('manage-modal').classList.add('open');
}
function closeManageModal(){ $('manage-modal').classList.remove('open'); }
function renderManageList(){
const list=$('manage-provider-list');
list.innerHTML='';
if(!providers.length){
list.innerHTML='<div style="text-align:center;padding:30px;color:var(--fg);opacity:.35;font-size:.85rem;">No providers configured. Click "+ Add Provider" below.</div>';
return;
}
providers.forEach(p=>{
const isCustom=p.id&&p.id.startsWith('custom-');
const hasKey=!!p.api_key;
const isActive=activeProvider&&activeProvider.id===p.id;
const card=document.createElement('div');
card.className='manage-card'+(isActive?' active-card':'');
card.innerHTML=
'<div class="manage-card-icon">'+esc(icon(p))+'</div>'+
'<div class="manage-card-info">'+
'<div class="manage-card-name">'+esc(p.name)+(isActive?'<span class="manage-card-badge">ACTIVE</span>':'')+(hasKey?'<span class="manage-card-badge" style="background:rgba(158,206,106,.12);color:var(--green);">KEY</span>':'')+'</div>'+
'<div class="manage-card-detail">'+esc(p.model||'')+' &middot; '+esc(p.base_url||'')+'</div>'+
'</div>'+
'<div class="manage-card-actions">'+
'<button class="manage-action-btn ma-select" title="Select this provider">&#10003;</button>'+
'<button class="manage-action-btn ma-edit" title="'+(hasKey?'Edit':'Add token')+'">'+(hasKey?'&#9998;':'&#128273;')+'</button>'+
(isCustom?'<button class="manage-action-btn ma-del" title="Delete">&#128465;</button>':'')+
'</div>';
card.querySelector('.ma-select').addEventListener('click', function(){
selectProvider(p); renderManageList();
});
card.querySelector('.ma-edit').addEventListener('click', function(){ closeManageModal(); setTimeout(()=>openSettings(p),150); });
const delBtn=card.querySelector('.ma-del');
if(delBtn) delBtn.addEventListener('click', function(){ deleteProvider(p); });
list.appendChild(card);
});
}
$('manage-close').addEventListener('click', closeManageModal);
$('manage-done').addEventListener('click', closeManageModal);
$('manage-modal').addEventListener('click', function(e){ if(e.target===this) closeManageModal(); });
$('manage-add-btn').addEventListener('click', function(){ closeManageModal(); setTimeout(()=>openSettings(),150); });
// Settings gear -> manage modal
$('tool-settings-btn').addEventListener('click', ()=>openManageModal());
// Sidebar + Add -> directly open add form
$('btn-add-provider').addEventListener('click', ()=>openSettings());
// ── Settings (Add/Edit) Modal ──
function openSettings(provider){
editingProvider=provider||null;
const title=$('settings-title');
const delBtn=$('settings-delete');
const saveBtn=$('settings-save');
if(provider){
const isPreset=!provider.id.startsWith('custom-');
const hasKey=!!provider.api_key;
title.textContent=isPreset?(hasKey?'Edit Provider':'Configure '+provider.name):'Edit Provider';
saveBtn.textContent='Save';
delBtn.style.display=isPreset?'none':'';
$('set-name').value=provider.name||'';
$('set-url').value=provider.base_url||'';
$('set-model').value=provider.model||'';
$('set-key').value=provider.api_key||'';
$('set-format').value=provider.format||'openai';
} else {
title.textContent='Add Provider';
saveBtn.textContent='Save';
delBtn.style.display='none';
$('set-name').value=''; $('set-url').value=''; $('set-model').value='';
$('set-key').value=''; $('set-format').value='openai';
}
$('settings-modal').classList.add('open');
}
function closeSettings(){ $('settings-modal').classList.remove('open'); editingProvider=null; }
$('settings-close').addEventListener('click', closeSettings);
$('settings-cancel').addEventListener('click', closeSettings);
$('settings-modal').addEventListener('click', function(e){ if(e.target===this) closeSettings(); });
async function deleteProvider(p){
if(!confirm('Delete "'+p.name+'"?')) return;
try{
await fetch(API+'providers/'+encodeURIComponent(p.id),{method:'DELETE'});
providers=await(await fetch(API+'providers')).json();
if(activeProvider&&activeProvider.id===p.id){
activeProvider=providers[0]||null;
updateMeta(); renderModelPicker();
}
renderProviders();
renderManageList();
} catch(e){ alert('Delete failed: '+e.message); }
}
$('settings-delete').addEventListener('click', function(){ if(editingProvider) deleteProvider(editingProvider); });
$('settings-save').addEventListener('click', async function(){
const name=$('set-name').value.trim();
const url=$('set-url').value.trim();
const model=$('set-model').value.trim();
if(!name||!url||!model){ alert('Name, URL, and Model are required.'); return; }
const isPreset=editingProvider&&!editingProvider.id.startsWith('custom-');
// Presets get forked as custom providers so the key is persisted server-side
const provider={
id:editingProvider?(isPreset?'custom-'+Date.now():editingProvider.id):'custom-'+Date.now(),
name:name,base_url:url,model:model,
api_key:$('set-key').value.trim(),
format:$('set-format').value,
icon:editingProvider?(editingProvider.icon||'\u2699'):'\u2699',
description:editingProvider?(editingProvider.description||'Custom'):'Custom'
};
try{
await fetch(API+'providers/save',{
method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify(provider)
});
providers=await(await fetch(API+'providers')).json();
renderProviders(); renderModelPicker();
// Auto-select the saved/forked provider
if(editingProvider){
activeProvider=provider; updateMeta();
} else {
activeProvider=provider; updateMeta();
}
closeSettings();
renderManageList();
} catch(e){ alert('Save failed: '+e.message); }
});
// ── Init ──
async function init(){
try{
providers=await(await fetch(API+'providers')).json();
renderProviders();
renderModelPicker();
if(providers.length>0&&!activeProvider){
activeProvider=providers[0];
renderProviders();
renderModelPicker();
updateMeta();
}
if(!providers.length) openSettings();
renderSessions();
} catch(e){
console.error('Init error:',e);
$('chat-history').innerHTML='<div class="msg msg-error"><div class="body">Failed to load chat service. Is the backend running?</div></div>';
hideWelcome();
}
}
init();
})();
</script>
<div class="save-toast" id="save-toast"></div>
</body>
</html>