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>
This commit is contained in:
347
inject_wiki_chat.py
Normal file
347
inject_wiki_chat.py
Normal file
@@ -0,0 +1,347 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user