This commit was merged in pull request #1211.
This commit is contained in:
105
docs/nexus-spec.md
Normal file
105
docs/nexus-spec.md
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
# Nexus — Scope & Acceptance Criteria
|
||||||
|
|
||||||
|
**Issue:** #1208
|
||||||
|
**Date:** 2026-03-23
|
||||||
|
**Status:** Initial implementation complete; teaching/RL harness deferred
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
The **Nexus** is a persistent conversational space where Timmy lives with full
|
||||||
|
access to his live memory. Unlike the main dashboard chat (which uses tools and
|
||||||
|
has a transient feel), the Nexus is:
|
||||||
|
|
||||||
|
- **Conversational only** — no tool approval flow; pure dialogue
|
||||||
|
- **Memory-aware** — semantically relevant memories surface alongside each exchange
|
||||||
|
- **Teachable** — the operator can inject facts directly into Timmy's live memory
|
||||||
|
- **Persistent** — the session survives page refreshes; history accumulates over time
|
||||||
|
- **Local** — always backed by Ollama; no cloud inference required
|
||||||
|
|
||||||
|
This is the foundation for future LoRA fine-tuning, RL training harnesses, and
|
||||||
|
eventually real-time self-improvement loops.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scope (v1 — this PR)
|
||||||
|
|
||||||
|
| Area | Included | Deferred |
|
||||||
|
|------|----------|----------|
|
||||||
|
| Conversational UI | ✅ Chat panel with HTMX streaming | Streaming tokens |
|
||||||
|
| Live memory sidebar | ✅ Semantic search on each turn | Auto-refresh on teach |
|
||||||
|
| Teaching panel | ✅ Inject personal facts | Bulk import, LoRA trigger |
|
||||||
|
| Session isolation | ✅ Dedicated `nexus` session ID | Per-operator sessions |
|
||||||
|
| Nav integration | ✅ NEXUS link in INTEL dropdown | Mobile nav |
|
||||||
|
| CSS/styling | ✅ Two-column responsive layout | Dark/light theme toggle |
|
||||||
|
| Tests | ✅ 9 unit tests, all green | E2E with real Ollama |
|
||||||
|
| LoRA / RL harness | ❌ deferred to future issue | |
|
||||||
|
| Auto-falsework | ❌ deferred | |
|
||||||
|
| Bannerlord interface | ❌ separate track | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
### AC-1: Nexus page loads
|
||||||
|
- **Given** the dashboard is running
|
||||||
|
- **When** I navigate to `/nexus`
|
||||||
|
- **Then** I see a two-panel layout: conversation on the left, memory sidebar on the right
|
||||||
|
- **And** the page title reads "// NEXUS"
|
||||||
|
- **And** the page is accessible from the nav (INTEL → NEXUS)
|
||||||
|
|
||||||
|
### AC-2: Conversation-only chat
|
||||||
|
- **Given** I am on the Nexus page
|
||||||
|
- **When** I type a message and submit
|
||||||
|
- **Then** Timmy responds using the `nexus` session (isolated from dashboard history)
|
||||||
|
- **And** no tool-approval cards appear — responses are pure text
|
||||||
|
- **And** my message and Timmy's reply are appended to the chat log
|
||||||
|
|
||||||
|
### AC-3: Memory context surfaces automatically
|
||||||
|
- **Given** I send a message
|
||||||
|
- **When** the response arrives
|
||||||
|
- **Then** the "LIVE MEMORY CONTEXT" panel shows up to 4 semantically relevant memories
|
||||||
|
- **And** each memory entry shows its type and content
|
||||||
|
|
||||||
|
### AC-4: Teaching panel stores facts
|
||||||
|
- **Given** I type a fact into the "TEACH TIMMY" input and submit
|
||||||
|
- **When** the request completes
|
||||||
|
- **Then** I see a green confirmation "✓ Taught: <fact>"
|
||||||
|
- **And** the fact appears in the "KNOWN FACTS" list
|
||||||
|
- **And** the fact is stored in Timmy's live memory (`store_personal_fact`)
|
||||||
|
|
||||||
|
### AC-5: Empty / invalid input is rejected gracefully
|
||||||
|
- **Given** I submit a blank message or fact
|
||||||
|
- **Then** no request is made and the log is unchanged
|
||||||
|
- **Given** I submit a message over 10 000 characters
|
||||||
|
- **Then** an inline error is shown without crashing the server
|
||||||
|
|
||||||
|
### AC-6: Conversation can be cleared
|
||||||
|
- **Given** the Nexus has conversation history
|
||||||
|
- **When** I click CLEAR and confirm
|
||||||
|
- **Then** the chat log shows only a "cleared" confirmation
|
||||||
|
- **And** the Agno session for `nexus` is reset
|
||||||
|
|
||||||
|
### AC-7: Graceful degradation when Ollama is down
|
||||||
|
- **Given** Ollama is unavailable
|
||||||
|
- **When** I send a message
|
||||||
|
- **Then** an error message is shown inline (not a 500 page)
|
||||||
|
- **And** the app continues to function
|
||||||
|
|
||||||
|
### AC-8: No regression on existing tests
|
||||||
|
- **Given** the nexus route is registered
|
||||||
|
- **When** `tox -e unit` runs
|
||||||
|
- **Then** all 343+ existing tests remain green
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Future Work (separate issues)
|
||||||
|
|
||||||
|
1. **LoRA trigger** — button in the teaching panel to queue a fine-tuning run
|
||||||
|
using the current Nexus conversation as training data
|
||||||
|
2. **RL harness** — reward signal collection during conversation for RLHF
|
||||||
|
3. **Auto-falsework pipeline** — scaffold harness generation from conversation
|
||||||
|
4. **Bannerlord interface** — Nexus as the live-memory bridge for in-game Timmy
|
||||||
|
5. **Streaming responses** — token-by-token display via WebSocket
|
||||||
|
6. **Per-operator sessions** — isolate Nexus history by logged-in user
|
||||||
@@ -42,6 +42,7 @@ from dashboard.routes.hermes import router as hermes_router
|
|||||||
from dashboard.routes.loop_qa import router as loop_qa_router
|
from dashboard.routes.loop_qa import router as loop_qa_router
|
||||||
from dashboard.routes.memory import router as memory_router
|
from dashboard.routes.memory import router as memory_router
|
||||||
from dashboard.routes.mobile import router as mobile_router
|
from dashboard.routes.mobile import router as mobile_router
|
||||||
|
from dashboard.routes.nexus import router as nexus_router
|
||||||
from dashboard.routes.models import api_router as models_api_router
|
from dashboard.routes.models import api_router as models_api_router
|
||||||
from dashboard.routes.models import router as models_router
|
from dashboard.routes.models import router as models_router
|
||||||
from dashboard.routes.quests import router as quests_router
|
from dashboard.routes.quests import router as quests_router
|
||||||
@@ -652,6 +653,7 @@ app.include_router(tools_router)
|
|||||||
app.include_router(spark_router)
|
app.include_router(spark_router)
|
||||||
app.include_router(discord_router)
|
app.include_router(discord_router)
|
||||||
app.include_router(memory_router)
|
app.include_router(memory_router)
|
||||||
|
app.include_router(nexus_router)
|
||||||
app.include_router(grok_router)
|
app.include_router(grok_router)
|
||||||
app.include_router(models_router)
|
app.include_router(models_router)
|
||||||
app.include_router(models_api_router)
|
app.include_router(models_api_router)
|
||||||
|
|||||||
168
src/dashboard/routes/nexus.py
Normal file
168
src/dashboard/routes/nexus.py
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
"""Nexus — Timmy's persistent conversational awareness space.
|
||||||
|
|
||||||
|
A conversational-only interface where Timmy maintains live memory context.
|
||||||
|
No tool use; pure conversation with memory integration and a teaching panel.
|
||||||
|
|
||||||
|
Routes:
|
||||||
|
GET /nexus — render nexus page with live memory sidebar
|
||||||
|
POST /nexus/chat — send a message; returns HTMX partial
|
||||||
|
POST /nexus/teach — inject a fact into Timmy's live memory
|
||||||
|
DELETE /nexus/history — clear the nexus conversation history
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Form, Request
|
||||||
|
from fastapi.responses import HTMLResponse
|
||||||
|
|
||||||
|
from dashboard.templating import templates
|
||||||
|
from timmy.memory_system import (
|
||||||
|
get_memory_stats,
|
||||||
|
recall_personal_facts_with_ids,
|
||||||
|
search_memories,
|
||||||
|
store_personal_fact,
|
||||||
|
)
|
||||||
|
from timmy.session import _clean_response, chat, reset_session
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/nexus", tags=["nexus"])
|
||||||
|
|
||||||
|
_NEXUS_SESSION_ID = "nexus"
|
||||||
|
_MAX_MESSAGE_LENGTH = 10_000
|
||||||
|
|
||||||
|
# In-memory conversation log for the Nexus session (mirrors chat store pattern
|
||||||
|
# but is scoped to the Nexus so it won't pollute the main dashboard history).
|
||||||
|
_nexus_log: list[dict] = []
|
||||||
|
|
||||||
|
|
||||||
|
def _ts() -> str:
|
||||||
|
return datetime.now(timezone.utc).strftime("%H:%M:%S")
|
||||||
|
|
||||||
|
|
||||||
|
def _append_log(role: str, content: str) -> None:
|
||||||
|
_nexus_log.append({"role": role, "content": content, "timestamp": _ts()})
|
||||||
|
# Keep last 200 exchanges to bound memory usage
|
||||||
|
if len(_nexus_log) > 200:
|
||||||
|
del _nexus_log[:-200]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_class=HTMLResponse)
|
||||||
|
async def nexus_page(request: Request):
|
||||||
|
"""Render the Nexus page with live memory context."""
|
||||||
|
stats = get_memory_stats()
|
||||||
|
facts = recall_personal_facts_with_ids()[:8]
|
||||||
|
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request,
|
||||||
|
"nexus.html",
|
||||||
|
{
|
||||||
|
"page_title": "Nexus",
|
||||||
|
"messages": list(_nexus_log),
|
||||||
|
"stats": stats,
|
||||||
|
"facts": facts,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/chat", response_class=HTMLResponse)
|
||||||
|
async def nexus_chat(request: Request, message: str = Form(...)):
|
||||||
|
"""Conversational-only chat routed through the Nexus session.
|
||||||
|
|
||||||
|
Does not invoke tool-use approval flow — pure conversation with memory
|
||||||
|
context injected from Timmy's live memory store.
|
||||||
|
"""
|
||||||
|
message = message.strip()
|
||||||
|
if not message:
|
||||||
|
return HTMLResponse("")
|
||||||
|
if len(message) > _MAX_MESSAGE_LENGTH:
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request,
|
||||||
|
"partials/nexus_message.html",
|
||||||
|
{
|
||||||
|
"user_message": message[:80] + "…",
|
||||||
|
"response": None,
|
||||||
|
"error": "Message too long (max 10 000 chars).",
|
||||||
|
"timestamp": _ts(),
|
||||||
|
"memory_hits": [],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
ts = _ts()
|
||||||
|
|
||||||
|
# Fetch semantically relevant memories to surface in the sidebar
|
||||||
|
try:
|
||||||
|
memory_hits = await asyncio.to_thread(
|
||||||
|
search_memories, query=message, limit=4
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Nexus memory search failed: %s", exc)
|
||||||
|
memory_hits = []
|
||||||
|
|
||||||
|
# Conversational response — no tool approval flow
|
||||||
|
response_text: str | None = None
|
||||||
|
error_text: str | None = None
|
||||||
|
try:
|
||||||
|
raw = await chat(message, session_id=_NEXUS_SESSION_ID)
|
||||||
|
response_text = _clean_response(raw)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Nexus chat error: %s", exc)
|
||||||
|
error_text = "Timmy is unavailable right now. Check that Ollama is running."
|
||||||
|
|
||||||
|
_append_log("user", message)
|
||||||
|
if response_text:
|
||||||
|
_append_log("assistant", response_text)
|
||||||
|
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request,
|
||||||
|
"partials/nexus_message.html",
|
||||||
|
{
|
||||||
|
"user_message": message,
|
||||||
|
"response": response_text,
|
||||||
|
"error": error_text,
|
||||||
|
"timestamp": ts,
|
||||||
|
"memory_hits": memory_hits,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/teach", response_class=HTMLResponse)
|
||||||
|
async def nexus_teach(request: Request, fact: str = Form(...)):
|
||||||
|
"""Inject a fact into Timmy's live memory from the Nexus teaching panel."""
|
||||||
|
fact = fact.strip()
|
||||||
|
if not fact:
|
||||||
|
return HTMLResponse("")
|
||||||
|
|
||||||
|
try:
|
||||||
|
await asyncio.to_thread(store_personal_fact, fact)
|
||||||
|
facts = await asyncio.to_thread(recall_personal_facts_with_ids)
|
||||||
|
facts = facts[:8]
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Nexus teach error: %s", exc)
|
||||||
|
facts = []
|
||||||
|
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request,
|
||||||
|
"partials/nexus_facts.html",
|
||||||
|
{"facts": facts, "taught": fact},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/history", response_class=HTMLResponse)
|
||||||
|
async def nexus_clear_history(request: Request):
|
||||||
|
"""Clear the Nexus conversation history."""
|
||||||
|
_nexus_log.clear()
|
||||||
|
reset_session(session_id=_NEXUS_SESSION_ID)
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request,
|
||||||
|
"partials/nexus_message.html",
|
||||||
|
{
|
||||||
|
"user_message": None,
|
||||||
|
"response": "Nexus conversation cleared.",
|
||||||
|
"error": None,
|
||||||
|
"timestamp": _ts(),
|
||||||
|
"memory_hits": [],
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -67,6 +67,7 @@
|
|||||||
<div class="mc-nav-dropdown">
|
<div class="mc-nav-dropdown">
|
||||||
<button class="mc-test-link mc-dropdown-toggle" aria-expanded="false">INTEL ▾</button>
|
<button class="mc-test-link mc-dropdown-toggle" aria-expanded="false">INTEL ▾</button>
|
||||||
<div class="mc-dropdown-menu">
|
<div class="mc-dropdown-menu">
|
||||||
|
<a href="/nexus" class="mc-test-link">NEXUS</a>
|
||||||
<a href="/spark/ui" class="mc-test-link">SPARK</a>
|
<a href="/spark/ui" class="mc-test-link">SPARK</a>
|
||||||
<a href="/memory" class="mc-test-link">MEMORY</a>
|
<a href="/memory" class="mc-test-link">MEMORY</a>
|
||||||
<a href="/marketplace/ui" class="mc-test-link">MARKET</a>
|
<a href="/marketplace/ui" class="mc-test-link">MARKET</a>
|
||||||
|
|||||||
122
src/dashboard/templates/nexus.html
Normal file
122
src/dashboard/templates/nexus.html
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Nexus{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_styles %}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container-fluid nexus-layout py-3">
|
||||||
|
|
||||||
|
<div class="nexus-header mb-3">
|
||||||
|
<div class="nexus-title">// NEXUS</div>
|
||||||
|
<div class="nexus-subtitle">
|
||||||
|
Persistent conversational awareness — always present, always learning.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="nexus-grid">
|
||||||
|
|
||||||
|
<!-- ── LEFT: Conversation ────────────────────────────────── -->
|
||||||
|
<div class="nexus-chat-col">
|
||||||
|
<div class="card mc-panel nexus-chat-panel">
|
||||||
|
<div class="card-header mc-panel-header d-flex justify-content-between align-items-center">
|
||||||
|
<span>// CONVERSATION</span>
|
||||||
|
<button class="mc-btn mc-btn-sm"
|
||||||
|
hx-delete="/nexus/history"
|
||||||
|
hx-target="#nexus-chat-log"
|
||||||
|
hx-swap="beforeend"
|
||||||
|
hx-confirm="Clear nexus conversation?">
|
||||||
|
CLEAR
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-body p-2" id="nexus-chat-log">
|
||||||
|
{% for msg in messages %}
|
||||||
|
<div class="chat-message {{ 'user' if msg.role == 'user' else 'agent' }}">
|
||||||
|
<div class="msg-meta">
|
||||||
|
{{ 'YOU' if msg.role == 'user' else 'TIMMY' }} // {{ msg.timestamp }}
|
||||||
|
</div>
|
||||||
|
<div class="msg-body {% if msg.role == 'assistant' %}timmy-md{% endif %}">
|
||||||
|
{{ msg.content | e }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="nexus-empty-state">
|
||||||
|
Nexus is ready. Start a conversation — memories will surface in real time.
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-footer p-2">
|
||||||
|
<form hx-post="/nexus/chat"
|
||||||
|
hx-target="#nexus-chat-log"
|
||||||
|
hx-swap="beforeend"
|
||||||
|
hx-on::after-request="this.reset(); document.getElementById('nexus-chat-log').scrollTop = 999999;">
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<input type="text"
|
||||||
|
name="message"
|
||||||
|
id="nexus-input"
|
||||||
|
class="mc-search-input flex-grow-1"
|
||||||
|
placeholder="Talk to Timmy..."
|
||||||
|
autocomplete="off"
|
||||||
|
required>
|
||||||
|
<button type="submit" class="mc-btn mc-btn-primary">SEND</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── RIGHT: Memory sidebar ─────────────────────────────── -->
|
||||||
|
<div class="nexus-sidebar-col">
|
||||||
|
|
||||||
|
<!-- Live memory context (updated with each response) -->
|
||||||
|
<div class="card mc-panel nexus-memory-panel mb-3">
|
||||||
|
<div class="card-header mc-panel-header">
|
||||||
|
<span>// LIVE MEMORY</span>
|
||||||
|
<span class="badge ms-2" style="background:var(--purple-dim); color:var(--purple);">
|
||||||
|
{{ stats.total_entries }} stored
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-2">
|
||||||
|
<div id="nexus-memory-panel" class="nexus-memory-hits">
|
||||||
|
<div class="nexus-memory-label">Relevant memories appear here as you chat.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Teaching panel -->
|
||||||
|
<div class="card mc-panel nexus-teach-panel">
|
||||||
|
<div class="card-header mc-panel-header">// TEACH TIMMY</div>
|
||||||
|
<div class="card-body p-2">
|
||||||
|
<form hx-post="/nexus/teach"
|
||||||
|
hx-target="#nexus-teach-response"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
hx-on::after-request="this.reset()">
|
||||||
|
<div class="d-flex gap-2 mb-2">
|
||||||
|
<input type="text"
|
||||||
|
name="fact"
|
||||||
|
class="mc-search-input flex-grow-1"
|
||||||
|
placeholder="e.g. I prefer dark themes"
|
||||||
|
required>
|
||||||
|
<button type="submit" class="mc-btn mc-btn-primary">TEACH</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<div id="nexus-teach-response"></div>
|
||||||
|
|
||||||
|
<div class="nexus-facts-header mt-3">// KNOWN FACTS</div>
|
||||||
|
<ul class="nexus-facts-list" id="nexus-facts-list">
|
||||||
|
{% for fact in facts %}
|
||||||
|
<li class="nexus-fact-item">{{ fact.content | e }}</li>
|
||||||
|
{% else %}
|
||||||
|
<li class="nexus-fact-empty">No personal facts stored yet.</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div><!-- /sidebar -->
|
||||||
|
</div><!-- /nexus-grid -->
|
||||||
|
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
12
src/dashboard/templates/partials/nexus_facts.html
Normal file
12
src/dashboard/templates/partials/nexus_facts.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{% if taught %}
|
||||||
|
<div class="nexus-taught-confirm">
|
||||||
|
✓ Taught: <em>{{ taught | e }}</em>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<ul class="nexus-facts-list" id="nexus-facts-list" hx-swap-oob="true">
|
||||||
|
{% for fact in facts %}
|
||||||
|
<li class="nexus-fact-item">{{ fact.content | e }}</li>
|
||||||
|
{% else %}
|
||||||
|
<li class="nexus-fact-empty">No facts stored yet.</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
36
src/dashboard/templates/partials/nexus_message.html
Normal file
36
src/dashboard/templates/partials/nexus_message.html
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
{% if user_message %}
|
||||||
|
<div class="chat-message user">
|
||||||
|
<div class="msg-meta">YOU // {{ timestamp }}</div>
|
||||||
|
<div class="msg-body">{{ user_message | e }}</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if response %}
|
||||||
|
<div class="chat-message agent">
|
||||||
|
<div class="msg-meta">TIMMY // {{ timestamp }}</div>
|
||||||
|
<div class="msg-body timmy-md">{{ response | e }}</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
var el = document.currentScript.previousElementSibling.querySelector('.timmy-md');
|
||||||
|
if (el && typeof marked !== 'undefined' && typeof DOMPurify !== 'undefined') {
|
||||||
|
el.innerHTML = DOMPurify.sanitize(marked.parse(el.textContent));
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% elif error %}
|
||||||
|
<div class="chat-message error-msg">
|
||||||
|
<div class="msg-meta">SYSTEM // {{ timestamp }}</div>
|
||||||
|
<div class="msg-body">{{ error | e }}</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if memory_hits %}
|
||||||
|
<div class="nexus-memory-hits" id="nexus-memory-panel" hx-swap-oob="true">
|
||||||
|
<div class="nexus-memory-label">// LIVE MEMORY CONTEXT</div>
|
||||||
|
{% for hit in memory_hits %}
|
||||||
|
<div class="nexus-memory-hit">
|
||||||
|
<span class="nexus-memory-type">{{ hit.memory_type }}</span>
|
||||||
|
<span class="nexus-memory-content">{{ hit.content | e }}</span>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
@@ -2664,3 +2664,53 @@
|
|||||||
color: var(--bg-deep);
|
color: var(--bg-deep);
|
||||||
}
|
}
|
||||||
.vs-btn-save:hover { opacity: 0.85; }
|
.vs-btn-save:hover { opacity: 0.85; }
|
||||||
|
|
||||||
|
/* ── Nexus ────────────────────────────────────────────────── */
|
||||||
|
.nexus-layout { max-width: 1400px; margin: 0 auto; }
|
||||||
|
|
||||||
|
.nexus-header { border-bottom: 1px solid var(--border); padding-bottom: 0.5rem; }
|
||||||
|
.nexus-title { font-size: 1.4rem; font-weight: 700; color: var(--purple); letter-spacing: 0.1em; }
|
||||||
|
.nexus-subtitle { font-size: 0.8rem; color: var(--text-dim); margin-top: 0.2rem; }
|
||||||
|
|
||||||
|
.nexus-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 320px;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.nexus-grid { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.nexus-chat-panel { height: calc(100vh - 180px); display: flex; flex-direction: column; }
|
||||||
|
.nexus-chat-panel .card-body { overflow-y: auto; flex: 1; }
|
||||||
|
|
||||||
|
.nexus-empty-state {
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-style: italic;
|
||||||
|
padding: 1rem 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Memory sidebar */
|
||||||
|
.nexus-memory-hits { font-size: 0.78rem; }
|
||||||
|
.nexus-memory-label { color: var(--text-dim); font-size: 0.72rem; margin-bottom: 0.4rem; letter-spacing: 0.05em; }
|
||||||
|
.nexus-memory-hit { display: flex; gap: 0.4rem; margin-bottom: 0.35rem; align-items: flex-start; }
|
||||||
|
.nexus-memory-type { color: var(--purple); font-size: 0.68rem; white-space: nowrap; padding-top: 0.1rem; min-width: 60px; }
|
||||||
|
.nexus-memory-content { color: var(--text); line-height: 1.4; }
|
||||||
|
|
||||||
|
/* Teaching panel */
|
||||||
|
.nexus-facts-header { font-size: 0.7rem; color: var(--text-dim); letter-spacing: 0.08em; margin-bottom: 0.4rem; }
|
||||||
|
.nexus-facts-list { list-style: none; padding: 0; margin: 0; font-size: 0.8rem; }
|
||||||
|
.nexus-fact-item { color: var(--text); border-bottom: 1px solid var(--border); padding: 0.3rem 0; }
|
||||||
|
.nexus-fact-empty { color: var(--text-dim); font-style: italic; }
|
||||||
|
.nexus-taught-confirm {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--green);
|
||||||
|
background: rgba(0,255,136,0.06);
|
||||||
|
border: 1px solid var(--green);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0.3rem 0.6rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|||||||
72
tests/dashboard/test_nexus.py
Normal file
72
tests/dashboard/test_nexus.py
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
"""Tests for the Nexus conversational awareness routes."""
|
||||||
|
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
|
||||||
|
def test_nexus_page_returns_200(client):
|
||||||
|
"""GET /nexus should render without error."""
|
||||||
|
response = client.get("/nexus")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "NEXUS" in response.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_nexus_page_contains_chat_form(client):
|
||||||
|
"""Nexus page must include the conversational chat form."""
|
||||||
|
response = client.get("/nexus")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "/nexus/chat" in response.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_nexus_page_contains_teach_form(client):
|
||||||
|
"""Nexus page must include the teaching panel form."""
|
||||||
|
response = client.get("/nexus")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "/nexus/teach" in response.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_nexus_chat_empty_message_returns_empty(client):
|
||||||
|
"""POST /nexus/chat with blank message returns empty response."""
|
||||||
|
response = client.post("/nexus/chat", data={"message": " "})
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.text == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_nexus_chat_too_long_returns_error(client):
|
||||||
|
"""POST /nexus/chat with overlong message returns error partial."""
|
||||||
|
long_msg = "x" * 10_001
|
||||||
|
response = client.post("/nexus/chat", data={"message": long_msg})
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "too long" in response.text.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_nexus_chat_posts_message(client):
|
||||||
|
"""POST /nexus/chat calls the session chat function and returns a partial."""
|
||||||
|
with patch("dashboard.routes.nexus.chat", return_value="Hello from Timmy"):
|
||||||
|
response = client.post("/nexus/chat", data={"message": "hello"})
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "hello" in response.text.lower() or "timmy" in response.text.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_nexus_teach_stores_fact(client):
|
||||||
|
"""POST /nexus/teach should persist a fact and return confirmation."""
|
||||||
|
with patch("dashboard.routes.nexus.store_personal_fact") as mock_store, \
|
||||||
|
patch("dashboard.routes.nexus.recall_personal_facts_with_ids", return_value=[]):
|
||||||
|
mock_store.return_value = None
|
||||||
|
response = client.post("/nexus/teach", data={"fact": "Timmy loves Python"})
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "Timmy loves Python" in response.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_nexus_teach_empty_fact_returns_empty(client):
|
||||||
|
"""POST /nexus/teach with blank fact returns empty response."""
|
||||||
|
response = client.post("/nexus/teach", data={"fact": " "})
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.text == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_nexus_clear_history(client):
|
||||||
|
"""DELETE /nexus/history should clear the conversation log."""
|
||||||
|
with patch("dashboard.routes.nexus.reset_session"):
|
||||||
|
response = client.request("DELETE", "/nexus/history")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "cleared" in response.text.lower()
|
||||||
Reference in New Issue
Block a user