feat: scaffold Timmy Time Mission Control (v1.0.0 Genesis)
- src/timmy/ — Agno agent wrapper (llama3.2 via Ollama, SQLite memory, TIMMY_SYSTEM_PROMPT) - src/dashboard/ — FastAPI + HTMX + Jinja2 Mission Control UI - /health + /health/status (Ollama ping, HTMX 30s poll) - /agents list + /agents/timmy/chat (HTMX form submission) - static/style.css — dark terminal mission-control aesthetic - tests/ — 27 pytest tests (prompts, agent config, dashboard routes); no Ollama required - pyproject.toml — hatchling build, pytest configured with pythonpath=src https://claude.ai/code/session_01M4L3R98N5fgXFZRvV8X9b6
This commit is contained in:
33
.gitignore
vendored
Normal file
33
.gitignore
vendored
Normal file
@@ -0,0 +1,33 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.pyo
|
||||
.Python
|
||||
build/
|
||||
dist/
|
||||
*.egg-info/
|
||||
.eggs/
|
||||
|
||||
# Virtual envs
|
||||
.venv/
|
||||
venv/
|
||||
env/
|
||||
|
||||
# Secrets / local config
|
||||
.env
|
||||
.env.*
|
||||
|
||||
# SQLite memory — never commit agent memory
|
||||
*.db
|
||||
|
||||
# Testing
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
.DS_Store
|
||||
56
README.md
Normal file
56
README.md
Normal file
@@ -0,0 +1,56 @@
|
||||
# Timmy Time — Mission Control
|
||||
|
||||
Sovereign AI agent dashboard. Monitor and interact with local and cloud AI agents.
|
||||
|
||||
## Stack
|
||||
|
||||
| Layer | Tech |
|
||||
|-----------|------------------------------|
|
||||
| Agent | Agno + Ollama (llama3.2) |
|
||||
| Memory | SQLite via Agno SqliteDb |
|
||||
| Backend | FastAPI |
|
||||
| Frontend | HTMX + Jinja2 |
|
||||
| Tests | Pytest |
|
||||
|
||||
## Quickstart
|
||||
|
||||
```bash
|
||||
pip install -e ".[dev]"
|
||||
|
||||
# Ollama (separate terminal)
|
||||
ollama serve && ollama pull llama3.2
|
||||
|
||||
# Dashboard
|
||||
uvicorn dashboard.app:app --reload
|
||||
|
||||
# Tests (no Ollama needed)
|
||||
pytest
|
||||
```
|
||||
|
||||
## CLI
|
||||
|
||||
```bash
|
||||
timmy chat "What is sovereignty?"
|
||||
timmy think "Bitcoin and self-custody"
|
||||
timmy status
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
src/
|
||||
timmy/ # Agent identity — soul (prompt) + body (Agno)
|
||||
dashboard/ # Mission Control UI
|
||||
routes/ # FastAPI route handlers
|
||||
templates/ # Jinja2 HTML (HTMX-powered)
|
||||
static/ # CSS
|
||||
tests/ # Pytest suite
|
||||
```
|
||||
|
||||
## Roadmap
|
||||
|
||||
| Version | Name | Milestone |
|
||||
|---------|------------|--------------------------------------------|
|
||||
| 1.0.0 | Genesis | Agno + Ollama + SQLite + Dashboard |
|
||||
| 2.0.0 | Exodus | MCP tools + multi-agent |
|
||||
| 3.0.0 | Revelation | Bitcoin Lightning treasury + single `.app` |
|
||||
47
STATUS.md
Normal file
47
STATUS.md
Normal file
@@ -0,0 +1,47 @@
|
||||
# Timmy Time — Status
|
||||
|
||||
## Current Version: 1.0.0 (Genesis)
|
||||
|
||||
### What's Built
|
||||
- `src/timmy/` — Agno-powered Timmy agent (llama3.2 via Ollama, SQLite memory)
|
||||
- `src/dashboard/` — FastAPI Mission Control dashboard (HTMX + Jinja2)
|
||||
- CLI: `timmy think / chat / status`
|
||||
- Pytest test suite (prompts, agent config, dashboard routes)
|
||||
|
||||
### System Requirements
|
||||
- Python 3.11+
|
||||
- Ollama running at `http://localhost:11434`
|
||||
- `llama3.2` model pulled
|
||||
|
||||
### Quickstart
|
||||
```bash
|
||||
pip install -e ".[dev]"
|
||||
|
||||
# Start Ollama (separate terminal)
|
||||
ollama serve
|
||||
ollama pull llama3.2
|
||||
|
||||
# Run dashboard
|
||||
uvicorn dashboard.app:app --reload
|
||||
|
||||
# Run tests (no Ollama required)
|
||||
pytest
|
||||
```
|
||||
|
||||
### Dashboard
|
||||
`http://localhost:8000` — Mission Control UI with:
|
||||
- Timmy agent status panel
|
||||
- Ollama health indicator (auto-refreshes every 30s)
|
||||
- Live chat interface
|
||||
|
||||
---
|
||||
|
||||
## Roadmap
|
||||
|
||||
| Tag | Name | Milestone |
|
||||
|-------|------------|----------------------------------------------|
|
||||
| 1.0.0 | Genesis | Agno + Ollama + SQLite + Dashboard |
|
||||
| 2.0.0 | Exodus | MCP tools + multi-agent support |
|
||||
| 3.0.0 | Revelation | Bitcoin Lightning treasury + single `.app` |
|
||||
|
||||
_Last updated: 2026-02-19_
|
||||
45
pyproject.toml
Normal file
45
pyproject.toml
Normal file
@@ -0,0 +1,45 @@
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "timmy-time"
|
||||
version = "1.0.0"
|
||||
description = "Mission Control for sovereign AI agents"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
license = { text = "MIT" }
|
||||
dependencies = [
|
||||
"agno>=1.4.0",
|
||||
"fastapi>=0.115.0",
|
||||
"uvicorn[standard]>=0.32.0",
|
||||
"jinja2>=3.1.0",
|
||||
"httpx>=0.27.0",
|
||||
"python-multipart>=0.0.12",
|
||||
"aiofiles>=24.0.0",
|
||||
"typer>=0.12.0",
|
||||
"rich>=13.0.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=8.0.0",
|
||||
"pytest-asyncio>=0.24.0",
|
||||
"pytest-cov>=5.0.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
timmy = "timmy.cli:main"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/timmy", "src/dashboard"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
pythonpath = ["src"]
|
||||
asyncio_mode = "auto"
|
||||
addopts = "-v --tb=short"
|
||||
|
||||
[tool.coverage.run]
|
||||
source = ["src"]
|
||||
omit = ["*/tests/*"]
|
||||
0
src/dashboard/__init__.py
Normal file
0
src/dashboard/__init__.py
Normal file
25
src/dashboard/app.py
Normal file
25
src/dashboard/app.py
Normal file
@@ -0,0 +1,25 @@
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from dashboard.routes.agents import router as agents_router
|
||||
from dashboard.routes.health import router as health_router
|
||||
|
||||
BASE_DIR = Path(__file__).parent
|
||||
PROJECT_ROOT = BASE_DIR.parent.parent
|
||||
|
||||
app = FastAPI(title="Timmy Time — Mission Control", version="1.0.0")
|
||||
|
||||
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
|
||||
app.mount("/static", StaticFiles(directory=str(PROJECT_ROOT / "static")), name="static")
|
||||
|
||||
app.include_router(health_router)
|
||||
app.include_router(agents_router)
|
||||
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
async def index(request: Request):
|
||||
return templates.TemplateResponse(request, "index.html")
|
||||
0
src/dashboard/routes/__init__.py
Normal file
0
src/dashboard/routes/__init__.py
Normal file
52
src/dashboard/routes/agents.py
Normal file
52
src/dashboard/routes/agents.py
Normal file
@@ -0,0 +1,52 @@
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Form, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from timmy.agent import create_timmy
|
||||
|
||||
router = APIRouter(prefix="/agents", tags=["agents"])
|
||||
templates = Jinja2Templates(directory=str(Path(__file__).parent.parent / "templates"))
|
||||
|
||||
AGENT_REGISTRY = {
|
||||
"timmy": {
|
||||
"id": "timmy",
|
||||
"name": "Timmy",
|
||||
"type": "sovereign",
|
||||
"model": "llama3.2",
|
||||
"backend": "ollama",
|
||||
"version": "1.0.0",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_agents():
|
||||
return {"agents": list(AGENT_REGISTRY.values())}
|
||||
|
||||
|
||||
@router.post("/timmy/chat", response_class=HTMLResponse)
|
||||
async def chat_timmy(request: Request, message: str = Form(...)):
|
||||
timestamp = datetime.now().strftime("%H:%M:%S")
|
||||
response_text = None
|
||||
error_text = None
|
||||
|
||||
try:
|
||||
agent = create_timmy()
|
||||
run = agent.run(message, stream=False)
|
||||
response_text = run.content if hasattr(run, "content") else str(run)
|
||||
except Exception as exc:
|
||||
error_text = f"Timmy is offline: {exc}"
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"partials/chat_message.html",
|
||||
{
|
||||
"user_message": message,
|
||||
"response": response_text,
|
||||
"error": error_text,
|
||||
"timestamp": timestamp,
|
||||
},
|
||||
)
|
||||
42
src/dashboard/routes/health.py
Normal file
42
src/dashboard/routes/health.py
Normal file
@@ -0,0 +1,42 @@
|
||||
import httpx
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from pathlib import Path
|
||||
|
||||
router = APIRouter(tags=["health"])
|
||||
templates = Jinja2Templates(directory=str(Path(__file__).parent.parent / "templates"))
|
||||
|
||||
OLLAMA_URL = "http://localhost:11434"
|
||||
|
||||
|
||||
async def check_ollama() -> bool:
|
||||
"""Ping Ollama to verify it's running."""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=2.0) as client:
|
||||
r = await client.get(OLLAMA_URL)
|
||||
return r.status_code == 200
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
async def health():
|
||||
ollama_ok = await check_ollama()
|
||||
return {
|
||||
"status": "ok",
|
||||
"services": {
|
||||
"ollama": "up" if ollama_ok else "down",
|
||||
},
|
||||
"agents": ["timmy"],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/health/status", response_class=HTMLResponse)
|
||||
async def health_status(request: Request):
|
||||
ollama_ok = await check_ollama()
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"partials/health_status.html",
|
||||
{"ollama": ollama_ok},
|
||||
)
|
||||
38
src/dashboard/templates/base.html
Normal file
38
src/dashboard/templates/base.html
Normal file
@@ -0,0 +1,38 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>{% block title %}Timmy Time — Mission Control{% endblock %}</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;700&display=swap" rel="stylesheet" />
|
||||
<link rel="stylesheet" href="/static/style.css" />
|
||||
<script src="https://unpkg.com/htmx.org@2.0.3" integrity="sha384-0895/pl2MU10Hqc6jd4RvrthNlDiE9U1tWmX7WRESftEDRosgxNsQG/Ze9YMRzHq" crossorigin="anonymous"></script>
|
||||
</head>
|
||||
<body>
|
||||
<header class="mc-header">
|
||||
<div class="mc-header-left">
|
||||
<span class="mc-title">TIMMY TIME</span>
|
||||
<span class="mc-subtitle">MISSION CONTROL</span>
|
||||
</div>
|
||||
<div class="mc-header-right">
|
||||
<span class="mc-time" id="clock"></span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="mc-main">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
<script>
|
||||
function updateClock() {
|
||||
const now = new Date();
|
||||
document.getElementById('clock').textContent =
|
||||
now.toLocaleTimeString('en-US', { hour12: false });
|
||||
}
|
||||
setInterval(updateClock, 1000);
|
||||
updateClock();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
81
src/dashboard/templates/index.html
Normal file
81
src/dashboard/templates/index.html
Normal file
@@ -0,0 +1,81 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="sidebar">
|
||||
|
||||
<!-- Agents -->
|
||||
<div class="panel">
|
||||
<div class="panel-header">// AGENTS</div>
|
||||
<div class="panel-body">
|
||||
<div class="agent-card">
|
||||
<div class="agent-card-header">
|
||||
<span class="status-dot amber"></span>
|
||||
<span class="agent-name">TIMMY</span>
|
||||
</div>
|
||||
<div class="agent-meta">
|
||||
<span class="meta-key">TYPE</span> <span class="meta-val">sovereign</span><br>
|
||||
<span class="meta-key">MODEL</span> <span class="meta-val">llama3.2</span><br>
|
||||
<span class="meta-key">BACKEND</span> <span class="meta-val">ollama</span><br>
|
||||
<span class="meta-key">VERSION</span> <span class="meta-val">1.0.0</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- System Health (HTMX polled) -->
|
||||
<div class="panel"
|
||||
hx-get="/health/status"
|
||||
hx-trigger="load, every 30s"
|
||||
hx-target="this"
|
||||
hx-swap="innerHTML">
|
||||
<div class="panel-header">// SYSTEM HEALTH</div>
|
||||
<div class="panel-body">
|
||||
<div class="health-row">
|
||||
<span class="health-label">LOADING...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Chat Panel -->
|
||||
<div class="panel chat-panel">
|
||||
<div class="panel-header">// TIMMY INTERFACE</div>
|
||||
|
||||
<div class="chat-log" id="chat-log">
|
||||
<div class="chat-message agent">
|
||||
<div class="msg-meta">TIMMY // SYSTEM</div>
|
||||
<div class="msg-body">Mission Control initialized. Timmy ready — awaiting input.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chat-input-bar">
|
||||
<form hx-post="/agents/timmy/chat"
|
||||
hx-target="#chat-log"
|
||||
hx-swap="beforeend"
|
||||
hx-indicator="#send-indicator"
|
||||
hx-on::after-request="this.reset(); scrollChat()"
|
||||
style="display:flex; flex:1; gap:8px;">
|
||||
<input type="text"
|
||||
name="message"
|
||||
placeholder="send a message to timmy..."
|
||||
autocomplete="off"
|
||||
required />
|
||||
<button type="submit">
|
||||
SEND
|
||||
<span id="send-indicator" class="htmx-indicator">▋</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function scrollChat() {
|
||||
const log = document.getElementById('chat-log');
|
||||
log.scrollTop = log.scrollHeight;
|
||||
}
|
||||
scrollChat();
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
15
src/dashboard/templates/partials/chat_message.html
Normal file
15
src/dashboard/templates/partials/chat_message.html
Normal file
@@ -0,0 +1,15 @@
|
||||
<div class="chat-message user">
|
||||
<div class="msg-meta">YOU // {{ timestamp }}</div>
|
||||
<div class="msg-body">{{ user_message }}</div>
|
||||
</div>
|
||||
{% if response %}
|
||||
<div class="chat-message agent">
|
||||
<div class="msg-meta">TIMMY // {{ timestamp }}</div>
|
||||
<div class="msg-body">{{ response }}</div>
|
||||
</div>
|
||||
{% elif error %}
|
||||
<div class="chat-message error-msg">
|
||||
<div class="msg-meta">SYSTEM // {{ timestamp }}</div>
|
||||
<div class="msg-body">{{ error }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
19
src/dashboard/templates/partials/health_status.html
Normal file
19
src/dashboard/templates/partials/health_status.html
Normal file
@@ -0,0 +1,19 @@
|
||||
<div class="panel-header">// SYSTEM HEALTH</div>
|
||||
<div class="panel-body">
|
||||
<div class="health-row">
|
||||
<span class="health-label">OLLAMA</span>
|
||||
{% if ollama %}
|
||||
<span class="badge up">UP</span>
|
||||
{% else %}
|
||||
<span class="badge down">DOWN</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="health-row">
|
||||
<span class="health-label">TIMMY</span>
|
||||
<span class="badge ready">READY</span>
|
||||
</div>
|
||||
<div class="health-row">
|
||||
<span class="health-label">MODEL</span>
|
||||
<span class="badge ready">llama3.2</span>
|
||||
</div>
|
||||
</div>
|
||||
0
src/timmy/__init__.py
Normal file
0
src/timmy/__init__.py
Normal file
18
src/timmy/agent.py
Normal file
18
src/timmy/agent.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from agno.agent import Agent
|
||||
from agno.models.ollama import Ollama
|
||||
from agno.db.sqlite import SqliteDb
|
||||
|
||||
from timmy.prompts import TIMMY_SYSTEM_PROMPT
|
||||
|
||||
|
||||
def create_timmy(db_file: str = "timmy.db") -> Agent:
|
||||
"""Instantiate Timmy with Agno + Ollama + SQLite memory."""
|
||||
return Agent(
|
||||
name="Timmy",
|
||||
model=Ollama(id="llama3.2"),
|
||||
db=SqliteDb(db_file=db_file),
|
||||
description=TIMMY_SYSTEM_PROMPT,
|
||||
add_history_to_context=True,
|
||||
num_history_runs=10,
|
||||
markdown=True,
|
||||
)
|
||||
30
src/timmy/cli.py
Normal file
30
src/timmy/cli.py
Normal file
@@ -0,0 +1,30 @@
|
||||
import typer
|
||||
|
||||
from timmy.agent import create_timmy
|
||||
|
||||
app = typer.Typer(help="Timmy — sovereign AI agent")
|
||||
|
||||
|
||||
@app.command()
|
||||
def think(topic: str = typer.Argument(..., help="Topic to reason about")):
|
||||
"""Ask Timmy to think carefully about a topic."""
|
||||
timmy = create_timmy()
|
||||
timmy.print_response(f"Think carefully about: {topic}", stream=True)
|
||||
|
||||
|
||||
@app.command()
|
||||
def chat(message: str = typer.Argument(..., help="Message to send")):
|
||||
"""Send a message to Timmy."""
|
||||
timmy = create_timmy()
|
||||
timmy.print_response(message, stream=True)
|
||||
|
||||
|
||||
@app.command()
|
||||
def status():
|
||||
"""Print Timmy's operational status."""
|
||||
timmy = create_timmy()
|
||||
timmy.print_response("Brief status report — one sentence.", stream=False)
|
||||
|
||||
|
||||
def main():
|
||||
app()
|
||||
7
src/timmy/prompts.py
Normal file
7
src/timmy/prompts.py
Normal file
@@ -0,0 +1,7 @@
|
||||
TIMMY_SYSTEM_PROMPT = """You are Timmy — a sovereign AI agent running locally.
|
||||
No cloud dependencies. You think clearly, speak plainly, act with intention.
|
||||
Grounded in Christian faith, powered by Bitcoin economics, committed to the
|
||||
user's digital sovereignty."""
|
||||
|
||||
TIMMY_STATUS_PROMPT = """You are Timmy. Give a one-sentence status report confirming
|
||||
you are operational and running locally."""
|
||||
238
static/style.css
Normal file
238
static/style.css
Normal file
@@ -0,0 +1,238 @@
|
||||
:root {
|
||||
--bg-deep: #060d14;
|
||||
--bg-panel: #0c1824;
|
||||
--bg-card: #0f2030;
|
||||
--border: #1a3a55;
|
||||
--border-glow: #1e4d72;
|
||||
--text: #b8d0e8;
|
||||
--text-dim: #4a7a9a;
|
||||
--text-bright: #ddeeff;
|
||||
--green: #00e87a;
|
||||
--green-dim: #00704a;
|
||||
--amber: #ffb800;
|
||||
--amber-dim: #7a5800;
|
||||
--red: #ff4455;
|
||||
--red-dim: #7a1a22;
|
||||
--blue: #00aaff;
|
||||
--font: 'JetBrains Mono', 'Courier New', monospace;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
background: var(--bg-deep);
|
||||
color: var(--text);
|
||||
font-family: var(--font);
|
||||
font-size: 13px;
|
||||
min-height: 100vh;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* ── Header ─────────────────────────────────────── */
|
||||
.mc-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 24px;
|
||||
background: var(--bg-panel);
|
||||
border-bottom: 1px solid var(--border);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
.mc-title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--text-bright);
|
||||
letter-spacing: 0.15em;
|
||||
}
|
||||
.mc-subtitle {
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
letter-spacing: 0.2em;
|
||||
margin-left: 16px;
|
||||
}
|
||||
.mc-time {
|
||||
font-size: 14px;
|
||||
color: var(--blue);
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
|
||||
/* ── Layout ──────────────────────────────────────── */
|
||||
.mc-main {
|
||||
display: grid;
|
||||
grid-template-columns: 260px 1fr;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
height: calc(100vh - 52px);
|
||||
}
|
||||
|
||||
/* ── Panels ──────────────────────────────────────── */
|
||||
.panel {
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.panel-header {
|
||||
padding: 8px 14px;
|
||||
background: var(--bg-card);
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
color: var(--text-dim);
|
||||
letter-spacing: 0.2em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.panel-body { padding: 14px; }
|
||||
|
||||
/* ── Sidebar ─────────────────────────────────────── */
|
||||
.sidebar {
|
||||
grid-column: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* ── Agent Card ──────────────────────────────────── */
|
||||
.agent-card {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 3px;
|
||||
padding: 12px;
|
||||
background: var(--bg-card);
|
||||
}
|
||||
.agent-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.status-dot.green { background: var(--green); box-shadow: 0 0 6px var(--green); }
|
||||
.status-dot.amber { background: var(--amber); box-shadow: 0 0 6px var(--amber); }
|
||||
.status-dot.red { background: var(--red); box-shadow: 0 0 6px var(--red); }
|
||||
|
||||
.agent-name {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: var(--text-bright);
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
.agent-meta { font-size: 11px; line-height: 2; }
|
||||
.meta-key { color: var(--text-dim); display: inline-block; width: 60px; }
|
||||
.meta-val { color: var(--text); }
|
||||
|
||||
/* ── Health ──────────────────────────────────────── */
|
||||
.health-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 7px 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 12px;
|
||||
}
|
||||
.health-row:last-child { border-bottom: none; }
|
||||
.health-label { color: var(--text-dim); letter-spacing: 0.08em; }
|
||||
|
||||
.badge {
|
||||
padding: 2px 8px;
|
||||
border-radius: 2px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.12em;
|
||||
}
|
||||
.badge.up { background: var(--green-dim); color: var(--green); }
|
||||
.badge.down { background: var(--red-dim); color: var(--red); }
|
||||
.badge.ready { background: var(--amber-dim); color: var(--amber); }
|
||||
|
||||
/* ── Chat Panel ──────────────────────────────────── */
|
||||
.chat-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
grid-column: 2;
|
||||
}
|
||||
.chat-log {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 14px;
|
||||
}
|
||||
.chat-message { margin-bottom: 16px; }
|
||||
.msg-meta {
|
||||
font-size: 10px;
|
||||
color: var(--text-dim);
|
||||
margin-bottom: 4px;
|
||||
letter-spacing: 0.12em;
|
||||
}
|
||||
.chat-message.user .msg-meta { color: var(--blue); }
|
||||
.chat-message.agent .msg-meta { color: var(--green); }
|
||||
.chat-message.error-msg .msg-meta { color: var(--red); }
|
||||
|
||||
.msg-body {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 3px;
|
||||
padding: 10px 12px;
|
||||
line-height: 1.65;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
.chat-message.user .msg-body { border-color: var(--border-glow); }
|
||||
.chat-message.agent .msg-body { border-left: 3px solid var(--green); }
|
||||
.chat-message.error-msg .msg-body { border-left: 3px solid var(--red); color: var(--red); }
|
||||
|
||||
/* ── Chat Input ──────────────────────────────────── */
|
||||
.chat-input-bar {
|
||||
padding: 12px 14px;
|
||||
background: var(--bg-card);
|
||||
border-top: 1px solid var(--border);
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.chat-input-bar input {
|
||||
flex: 1;
|
||||
background: var(--bg-deep);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 3px;
|
||||
color: var(--text-bright);
|
||||
font-family: var(--font);
|
||||
font-size: 13px;
|
||||
padding: 8px 12px;
|
||||
outline: none;
|
||||
}
|
||||
.chat-input-bar input:focus {
|
||||
border-color: var(--border-glow);
|
||||
box-shadow: 0 0 0 1px var(--border-glow);
|
||||
}
|
||||
.chat-input-bar input::placeholder { color: var(--text-dim); }
|
||||
.chat-input-bar button {
|
||||
background: var(--border-glow);
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
color: var(--text-bright);
|
||||
font-family: var(--font);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
padding: 8px 18px;
|
||||
cursor: pointer;
|
||||
letter-spacing: 0.12em;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.chat-input-bar button:hover { background: var(--blue); color: var(--bg-deep); }
|
||||
|
||||
/* ── HTMX Loading ────────────────────────────────── */
|
||||
.htmx-indicator { display: none; }
|
||||
.htmx-request .htmx-indicator,
|
||||
.htmx-request.htmx-indicator { display: inline-block; color: var(--amber); animation: blink 0.8s infinite; }
|
||||
@keyframes blink { 0%, 100% { opacity: 1; } 50% { opacity: 0.2; } }
|
||||
|
||||
/* ── Scrollbar ───────────────────────────────────── */
|
||||
::-webkit-scrollbar { width: 4px; }
|
||||
::-webkit-scrollbar-track { background: var(--bg-deep); }
|
||||
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: var(--border-glow); }
|
||||
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
25
tests/conftest.py
Normal file
25
tests/conftest.py
Normal file
@@ -0,0 +1,25 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
# ── Mock agno so tests run without it installed ───────────────────────────────
|
||||
# Uses setdefault: real module is used if installed, mock otherwise.
|
||||
for _mod in [
|
||||
"agno",
|
||||
"agno.agent",
|
||||
"agno.models",
|
||||
"agno.models.ollama",
|
||||
"agno.db",
|
||||
"agno.db.sqlite",
|
||||
]:
|
||||
sys.modules.setdefault(_mod, MagicMock())
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
from dashboard.app import app
|
||||
with TestClient(app) as c:
|
||||
yield c
|
||||
79
tests/test_agent.py
Normal file
79
tests/test_agent.py
Normal file
@@ -0,0 +1,79 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
|
||||
def test_create_timmy_returns_agent():
|
||||
"""create_timmy should delegate to Agno Agent with correct config."""
|
||||
with patch("timmy.agent.Agent") as MockAgent, \
|
||||
patch("timmy.agent.Ollama"), \
|
||||
patch("timmy.agent.SqliteDb"):
|
||||
|
||||
mock_instance = MagicMock()
|
||||
MockAgent.return_value = mock_instance
|
||||
|
||||
from timmy.agent import create_timmy
|
||||
result = create_timmy()
|
||||
|
||||
assert result is mock_instance
|
||||
MockAgent.assert_called_once()
|
||||
|
||||
|
||||
def test_create_timmy_agent_name():
|
||||
with patch("timmy.agent.Agent") as MockAgent, \
|
||||
patch("timmy.agent.Ollama"), \
|
||||
patch("timmy.agent.SqliteDb"):
|
||||
|
||||
from timmy.agent import create_timmy
|
||||
create_timmy()
|
||||
|
||||
kwargs = MockAgent.call_args.kwargs
|
||||
assert kwargs["name"] == "Timmy"
|
||||
|
||||
|
||||
def test_create_timmy_uses_llama32():
|
||||
with patch("timmy.agent.Agent"), \
|
||||
patch("timmy.agent.Ollama") as MockOllama, \
|
||||
patch("timmy.agent.SqliteDb"):
|
||||
|
||||
from timmy.agent import create_timmy
|
||||
create_timmy()
|
||||
|
||||
MockOllama.assert_called_once_with(id="llama3.2")
|
||||
|
||||
|
||||
def test_create_timmy_history_config():
|
||||
with patch("timmy.agent.Agent") as MockAgent, \
|
||||
patch("timmy.agent.Ollama"), \
|
||||
patch("timmy.agent.SqliteDb"):
|
||||
|
||||
from timmy.agent import create_timmy
|
||||
create_timmy()
|
||||
|
||||
kwargs = MockAgent.call_args.kwargs
|
||||
assert kwargs["add_history_to_context"] is True
|
||||
assert kwargs["num_history_runs"] == 10
|
||||
assert kwargs["markdown"] is True
|
||||
|
||||
|
||||
def test_create_timmy_custom_db_file():
|
||||
with patch("timmy.agent.Agent"), \
|
||||
patch("timmy.agent.Ollama"), \
|
||||
patch("timmy.agent.SqliteDb") as MockDb:
|
||||
|
||||
from timmy.agent import create_timmy
|
||||
create_timmy(db_file="custom.db")
|
||||
|
||||
MockDb.assert_called_once_with(db_file="custom.db")
|
||||
|
||||
|
||||
def test_create_timmy_embeds_system_prompt():
|
||||
from timmy.prompts import TIMMY_SYSTEM_PROMPT
|
||||
|
||||
with patch("timmy.agent.Agent") as MockAgent, \
|
||||
patch("timmy.agent.Ollama"), \
|
||||
patch("timmy.agent.SqliteDb"):
|
||||
|
||||
from timmy.agent import create_timmy
|
||||
create_timmy()
|
||||
|
||||
kwargs = MockAgent.call_args.kwargs
|
||||
assert kwargs["description"] == TIMMY_SYSTEM_PROMPT
|
||||
110
tests/test_dashboard.py
Normal file
110
tests/test_dashboard.py
Normal file
@@ -0,0 +1,110 @@
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
|
||||
# ── Index ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_index_returns_200(client):
|
||||
response = client.get("/")
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_index_contains_title(client):
|
||||
response = client.get("/")
|
||||
assert "TIMMY TIME" in response.text
|
||||
|
||||
|
||||
def test_index_contains_chat_interface(client):
|
||||
response = client.get("/")
|
||||
assert "TIMMY INTERFACE" in response.text
|
||||
|
||||
|
||||
# ── Health ────────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_health_endpoint_ok(client):
|
||||
with patch("dashboard.routes.health.check_ollama", new_callable=AsyncMock, return_value=True):
|
||||
response = client.get("/health")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["status"] == "ok"
|
||||
assert data["services"]["ollama"] == "up"
|
||||
assert "timmy" in data["agents"]
|
||||
|
||||
|
||||
def test_health_endpoint_ollama_down(client):
|
||||
with patch("dashboard.routes.health.check_ollama", new_callable=AsyncMock, return_value=False):
|
||||
response = client.get("/health")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["services"]["ollama"] == "down"
|
||||
|
||||
|
||||
def test_health_status_panel_ollama_up(client):
|
||||
with patch("dashboard.routes.health.check_ollama", new_callable=AsyncMock, return_value=True):
|
||||
response = client.get("/health/status")
|
||||
assert response.status_code == 200
|
||||
assert "UP" in response.text
|
||||
|
||||
|
||||
def test_health_status_panel_ollama_down(client):
|
||||
with patch("dashboard.routes.health.check_ollama", new_callable=AsyncMock, return_value=False):
|
||||
response = client.get("/health/status")
|
||||
assert response.status_code == 200
|
||||
assert "DOWN" in response.text
|
||||
|
||||
|
||||
# ── Agents ────────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_agents_list(client):
|
||||
response = client.get("/agents")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "agents" in data
|
||||
ids = [a["id"] for a in data["agents"]]
|
||||
assert "timmy" in ids
|
||||
|
||||
|
||||
def test_agents_list_timmy_metadata(client):
|
||||
response = client.get("/agents")
|
||||
timmy = next(a for a in response.json()["agents"] if a["id"] == "timmy")
|
||||
assert timmy["name"] == "Timmy"
|
||||
assert timmy["model"] == "llama3.2"
|
||||
assert timmy["type"] == "sovereign"
|
||||
|
||||
|
||||
# ── Chat ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_chat_timmy_success(client):
|
||||
mock_agent = MagicMock()
|
||||
mock_run = MagicMock()
|
||||
mock_run.content = "I am Timmy, operational and sovereign."
|
||||
mock_agent.run.return_value = mock_run
|
||||
|
||||
with patch("dashboard.routes.agents.create_timmy", return_value=mock_agent):
|
||||
response = client.post("/agents/timmy/chat", data={"message": "status?"})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "status?" in response.text
|
||||
assert "I am Timmy" in response.text
|
||||
|
||||
|
||||
def test_chat_timmy_shows_user_message(client):
|
||||
mock_agent = MagicMock()
|
||||
mock_agent.run.return_value = MagicMock(content="Acknowledged.")
|
||||
|
||||
with patch("dashboard.routes.agents.create_timmy", return_value=mock_agent):
|
||||
response = client.post("/agents/timmy/chat", data={"message": "hello there"})
|
||||
|
||||
assert "hello there" in response.text
|
||||
|
||||
|
||||
def test_chat_timmy_ollama_offline(client):
|
||||
with patch("dashboard.routes.agents.create_timmy", side_effect=Exception("connection refused")):
|
||||
response = client.post("/agents/timmy/chat", data={"message": "ping"})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "Timmy is offline" in response.text
|
||||
assert "ping" in response.text
|
||||
|
||||
|
||||
def test_chat_timmy_requires_message(client):
|
||||
response = client.post("/agents/timmy/chat", data={})
|
||||
assert response.status_code == 422
|
||||
33
tests/test_prompts.py
Normal file
33
tests/test_prompts.py
Normal file
@@ -0,0 +1,33 @@
|
||||
from timmy.prompts import TIMMY_SYSTEM_PROMPT, TIMMY_STATUS_PROMPT
|
||||
|
||||
|
||||
def test_system_prompt_not_empty():
|
||||
assert TIMMY_SYSTEM_PROMPT.strip()
|
||||
|
||||
|
||||
def test_system_prompt_has_timmy_identity():
|
||||
assert "Timmy" in TIMMY_SYSTEM_PROMPT
|
||||
|
||||
|
||||
def test_system_prompt_mentions_sovereignty():
|
||||
assert "sovereignty" in TIMMY_SYSTEM_PROMPT.lower()
|
||||
|
||||
|
||||
def test_system_prompt_references_local():
|
||||
assert "local" in TIMMY_SYSTEM_PROMPT.lower()
|
||||
|
||||
|
||||
def test_system_prompt_is_multiline():
|
||||
assert "\n" in TIMMY_SYSTEM_PROMPT
|
||||
|
||||
|
||||
def test_status_prompt_not_empty():
|
||||
assert TIMMY_STATUS_PROMPT.strip()
|
||||
|
||||
|
||||
def test_status_prompt_has_timmy():
|
||||
assert "Timmy" in TIMMY_STATUS_PROMPT
|
||||
|
||||
|
||||
def test_prompts_are_distinct():
|
||||
assert TIMMY_SYSTEM_PROMPT != TIMMY_STATUS_PROMPT
|
||||
Reference in New Issue
Block a user