Some checks are pending
Check Python Version Consistency / Check Python Version (push) Waiting to run
473 lines
19 KiB
HTML
473 lines
19 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="no">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<meta name="robots" content="noindex, nofollow">
|
|
<title>Jason — Vauco Operator</title>
|
|
<link rel="manifest" href="/manifest.json">
|
|
<style>
|
|
:root{
|
|
--bg:#0a0a0f;--bg-2:#11131a;--bg-3:#161922;
|
|
--border:rgba(255,255,255,0.08);--border-2:rgba(255,255,255,0.14);
|
|
--text:#e8eaf0;--text-dim:#8b909c;--text-faint:#5a5f6b;
|
|
--accent:#7c5cff;--accent-2:#00d4a8;
|
|
--green:#3ecf8e;--amber:#ffb547;--danger:#ff5c7c;
|
|
--radius:12px;--radius-lg:18px;
|
|
--sans:-apple-system,BlinkMacSystemFont,'Inter',system-ui,sans-serif;
|
|
--mono:'SF Mono',ui-monospace,'JetBrains Mono',Menlo,monospace;
|
|
}
|
|
*{margin:0;padding:0;box-sizing:border-box}
|
|
html,body{height:100%;background:var(--bg);color:var(--text);font-family:var(--sans);-webkit-font-smoothing:antialiased}
|
|
button{font-family:inherit;cursor:pointer;border:none;background:none;color:inherit}
|
|
::-webkit-scrollbar{width:8px}
|
|
::-webkit-scrollbar-thumb{background:var(--bg-3);border-radius:6px}
|
|
|
|
/* layout */
|
|
.shell{display:flex;height:100vh;overflow:hidden}
|
|
|
|
/* sidebar */
|
|
.sidebar{
|
|
width:220px;flex-shrink:0;
|
|
background:var(--bg-2);border-right:1px solid var(--border);
|
|
display:flex;flex-direction:column;padding:20px 12px;
|
|
gap:4px;
|
|
}
|
|
.brand{display:flex;align-items:center;gap:10px;padding:8px 10px 20px}
|
|
.logo{width:28px;height:28px;border-radius:8px;background:linear-gradient(135deg,var(--accent),var(--accent-2));display:flex;align-items:center;justify-content:center;color:#0a0a0f;font-weight:900;font-size:13px;flex-shrink:0}
|
|
.brand-name{font-weight:800;font-size:15px;letter-spacing:-0.02em}
|
|
.side-label{font-size:10px;text-transform:uppercase;letter-spacing:0.08em;color:var(--text-faint);font-weight:700;padding:12px 10px 4px}
|
|
.nav-item{display:flex;align-items:center;gap:9px;padding:8px 10px;border-radius:9px;font-size:13px;color:var(--text-dim);font-weight:500;cursor:pointer;text-decoration:none;transition:background .14s,color .14s}
|
|
.nav-item:hover{background:var(--bg-3);color:var(--text)}
|
|
.nav-item.active{background:var(--bg-3);color:var(--text)}
|
|
.nav-item svg{flex-shrink:0;opacity:.75}
|
|
.side-foot{margin-top:auto;padding-top:14px;border-top:1px solid var(--border)}
|
|
.me-card{padding:10px 10px;border-radius:9px;background:var(--bg-3);border:1px solid var(--border)}
|
|
.me-name{font-size:12px;font-weight:600}
|
|
.me-role{font-size:10px;color:var(--text-faint);margin-top:1px;font-family:var(--mono)}
|
|
.session-id{font-size:9px;color:var(--text-faint);font-family:var(--mono);margin-top:6px;word-break:break-all}
|
|
|
|
/* chat area */
|
|
.chat-wrap{
|
|
flex:1;display:flex;flex-direction:column;overflow:hidden;
|
|
background:var(--bg);
|
|
}
|
|
.chat-head{
|
|
display:flex;align-items:center;gap:12px;
|
|
padding:16px 24px;border-bottom:1px solid var(--border);
|
|
background:rgba(10,10,15,0.9);backdrop-filter:blur(16px);
|
|
flex-shrink:0;
|
|
}
|
|
.jason-avatar{
|
|
width:36px;height:36px;border-radius:10px;
|
|
background:linear-gradient(135deg,var(--accent) 0%,var(--accent-2) 100%);
|
|
display:flex;align-items:center;justify-content:center;
|
|
font-size:18px;flex-shrink:0;
|
|
}
|
|
.jason-info{flex:1}
|
|
.jason-name{font-size:15px;font-weight:700;letter-spacing:-0.01em}
|
|
.jason-status{font-size:12px;color:var(--text-dim);display:flex;align-items:center;gap:5px;margin-top:1px}
|
|
.dot{width:6px;height:6px;border-radius:50%;background:var(--green);flex-shrink:0;animation:pulse 2.4s infinite}
|
|
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.3}}
|
|
.head-actions{display:flex;gap:8px}
|
|
.head-btn{
|
|
padding:6px 14px;border-radius:8px;font-size:12px;font-weight:600;
|
|
background:var(--bg-2);border:1px solid var(--border);color:var(--text-dim);
|
|
transition:background .14s,color .14s;
|
|
}
|
|
.head-btn:hover{background:var(--bg-3);color:var(--text)}
|
|
.head-btn.danger:hover{color:var(--danger);border-color:rgba(255,92,124,0.3)}
|
|
|
|
/* messages */
|
|
.messages{
|
|
flex:1;overflow-y:auto;
|
|
padding:28px 32px;
|
|
display:flex;flex-direction:column;gap:20px;
|
|
}
|
|
.msg{display:flex;gap:12px;max-width:820px;width:100%}
|
|
.msg.user{align-self:flex-end;flex-direction:row-reverse}
|
|
.msg-avatar{
|
|
width:32px;height:32px;border-radius:9px;flex-shrink:0;
|
|
display:flex;align-items:center;justify-content:center;font-size:14px;
|
|
}
|
|
.msg.user .msg-avatar{background:var(--accent);color:#fff;font-weight:700;font-size:12px}
|
|
.msg.jason .msg-avatar{background:linear-gradient(135deg,var(--accent),var(--accent-2));font-size:15px}
|
|
.msg-body{display:flex;flex-direction:column;gap:4px;min-width:0}
|
|
.msg.user .msg-body{align-items:flex-end}
|
|
.msg-meta{font-size:11px;color:var(--text-faint);font-family:var(--mono)}
|
|
.msg-bubble{
|
|
padding:12px 16px;border-radius:14px;
|
|
font-size:14px;line-height:1.65;
|
|
max-width:700px;word-break:break-word;
|
|
white-space:pre-wrap;
|
|
}
|
|
.msg.user .msg-bubble{
|
|
background:var(--accent);
|
|
color:#fff;border-radius:14px 4px 14px 14px;
|
|
}
|
|
.msg.jason .msg-bubble{
|
|
background:var(--bg-2);border:1px solid var(--border);
|
|
color:var(--text);border-radius:4px 14px 14px 14px;
|
|
}
|
|
.msg-bubble code{
|
|
font-family:var(--mono);font-size:12px;
|
|
background:rgba(0,0,0,0.3);padding:1px 5px;border-radius:4px;
|
|
}
|
|
.msg-bubble pre{
|
|
background:rgba(0,0,0,0.35);padding:12px;border-radius:8px;
|
|
overflow-x:auto;font-family:var(--mono);font-size:12px;
|
|
margin:8px 0;
|
|
}
|
|
|
|
/* typing indicator */
|
|
.typing-dots{display:flex;gap:4px;align-items:center;padding:4px 0}
|
|
.typing-dots span{
|
|
width:6px;height:6px;border-radius:50%;background:var(--text-faint);
|
|
animation:bounce 1.2s infinite;
|
|
}
|
|
.typing-dots span:nth-child(2){animation-delay:.2s}
|
|
.typing-dots span:nth-child(3){animation-delay:.4s}
|
|
@keyframes bounce{0%,80%,100%{transform:translateY(0)}40%{transform:translateY(-5px)}}
|
|
|
|
/* input area */
|
|
.input-wrap{
|
|
padding:16px 24px 20px;
|
|
border-top:1px solid var(--border);
|
|
background:var(--bg);flex-shrink:0;
|
|
}
|
|
.input-row{
|
|
display:flex;gap:10px;align-items:flex-end;
|
|
background:var(--bg-2);border:1px solid var(--border-2);
|
|
border-radius:14px;padding:10px 14px;
|
|
transition:border-color .2s;
|
|
}
|
|
.input-row:focus-within{border-color:var(--accent)}
|
|
#msg-input{
|
|
flex:1;background:none;border:none;color:var(--text);
|
|
font-family:var(--sans);font-size:14px;resize:none;
|
|
outline:none;min-height:22px;max-height:160px;
|
|
line-height:1.55;
|
|
}
|
|
#msg-input::placeholder{color:var(--text-faint)}
|
|
.send-btn{
|
|
width:34px;height:34px;border-radius:9px;
|
|
background:var(--accent);color:#fff;
|
|
display:flex;align-items:center;justify-content:center;
|
|
flex-shrink:0;transition:background .15s,opacity .15s;
|
|
}
|
|
.send-btn:hover{background:#6a4cff}
|
|
.send-btn:disabled{opacity:.38;cursor:default}
|
|
.input-hint{font-size:11px;color:var(--text-faint);margin-top:7px;padding:0 4px}
|
|
|
|
/* empty state */
|
|
.empty-state{
|
|
flex:1;display:flex;flex-direction:column;align-items:center;
|
|
justify-content:center;gap:16px;padding:40px;
|
|
text-align:center;
|
|
}
|
|
.empty-icon{font-size:48px}
|
|
.empty-title{font-size:20px;font-weight:700;letter-spacing:-0.02em}
|
|
.empty-desc{font-size:14px;color:var(--text-dim);max-width:380px;line-height:1.6}
|
|
.suggestions{display:flex;flex-wrap:wrap;gap:8px;justify-content:center;margin-top:8px}
|
|
.sug-btn{
|
|
padding:8px 16px;border-radius:99px;
|
|
background:var(--bg-2);border:1px solid var(--border);
|
|
font-size:13px;color:var(--text-dim);
|
|
transition:background .14s,color .14s,border-color .14s;
|
|
}
|
|
.sug-btn:hover{background:var(--bg-3);color:var(--text);border-color:var(--border-2)}
|
|
|
|
/* error banner */
|
|
.error-bar{
|
|
display:none;background:rgba(255,92,124,0.1);border:1px solid rgba(255,92,124,0.2);
|
|
color:var(--danger);font-size:12px;padding:8px 16px;border-radius:8px;
|
|
margin:0 24px 8px;text-align:center;
|
|
}
|
|
.error-bar.show{display:block}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="shell">
|
|
|
|
<!-- sidebar -->
|
|
<nav class="sidebar">
|
|
<div class="brand">
|
|
<div class="logo">V</div>
|
|
<span class="brand-name">Vauco OS</span>
|
|
</div>
|
|
|
|
<div class="side-label">Grensesnitt</div>
|
|
<a class="nav-item active" href="/jason">
|
|
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
|
|
Jason
|
|
</a>
|
|
<a class="nav-item" href="/opax">
|
|
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18M9 21V9"/></svg>
|
|
OPAX Hub
|
|
</a>
|
|
<a class="nav-item" href="/billing" target="_blank">
|
|
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M12 2v20M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/></svg>
|
|
CostGuard
|
|
</a>
|
|
|
|
<div class="side-label">Tilgang</div>
|
|
<a class="nav-item" href="https://console.cloud.google.com/run?project=propane-will-491900-m5" target="_blank">
|
|
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>
|
|
Cloud Run
|
|
</a>
|
|
<a class="nav-item" href="https://console.cloud.google.com/cloud-build/builds?project=propane-will-491900-m5" target="_blank">
|
|
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
|
|
Cloud Build
|
|
</a>
|
|
<a class="nav-item" href="https://ssh.cloud.google.com/v2/ssh/projects/propane-will-491900-m5/zones/us-central1-b/instances/osvauco-dev-from-snap" target="_blank">
|
|
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/></svg>
|
|
VM Shell
|
|
</a>
|
|
|
|
<div class="side-foot">
|
|
<div class="me-card">
|
|
<div class="me-name">Chris Christiansen</div>
|
|
<div class="me-role">operator · vauco.no</div>
|
|
<div class="session-id" id="sidebar-session">session: laster...</div>
|
|
</div>
|
|
</div>
|
|
</nav>
|
|
|
|
<!-- chat -->
|
|
<div class="chat-wrap">
|
|
<div class="chat-head">
|
|
<div class="jason-avatar">🤖</div>
|
|
<div class="jason-info">
|
|
<div class="jason-name">Jason</div>
|
|
<div class="jason-status"><span class="dot"></span> Online · ADK + Gemini 2.5 Pro</div>
|
|
</div>
|
|
<div class="head-actions">
|
|
<button class="head-btn" onclick="exportHistory()">⬇ Eksport</button>
|
|
<button class="head-btn danger" onclick="clearHistory()">Tøm chat</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="error-bar" id="error-bar"></div>
|
|
|
|
<div class="messages" id="messages">
|
|
<div class="empty-state" id="empty-state">
|
|
<div class="empty-icon">🤖</div>
|
|
<div class="empty-title">Jason er klar</div>
|
|
<div class="empty-desc">Primærgrensesnitt for Vauco OS. Still spørsmål, gi oppgaver, eller be om status på infrastruktur, builds og agenter.</div>
|
|
<div class="suggestions">
|
|
<button class="sug-btn" onclick="suggest(this)">Hva er status på siste build?</button>
|
|
<button class="sug-btn" onclick="suggest(this)">Oppsummer token-forbruk siste 7 dager</button>
|
|
<button class="sug-btn" onclick="suggest(this)">Hvilke oppgaver er aktive i ROADMAP?</button>
|
|
<button class="sug-btn" onclick="suggest(this)">Lag en ny Cloud Run-tjeneste</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="input-wrap">
|
|
<div class="input-row">
|
|
<textarea id="msg-input" rows="1" placeholder="Skriv til Jason... (Enter sender, Shift+Enter linjeskift)"
|
|
oninput="autoResize(this)" onkeydown="handleKey(event)"></textarea>
|
|
<button class="send-btn" id="send-btn" onclick="sendMessage()" disabled>
|
|
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
|
|
<line x1="22" y1="2" x2="11" y2="13"/>
|
|
<polygon points="22 2 15 22 11 13 2 9 22 2"/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
<div class="input-hint">Jason har tilgang til GCP, GitHub og Vauco-agenter. Historikk lagres lokalt i denne nettleseren.</div>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<script>
|
|
// ── Config ────────────────────────────────────────────────────────────────────
|
|
const AGENT_URL = '' // tom = samme origin (Cloud Run serverer /run)
|
|
const USER_ID = 'chris'
|
|
const STORAGE_KEY = 'jason_history_v2'
|
|
const SESSION_KEY = 'jason_session_v2'
|
|
|
|
// ── Session ───────────────────────────────────────────────────────────────────
|
|
function getSessionId() {
|
|
let sid = sessionStorage.getItem(SESSION_KEY)
|
|
if (!sid) {
|
|
sid = 'ses-' + Date.now() + '-' + Math.random().toString(36).slice(2,8)
|
|
sessionStorage.setItem(SESSION_KEY, sid)
|
|
}
|
|
return sid
|
|
}
|
|
const SESSION_ID = getSessionId()
|
|
document.getElementById('sidebar-session').textContent = 'session: ' + SESSION_ID.slice(0,18) + '…'
|
|
|
|
// ── History ───────────────────────────────────────────────────────────────────
|
|
function loadHistory() {
|
|
try { return JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]') }
|
|
catch { return [] }
|
|
}
|
|
function saveHistory(h) {
|
|
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(h.slice(-120))) }
|
|
catch {}
|
|
}
|
|
|
|
// ── Render ────────────────────────────────────────────────────────────────────
|
|
function fmtTime(ts) {
|
|
const d = new Date(ts)
|
|
return d.toLocaleTimeString('no-NO', {hour:'2-digit',minute:'2-digit'})
|
|
}
|
|
function escHtml(s) {
|
|
return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>')
|
|
}
|
|
function renderMarkdown(text) {
|
|
// minimal: code blocks, inline code, bold
|
|
let s = escHtml(text)
|
|
s = s.replace(/```([\s\S]*?)```/g, '<pre><code>$1</code></pre>')
|
|
s = s.replace(/`([^`]+)`/g, '<code>$1</code>')
|
|
s = s.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
|
return s
|
|
}
|
|
|
|
function appendMsg(role, text, ts) {
|
|
const empty = document.getElementById('empty-state')
|
|
if (empty) empty.remove()
|
|
const wrap = document.getElementById('messages')
|
|
const isUser = role === 'user'
|
|
const div = document.createElement('div')
|
|
div.className = 'msg ' + (isUser ? 'user' : 'jason')
|
|
div.innerHTML = `
|
|
<div class="msg-avatar">${isUser ? 'CC' : '🤖'}</div>
|
|
<div class="msg-body">
|
|
<div class="msg-meta">${isUser ? 'deg' : 'Jason'} · ${fmtTime(ts || Date.now())}</div>
|
|
<div class="msg-bubble">${isUser ? escHtml(text) : renderMarkdown(text)}</div>
|
|
</div>`
|
|
wrap.appendChild(div)
|
|
wrap.scrollTop = wrap.scrollHeight
|
|
return div
|
|
}
|
|
|
|
function appendTyping() {
|
|
const empty = document.getElementById('empty-state')
|
|
if (empty) empty.remove()
|
|
const wrap = document.getElementById('messages')
|
|
const div = document.createElement('div')
|
|
div.className = 'msg jason'
|
|
div.id = 'typing-indicator'
|
|
div.innerHTML = `
|
|
<div class="msg-avatar">🤖</div>
|
|
<div class="msg-body">
|
|
<div class="msg-meta">Jason · skriver...</div>
|
|
<div class="msg-bubble"><div class="typing-dots"><span></span><span></span><span></span></div></div>
|
|
</div>`
|
|
wrap.appendChild(div)
|
|
wrap.scrollTop = wrap.scrollHeight
|
|
}
|
|
|
|
function removeTyping() {
|
|
const t = document.getElementById('typing-indicator')
|
|
if (t) t.remove()
|
|
}
|
|
|
|
// ── Input ─────────────────────────────────────────────────────────────────────
|
|
const input = document.getElementById('msg-input')
|
|
const sendBtn = document.getElementById('send-btn')
|
|
input.addEventListener('input', () => {
|
|
sendBtn.disabled = !input.value.trim()
|
|
})
|
|
function autoResize(el) {
|
|
el.style.height = 'auto'
|
|
el.style.height = Math.min(el.scrollHeight, 160) + 'px'
|
|
}
|
|
function handleKey(e) {
|
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
e.preventDefault()
|
|
if (!sendBtn.disabled) sendMessage()
|
|
}
|
|
}
|
|
function suggest(btn) {
|
|
input.value = btn.textContent.trim()
|
|
input.dispatchEvent(new Event('input', { bubbles: true }))
|
|
autoResize(input)
|
|
sendBtn.disabled = false
|
|
sendMessage()
|
|
}
|
|
|
|
// ── Send ──────────────────────────────────────────────────────────────────────
|
|
let isSending = false
|
|
async function sendMessage() {
|
|
if (isSending) return
|
|
const text = input.value.trim()
|
|
if (!text) return
|
|
isSending = true
|
|
sendBtn.disabled = true
|
|
input.value = ''
|
|
input.style.height = 'auto'
|
|
hideError()
|
|
|
|
const ts = Date.now()
|
|
const history = loadHistory()
|
|
history.push({role:'user', text, ts})
|
|
saveHistory(history)
|
|
appendMsg('user', text, ts)
|
|
appendTyping()
|
|
|
|
try {
|
|
const res = await fetch(AGENT_URL + '/run', {
|
|
method: 'POST',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({user_id: USER_ID, session_id: SESSION_ID, message: text})
|
|
})
|
|
removeTyping()
|
|
if (!res.ok) throw new Error('HTTP ' + res.status)
|
|
const data = await res.json()
|
|
const reply = data.response || '(tomt svar)'
|
|
const rts = Date.now()
|
|
history.push({role:'jason', text:reply, ts:rts})
|
|
saveHistory(history)
|
|
appendMsg('jason', reply, rts)
|
|
} catch(err) {
|
|
removeTyping()
|
|
showError('Jason svarte ikke: ' + err.message + ' — sjekk at Cloud Run er oppe.')
|
|
} finally {
|
|
isSending = false
|
|
sendBtn.disabled = !input.value.trim()
|
|
input.focus()
|
|
}
|
|
}
|
|
|
|
// ── Utilities ─────────────────────────────────────────────────────────────────
|
|
function showError(msg) {
|
|
const bar = document.getElementById('error-bar')
|
|
bar.textContent = msg
|
|
bar.classList.add('show')
|
|
}
|
|
function hideError() {
|
|
document.getElementById('error-bar').classList.remove('show')
|
|
}
|
|
function clearHistory() {
|
|
if (!confirm('Tøm hele chat-historikken?')) return
|
|
localStorage.removeItem(STORAGE_KEY)
|
|
sessionStorage.removeItem(SESSION_KEY)
|
|
location.reload()
|
|
}
|
|
function exportHistory() {
|
|
const h = loadHistory()
|
|
const lines = h.map(m => `[${new Date(m.ts).toISOString()}] ${m.role.toUpperCase()}: ${m.text}`)
|
|
const blob = new Blob([lines.join('\n\n')], {type:'text/plain'})
|
|
const a = document.createElement('a')
|
|
a.href = URL.createObjectURL(blob)
|
|
a.download = 'jason-historikk-' + new Date().toISOString().slice(0,10) + '.txt'
|
|
a.click()
|
|
}
|
|
|
|
// ── Restore history on load ───────────────────────────────────────────────────
|
|
;(function restoreHistory(){
|
|
const h = loadHistory()
|
|
if (!h.length) return
|
|
h.forEach(m => appendMsg(m.role, m.text, m.ts))
|
|
document.getElementById('messages').scrollTop = 99999
|
|
})()
|
|
|
|
input.focus()
|
|
</script>
|
|
</body>
|
|
</html>
|