New Module: The Forge (/admin/forge) - AI knowledge assistant powered by Gemma 4 via Dify RAG - Streaming SSE chat interface with markdown rendering - Think-tag filtering for Gemma 4's reasoning tokens - Conversation continuity via Dify conversation IDs - Source citations from knowledge base documents - Fire/Frost/Arcane gradient branding - Welcome screen with suggestion buttons - Env vars: DIFY_API_URL, DIFY_APP_KEY Sidebar Navigation Overhaul (layout.ejs) - The Forge featured prominently at top with gradient border - Collapsible category groups: Core, Revenue, Community, Operations - localStorage persistence for collapsed/expanded state - CSS transitions for smooth collapse animation Chronicler #82 | April 12, 2026
286 lines
9.9 KiB
Plaintext
286 lines
9.9 KiB
Plaintext
<style>
|
|
#forge-container {
|
|
display: flex;
|
|
flex-direction: column;
|
|
height: calc(100vh - 8rem);
|
|
max-width: 900px;
|
|
margin: 0 auto;
|
|
}
|
|
#forge-messages {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
padding: 1rem;
|
|
scroll-behavior: smooth;
|
|
}
|
|
.forge-msg {
|
|
margin-bottom: 1rem;
|
|
padding: 0.75rem 1rem;
|
|
border-radius: 0.75rem;
|
|
max-width: 85%;
|
|
line-height: 1.6;
|
|
font-size: 0.95rem;
|
|
}
|
|
.forge-msg.user {
|
|
background: linear-gradient(135deg, rgba(255,107,53,0.15), rgba(78,205,196,0.15));
|
|
border: 1px solid rgba(168,85,247,0.3);
|
|
margin-left: auto;
|
|
text-align: right;
|
|
}
|
|
.forge-msg.assistant {
|
|
background: rgba(45,45,45,0.8);
|
|
border: 1px solid rgba(78,205,196,0.2);
|
|
margin-right: auto;
|
|
}
|
|
.forge-msg.assistant pre {
|
|
background: rgba(0,0,0,0.4);
|
|
padding: 0.75rem;
|
|
border-radius: 0.5rem;
|
|
overflow-x: auto;
|
|
margin: 0.5rem 0;
|
|
font-size: 0.85rem;
|
|
}
|
|
.forge-msg.assistant code {
|
|
font-family: 'Courier New', monospace;
|
|
font-size: 0.85rem;
|
|
}
|
|
.forge-citations {
|
|
margin-top: 0.5rem;
|
|
padding-top: 0.5rem;
|
|
border-top: 1px solid rgba(78,205,196,0.2);
|
|
font-size: 0.8rem;
|
|
color: #888;
|
|
}
|
|
#forge-input-area {
|
|
padding: 1rem;
|
|
border-top: 1px solid rgba(78,205,196,0.2);
|
|
}
|
|
#forge-input {
|
|
width: 100%;
|
|
padding: 0.75rem 1rem;
|
|
border-radius: 0.75rem;
|
|
border: 1px solid rgba(168,85,247,0.3);
|
|
background: rgba(45,45,45,0.6);
|
|
color: #e8f4f8;
|
|
font-size: 1rem;
|
|
resize: none;
|
|
outline: none;
|
|
transition: border-color 0.2s;
|
|
}
|
|
#forge-input:focus {
|
|
border-color: rgba(168,85,247,0.6);
|
|
}
|
|
#forge-send {
|
|
margin-top: 0.5rem;
|
|
padding: 0.5rem 1.5rem;
|
|
background: linear-gradient(135deg, #FF6B35, #A855F7, #4ECDC4);
|
|
color: white;
|
|
border: none;
|
|
border-radius: 0.5rem;
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
float: right;
|
|
transition: opacity 0.2s;
|
|
}
|
|
#forge-send:disabled { opacity: 0.4; cursor: not-allowed; }
|
|
#forge-send:hover:not(:disabled) { opacity: 0.9; }
|
|
.forge-welcome {
|
|
text-align: center;
|
|
padding: 3rem 1rem;
|
|
color: #888;
|
|
}
|
|
.forge-welcome h2 {
|
|
font-size: 1.5rem;
|
|
font-weight: 700;
|
|
background: linear-gradient(135deg, #FF6B35, #A855F7, #4ECDC4);
|
|
-webkit-background-clip: text;
|
|
-webkit-text-fill-color: transparent;
|
|
background-clip: text;
|
|
margin-bottom: 0.5rem;
|
|
}
|
|
.forge-welcome p {
|
|
max-width: 500px;
|
|
margin: 0.5rem auto;
|
|
line-height: 1.6;
|
|
}
|
|
.forge-suggestions {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 0.5rem;
|
|
justify-content: center;
|
|
margin-top: 1.5rem;
|
|
}
|
|
.forge-suggestion {
|
|
padding: 0.5rem 1rem;
|
|
border: 1px solid rgba(168,85,247,0.3);
|
|
border-radius: 2rem;
|
|
font-size: 0.85rem;
|
|
color: #a8dadc;
|
|
cursor: pointer;
|
|
transition: all 0.2s;
|
|
background: transparent;
|
|
}
|
|
.forge-suggestion:hover {
|
|
border-color: rgba(168,85,247,0.6);
|
|
background: rgba(168,85,247,0.1);
|
|
}
|
|
</style>
|
|
|
|
<div id="forge-container">
|
|
<div id="forge-messages">
|
|
<div class="forge-welcome" id="forge-welcome">
|
|
<h2>🔥 The Forge</h2>
|
|
<p>Ask me anything about Firefrost Gaming — infrastructure, procedures, server configs, policies, or history.</p>
|
|
<p style="font-size: 0.8rem; color: #666;">Powered by Gemma 4 on TX1 • Responses may take 30-60 seconds</p>
|
|
<div class="forge-suggestions">
|
|
<button class="forge-suggestion" onclick="askForge('How do I restart Arbiter?')">How do I restart Arbiter?</button>
|
|
<button class="forge-suggestion" onclick="askForge('What servers are on TX1?')">What servers are on TX1?</button>
|
|
<button class="forge-suggestion" onclick="askForge('What is the Sovereign tier?')">What is the Sovereign tier?</button>
|
|
<button class="forge-suggestion" onclick="askForge('What is the cancellation policy?')">Cancellation policy?</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="forge-input-area">
|
|
<textarea id="forge-input" rows="2" placeholder="Ask The Forge..." onkeydown="if(event.key==='Enter' && !event.shiftKey){event.preventDefault();sendForge();}"></textarea>
|
|
<button id="forge-send" onclick="sendForge()">Ask The Forge</button>
|
|
<div style="clear:both;"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
let conversationId = '';
|
|
let isStreaming = false;
|
|
|
|
function askForge(text) {
|
|
document.getElementById('forge-input').value = text;
|
|
sendForge();
|
|
}
|
|
|
|
async function sendForge() {
|
|
const input = document.getElementById('forge-input');
|
|
const query = input.value.trim();
|
|
if (!query || isStreaming) return;
|
|
|
|
const welcome = document.getElementById('forge-welcome');
|
|
if (welcome) welcome.style.display = 'none';
|
|
|
|
const messages = document.getElementById('forge-messages');
|
|
const userDiv = document.createElement('div');
|
|
userDiv.className = 'forge-msg user';
|
|
userDiv.textContent = query;
|
|
messages.appendChild(userDiv);
|
|
|
|
const msgId = 'msg-' + Date.now();
|
|
const assistDiv = document.createElement('div');
|
|
assistDiv.className = 'forge-msg assistant';
|
|
assistDiv.id = msgId;
|
|
assistDiv.innerHTML = '<span style="color:#888;">🔥 The Forge is thinking...</span>';
|
|
messages.appendChild(assistDiv);
|
|
messages.scrollTop = messages.scrollHeight;
|
|
|
|
input.value = '';
|
|
isStreaming = true;
|
|
document.getElementById('forge-send').disabled = true;
|
|
|
|
try {
|
|
const response = await fetch('/admin/forge/chat', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'CSRF-Token': '<%= csrfToken %>'
|
|
},
|
|
body: JSON.stringify({ query, conversation_id: conversationId })
|
|
});
|
|
|
|
const reader = response.body.getReader();
|
|
const decoder = new TextDecoder();
|
|
let rawAnswer = '';
|
|
let buffer = '';
|
|
const msgEl = document.getElementById(msgId);
|
|
|
|
while (true) {
|
|
const { done, value } = await reader.read();
|
|
if (done) break;
|
|
|
|
buffer += decoder.decode(value, { stream: true });
|
|
const lines = buffer.split('\n');
|
|
buffer = lines.pop();
|
|
|
|
for (const line of lines) {
|
|
if (!line.startsWith('data: ')) continue;
|
|
const jsonStr = line.substring(6).trim();
|
|
if (!jsonStr) continue;
|
|
|
|
try {
|
|
const data = JSON.parse(jsonStr);
|
|
|
|
if (data.event === 'message' && data.answer) {
|
|
rawAnswer += data.answer;
|
|
|
|
let display = rawAnswer;
|
|
display = display.replace(/<think>[\s\S]*?<\/think>/g, '');
|
|
display = display.replace(/<think>[\s\S]*/g, '');
|
|
display = display.trim();
|
|
|
|
if (display) {
|
|
msgEl.innerHTML = renderMarkdown(display);
|
|
}
|
|
messages.scrollTop = messages.scrollHeight;
|
|
|
|
if (data.conversation_id) {
|
|
conversationId = data.conversation_id;
|
|
}
|
|
}
|
|
|
|
if (data.event === 'message_end') {
|
|
if (data.metadata && data.metadata.retriever_resources) {
|
|
const sources = data.metadata.retriever_resources.map(function(r) { return r.document_name; });
|
|
const unique = [...new Set(sources)];
|
|
if (unique.length > 0) {
|
|
msgEl.innerHTML += '<div class="forge-citations">📄 Sources: ' + unique.join(', ') + '</div>';
|
|
}
|
|
}
|
|
}
|
|
|
|
if (data.event === 'error') {
|
|
msgEl.innerHTML = '<span style="color:#ff6b35;">⚠️ ' + escapeHtml(data.message || 'Something went wrong.') + '</span>';
|
|
}
|
|
} catch (e) {}
|
|
}
|
|
}
|
|
|
|
let finalDisplay = rawAnswer.replace(/<think>[\s\S]*?<\/think>/g, '').trim();
|
|
if (finalDisplay) {
|
|
msgEl.innerHTML = renderMarkdown(finalDisplay);
|
|
} else if (!rawAnswer) {
|
|
msgEl.innerHTML = '<span style="color:#888;">No response received. The model may be loading.</span>';
|
|
}
|
|
|
|
} catch (err) {
|
|
document.getElementById(msgId).innerHTML = '<span style="color:#ff6b35;">⚠️ Connection failed: ' + escapeHtml(err.message) + '</span>';
|
|
}
|
|
|
|
isStreaming = false;
|
|
document.getElementById('forge-send').disabled = false;
|
|
input.focus();
|
|
}
|
|
|
|
function escapeHtml(text) {
|
|
const div = document.createElement('div');
|
|
div.textContent = text;
|
|
return div.innerHTML;
|
|
}
|
|
|
|
function renderMarkdown(text) {
|
|
let html = escapeHtml(text);
|
|
html = html.replace(/```(\w*)\n([\s\S]*?)```/g, '<pre><code>$2</code></pre>');
|
|
html = html.replace(/`([^`]+)`/g, '<code style="background:rgba(0,0,0,0.3);padding:0.1rem 0.3rem;border-radius:3px;">$1</code>');
|
|
html = html.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
|
|
html = html.replace(/^### (.+)$/gm, '<h4 style="color:#4ECDC4;margin:0.5rem 0;">$1</h4>');
|
|
html = html.replace(/^## (.+)$/gm, '<h3 style="color:#4ECDC4;margin:0.5rem 0;">$1</h3>');
|
|
html = html.replace(/^- (.+)$/gm, '• $1');
|
|
html = html.replace(/\n/g, '<br>');
|
|
return html;
|
|
}
|
|
</script>
|