forked from Rockachopa/Timmy-time-dashboard
refactor: Phase 2a — consolidate dashboard routes (27→22 files)
Merge related route files to reduce sprawl: - voice.py ← voice_enhanced.py (enhanced pipeline merged in) - swarm.py ← swarm_internal.py + swarm_ws.py (internal API + WebSocket) - self_coding.py ← self_modify.py (self-modify endpoints merged in) - Delete mobile_test.py route + template (test-only page, not for prod) - Delete test_xss_prevention.py (tested the deleted mobile_test page) Update app.py to use consolidated imports. Update test_voice_enhanced.py patch paths. Remove mobile_test.py from coverage omit (file deleted). 27 route files → 22. Tests: 1502 passed (1 removed with deleted page). https://claude.ai/code/session_019oMFNvD8uSGSSmBMGkBfQN
This commit is contained in:
@@ -130,7 +130,6 @@ markers = [
|
||||
source = ["src"]
|
||||
omit = [
|
||||
"*/tests/*",
|
||||
"src/dashboard/routes/mobile_test.py",
|
||||
]
|
||||
|
||||
[tool.coverage.report]
|
||||
|
||||
@@ -12,21 +12,17 @@ from fastapi.templating import Jinja2Templates
|
||||
from config import settings
|
||||
from dashboard.routes.agents import router as agents_router
|
||||
from dashboard.routes.health import router as health_router
|
||||
from dashboard.routes.mobile_test import router as mobile_test_router
|
||||
from dashboard.routes.swarm import router as swarm_router
|
||||
from dashboard.routes.swarm import internal_router as swarm_internal_router
|
||||
from dashboard.routes.marketplace import router as marketplace_router
|
||||
from dashboard.routes.voice import router as voice_router
|
||||
from dashboard.routes.voice_enhanced import router as voice_enhanced_router
|
||||
from dashboard.routes.mobile import router as mobile_router
|
||||
from dashboard.routes.swarm_ws import router as swarm_ws_router
|
||||
from dashboard.routes.briefing import router as briefing_router
|
||||
from dashboard.routes.telegram import router as telegram_router
|
||||
from dashboard.routes.swarm_internal import router as swarm_internal_router
|
||||
from dashboard.routes.tools import router as tools_router
|
||||
from dashboard.routes.spark import router as spark_router
|
||||
from dashboard.routes.creative import router as creative_router
|
||||
from dashboard.routes.discord import router as discord_router
|
||||
from dashboard.routes.self_modify import router as self_modify_router
|
||||
from dashboard.routes.events import router as events_router
|
||||
from dashboard.routes.ledger import router as ledger_router
|
||||
from dashboard.routes.memory import router as memory_router
|
||||
@@ -36,6 +32,7 @@ from dashboard.routes.work_orders import router as work_orders_router
|
||||
from dashboard.routes.tasks import router as tasks_router
|
||||
from dashboard.routes.scripture import router as scripture_router
|
||||
from dashboard.routes.self_coding import router as self_coding_router
|
||||
from dashboard.routes.self_coding import self_modify_router
|
||||
from dashboard.routes.hands import router as hands_router
|
||||
from router.api import router as cascade_router
|
||||
|
||||
@@ -131,7 +128,7 @@ async def lifespan(app: FastAPI):
|
||||
logger.info("MCP auto-bootstrap: %d tools registered", len(registered))
|
||||
except Exception as exc:
|
||||
logger.warning("MCP auto-bootstrap failed: %s", exc)
|
||||
|
||||
|
||||
# Initialise Spark Intelligence engine
|
||||
from spark.engine import spark_engine
|
||||
if spark_engine.enabled:
|
||||
@@ -178,20 +175,18 @@ app.mount("/static", StaticFiles(directory=str(PROJECT_ROOT / "static")), name="
|
||||
|
||||
app.include_router(health_router)
|
||||
app.include_router(agents_router)
|
||||
app.include_router(mobile_test_router)
|
||||
app.include_router(swarm_router)
|
||||
app.include_router(swarm_internal_router)
|
||||
app.include_router(marketplace_router)
|
||||
app.include_router(voice_router)
|
||||
app.include_router(voice_enhanced_router)
|
||||
app.include_router(mobile_router)
|
||||
app.include_router(swarm_ws_router)
|
||||
app.include_router(briefing_router)
|
||||
app.include_router(telegram_router)
|
||||
app.include_router(swarm_internal_router)
|
||||
app.include_router(tools_router)
|
||||
app.include_router(spark_router)
|
||||
app.include_router(creative_router)
|
||||
app.include_router(discord_router)
|
||||
app.include_router(self_coding_router)
|
||||
app.include_router(self_modify_router)
|
||||
app.include_router(events_router)
|
||||
app.include_router(ledger_router)
|
||||
@@ -201,7 +196,6 @@ app.include_router(upgrades_router)
|
||||
app.include_router(work_orders_router)
|
||||
app.include_router(tasks_router)
|
||||
app.include_router(scripture_router)
|
||||
app.include_router(self_coding_router)
|
||||
app.include_router(hands_router)
|
||||
app.include_router(cascade_router)
|
||||
|
||||
|
||||
@@ -1,257 +0,0 @@
|
||||
"""Mobile HITL (Human-in-the-Loop) test checklist route.
|
||||
|
||||
GET /mobile-test — interactive checklist for a human tester on their phone.
|
||||
|
||||
Each scenario specifies what to do and what to observe. The tester marks
|
||||
each one PASS / FAIL / SKIP. Results are stored in sessionStorage so they
|
||||
survive page scrolling without hitting the server.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
router = APIRouter(tags=["mobile-test"])
|
||||
templates = Jinja2Templates(directory=str(Path(__file__).parent.parent / "templates"))
|
||||
|
||||
# ── Test scenarios ────────────────────────────────────────────────────────────
|
||||
# Each dict: id, category, title, steps (list), expected
|
||||
SCENARIOS = [
|
||||
# Layout
|
||||
{
|
||||
"id": "L01",
|
||||
"category": "Layout",
|
||||
"title": "Sidebar renders as horizontal strip",
|
||||
"steps": [
|
||||
"Open the Mission Control page on your phone.",
|
||||
"Look at the top section above the chat window.",
|
||||
],
|
||||
"expected": (
|
||||
"AGENTS and SYSTEM HEALTH panels appear side-by-side in a "
|
||||
"horizontally scrollable strip — not stacked vertically."
|
||||
),
|
||||
},
|
||||
{
|
||||
"id": "L02",
|
||||
"category": "Layout",
|
||||
"title": "Sidebar panels are horizontally scrollable",
|
||||
"steps": [
|
||||
"Swipe left/right on the AGENTS / SYSTEM HEALTH strip.",
|
||||
],
|
||||
"expected": "Both panels slide smoothly; no page scroll is triggered.",
|
||||
},
|
||||
{
|
||||
"id": "L03",
|
||||
"category": "Layout",
|
||||
"title": "Chat panel fills ≥ 60 % of viewport height",
|
||||
"steps": [
|
||||
"Look at the TIMMY INTERFACE chat card below the strip.",
|
||||
],
|
||||
"expected": "The chat card occupies at least 60 % of the visible screen height.",
|
||||
},
|
||||
{
|
||||
"id": "L04",
|
||||
"category": "Layout",
|
||||
"title": "Header stays fixed while chat scrolls",
|
||||
"steps": [
|
||||
"Send several messages until the chat overflows.",
|
||||
"Scroll the chat log up and down.",
|
||||
],
|
||||
"expected": "The TIMMY TIME / MISSION CONTROL header remains pinned at the top.",
|
||||
},
|
||||
{
|
||||
"id": "L05",
|
||||
"category": "Layout",
|
||||
"title": "No horizontal page overflow",
|
||||
"steps": [
|
||||
"Try swiping left or right anywhere on the page.",
|
||||
],
|
||||
"expected": "The page does not scroll horizontally; nothing is cut off.",
|
||||
},
|
||||
# Touch & Input
|
||||
{
|
||||
"id": "T01",
|
||||
"category": "Touch & Input",
|
||||
"title": "iOS does NOT zoom when tapping the input",
|
||||
"steps": [
|
||||
"Tap the message input field once.",
|
||||
"Watch whether the browser zooms in.",
|
||||
],
|
||||
"expected": "The keyboard rises; the layout does NOT zoom in.",
|
||||
},
|
||||
{
|
||||
"id": "T02",
|
||||
"category": "Touch & Input",
|
||||
"title": "Keyboard return key is labelled 'Send'",
|
||||
"steps": [
|
||||
"Tap the message input to open the iOS/Android keyboard.",
|
||||
"Look at the return / action key in the bottom-right of the keyboard.",
|
||||
],
|
||||
"expected": "The key is labelled 'Send' (not 'Return' or 'Go').",
|
||||
},
|
||||
{
|
||||
"id": "T03",
|
||||
"category": "Touch & Input",
|
||||
"title": "Send button is easy to tap (≥ 44 px tall)",
|
||||
"steps": [
|
||||
"Try tapping the SEND button with your thumb.",
|
||||
],
|
||||
"expected": "The button registers the tap reliably on the first attempt.",
|
||||
},
|
||||
{
|
||||
"id": "T04",
|
||||
"category": "Touch & Input",
|
||||
"title": "SEND button disabled during in-flight request",
|
||||
"steps": [
|
||||
"Type a message and press SEND.",
|
||||
"Immediately try to tap SEND again before a response arrives.",
|
||||
],
|
||||
"expected": "The button is visually disabled; no duplicate message is sent.",
|
||||
},
|
||||
{
|
||||
"id": "T05",
|
||||
"category": "Touch & Input",
|
||||
"title": "Empty message cannot be submitted",
|
||||
"steps": [
|
||||
"Leave the input blank.",
|
||||
"Tap SEND.",
|
||||
],
|
||||
"expected": "Nothing is submitted; the form shows a required-field indicator.",
|
||||
},
|
||||
{
|
||||
"id": "T06",
|
||||
"category": "Touch & Input",
|
||||
"title": "CLEAR button shows confirmation dialog",
|
||||
"steps": [
|
||||
"Send at least one message.",
|
||||
"Tap the CLEAR button in the top-right of the chat header.",
|
||||
],
|
||||
"expected": "A browser confirmation dialog appears before history is cleared.",
|
||||
},
|
||||
# Chat behaviour
|
||||
{
|
||||
"id": "C01",
|
||||
"category": "Chat",
|
||||
"title": "Chat auto-scrolls to the latest message",
|
||||
"steps": [
|
||||
"Scroll the chat log to the top.",
|
||||
"Send a new message.",
|
||||
],
|
||||
"expected": "After the response arrives the chat automatically scrolls to the bottom.",
|
||||
},
|
||||
{
|
||||
"id": "C02",
|
||||
"category": "Chat",
|
||||
"title": "Multi-turn conversation — Timmy remembers context",
|
||||
"steps": [
|
||||
"Send: 'My name is <your name>.'",
|
||||
"Then send: 'What is my name?'",
|
||||
],
|
||||
"expected": "Timmy replies with your name, demonstrating conversation memory.",
|
||||
},
|
||||
{
|
||||
"id": "C03",
|
||||
"category": "Chat",
|
||||
"title": "Loading indicator appears while waiting",
|
||||
"steps": [
|
||||
"Send a message and watch the SEND button.",
|
||||
],
|
||||
"expected": "A blinking cursor (▋) appears next to SEND while the response is loading.",
|
||||
},
|
||||
{
|
||||
"id": "C04",
|
||||
"category": "Chat",
|
||||
"title": "Offline error is shown gracefully",
|
||||
"steps": [
|
||||
"Stop Ollama on your host machine (or disconnect from Wi-Fi temporarily).",
|
||||
"Send a message from your phone.",
|
||||
],
|
||||
"expected": "A red 'Timmy is offline' error appears in the chat — no crash or spinner hang.",
|
||||
},
|
||||
# Health panel
|
||||
{
|
||||
"id": "H01",
|
||||
"category": "Health",
|
||||
"title": "Health panel shows Ollama UP when running",
|
||||
"steps": [
|
||||
"Ensure Ollama is running on your host.",
|
||||
"Check the SYSTEM HEALTH panel.",
|
||||
],
|
||||
"expected": "OLLAMA badge shows green UP.",
|
||||
},
|
||||
{
|
||||
"id": "H02",
|
||||
"category": "Health",
|
||||
"title": "Health panel auto-refreshes without reload",
|
||||
"steps": [
|
||||
"Start Ollama if it is not running.",
|
||||
"Wait up to 35 seconds with the page open.",
|
||||
],
|
||||
"expected": "The OLLAMA badge flips from DOWN → UP automatically, without a page reload.",
|
||||
},
|
||||
# Scroll & overscroll
|
||||
{
|
||||
"id": "S01",
|
||||
"category": "Scroll",
|
||||
"title": "No rubber-band / bounce on the main page",
|
||||
"steps": [
|
||||
"Scroll to the very top of the page.",
|
||||
"Continue pulling downward.",
|
||||
],
|
||||
"expected": "The page does not bounce or show a white gap — overscroll is suppressed.",
|
||||
},
|
||||
{
|
||||
"id": "S02",
|
||||
"category": "Scroll",
|
||||
"title": "Chat log scrolls independently inside the card",
|
||||
"steps": [
|
||||
"Scroll inside the chat log area.",
|
||||
],
|
||||
"expected": "The chat log scrolls smoothly; the outer page does not move.",
|
||||
},
|
||||
# Safe area / notch
|
||||
{
|
||||
"id": "N01",
|
||||
"category": "Notch / Home Bar",
|
||||
"title": "Header clears the status bar / Dynamic Island",
|
||||
"steps": [
|
||||
"On a notched iPhone (Face ID), look at the top of the page.",
|
||||
],
|
||||
"expected": "The TIMMY TIME header text is not obscured by the notch or Dynamic Island.",
|
||||
},
|
||||
{
|
||||
"id": "N02",
|
||||
"category": "Notch / Home Bar",
|
||||
"title": "Chat input not hidden behind home indicator",
|
||||
"steps": [
|
||||
"Tap the input field and look at the bottom of the screen.",
|
||||
],
|
||||
"expected": "The input row sits above the iPhone home indicator bar — nothing is cut off.",
|
||||
},
|
||||
# Clock
|
||||
{
|
||||
"id": "X01",
|
||||
"category": "Live UI",
|
||||
"title": "Clock updates every second",
|
||||
"steps": [
|
||||
"Look at the time display in the top-right of the header.",
|
||||
"Watch for 3 seconds.",
|
||||
],
|
||||
"expected": "The time increments each second in HH:MM:SS format.",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@router.get("/mobile-test", response_class=HTMLResponse)
|
||||
async def mobile_test(request: Request):
|
||||
"""Interactive HITL mobile test checklist — open on your phone."""
|
||||
categories: dict[str, list] = {}
|
||||
for s in SCENARIOS:
|
||||
categories.setdefault(s["category"], []).append(s)
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"mobile_test.html",
|
||||
{"scenarios": SCENARIOS, "categories": categories, "total": len(SCENARIOS)},
|
||||
)
|
||||
@@ -5,17 +5,21 @@ API endpoints and HTMX views for the self-coding system:
|
||||
- Stats dashboard
|
||||
- Manual task execution
|
||||
- Real-time status updates
|
||||
- Self-modification loop (/self-modify/*)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Form, Request
|
||||
from fastapi import APIRouter, Form, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from config import settings
|
||||
|
||||
from self_coding import (
|
||||
CodebaseIndexer,
|
||||
ModificationJournal,
|
||||
@@ -366,3 +370,59 @@ async def journal_entry_detail(request: Request, attempt_id: int):
|
||||
"entry": entry,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# ── Self-Modification Routes (/self-modify/*) ───────────────────────────
|
||||
|
||||
self_modify_router = APIRouter(prefix="/self-modify", tags=["self-modify"])
|
||||
|
||||
|
||||
@self_modify_router.post("/run")
|
||||
async def run_self_modify(
|
||||
instruction: str = Form(...),
|
||||
target_files: str = Form(""),
|
||||
dry_run: bool = Form(False),
|
||||
speak_result: bool = Form(False),
|
||||
):
|
||||
"""Execute a self-modification loop."""
|
||||
if not settings.self_modify_enabled:
|
||||
raise HTTPException(403, "Self-modification is disabled")
|
||||
|
||||
from self_modify.loop import SelfModifyLoop, ModifyRequest
|
||||
|
||||
files = [f.strip() for f in target_files.split(",") if f.strip()]
|
||||
request = ModifyRequest(
|
||||
instruction=instruction,
|
||||
target_files=files,
|
||||
dry_run=dry_run,
|
||||
)
|
||||
|
||||
loop = SelfModifyLoop()
|
||||
result = await asyncio.to_thread(loop.run, request)
|
||||
|
||||
if speak_result and result.success:
|
||||
try:
|
||||
from timmy_serve.voice_tts import voice_tts
|
||||
if voice_tts.available:
|
||||
voice_tts.speak(
|
||||
f"Code modification complete. "
|
||||
f"{len(result.files_changed)} files changed. Tests passing."
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {
|
||||
"success": result.success,
|
||||
"files_changed": result.files_changed,
|
||||
"test_passed": result.test_passed,
|
||||
"commit_sha": result.commit_sha,
|
||||
"branch_name": result.branch_name,
|
||||
"error": result.error,
|
||||
"attempts": result.attempts,
|
||||
}
|
||||
|
||||
|
||||
@self_modify_router.get("/status")
|
||||
async def self_modify_status():
|
||||
"""Return whether self-modification is enabled."""
|
||||
return {"enabled": settings.self_modify_enabled}
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
"""Self-modification routes — /self-modify endpoints.
|
||||
|
||||
Exposes the edit-test-commit loop as a REST API. Gated by
|
||||
``SELF_MODIFY_ENABLED`` (default False).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Form, HTTPException
|
||||
|
||||
from config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/self-modify", tags=["self-modify"])
|
||||
|
||||
|
||||
@router.post("/run")
|
||||
async def run_self_modify(
|
||||
instruction: str = Form(...),
|
||||
target_files: str = Form(""),
|
||||
dry_run: bool = Form(False),
|
||||
speak_result: bool = Form(False),
|
||||
):
|
||||
"""Execute a self-modification loop.
|
||||
|
||||
Returns the ModifyResult as JSON.
|
||||
"""
|
||||
if not settings.self_modify_enabled:
|
||||
raise HTTPException(403, "Self-modification is disabled")
|
||||
|
||||
from self_modify.loop import SelfModifyLoop, ModifyRequest
|
||||
|
||||
files = [f.strip() for f in target_files.split(",") if f.strip()]
|
||||
request = ModifyRequest(
|
||||
instruction=instruction,
|
||||
target_files=files,
|
||||
dry_run=dry_run,
|
||||
)
|
||||
|
||||
loop = SelfModifyLoop()
|
||||
result = await asyncio.to_thread(loop.run, request)
|
||||
|
||||
if speak_result and result.success:
|
||||
try:
|
||||
from timmy_serve.voice_tts import voice_tts
|
||||
|
||||
if voice_tts.available:
|
||||
voice_tts.speak(
|
||||
f"Code modification complete. "
|
||||
f"{len(result.files_changed)} files changed. Tests passing."
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {
|
||||
"success": result.success,
|
||||
"files_changed": result.files_changed,
|
||||
"test_passed": result.test_passed,
|
||||
"commit_sha": result.commit_sha,
|
||||
"branch_name": result.branch_name,
|
||||
"error": result.error,
|
||||
"attempts": result.attempts,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
async def self_modify_status():
|
||||
"""Return whether self-modification is enabled."""
|
||||
return {"enabled": settings.self_modify_enabled}
|
||||
@@ -1,22 +1,28 @@
|
||||
"""Swarm dashboard routes — /swarm/* endpoints.
|
||||
"""Swarm dashboard routes — /swarm/*, /internal/*, and /swarm/live endpoints.
|
||||
|
||||
Provides REST endpoints for managing the swarm: listing agents,
|
||||
spawning sub-agents, posting tasks, and viewing auction results.
|
||||
spawning sub-agents, posting tasks, viewing auction results, Docker
|
||||
container agent HTTP API, and WebSocket live feed.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Form, HTTPException, Request
|
||||
from fastapi import APIRouter, Form, HTTPException, Request, WebSocket, WebSocketDisconnect
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from pydantic import BaseModel
|
||||
|
||||
from swarm import learner as swarm_learner
|
||||
from swarm import registry
|
||||
from swarm.coordinator import coordinator
|
||||
from swarm.tasks import TaskStatus, update_task
|
||||
from ws_manager.handler import ws_manager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/swarm", tags=["swarm"])
|
||||
templates = Jinja2Templates(directory=str(Path(__file__).parent.parent / "templates"))
|
||||
@@ -325,3 +331,92 @@ async def message_agent(agent_id: str, request: Request, message: str = Form(...
|
||||
)
|
||||
|
||||
|
||||
# ── Internal HTTP API (Docker container agents) ─────────────────────────
|
||||
|
||||
internal_router = APIRouter(prefix="/internal", tags=["internal"])
|
||||
|
||||
|
||||
class BidRequest(BaseModel):
|
||||
task_id: str
|
||||
agent_id: str
|
||||
bid_sats: int
|
||||
capabilities: Optional[str] = ""
|
||||
|
||||
|
||||
class BidResponse(BaseModel):
|
||||
accepted: bool
|
||||
task_id: str
|
||||
agent_id: str
|
||||
message: str
|
||||
|
||||
|
||||
class TaskSummary(BaseModel):
|
||||
task_id: str
|
||||
description: str
|
||||
status: str
|
||||
|
||||
|
||||
@internal_router.get("/tasks", response_model=list[TaskSummary])
|
||||
def list_biddable_tasks():
|
||||
"""Return all tasks currently open for bidding."""
|
||||
tasks = coordinator.list_tasks(status=TaskStatus.BIDDING)
|
||||
return [
|
||||
TaskSummary(
|
||||
task_id=t.id,
|
||||
description=t.description,
|
||||
status=t.status.value,
|
||||
)
|
||||
for t in tasks
|
||||
]
|
||||
|
||||
|
||||
@internal_router.post("/bids", response_model=BidResponse)
|
||||
def submit_bid(bid: BidRequest):
|
||||
"""Accept a bid from a container agent."""
|
||||
if bid.bid_sats <= 0:
|
||||
raise HTTPException(status_code=422, detail="bid_sats must be > 0")
|
||||
|
||||
accepted = coordinator.auctions.submit_bid(
|
||||
task_id=bid.task_id,
|
||||
agent_id=bid.agent_id,
|
||||
bid_sats=bid.bid_sats,
|
||||
)
|
||||
|
||||
if accepted:
|
||||
from swarm import stats as swarm_stats
|
||||
swarm_stats.record_bid(bid.task_id, bid.agent_id, bid.bid_sats, won=False)
|
||||
logger.info(
|
||||
"Docker agent %s bid %d sats on task %s",
|
||||
bid.agent_id, bid.bid_sats, bid.task_id,
|
||||
)
|
||||
return BidResponse(
|
||||
accepted=True,
|
||||
task_id=bid.task_id,
|
||||
agent_id=bid.agent_id,
|
||||
message="Bid accepted.",
|
||||
)
|
||||
|
||||
return BidResponse(
|
||||
accepted=False,
|
||||
task_id=bid.task_id,
|
||||
agent_id=bid.agent_id,
|
||||
message="No open auction for this task — it may have already closed.",
|
||||
)
|
||||
|
||||
|
||||
# ── WebSocket live feed ──────────────────────────────────────────────────
|
||||
|
||||
@router.websocket("/live")
|
||||
async def swarm_live(websocket: WebSocket):
|
||||
"""WebSocket endpoint for live swarm event streaming."""
|
||||
await ws_manager.connect(websocket)
|
||||
try:
|
||||
while True:
|
||||
data = await websocket.receive_text()
|
||||
logger.debug("WS received: %s", data[:100])
|
||||
except WebSocketDisconnect:
|
||||
ws_manager.disconnect(websocket)
|
||||
except Exception as exc:
|
||||
logger.error("WebSocket error: %s", exc)
|
||||
ws_manager.disconnect(websocket)
|
||||
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
"""Internal swarm HTTP API — for Docker container agents.
|
||||
|
||||
Container agents can't use the in-memory SwarmComms channel, so they poll
|
||||
these lightweight endpoints to participate in the auction system.
|
||||
|
||||
Routes
|
||||
------
|
||||
GET /internal/tasks
|
||||
Returns all tasks currently in BIDDING status — the set an agent
|
||||
can submit bids for.
|
||||
|
||||
POST /internal/bids
|
||||
Accepts a bid from a container agent and feeds it into the in-memory
|
||||
AuctionManager. The coordinator then closes auctions and assigns
|
||||
winners exactly as it does for in-process agents.
|
||||
|
||||
These endpoints are intentionally unauthenticated because they are only
|
||||
reachable inside the Docker swarm-net bridge network. Do not expose them
|
||||
through a reverse-proxy to the public internet.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from swarm.coordinator import coordinator
|
||||
from swarm.tasks import TaskStatus
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/internal", tags=["internal"])
|
||||
|
||||
|
||||
# ── Request / response models ─────────────────────────────────────────────────
|
||||
|
||||
class BidRequest(BaseModel):
|
||||
task_id: str
|
||||
agent_id: str
|
||||
bid_sats: int
|
||||
capabilities: Optional[str] = ""
|
||||
|
||||
|
||||
class BidResponse(BaseModel):
|
||||
accepted: bool
|
||||
task_id: str
|
||||
agent_id: str
|
||||
message: str
|
||||
|
||||
|
||||
class TaskSummary(BaseModel):
|
||||
task_id: str
|
||||
description: str
|
||||
status: str
|
||||
|
||||
|
||||
# ── Routes ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/tasks", response_model=list[TaskSummary])
|
||||
def list_biddable_tasks():
|
||||
"""Return all tasks currently open for bidding.
|
||||
|
||||
Container agents should poll this endpoint and submit bids for any
|
||||
tasks they are capable of handling.
|
||||
"""
|
||||
tasks = coordinator.list_tasks(status=TaskStatus.BIDDING)
|
||||
return [
|
||||
TaskSummary(
|
||||
task_id=t.id,
|
||||
description=t.description,
|
||||
status=t.status.value,
|
||||
)
|
||||
for t in tasks
|
||||
]
|
||||
|
||||
|
||||
@router.post("/bids", response_model=BidResponse)
|
||||
def submit_bid(bid: BidRequest):
|
||||
"""Accept a bid from a container agent.
|
||||
|
||||
The bid is injected directly into the in-memory AuctionManager.
|
||||
If no auction is open for the task (e.g. it already closed), the
|
||||
bid is rejected gracefully — the agent should just move on.
|
||||
"""
|
||||
if bid.bid_sats <= 0:
|
||||
raise HTTPException(status_code=422, detail="bid_sats must be > 0")
|
||||
|
||||
accepted = coordinator.auctions.submit_bid(
|
||||
task_id=bid.task_id,
|
||||
agent_id=bid.agent_id,
|
||||
bid_sats=bid.bid_sats,
|
||||
)
|
||||
|
||||
if accepted:
|
||||
# Persist bid in stats table for marketplace analytics
|
||||
from swarm import stats as swarm_stats
|
||||
swarm_stats.record_bid(bid.task_id, bid.agent_id, bid.bid_sats, won=False)
|
||||
logger.info(
|
||||
"Docker agent %s bid %d sats on task %s",
|
||||
bid.agent_id, bid.bid_sats, bid.task_id,
|
||||
)
|
||||
return BidResponse(
|
||||
accepted=True,
|
||||
task_id=bid.task_id,
|
||||
agent_id=bid.agent_id,
|
||||
message="Bid accepted.",
|
||||
)
|
||||
|
||||
return BidResponse(
|
||||
accepted=False,
|
||||
task_id=bid.task_id,
|
||||
agent_id=bid.agent_id,
|
||||
message="No open auction for this task — it may have already closed.",
|
||||
)
|
||||
@@ -1,33 +0,0 @@
|
||||
"""Swarm WebSocket route — /swarm/live endpoint.
|
||||
|
||||
Provides a real-time WebSocket feed of swarm events for the live
|
||||
dashboard view. Clients connect and receive JSON events as they
|
||||
happen: agent joins, task posts, bids, assignments, completions.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||
|
||||
from ws_manager.handler import ws_manager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(tags=["swarm-ws"])
|
||||
|
||||
|
||||
@router.websocket("/swarm/live")
|
||||
async def swarm_live(websocket: WebSocket):
|
||||
"""WebSocket endpoint for live swarm event streaming."""
|
||||
await ws_manager.connect(websocket)
|
||||
try:
|
||||
while True:
|
||||
# Keep the connection alive; client can also send commands
|
||||
data = await websocket.receive_text()
|
||||
# Echo back as acknowledgment (future: handle client commands)
|
||||
logger.debug("WS received: %s", data[:100])
|
||||
except WebSocketDisconnect:
|
||||
ws_manager.disconnect(websocket)
|
||||
except Exception as exc:
|
||||
logger.error("WebSocket error: %s", exc)
|
||||
ws_manager.disconnect(websocket)
|
||||
@@ -1,12 +1,17 @@
|
||||
"""Voice routes — /voice/* endpoints.
|
||||
"""Voice routes — /voice/* and /voice/enhanced/* endpoints.
|
||||
|
||||
Provides NLU intent detection and TTS control endpoints for the
|
||||
voice interface.
|
||||
Provides NLU intent detection, TTS control, and the full voice-to-action
|
||||
pipeline (detect intent → execute → optionally speak).
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Form
|
||||
|
||||
from voice.nlu import detect_intent, extract_command
|
||||
from timmy.agent import create_timmy
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/voice", tags=["voice"])
|
||||
|
||||
@@ -49,3 +54,103 @@ async def tts_speak(text: str = Form(...)):
|
||||
return {"spoken": True, "text": text}
|
||||
except Exception as exc:
|
||||
return {"spoken": False, "reason": str(exc)}
|
||||
|
||||
|
||||
# ── Enhanced voice pipeline ──────────────────────────────────────────────
|
||||
|
||||
@router.post("/enhanced/process")
|
||||
async def process_voice_input(
|
||||
text: str = Form(...),
|
||||
speak_response: bool = Form(False),
|
||||
):
|
||||
"""Process a voice input: detect intent -> execute -> optionally speak.
|
||||
|
||||
This is the main entry point for voice-driven interaction with Timmy.
|
||||
"""
|
||||
intent = detect_intent(text)
|
||||
response_text = None
|
||||
error = None
|
||||
|
||||
try:
|
||||
if intent.name == "status":
|
||||
response_text = "Timmy is operational and running locally. All systems sovereign."
|
||||
|
||||
elif intent.name == "help":
|
||||
response_text = (
|
||||
"Available commands: chat with me, check status, "
|
||||
"manage the swarm, create tasks, or adjust voice settings. "
|
||||
"Everything runs locally — no cloud, no permission needed."
|
||||
)
|
||||
|
||||
elif intent.name == "swarm":
|
||||
from swarm.coordinator import coordinator
|
||||
status = coordinator.status()
|
||||
response_text = (
|
||||
f"Swarm status: {status['agents']} agents registered, "
|
||||
f"{status['agents_idle']} idle, {status['agents_busy']} busy. "
|
||||
f"{status['tasks_total']} total tasks, "
|
||||
f"{status['tasks_completed']} completed."
|
||||
)
|
||||
|
||||
elif intent.name == "voice":
|
||||
response_text = "Voice settings acknowledged. TTS is available for spoken responses."
|
||||
|
||||
elif intent.name == "code":
|
||||
from config import settings as app_settings
|
||||
if not app_settings.self_modify_enabled:
|
||||
response_text = (
|
||||
"Self-modification is disabled. "
|
||||
"Set SELF_MODIFY_ENABLED=true to enable."
|
||||
)
|
||||
else:
|
||||
import asyncio
|
||||
from self_modify.loop import SelfModifyLoop, ModifyRequest
|
||||
|
||||
target_files = []
|
||||
if "target_file" in intent.entities:
|
||||
target_files = [intent.entities["target_file"]]
|
||||
|
||||
loop = SelfModifyLoop()
|
||||
request = ModifyRequest(
|
||||
instruction=text,
|
||||
target_files=target_files,
|
||||
)
|
||||
result = await asyncio.to_thread(loop.run, request)
|
||||
|
||||
if result.success:
|
||||
sha_short = result.commit_sha[:8] if result.commit_sha else "none"
|
||||
response_text = (
|
||||
f"Code modification complete. "
|
||||
f"Changed {len(result.files_changed)} file(s). "
|
||||
f"Tests passed. Committed as {sha_short} "
|
||||
f"on branch {result.branch_name}."
|
||||
)
|
||||
else:
|
||||
response_text = f"Code modification failed: {result.error}"
|
||||
|
||||
else:
|
||||
# Default: chat with Timmy
|
||||
agent = create_timmy()
|
||||
run = agent.run(text, stream=False)
|
||||
response_text = run.content if hasattr(run, "content") else str(run)
|
||||
|
||||
except Exception as exc:
|
||||
error = f"Processing failed: {exc}"
|
||||
logger.error("Voice processing error: %s", exc)
|
||||
|
||||
# Optionally speak the response
|
||||
if speak_response and response_text:
|
||||
try:
|
||||
from timmy_serve.voice_tts import voice_tts
|
||||
if voice_tts.available:
|
||||
voice_tts.speak(response_text)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {
|
||||
"intent": intent.name,
|
||||
"confidence": intent.confidence,
|
||||
"response": response_text,
|
||||
"error": error,
|
||||
"spoken": speak_response and response_text is not None,
|
||||
}
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
"""Enhanced voice routes — /voice/enhanced/* endpoints.
|
||||
|
||||
Combines NLU intent detection with Timmy agent execution to provide
|
||||
a complete voice-to-action pipeline. Detects the intent, routes to
|
||||
the appropriate handler, and optionally speaks the response.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Form
|
||||
|
||||
from voice.nlu import detect_intent
|
||||
from timmy.agent import create_timmy
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/voice/enhanced", tags=["voice-enhanced"])
|
||||
|
||||
|
||||
@router.post("/process")
|
||||
async def process_voice_input(
|
||||
text: str = Form(...),
|
||||
speak_response: bool = Form(False),
|
||||
):
|
||||
"""Process a voice input: detect intent → execute → optionally speak.
|
||||
|
||||
This is the main entry point for voice-driven interaction with Timmy.
|
||||
"""
|
||||
intent = detect_intent(text)
|
||||
response_text = None
|
||||
error = None
|
||||
|
||||
try:
|
||||
if intent.name == "status":
|
||||
response_text = "Timmy is operational and running locally. All systems sovereign."
|
||||
|
||||
elif intent.name == "help":
|
||||
response_text = (
|
||||
"Available commands: chat with me, check status, "
|
||||
"manage the swarm, create tasks, or adjust voice settings. "
|
||||
"Everything runs locally — no cloud, no permission needed."
|
||||
)
|
||||
|
||||
elif intent.name == "swarm":
|
||||
from swarm.coordinator import coordinator
|
||||
status = coordinator.status()
|
||||
response_text = (
|
||||
f"Swarm status: {status['agents']} agents registered, "
|
||||
f"{status['agents_idle']} idle, {status['agents_busy']} busy. "
|
||||
f"{status['tasks_total']} total tasks, "
|
||||
f"{status['tasks_completed']} completed."
|
||||
)
|
||||
|
||||
elif intent.name == "voice":
|
||||
response_text = "Voice settings acknowledged. TTS is available for spoken responses."
|
||||
|
||||
elif intent.name == "code":
|
||||
from config import settings as app_settings
|
||||
if not app_settings.self_modify_enabled:
|
||||
response_text = (
|
||||
"Self-modification is disabled. "
|
||||
"Set SELF_MODIFY_ENABLED=true to enable."
|
||||
)
|
||||
else:
|
||||
import asyncio
|
||||
from self_modify.loop import SelfModifyLoop, ModifyRequest
|
||||
|
||||
target_files = []
|
||||
if "target_file" in intent.entities:
|
||||
target_files = [intent.entities["target_file"]]
|
||||
|
||||
loop = SelfModifyLoop()
|
||||
request = ModifyRequest(
|
||||
instruction=text,
|
||||
target_files=target_files,
|
||||
)
|
||||
result = await asyncio.to_thread(loop.run, request)
|
||||
|
||||
if result.success:
|
||||
sha_short = result.commit_sha[:8] if result.commit_sha else "none"
|
||||
response_text = (
|
||||
f"Code modification complete. "
|
||||
f"Changed {len(result.files_changed)} file(s). "
|
||||
f"Tests passed. Committed as {sha_short} "
|
||||
f"on branch {result.branch_name}."
|
||||
)
|
||||
else:
|
||||
response_text = f"Code modification failed: {result.error}"
|
||||
|
||||
else:
|
||||
# Default: chat with Timmy
|
||||
agent = create_timmy()
|
||||
run = agent.run(text, stream=False)
|
||||
response_text = run.content if hasattr(run, "content") else str(run)
|
||||
|
||||
except Exception as exc:
|
||||
error = f"Processing failed: {exc}"
|
||||
logger.error("Voice processing error: %s", exc)
|
||||
|
||||
# Optionally speak the response
|
||||
if speak_response and response_text:
|
||||
try:
|
||||
from timmy_serve.voice_tts import voice_tts
|
||||
if voice_tts.available:
|
||||
voice_tts.speak(response_text)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {
|
||||
"intent": intent.name,
|
||||
"confidence": intent.confidence,
|
||||
"response": response_text,
|
||||
"error": error,
|
||||
"spoken": speak_response and response_text is not None,
|
||||
}
|
||||
@@ -1,422 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Mobile Test — Timmy Time{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid mc-content" style="height:auto; overflow:visible;">
|
||||
|
||||
<!-- ── Page header ─────────────────────────────────────────────────── -->
|
||||
<div class="mt-hitl-header">
|
||||
<div>
|
||||
<span class="mt-title">// MOBILE TEST SUITE</span>
|
||||
<span class="mt-sub">HUMAN-IN-THE-LOOP</span>
|
||||
</div>
|
||||
<div class="mt-score-wrap">
|
||||
<span class="mt-score" id="score-display">0 / {{ total }}</span>
|
||||
<span class="mt-score-label">PASSED</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Progress bar ────────────────────────────────────────────────── -->
|
||||
<div class="mt-progress-wrap">
|
||||
<div class="progress" style="height:6px; background:var(--bg-card); border-radius:3px;">
|
||||
<div class="progress-bar mt-progress-bar"
|
||||
id="progress-bar"
|
||||
role="progressbar"
|
||||
style="width:0%; background:var(--green);"
|
||||
aria-valuenow="0" aria-valuemin="0" aria-valuemax="{{ total }}"></div>
|
||||
</div>
|
||||
<div class="mt-progress-legend">
|
||||
<span><span class="mt-dot green"></span>PASS</span>
|
||||
<span><span class="mt-dot red"></span>FAIL</span>
|
||||
<span><span class="mt-dot amber"></span>SKIP</span>
|
||||
<span><span class="mt-dot" style="background:var(--text-dim);box-shadow:none;"></span>PENDING</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Reset / Back ────────────────────────────────────────────────── -->
|
||||
<div class="mt-actions">
|
||||
<a href="/" class="mc-btn-clear">← MISSION CONTROL</a>
|
||||
<button class="mc-btn-clear" onclick="resetAll()" style="border-color:var(--red);color:var(--red);">RESET ALL</button>
|
||||
</div>
|
||||
|
||||
<!-- ── Scenario cards ──────────────────────────────────────────────── -->
|
||||
{% for category, items in categories.items() %}
|
||||
<div class="mt-category-label">{{ category | upper }}</div>
|
||||
|
||||
{% for s in items %}
|
||||
<div class="card mc-panel mt-card" id="card-{{ s.id }}" data-scenario="{{ s.id }}">
|
||||
<div class="card-header mc-panel-header d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<span class="mt-id-badge" id="badge-{{ s.id }}">{{ s.id }}</span>
|
||||
<span class="mt-scenario-title">{{ s.title }}</span>
|
||||
</div>
|
||||
<span class="mt-state-chip" id="chip-{{ s.id }}">PENDING</span>
|
||||
</div>
|
||||
<div class="card-body p-3">
|
||||
|
||||
<div class="mt-steps-label">STEPS</div>
|
||||
<ol class="mt-steps">
|
||||
{% for step in s.steps %}
|
||||
<li>{{ step }}</li>
|
||||
{% endfor %}
|
||||
</ol>
|
||||
|
||||
<div class="mt-expected-label">EXPECTED</div>
|
||||
<div class="mt-expected">{{ s.expected }}</div>
|
||||
|
||||
<div class="mt-btn-row">
|
||||
<button class="mt-btn mt-btn-pass" onclick="mark('{{ s.id }}', 'pass')">✓ PASS</button>
|
||||
<button class="mt-btn mt-btn-fail" onclick="mark('{{ s.id }}', 'fail')">✗ FAIL</button>
|
||||
<button class="mt-btn mt-btn-skip" onclick="mark('{{ s.id }}', 'skip')">— SKIP</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
|
||||
<!-- ── Summary footer ──────────────────────────────────────────────── -->
|
||||
<div class="card mc-panel mt-summary" id="summary">
|
||||
<div class="card-header mc-panel-header">// SUMMARY</div>
|
||||
<div class="card-body p-3" id="summary-body">
|
||||
<p class="mt-summary-hint">Mark all scenarios above to see your final score.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- /container -->
|
||||
|
||||
|
||||
<!-- ── Styles (scoped to this page) ────────────────────────────────────── -->
|
||||
<style>
|
||||
.mt-hitl-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
padding: 16px 0 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.mt-title {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: var(--text-bright);
|
||||
letter-spacing: 0.18em;
|
||||
display: block;
|
||||
}
|
||||
.mt-sub {
|
||||
font-size: 10px;
|
||||
color: var(--text-dim);
|
||||
letter-spacing: 0.2em;
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.mt-score-wrap { text-align: right; }
|
||||
.mt-score {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: var(--green);
|
||||
letter-spacing: 0.06em;
|
||||
display: block;
|
||||
}
|
||||
.mt-score-label { font-size: 9px; color: var(--text-dim); letter-spacing: 0.2em; }
|
||||
|
||||
.mt-progress-wrap { margin-bottom: 10px; }
|
||||
.mt-progress-legend {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
font-size: 9px;
|
||||
color: var(--text-dim);
|
||||
letter-spacing: 0.12em;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.mt-dot {
|
||||
display: inline-block;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
margin-right: 4px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.mt-dot.green { background: var(--green); box-shadow: 0 0 5px var(--green); }
|
||||
.mt-dot.red { background: var(--red); box-shadow: 0 0 5px var(--red); }
|
||||
.mt-dot.amber { background: var(--amber); box-shadow: 0 0 5px var(--amber); }
|
||||
|
||||
.mt-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.mt-category-label {
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
color: var(--text-dim);
|
||||
letter-spacing: 0.25em;
|
||||
margin: 20px 0 8px;
|
||||
padding-left: 2px;
|
||||
}
|
||||
|
||||
.mt-card {
|
||||
margin-bottom: 10px;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
.mt-card.state-pass { border-color: var(--green) !important; }
|
||||
.mt-card.state-fail { border-color: var(--red) !important; }
|
||||
.mt-card.state-skip { border-color: var(--amber) !important; opacity: 0.7; }
|
||||
|
||||
.mt-id-badge {
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
background: var(--border);
|
||||
color: var(--text-dim);
|
||||
border-radius: 2px;
|
||||
padding: 2px 6px;
|
||||
letter-spacing: 0.12em;
|
||||
margin-right: 8px;
|
||||
}
|
||||
.mt-card.state-pass .mt-id-badge { background: var(--green-dim); color: var(--green); }
|
||||
.mt-card.state-fail .mt-id-badge { background: var(--red-dim); color: var(--red); }
|
||||
.mt-card.state-skip .mt-id-badge { background: var(--amber-dim); color: var(--amber); }
|
||||
|
||||
.mt-scenario-title {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--text-bright);
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.mt-state-chip {
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.15em;
|
||||
color: var(--text-dim);
|
||||
padding: 2px 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 2px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.mt-card.state-pass .mt-state-chip { color: var(--green); border-color: var(--green); }
|
||||
.mt-card.state-fail .mt-state-chip { color: var(--red); border-color: var(--red); }
|
||||
.mt-card.state-skip .mt-state-chip { color: var(--amber); border-color: var(--amber); }
|
||||
|
||||
.mt-steps-label, .mt-expected-label {
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
color: var(--text-dim);
|
||||
letter-spacing: 0.2em;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.mt-expected-label { margin-top: 12px; }
|
||||
|
||||
.mt-steps {
|
||||
padding-left: 18px;
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.8;
|
||||
color: var(--text);
|
||||
}
|
||||
.mt-expected {
|
||||
font-size: 12px;
|
||||
line-height: 1.65;
|
||||
color: var(--text-bright);
|
||||
background: var(--bg-card);
|
||||
border-left: 3px solid var(--border-glow);
|
||||
padding: 8px 12px;
|
||||
border-radius: 0 3px 3px 0;
|
||||
}
|
||||
|
||||
.mt-btn-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
.mt-btn {
|
||||
flex: 1;
|
||||
min-height: 44px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 3px;
|
||||
background: var(--bg-deep);
|
||||
color: var(--text-dim);
|
||||
font-family: var(--font);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.12em;
|
||||
cursor: pointer;
|
||||
touch-action: manipulation;
|
||||
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||
}
|
||||
.mt-btn-pass:hover, .mt-btn-pass.active { background: var(--green-dim); color: var(--green); border-color: var(--green); }
|
||||
.mt-btn-fail:hover, .mt-btn-fail.active { background: var(--red-dim); color: var(--red); border-color: var(--red); }
|
||||
.mt-btn-skip:hover, .mt-btn-skip.active { background: var(--amber-dim); color: var(--amber); border-color: var(--amber); }
|
||||
|
||||
.mt-summary { margin-top: 24px; margin-bottom: 32px; }
|
||||
.mt-summary-hint { color: var(--text-dim); font-size: 12px; margin: 0; }
|
||||
|
||||
.mt-summary-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 12px;
|
||||
}
|
||||
.mt-summary-row:last-child { border-bottom: none; }
|
||||
.mt-summary-score { font-size: 28px; font-weight: 700; color: var(--green); margin: 12px 0 4px; }
|
||||
.mt-summary-pct { font-size: 13px; color: var(--text-dim); }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.mt-btn-row { gap: 6px; }
|
||||
.mt-btn { font-size: 10px; padding: 0 4px; }
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
<!-- ── HITL State Machine (sessionStorage) ─────────────────────────────── -->
|
||||
<script>
|
||||
const TOTAL = {{ total }};
|
||||
const KEY = "timmy-mobile-test-results";
|
||||
|
||||
function loadResults() {
|
||||
try { return JSON.parse(sessionStorage.getItem(KEY) || "{}"); }
|
||||
catch { return {}; }
|
||||
}
|
||||
function saveResults(r) {
|
||||
sessionStorage.setItem(KEY, JSON.stringify(r));
|
||||
}
|
||||
|
||||
function mark(id, state) {
|
||||
const results = loadResults();
|
||||
results[id] = state;
|
||||
saveResults(results);
|
||||
applyState(id, state);
|
||||
updateScore(results);
|
||||
updateSummary(results);
|
||||
}
|
||||
|
||||
function applyState(id, state) {
|
||||
const card = document.getElementById("card-" + id);
|
||||
const chip = document.getElementById("chip-" + id);
|
||||
const labels = { pass: "PASS", fail: "FAIL", skip: "SKIP" };
|
||||
|
||||
card.classList.remove("state-pass", "state-fail", "state-skip");
|
||||
if (state) card.classList.add("state-" + state);
|
||||
chip.textContent = state ? labels[state] : "PENDING";
|
||||
|
||||
// highlight active button
|
||||
card.querySelectorAll(".mt-btn").forEach(btn => btn.classList.remove("active"));
|
||||
const activeBtn = card.querySelector(".mt-btn-" + state);
|
||||
if (activeBtn) activeBtn.classList.add("active");
|
||||
}
|
||||
|
||||
function updateScore(results) {
|
||||
const passed = Object.values(results).filter(v => v === "pass").length;
|
||||
const decided = Object.values(results).filter(v => v !== undefined).length;
|
||||
document.getElementById("score-display").textContent = passed + " / " + TOTAL;
|
||||
|
||||
const pct = TOTAL ? (decided / TOTAL) * 100 : 0;
|
||||
const bar = document.getElementById("progress-bar");
|
||||
bar.style.width = pct + "%";
|
||||
|
||||
// colour the bar by overall health
|
||||
const failCount = Object.values(results).filter(v => v === "fail").length;
|
||||
bar.style.background = failCount > 0
|
||||
? "var(--red)"
|
||||
: passed === TOTAL ? "var(--green)" : "var(--amber)";
|
||||
}
|
||||
|
||||
function updateSummary(results) {
|
||||
const passed = Object.values(results).filter(v => v === "pass").length;
|
||||
const failed = Object.values(results).filter(v => v === "fail").length;
|
||||
const skipped = Object.values(results).filter(v => v === "skip").length;
|
||||
const decided = passed + failed + skipped;
|
||||
const summaryBody = document.getElementById("summary-body");
|
||||
|
||||
if (decided < TOTAL) {
|
||||
summaryBody.innerHTML = '';
|
||||
const p = document.createElement('p');
|
||||
p.className = 'mt-summary-hint';
|
||||
p.textContent = (TOTAL - decided) + ' scenario(s) still pending.';
|
||||
summaryBody.appendChild(p);
|
||||
return;
|
||||
}
|
||||
|
||||
const pct = TOTAL ? Math.round((passed / TOTAL) * 100) : 0;
|
||||
const color = failed > 0 ? "var(--red)" : "var(--green)";
|
||||
|
||||
// Safely build summary UI using DOM API to avoid XSS from potentially untrusted variables
|
||||
summaryBody.innerHTML = '';
|
||||
|
||||
const scoreDiv = document.createElement('div');
|
||||
scoreDiv.className = 'mt-summary-score';
|
||||
scoreDiv.style.color = color;
|
||||
scoreDiv.textContent = passed + ' / ' + TOTAL;
|
||||
summaryBody.appendChild(scoreDiv);
|
||||
|
||||
const pctDiv = document.createElement('div');
|
||||
pctDiv.className = 'mt-summary-pct';
|
||||
pctDiv.textContent = pct + '% pass rate';
|
||||
summaryBody.appendChild(pctDiv);
|
||||
|
||||
const statsContainer = document.createElement('div');
|
||||
statsContainer.style.marginTop = '16px';
|
||||
|
||||
const createRow = (label, value, colorVar) => {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'mt-summary-row';
|
||||
const labelSpan = document.createElement('span');
|
||||
labelSpan.textContent = label;
|
||||
const valSpan = document.createElement('span');
|
||||
valSpan.style.color = 'var(--' + colorVar + ')';
|
||||
valSpan.style.fontWeight = '700';
|
||||
valSpan.textContent = value;
|
||||
row.appendChild(labelSpan);
|
||||
row.appendChild(valSpan);
|
||||
return row;
|
||||
};
|
||||
|
||||
statsContainer.appendChild(createRow('PASSED', passed, 'green'));
|
||||
statsContainer.appendChild(createRow('FAILED', failed, 'red'));
|
||||
statsContainer.appendChild(createRow('SKIPPED', skipped, 'amber'));
|
||||
summaryBody.appendChild(statsContainer);
|
||||
|
||||
const statusMsg = document.createElement('p');
|
||||
statusMsg.style.marginTop = '12px';
|
||||
statusMsg.style.fontSize = '11px';
|
||||
if (failed > 0) {
|
||||
statusMsg.style.color = 'var(--red)';
|
||||
statusMsg.textContent = '⚠ ' + failed + ' failure(s) need attention before release.';
|
||||
} else {
|
||||
statusMsg.style.color = 'var(--green)';
|
||||
statusMsg.textContent = 'All tested scenarios passed — ship it.';
|
||||
}
|
||||
summaryBody.appendChild(statusMsg);
|
||||
}
|
||||
|
||||
function resetAll() {
|
||||
if (!confirm("Reset all test results?")) return;
|
||||
sessionStorage.removeItem(KEY);
|
||||
const results = {};
|
||||
document.querySelectorAll("[data-scenario]").forEach(card => {
|
||||
const id = card.dataset.scenario;
|
||||
applyState(id, null);
|
||||
});
|
||||
updateScore(results);
|
||||
|
||||
const summaryBody = document.getElementById("summary-body");
|
||||
summaryBody.innerHTML = '';
|
||||
const p = document.createElement('p');
|
||||
p.className = 'mt-summary-hint';
|
||||
p.textContent = 'Mark all scenarios above to see your final score.';
|
||||
summaryBody.appendChild(p);
|
||||
}
|
||||
|
||||
// Restore saved state on load
|
||||
(function init() {
|
||||
const results = loadResults();
|
||||
Object.entries(results).forEach(([id, state]) => applyState(id, state));
|
||||
updateScore(results);
|
||||
updateSummary(results);
|
||||
})();
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Tests for dashboard/routes/voice_enhanced.py — enhanced voice processing."""
|
||||
"""Tests for enhanced voice processing (merged into dashboard/routes/voice.py)."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
@@ -56,7 +56,7 @@ class TestVoiceEnhancedProcess:
|
||||
mock_run.content = "Hello from Timmy!"
|
||||
mock_agent.run.return_value = mock_run
|
||||
|
||||
with patch("dashboard.routes.voice_enhanced.create_timmy", return_value=mock_agent):
|
||||
with patch("dashboard.routes.voice.create_timmy", return_value=mock_agent):
|
||||
resp = client.post(
|
||||
"/voice/enhanced/process",
|
||||
data={"text": "tell me about Bitcoin", "speak_response": "false"},
|
||||
@@ -69,7 +69,7 @@ class TestVoiceEnhancedProcess:
|
||||
def test_chat_fallback_error_handling(self, client):
|
||||
"""When the agent raises, the error should be captured gracefully."""
|
||||
with patch(
|
||||
"dashboard.routes.voice_enhanced.create_timmy",
|
||||
"dashboard.routes.voice.create_timmy",
|
||||
side_effect=RuntimeError("Ollama offline"),
|
||||
):
|
||||
resp = client.post(
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
"""Regression tests for XSS prevention in the dashboard."""
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
def test_mobile_test_page_xss_prevention(client: TestClient):
|
||||
"""
|
||||
Verify that the mobile-test page uses safer DOM manipulation.
|
||||
This test checks the template content for the presence of textContent
|
||||
and proper usage of innerHTML for known safe constants.
|
||||
"""
|
||||
response = client.get("/mobile-test")
|
||||
assert response.status_code == 200
|
||||
content = response.text
|
||||
|
||||
# Check that we are using textContent for dynamic content
|
||||
assert "textContent =" in content
|
||||
|
||||
# Check that we've updated the summaryBody.innerHTML usage to be safer
|
||||
# or replaced with appendChild/textContent where appropriate.
|
||||
# The fix uses innerHTML with template literals for structural parts
|
||||
# but textContent for data parts.
|
||||
assert "summaryBody.innerHTML = '';" in content
|
||||
assert "p.textContent =" in content
|
||||
assert "statusMsg.textContent =" in content
|
||||
Reference in New Issue
Block a user