Files
Zportal-Wiki-VectorDB-Chat/inject_wiki_chat.py
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

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">&#x2014;</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">&#x1F4AC;</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()