Files
Zportal-Wiki-VectorDB-Chat/zportal-chat.html
admin ae621ecbb5 Initial release: Multi-provider AI chat with RAG
FastAPI backend (wiki-vector-chat.py) with Odysseus-style frontend.
Features: multi-provider LLM, Wiki KB + VectorDB RAG, session history,
chat modes, save-to-wiki, markdown rendering, SSE streaming.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 10:25:29 +00:00

1474 lines
62 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;
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'){
if(!bubbleEl) bubbleEl=addMessage(chunk.delta,'assistant',{error:true});
else bubbleEl.innerHTML+=esc(chunk.delta);
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>