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>
1488 lines
63 KiB
HTML
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">🧠</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 — 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>🔍 Knowledge Sources Used</span>
|
|
<span id="rag-chevron">▲</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 · Shift+Enter for newline · 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">×</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">×</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)+' · '+(s.history?s.history.length:0)+' msgs</div>'+
|
|
'</div>'+
|
|
'<button class="sess-del" title="Delete session">×</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">✓</span>'+
|
|
'<div class="p-actions">'+
|
|
'<button class="p-action-btn p-edit" title="'+(isCustom?'Edit':hasKey?'Edit token':'Add token')+'">'+(hasKey?'✎':'🔑')+'</button>'+
|
|
(isCustom?'<button class="p-action-btn p-del" title="Delete">🗑</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||'')+' · '+esc(p.base_url||'')+'</div>'+
|
|
'</div>'+
|
|
'<div class="manage-card-actions">'+
|
|
'<button class="manage-action-btn ma-select" title="Select this provider">✓</button>'+
|
|
'<button class="manage-action-btn ma-edit" title="'+(hasKey?'Edit':'Add token')+'">'+(hasKey?'✎':'🔑')+'</button>'+
|
|
(isCustom?'<button class="manage-action-btn ma-del" title="Delete">🗑</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>
|