diff --git a/agents/core-logic/app.py b/agents/core-logic/app.py index 61d12e0..22995ca 100644 --- a/agents/core-logic/app.py +++ b/agents/core-logic/app.py @@ -80,13 +80,13 @@ async def _ensure_session(user_id: str, session_id: str): return session -# ── helse ────────────────────────────────────────────────────────────────────────────── +# ── helse ──────────────────────────────────────────────────────────────────── @app.get("/health") async def health(): return JSONResponse({"status": "ok", "service": APP_NAME}) -# ── agent run ─────────────────────────────────────────────────────────────────── +# ── agent run ──────────────────────────────────────────────────────────────── @app.post("/run", response_model=RunResponse) async def run(req: RunRequest): try: @@ -108,58 +108,35 @@ async def run(req: RunRequest): raise HTTPException(status_code=500, detail=str(e)) -# ── OX1a: Cloud Build status ──────────────────────────────────────────────────────── +# ── OX1a: Cloud Build status ────────────────────────────────────────────────── @app.get("/opax/build-status") async def build_status(): """ - OX1a — Hent siste Cloud Build-kjøring for OSVauco-repoet. - Returnerer status, tidspunkt og varighet slik at opax.html kan vise - live build-widget (spinner → ✅/❌). - - Tilstandsverdier fra Cloud Build API: - QUEUED, WORKING → bygger nå (vis spinner) - SUCCESS → ok - FAILURE / TIMEOUT / CANCELLED / INTERNAL_ERROR → feil + OX1a — Hent siste Cloud Build-kjøring. + Status: queued|working|success|failure|timeout|cancelled|unknown """ try: from google.cloud.devtools import cloudbuild_v1 client = cloudbuild_v1.CloudBuildClient() - - # Hent siste 5 builds for prosjektet request = cloudbuild_v1.ListBuildsRequest( project_id=PROJECT_ID, - filter='trigger_id!=""', # kun trigger-builds, ikke manuelle + filter='trigger_id!=""', page_size=5, ) builds = list(client.list_builds(request=request)) - if not builds: return JSONResponse({"status": "unknown", "message": "Ingen builds funnet"}) - - b = builds[0] # nyeste - status_map = { - 1: "queued", - 2: "working", - 3: "success", - 4: "failure", - 5: "internal_error", - 6: "timeout", - 7: "cancelled", - } + b = builds[0] + status_map = {1:"queued",2:"working",3:"success",4:"failure",5:"internal_error",6:"timeout",7:"cancelled"} status_str = status_map.get(int(b.status), "unknown") - - # Beregn varighet duration_s = None - start = b.start_time - finish = b.finish_time - if start and finish: - duration_s = int((finish.seconds - start.seconds)) - elif start: + if b.start_time and b.finish_time: + duration_s = int(b.finish_time.seconds - b.start_time.seconds) + elif b.start_time: import time - duration_s = int(time.time() - start.seconds) - + duration_s = int(time.time() - b.start_time.seconds) return JSONResponse({ - "status": status_str, # queued|working|success|failure|timeout|cancelled + "status": status_str, "build_id": b.id, "trigger_id": b.build_trigger_id or "", "branch": (b.substitutions or {}).get("BRANCH_NAME", "main"), @@ -174,7 +151,99 @@ async def build_status(): return JSONResponse({"status": "error", "message": str(e)}, status_code=200) -# ── billing: tokens ────────────────────────────────────────────────────────────────── +# ── CG4a: Token intelligence — per modul ───────────────────────────────────── +@app.get("/billing/tokens/by-module") +async def tokens_by_module(days: int = 30): + """ + CG4a — Token-forbruk aggregert per module_name + caller_type. + Brukes av opax.html TUI + fremtidig token-panel i billing dashboard. + Fallback: tom liste hvis BQ mangler data (OQ-15 safe). + """ + try: + from google.cloud import bigquery + client = bigquery.Client(project=PROJECT_ID) + query = f""" + SELECT + COALESCE(module_name, 'ukjent') AS module_name, + COALESCE(caller_type, 'ukjent') AS caller_type, + model_name, + COUNT(*) AS calls, + SUM(input_tokens) AS input_tokens, + SUM(output_tokens) AS output_tokens, + SUM(total_tokens) AS total_tokens, + ROUND(SUM(estimated_cost_usd), 6) AS cost_usd + FROM `{PROJECT_ID}.{BQ_BILLING_DATASET}.llm_token_usage` + WHERE timestamp >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL {int(days)} DAY) + GROUP BY module_name, caller_type, model_name + ORDER BY cost_usd DESC + LIMIT 100 + """ + rows = [ + { + "module_name": row.module_name, + "caller_type": row.caller_type, + "model_name": row.model_name, + "calls": row.calls, + "input_tokens": row.input_tokens, + "output_tokens":row.output_tokens, + "total_tokens": row.total_tokens, + "cost_usd": float(row.cost_usd), + } + for row in client.query(query).result() + ] + return JSONResponse({"period_days": days, "rows": rows}) + except Exception as e: + logger.error(f"[/billing/tokens/by-module] Failed: {e}", exc_info=True) + return JSONResponse({"period_days": days, "rows": [], "warning": str(e)}) + + +# ── CG4c: Prosjektestimator ─────────────────────────────────────────────────── +@app.get("/billing/tokens/estimate") +async def tokens_estimate(complexity: str = "medium"): + """ + CG4c — Prosjektestimator. + Henter snitt-tokens per kall siste 30 dager, multipliserer med kompleksitetsfaktor. + complexity: low | medium | high | extreme + Returnerer estimert kostnad i USD for et nytt oppdrag. + """ + factors = {"low": 0.5, "medium": 1.0, "high": 2.5, "extreme": 6.0} + factor = factors.get(complexity, 1.0) + try: + from google.cloud import bigquery + client = bigquery.Client(project=PROJECT_ID) + query = f""" + SELECT + AVG(total_tokens) AS avg_tokens_per_call, + AVG(estimated_cost_usd) AS avg_cost_per_call, + COUNT(*) AS total_calls, + SUM(estimated_cost_usd) AS total_cost_usd + FROM `{PROJECT_ID}.{BQ_BILLING_DATASET}.llm_token_usage` + WHERE timestamp >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 30 DAY) + """ + result = list(client.query(query).result()) + if not result or result[0].avg_tokens_per_call is None: + return JSONResponse({"complexity": complexity, "factor": factor, "estimated_usd": None, "warning": "Ingen data ennå (OQ-15)"}) + row = result[0] + avg_cost = float(row.avg_cost_per_call or 0) + # Estimat: 1 oppdrag = 20 kall snitt × faktor + CALLS_PER_TASK = 20 + estimated = round(avg_cost * CALLS_PER_TASK * factor, 4) + return JSONResponse({ + "complexity": complexity, + "factor": factor, + "avg_tokens_per_call": round(float(row.avg_tokens_per_call or 0), 1), + "avg_cost_per_call": round(avg_cost, 6), + "total_calls_30d": row.total_calls, + "total_cost_30d_usd": round(float(row.total_cost_usd or 0), 4), + "estimated_usd": estimated, + "calls_assumed": CALLS_PER_TASK, + }) + except Exception as e: + logger.error(f"[/billing/tokens/estimate] Failed: {e}", exc_info=True) + return JSONResponse({"complexity": complexity, "estimated_usd": None, "warning": str(e)}) + + +# ── billing: tokens summary (eksisterende) ──────────────────────────────────── @app.get("/billing/tokens/summary") async def billing_tokens_summary(): try: @@ -182,9 +251,7 @@ async def billing_tokens_summary(): client = bigquery.Client(project=PROJECT_ID) query = f""" SELECT - module_name, - caller_type, - model_name, + module_name, caller_type, model_name, SUM(input_tokens) AS total_input_tokens, SUM(output_tokens) AS total_output_tokens, SUM(total_tokens) AS total_tokens, @@ -212,7 +279,6 @@ async def billing_tokens_summary(): raise HTTPException(status_code=500, detail=str(e)) -# ── billing: anbefalinger ───────────────────────────────────────────────────────────── @app.get("/billing/recommendations") async def billing_recommendations(budget: float = 500.0): try: @@ -227,8 +293,7 @@ async def billing_recommendations(budget: float = 500.0): async def billing_by_service(days: int = 30): try: from ml.billing_agent import BillingAgent - agent = BillingAgent() - return JSONResponse(agent.get_service_totals(days)) + return JSONResponse(BillingAgent().get_service_totals(days)) except Exception as e: logger.error(f"[/billing/by-service] Failed: {e}", exc_info=True) return JSONResponse([]) @@ -238,8 +303,7 @@ async def billing_by_service(days: int = 30): async def billing_anomalies(): try: from ml.billing_agent import BillingAgent - agent = BillingAgent() - return JSONResponse(agent.get_anomalies()) + return JSONResponse(BillingAgent().get_anomalies()) except Exception as e: logger.error(f"[/billing/anomalies] Failed: {e}", exc_info=True) return JSONResponse({"anomalies": []}) @@ -249,8 +313,7 @@ async def billing_anomalies(): async def billing_history(days: int = 30): try: from ml.billing_agent import BillingAgent - agent = BillingAgent() - return JSONResponse({"history": agent.get_daily_history(days)}) + return JSONResponse({"history": BillingAgent().get_daily_history(days)}) except Exception as e: logger.error(f"[/billing/history] Failed: {e}", exc_info=True) return JSONResponse({"history": []}) @@ -260,8 +323,7 @@ async def billing_history(days: int = 30): async def billing_summary(): try: from ml.billing_agent import BillingAgent - agent = BillingAgent() - return JSONResponse(agent.get_summary()) + return JSONResponse(BillingAgent().get_summary()) except Exception as e: logger.error(f"[/billing/summary] Failed: {e}", exc_info=True) return JSONResponse({"summary": []}) @@ -271,8 +333,7 @@ async def billing_summary(): async def billing_live(): try: from ml.billing_agent import BillingAgent - agent = BillingAgent() - return JSONResponse(agent.get_forecast()) + return JSONResponse(BillingAgent().get_forecast()) except Exception as e: logger.error(f"[/billing/live] Failed: {e}", exc_info=True) return JSONResponse({}) @@ -280,8 +341,7 @@ async def billing_live(): @app.get("/billing/budget") async def get_budget(user: str = "default"): - budget = _budget_store.get(user, 500.0) - return JSONResponse({"budget": budget, "user": user}) + return JSONResponse({"budget": _budget_store.get(user, 500.0), "user": user}) @app.post("/billing/budget") @@ -289,5 +349,4 @@ async def set_budget(req: BudgetRequest): if req.budget <= 0: raise HTTPException(status_code=400, detail="Budget must be > 0") _budget_store[req.user] = req.budget - logger.info(f"Budget updated: {req.user} -> {req.budget}") return JSONResponse({"budget": req.budget, "user": req.user}) diff --git a/static/opax.html b/static/opax.html index 38e21aa..ed6bb42 100644 --- a/static/opax.html +++ b/static/opax.html @@ -14,7 +14,6 @@ --accent:#7c5cff;--accent-2:#00d4a8;--accent-glow:rgba(124,92,255,0.22); --green:#3ecf8e;--amber:#ffb547;--danger:#ff5c7c; --radius:12px;--radius-lg:18px; - --shadow:0 8px 32px rgba(0,0,0,0.4); --sans:-apple-system,BlinkMacSystemFont,'Inter','SF Pro Display',system-ui,sans-serif; --mono:'SF Mono',ui-monospace,'JetBrains Mono',Menlo,monospace; } @@ -26,14 +25,15 @@ ::-webkit-scrollbar-thumb{background:var(--bg-3);border-radius:6px} ::-webkit-scrollbar-track{background:transparent} - .topbar{position:sticky;top:0;z-index:100;display:flex;align-items:center;justify-content:space-between;padding:14px 28px;background:rgba(10,10,15,0.82);backdrop-filter:blur(20px);border-bottom:1px solid var(--border)} + /* topbar */ + .topbar{position:sticky;top:0;z-index:100;display:flex;align-items:center;justify-content:space-between;padding:14px 28px;background:rgba(10,10,15,0.88);backdrop-filter:blur(20px);border-bottom:1px solid var(--border)} .topbar-brand{display:flex;align-items:center;gap:12px} .logo-mark{width:30px;height:30px;border-radius:9px;background:linear-gradient(135deg,var(--accent),var(--accent-2));display:flex;align-items:center;justify-content:center;color:#0a0a0f;flex-shrink:0} .topbar-name{font-weight:800;font-size:16px;letter-spacing:-0.02em} .topbar-sep{color:var(--text-faint);font-weight:300} .topbar-sub{color:var(--text-dim);font-size:14px} - .topbar-right{display:flex;align-items:center;gap:18px;flex-wrap:wrap;justify-content:flex-end} - .status-pill{display:inline-flex;align-items:center;gap:7px;padding:5px 12px;border-radius:99px;background:var(--bg-3);border:1px solid var(--border);font-size:12px;color:var(--text-dim);font-weight:500} + .topbar-right{display:flex;align-items:center;gap:14px;flex-wrap:wrap;justify-content:flex-end} + .status-pill{display:inline-flex;align-items:center;gap:7px;padding:5px 12px;border-radius:99px;background:var(--bg-3);border:1px solid var(--border);font-size:12px;color:var(--text-dim);font-weight:500;cursor:default} .dot{width:7px;height:7px;border-radius:50%;background:var(--green);flex-shrink:0} .dot.amber{background:var(--amber)} .dot.red{background:var(--danger)} @@ -42,8 +42,9 @@ @keyframes pulse{0%,100%{opacity:1}50%{opacity:.35}} .topbar-id{font-family:var(--mono);font-size:11px;color:var(--text-faint)} + /* shell */ .shell{display:flex;min-height:calc(100vh - 60px)} - .sidebar{width:230px;flex-shrink:0;padding:24px 16px;border-right:1px solid var(--border);display:flex;flex-direction:column;gap:4px;position:sticky;top:60px;height:calc(100vh - 60px)} + .sidebar{width:230px;flex-shrink:0;padding:24px 16px;border-right:1px solid var(--border);display:flex;flex-direction:column;gap:4px;position:sticky;top:60px;height:calc(100vh - 60px);overflow-y:auto} .side-label{font-size:11px;text-transform:uppercase;letter-spacing:0.08em;color:var(--text-faint);font-weight:600;padding:14px 12px 6px} .nav-item{display:flex;align-items:center;gap:10px;padding:9px 12px;border-radius:9px;font-size:14px;color:var(--text-dim);font-weight:500;transition:background .15s,color .15s} .nav-item:hover{background:var(--bg-2);color:var(--text)} @@ -54,9 +55,10 @@ .nav-badge.soon{background:rgba(255,181,71,0.1);color:var(--amber);border-color:rgba(255,181,71,0.18)} .side-foot{margin-top:auto;padding-top:16px;border-top:1px solid var(--border)} .me-card{padding:10px 12px;border-radius:10px;background:var(--bg-2);border:1px solid var(--border)} - .me-name{font-size:12px;font-weight:600;color:var(--text)} + .me-name{font-size:12px;font-weight:600} .me-role{font-size:11px;color:var(--text-faint);margin-top:2px;font-family:var(--mono)} + /* main */ .main{flex:1;padding:32px 40px 56px;max-width:1180px;width:100%} .page-head{margin-bottom:36px} .page-title{font-size:30px;font-weight:800;letter-spacing:-0.03em;background:linear-gradient(180deg,#fff 0%,#a8aabc 100%);-webkit-background-clip:text;background-clip:text;-webkit-text-fill-color:transparent;padding-bottom:.08em} @@ -66,6 +68,7 @@ .section-label{font-size:13px;text-transform:uppercase;letter-spacing:0.1em;color:var(--text-faint);font-weight:700} .section-line{flex:1;height:1px;background:var(--border)} + /* cards */ .grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(290px,1fr));gap:18px} .card{padding:24px;background:var(--bg-2);border:1px solid var(--border);border-radius:var(--radius-lg);transition:border-color .2s,transform .2s,background .2s;position:relative;overflow:hidden} .card.clickable{cursor:pointer} @@ -79,10 +82,10 @@ .card-name{font-size:17px;font-weight:700;letter-spacing:-0.01em} .card-desc{font-size:13.5px;color:var(--text-dim);margin-top:8px;line-height:1.55} .card-meta{display:flex;flex-wrap:wrap;gap:14px;margin-top:18px;font-size:12px;color:var(--text-faint);font-family:var(--mono)} - .card-meta span{display:inline-flex;align-items:center;gap:6px} .card-link{display:inline-flex;align-items:center;gap:7px;margin-top:18px;font-size:13px;font-weight:600;color:var(--accent)} .card-link:hover{color:#9b80ff} + /* agents */ .agent-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:14px} .agent-row{display:flex;align-items:center;gap:14px;padding:16px 18px;background:var(--bg-2);border:1px solid var(--border);border-radius:var(--radius);transition:border-color .2s,background .2s} .agent-row.actionable{cursor:pointer} @@ -92,6 +95,25 @@ .agent-go{margin-left:auto;font-size:11px;color:var(--accent);font-weight:600;opacity:0;transition:opacity .2s} .agent-row.actionable:hover .agent-go{opacity:1} + /* build widget */ + .build-widget{padding:20px 24px;background:var(--bg-2);border:1px solid var(--border);border-radius:var(--radius-lg);display:flex;align-items:center;gap:20px;flex-wrap:wrap} + .build-icon{width:44px;height:44px;border-radius:12px;flex-shrink:0;background:var(--bg-3);border:1px solid var(--border-2);display:flex;align-items:center;justify-content:center} + .build-info{flex:1;min-width:180px} + .build-title{font-size:14px;font-weight:700;margin-bottom:3px} + .build-meta{font-size:12px;color:var(--text-faint);font-family:var(--mono)} + .build-status-row{display:flex;align-items:center;gap:10px;margin-top:8px;flex-wrap:wrap} + .build-badge{display:inline-flex;align-items:center;gap:6px;padding:4px 12px;border-radius:99px;font-size:12px;font-weight:600} + .build-badge.working{background:rgba(255,181,71,0.12);color:var(--amber);border:1px solid rgba(255,181,71,0.25)} + .build-badge.success{background:rgba(62,207,142,0.12);color:var(--green);border:1px solid rgba(62,207,142,0.22)} + .build-badge.failure,.build-badge.timeout,.build-badge.internal_error{background:rgba(255,92,124,0.1);color:var(--danger);border:1px solid rgba(255,92,124,0.2)} + .build-badge.queued,.build-badge.unknown,.build-badge.error{background:var(--bg-3);color:var(--text-dim);border:1px solid var(--border)} + .build-spinner{width:14px;height:14px;border-radius:50%;border:2px solid var(--amber);border-top-color:transparent;animation:spin .7s linear infinite;flex-shrink:0} + @keyframes spin{to{transform:rotate(360deg)}} + .build-duration,.build-commit{font-size:11px;color:var(--text-faint);font-family:var(--mono)} + .build-log-link{font-size:12px;color:var(--accent);font-weight:600} + .build-log-link:hover{color:#9b80ff} + + /* infra */ .infra-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:14px} .infra-item{padding:16px 18px;background:var(--bg-2);border:1px solid var(--border);border-radius:var(--radius)} .infra-label{font-size:11px;text-transform:uppercase;letter-spacing:0.06em;color:var(--text-faint);font-weight:600;margin-bottom:6px} @@ -100,502 +122,42 @@ .infra-value.warn{color:var(--amber)} .infra-value.neutral{color:var(--text)} - /* ── Build widget (OX1a) ────────────────────────────────────────────────────────── */ - .build-widget{ - padding:20px 24px;background:var(--bg-2); - border:1px solid var(--border);border-radius:var(--radius-lg); - display:flex;align-items:center;gap:20px;flex-wrap:wrap; + /* ── OX1b: TUI panel ── */ + .tui-panel{ + background:var(--bg-2);border:1px solid var(--border); + border-radius:var(--radius-lg);overflow:hidden; } - .build-icon{ - width:44px;height:44px;border-radius:12px;flex-shrink:0; - background:var(--bg-3);border:1px solid var(--border-2); - display:flex;align-items:center;justify-content:center; + .tui-head{ + display:flex;align-items:center;gap:10px; + padding:12px 18px;border-bottom:1px solid var(--border); + background:var(--bg-3); } - .build-info{flex:1;min-width:180px} - .build-title{font-size:14px;font-weight:700;margin-bottom:3px} - .build-meta{font-size:12px;color:var(--text-faint);font-family:var(--mono)} - .build-status-row{display:flex;align-items:center;gap:10px;margin-top:8px;flex-wrap:wrap} - .build-badge{ - display:inline-flex;align-items:center;gap:6px; - padding:4px 12px;border-radius:99px; - font-size:12px;font-weight:600; + .tui-tabs{display:flex;gap:2px} + .tui-tab{ + padding:6px 14px;border-radius:8px;font-size:13px;font-weight:600; + color:var(--text-dim);transition:background .15s,color .15s; } - .build-badge.working{background:rgba(255,181,71,0.12);color:var(--amber);border:1px solid rgba(255,181,71,0.25)} - .build-badge.success{background:rgba(62,207,142,0.12);color:var(--green);border:1px solid rgba(62,207,142,0.22)} - .build-badge.failure,.build-badge.timeout,.build-badge.internal_error{ - background:rgba(255,92,124,0.1);color:var(--danger);border:1px solid rgba(255,92,124,0.2)} - .build-badge.queued{background:var(--bg-3);color:var(--text-dim);border:1px solid var(--border)} - .build-badge.unknown,.build-badge.error{background:var(--bg-3);color:var(--text-dim);border:1px solid var(--border)} - .build-spinner{ - width:14px;height:14px;border-radius:50%; - border:2px solid var(--amber);border-top-color:transparent; - animation:spin .7s linear infinite;flex-shrink:0; + .tui-tab:hover{background:var(--bg-2);color:var(--text)} + .tui-tab.active{background:var(--accent);color:#fff} + .tui-actions{margin-left:auto;display:flex;gap:8px} + .tui-btn{ + padding:5px 12px;border-radius:8px;font-size:11px;font-weight:600; + background:var(--bg-2);border:1px solid var(--border);color:var(--text-dim); + transition:background .15s,color .15s; } - @keyframes spin{to{transform:rotate(360deg)}} - .build-duration{font-size:11px;color:var(--text-faint);font-family:var(--mono)} - .build-log-link{font-size:12px;color:var(--accent);font-weight:600} - .build-log-link:hover{color:#9b80ff} - .build-commit{font-family:var(--mono);font-size:11px;color:var(--text-faint)} + .tui-btn:hover{background:var(--bg-3);color:var(--text)} + .tui-btn.danger:hover{background:rgba(255,92,124,0.12);color:var(--danger);border-color:rgba(255,92,124,0.25)} - .flow-box{padding:24px;background:var(--bg-2);border:1px solid var(--border);border-radius:var(--radius-lg);position:relative} - .flow-box::after{content:'FASE 2 — IKKE AKTIVERT';position:absolute;top:18px;right:20px;font-size:10px;font-weight:700;letter-spacing:0.08em;color:var(--text-faint);font-family:var(--mono)} - .flow-title{font-size:14px;font-weight:600;margin-bottom:18px} - .flow-steps{display:flex;flex-wrap:wrap;align-items:center;gap:8px} - .flow-step{padding:8px 14px;background:var(--bg-3);border:1px solid var(--border);border-radius:99px;font-size:12px;color:var(--text-dim);white-space:nowrap} - .flow-arrow{color:var(--text-faint);font-size:13px} - .flow-note{margin-top:16px;font-size:11px;color:var(--text-faint);font-family:var(--mono)} - - .page-foot{display:flex;justify-content:space-between;flex-wrap:wrap;gap:10px;margin-top:48px;padding-top:20px;border-top:1px solid var(--border);font-size:11px;color:var(--text-faint);font-family:var(--mono)} - - .chat-overlay{position:fixed;inset:0;background:rgba(0,0,0,0.55);backdrop-filter:blur(2px);z-index:200;opacity:0;pointer-events:none;transition:opacity .25s} - .chat-overlay.open{opacity:1;pointer-events:auto} - .chat-drawer{position:fixed;top:0;right:0;bottom:0;width:min(440px,100vw);background:var(--bg-2);border-left:1px solid var(--border-2);z-index:201;display:flex;flex-direction:column;transform:translateX(100%);transition:transform .28s cubic-bezier(.4,0,.2,1);box-shadow:-20px 0 60px rgba(0,0,0,0.5)} - .chat-drawer.open{transform:translateX(0)} - .chat-head{display:flex;align-items:center;gap:12px;padding:18px 20px;border-bottom:1px solid var(--border)} - .chat-avatar{width:34px;height:34px;border-radius:10px;background:linear-gradient(135deg,var(--accent),var(--accent-2));display:flex;align-items:center;justify-content:center;color:#0a0a0f;font-weight:800;font-size:14px} - .chat-title{font-size:15px;font-weight:700} - .chat-sub{font-size:11px;color:var(--text-faint);font-family:var(--mono)} - .chat-close{margin-left:auto;width:30px;height:30px;border-radius:8px;display:flex;align-items:center;justify-content:center;color:var(--text-dim)} - .chat-close:hover{background:var(--bg-3);color:var(--text)} - .chat-body{flex:1;overflow-y:auto;padding:20px;display:flex;flex-direction:column;gap:14px} - .msg{max-width:85%;padding:11px 14px;border-radius:14px;font-size:14px;line-height:1.55;white-space:pre-wrap;word-wrap:break-word} - .msg.user{align-self:flex-end;background:var(--accent);color:#fff;border-bottom-right-radius:4px} - .msg.bot{align-self:flex-start;background:var(--bg-3);border:1px solid var(--border);border-bottom-left-radius:4px} - .msg.err{align-self:flex-start;background:rgba(255,92,124,0.1);border:1px solid rgba(255,92,124,0.25);color:#ff9bb0} - .msg.typing{align-self:flex-start;color:var(--text-faint);font-style:italic;font-size:13px;background:none;padding:4px 0} - .chat-empty{margin:auto;text-align:center;color:var(--text-faint);font-size:13px;max-width:260px} - .chat-empty svg{margin-bottom:12px;opacity:.5} - .chat-foot{padding:14px 16px;border-top:1px solid var(--border);display:flex;gap:10px;align-items:flex-end} - .chat-input{flex:1;background:var(--bg-3);border:1px solid var(--border-2);border-radius:12px;padding:11px 14px;color:var(--text);font-family:var(--sans);font-size:14px;resize:none;max-height:120px;line-height:1.5} - .chat-input:focus{outline:none;border-color:var(--accent)} - .chat-send{width:42px;height:42px;border-radius:11px;background:var(--accent);color:#fff;display:flex;align-items:center;justify-content:center;flex-shrink:0;transition:background .15s} - .chat-send:hover{background:#6a4dee} - .chat-send:disabled{opacity:.4;cursor:not-allowed} - .mode-row{display:flex;gap:6px;padding:0 16px 12px} - .mode-chip{font-size:11px;padding:4px 11px;border-radius:99px;background:var(--bg-3);border:1px solid var(--border);color:var(--text-dim);font-weight:600} - .mode-chip.active{background:var(--accent);color:#fff;border-color:var(--accent)} - - @media(max-width:860px){ - .shell{flex-direction:column} - .sidebar{width:100%;height:auto;display:flex;flex-direction:row;align-items:center;overflow-x:auto;border-right:none;border-bottom:1px solid var(--border);padding:8px 12px;gap:6px;position:sticky;top:0;background:var(--bg-2);z-index:50} - .sidebar::-webkit-scrollbar{display:none} - .side-label{display:none} - .nav-item{flex-shrink:0;white-space:nowrap;border-radius:99px;padding:8px 13px;align-self:center} - .side-foot{display:none} - .main{padding:22px 16px 48px} - .topbar{padding:12px 16px;flex-wrap:wrap;gap:8px} - .topbar-id{display:none} - .topbar-sub{display:none} + /* Chat-tab */ + .tui-chat-body{ + height:360px;overflow-y:auto;padding:20px; + display:flex;flex-direction:column;gap:12px; + background:#0c0d13; + font-family:var(--mono);font-size:13px; } - @media(max-width:560px){ - .chat-drawer{width:100vw} - .topbar-right{width:100%;overflow-x:auto;flex-wrap:nowrap} - .topbar-right::-webkit-scrollbar{display:none} - } - - - - -
-
- - - - - - - OPAX - / - Vauco Operator Hub -
-
-
kobler til…
-
build…
-
propane-will-491900-m5 · us-central1
-
-
- -
- - -
-
-
Operator Hub
-
Vauco OS — intern plattform for drift, planlegging og kjøring av OPAX-moduler.
-
- - -
-
-
-
-
- - Live -
-
CostGuard
-
Sanntids GCP-kostnadskontroll. Anomalideteksjon, budsjettvarsler, historikk og token-måler.
-
0 kundercostguard.oss.vauco.no
- Åpne dashboard -
-
-
- - Planlagt -
-
Threadstone
-
Kundeskreddersydd AI-salgsassistent. Landingsside er live, app under utvikling.
-
threadstone.vauco.no
-
-
-
- - Fase 3 -
-
Module Builder
-
Bygg og deploy nye salgsmoduler visuelt direkte fra OPAX.
-
opax.vauco.no/build
-
-
-
- - -
-
-
-
- -
Jason — Light
jason/light · gemini-2.5-flash
- Snakk → -
-
- -
Jason — Heavy
jason/heavy · gemini-2.5-pro
- Snakk → -
-
- -
OPAX Core
POST /run · orkestrering
- Snakk → -
-
- -
Provisioner
mail · magic link · fase 2
-
-
-
- - -
-
-
-
- - - -
-
-
Cloud Build — OSVauco
-
laster…
-
- sjekker… - -
-
-
- - -
-
-
- - -
-
-
-
Cloud Run
osvauco-agent
-
Region (agent)
us-central1
-
Project
propane-will-491900-m5
-
RAG-korpus
europe-west4 · EU
-
opax.vauco.no
CNAME live
-
costguard.oss.vauco.no
Cloud Run live
-
vauco.no
GCS + LB live
-
Secret Manager
aktiv
-
Deploy
auto · push-to-main
-
Backend /health
— sjekker…
-
-
- - -
-
-
-
Kundeprovisjoneringsflyt — Voice → Modul → Mail
-
- Chris taler (AirPods) - Web Speech API - Jason analyserer - Modul genereres - Chris godkjenner - Magic link → kunde -
-
Avhenger av: Fase 2 (SendGrid/Resend) · POST /opax/generate · kunde-subdomain via Terraform
-
-
- - -
-
- - -
- - - - - + .tui-chat-foot{display:flex;gap:0;border-top:1px solid var(--border)} + .tui-prefix{padding:12px 14px;font-family:var(--mono);font-size:13px;color:var(--accent-2);background:#0c0d13;flex-shrink:0;display:flex;align-items:center} + .tui-input{ + flex:1;background:#0c0d13;border:none;color:var(--text); + font-family:var(--mono);font-size:13px;padding:12px 0; + outline:none;caret-color:var(-- \ No newline at end of file