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>
348 lines
14 KiB
Python
348 lines
14 KiB
Python
#!/usr/bin/env python3
|
|
"""Inject VectorDB Chat panel into wiki HTML files.
|
|
|
|
Usage: python3 inject_wiki_chat.py [--file /path/to/wiki.html]
|
|
If no --file, modifies both ambassador and support wikis in-place.
|
|
"""
|
|
|
|
import os
|
|
import re
|
|
import sys
|
|
|
|
WIKI_FILES = [
|
|
"/opt/blog/zai-ambassador-team-wiki.html",
|
|
"/opt/blog/zai-support-wiki.html",
|
|
]
|
|
|
|
CHAT_CSS = """
|
|
/* ── VectorDB Chat Panel ── */
|
|
.vdb-fab{position:fixed;bottom:24px;right:24px;width:56px;height:56px;border-radius:28px;
|
|
background:linear-gradient(135deg,#4a9eff,#a78bfa);border:none;color:#fff;font-size:22px;
|
|
cursor:pointer;box-shadow:0 4px 20px rgba(74,158,255,.35);z-index:999;transition:all .2s;display:flex;align-items:center;justify-content:center}
|
|
.vdb-fab:hover{transform:scale(1.08);box-shadow:0 6px 28px rgba(74,158,255,.45)}
|
|
.vdb-chat-container{position:fixed;bottom:0;right:0;width:420px;height:560px;background:#131620;
|
|
border:1px solid #252a3b;border-radius:16px 16px 0 0;z-index:998;display:flex;
|
|
flex-direction:column;box-shadow:-4px 0 30px rgba(0,0,0,.4);font-family:'Inter',system-ui,sans-serif;
|
|
transition:opacity .25s, transform .25s cubic-bezier(.175,.885,.32,1.275)}
|
|
.vdb-chat-container.hidden{opacity:0;pointer-events:none;transform:translateY(20px)}
|
|
.vdb-chat-header{display:flex;align-items:center;gap:10px;padding:14px 16px;
|
|
background:#0c0e14;border-bottom:1px solid #252a3b;flex-shrink:0}
|
|
.vdb-chat-title{font-size:13px;font-weight:600;color:#e8eaed;flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
.vdb-provider-select{font-size:11px;padding:4px 8px;border:1px solid #333;border-radius:6px;
|
|
background:#1a1d26;color:#ccc;cursor:pointer;outline:none;color:#e8eaed;min-width:120px}
|
|
.vdb-provider-select:focus{border-color:#4a9eff}
|
|
.vdb-toggle{background:none;border:none;color:#888;font-size:18px;cursor:pointer;padding:4px 8px;line-height:1}
|
|
.vdb-toggle:hover{color:#fff}
|
|
.vdb-messages{flex:1;overflow-y:auto;padding:12px 16px;display:flex;flex-direction:column;gap:8px;
|
|
scrollbar-width:4px;scrollbar-thumb:#333}
|
|
.vdb-msg{max-width:85%;padding:10px 14px;border-radius:12px;font-size:13px;line-height:1.5;
|
|
color:#b0b5bc;word-wrap:break-word}
|
|
.vdb-msg-user{align-self:flex-end;background:#1e3a5f;border:1px solid #2a4070;margin-left:auto}
|
|
.vdb-msg-assistant{align-self:flex-start;background:#1a1f2e;border:1px solid #252a3b}
|
|
.vdb-msg-meta{font-size:10px;color:#666;margin-top:4px;display:flex;gap:8px;align-items:center}
|
|
.vdb-msg-provider{background:rgba(74,158,255,.1);color:#4a9eff;padding:1px 6px;border-radius:3px;font-weight:600}
|
|
.vdb-msg-error{background:rgba(248,113,113,.1);color:#f87171;border-color:rgba(248,113,113,.3)}
|
|
.vdb-typing{font-size:11px;color:#7c8497;font-style:italic;padding:8px 16px 0;display:none}
|
|
.vdb-typing.active{display:block}
|
|
.vdb-input-row{display:flex;gap:8px;padding:12px 16px;border-top:1px solid #252a3b;
|
|
background:#0c0e14;flex-shrink:0}
|
|
.vdb-input{flex:1;padding:10px 12px;border:1px solid #333;border-radius:8px;
|
|
background:#1a1d26;color:#e8eaed;font-size:13px;font-family:inherit;resize:none;
|
|
outline:none;min-height:20px;max-height:80px;line-height:1.4}
|
|
.vdb-input:focus{border-color:#4a9eff}
|
|
.vdb-send{padding:10px 18px;border:1px solid #333;border-radius:8px;background:rgba(74,158,255,.1);
|
|
color:#4a9eff;font-weight:600;font-size:12px;cursor:pointer;white-space:nowrap;
|
|
transition:all .15s}
|
|
.vdb-send:hover{background:rgba(74,158,255,.2);color:#fff}
|
|
.vdb-send:disabled{opacity:.4;cursor:not-allowed}
|
|
.vdb-settings{border-top:1px solid #252a3b;padding:12px 16px;display:none}
|
|
.vdb-settings-row{display:flex;gap:8px;margin-bottom:8px;align-items:center}
|
|
.vdb-settings-row label{font-size:11px;color:#888;width:70px;flex-shrink:0}
|
|
.vdb-settings-row input,.vdb-settings-row select{flex:1;padding:6px 8px;border:1px solid #333;
|
|
border-radius:4px;background:#1a1d26;color:#e8eaed;font-size:11px}
|
|
.vdb-settings-btn{padding:4px 12px;border-radius:4px;font-size:10px;cursor:pointer;
|
|
border:1px solid #333;background:#1a1d26;color:#aaa;transition:all .15s}
|
|
.vdb-settings-btn:hover{color:#fff;border-color:#444}
|
|
.vdb-settings-btn.danger{color:#f87171;border-color:rgba(248,113,113,.3)}
|
|
@media(max-width:600px){
|
|
.vdb-chat-container{width:100vw;height:100vh;border-radius:0;right:0;bottom:0}
|
|
}
|
|
"""
|
|
|
|
CHAT_HTML = """
|
|
<div id="vdb-chat" class="vdb-chat-container hidden" style="display:none">
|
|
<div class="vdb-chat-header">
|
|
<span class="vdb-chat-title">VectorDB Chat</span>
|
|
<select id="vdb-provider-select" class="vdb-provider-select"></select>
|
|
<button id="vdb-toggle" class="vdb-toggle">—</button>
|
|
</div>
|
|
<div class="vdb-messages" id="vdb-chat-messages"></div>
|
|
<div class="vdb-typing" id="vdb-typing"> thinking...</div>
|
|
<div class="vdb-input-row">
|
|
<textarea id="vdb-input" class="vdb-input" placeholder="Ask about Z.ai wiki, community issues..." rows="1"></textarea>
|
|
<button id="vdb-send" class="vdb-send">Send</button>
|
|
</div>
|
|
<div class="vdb-settings" id="vdb-settings">
|
|
<div style="font-size:11px;color:#888;margin-bottom:8px;font-weight:600;text-transform:uppercase;letter-spacing:.5px">Custom Provider</div>
|
|
<div class="vdb-settings-row"><label>Name</label><input id="vdb-cust-name" placeholder"My Provider"></div></div>
|
|
<div class="vdb-settings-row"><label>API URL</label><input id="vdb-cust-url" placeholder="https://api.example.com/v1"></div></div>
|
|
<div class="vdb-settings-row"><label>Model</label><input id="vdb-cust-model" placeholder="gpt-4o-mini"></div></div>
|
|
<div class="vdb-settings-row"><label>API Key</label><input id="vdb-cust-key" type="password" placeholder="sk-..."></div></div>
|
|
<div style="display:flex;gap:6px;margin-top:8px">
|
|
<button id="vdb-cust-save" class="vdb-settings-btn">Save Provider</button>
|
|
<button id="vdb-cust-cancel" class="vdb-settings-btn danger">Cancel</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<button id="vdb-chat-btn" class="vdb-fab" title="Open Wiki Chat">💬</button>
|
|
"""
|
|
|
|
CHAT_JS = """
|
|
(function(){
|
|
const CHAT_API = (function(){
|
|
const p = location.pathname;
|
|
return p.endsWith('/') ? p : p + '/';
|
|
})();
|
|
const $ = id => document.getElementById(id);
|
|
let _providers = [];
|
|
let _activeProvider = null;
|
|
let _history = [];
|
|
let _isStreaming = false;
|
|
|
|
function esc(s){ if(!s) return ''; const d=document.createElement('div'); d.textContent=s; return d.innerHTML; }
|
|
|
|
function providerIcon(p){ return p.icon || '\u2B99'; }
|
|
function providerLabel(p){ return p.icon + ' ' + p.name; }
|
|
|
|
function renderProviders(){
|
|
const sel = $('vdb-provider-select');
|
|
sel.innerHTML = '<option value="">Select provider...</option>';
|
|
_providers.forEach(p => {
|
|
const opt = document.createElement('option');
|
|
opt.value = p.id;
|
|
opt.textContent = providerLabel(p);
|
|
if(_activeProvider && p.id === _activeProvider.id) opt.selected = true;
|
|
sel.appendChild(opt);
|
|
});
|
|
}
|
|
|
|
function addMsg(content, isUser, meta){
|
|
const msgs = $('vdb-chat-messages');
|
|
const div = document.createElement('div');
|
|
div.className = 'vdb-msg vdb-msg-' + (isUser ? 'user' : 'assistant');
|
|
let html = '';
|
|
if(meta && meta.provider) html += '<div class="vdb-msg-meta"><span class="vdb-msg-provider">' + providerIcon(meta.provider) + ' ' + meta.provider.name + '</span></div>';
|
|
if(meta && meta.error) { div.className += ' vdb-msg-error'; html = content; }
|
|
else { html += esc(content).replace(/\\n/g, '<br>'); }
|
|
div.innerHTML = html;
|
|
msgs.appendChild(div);
|
|
msgs.scrollTop = msgs.scrollHeight;
|
|
return div;
|
|
}
|
|
|
|
function setStreaming(on){
|
|
_isStreaming = on;
|
|
$('vdb-typing').className = 'vdb-typing' + (on ? ' active' : '');
|
|
$('vdb-send').disabled = on;
|
|
}
|
|
|
|
async function sendMessage(){
|
|
const input = $('vdb-input');
|
|
const text = (input.value || '').trim();
|
|
if(!text || !_activeProvider || _isStreaming) return;
|
|
_history.push({role:'user', content:text});
|
|
addMsg(text, true, null);
|
|
input.value = '';
|
|
input.style.height = 'auto';
|
|
setStreaming(true);
|
|
|
|
try{
|
|
const resp = await fetch(CHAT_API + 'chat/message', {
|
|
method:'POST',
|
|
headers:{'Content-Type':'application/json'},
|
|
body:JSON.stringify({message:text, provider_id:_activeProvider.id, history:_history})
|
|
});
|
|
const reader = resp.body.getReader();
|
|
const decoder = new TextDecoder();
|
|
let buf = '';
|
|
let fullText = '';
|
|
let assistantDiv = null;
|
|
|
|
while(true){
|
|
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(); // keep incomplete line
|
|
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'){ setStreaming(false); break; }
|
|
if(chunk.type === 'error'){
|
|
if(!assistantDiv) assistantDiv = addMsg(chunk.delta, false, {error:true});
|
|
else assistantDiv.textContent += chunk.delta;
|
|
continue;
|
|
}
|
|
if(chunk.type === 'delta'){
|
|
if(!assistantDiv) assistantDiv = addMsg('', false, {provider:_activeProvider});
|
|
fullText += chunk.delta;
|
|
assistantDiv.innerHTML = esc(fullText.replace(/\\n/g, '<br>'));
|
|
assistantDiv.scrollIntoView({block:'nearest', behavior:'smooth'});
|
|
} else if(chunk.type === 'tool' || chunk.type === 'raw'){
|
|
if(!assistantDiv) assistantDiv = addMsg('', false, {provider:_activeProvider});
|
|
assistantDiv.innerHTML += esc(chunk.delta).replace(/\\n/g, '<br>');
|
|
}
|
|
}catch(e){ /* skip malformed */ }
|
|
}
|
|
}
|
|
// 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(!assistantDiv) assistantDiv = addMsg('', false, {provider:_activeProvider});
|
|
fullText += chunk.delta;
|
|
assistantDiv.innerHTML = esc(fullText.replace(/\\n/g, '<br>'));
|
|
}
|
|
}catch(e){}
|
|
}
|
|
}
|
|
_history.push({role:'assistant', content:fullText || '(no response)'});
|
|
} catch(e){
|
|
addMsg('Error: ' + e.message, false, {error:true});
|
|
}
|
|
setStreaming(false);
|
|
}
|
|
|
|
// Provider selection
|
|
$('vdb-provider-select').addEventListener('change', function(){
|
|
const pid = this.value;
|
|
_activeProvider = _providers.find(p => p.id === pid) || null;
|
|
});
|
|
|
|
// Send button
|
|
$('vdb-send').addEventListener('click', sendMessage);
|
|
$('vdb-input').addEventListener('keydown', function(e){ if(e.key === 'Enter' && !e.shiftKey){ e.preventDefault(); sendMessage(); }});
|
|
|
|
// Toggle chat panel
|
|
let _chatOpen = false;
|
|
$('vdb-chat-btn').addEventListener('click', function(){
|
|
_chatOpen = !_chatOpen;
|
|
const chat = $('vdb-chat');
|
|
chat.classList.toggle('hidden', !_chatOpen);
|
|
this.textContent = _chatOpen ? '\u2715' : '\u1F4AC';
|
|
this.title = _chatOpen ? 'Close Chat' : 'Open Wiki Chat';
|
|
});
|
|
|
|
$('vdb-toggle').addEventListener('click', function(){
|
|
_chatOpen = false;
|
|
$('vdb-chat').classList.add('hidden');
|
|
this.textContent = '\u25B2';
|
|
$('vdb-chat-btn').textContent = '\u1F4AC';
|
|
$('vdb-chat-btn').title = 'Open Wiki Chat';
|
|
});
|
|
|
|
// Settings
|
|
$('vdb-cust-save').addEventListener('click', async function(){
|
|
const name = $('vdb-cust-name').value.trim();
|
|
const url = $('vdb-cust-url').value.trim();
|
|
const model = $('vdb-cust-model').value.trim();
|
|
const key = $('vdb-cust-key').value.trim();
|
|
if(!name || !url || !model){ alert('Name, URL, and Model are required'); return; }
|
|
const provider = {id:'custom-'+Date.now(), name:name, base_url:url, model:model,
|
|
api_key:key, format:'openai', icon:'\u2699', description:'Custom'};
|
|
// Save via API
|
|
try{
|
|
await fetch(CHAT_API + 'providers/save', {
|
|
method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(provider)
|
|
});
|
|
_providers = await (await fetch(CHAT_API + 'providers')).json();
|
|
renderProviders();
|
|
_activeProvider = provider;
|
|
$('vdb-settings').style.display = 'none';
|
|
alert('Provider saved!');
|
|
} catch(e){ alert('Save failed: ' + e.message); }
|
|
});
|
|
|
|
$('vdb-cust-cancel').addEventListener('click', function(){
|
|
$('vdb-settings').style.display = 'none';
|
|
});
|
|
|
|
// Auto-open settings if no providers loaded
|
|
function checkProviders(){
|
|
if(!_providers.length){
|
|
$('vdb-settings').style.display = '';
|
|
}
|
|
}
|
|
|
|
// Init
|
|
async function init(){
|
|
try{
|
|
const [presetsResp, customResp] = await Promise.all([
|
|
fetch(CHAT_API + 'providers/presets'),
|
|
fetch(CHAT_API + 'providers')
|
|
]);
|
|
_presets = await presetsResp.json();
|
|
_custom = await customResp.json();
|
|
_providers = [..._presets, ..._custom];
|
|
renderProviders();
|
|
// Auto-select first available provider
|
|
if(_providers.length > 0 && !_activeProvider){
|
|
_activeProvider = _providers[0];
|
|
renderProviders();
|
|
}
|
|
checkProviders();
|
|
} catch(e){
|
|
console.error('Chat init error:', e);
|
|
$('vdb-chat-messages').innerHTML = '<div class="vdb-msg vdb-msg-error">Failed to load chat service.</div>';
|
|
}
|
|
}
|
|
|
|
init();
|
|
})();
|
|
"""
|
|
|
|
|
|
def inject_chat(html_content: str) -> str:
|
|
"""Inject chat CSS, HTML, and JS into wiki HTML."""
|
|
# Inject CSS before </style>
|
|
if "</style>" in html_content:
|
|
html_content = html_content.replace("</style>", CHAT_CSS + "</style>", 1)
|
|
|
|
# Inject HTML before </body>
|
|
if "</body>" in html_content:
|
|
html_content = html_content.replace("</body>", CHAT_HTML + "\n<script>" + CHAT_JS + "\n</script>", 1)
|
|
|
|
return html_content
|
|
|
|
|
|
def main():
|
|
files = WIKI_FILES
|
|
# Check for --file argument
|
|
if "--file" in sys.argv:
|
|
idx = sys.argv.index("--file") + 1
|
|
if idx < len(sys.argv):
|
|
files = [sys.argv[idx]]
|
|
|
|
for fpath in files:
|
|
if not os.path.exists(fpath):
|
|
print(f"SKIP: {fpath} not found")
|
|
continue
|
|
with open(fpath, "r") as f:
|
|
content = f.read()
|
|
# Check if already injected
|
|
if "vdb-chat-container" in content:
|
|
print(f"SKIP: {fpath} already has chat injected")
|
|
continue
|
|
new_content = inject_chat(content)
|
|
with open(fpath, "w") as f:
|
|
f.write(new_content)
|
|
print(f"OK: {fpath} ({len(new_content)} bytes)")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|