Files
firefrost-services/services/arbiter-3.0/src/views/admin/forge/index.ejs
Claude (Chronicler #82) 240a4776f6 The Forge module + collapsible sidebar nav
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
2026-04-12 02:14:12 -05:00

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>