forked from Rockachopa/Timmy-time-dashboard
feat: single-command Docker startup, fix UI bugs, add Selenium tests
- Add `make up` / `make up DEV=1` for one-command Docker startup with optional hot-reload via docker-compose.dev.yml overlay - Add `timmy up --dev` / `timmy down` CLI commands - Fix cross-platform font resolution in creative assembler (7 test failures) - Fix Ollama host URL not passed to Agno model (container connectivity) - Fix task panel route shadowing by reordering literal routes before parameterized routes in swarm.py - Fix chat input not clearing after send (hx-on::after-request) - Fix chat scroll overflow (CSS min-height: 0 on flex children) - Add Selenium UI smoke tests (17 tests, gated behind SELENIUM_UI=1) - Install fonts-dejavu-core in Dockerfile for container font support - Remove obsolete docker-compose version key - Bump CSS cache-bust to v4 833 unit tests pass, 15 Selenium tests pass (2 skipped). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -28,8 +28,26 @@ try:
|
||||
except ImportError:
|
||||
_MOVIEPY_AVAILABLE = False
|
||||
|
||||
# Resolve a font that actually exists on this system.
|
||||
_DEFAULT_FONT = "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"
|
||||
def _resolve_font() -> str:
|
||||
"""Find a usable TrueType font on the current platform."""
|
||||
candidates = [
|
||||
# Linux (Debian/Ubuntu)
|
||||
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
||||
"/usr/share/fonts/TTF/DejaVuSans.ttf", # Arch
|
||||
"/usr/share/fonts/dejavu-sans-fonts/DejaVuSans.ttf", # Fedora
|
||||
# macOS
|
||||
"/System/Library/Fonts/Supplemental/Arial.ttf",
|
||||
"/System/Library/Fonts/Helvetica.ttc",
|
||||
"/Library/Fonts/Arial.ttf",
|
||||
]
|
||||
for path in candidates:
|
||||
if Path(path).exists():
|
||||
return path
|
||||
logger.warning("No system TrueType font found; using Pillow default")
|
||||
return "Helvetica"
|
||||
|
||||
|
||||
_DEFAULT_FONT = _resolve_font()
|
||||
|
||||
|
||||
def _require_moviepy() -> None:
|
||||
|
||||
@@ -114,6 +114,52 @@ async def post_task_and_auction(description: str = Form(...)):
|
||||
}
|
||||
|
||||
|
||||
@router.get("/tasks/panel", response_class=HTMLResponse)
|
||||
async def task_create_panel(request: Request, agent_id: Optional[str] = None):
|
||||
"""Task creation panel, optionally pre-selecting an agent."""
|
||||
agents = coordinator.list_swarm_agents()
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"partials/task_assign_panel.html",
|
||||
{"agents": agents, "preselected_agent_id": agent_id},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/tasks/direct", response_class=HTMLResponse)
|
||||
async def direct_assign_task(
|
||||
request: Request,
|
||||
description: str = Form(...),
|
||||
agent_id: Optional[str] = Form(None),
|
||||
):
|
||||
"""Create a task: assign directly if agent_id given, else open auction."""
|
||||
timestamp = datetime.now(timezone.utc).strftime("%H:%M:%S")
|
||||
|
||||
if agent_id:
|
||||
agent = registry.get_agent(agent_id)
|
||||
task = coordinator.post_task(description)
|
||||
coordinator.auctions.open_auction(task.id)
|
||||
coordinator.auctions.submit_bid(task.id, agent_id, 1)
|
||||
coordinator.auctions.close_auction(task.id)
|
||||
update_task(task.id, status=TaskStatus.ASSIGNED, assigned_agent=agent_id)
|
||||
registry.update_status(agent_id, "busy")
|
||||
agent_name = agent.name if agent else agent_id
|
||||
else:
|
||||
task = coordinator.post_task(description)
|
||||
winner = await coordinator.run_auction_and_assign(task.id)
|
||||
task = coordinator.get_task(task.id)
|
||||
agent_name = winner.agent_id if winner else "unassigned"
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"partials/task_result.html",
|
||||
{
|
||||
"task": task,
|
||||
"agent_name": agent_name,
|
||||
"timestamp": timestamp,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/tasks/{task_id}")
|
||||
async def get_task(task_id: str):
|
||||
"""Get details for a specific task."""
|
||||
@@ -268,47 +314,3 @@ async def message_agent(agent_id: str, request: Request, message: str = Form(...
|
||||
)
|
||||
|
||||
|
||||
@router.get("/tasks/panel", response_class=HTMLResponse)
|
||||
async def task_create_panel(request: Request, agent_id: Optional[str] = None):
|
||||
"""Task creation panel, optionally pre-selecting an agent."""
|
||||
agents = coordinator.list_swarm_agents()
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"partials/task_assign_panel.html",
|
||||
{"agents": agents, "preselected_agent_id": agent_id},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/tasks/direct", response_class=HTMLResponse)
|
||||
async def direct_assign_task(
|
||||
request: Request,
|
||||
description: str = Form(...),
|
||||
agent_id: Optional[str] = Form(None),
|
||||
):
|
||||
"""Create a task: assign directly if agent_id given, else open auction."""
|
||||
timestamp = datetime.now(timezone.utc).strftime("%H:%M:%S")
|
||||
|
||||
if agent_id:
|
||||
agent = registry.get_agent(agent_id)
|
||||
task = coordinator.post_task(description)
|
||||
coordinator.auctions.open_auction(task.id)
|
||||
coordinator.auctions.submit_bid(task.id, agent_id, 1)
|
||||
coordinator.auctions.close_auction(task.id)
|
||||
update_task(task.id, status=TaskStatus.ASSIGNED, assigned_agent=agent_id)
|
||||
registry.update_status(agent_id, "busy")
|
||||
agent_name = agent.name if agent else agent_id
|
||||
else:
|
||||
task = coordinator.post_task(description)
|
||||
winner = await coordinator.run_auction_and_assign(task.id)
|
||||
task = coordinator.get_task(task.id)
|
||||
agent_name = winner.agent_id if winner else "unassigned"
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"partials/task_result.html",
|
||||
{
|
||||
"task": task,
|
||||
"agent_name": agent_name,
|
||||
"timestamp": timestamp,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
<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 href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous" />
|
||||
<link rel="stylesheet" href="/static/style.css?v=3" />
|
||||
<link rel="stylesheet" href="/static/style.css?v=4" />
|
||||
{% block extra_styles %}{% endblock %}
|
||||
<script src="https://unpkg.com/htmx.org@2.0.3" integrity="sha384-0895/pl2MU10Hqc6jd4RvrthNlDiE9U1tWmX7WRESftEDRosgxNsQG/Ze9YMRzHq" crossorigin="anonymous"></script>
|
||||
</head>
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
hx-swap="beforeend"
|
||||
hx-indicator="#agent-send-indicator"
|
||||
hx-disabled-elt="find button"
|
||||
hx-on::after-settle="this.reset(); scrollAgentLog('{{ agent.id }}')"
|
||||
hx-on::after-request="if(event.detail.successful){this.querySelector('[name=message]').value=''; scrollAgentLog('{{ agent.id }}')}"
|
||||
class="d-flex gap-2">
|
||||
<input type="text"
|
||||
name="message"
|
||||
|
||||
@@ -18,7 +18,8 @@
|
||||
<div class="chat-log flex-grow-1 overflow-auto p-3" id="chat-log"
|
||||
hx-get="/agents/timmy/history"
|
||||
hx-trigger="load"
|
||||
hx-swap="innerHTML"></div>
|
||||
hx-swap="innerHTML"
|
||||
hx-on::after-settle="scrollChat()"></div>
|
||||
|
||||
<div class="card-footer mc-chat-footer">
|
||||
<form hx-post="/agents/timmy/chat"
|
||||
@@ -27,7 +28,7 @@
|
||||
hx-indicator="#send-indicator"
|
||||
hx-sync="this:drop"
|
||||
hx-disabled-elt="find button"
|
||||
hx-on::after-settle="this.reset(); scrollChat()"
|
||||
hx-on::after-request="if(event.detail.successful){this.querySelector('[name=message]').value=''; scrollChat()}"
|
||||
class="d-flex gap-2">
|
||||
<input type="text"
|
||||
name="message"
|
||||
|
||||
@@ -68,7 +68,7 @@ def create_timmy(
|
||||
|
||||
return Agent(
|
||||
name="Timmy",
|
||||
model=Ollama(id=settings.ollama_model),
|
||||
model=Ollama(id=settings.ollama_model, host=settings.ollama_url),
|
||||
db=SqliteDb(db_file=db_file),
|
||||
description=TIMMY_SYSTEM_PROMPT,
|
||||
add_history_to_context=True,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import subprocess
|
||||
from typing import Optional
|
||||
|
||||
import typer
|
||||
@@ -54,5 +55,34 @@ def status(
|
||||
timmy.print_response(TIMMY_STATUS_PROMPT, stream=False)
|
||||
|
||||
|
||||
@app.command()
|
||||
def up(
|
||||
dev: bool = typer.Option(False, "--dev", help="Enable hot-reload for development"),
|
||||
build: bool = typer.Option(True, "--build/--no-build", help="Rebuild images before starting"),
|
||||
):
|
||||
"""Start Timmy Time in Docker (dashboard + agents)."""
|
||||
cmd = ["docker", "compose"]
|
||||
if dev:
|
||||
cmd += ["-f", "docker-compose.yml", "-f", "docker-compose.dev.yml"]
|
||||
cmd += ["up", "-d"]
|
||||
if build:
|
||||
cmd.append("--build")
|
||||
|
||||
mode = "dev mode (hot-reload active)" if dev else "production mode"
|
||||
typer.echo(f"Starting Timmy Time in {mode}...")
|
||||
result = subprocess.run(cmd)
|
||||
if result.returncode == 0:
|
||||
typer.echo(f"\n Timmy Time running at http://localhost:8000 ({mode})\n")
|
||||
else:
|
||||
typer.echo("Failed to start. Is Docker running?", err=True)
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
@app.command()
|
||||
def down():
|
||||
"""Stop all Timmy Time Docker containers."""
|
||||
subprocess.run(["docker", "compose", "down"], check=True)
|
||||
|
||||
|
||||
def main():
|
||||
app()
|
||||
|
||||
Reference in New Issue
Block a user