diff --git a/CHANGELOG.md b/CHANGELOG.md index b56d7cbe..f2a8e0cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.9.4] - 2026-07-11 + +### Fixed + +- ⚡ **Your model choices are ready sooner.** Computer now gets them ready as it opens and keeps the last working list if a check fails. +- 👋 **More dependable restarts.** Computer now finishes its open work more reliably before it closes. ## [0.9.3] - 2026-07-11 ### Changed diff --git a/cptr/app.py b/cptr/app.py index de66f80e..a0421af6 100644 --- a/cptr/app.py +++ b/cptr/app.py @@ -1,4 +1,6 @@ +import asyncio import time +from contextlib import asynccontextmanager, suppress from pathlib import Path from fastapi import FastAPI, Request @@ -31,11 +33,10 @@ from cptr.utils.db import init_db START_TIME = time.time() -app = FastAPI() -@app.on_event("startup") -async def startup(): +@asynccontextmanager +async def lifespan(app: FastAPI): from cptr.utils.logger import setup_logging setup_logging() @@ -49,6 +50,7 @@ async def startup(): _logging.getLogger(__name__).info("truststore: using system certificate store") await init_db() + app.state.MODELS = {} from cptr.env import STARTUP_TOKEN app.state.startup_token = STARTUP_TOKEN @@ -60,50 +62,62 @@ async def startup(): await reconcile_chat_state() + from cptr.routers.chat import warm_model_cache + + await warm_model_cache(app.state) + # Start automation scheduler - import asyncio from cptr.utils.automations import scheduler_worker_loop - asyncio.create_task(scheduler_worker_loop(app)) + app.state.scheduler_task = asyncio.create_task(scheduler_worker_loop(app)) # Start messaging bots from cptr.utils.bridge import BotManager app.state.bot_manager = BotManager() - asyncio.create_task(app.state.bot_manager.start_all()) - - -@app.on_event("shutdown") -async def shutdown(): - bot_manager = getattr(app.state, "bot_manager", None) - if bot_manager: - await bot_manager.stop_all() - try: - from cptr.utils.async_subagents import cancel_all_async_subagents + await app.state.bot_manager.start_all() - await cancel_all_async_subagents(reason="shutdown") - except Exception: - pass - # Clean up browser sessions and launched Chrome used by agent tools. - try: - from cptr.utils.browser.session import session_manager - from cptr.utils.browser.launcher import shutdown_browser - from cptr.utils.browser.proxy import manager as browser_proxy_manager - from cptr.utils.browser.viewer import manager as chrome_viewer_manager - - await chrome_viewer_manager.close_all() - await browser_proxy_manager.close_all() - await session_manager.close_all() - await shutdown_browser() - except Exception: - pass - # Clean up stdio MCP server processes try: - from cptr.utils.mcp.stdio_manager import stdio_manager - - await stdio_manager.disconnect_all() - except Exception: - pass + yield + finally: + scheduler_task = getattr(app.state, "scheduler_task", None) + if scheduler_task: + scheduler_task.cancel() + with suppress(asyncio.CancelledError): + await scheduler_task + + bot_manager = getattr(app.state, "bot_manager", None) + if bot_manager: + await bot_manager.stop_all() + try: + from cptr.utils.async_subagents import cancel_all_async_subagents + + await cancel_all_async_subagents(reason="shutdown") + except Exception: + pass + # Clean up browser sessions and launched Chrome used by agent tools. + try: + from cptr.utils.browser.session import session_manager + from cptr.utils.browser.launcher import shutdown_browser + from cptr.utils.browser.proxy import manager as browser_proxy_manager + from cptr.utils.browser.viewer import manager as chrome_viewer_manager + + await chrome_viewer_manager.close_all() + await browser_proxy_manager.close_all() + await session_manager.close_all() + await shutdown_browser() + except Exception: + pass + # Clean up stdio MCP server processes + try: + from cptr.utils.mcp.stdio_manager import stdio_manager + + await stdio_manager.disconnect_all() + except Exception: + pass + + +app = FastAPI(lifespan=lifespan) # Auth middleware diff --git a/cptr/frontend/src/lib/i18n/locales/de.json b/cptr/frontend/src/lib/i18n/locales/de.json index 3910d416..dc866151 100644 --- a/cptr/frontend/src/lib/i18n/locales/de.json +++ b/cptr/frontend/src/lib/i18n/locales/de.json @@ -17,7 +17,7 @@ "home.greeting.evening.2": "Gute Zeit, etwas abzuschließen, {{name}}.", "home.greeting.night.0": "Nachtschicht, {{name}}?", "home.greeting.night.1": "Der Tag ist noch nicht vorbei, {{name}}.", - "home.greeting.night.2": "Noch eine Sache, {{name}}?", + "home.greeting.night.2": "Noch immer dran, {{name}}?", "home.greeting.lateNight.0": "Noch wach, {{name}}?", "home.greeting.lateNight.1": "Die Nachteule ist wieder da, {{name}}.", "home.greeting.lateNight.2": "Speichere deine Arbeit, {{name}}.", diff --git a/cptr/frontend/src/lib/i18n/locales/en.json b/cptr/frontend/src/lib/i18n/locales/en.json index 9ac16386..ce65fc12 100644 --- a/cptr/frontend/src/lib/i18n/locales/en.json +++ b/cptr/frontend/src/lib/i18n/locales/en.json @@ -17,7 +17,7 @@ "home.greeting.evening.2": "A good time to finish something, {{name}}.", "home.greeting.night.0": "Night shift, {{name}}?", "home.greeting.night.1": "The day is not over yet, {{name}}.", - "home.greeting.night.2": "One more thing, {{name}}?", + "home.greeting.night.2": "Still going, {{name}}?", "home.greeting.lateNight.0": "Still here, {{name}}?", "home.greeting.lateNight.1": "The night owl is back, {{name}}.", "home.greeting.lateNight.2": "Save your work, {{name}}.", diff --git a/cptr/frontend/src/lib/i18n/locales/es.json b/cptr/frontend/src/lib/i18n/locales/es.json index 641eb36a..2632fba0 100644 --- a/cptr/frontend/src/lib/i18n/locales/es.json +++ b/cptr/frontend/src/lib/i18n/locales/es.json @@ -17,7 +17,7 @@ "home.greeting.evening.2": "Buen momento para terminar algo, {{name}}.", "home.greeting.night.0": "Turno de noche, {{name}}?", "home.greeting.night.1": "El día aún no termina, {{name}}.", - "home.greeting.night.2": "Una cosa más, {{name}}?", + "home.greeting.night.2": "¿Sigues con ello, {{name}}?", "home.greeting.lateNight.0": "Sigues aquí, {{name}}?", "home.greeting.lateNight.1": "El búho nocturno volvió, {{name}}.", "home.greeting.lateNight.2": "Guarda tu trabajo, {{name}}.", diff --git a/cptr/frontend/src/lib/i18n/locales/fr.json b/cptr/frontend/src/lib/i18n/locales/fr.json index 4afdbf97..d37e12a7 100644 --- a/cptr/frontend/src/lib/i18n/locales/fr.json +++ b/cptr/frontend/src/lib/i18n/locales/fr.json @@ -17,7 +17,7 @@ "home.greeting.evening.2": "Bon moment pour finir quelque chose, {{name}}.", "home.greeting.night.0": "Service de nuit, {{name}} ?", "home.greeting.night.1": "La journée n'est pas finie, {{name}}.", - "home.greeting.night.2": "Encore une chose, {{name}} ?", + "home.greeting.night.2": "Toujours à l’œuvre, {{name}} ?", "home.greeting.lateNight.0": "Toujours là, {{name}} ?", "home.greeting.lateNight.1": "Le hibou de nuit est de retour, {{name}}.", "home.greeting.lateNight.2": "Sauvegarde ton travail, {{name}}.", diff --git a/cptr/frontend/src/lib/i18n/locales/ja.json b/cptr/frontend/src/lib/i18n/locales/ja.json index 7f2e5439..ee82f822 100644 --- a/cptr/frontend/src/lib/i18n/locales/ja.json +++ b/cptr/frontend/src/lib/i18n/locales/ja.json @@ -17,7 +17,7 @@ "home.greeting.evening.2": "何かを終えるのにいい時間です、{{name}}さん。", "home.greeting.night.0": "夜の時間ですね、{{name}}さん?", "home.greeting.night.1": "まだ一日は終わっていません、{{name}}さん。", - "home.greeting.night.2": "あと一つだけ、{{name}}さん?", + "home.greeting.night.2": "まだ続けてるんですか、{{name}}さん?", "home.greeting.lateNight.0": "まだ起きてますか、{{name}}さん?", "home.greeting.lateNight.1": "夜型ですね、{{name}}さん。", "home.greeting.lateNight.2": "保存を忘れずに、{{name}}さん。", diff --git a/cptr/frontend/src/lib/i18n/locales/ko.json b/cptr/frontend/src/lib/i18n/locales/ko.json index 0b691278..b102a768 100644 --- a/cptr/frontend/src/lib/i18n/locales/ko.json +++ b/cptr/frontend/src/lib/i18n/locales/ko.json @@ -17,7 +17,7 @@ "home.greeting.evening.2": "무언가 마무리하기 좋은 때예요, {{name}}님.", "home.greeting.night.0": "밤 시간이에요, {{name}}님?", "home.greeting.night.1": "아직 하루가 끝나지 않았어요, {{name}}님.", - "home.greeting.night.2": "한 가지만 더, {{name}}님?", + "home.greeting.night.2": "아직도 하고 계시네요, {{name}}님?", "home.greeting.lateNight.0": "아직 여기 있네요, {{name}}님?", "home.greeting.lateNight.1": "밤 올빼미군요, {{name}}님.", "home.greeting.lateNight.2": "저장하는 것 잊지 마세요, {{name}}님.", diff --git a/cptr/frontend/src/lib/i18n/locales/pt-BR.json b/cptr/frontend/src/lib/i18n/locales/pt-BR.json index eaf015b3..18d53400 100644 --- a/cptr/frontend/src/lib/i18n/locales/pt-BR.json +++ b/cptr/frontend/src/lib/i18n/locales/pt-BR.json @@ -17,7 +17,7 @@ "home.greeting.evening.2": "Boa hora para terminar algo, {{name}}.", "home.greeting.night.0": "Turno da noite, {{name}}?", "home.greeting.night.1": "O dia ainda não acabou, {{name}}.", - "home.greeting.night.2": "Só mais uma coisa, {{name}}?", + "home.greeting.night.2": "Ainda nessa, {{name}}?", "home.greeting.lateNight.0": "Ainda por aqui, {{name}}?", "home.greeting.lateNight.1": "A coruja noturna apareceu, {{name}}.", "home.greeting.lateNight.2": "Não esqueça de salvar, {{name}}.", diff --git a/cptr/frontend/src/lib/i18n/locales/ru.json b/cptr/frontend/src/lib/i18n/locales/ru.json index 97841226..2e224d51 100644 --- a/cptr/frontend/src/lib/i18n/locales/ru.json +++ b/cptr/frontend/src/lib/i18n/locales/ru.json @@ -17,7 +17,7 @@ "home.greeting.evening.2": "Хорошее время что-то закончить, {{name}}.", "home.greeting.night.0": "Ночная смена, {{name}}?", "home.greeting.night.1": "День ещё не закончился, {{name}}.", - "home.greeting.night.2": "Ещё одно дело, {{name}}?", + "home.greeting.night.2": "Всё ещё в деле, {{name}}?", "home.greeting.lateNight.0": "Всё ещё здесь, {{name}}?", "home.greeting.lateNight.1": "Ночная сова снова здесь, {{name}}.", "home.greeting.lateNight.2": "Не забудьте сохранить, {{name}}.", diff --git a/cptr/frontend/src/lib/i18n/locales/zh-CN.json b/cptr/frontend/src/lib/i18n/locales/zh-CN.json index 503a3969..becfa61d 100644 --- a/cptr/frontend/src/lib/i18n/locales/zh-CN.json +++ b/cptr/frontend/src/lib/i18n/locales/zh-CN.json @@ -17,7 +17,7 @@ "home.greeting.evening.2": "是完成一件事的好时候,{{name}}。", "home.greeting.night.0": "夜间模式,{{name}}?", "home.greeting.night.1": "这一天还没结束,{{name}}。", - "home.greeting.night.2": "再做一件事,{{name}}?", + "home.greeting.night.2": "还在忙吗,{{name}}?", "home.greeting.lateNight.0": "还在这里,{{name}}?", "home.greeting.lateNight.1": "夜猫子来了,{{name}}。", "home.greeting.lateNight.2": "别忘了保存,{{name}}。", diff --git a/cptr/frontend/src/lib/i18n/locales/zh-TW.json b/cptr/frontend/src/lib/i18n/locales/zh-TW.json index badef045..6a489408 100644 --- a/cptr/frontend/src/lib/i18n/locales/zh-TW.json +++ b/cptr/frontend/src/lib/i18n/locales/zh-TW.json @@ -17,7 +17,7 @@ "home.greeting.evening.2": "是完成一件事的好時候,{{name}}。", "home.greeting.night.0": "夜間模式,{{name}}?", "home.greeting.night.1": "這一天還沒結束,{{name}}。", - "home.greeting.night.2": "再做一件事,{{name}}?", + "home.greeting.night.2": "還在忙嗎,{{name}}?", "home.greeting.lateNight.0": "還在這裡,{{name}}?", "home.greeting.lateNight.1": "夜貓子來了,{{name}}。", "home.greeting.lateNight.2": "別忘了儲存,{{name}}。", diff --git a/cptr/routers/chat.py b/cptr/routers/chat.py index c2a7c2c5..8017f27e 100644 --- a/cptr/routers/chat.py +++ b/cptr/routers/chat.py @@ -243,8 +243,21 @@ async def _get_connection_models(conn: dict, app_state) -> list[str]: return cache[conn_id] models = await _fetch_provider_models(conn) - cache[conn_id] = models - return models + if models is not None: + cache[conn_id] = models + return models + return cache.get(conn_id, []) + + +async def warm_model_cache(app_state) -> None: + """Pre-fetch chat model sources before the application accepts requests.""" + connections = [c for c in await _get_connections() if c.get("enabled", True)] + tasks = [_get_connection_models(conn, app_state) for conn in connections] + + from cptr.utils.agents.detection import get_available_agent_model_entries + + tasks.append(get_available_agent_model_entries(app_state)) + await asyncio.gather(*tasks) def invalidate_model_cache(app_state): @@ -294,18 +307,18 @@ async def get_models(request: Request): return {"models": models, "default": default_model} -async def _fetch_provider_models(conn: dict) -> list[str]: +async def _fetch_provider_models(conn: dict) -> list[str] | None: """Discover models from a provider's /models endpoint.""" import httpx from cptr.utils.ai import _openrouter_headers - secret = _get_jwt_secret() - api_key = decrypt_key(conn.get("api_key", ""), secret) if conn.get("api_key") else None - provider = conn.get("provider", "") - base_url = conn.get("base_url") - try: + secret = _get_jwt_secret() + api_key = decrypt_key(conn.get("api_key", ""), secret) if conn.get("api_key") else None + provider = conn.get("provider", "") + base_url = conn.get("base_url") + if provider == "anthropic": url = (base_url or "https://api.anthropic.com/v1") + "/models" async with httpx.AsyncClient(timeout=10) as client: @@ -357,7 +370,7 @@ async def _fetch_provider_models(conn: dict) -> list[str]: except Exception: log.exception("Model auto-discovery error for connection %s", conn.get("id", "?")) - return [] + return None # ── Get a chat with all messages ──────────────────────────── diff --git a/pyproject.toml b/pyproject.toml index 17f686fb..66fbf3fa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "cptr" -version = "0.9.3" +version = "0.9.4" description = "Your computer, from anywhere. Code, manage, and control your machine from the web." license = {file = "LICENSE"} readme = "README.md" diff --git a/uv.lock b/uv.lock index d5ecf6ed..44e3c0af 100644 --- a/uv.lock +++ b/uv.lock @@ -284,7 +284,7 @@ wheels = [ [[package]] name = "cptr" -version = "0.9.3" +version = "0.9.4" source = { editable = "." } dependencies = [ { name = "aiosqlite" },