From 1b7307f506e1fe29289a6010a6691d66fc3546a3 Mon Sep 17 00:00:00 2001 From: Arietids Date: Tue, 17 Mar 2026 15:47:26 +0800 Subject: [PATCH 1/5] docs: define setup studio UX redesign --- .../2026-03-17-setup-studio-ux-design.md | 402 ++++++++++++++++++ 1 file changed, 402 insertions(+) create mode 100644 docs/superpowers/specs/2026-03-17-setup-studio-ux-design.md diff --git a/docs/superpowers/specs/2026-03-17-setup-studio-ux-design.md b/docs/superpowers/specs/2026-03-17-setup-studio-ux-design.md new file mode 100644 index 0000000..af9bb6a --- /dev/null +++ b/docs/superpowers/specs/2026-03-17-setup-studio-ux-design.md @@ -0,0 +1,402 @@ +# Setup Studio UX Redesign + +Date: 2026-03-17 +Status: Approved + +## Goal + +Redesign the setup studio so first-time Windows users can reach a correct, +working local setup with fewer mistakes and less confusion. + +The primary optimization target is not speed. It is error prevention: + +- make the next safe action obvious +- surface blockers before deeper configuration work +- reduce overwhelming walls of fields and raw output +- preserve access to advanced controls without making them the default path + +## Context + +The current setup studio already exposes the right backend capabilities, but the +frontend shape makes them harder to use safely: + +- the page presents prerequisites, full configuration, runtime checks, and a + first processing run with roughly equal weight on one screen +- diagnostics auto-run on page load, which creates output before the user has + context for what they are seeing +- configuration is shown as a large all-fields form instead of a guided, + beginner-safe workflow +- raw JSON and log-like output dominate the feedback model +- the current dark control-panel styling feels more like an expert dashboard + than a first-run setup companion + +The backend seams already align well with a guided setup flow: + +- `GET /setup/config` +- `GET /setup/diagnostics` +- `POST /setup/bootstrap` +- `GET /runtime` +- `POST /process` + +The redesign should reuse these surfaces instead of requiring a backend rewrite. + +## Chosen Approach + +Adopt a guided workbench layout with persistent progress, step-level guardrails, +and progressive disclosure. + +This approach was chosen over: + +1. a strict wizard, which would reduce mistakes well but add too much friction + for repeated use +2. a denser mission-control dashboard, which would preserve speed but keep too + much cognitive load for first-time users + +The guided workbench keeps the user oriented with a visible checklist while +focusing the main panel on one step at a time. + +## UX Principles + +- Beginner-first by default, expert-capable on demand +- Explain what matters before exposing every tuning knob +- Make status human-readable first, machine-readable second +- Prevent mistakes earlier instead of describing them after failure +- Keep progress visible and reversible +- Use an instructional tone instead of a dashboard tone + +## Information Architecture + +### Overall Layout + +Desktop layout uses two coordinated regions: + +1. a left-side progress rail +2. a right-side active step panel + +The left rail contains: + +- overall readiness summary +- current blocker summary +- four-step checklist with state indicators +- quick help and common-fix links + +The right panel contains the currently active step only: + +- step title and short explanation +- why the step matters +- required checks or inputs +- primary action area +- result summary and next action +- advanced details behind collapsible sections + +Mobile layout collapses this into a current-step-first view with progress and +the full checklist available behind an expander or sheet. + +Mobile uses responsive reflow, not a different product flow. The checklist, +blocker summaries, advanced details, and actions remain the same concepts with a +different presentation. + +### Step Order + +1. Install prerequisites +2. Build configuration +3. Verify runtime +4. Run first processing test + +This order matches the real dependency chain and prevents users from editing +deep runtime knobs before the environment is viable. + +### Progress Persistence + +The workbench does not introduce a durable server-side wizard session. + +- progress is a client-side view model +- configuration fields rehydrate from `GET /setup/config` on load +- prerequisite, runtime, and processing status are derived from the latest check + results collected in the current browser session +- after reload, steps return to an unverified state unless they can be derived + directly from current config payloads + +This keeps the UI honest about what has been verified while avoiding new backend +state-management scope. + +## Step Design + +### 1. Install Prerequisites + +Primary source: `GET /setup/diagnostics` + +Purpose: + +- confirm Python, FFmpeg, FFprobe, yt-dlp, and Ollama availability +- show exact missing items before any deeper setup work + +Default presentation: + +- dependency cards with clear pass/fail state +- short explanation of why each dependency matters +- specific recovery guidance for failed checks +- explicit `Run checks` or `Re-check` action + +Not shown by default: + +- raw diagnostic payloads +- large unstructured output blocks + +### 2. Build Configuration + +Primary source: `GET /setup/config` + +Purpose: + +- let the user produce a correct starter configuration without facing the full + tuning surface immediately + +Default presentation: + +- basic mode for the small set of fields that matter first +- advanced mode for VRAM budgeting, thresholds, retention, and similar tuning +- generated `.env` preview as an output artifact +- copy actions for `.env` and quick commands + +Basic mode should prioritize: + +- bind address +- storage directory +- Ollama URL +- model priority + +RSS title, link, and description should live in advanced mode with their current +defaults preserved. They are useful for publishing setup, but not necessary for +first-run validation. + +Advanced mode should contain: + +- budget ratio +- reserve amount +- frame extraction thresholds +- transcript and summary retention +- token and output tuning + +The full `.env` block remains available, but it should no longer be the primary +editing model. + +### 3. Verify Runtime + +Primary sources: + +- `GET /runtime` +- `POST /setup/bootstrap` + +Purpose: + +- prove the configured runtime is reachable and ready to summarize + +Default presentation: + +- connection status in plain language +- whether Ollama is reachable +- visible local-model readiness +- missing-model explanation before bootstrap +- bootstrap progress and completion state + +Advanced runtime payload details can remain accessible through a disclosure, but +the first screen should answer a simple question: can this system actually run? + +### 4. Run First Processing Test + +Primary source: `POST /process` + +Purpose: + +- give the user a confidence-building proof that the full pipeline works end to + end + +Default presentation: + +- sample source prefilled with the current public default already used by the UI: + `https://www.youtube.com/watch?v=dQw4w9WgXcQ` +- optional title override +- expectation setting that processing may take time +- a success summary emphasizing outcome, not raw response shape + +The sample source remains editable. If the default source is unavailable or the +request fails, the UI keeps the step in a failed state, preserves the entered +value, and suggests retrying with a different URL or local file path. + +The first success view should highlight: + +- processed title +- transcript size +- frame count +- model used +- generated summary highlights + +Raw response details should remain available behind a details section. + +## Guardrail Model + +Each step uses a small set of explicit states: + +- `unverified` +- `blocked` +- `ready` +- `running` +- `failed` +- `complete` + +Rules: + +- diagnostics do not auto-run on initial page load +- page load initializes prerequisite, runtime, and processing steps as + `unverified` until the user runs them +- steps that depend on unmet prerequisites stay visibly blocked +- blocked steps explain both the problem and the exact next fix +- failed steps preserve the most recent meaningful result, show recovery + actions, and allow retry without clearing user-entered inputs +- completing one step highlights the recommended next step +- completed steps remain editable without losing overall orientation +- advanced controls are available but collapsed by default + +This model changes the interface from an output-heavy control panel into a +guided state machine. + +## Frontend Units and Boundaries + +The implementation plan should decompose the setup studio into a small set of +clear frontend units even if the first pass remains in the existing template, +CSS, and JS files. + +Suggested units: + +- `setup shell`: template structure for the progress rail, active step panel, + helper sections, and mobile variants +- `step state controller`: client-side state machine that owns transitions, + gating rules, and current-step selection +- `setup API client`: thin wrappers around the existing setup/runtime/process + endpoints +- `view-model mappers`: functions that convert raw diagnostics, runtime, and + process payloads into human-readable summaries and blocker/fix models +- `step renderers`: focused DOM update logic for each step's summary, actions, + and detail sections +- `adapter regression tests`: tests that verify structure, state-driven + behavior, and critical content without depending on visual styling details + +Boundaries: + +- API client functions should not decide UX copy or state transitions +- view-model mappers should not manipulate the DOM directly +- the state controller should consume mapped results and decide whether a step + is unverified, blocked, ready, failed, running, or complete +- DOM renderers should be replaceable without changing endpoint integration + +This gives the planner independently understandable and testable units without +forcing an unnecessary framework migration. + +## Content and Messaging Strategy + +Every step should answer the same questions in the same order: + +1. what are we checking or configuring? +2. why does it matter? +3. what should the user do now? +4. what happened? +5. what comes next? + +Messaging style should be: + +- direct +- plain-language +- specific about recovery steps +- calm rather than alarmist +- instructional rather than overly technical + +Raw JSON, full diagnostic objects, and other machine-shaped data should move to +secondary detail views. + +## Visual Direction + +Use the approved `Workshop Manual` direction. + +Characteristics: + +- warm neutral palette rather than dark glassmorphism +- supportive, trust-building visual tone +- stronger reading comfort for first-run guidance +- status colors used sparingly and intentionally +- interface that feels instructional and grounded instead of like a mission + control board + +This direction should replace the current heavy dark background and purple-led +accent language in `video_rss_aggregator/static/setup.css`. + +## Data and Interaction Mapping + +The redesign should preserve the current backend contract and remap the frontend +around it. + +- load configuration defaults from `GET /setup/config` +- run dependency checks only when the user starts or repeats them via + `GET /setup/diagnostics` +- check runtime readiness via `GET /runtime` +- bootstrap models via `POST /setup/bootstrap` +- run the first validation job via `POST /process` + +Allowed backend changes are intentionally narrow: + +- no new workflow endpoints for the redesign's first implementation pass +- optional response-shaping additions to existing setup/runtime endpoints if the + frontend needs clearer view-model fields +- no changes to application-use-case responsibilities beyond exposing clearer + adapter-facing payloads + +If response shaping is needed, it should happen at the FastAPI adapter boundary +or in lightweight view-model helpers, not by expanding core domain or workflow +scope. + +## Error Handling + +- Show blocker summaries inline at the step level +- Pair failures with actionable fixes, not just error text +- Keep raw error details available in expandable sections +- Avoid presenting partial or failed operations as generic success states +- Preserve user-entered configuration while retrying checks or actions +- Keep the last successful summary visible when a later retry fails, clearly + labeled as stale if needed + +## Testing Strategy + +Update the setup studio regression net to validate the new guided experience. + +Key coverage areas: + +- setup page structure reflects the step-based workbench +- beginner-first content appears before advanced controls +- diagnostics are not auto-triggered on page load +- `.env` generation still reflects edited configuration values +- runtime and processing results render human-readable summaries +- advanced details remain available for debugging without dominating the default + view + +Tests should continue to protect the existing setup/runtime/process API +contracts, while adapter-facing UI tests evolve to reflect the new structure. + +## Non-Goals + +- redesigning the underlying runtime architecture +- adding new core setup endpoints unless view-model gaps make them necessary +- building a multi-page onboarding flow disconnected from the current server UI +- optimizing primarily for expert repeat use at the expense of first-run safety + +## Expected Benefits + +- fewer first-run setup mistakes +- clearer progression from install to successful processing +- less intimidation from the configuration surface +- improved trust in runtime readiness and failure recovery +- a more distinctive and intentional setup experience + +## Next Step + +Create an implementation plan for the setup studio redesign, staged around +template structure, interaction model, visual system, and regression updates. From e334038eef9a63e093e0c1b07a15606bf722ce8a Mon Sep 17 00:00:00 2001 From: Arietids Date: Tue, 17 Mar 2026 18:40:55 +0800 Subject: [PATCH 2/5] feat: redesign setup studio workflow Guide first-time setup through prerequisites, configuration, runtime validation, and a first processing run so blockers and recovery steps are clear. --- tests/adapters/test_api_app.py | 16 +- tests/test_api_setup.py | 34 +- tests/test_setup_studio_contract.py | 255 ++++++ tests/test_setup_view_models.py | 166 ++++ video_rss_aggregator/api.py | 14 +- video_rss_aggregator/setup_view_models.py | 142 +++ video_rss_aggregator/static/setup.css | 837 ++++++++++-------- video_rss_aggregator/static/setup.js | 404 ++++----- video_rss_aggregator/static/setup_api.js | 55 ++ video_rss_aggregator/static/setup_state.js | 216 +++++ .../static/setup_view_models.js | 164 ++++ video_rss_aggregator/static/setup_views.js | 405 +++++++++ video_rss_aggregator/templates/setup.html | 230 +++-- 13 files changed, 2206 insertions(+), 732 deletions(-) create mode 100644 tests/test_setup_studio_contract.py create mode 100644 tests/test_setup_view_models.py create mode 100644 video_rss_aggregator/setup_view_models.py create mode 100644 video_rss_aggregator/static/setup_api.js create mode 100644 video_rss_aggregator/static/setup_state.js create mode 100644 video_rss_aggregator/static/setup_view_models.js create mode 100644 video_rss_aggregator/static/setup_views.js diff --git a/tests/adapters/test_api_app.py b/tests/adapters/test_api_app.py index c6cbc25..70e2df7 100644 --- a/tests/adapters/test_api_app.py +++ b/tests/adapters/test_api_app.py @@ -102,6 +102,8 @@ def test_routes_delegate_to_runtime_use_cases_and_keep_http_shapes() -> None: rss = client.get("/rss?limit=5") runtime_response = client.get("/runtime") bootstrap = client.post("/setup/bootstrap") + runtime_payload = runtime_response.json() + bootstrap_payload = bootstrap.json() assert ingest.status_code == 200 assert ingest.json() == { @@ -127,16 +129,26 @@ def test_routes_delegate_to_runtime_use_cases_and_keep_http_shapes() -> None: assert rss.status_code == 200 assert rss.text == "feed" assert runtime_response.status_code == 200 - assert runtime_response.json() == { + assert runtime_payload == { "ollama_version": "0.6.0", "local_models": {"qwen": {"size": 1}}, "reachable": True, "database_path": ".data/runtime.db", "storage_dir": ".data", "models": ["qwen", "qwen:min"], + "setup_view": { + "state": "blocked", + "missing_models": ["qwen:min"], + "next_action": "Bootstrap required models", + }, + } + assert runtime_payload["setup_view"] == { + "state": "blocked", + "missing_models": ["qwen:min"], + "next_action": "Bootstrap required models", } assert bootstrap.status_code == 200 - assert bootstrap.json() == { + assert bootstrap_payload == { "models_prepared": ["qwen"], "runtime": { "ollama_version": "0.6.0", diff --git a/tests/test_api_setup.py b/tests/test_api_setup.py index 70ca047..0d4d2fd 100644 --- a/tests/test_api_setup.py +++ b/tests/test_api_setup.py @@ -57,13 +57,15 @@ def test_gui_and_setup_routes() -> None: css = client.get("/static/setup.css") assert css.status_code == 200 - assert "--accent" in css.text + assert ".shell" in css.text js = client.get("/static/setup.js") assert js.status_code == 200 - assert "runDiagnostics" in js.text - assert "API_KEY=${fields.apiKey.value.trim()}" not in js.text - assert "const apiKey = fields.apiKey.value.trim();" in js.text + assert js.headers["content-type"].startswith("text/javascript") + + setup_state = client.get("/static/setup_state.js") + assert setup_state.status_code == 200 + assert setup_state.headers["content-type"].startswith("text/javascript") setup = client.get("/setup/config") assert setup.status_code == 200 @@ -82,6 +84,17 @@ def test_gui_and_setup_routes() -> None: diagnostics = client.get("/setup/diagnostics") assert diagnostics.status_code == 200 diag_payload = diagnostics.json() + setup_view = diag_payload["setup_view"] + assert set(setup_view) >= {"state", "checks", "blockers", "next_action"} + assert setup_view["state"] in {"ready", "blocked"} + assert isinstance(setup_view["checks"], list) + assert {check["id"] for check in setup_view["checks"]} >= { + "python", + "ffmpeg", + "ffprobe", + "yt_dlp", + "ollama", + } assert "platform" in diag_payload assert "dependencies" in diag_payload assert "ready" in diag_payload @@ -102,18 +115,7 @@ def test_runtime_requires_api_key_when_enabled() -> None: authorized = client.get("/runtime", headers={"X-API-Key": "secret"}) assert authorized.status_code == 200 - assert authorized.json() == { - "ollama_version": "0.6.0", - "local_models": {"qwen3.5:2b-q4_K_M": {}}, - "reachable": True, - "database_path": ".data/vra.db", - "storage_dir": ".data", - "models": [ - "qwen3.5:4b-q4_K_M", - "qwen3.5:2b-q4_K_M", - "qwen3.5:0.8b-q8_0", - ], - } + assert authorized.json()["setup_view"]["state"] == "blocked" def test_setup_config_omits_api_key_and_uses_current_bind_in_commands() -> None: diff --git a/tests/test_setup_studio_contract.py b/tests/test_setup_studio_contract.py new file mode 100644 index 0000000..6b40f16 --- /dev/null +++ b/tests/test_setup_studio_contract.py @@ -0,0 +1,255 @@ +from __future__ import annotations + +import re +from dataclasses import dataclass +from typing import Any, cast + +from fastapi.testclient import TestClient + +from adapter_api import create_app +from core_config import Config +from video_rss_aggregator.bootstrap import AppRuntime, AppUseCases + + +@dataclass +class _AsyncValue: + value: Any + + async def execute(self, *_args, **_kwargs) -> Any: + return self.value + + +def _build_runtime(config: Config) -> AppRuntime: + runtime_status = { + "ollama_version": "0.6.0", + "local_models": {"qwen3.5:2b-q4_K_M": {}}, + "reachable": True, + "database_path": config.database_path, + "storage_dir": config.storage_dir, + "models": list(config.model_priority), + } + return AppRuntime( + config=config, + use_cases=AppUseCases( + get_runtime_status=_AsyncValue(runtime_status), + bootstrap_runtime=_AsyncValue({"models": ["qwen3.5:2b-q4_K_M"]}), + ingest_feed=cast(Any, _AsyncValue(None)), + process_source=cast(Any, _AsyncValue(None)), + render_rss_feed=_AsyncValue(""), + ), + close=lambda: None, + ) + + +client = TestClient(create_app(_build_runtime(Config()))) + + +def class_tokens_for_id(html: str, element_id: str) -> set[str]: + tag_match = re.search(rf'<[^>]*\bid="{re.escape(element_id)}"[^>]*>', html) + assert tag_match is not None + + class_match = re.search(r'\bclass="([^"]+)"', tag_match.group(0)) + assert class_match is not None + + return set(class_match.group(1).split()) + + +def test_setup_css_supports_shell_and_disclosure_layout() -> None: + css = client.get("/static/setup.css") + + assert css.status_code == 200 + assert "--paper-base" in css.text + assert ".shell" in css.text + assert ".progress-rail" in css.text + assert ".detail-drawer" in css.text + assert ".summary-card" in css.text + assert "@media (max-width: 860px)" in css.text + assert '#setup-progress[data-mobile-open="false"]' in css.text + assert '#mobile-step-toggle[aria-expanded="true"]' in css.text + assert "#6366f1" not in css.text + + +def test_setup_home_renders_shell_and_collapsed_detail_drawers() -> None: + home = client.get("/") + workbench_classes = class_tokens_for_id(home.text, "setup-workbench") + progress_classes = class_tokens_for_id(home.text, "setup-progress") + prerequisites_panel_classes = class_tokens_for_id( + home.text, "step-panel-prerequisites" + ) + configuration_panel_classes = class_tokens_for_id( + home.text, "step-panel-configuration" + ) + runtime_panel_classes = class_tokens_for_id(home.text, "step-panel-runtime") + process_panel_classes = class_tokens_for_id(home.text, "step-panel-process") + + assert home.status_code == 200 + assert 'id="setup-workbench"' in home.text + assert 'id="setup-progress"' in home.text + assert 'id="readiness-summary"' in home.text + assert 'id="blocker-summary"' in home.text + assert 'id="common-fixes"' in home.text + assert 'data-step-id="prerequisites"' in home.text + assert 'data-step-id="configuration"' in home.text + assert 'data-step-id="runtime"' in home.text + assert 'data-step-id="process"' in home.text + assert "shell" in workbench_classes + assert "progress-rail" in progress_classes + assert {"step-panel", "card"} <= prerequisites_panel_classes + assert {"step-panel", "card", "wide"} <= configuration_panel_classes + assert {"step-panel", "card"} <= runtime_panel_classes + assert {"step-panel", "card"} <= process_panel_classes + assert 'id="mobile-step-toggle"' in home.text + assert 'aria-controls="setup-progress"' in home.text + assert 'aria-expanded="false"' in home.text + assert 'id="run-diagnostics"' in home.text + assert 'id="copy-env"' in home.text + assert 'id="runtime-check"' in home.text + assert 'id="bootstrap-models"' in home.text + assert 'id="process-run"' in home.text + assert 'id="runtime-summary"' in home.text + assert 'id="process-summary"' in home.text + assert 'id="advanced-config"' in home.text + assert 'id="runtime-details"' in home.text + assert 'id="process-details"' in home.text + assert re.search( + r']*id="advanced-config"(?![^>]*\bopen\b)[^>]*>', home.text + ) + assert re.search( + r']*id="runtime-details"(?![^>]*\bopen\b)[^>]*>', home.text + ) + assert re.search( + r']*id="process-details"(?![^>]*\bopen\b)[^>]*>', home.text + ) + + +def test_setup_assets_expose_module_and_top_level_contracts() -> None: + home = client.get("/") + entry_js = client.get("/static/setup.js") + api_js = client.get("/static/setup_api.js") + state_js = client.get("/static/setup_state.js") + view_models_js = client.get("/static/setup_view_models.js") + views_js = client.get("/static/setup_views.js") + + assert 'src="static/setup.js" type="module"' in home.text + + for response in (entry_js, api_js, state_js, view_models_js, views_js): + assert response.status_code == 200 + assert response.headers["content-type"].startswith("text/javascript") + + assert 'import { createSetupApi } from "./setup_api.js";' in entry_js.text + assert 'import { createSetupState } from "./setup_state.js";' in entry_js.text + assert ( + 'import { buildShellSummaryView } from "./setup_view_models.js";' + in entry_js.text + ) + assert ( + 'import { buildProcessSummaryView } from "./setup_view_models.js";' + in entry_js.text + ) + assert ( + 'import { buildProcessFailureView } from "./setup_view_models.js";' + in entry_js.text + ) + assert 'import { createSetupViews } from "./setup_views.js";' in entry_js.text + assert "function boot()" in entry_js.text + assert "boot();" in entry_js.text + assert "handleDiagnosticsRun" in entry_js.text + assert "handleRuntimeCheck" in entry_js.text + assert "handleBootstrapModels" in entry_js.text + assert "handleProcessRun" in entry_js.text + assert "runDiagnostics();" not in entry_js.text + + assert "export function createSetupApi" in api_js.text + assert "runDiagnostics" in api_js.text + assert "checkRuntime" in api_js.text + assert "bootstrapModels" in api_js.text + assert "runProcess" in api_js.text + + assert "export function createSetupState" in state_js.text + assert "beginDiagnosticsCheck" in state_js.text + assert "markConfigurationComplete" in state_js.text + assert "beginRuntimeCheck" in state_js.text + assert "completeRuntimeCheck" in state_js.text + assert "beginProcess" in state_js.text + assert "markProcessSuccess" in state_js.text + assert "markProcessFailure" in state_js.text + assert ( + 'const STEP_ORDER = ["prerequisites", "configuration", "runtime", "process"]' + in state_js.text + ) + + assert "export function buildShellSummaryView" in view_models_js.text + assert "export function buildStaleSummaryLabel" in view_models_js.text + assert "export function buildProcessSummaryView" in view_models_js.text + assert "export function buildProcessFailureView" in view_models_js.text + + assert "export function createSetupViews" in views_js.text + assert "renderShellState" in views_js.text + assert "renderDiagnosticsSummary" in views_js.text + assert "renderRuntimeSummary" in views_js.text + assert "renderRuntimeDetails" in views_js.text + assert "renderProcessSummary" in views_js.text + assert "renderProcessDetails" in views_js.text + assert "bindMobileStepToggle" in views_js.text + assert 'document.getElementById("mobile-step-toggle")' in views_js.text + assert ( + 'progressRail.dataset.mobileOpen = expanded ? "true" : "false";' + in views_js.text + ) + assert ( + 'mobileStepToggle.setAttribute("aria-expanded", String(expanded));' + in views_js.text + ) + assert "views.bindMobileStepToggle();" in entry_js.text + + +def test_setup_js_prefers_backend_setup_views_for_state_transitions() -> None: + entry_js = client.get("/static/setup.js") + + assert entry_js.status_code == 200 + assert "function toDiagnosticsView(report)" in entry_js.text + assert ( + 'return report.setup_view || { state: report.ready ? "ready" : "blocked" };' + in entry_js.text + ) + assert "function toRuntimeView(runtime)" in entry_js.text + assert ( + 'return runtime.setup_view || { state: runtime.reachable ? "ready" : "blocked" };' + in entry_js.text + ) + assert "const diagnosticsView = toDiagnosticsView(report);" in entry_js.text + assert "state.applyDiagnosticsResult(diagnosticsView);" in entry_js.text + assert "const runtimeView = toRuntimeView(runtime);" in entry_js.text + assert "state.completeRuntimeCheck(runtimeView);" in entry_js.text + + +def test_setup_js_renders_shaped_setup_view_summaries_and_common_fixes() -> None: + entry_js = client.get("/static/setup.js") + + assert entry_js.status_code == 200 + assert "const diagnosticsView = toDiagnosticsView(report);" in entry_js.text + assert "views.renderDiagnosticsSummary(diagnosticsView);" in entry_js.text + assert "views.renderCommonFixes(diagnosticsView);" in entry_js.text + assert "const runtimeView = toRuntimeView(runtime);" in entry_js.text + assert "renderRuntimeState(runtimeView, runtime);" in entry_js.text + assert ( + "renderRuntimeState(runtimeView, { ...runtime, bootstrap: report });" + in entry_js.text + ) + + +def test_setup_views_expose_common_fixes_and_runtime_readiness_copy() -> None: + views_js = client.get("/static/setup_views.js") + + assert views_js.status_code == 200 + assert "function renderCommonFixes(view)" in views_js.text + assert 'const container = document.getElementById("common-fixes");' in views_js.text + assert "const fixes = Array.isArray(view?.checks)" in views_js.text + assert "view.checks.filter((check) => check.fix)" in views_js.text + assert "function renderRuntimeSummary(runtimeView, runtime)" in views_js.text + assert ( + '["Required models", `${requiredModels.length} configured`],' in views_js.text + ) + assert '["Available locally", `${localModels.length} ready`],' in views_js.text + assert "runtimeView?.next_action ||" in views_js.text + assert "renderCommonFixes," in views_js.text diff --git a/tests/test_setup_view_models.py b/tests/test_setup_view_models.py new file mode 100644 index 0000000..14ddb87 --- /dev/null +++ b/tests/test_setup_view_models.py @@ -0,0 +1,166 @@ +from video_rss_aggregator.setup_view_models import ( + build_diagnostics_view, + build_runtime_view, +) + + +def test_build_diagnostics_view_marks_missing_dependencies_blocked() -> None: + report = { + "ready": False, + "platform": { + "python_version": "3.11.9", + "python_executable": "/usr/bin/python3", + }, + "dependencies": { + "ffmpeg": {"available": False, "command": None}, + "ffprobe": {"available": True, "command": "/usr/bin/ffprobe"}, + "yt_dlp": {"available": True, "command": "/usr/bin/yt-dlp"}, + "ollama": {"reachable": False, "error": "connection refused"}, + }, + } + + view = build_diagnostics_view(report) + + assert view["state"] == "blocked" + assert view["blockers"] == [ + "Install FFmpeg and add it to PATH", + "Start Ollama so the local API is reachable", + ] + assert view["checks"][0] == { + "id": "python", + "label": "Python 3.11+", + "state": "complete", + "detail": "3.11.9 via /usr/bin/python3", + "fix": None, + } + assert view["checks"][1]["id"] == "ffmpeg" + assert view["checks"][1]["state"] == "blocked" + assert view["next_action"] == "Resolve the blocked checks and run diagnostics again" + + +def test_build_diagnostics_view_marks_ready_path_complete() -> None: + report = { + "ready": True, + "platform": { + "python_version": "3.11.9", + "python_executable": "/usr/bin/python3", + }, + "dependencies": { + "ffmpeg": {"available": True, "command": "/usr/bin/ffmpeg"}, + "ffprobe": {"available": True, "command": "/usr/bin/ffprobe"}, + "yt_dlp": {"available": True, "command": "/usr/bin/yt-dlp"}, + "ollama": {"reachable": True, "error": None}, + }, + } + + view = build_diagnostics_view(report) + + assert view["state"] == "ready" + assert view["blockers"] == [] + assert view["next_action"] == "Continue to configuration" + + +def test_build_diagnostics_view_uses_ollama_version_for_reachable_detail() -> None: + report = { + "ready": True, + "platform": { + "python_version": "3.11.9", + "python_executable": "/usr/bin/python3", + }, + "dependencies": { + "ffmpeg": {"available": True, "command": "/usr/bin/ffmpeg"}, + "ffprobe": {"available": True, "command": "/usr/bin/ffprobe"}, + "yt_dlp": {"available": True, "command": "/usr/bin/yt-dlp"}, + "ollama": {"reachable": True, "error": None, "version": "0.6.0"}, + }, + } + + view = build_diagnostics_view(report) + + assert view["checks"][4]["id"] == "ollama" + assert view["checks"][4]["detail"] == "0.6.0" + + +def test_build_diagnostics_view_blocks_python_below_311() -> None: + report = { + "ready": False, + "platform": { + "python_version": "3.10.14", + "python_executable": "/usr/bin/python3", + }, + "dependencies": { + "ffmpeg": {"available": True, "command": "/usr/bin/ffmpeg"}, + "ffprobe": {"available": True, "command": "/usr/bin/ffprobe"}, + "yt_dlp": {"available": True, "command": "/usr/bin/yt-dlp"}, + "ollama": {"reachable": True, "error": None, "version": "0.6.0"}, + }, + } + + view = build_diagnostics_view(report) + + assert view["state"] == "blocked" + assert view["checks"][0]["state"] == "blocked" + assert view["checks"][0]["fix"] == "Install Python 3.11+" + assert "Install Python 3.11+" in view["blockers"] + + +def test_build_runtime_view_marks_missing_models_blocked() -> None: + runtime = { + "reachable": True, + "local_models": {"qwen3.5:2b-q4_K_M": {}}, + "models": ["qwen3.5:4b-q4_K_M", "qwen3.5:2b-q4_K_M"], + } + + view = build_runtime_view(runtime) + + assert view["state"] == "blocked" + assert view["missing_models"] == ["qwen3.5:4b-q4_K_M"] + assert view["next_action"] == "Bootstrap required models" + + +def test_build_runtime_view_marks_unreachable_runtime_for_check() -> None: + runtime = { + "reachable": False, + "local_models": { + "qwen3.5:4b-q4_K_M": {}, + "qwen3.5:2b-q4_K_M": {}, + }, + "models": ["qwen3.5:4b-q4_K_M", "qwen3.5:2b-q4_K_M"], + } + + view = build_runtime_view(runtime) + + assert view["state"] == "blocked" + assert view["missing_models"] == [] + assert view["next_action"] == "Check runtime" + + +def test_build_runtime_view_accepts_list_shaped_local_models() -> None: + runtime = { + "reachable": True, + "local_models": ["qwen3.5:4b-q4_K_M", "qwen3.5:2b-q4_K_M"], + "models": ["qwen3.5:4b-q4_K_M", "qwen3.5:2b-q4_K_M"], + } + + view = build_runtime_view(runtime) + + assert view["state"] == "ready" + assert view["missing_models"] == [] + assert view["next_action"] == "Run the first processing test" + + +def test_build_runtime_view_marks_ready_path_complete() -> None: + runtime = { + "reachable": True, + "local_models": { + "qwen3.5:4b-q4_K_M": {}, + "qwen3.5:2b-q4_K_M": {}, + }, + "models": ["qwen3.5:4b-q4_K_M", "qwen3.5:2b-q4_K_M"], + } + + view = build_runtime_view(runtime) + + assert view["state"] == "ready" + assert view["missing_models"] == [] + assert view["next_action"] == "Run the first processing test" diff --git a/video_rss_aggregator/api.py b/video_rss_aggregator/api.py index f1fbd8d..9041366 100644 --- a/video_rss_aggregator/api.py +++ b/video_rss_aggregator/api.py @@ -19,6 +19,10 @@ from service_media import runtime_dependency_report from video_rss_aggregator.bootstrap import AppRuntime, build_runtime from video_rss_aggregator.domain.outcomes import Failure +from video_rss_aggregator.setup_view_models import ( + build_diagnostics_view, + build_runtime_view, +) class IngestRequest(BaseModel): @@ -131,7 +135,7 @@ async def setup_diagnostics(request: Request) -> dict[str, object]: ytdlp_ok = bool(ytdlp["available"]) ollama_ok = bool(ollama["reachable"]) - return { + diagnostics_payload = { "platform": { "system": platform.system(), "release": platform.release(), @@ -146,6 +150,8 @@ async def setup_diagnostics(request: Request) -> dict[str, object]: }, "ready": ffmpeg_ok and ffprobe_ok and ytdlp_ok and ollama_ok, } + diagnostics_payload["setup_view"] = build_diagnostics_view(diagnostics_payload) + return diagnostics_payload @app.post("/setup/bootstrap") async def setup_bootstrap( @@ -220,6 +226,10 @@ async def rss_feed( async def runtime_status( request: Request, _=Depends(_check_auth) ) -> dict[str, object]: - return await _runtime(request).use_cases.get_runtime_status.execute() + runtime_payload = await _runtime(request).use_cases.get_runtime_status.execute() + return { + **runtime_payload, + "setup_view": build_runtime_view(runtime_payload), + } return app diff --git a/video_rss_aggregator/setup_view_models.py b/video_rss_aggregator/setup_view_models.py new file mode 100644 index 0000000..c5154f6 --- /dev/null +++ b/video_rss_aggregator/setup_view_models.py @@ -0,0 +1,142 @@ +from __future__ import annotations + + +def build_diagnostics_view(report: dict[str, object]) -> dict[str, object]: + platform = _as_dict(report.get("platform")) + dependencies = _as_dict(report.get("dependencies")) + + python_version = str(platform.get("python_version") or "unknown") + python_executable = str(platform.get("python_executable") or "unknown") + python_ready = _is_supported_python(python_version) + + checks = [ + { + "id": "python", + "label": "Python 3.11+", + "state": "complete" if python_ready else "blocked", + "detail": f"{python_version} via {python_executable}", + "fix": None if python_ready else "Install Python 3.11+", + }, + _dependency_check( + "ffmpeg", + "FFmpeg", + dependencies.get("ffmpeg"), + fix="Install FFmpeg and add it to PATH", + ), + _dependency_check( + "ffprobe", + "FFprobe", + dependencies.get("ffprobe"), + fix="Install FFprobe and add it to PATH", + ), + _dependency_check( + "yt_dlp", + "yt-dlp", + dependencies.get("yt_dlp"), + fix="Install yt-dlp and add it to PATH", + ), + _ollama_check(dependencies.get("ollama")), + ] + + blockers = [ + check["fix"] for check in checks if check["state"] == "blocked" and check["fix"] + ] + state = "ready" if not blockers and bool(report.get("ready")) else "blocked" + + return { + "state": state, + "blockers": blockers, + "checks": checks, + "next_action": "Continue to configuration" + if state == "ready" + else "Resolve the blocked checks and run diagnostics again", + } + + +def build_runtime_view(runtime: dict[str, object]) -> dict[str, object]: + local_models = _local_model_names(runtime.get("local_models")) + models = [str(model) for model in _as_list(runtime.get("models"))] + missing_models = [model for model in models if model not in local_models] + + reachable = bool(runtime.get("reachable")) + state = "ready" if reachable and not missing_models else "blocked" + if not reachable: + next_action = "Check runtime" + elif missing_models: + next_action = "Bootstrap required models" + else: + next_action = "Run the first processing test" + + return { + "state": state, + "missing_models": missing_models, + "next_action": next_action, + } + + +def _dependency_check( + check_id: str, + label: str, + dependency: object, + *, + fix: str, +) -> dict[str, object]: + payload = _as_dict(dependency) + command = payload.get("command") + available = bool(payload.get("available")) + + return { + "id": check_id, + "label": label, + "state": "complete" if available else "blocked", + "detail": str(command) if command else "Not detected", + "fix": None if available else fix, + } + + +def _ollama_check(dependency: object) -> dict[str, object]: + payload = _as_dict(dependency) + reachable = bool(payload.get("reachable")) + error = payload.get("error") + version = payload.get("version") + + return { + "id": "ollama", + "label": "Ollama API", + "state": "complete" if reachable else "blocked", + "detail": str(version) + if reachable and version + else ("Reachable" if reachable else str(error or "Unavailable")), + "fix": None if reachable else "Start Ollama so the local API is reachable", + } + + +def _as_dict(value: object) -> dict[str, object]: + if isinstance(value, dict): + return value + return {} + + +def _as_list(value: object) -> list[object]: + if isinstance(value, list): + return value + return [] + + +def _is_supported_python(version: str) -> bool: + parts = version.split(".") + if len(parts) < 2: + return False + if not parts[0].isdigit() or not parts[1].isdigit(): + return False + major = int(parts[0]) + minor = int(parts[1]) + return major > 3 or (major == 3 and minor >= 11) + + +def _local_model_names(value: object) -> set[str]: + if isinstance(value, dict): + return {str(model) for model in value} + if isinstance(value, list): + return {str(model) for model in value} + return set() diff --git a/video_rss_aggregator/static/setup.css b/video_rss_aggregator/static/setup.css index 6542293..239ece8 100644 --- a/video_rss_aggregator/static/setup.css +++ b/video_rss_aggregator/static/setup.css @@ -1,39 +1,39 @@ -/* ─── Design Tokens ─────────────────────────────────────────── */ :root { - --bg-base: #0a0a0f; - --bg-elevated: #121218; - --bg-surface: rgba(255, 255, 255, 0.04); - --panel: rgba(255, 255, 255, 0.05); - --panel-hover: rgba(255, 255, 255, 0.07); - --panel-border: rgba(255, 255, 255, 0.08); - --panel-border-hover: rgba(255, 255, 255, 0.14); - --text-primary: rgba(255, 255, 255, 0.92); - --text-secondary: rgba(255, 255, 255, 0.55); - --text-tertiary: rgba(255, 255, 255, 0.35); - --accent: #6366f1; - --accent-soft: rgba(99, 102, 241, 0.15); - --accent-glow: rgba(99, 102, 241, 0.25); - --success: #34d399; - --success-soft: rgba(52, 211, 153, 0.12); - --warning: #fbbf24; - --warning-soft: rgba(251, 191, 36, 0.12); - --danger: #f87171; - --danger-soft: rgba(248, 113, 113, 0.12); + --paper-base: #f5f1e8; + --paper-panel: #fffaf2; + --surface-raised: rgba(255, 250, 242, 0.72); + --surface-raised-strong: rgba(255, 250, 242, 0.92); + --surface-olive-wash: rgba(107, 122, 64, 0.05); + --surface-code: rgba(47, 42, 36, 0.06); + --surface-input: #fffdf8; + --surface-input-focus: #ffffff; + --surface-output: #f2ebdf; + --paper-shadow: rgba(47, 42, 36, 0.08); + --paper-shadow-strong: rgba(47, 42, 36, 0.1); + --ink-strong: #2f2a24; + --ink-soft: #665d52; + --ink-muted: #8a7f72; + --accent-olive: #6b7a40; + --accent-olive-soft: rgba(107, 122, 64, 0.14); + --accent-amber: #b86a2b; + --accent-amber-soft: rgba(184, 106, 43, 0.16); + --danger-rust: #b44c37; + --danger-rust-soft: rgba(180, 76, 55, 0.14); + --line-soft: rgba(47, 42, 36, 0.12); + --line-strong: rgba(47, 42, 36, 0.2); + --focus-ring: 0 0 0 3px rgba(107, 122, 64, 0.16); --radius-sm: 10px; - --radius-md: 16px; - --radius-lg: 22px; - --spring: cubic-bezier(0.22, 1, 0.36, 1); + --radius-md: 18px; + --radius-lg: 28px; + --duration-fast: 160ms; + --duration-normal: 280ms; --ease-out: cubic-bezier(0.16, 1, 0.3, 1); - --duration-fast: 180ms; - --duration-normal: 350ms; - --duration-slow: 600ms; } -/* ─── Reset & Base ──────────────────────────────────────────── */ -*, *::before, *::after { +*, +*::before, +*::after { box-sizing: border-box; - margin: 0; - padding: 0; } html { @@ -43,193 +43,321 @@ html { } body { - font-family: "Avenir Next", "Segoe UI", "Trebuchet MS", sans-serif; - color: var(--text-primary); - background: var(--bg-base); + margin: 0; min-height: 100vh; - line-height: 1.5; - overflow-x: hidden; + font-family: "Avenir Next", "Segoe UI", sans-serif; + line-height: 1.55; + color: var(--ink-strong); + background: + radial-gradient(circle at top left, rgba(184, 106, 43, 0.12), transparent 28%), + linear-gradient(180deg, #f8f4ec 0%, var(--paper-base) 42%, #efe6d8 100%); } -/* Ambient gradient orbs */ -body::before, -body::after { +body::before { content: ""; position: fixed; - border-radius: 50%; + inset: 0; pointer-events: none; - filter: blur(120px); - opacity: 0.4; -} - -body::before { - width: 600px; - height: 600px; - top: -200px; - left: -100px; - background: radial-gradient(circle, rgba(99, 102, 241, 0.3), transparent 70%); -} - -body::after { - width: 500px; - height: 500px; - bottom: -150px; - right: -100px; - background: radial-gradient(circle, rgba(52, 211, 153, 0.2), transparent 70%); + background-image: linear-gradient(rgba(47, 42, 36, 0.025) 1px, transparent 1px), + linear-gradient(90deg, rgba(47, 42, 36, 0.025) 1px, transparent 1px); + background-size: 22px 22px; + mask-image: linear-gradient(180deg, rgba(0, 0, 0, 0.45), transparent 88%); } -/* ─── Shell (Page Container) ────────────────────────────────── */ .shell { - width: min(960px, 90vw); + width: min(1180px, calc(100vw - 40px)); margin: 0 auto; - padding: 48px 0 80px; + padding: 40px 0 72px; position: relative; - z-index: 1; } -/* ─── Hero ──────────────────────────────────────────────────── */ +.hero, +.progress-rail, +.step-panel, +.detail-drawer, +.toast { + background: var(--paper-panel); + border: 1px solid var(--line-soft); + box-shadow: 0 18px 42px var(--paper-shadow); +} + .hero { - margin-bottom: 40px; - padding: 40px 36px; + margin-bottom: 28px; + padding: 34px 36px; border-radius: var(--radius-lg); - background: var(--panel); - border: 1px solid var(--panel-border); - backdrop-filter: blur(40px) saturate(1.2); - -webkit-backdrop-filter: blur(40px) saturate(1.2); - opacity: 0; - transform: translateY(20px); - animation: fadeSlideUp var(--duration-slow) var(--spring) forwards; + position: relative; + overflow: hidden; +} + +.hero::after { + content: ""; + position: absolute; + inset: auto 0 0 0; + height: 5px; + background: linear-gradient(90deg, var(--accent-olive), var(--accent-amber)); } .eyebrow { display: inline-flex; - align-items: center; - gap: 6px; - font-size: 0.7rem; - font-weight: 600; - letter-spacing: 0.1em; - text-transform: uppercase; - color: var(--accent); - background: var(--accent-soft); - padding: 5px 12px; + margin: 0 0 14px; + padding: 6px 12px; border-radius: 999px; - margin-bottom: 16px; + background: var(--accent-olive-soft); + color: var(--accent-olive); + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.11em; + text-transform: uppercase; +} + +h1, +h2, +h3, +strong { + font-family: "Iowan Old Style", "Palatino Linotype", "Book Antiqua", serif; } h1 { - font-size: clamp(1.6rem, 3vw, 2.2rem); - font-weight: 600; - line-height: 1.15; - letter-spacing: -0.025em; - color: var(--text-primary); - margin-bottom: 10px; + margin: 0 0 10px; + font-size: clamp(2rem, 4vw, 3.2rem); + line-height: 1.05; +} + +h2, +h3, +p, +ol, +pre { + margin: 0; +} + +h2 { + font-size: 1.3rem; + line-height: 1.2; +} + +h3 { + font-size: 1rem; + margin-bottom: 8px; +} + +.subhead, +.muted, +ol, +.status, +.footer { + color: var(--ink-soft); } .subhead { - color: var(--text-secondary); - max-width: 640px; - line-height: 1.65; - font-size: 0.94rem; + max-width: 720px; + font-size: 1rem; } -/* ─── Grid ──────────────────────────────────────────────────── */ .grid { display: grid; - gap: 20px; - grid-template-columns: repeat(12, minmax(0, 1fr)); + gap: 24px; } -/* ─── Cards ─────────────────────────────────────────────────── */ -.card { - grid-column: span 6; - background: var(--panel); - border: 1px solid var(--panel-border); - border-radius: var(--radius-lg); - padding: 28px; - backdrop-filter: blur(30px) saturate(1.15); - -webkit-backdrop-filter: blur(30px) saturate(1.15); - transition: - background var(--duration-normal) var(--spring), - border-color var(--duration-normal) var(--spring), - transform var(--duration-normal) var(--spring), - box-shadow var(--duration-normal) var(--spring); +.workbench { + display: grid; + grid-template-columns: 300px minmax(0, 1fr); + gap: 24px; + align-items: start; +} - /* Animation — cards start hidden, revealed by IntersectionObserver in JS */ - opacity: 0; - transform: translateY(24px) scale(0.98); +.progress-rail { + border-radius: var(--radius-lg); + padding: 24px; + position: sticky; + top: 24px; } -.card.revealed { - opacity: 1; - transform: translateY(0) scale(1); - transition: - opacity var(--duration-slow) var(--spring), - transform var(--duration-slow) var(--spring), - background var(--duration-normal) var(--spring), - border-color var(--duration-normal) var(--spring), - box-shadow var(--duration-normal) var(--spring); +.step-list { + list-style: none; + padding: 0; + margin: 24px 0; + display: grid; + gap: 12px; } -.card:hover { - background: var(--panel-hover); - border-color: var(--panel-border-hover); - transform: translateY(-2px) scale(1); - box-shadow: 0 8px 40px rgba(0, 0, 0, 0.25); +.step-item { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 12px; + align-items: start; + padding: 14px; + border-radius: var(--radius-md); + border: 1px solid transparent; + background: var(--surface-olive-wash); } -.card.wide { - grid-column: span 12; +.step-item .muted { + margin-top: 4px; + font-size: 0.9rem; } -.card h2 { - font-size: 0.95rem; - font-weight: 600; - letter-spacing: -0.01em; - margin-bottom: 6px; - display: flex; +.step-number { + display: inline-flex; align-items: center; - gap: 8px; + justify-content: center; + width: 32px; + height: 32px; + border-radius: 999px; + flex-shrink: 0; + background: var(--accent-olive-soft); + color: var(--accent-olive); + font-size: 0.88rem; + font-weight: 700; } -.card h2 .step-number { - display: inline-flex; +.step-item[data-state="active"] { + border-color: rgba(107, 122, 64, 0.28); + background: rgba(107, 122, 64, 0.12); +} + +.step-item[data-state="complete"] { + border-color: rgba(107, 122, 64, 0.24); +} + +.step-item[data-state="blocked"] { + border-color: rgba(180, 76, 55, 0.24); + background: var(--danger-rust-soft); +} + +#common-fixes, +#active-step-copy, +#readiness-summary, +#blocker-summary, +#advanced-config { + padding: 18px; + border-radius: var(--radius-md); + background: var(--surface-raised); + border: 1px dashed var(--line-soft); +} + +#blocker-summary { + margin-top: 14px; +} + +.step-stage { + display: grid; + gap: 18px; + min-width: 0; +} + +.mobile-step-toggle, +#mobile-step-toggle { + display: none; align-items: center; justify-content: center; - width: 24px; - height: 24px; - border-radius: 8px; - background: var(--accent-soft); - color: var(--accent); + width: fit-content; + padding: 10px 14px; + border-radius: 999px; + border: 1px solid var(--line-strong); + background: var(--surface-raised-strong); + color: var(--ink-strong); +} + +.card, +.step-panel, +.detail-drawer { + border-radius: var(--radius-lg); + padding: 24px; +} + +.card { + opacity: 0; + transform: translateY(18px); + transition: + opacity var(--duration-normal) var(--ease-out), + transform var(--duration-normal) var(--ease-out), + box-shadow var(--duration-fast) ease; +} + +.step-panel { + display: grid; + gap: 16px; +} + +.card.wide { + grid-column: auto; +} + +.summary-card { + display: grid; + gap: 12px; + padding: 18px; + border-radius: var(--radius-md); + border: 1px solid var(--line-soft); + background: var(--surface-raised); +} + +.summary-card[data-state="unverified"] { + border-style: dashed; +} + +.summary-card[data-state="complete"] { + border-color: rgba(107, 122, 64, 0.28); + background: rgba(107, 122, 64, 0.08); +} + +.summary-card[data-state="blocked"], +.summary-card[data-state="failed"] { + border-color: rgba(180, 76, 55, 0.26); + background: rgba(180, 76, 55, 0.08); +} + +.summary-card[data-state="stale"] { + border-color: rgba(184, 106, 43, 0.3); + background: rgba(184, 106, 43, 0.08); +} + +.summary-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px 14px; + margin: 0; +} + +.summary-grid div { + min-width: 0; +} + +.summary-grid dt { + margin: 0 0 4px; font-size: 0.72rem; - font-weight: 600; - flex-shrink: 0; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--ink-muted); } -.muted { - color: var(--text-secondary); - line-height: 1.6; - font-size: 0.86rem; - margin-bottom: 16px; +.summary-grid dd { + margin: 0; + color: var(--ink-strong); + word-break: break-word; } -/* ─── Lists ─────────────────────────────────────────────────── */ -ol { - padding-left: 20px; - line-height: 1.7; - color: var(--text-secondary); - font-size: 0.88rem; +.stale-chip { + display: inline-flex; + width: fit-content; + padding: 5px 10px; + border-radius: 999px; + background: rgba(184, 106, 43, 0.14); + color: var(--accent-amber); + font-size: 0.78rem; + font-weight: 700; } -ol li { - padding-left: 4px; - margin-bottom: 4px; +ol { + padding-left: 20px; } -ol li::marker { - color: var(--text-tertiary); +ol li + li { + margin-top: 6px; } -/* ─── Code & Mono ───────────────────────────────────────────── */ code, pre, input, @@ -238,19 +366,20 @@ textarea { } code { - background: var(--bg-surface); padding: 2px 6px; - border-radius: 5px; - font-size: 0.82em; - color: var(--text-primary); + border-radius: 6px; + background: var(--surface-code); + color: var(--ink-strong); } -/* ─── Form Fields ───────────────────────────────────────────── */ -.field-grid { +.field-grid, +.toggle-grid { display: grid; - gap: 12px; + gap: 14px; +} + +.field-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); - margin-bottom: 16px; } .field-grid.single-column { @@ -258,8 +387,7 @@ code { } .field { - display: flex; - flex-direction: column; + display: grid; gap: 6px; } @@ -268,24 +396,22 @@ code { } .field label { - color: var(--text-tertiary); font-size: 0.74rem; - font-weight: 500; - letter-spacing: 0.03em; + font-weight: 700; + letter-spacing: 0.08em; text-transform: uppercase; + color: var(--ink-muted); } input[type="text"], input[type="number"], textarea { width: 100%; - border: 1px solid var(--panel-border); + padding: 11px 12px; + border: 1px solid var(--line-soft); border-radius: var(--radius-sm); - padding: 10px 12px; - background: rgba(255, 255, 255, 0.03); - color: var(--text-primary); - font-size: 0.86rem; - outline: none; + background: var(--surface-input); + color: var(--ink-strong); transition: border-color var(--duration-fast) ease, box-shadow var(--duration-fast) ease, @@ -295,347 +421,272 @@ textarea { input[type="text"]:hover, input[type="number"]:hover, textarea:hover { - border-color: rgba(255, 255, 255, 0.14); - background: rgba(255, 255, 255, 0.045); + border-color: var(--line-strong); } input[type="text"]:focus, input[type="number"]:focus, textarea:focus { - border-color: var(--accent); - box-shadow: 0 0 0 3px var(--accent-glow); - background: rgba(255, 255, 255, 0.05); + outline: none; + border-color: var(--accent-olive); + box-shadow: var(--focus-ring); + background: var(--surface-input-focus); } textarea { + min-height: 58px; resize: vertical; - min-height: 52px; } -/* ─── Toggles ───────────────────────────────────────────────── */ .toggle-grid { - display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 10px; - margin-bottom: 16px; } .inline { display: flex; + gap: 10px; align-items: center; - gap: 8px; - color: var(--text-secondary); - font-size: 0.86rem; - cursor: pointer; - transition: color var(--duration-fast) ease; -} - -.inline:hover { - color: var(--text-primary); + padding: 12px 14px; + border-radius: var(--radius-md); + border: 1px solid var(--line-soft); + background: var(--surface-olive-wash); + color: var(--ink-strong); } input[type="checkbox"] { - appearance: none; - -webkit-appearance: none; width: 18px; height: 18px; - border: 1.5px solid var(--panel-border-hover); - border-radius: 5px; - background: rgba(255, 255, 255, 0.04); - cursor: pointer; - position: relative; - flex-shrink: 0; - transition: - background var(--duration-fast) ease, - border-color var(--duration-fast) ease; -} - -input[type="checkbox"]:checked { - background: var(--accent); - border-color: var(--accent); -} - -input[type="checkbox"]:checked::after { - content: ""; - position: absolute; - top: 3px; - left: 5.5px; - width: 5px; - height: 8px; - border: 2px solid white; - border-top: none; - border-left: none; - transform: rotate(40deg); + margin: 0; + accent-color: var(--accent-olive); } -/* ─── Buttons ───────────────────────────────────────────────── */ .actions { display: flex; flex-wrap: wrap; gap: 10px; - margin-bottom: 14px; } .top-space { - margin-top: 16px; + margin-top: 4px; } button { - border: none; - border-radius: var(--radius-sm); - padding: 9px 18px; - font-family: "Inter", -apple-system, sans-serif; - font-size: 0.82rem; - font-weight: 600; + appearance: none; + border: 1px solid transparent; + border-radius: 999px; + padding: 10px 16px; + font: inherit; + font-weight: 700; cursor: pointer; - color: white; - background: var(--accent); - position: relative; - overflow: hidden; + color: var(--paper-panel); + background: var(--accent-olive); transition: - transform var(--duration-fast) var(--spring), - filter var(--duration-fast) ease, - box-shadow var(--duration-fast) ease; -} - -button::after { - content: ""; - position: absolute; - inset: 0; - background: rgba(255, 255, 255, 0); - transition: background var(--duration-fast) ease; + transform var(--duration-fast) var(--ease-out), + box-shadow var(--duration-fast) ease, + background var(--duration-fast) ease, + border-color var(--duration-fast) ease; } button:hover { transform: translateY(-1px); - box-shadow: 0 4px 16px rgba(99, 102, 241, 0.3); + box-shadow: 0 10px 24px rgba(107, 122, 64, 0.18); } -button:hover::after { - background: rgba(255, 255, 255, 0.08); +button.pressing { + transform: scale(0.98); + box-shadow: 0 6px 14px rgba(107, 122, 64, 0.16); } button:active { - transform: scale(0.97); - box-shadow: none; + transform: translateY(0); } button.warm { - background: linear-gradient(135deg, #f59e0b, #f97316); - color: #1a0800; + background: var(--accent-amber); } button.warm:hover { - box-shadow: 0 4px 16px rgba(245, 158, 11, 0.3); + box-shadow: 0 10px 24px rgba(184, 106, 43, 0.18); } button.ghost { - background: rgba(255, 255, 255, 0.06); - color: var(--text-primary); - border: 1px solid var(--panel-border); + color: var(--ink-strong); + background: var(--surface-raised); + border-color: var(--line-strong); } button.ghost:hover { - background: rgba(255, 255, 255, 0.1); - border-color: var(--panel-border-hover); - box-shadow: none; + box-shadow: 0 10px 20px var(--paper-shadow-strong); } -/* Button press spring animation */ -button.pressing { - animation: buttonPress var(--duration-fast) var(--spring); -} - -/* ─── Pre / Output ──────────────────────────────────────────── */ pre { - margin: 0; - border: 1px solid var(--panel-border); - border-radius: var(--radius-md); - background: rgba(0, 0, 0, 0.35); - padding: 16px; - min-height: 100px; - max-height: 260px; + min-height: 110px; + max-width: 100%; overflow: auto; - line-height: 1.55; - font-size: 0.78rem; - color: var(--text-secondary); - transition: border-color var(--duration-normal) ease; -} - -pre:hover { - border-color: var(--panel-border-hover); -} - -/* Loading shimmer on pre */ -pre.loading { - position: relative; - overflow: hidden; + padding: 16px; + border-radius: var(--radius-md); + border: 1px solid var(--line-soft); + background: var(--surface-output); + color: var(--ink-strong); + line-height: 1.6; + white-space: pre-wrap; } -pre.loading::after { - content: ""; - position: absolute; - inset: 0; - background: linear-gradient( - 90deg, - transparent 0%, - rgba(255, 255, 255, 0.03) 40%, - rgba(255, 255, 255, 0.06) 50%, - rgba(255, 255, 255, 0.03) 60%, - transparent 100% - ); - animation: shimmer 1.8s ease-in-out infinite; +.detail-drawer { + display: grid; + gap: 12px; } -/* Custom scrollbar */ -pre::-webkit-scrollbar { - width: 6px; - height: 6px; +.detail-drawer summary { + cursor: pointer; + font-weight: 700; + color: var(--ink-strong); } -pre::-webkit-scrollbar-track { - background: transparent; +.detail-drawer[open] summary { + color: var(--accent-olive); } -pre::-webkit-scrollbar-thumb { - background: rgba(255, 255, 255, 0.1); - border-radius: 3px; +.card.revealed { + opacity: 1; + transform: translateY(0); } -pre::-webkit-scrollbar-thumb:hover { - background: rgba(255, 255, 255, 0.18); +pre.loading { + background-image: linear-gradient( + 90deg, + var(--surface-output) 0%, + #f7f1e7 45%, + var(--surface-output) 100% + ); + background-size: 220% 100%; + animation: setup-loading 1.4s ease-in-out infinite; } -/* ─── Status ────────────────────────────────────────────────── */ .status { - margin-top: 10px; - font-size: 0.78rem; - color: var(--text-tertiary); min-height: 1.2em; - transition: opacity var(--duration-fast) ease; + font-size: 0.88rem; } -.status.error { - color: var(--danger); +.status.fade-in { + animation: status-fade-in var(--duration-normal) var(--ease-out); } -.status.fade-in { - animation: fadeIn var(--duration-normal) var(--spring); +.status.error { + color: var(--danger-rust); } -/* ─── Toast ─────────────────────────────────────────────────── */ .toast { position: fixed; - bottom: 32px; - left: 50%; - transform: translateX(-50%) translateY(20px); - background: var(--bg-elevated); - border: 1px solid var(--panel-border-hover); - color: var(--text-primary); - padding: 10px 20px; - border-radius: var(--radius-sm); - font-size: 0.82rem; - font-weight: 500; - backdrop-filter: blur(20px); - -webkit-backdrop-filter: blur(20px); - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); - z-index: 1000; + right: 24px; + bottom: 24px; + max-width: min(340px, calc(100vw - 32px)); + padding: 14px 16px; + border-radius: var(--radius-md); + color: var(--ink-strong); opacity: 0; + transform: translateY(12px); pointer-events: none; transition: - opacity var(--duration-normal) var(--spring), - transform var(--duration-normal) var(--spring); + opacity var(--duration-normal) ease, + transform var(--duration-normal) var(--ease-out); } .toast.visible { opacity: 1; - transform: translateX(-50%) translateY(0); - pointer-events: auto; + transform: translateY(0); } -/* ─── Footer ────────────────────────────────────────────────── */ .footer { - margin-top: 48px; - padding-top: 24px; - border-top: 1px solid var(--panel-border); + margin-top: 30px; text-align: center; - color: var(--text-tertiary); - font-size: 0.74rem; - opacity: 0; - animation: fadeSlideUp var(--duration-slow) var(--spring) 0.8s forwards; + font-size: 0.82rem; } -/* ─── Responsive ────────────────────────────────────────────── */ @media (max-width: 860px) { .shell { - padding: 32px 0 60px; + width: min(100vw - 24px, 920px); + padding: 24px 0 56px; + } + + .workbench { + grid-template-columns: 1fr; } - .hero { - padding: 28px 24px; + .progress-rail { + position: static; + order: 2; } - .card, - .card.wide, - .field-span { - grid-column: span 12; + #setup-progress[data-mobile-open="false"] { + display: none; } - .card { - padding: 22px; + #setup-progress[data-mobile-open="true"] { + display: grid; + } + + .mobile-step-toggle, + #mobile-step-toggle { + display: inline-flex; + } + + #mobile-step-toggle[aria-expanded="true"] { + background: var(--accent-olive); + border-color: var(--accent-olive); + color: var(--paper-panel); } .field-grid, - .toggle-grid { + .toggle-grid, + .summary-grid { grid-template-columns: 1fr; } -} -@media (max-width: 480px) { - .shell { - width: 94vw; - padding: 24px 0 48px; + .field-span { + grid-column: auto; } +} - .hero { - padding: 24px 18px; - border-radius: var(--radius-md); +@media (min-width: 861px) { + #setup-progress { + display: block; } +} - .card { - border-radius: var(--radius-md); +@media (max-width: 560px) { + .hero, + .card, + .step-panel, + .detail-drawer, + .progress-rail { padding: 18px; + border-radius: 20px; } h1 { - font-size: 1.4rem; + font-size: 1.8rem; + } +} + +@keyframes setup-loading { + 0% { + background-position: 100% 0; + } + + 100% { + background-position: -100% 0; } } -/* ─── Keyframes ─────────────────────────────────────────────── */ -@keyframes fadeSlideUp { +@keyframes status-fade-in { from { opacity: 0; - transform: translateY(20px); + transform: translateY(2px); } + to { opacity: 1; transform: translateY(0); } } - -@keyframes fadeIn { - from { opacity: 0; } - to { opacity: 1; } -} - -@keyframes buttonPress { - 0% { transform: scale(1); } - 40% { transform: scale(0.97); } - 100% { transform: scale(1); } -} - -@keyframes shimmer { - 0% { transform: translateX(-100%); } - 100% { transform: translateX(100%); } -} diff --git a/video_rss_aggregator/static/setup.js b/video_rss_aggregator/static/setup.js index 08d7483..4b32a2d 100644 --- a/video_rss_aggregator/static/setup.js +++ b/video_rss_aggregator/static/setup.js @@ -1,6 +1,9 @@ -/* ─── Micro-interaction layer ───────────────────────────────── */ -/* Preserves all existing business logic; adds reveal, press */ -/* feedback, toast, shimmer, and smooth-scroll behaviours. */ +import { createSetupApi } from "./setup_api.js"; +import { createSetupState } from "./setup_state.js"; +import { buildShellSummaryView } from "./setup_view_models.js"; +import { buildProcessSummaryView } from "./setup_view_models.js"; +import { buildProcessFailureView } from "./setup_view_models.js"; +import { createSetupViews } from "./setup_views.js"; const defaults = JSON.parse(document.getElementById("setup-defaults").textContent); @@ -47,300 +50,227 @@ const status = { process: document.getElementById("process-status"), }; -/* ─── Toast ─────────────────────────────────────────────────── */ -const toastEl = document.getElementById("toast"); -let toastTimer = null; - -function showToast(message, duration = 2200) { - toastEl.textContent = message; - toastEl.classList.add("visible"); - clearTimeout(toastTimer); - toastTimer = setTimeout(() => { - toastEl.classList.remove("visible"); - }, duration); -} +const state = createSetupState({ defaults, fields }); +const views = createSetupViews({ + document, + outputs, + status, + toastEl: document.getElementById("toast"), +}); +const api = createSetupApi({ + fetchImpl: (...args) => fetch(...args), + getAuthHeaders: state.authHeaders, +}); -/* ─── Status helpers ────────────────────────────────────────── */ -function setStatus(target, message, isError = false) { - target.style.opacity = "0"; - requestAnimationFrame(() => { - target.textContent = message; - target.classList.toggle("error", isError); - target.style.opacity = ""; - target.classList.remove("fade-in"); - void target.offsetWidth; /* force reflow for re-trigger */ - target.classList.add("fade-in"); - }); -} +function renderShell() { + const shellState = state.getShellState(); + const shellSummaryView = buildShellSummaryView(shellState); -function asBool(value) { - return value ? "true" : "false"; + views.renderShellState(shellState, shellSummaryView); } -/* ─── Shimmer helpers ───────────────────────────────────────── */ -function startLoading(preEl) { - preEl.classList.add("loading"); +function toDiagnosticsView(report) { + return report.setup_view || { state: report.ready ? "ready" : "blocked" }; } -function stopLoading(preEl) { - preEl.classList.remove("loading"); +function toRuntimeView(runtime) { + return runtime.setup_view || { state: runtime.reachable ? "ready" : "blocked" }; } -/* ─── Smooth scroll to element ──────────────────────────────── */ -function scrollIntoViewSmooth(el) { - el.scrollIntoView({ behavior: "smooth", block: "nearest" }); +function refreshEnvBlock() { + views.renderEnvBlock(state.buildEnvLines()); } -/* ─── Env block renderer ────────────────────────────────────── */ -function renderEnvBlock() { - const apiKey = fields.apiKey.value.trim(); - const lines = [ - "# Video RSS Aggregator generated configuration", - `BIND_ADDRESS=${fields.bind.value.trim()}`, - `VRA_STORAGE_DIR=${fields.storage.value.trim()}`, - `VRA_DATABASE_PATH=${fields.db.value.trim()}`, - `VRA_OLLAMA_BASE_URL=${fields.ollama.value.trim()}`, - `VRA_MODEL_PRIMARY=${fields.modelPrimary.value.trim()}`, - `VRA_MODEL_FALLBACK=${fields.modelFallback.value.trim()}`, - `VRA_MODEL_MIN=${fields.modelMin.value.trim()}`, - `VRA_AUTO_PULL_MODELS=${asBool(fields.autoPullModels.checked)}`, - `VRA_VRAM_BUDGET_MB=${fields.budget.value.trim()}`, - `VRA_MODEL_SIZE_BUDGET_RATIO=${fields.budgetRatio.value.trim()}`, - `VRA_MODEL_SELECTION_RESERVE_MB=${fields.reserve.value.trim()}`, - `VRA_CONTEXT_TOKENS=${fields.contextTokens.value.trim()}`, - `VRA_MAX_OUTPUT_TOKENS=${fields.outputTokens.value.trim()}`, - `VRA_MAX_FRAMES=${fields.maxFrames.value.trim()}`, - `VRA_FRAME_SCENE_DETECTION=${asBool(fields.sceneDetection.checked)}`, - `VRA_FRAME_SCENE_THRESHOLD=${fields.sceneThreshold.value.trim()}`, - `VRA_FRAME_SCENE_MIN_FRAMES=${fields.sceneMin.value.trim()}`, - `VRA_MAX_TRANSCRIPT_CHARS=${fields.maxTranscript.value.trim()}`, - `VRA_TRANSCRIPT_RETENTION_PER_VIDEO=${fields.transcriptRetention.value.trim()}`, - `VRA_SUMMARY_RETENTION_PER_VIDEO=${fields.summaryRetention.value.trim()}`, - `VRA_RSS_TITLE=${fields.rssTitle.value.trim()}`, - `VRA_RSS_LINK=${fields.rssLink.value.trim()}`, - `VRA_RSS_DESCRIPTION=${fields.rssDescription.value.trim()}`, - ]; - if (apiKey) { - lines.splice(2, 0, `API_KEY=${apiKey}`); - } - outputs.env.textContent = lines.join("\n"); -} +function renderRuntimeState(runtimeView = null, runtime = null) { + const currentRuntimeView = runtimeView || { state: "unverified" }; + const currentRuntime = runtime || currentRuntimeView; -async function copyEnvBlock() { - try { - await navigator.clipboard.writeText(outputs.env.textContent); - showToast("Copied to clipboard"); - } catch (error) { - setStatus(status.env, `Copy failed: ${error}`, true); - } + views.renderRuntimeSummary(currentRuntimeView, currentRuntime); + views.renderRuntimeDetails(currentRuntime); + views.renderCommonFixes(currentRuntimeView); } -/* ─── API helpers ───────────────────────────────────────────── */ -function authHeaders() { - const token = fields.apiKey.value.trim(); - if (!token) { - return {}; - } - return { "X-API-Key": token }; +function renderProcessState(summaryView = null, detailView = null) { + views.renderProcessSummary(summaryView); + views.renderProcessDetails(detailView || summaryView || ""); } -async function apiFetch(path, options = {}) { - const headers = { - ...(options.headers || {}), - ...authHeaders(), - }; - - const response = await fetch(path, { - ...options, - headers, - }); - - const bodyText = await response.text(); - let data; - try { - data = JSON.parse(bodyText); - } catch { - data = bodyText; - } +function syncConfigurationProgress() { + refreshEnvBlock(); + const configurationReady = state.markConfigurationComplete(); - if (!response.ok) { - throw new Error(typeof data === "string" ? data : JSON.stringify(data, null, 2)); - } - return data; + renderShell(); + return configurationReady; } -/* ─── API operations ────────────────────────────────────────── */ -async function loadSetupConfig() { +async function copyEnvBlock() { try { - const configData = await apiFetch("setup/config"); - outputs.runtime.textContent = JSON.stringify(configData, null, 2); - setStatus(status.runtime, "Loaded setup defaults."); + syncConfigurationProgress(); + await navigator.clipboard.writeText(outputs.env.textContent); + views.showToast("Copied to clipboard"); } catch (error) { - setStatus(status.runtime, `Unable to load setup config: ${error}`, true); + views.setStatus(status.env, `Copy failed: ${error}`, true); } } -async function runDiagnostics() { - setStatus(status.diagnostics, "Running diagnostics..."); - startLoading(outputs.diagnostics); +async function handleDiagnostics() { + const requestDiagnostics = api.runDiagnostics; + + state.beginDiagnosticsCheck(); + renderShell(); + views.setStatus(status.diagnostics, "Running diagnostics..."); + views.startLoading(outputs.diagnostics); try { - const report = await apiFetch("setup/diagnostics"); + const report = await requestDiagnostics(); + const diagnosticsView = toDiagnosticsView(report); + + state.applyDiagnosticsResult(diagnosticsView); + syncConfigurationProgress(); outputs.diagnostics.textContent = JSON.stringify(report, null, 2); + views.renderDiagnosticsSummary(diagnosticsView); + views.renderCommonFixes(diagnosticsView); if (report.ready) { - setStatus(status.diagnostics, "All required dependencies are available."); + views.setStatus(status.diagnostics, "All required dependencies are available."); } else { - setStatus(status.diagnostics, "Some dependencies are missing or unreachable.", true); + views.setStatus(status.diagnostics, "Some dependencies are missing or unreachable.", true); } } catch (error) { - setStatus(status.diagnostics, `Diagnostics failed: ${error}`, true); + state.applyDiagnosticsResult({ state: "blocked" }); + views.setStatus(status.diagnostics, `Diagnostics failed: ${error}`, true); } finally { - stopLoading(outputs.diagnostics); - scrollIntoViewSmooth(outputs.diagnostics); + renderShell(); + views.stopLoading(outputs.diagnostics); + views.scrollIntoViewSmooth(outputs.diagnostics); } } -async function checkRuntime() { - setStatus(status.runtime, "Checking runtime..."); - startLoading(outputs.runtime); +const handleDiagnosticsRun = handleDiagnostics; + +async function handleRuntimeCheck() { + if (!state.beginRuntimeCheck()) { + renderShell(); + views.setStatus( + status.runtime, + "Complete prerequisites and refresh the configuration before running runtime checks.", + true, + ); + return; + } + + renderShell(); + views.setStatus(status.runtime, "Checking runtime..."); + views.startLoading(outputs.runtime); try { - const runtime = await apiFetch("runtime"); - outputs.runtime.textContent = JSON.stringify(runtime, null, 2); - setStatus(status.runtime, "Runtime check completed."); + const runtime = await api.checkRuntime(); + const runtimeView = toRuntimeView(runtime); + + state.completeRuntimeCheck(runtimeView); + renderRuntimeState(runtimeView, runtime); + views.setStatus(status.runtime, "Runtime check completed."); } catch (error) { - setStatus(status.runtime, `Runtime check failed: ${error}`, true); + state.completeRuntimeCheck({ state: "blocked" }); + renderRuntimeState({ state: "blocked" }, { reachable: false, error: String(error) }); + views.setStatus(status.runtime, `Runtime check failed: ${error}`, true); } finally { - stopLoading(outputs.runtime); - scrollIntoViewSmooth(outputs.runtime); + renderShell(); + views.stopLoading(outputs.runtime); + views.scrollIntoViewSmooth(outputs.runtime); } } -async function bootstrapModels() { - setStatus(status.runtime, "Bootstrapping models..."); - startLoading(outputs.runtime); +async function handleBootstrapModels() { + if (!state.beginRuntimeCheck()) { + renderShell(); + views.setStatus( + status.runtime, + "Complete prerequisites and refresh the configuration before bootstrapping models.", + true, + ); + return; + } + + renderShell(); + views.setStatus(status.runtime, "Bootstrapping models..."); + views.startLoading(outputs.runtime); try { - const report = await apiFetch("setup/bootstrap", { method: "POST" }); - outputs.runtime.textContent = JSON.stringify(report, null, 2); - setStatus(status.runtime, "Models are ready."); + const report = await api.bootstrapModels(); + const runtime = await api.checkRuntime(); + const runtimeView = toRuntimeView(runtime); + + state.completeRuntimeCheck(runtimeView); + renderRuntimeState(runtimeView, { ...runtime, bootstrap: report }); + views.setStatus(status.runtime, "Models are ready."); } catch (error) { - setStatus(status.runtime, `Bootstrap failed: ${error}`, true); + state.completeRuntimeCheck({ state: "blocked" }); + renderRuntimeState({ state: "blocked" }, { reachable: false, error: String(error) }); + views.setStatus(status.runtime, `Bootstrap failed: ${error}`, true); } finally { - stopLoading(outputs.runtime); - scrollIntoViewSmooth(outputs.runtime); + renderShell(); + views.stopLoading(outputs.runtime); + views.scrollIntoViewSmooth(outputs.runtime); } } -async function runProcess() { +async function handleProcessRun() { const sourceUrl = fields.processSource.value.trim(); if (!sourceUrl) { - setStatus(status.process, "Please provide a source URL or local path.", true); + views.setStatus(status.process, "Please provide a source URL or local path.", true); + return; + } + + if (!state.beginProcess()) { + renderShell(); + views.setStatus(status.process, "Complete the runtime check before processing a source.", true); return; } - setStatus(status.process, "Processing source... this may take a while."); - startLoading(outputs.process); + renderShell(); + views.setStatus(status.process, "Processing source... this may take a while."); + views.startLoading(outputs.process); try { - const payload = { + const result = await api.runProcess({ source_url: sourceUrl, title: fields.processTitle.value.trim() || null, - }; - const result = await apiFetch("process", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), }); - outputs.process.textContent = JSON.stringify(result, null, 2); - setStatus(status.process, "Processing completed."); + const summaryView = buildProcessSummaryView(result); + state.markProcessSuccess(result); + renderProcessState(summaryView, summaryView); + views.setStatus(status.process, "Processing completed."); } catch (error) { - setStatus(status.process, `Process request failed: ${error}`, true); + state.markProcessFailure(error); + const priorSummary = state.steps.process.summary + ? buildProcessSummaryView(state.steps.process.summary, true) + : null; + const failureView = buildProcessFailureView(error, priorSummary); + const summaryView = priorSummary || failureView; + renderProcessState(summaryView, failureView); + views.setStatus(status.process, `Process request failed: ${error}`, true); } finally { - stopLoading(outputs.process); - scrollIntoViewSmooth(outputs.process); + renderShell(); + views.stopLoading(outputs.process); + views.scrollIntoViewSmooth(outputs.process); } } -/* ─── Hydrate defaults ──────────────────────────────────────── */ -function hydrateDefaults() { - fields.bind.value = defaults.bind_address || "127.0.0.1:8080"; - fields.apiKey.value = defaults.api_key || ""; - fields.storage.value = defaults.storage_dir || ".data"; - fields.db.value = defaults.database_path || ".data/vra.db"; - fields.ollama.value = defaults.ollama_base_url || "http://127.0.0.1:11434"; - fields.modelPrimary.value = defaults.model_primary || "qwen3.5:4b-q4_K_M"; - fields.modelFallback.value = defaults.model_fallback || "qwen3.5:2b-q4_K_M"; - fields.modelMin.value = defaults.model_min || "qwen3.5:0.8b-q8_0"; - fields.autoPullModels.checked = Boolean(defaults.auto_pull_models); - fields.budget.value = defaults.vram_budget_mb ?? 8192; - fields.budgetRatio.value = defaults.model_size_budget_ratio ?? 0.75; - fields.reserve.value = defaults.model_selection_reserve_mb ?? 768; - fields.contextTokens.value = defaults.context_tokens ?? 3072; - fields.outputTokens.value = defaults.max_output_tokens ?? 768; - fields.maxFrames.value = defaults.max_frames ?? 5; - fields.sceneDetection.checked = Boolean(defaults.frame_scene_detection); - fields.sceneThreshold.value = defaults.frame_scene_threshold ?? 0.28; - fields.sceneMin.value = defaults.frame_scene_min_frames ?? 2; - fields.maxTranscript.value = defaults.max_transcript_chars ?? 16000; - fields.transcriptRetention.value = defaults.transcript_retention_per_video ?? 3; - fields.summaryRetention.value = defaults.summary_retention_per_video ?? 5; - fields.rssTitle.value = defaults.rss_title || "Video RSS Aggregator"; - fields.rssLink.value = defaults.rss_link || "http://127.0.0.1:8080/rss"; - fields.rssDescription.value = defaults.rss_description || "Video summaries"; - fields.processSource.value = "https://www.youtube.com/watch?v=dQw4w9WgXcQ"; - renderEnvBlock(); +function bindEvents() { + document.getElementById("copy-env").addEventListener("click", copyEnvBlock); + document.getElementById("run-diagnostics").addEventListener("click", handleDiagnosticsRun); + document.getElementById("runtime-check").addEventListener("click", handleRuntimeCheck); + document.getElementById("bootstrap-models").addEventListener("click", handleBootstrapModels); + document.getElementById("process-run").addEventListener("click", handleProcessRun); + views.bindEnvInputs(fields, syncConfigurationProgress); } -/* ─── Button press feedback ─────────────────────────────────── */ -function addPressEffect(button) { - button.addEventListener("mousedown", () => { - button.classList.remove("pressing"); - void button.offsetWidth; - button.classList.add("pressing"); - }); - - button.addEventListener("animationend", () => { - button.classList.remove("pressing"); - }); +function boot() { + state.hydrateDefaults(); + renderShell(); + syncConfigurationProgress(); + renderRuntimeState(); + renderProcessState(); + bindEvents(); + views.bindMobileStepToggle(); + views.bindPressEffects(); + views.bindRevealCards(); } -document.querySelectorAll("button").forEach(addPressEffect); - -/* ─── Event bindings ────────────────────────────────────────── */ -document.getElementById("copy-env").addEventListener("click", copyEnvBlock); -document.getElementById("run-diagnostics").addEventListener("click", runDiagnostics); -document.getElementById("runtime-check").addEventListener("click", checkRuntime); -document.getElementById("bootstrap-models").addEventListener("click", bootstrapModels); -document.getElementById("process-run").addEventListener("click", runProcess); - -Object.values(fields).forEach((node) => { - if (!node || node === fields.processSource || node === fields.processTitle) { - return; - } - node.addEventListener("input", renderEnvBlock); - node.addEventListener("change", renderEnvBlock); -}); - -/* ─── Intersection Observer — staggered card reveal ─────────── */ -const revealObserver = new IntersectionObserver( - (entries) => { - entries.forEach((entry) => { - if (entry.isIntersecting) { - const card = entry.target; - const delay = getComputedStyle(card).getPropertyValue("--reveal-delay").trim(); - const ms = parseInt(delay, 10) || 0; - setTimeout(() => { - card.classList.add("revealed"); - }, ms); - revealObserver.unobserve(card); - } - }); - }, - { threshold: 0.08 } -); - -document.querySelectorAll(".card").forEach((card) => { - revealObserver.observe(card); -}); - -/* ─── Boot ──────────────────────────────────────────────────── */ -hydrateDefaults(); -runDiagnostics(); -loadSetupConfig(); +boot(); diff --git a/video_rss_aggregator/static/setup_api.js b/video_rss_aggregator/static/setup_api.js new file mode 100644 index 0000000..12cd455 --- /dev/null +++ b/video_rss_aggregator/static/setup_api.js @@ -0,0 +1,55 @@ +export function createSetupApi({ fetchImpl, getAuthHeaders }) { + async function apiFetch(path, options = {}) { + const headers = { + ...(options.headers || {}), + ...getAuthHeaders(), + }; + + const response = await fetchImpl(path, { + ...options, + headers, + }); + + const bodyText = await response.text(); + let data; + try { + data = JSON.parse(bodyText); + } catch { + data = bodyText; + } + + if (!response.ok) { + throw new Error(typeof data === "string" ? data : JSON.stringify(data, null, 2)); + } + + return data; + } + + function runDiagnostics() { + return apiFetch("setup/diagnostics"); + } + + function checkRuntime() { + return apiFetch("runtime"); + } + + function bootstrapModels() { + return apiFetch("setup/bootstrap", { method: "POST" }); + } + + function runProcess(payload) { + return apiFetch("process", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + } + + return { + apiFetch, + bootstrapModels, + checkRuntime, + runDiagnostics, + runProcess, + }; +} diff --git a/video_rss_aggregator/static/setup_state.js b/video_rss_aggregator/static/setup_state.js new file mode 100644 index 0000000..d877371 --- /dev/null +++ b/video_rss_aggregator/static/setup_state.js @@ -0,0 +1,216 @@ +function asBool(value) { + return value ? "true" : "false"; +} + +const STEP_ORDER = ["prerequisites", "configuration", "runtime", "process"]; + +export function createSetupState({ defaults = {}, fields = {} } = {}) { + const steps = { + prerequisites: { state: "unverified" }, + configuration: { state: "ready" }, + runtime: { state: "unverified" }, + process: { state: "unverified", stale: false, summary: null, error: null }, + }; + const shellState = { steps }; + + function getStepStatus(stepId) { + if (stepId === "configuration" && steps.prerequisites.state !== "complete") { + return "blocked"; + } + + if (stepId === "runtime" && steps.configuration.state !== "complete") { + return "blocked"; + } + + if (stepId === "process" && steps.runtime.state !== "complete") { + return "blocked"; + } + + return steps[stepId].state; + } + + function getActiveStep() { + const runningStep = STEP_ORDER.find((stepId) => steps[stepId].state === "running"); + if (runningStep) { + return runningStep; + } + + const failedStep = STEP_ORDER.find((stepId) => steps[stepId].state === "failed"); + if (failedStep) { + return failedStep; + } + + return getRecommendedNextStep() || "process"; + } + + function getShellState() { + return { + activeStep: getActiveStep(), + steps: STEP_ORDER.map((stepId) => ({ + id: stepId, + status: getStepStatus(stepId), + })), + }; + } + + function buildEnvLines() { + const apiKey = fields.apiKey.value.trim(); + const lines = [ + "# Video RSS Aggregator generated configuration", + `BIND_ADDRESS=${fields.bind.value.trim()}`, + `VRA_STORAGE_DIR=${fields.storage.value.trim()}`, + `VRA_DATABASE_PATH=${fields.db.value.trim()}`, + `VRA_OLLAMA_BASE_URL=${fields.ollama.value.trim()}`, + `VRA_MODEL_PRIMARY=${fields.modelPrimary.value.trim()}`, + `VRA_MODEL_FALLBACK=${fields.modelFallback.value.trim()}`, + `VRA_MODEL_MIN=${fields.modelMin.value.trim()}`, + `VRA_AUTO_PULL_MODELS=${asBool(fields.autoPullModels.checked)}`, + `VRA_VRAM_BUDGET_MB=${fields.budget.value.trim()}`, + `VRA_MODEL_SIZE_BUDGET_RATIO=${fields.budgetRatio.value.trim()}`, + `VRA_MODEL_SELECTION_RESERVE_MB=${fields.reserve.value.trim()}`, + `VRA_CONTEXT_TOKENS=${fields.contextTokens.value.trim()}`, + `VRA_MAX_OUTPUT_TOKENS=${fields.outputTokens.value.trim()}`, + `VRA_MAX_FRAMES=${fields.maxFrames.value.trim()}`, + `VRA_FRAME_SCENE_DETECTION=${asBool(fields.sceneDetection.checked)}`, + `VRA_FRAME_SCENE_THRESHOLD=${fields.sceneThreshold.value.trim()}`, + `VRA_FRAME_SCENE_MIN_FRAMES=${fields.sceneMin.value.trim()}`, + `VRA_MAX_TRANSCRIPT_CHARS=${fields.maxTranscript.value.trim()}`, + `VRA_TRANSCRIPT_RETENTION_PER_VIDEO=${fields.transcriptRetention.value.trim()}`, + `VRA_SUMMARY_RETENTION_PER_VIDEO=${fields.summaryRetention.value.trim()}`, + `VRA_RSS_TITLE=${fields.rssTitle.value.trim()}`, + `VRA_RSS_LINK=${fields.rssLink.value.trim()}`, + `VRA_RSS_DESCRIPTION=${fields.rssDescription.value.trim()}`, + ]; + + if (apiKey) { + lines.splice(2, 0, `API_KEY=${apiKey}`); + } + + return lines; + } + + function authHeaders() { + const token = fields.apiKey.value.trim(); + return token ? { "X-API-Key": token } : {}; + } + + function beginDiagnosticsCheck() { + steps.prerequisites.state = "running"; + } + + function applyDiagnosticsResult(view) { + const diagnosticsReady = view.state === "ready"; + + steps.prerequisites.state = diagnosticsReady ? "complete" : "blocked"; + steps.configuration.state = "ready"; + steps.runtime.state = "unverified"; + steps.process = { ...steps.process, state: "unverified", error: null }; + } + + function markConfigurationComplete() { + if (steps.prerequisites.state !== "complete") { + return false; + } + + steps.configuration.state = "complete"; + return true; + } + + function beginRuntimeCheck() { + if (steps.prerequisites.state !== "complete") { + return false; + } + + if (steps.configuration.state !== "complete") { + return false; + } + + steps.runtime.state = "running"; + return true; + } + + function completeRuntimeCheck(view) { + const runtimeReady = view.state === "ready"; + + steps.runtime.state = runtimeReady ? "complete" : "blocked"; + steps.process = { + ...steps.process, + state: "unverified", + error: runtimeReady ? null : steps.process.error, + }; + } + + function beginProcess() { + if (getStepStatus("runtime") !== "complete") { + return false; + } + + steps.process.state = "running"; + return true; + } + + function markProcessSuccess(summary) { + steps.process = { state: "complete", stale: false, summary, error: null }; + } + + function markProcessFailure(error) { + steps.process = { + ...steps.process, + state: "failed", + stale: Boolean(steps.process.summary), + error, + }; + } + + function getRecommendedNextStep() { + return STEP_ORDER.find((stepId) => + ["ready", "unverified", "blocked"].includes(getStepStatus(stepId)), + ); + } + + function hydrateDefaults() { + fields.bind.value = defaults.bind_address || "127.0.0.1:8080"; + fields.apiKey.value = defaults.api_key || ""; + fields.storage.value = defaults.storage_dir || ".data"; + fields.db.value = defaults.database_path || ".data/vra.db"; + fields.ollama.value = defaults.ollama_base_url || "http://127.0.0.1:11434"; + fields.modelPrimary.value = defaults.model_primary || "qwen3.5:4b-q4_K_M"; + fields.modelFallback.value = defaults.model_fallback || "qwen3.5:2b-q4_K_M"; + fields.modelMin.value = defaults.model_min || "qwen3.5:0.8b-q8_0"; + fields.autoPullModels.checked = Boolean(defaults.auto_pull_models); + fields.budget.value = defaults.vram_budget_mb ?? 8192; + fields.budgetRatio.value = defaults.model_size_budget_ratio ?? 0.75; + fields.reserve.value = defaults.model_selection_reserve_mb ?? 768; + fields.contextTokens.value = defaults.context_tokens ?? 3072; + fields.outputTokens.value = defaults.max_output_tokens ?? 768; + fields.maxFrames.value = defaults.max_frames ?? 5; + fields.sceneDetection.checked = Boolean(defaults.frame_scene_detection); + fields.sceneThreshold.value = defaults.frame_scene_threshold ?? 0.28; + fields.sceneMin.value = defaults.frame_scene_min_frames ?? 2; + fields.maxTranscript.value = defaults.max_transcript_chars ?? 16000; + fields.transcriptRetention.value = defaults.transcript_retention_per_video ?? 3; + fields.summaryRetention.value = defaults.summary_retention_per_video ?? 5; + fields.rssTitle.value = defaults.rss_title || "Video RSS Aggregator"; + fields.rssLink.value = defaults.rss_link || "http://127.0.0.1:8080/rss"; + fields.rssDescription.value = defaults.rss_description || "Video summaries"; + fields.processSource.value = "https://www.youtube.com/watch?v=dQw4w9WgXcQ"; + } + + return { + authHeaders, + beginDiagnosticsCheck, + beginProcess, + beginRuntimeCheck, + buildEnvLines, + applyDiagnosticsResult, + completeRuntimeCheck, + getShellState, + getRecommendedNextStep, + hydrateDefaults, + markConfigurationComplete, + markProcessFailure, + markProcessSuccess, + shellState, + steps, + }; +} diff --git a/video_rss_aggregator/static/setup_view_models.js b/video_rss_aggregator/static/setup_view_models.js new file mode 100644 index 0000000..9ec1935 --- /dev/null +++ b/video_rss_aggregator/static/setup_view_models.js @@ -0,0 +1,164 @@ +function toTitleCase(value) { + return String(value || "") + .split(/[-_\s]+/) + .filter(Boolean) + .map((token) => token.charAt(0).toUpperCase() + token.slice(1)) + .join(" "); +} + +function formatCount(value, noun) { + if (value === null || value === undefined || value === "") { + return `${noun} not reported`; + } + + const count = Number(value); + if (Number.isNaN(count)) { + return `${value} ${noun}`; + } + + return `${count} ${noun}${count === 1 ? "" : "s"}`; +} + +function collectProcessFrameCount(result) { + if (typeof result.frame_count === "number") { + return result.frame_count; + } + + if (typeof result.frames_processed === "number") { + return result.frames_processed; + } + + if (typeof result.selected_frame_count === "number") { + return result.selected_frame_count; + } + + if (Array.isArray(result.frames)) { + return result.frames.length; + } + + return null; +} + +function collectTranscriptChars(result) { + if (typeof result.transcript_chars === "number") { + return result.transcript_chars; + } + + if (typeof result.transcript_length === "number") { + return result.transcript_length; + } + + if (typeof result.transcript === "string") { + return result.transcript.length; + } + + return null; +} + +function collectProcessModel(result) { + return ( + result.model || + result.model_name || + result.summary_model || + result.selected_model || + "Model not reported" + ); +} + +function describeScalar(value) { + if (value === null || value === undefined || value === "") { + return "Not reported"; + } + + if (typeof value === "boolean") { + return value ? "Yes" : "No"; + } + + return String(value); +} + +function buildDetailRows(payload) { + return Object.entries(payload || {}) + .filter(([, value]) => value !== null && value !== undefined) + .map(([key, value]) => { + if (typeof value === "object") { + return `${toTitleCase(key)}: ${JSON.stringify(value, null, 2)}`; + } + + return `${toTitleCase(key)}: ${value}`; + }); +} + +function stringifyPayload(payload) { + return JSON.stringify(payload, null, 2); +} + +export function buildShellSummaryView(shellState) { + const activeStep = shellState.steps.find((step) => step.id === shellState.activeStep); + const activeStepLabel = activeStep ? toTitleCase(activeStep.id) : "Prerequisites"; + const blockedSteps = shellState.steps.filter((step) => step.status === "blocked"); + + return { + activeStepId: shellState.activeStep, + activeStepLabel, + readinessTitle: "Readiness", + readinessBody: `Start with ${activeStepLabel.toLowerCase()}.`, + blockerTitle: "Current blockers", + blockerBody: + blockedSteps.length > 0 + ? `${blockedSteps.length} step blockers need attention.` + : "Diagnostics and runtime checks will surface anything still missing.", + activeStepEyebrow: "Active step", + activeStepTitle: `Focus: ${activeStepLabel}`, + activeStepBody: "Move through the workbench one step at a time.", + }; +} + +export function buildStaleSummaryLabel() { + return "Showing the last successful result while the latest rerun failed."; +} + +export function buildProcessSummaryView(result, stale = false) { + if (!result || typeof result !== "object") { + const summaryLines = [String(result)]; + + return { + heading: stale ? "Previous successful run" : "Process result", + title: "No successful process result yet", + model: "Model not reported", + frameCount: "Frames not reported", + transcriptChars: "Transcript not reported", + source: "Not reported", + feedUrl: "Not reported", + staleLabel: stale ? buildStaleSummaryLabel() : "", + detailLines: summaryLines, + raw: stringifyPayload(result), + }; + } + + const summaryLines = buildDetailRows(result); + + return { + heading: stale ? "Previous successful run" : "Latest successful run", + title: describeScalar(result.title || result.source_title || result.video_title || "Untitled source"), + model: describeScalar(collectProcessModel(result)), + frameCount: formatCount(collectProcessFrameCount(result), "frame"), + transcriptChars: formatCount(collectTranscriptChars(result), "transcript char"), + source: describeScalar(result.source_url || result.url || result.source_path), + feedUrl: describeScalar(result.feed_url || result.rss_url), + staleLabel: stale ? buildStaleSummaryLabel() : "", + detailLines: summaryLines, + raw: stringifyPayload(result), + }; +} + +export function buildProcessFailureView(error, priorSummary = null) { + const message = error instanceof Error ? error.message : String(error); + + return { + heading: "Latest run failed", + message, + staleLabel: priorSummary ? buildStaleSummaryLabel() : "", + detailLines: [`Error: ${message}`], + }; +} diff --git a/video_rss_aggregator/static/setup_views.js b/video_rss_aggregator/static/setup_views.js new file mode 100644 index 0000000..fa5d460 --- /dev/null +++ b/video_rss_aggregator/static/setup_views.js @@ -0,0 +1,405 @@ +export function createSetupViews({ document, outputs, status, toastEl }) { + let toastTimer = null; + + function appendTextNode(parent, tagName, text, className = "") { + const node = document.createElement(tagName); + if (className) { + node.className = className; + } + node.textContent = text; + parent.appendChild(node); + return node; + } + + function buildSummaryGrid(rows) { + const list = document.createElement("dl"); + list.className = "summary-grid"; + + rows.forEach(([label, value]) => { + const wrapper = document.createElement("div"); + const term = document.createElement("dt"); + const detail = document.createElement("dd"); + + term.textContent = label; + detail.textContent = value; + wrapper.append(term, detail); + list.appendChild(wrapper); + }); + + return list; + } + + function showToast(message, duration = 2200) { + toastEl.textContent = message; + toastEl.classList.add("visible"); + clearTimeout(toastTimer); + toastTimer = setTimeout(() => { + toastEl.classList.remove("visible"); + }, duration); + } + + function setStatus(target, message, isError = false) { + target.style.opacity = "0"; + requestAnimationFrame(() => { + target.textContent = message; + target.classList.toggle("error", isError); + target.style.opacity = ""; + target.classList.remove("fade-in"); + void target.offsetWidth; + target.classList.add("fade-in"); + }); + } + + function startLoading(preEl) { + preEl.classList.add("loading"); + } + + function stopLoading(preEl) { + preEl.classList.remove("loading"); + } + + function scrollIntoViewSmooth(el) { + el.scrollIntoView({ behavior: "smooth", block: "nearest" }); + } + + function renderEnvBlock(lines) { + outputs.env.textContent = lines.join("\n"); + } + + function renderProgress(shellState) { + document.querySelectorAll(".step-item").forEach((stepEl) => { + const stepId = stepEl.dataset.stepId; + const step = shellState.steps.find((item) => item.id === stepId); + if (!step) { + return; + } + + const panel = document.getElementById(`step-panel-${stepId}`); + const state = step.id === shellState.activeStep ? "active" : step.status; + + stepEl.setAttribute("data-state", state); + if (panel) { + panel.setAttribute("data-state", state); + } + }); + } + + function renderActiveStep(shellSummaryView) { + const activeStepCopy = document.getElementById("active-step-copy"); + const eyebrow = document.createElement("p"); + eyebrow.className = "eyebrow"; + eyebrow.textContent = shellSummaryView.activeStepEyebrow; + const heading = document.createElement("h2"); + heading.textContent = shellSummaryView.activeStepTitle; + const body = document.createElement("p"); + body.className = "muted"; + body.textContent = shellSummaryView.activeStepBody; + activeStepCopy.replaceChildren(eyebrow, heading, body); + } + + function renderShellState(shellState, shellSummaryView) { + renderProgress(shellState); + + const readinessSummary = document.getElementById("readiness-summary"); + const blockerSummary = document.getElementById("blocker-summary"); + + const readinessHeading = document.createElement("h2"); + readinessHeading.textContent = shellSummaryView.readinessTitle; + const readinessBody = document.createElement("p"); + readinessBody.className = "muted"; + readinessBody.textContent = shellSummaryView.readinessBody; + readinessSummary.replaceChildren(readinessHeading, readinessBody); + + const blockerHeading = document.createElement("h2"); + blockerHeading.textContent = shellSummaryView.blockerTitle; + const blockerBody = document.createElement("p"); + blockerBody.className = "muted"; + blockerBody.textContent = shellSummaryView.blockerBody; + blockerSummary.replaceChildren(blockerHeading, blockerBody); + renderActiveStep(shellSummaryView); + } + + function renderDiagnosticsSummary(report) { + const blockers = Array.isArray(report?.blockers) ? report.blockers : []; + const warningChecks = Array.isArray(report?.checks) + ? report.checks.filter((check) => check.state !== "complete") + : []; + const lines = [ + report.ready + || report?.state === "ready" + ? "Prerequisites look ready for configuration and runtime checks." + : "Diagnostics found blockers you should address before continuing.", + ]; + + if (blockers.length > 0) { + lines.push(`Fixes: ${blockers.join(" | ")}`); + } else if (Array.isArray(report?.missing) && report.missing.length > 0) { + lines.push(`Missing: ${report.missing.join(", ")}`); + } + + if (warningChecks.length > 0) { + lines.push(`Checks: ${warningChecks.map((check) => `${check.label}: ${check.detail}`).join(" | ")}`); + } else if (Array.isArray(report?.warnings) && report.warnings.length > 0) { + lines.push(`Warnings: ${report.warnings.join(" | ")}`); + } + + outputs.diagnostics.textContent = lines.join("\n"); + } + + function renderCommonFixes(view) { + const container = document.getElementById("common-fixes"); + const heading = document.createElement("h2"); + heading.textContent = "Common fixes"; + + const fixes = Array.isArray(view?.checks) + ? view.checks.filter((check) => check.fix) + : Array.isArray(view?.missing_models) + ? view.missing_models.map((model) => ({ + label: model, + detail: "Required locally before processing can start.", + fix: `Pull or bootstrap ${model}`, + })) + : []; + + if (fixes.length === 0) { + const body = document.createElement("p"); + body.className = "muted"; + body.textContent = view?.next_action || "Quick links and remediation steps will appear here."; + container.replaceChildren(heading, body); + return; + } + + const list = document.createElement("ol"); + fixes.forEach((item) => { + const row = document.createElement("li"); + const title = document.createElement("strong"); + title.textContent = item.label || "Fix"; + const detail = document.createElement("p"); + detail.className = "muted"; + detail.textContent = item.detail || item.fix; + const fix = document.createElement("p"); + fix.textContent = item.fix; + row.append(title, detail, fix); + list.appendChild(row); + }); + + container.replaceChildren(heading, list); + } + + function renderRuntimeSummary(runtimeView, runtime) { + const summary = document.getElementById("runtime-summary"); + const isUnverified = !runtimeView || runtimeView.state === "unverified"; + + if (isUnverified) { + summary.setAttribute("data-state", "unverified"); + const eyebrow = appendTextNode(summary, "p", "Runtime summary", "eyebrow"); + const heading = appendTextNode(summary, "h3", "Runtime not checked yet"); + const body = appendTextNode( + summary, + "p", + "Run a runtime check or bootstrap models to confirm connectivity and local model availability.", + "muted", + ); + summary.replaceChildren(eyebrow, heading, body); + return; + } + + const requiredModels = Array.isArray(runtime?.models) ? runtime.models : []; + const localModels = Array.isArray(runtime?.local_models) + ? runtime.local_models + : Object.keys(runtime?.local_models || {}); + const databasePath = runtime?.database_path || "Database path not reported"; + const storageDir = runtime?.storage_dir || "Storage directory not reported"; + const eyebrow = document.createElement("p"); + eyebrow.className = "eyebrow"; + eyebrow.textContent = "Runtime summary"; + const heading = document.createElement("h3"); + heading.textContent = runtimeView?.state === "ready" ? "Runtime ready for processing" : "Runtime needs local model setup"; + const body = document.createElement("p"); + body.className = "muted"; + body.textContent = runtimeView?.next_action || "Check runtime connectivity and model readiness."; + const grid = buildSummaryGrid([ + ["Required models", `${requiredModels.length} configured`], + ["Available locally", `${localModels.length} ready`], + ["Database", databasePath], + ["Storage", storageDir], + ]); + + summary.setAttribute("data-state", runtimeView?.state === "ready" ? "complete" : "blocked"); + summary.replaceChildren(eyebrow, heading, body, grid); + } + + function renderRuntimeDetails(runtime) { + outputs.runtime.textContent = JSON.stringify(runtime, null, 2); + } + + function renderProcessSummary(summaryView) { + const summary = document.getElementById("process-summary"); + const isStale = Boolean(summaryView?.staleLabel); + const isFailure = Boolean(summaryView?.message); + + summary.setAttribute( + "data-state", + isFailure ? "failed" : isStale ? "stale" : summaryView?.title ? "complete" : "unverified", + ); + + if (!summaryView) { + const eyebrow = document.createElement("p"); + eyebrow.className = "eyebrow"; + eyebrow.textContent = "Processing summary"; + const heading = document.createElement("h3"); + heading.textContent = "No processing run yet"; + const body = document.createElement("p"); + body.className = "muted"; + body.textContent = "Run one source to see a human-readable result here."; + summary.replaceChildren(eyebrow, heading, body); + return; + } + + const children = []; + const eyebrow = document.createElement("p"); + eyebrow.className = "eyebrow"; + eyebrow.textContent = summaryView.heading || "Processing summary"; + children.push(eyebrow); + + const heading = document.createElement("h3"); + heading.textContent = summaryView.title || "Process run"; + children.push(heading); + + if (summaryView.staleLabel) { + const staleChip = document.createElement("p"); + staleChip.className = "stale-chip"; + staleChip.textContent = summaryView.staleLabel; + children.push(staleChip); + } + + if (summaryView.message) { + const message = document.createElement("p"); + message.className = "muted"; + message.textContent = summaryView.message; + children.push(message); + } + + children.push( + buildSummaryGrid([ + ["Model", summaryView.model || "Model not reported"], + ["Frames", summaryView.frameCount || "Frames not reported"], + ["Transcript", summaryView.transcriptChars || "Transcript not reported"], + ["Source", summaryView.source || "Not reported"], + ["Feed", summaryView.feedUrl || "Not reported"], + ]), + ); + + summary.replaceChildren(...children); + } + + function renderProcessDetails(detailView) { + if (typeof detailView === "string") { + outputs.process.textContent = detailView; + return; + } + + if (Array.isArray(detailView?.detailLines)) { + outputs.process.textContent = detailView.detailLines.join("\n"); + return; + } + + if (detailView?.raw) { + outputs.process.textContent = detailView.raw; + return; + } + + outputs.process.textContent = String(detailView || ""); + } + + function addPressEffect(button) { + button.addEventListener("mousedown", () => { + button.classList.remove("pressing"); + void button.offsetWidth; + button.classList.add("pressing"); + }); + + button.addEventListener("animationend", () => { + button.classList.remove("pressing"); + }); + } + + function bindPressEffects() { + document.querySelectorAll("button").forEach(addPressEffect); + } + + function bindEnvInputs(fields, onUpdate) { + Object.values(fields).forEach((node) => { + if (!node || node === fields.processSource || node === fields.processTitle) { + return; + } + node.addEventListener("input", onUpdate); + node.addEventListener("change", onUpdate); + }); + } + + function bindRevealCards() { + const revealObserver = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + const card = entry.target; + const delay = getComputedStyle(card).getPropertyValue("--reveal-delay").trim(); + const ms = parseInt(delay, 10) || 0; + setTimeout(() => { + card.classList.add("revealed"); + }, ms); + revealObserver.unobserve(card); + } + }); + }, + { threshold: 0.08 } + ); + + document.querySelectorAll(".card").forEach((card) => { + revealObserver.observe(card); + }); + } + + function bindMobileStepToggle() { + const mobileStepToggle = document.getElementById("mobile-step-toggle"); + const progressRail = document.getElementById("setup-progress"); + + if (!mobileStepToggle || !progressRail) { + return; + } + + function setExpanded(expanded) { + progressRail.dataset.mobileOpen = expanded ? "true" : "false"; + mobileStepToggle.setAttribute("aria-expanded", String(expanded)); + mobileStepToggle.textContent = expanded ? "Hide setup steps" : "Open setup steps"; + } + + setExpanded(progressRail.dataset.mobileOpen === "true"); + mobileStepToggle.addEventListener("click", () => { + setExpanded(progressRail.dataset.mobileOpen !== "true"); + }); + } + + return { + bindEnvInputs, + bindMobileStepToggle, + bindPressEffects, + bindRevealCards, + renderActiveStep, + renderDiagnosticsSummary, + renderEnvBlock, + renderCommonFixes, + renderProcessDetails, + renderProcessSummary, + renderProgress, + renderRuntimeDetails, + renderRuntimeSummary, + renderShellState, + scrollIntoViewSmooth, + setStatus, + showToast, + startLoading, + stopLoading, + }; +} diff --git a/video_rss_aggregator/templates/setup.html b/video_rss_aggregator/templates/setup.html index 9126eaf..a6f7141 100644 --- a/video_rss_aggregator/templates/setup.html +++ b/video_rss_aggregator/templates/setup.html @@ -9,7 +9,7 @@ -
+

Setup Studio

Video RSS Aggregator Installation + Configuration

@@ -19,92 +19,158 @@

Video RSS Aggregator Installation + Configuration

-
-
-

1 Install Prerequisites

-

Follow this sequence before starting API operations.

-
    -
  1. Install Python 3.11+ and make sure python is on PATH.
  2. -
  3. Install Ollama for Windows and open it once so the API is online.
  4. -
  5. Install FFmpeg/FFprobe and confirm both commands resolve in terminal.
  6. -
  7. In this repo run pip install -e ., then python -m vra bootstrap.
  8. -
  9. Run python -m vra serve --bind <bind address> to keep this GUI active.
  10. -
-
- -
-

-        

-
- -
-

2 Configuration Builder

-

Edit the full runtime surface and copy the generated .env block.

-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- - +
+
+
    +
  1. + 1 +
    + Prerequisites +

    Install Python, Ollama, FFmpeg, and project dependencies.

    +
    +
  2. +
  3. + 2 +
    + Configuration +

    Build the environment block and review advanced settings.

    +
    +
  4. +
  5. + 3 +
    + Runtime +

    Validate Ollama connectivity and bootstrap required models.

    +
    +
  6. +
  7. + 4 +
    + Process +

    Run one end-to-end request and inspect the generated output.

    +
    +
  8. +
+ + -
-

3 Runtime Validation

-

Validate the live Ollama connection and bootstrap models.

-
- - -
-

-        

-
+
+ +
+

Active step

+

Guided workbench

+

Move from prerequisite checks through the first live processing run.

+
-
-

4 First Processing Run

-

Process a URL or local media path and inspect summary outputs.

-
-
- - +
+

1 Install Prerequisites

+

Follow this sequence before starting API operations.

+
    +
  1. Install Python 3.11+ and make sure python is on PATH.
  2. +
  3. Install Ollama for Windows and open it once so the API is online.
  4. +
  5. Install FFmpeg/FFprobe and confirm both commands resolve in terminal.
  6. +
  7. In this repo run pip install -e ., then python -m vra bootstrap.
  8. +
  9. Run python -m vra serve --bind <bind address> to keep this GUI active.
  10. +
+
+
-
- - +

+          

+
+ +
+

2 Configuration Builder

+

Edit the full runtime surface and copy the generated .env block.

+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
- -
-

-        

-
+
+ + +
+
+ Advanced configuration +

Fine-tune runtime capacity, transcript retention, and summarization behavior.

+
+
+ +
+

+          

+ + +
+

3 Runtime Validation

+

Validate the live Ollama connection and bootstrap models.

+
+ + +
+
+
+ Runtime response details +

+          
+

+
+ +
+

4 First Processing Run

+

Process a URL or local media path and inspect summary outputs.

+
+
+ + +
+
+ + +
+
+
+ +
+
+
+ Processing details +

+          
+

+
+