commit 055e350108c27590cb8b982caddc7d7cf6049b4d Author: CxAI Agent Date: Sat May 16 14:32:01 2026 -0500 feat: initial CxWebApp (macOS shell + swift-app wired to CxLLM-SDK) diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..11ff2c8 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +build/ +out/ +.vscode/ +.idea/ +DerivedData/ +.cache/ +.DS_Store +*.dSYM +*.log +node_modules/ diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml new file mode 100644 index 0000000..53046ff --- /dev/null +++ b/.gitea/workflows/build.yml @@ -0,0 +1,28 @@ +name: build-and-push +on: + push: + branches: [main, master] + tags: ['v*'] + workflow_dispatch: {} + +jobs: + image: + runs-on: cxai-hostinger + steps: + - uses: actions/checkout@v4 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - name: Login to cxai registry + uses: docker/login-action@v3 + with: + registry: registry.76-13-126-127.nip.io + username: ${{ secrets.REGISTRY_USER }} + password: ${{ secrets.REGISTRY_PASS }} + - name: Build & push + uses: docker/build-push-action@v6 + with: + context: . + push: true + tags: | + registry.76-13-126-127.nip.io/cxai/cxwebapp:latest + registry.76-13-126-127.nip.io/cxai/cxwebapp:${{ github.sha }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a5a180b --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +# Build +build/ +cmake-build-*/ +.cache/ + +# Xcode +*.xcworkspace +xcuserdata/ +DerivedData/ +*.xcodeproj/xcuserdata/ +*.xcodeproj/project.xcworkspace/xcuserdata/ + +# macOS +.DS_Store + +# Swift Package Manager (swift-app) +swift-app/.build/ +swift-app/build/ +swift-app/Package.resolved diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..372e0a2 --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,88 @@ +cmake_minimum_required(VERSION 3.22) +project(CxWebApp VERSION 1.0.0 LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_EXPORT_COMPILE_COMMANDS ON) + +# ── Dependencies via FetchContent ───────────────────────────── +include(FetchContent) + +# Asio (header-only, required by Crow) +FetchContent_Declare( + asio + GIT_REPOSITORY https://github.com/chriskohlhoff/asio.git + GIT_TAG asio-1-30-2 + GIT_SHALLOW TRUE +) +FetchContent_MakeAvailable(asio) + +# Tell Crow where to find Asio headers +set(ASIO_INCLUDE_DIR "${asio_SOURCE_DIR}/asio/include" CACHE PATH "" FORCE) + +# Crow web framework +FetchContent_Declare( + crow + GIT_REPOSITORY https://github.com/CrowCpp/Crow.git + GIT_TAG v1.2.0 + GIT_SHALLOW TRUE +) +FetchContent_MakeAvailable(crow) + +# cpp-httplib (single-header HTTP/1.1 client used by the reverse-proxy routes) +FetchContent_Declare( + cpphttplib + GIT_REPOSITORY https://github.com/yhirose/cpp-httplib.git + GIT_TAG v0.18.0 + GIT_SHALLOW TRUE +) +FetchContent_MakeAvailable(cpphttplib) + +# ── Application ─────────────────────────────────────────────── +add_executable(${PROJECT_NAME} + src/main.cpp + src/routes/api.cpp + src/routes/pages.cpp + src/routes/system.cpp + src/routes/proxy.cpp + src/routes/mac.cpp +) + +target_compile_definitions(${PROJECT_NAME} PRIVATE + CXWEBAPP_STATIC_DIR_DEFAULT="/opt/cxwebapp/share/CxWebApp/static" +) + +target_include_directories(${PROJECT_NAME} PRIVATE + ${CMAKE_SOURCE_DIR}/include + ${asio_SOURCE_DIR}/asio/include + ${cpphttplib_SOURCE_DIR} +) + +target_link_libraries(${PROJECT_NAME} PRIVATE Crow::Crow) + +# macOS: Link system frameworks +if(APPLE) + find_library(COREFOUNDATION_LIBRARY CoreFoundation) + find_library(SECURITY_LIBRARY Security) + if(COREFOUNDATION_LIBRARY) + target_link_libraries(${PROJECT_NAME} PRIVATE ${COREFOUNDATION_LIBRARY}) + endif() + if(SECURITY_LIBRARY) + target_link_libraries(${PROJECT_NAME} PRIVATE ${SECURITY_LIBRARY}) + endif() +endif() + +# Copy static assets and templates to build directory +add_custom_command(TARGET ${PROJECT_NAME} POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_directory + ${CMAKE_SOURCE_DIR}/static $/static + COMMAND ${CMAKE_COMMAND} -E copy_directory + ${CMAKE_SOURCE_DIR}/templates $/templates + COMMENT "Copying static assets and templates" +) + +# ── Install ─────────────────────────────────────────────────── +install(TARGETS ${PROJECT_NAME} DESTINATION bin) +install(DIRECTORY static/ DESTINATION share/${PROJECT_NAME}/static) +install(DIRECTORY templates/ DESTINATION share/${PROJECT_NAME}/templates) +install(DIRECTORY share/cxai-mac/ DESTINATION share/cxai-mac) diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..85bae26 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,43 @@ +# syntax=docker/dockerfile:1.7 +# Multi-stage build: cmake/clang on debian:bookworm -> debian:stable-slim runtime. + +FROM debian:bookworm AS builder +ENV DEBIAN_FRONTEND=noninteractive +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential \ + cmake \ + clang \ + git \ + ca-certificates \ + pkg-config \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /src +COPY . /src +RUN cmake -S . -B build \ + -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_C_COMPILER=clang \ + -DCMAKE_CXX_COMPILER=clang++ \ + && cmake --build build --parallel "$(nproc)" \ + && cmake --install build --prefix /opt/cxwebapp + +# ----- runtime --------------------------------------------------------------- +FROM debian:stable-slim AS runtime +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + libstdc++6 \ + curl \ + && rm -rf /var/lib/apt/lists/* \ + && useradd --system --uid 10001 --home /opt/cxwebapp --shell /usr/sbin/nologin cxwebapp + +COPY --from=builder /opt/cxwebapp /opt/cxwebapp +WORKDIR /opt/cxwebapp/bin + +ENV PORT=8080 +EXPOSE 8080 +USER cxwebapp + +HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \ + CMD curl -fsS "http://127.0.0.1:${PORT}/api/health" || exit 1 + +ENTRYPOINT ["/opt/cxwebapp/bin/CxWebApp"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..731430c --- /dev/null +++ b/README.md @@ -0,0 +1,54 @@ +# CxWebApp + +C++ web application using [Crow](https://crowcpp.org) framework, built with CMake and Xcode. + +## Prerequisites + +- macOS with Xcode and command-line tools installed +- CMake 3.22+ (`brew install cmake`) + +## Build with Xcode + +```bash +# Generate Xcode project +cmake -B build -G Xcode + +# Open in Xcode +open build/CxWebApp.xcodeproj +``` + +Build and run the `CxWebApp` scheme in Xcode (⌘R). + +## Build from command line + +```bash +cmake -B build +cmake --build build +./build/CxWebApp +``` + +## Usage + +Open **http://localhost:8080** in your browser. + +### API Endpoints + +| Method | Path | Description | +|--------|------------------|-------------------| +| GET | `/api/health` | Health check | +| GET | `/api/items` | List all items | +| GET | `/api/items/:id` | Get item by ID | +| POST | `/api/items` | Create item | +| DELETE | `/api/items/:id` | Delete item | + +### Example + +```bash +# Create +curl -X POST http://localhost:8080/api/items \ + -H "Content-Type: application/json" \ + -d '{"name":"Test","description":"A test item"}' + +# List +curl http://localhost:8080/api/items +``` diff --git a/compose/cxai-stack.yaml b/compose/cxai-stack.yaml new file mode 100644 index 0000000..7142bb2 --- /dev/null +++ b/compose/cxai-stack.yaml @@ -0,0 +1,171 @@ +# cxai-stack — full CxWebApp + sidecar services compose. +# Lives at /srv/cxai/compose/cxai-stack/compose.yaml on the VPS. +# +# All sidecars listen only on the internal `cxai` network. Only the cxwebapp +# is bound to 127.0.0.1:8085 for Caddy to TLS-terminate in front of. + +name: cxai-stack + +networks: + cxai: + driver: bridge + +volumes: + demand-reports: + slack-state: + diffusion-out: + # Forge models + outputs + huggingface cache. Large (~5GB for SD 1.5 + # base alone). Lives on /var/lib/docker/volumes on the VPS, which has + # ~360GB free. + forge-data: + +services: + cxwebapp: + image: registry.76-13-126-127.nip.io/cxai/cxwebapp:latest + pull_policy: always + container_name: cxai-cxwebapp + restart: unless-stopped + networks: [cxai] + ports: ["127.0.0.1:8085:8080"] + volumes: + # Inject the freshly-built CxAI ADE build metadata over the baked-in + # default so `/api/mac/info` reflects what's actually shipping. + - ./cxai-mac-build-info.json:/opt/cxwebapp/share/cxai-mac/build-info.json:ro + environment: + CXWEBAPP_PORT: "8080" + CXAI_DIFFUSION_UPSTREAM: "http://cxai-diffusion:8101" + CXAI_DEMAND_UPSTREAM: "http://cxai-demand:8102" + CXAI_LANG_UPSTREAM: "http://cxai-langsmith:8103" + CXAI_SLACK_UPSTREAM: "http://cxai-slack:8104" + CXAI_MAC_BUILD_INFO: "/opt/cxwebapp/share/cxai-mac/build-info.json" + CXAI_MAC_DOWNLOAD_URL: "${CXAI_MAC_DOWNLOAD_URL:-}" + healthcheck: + test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8080/api/health"] + interval: 30s + timeout: 3s + retries: 3 + + # Real Stable-Diffusion-WebUI-Forge backend, CPU-only build (no GPU on + # this VPS). Image is built from diffusion/forge/Dockerfile and serves + # the standard /sdapi/v1 surface that cxai-diffusion's ForgeClient + # talks to. First boot pulls SD 1.5 (~4GB) into forge-data. + cxai-forge: + image: registry.76-13-126-127.nip.io/cxai/cxai-forge:latest + pull_policy: always + container_name: cxai-forge + restart: unless-stopped + networks: [cxai] + volumes: + - forge-data:/data + environment: + # Reserve threads for the rest of the stack — EPYC has 8 cores. + OMP_NUM_THREADS: "${CXAI_FORGE_THREADS:-6}" + MKL_NUM_THREADS: "${CXAI_FORGE_THREADS:-6}" + healthcheck: + test: ["CMD", "curl", "-fsS", "http://127.0.0.1:7860/sdapi/v1/options"] + # Generous start period: first boot has to download SD 1.5. + start_period: 600s + interval: 30s + timeout: 10s + retries: 5 + + cxai-diffusion: + image: registry.76-13-126-127.nip.io/cxai/cxai-diffusion-svc:latest + pull_policy: always + container_name: cxai-diffusion + restart: unless-stopped + networks: [cxai] + depends_on: + cxai-forge: + condition: service_started + volumes: + - diffusion-out:/var/lib/cxai-diffusion/out + environment: + # Point at the in-network Forge container by default; override with + # CXAI_DIFFUSION_BASE_URL to swap in a remote/hosted backend. (The + # gateway reads CXAI_DIFFUSION_BASE_URL — see cxai_diffusion.config + # ._resolve_base_url. Keep this var name aligned.) + CXAI_DIFFUSION_BASE_URL: "${CXAI_DIFFUSION_BASE_URL:-http://cxai-forge:7860}" + CXAI_DIFFUSION_GATEWAY_TOKEN: "${CXAI_DIFFUSION_GATEWAY_TOKEN:-}" + CXAI_DIFFUSION_OUT_DIR: "/var/lib/cxai-diffusion/out" + CXAI_DIFFUSION_QUANTUM_SEED: "${CXAI_DIFFUSION_QUANTUM_SEED:-1}" + # qiskit/matplotlib both want a writable home; /app is owned by root + # (image is built with USER cxai uid 10001 but no /home). + MPLCONFIGDIR: "/tmp/matplotlib" + HOME: "/tmp" + healthcheck: + test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8101/healthz"] + interval: 30s + timeout: 5s + retries: 3 + + cxai-demand: + image: registry.76-13-126-127.nip.io/cxai/cxai-demand-svc:latest + pull_policy: always + container_name: cxai-demand + restart: unless-stopped + networks: [cxai] + volumes: + - demand-reports:/var/lib/cxai-demand/reports + environment: + OPENAI_API_KEY: "${OPENAI_API_KEY:-}" + CXAI_BASE_URL: "${CXAI_BASE_URL:-https://cxai-studio.com/studio/v1}" + CXAI_DRY_RUN: "${CXAI_DRY_RUN:-true}" + CXAI_DEMAND_PORT: "8102" + CXAI_DEMAND_REPORTS_DIR: "/var/lib/cxai-demand/reports" + healthcheck: + test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8102/healthz"] + interval: 30s + timeout: 5s + retries: 3 + + cxai-langsmith: + image: registry.76-13-126-127.nip.io/cxai/cxai-langsmith-svc:latest + pull_policy: always + container_name: cxai-langsmith + restart: unless-stopped + networks: [cxai] + environment: + CXBASE_TRANSPORT: "${CXBASE_TRANSPORT:-bearer}" + CXBASE_BASE_URL: "${CXBASE_BASE_URL:-https://cxai-studio.com/studio/v1}" + CXBASE_BEARER_TOKEN: "${CXBASE_BEARER_TOKEN:-freecc}" + CXAI_MCP_URL: "${CXAI_MCP_URL:-http://cxai-mac.tail91d296.ts.net/mcp}" + LANGSMITH_API_KEY: "${LANGSMITH_API_KEY:-}" + LANGSMITH_TRACING: "${LANGSMITH_TRACING:-false}" + healthcheck: + test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8103/healthz"] + interval: 30s + timeout: 5s + retries: 3 + + cxai-slack: + image: registry.76-13-126-127.nip.io/cxai/cxai-slack-svc:latest + pull_policy: always + container_name: cxai-slack + restart: unless-stopped + networks: [cxai] + volumes: + - slack-state:/var/lib/cxai-slack + environment: + SLACK_BOT_TOKEN: "${SLACK_BOT_TOKEN:-}" + SLACK_APP_TOKEN: "${SLACK_APP_TOKEN:-}" + SLACK_APP_SIGNING_SECRET: "${SLACK_APP_SIGNING_SECRET:-}" + SLACK_APP_ID: "${SLACK_APP_ID:-}" + SLACK_APP_CLIENT_ID: "${SLACK_APP_CLIENT_ID:-}" + SLACK_BOT_WEBHOOK_URL: "${SLACK_BOT_WEBHOOK_URL:-}" + USER_OAUTH_TOKEN: "${USER_OAUTH_TOKEN:-}" + OPENAI_API_KEY: "${OPENAI_API_KEY:-}" + OPENAI_BASE_URL: "${OPENAI_BASE_URL:-}" + OPENAI_MODEL: "${OPENAI_MODEL:-}" + CXAI_GATEWAY_TOKEN: "${CXAI_GATEWAY_TOKEN:-${CXBASE_BEARER_TOKEN:-freecc}}" + CXAI_GATEWAY_URL: "${CXAI_GATEWAY_URL:-${CXBASE_BASE_URL:-https://cxai-studio.com/studio/v1}}" + CXAI_GATEWAY_MODEL: "${CXAI_GATEWAY_MODEL:-${CXBASE_MCP_MODEL:-claude-sonnet-4}}" + XDG_STATE_HOME: "/var/lib/cxai-slack" + HOME: "/home/cxai" + CXAI_SANDBOX_ROOT: "/tmp/cxai-sandbox" + CXAI_SLACK_SVC_PORT: "8104" + healthcheck: + test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8104/healthz"] + interval: 30s + timeout: 5s + retries: 3 diff --git a/include/routes.h b/include/routes.h new file mode 100644 index 0000000..ea2b041 --- /dev/null +++ b/include/routes.h @@ -0,0 +1,26 @@ +#pragma once + +#include "crow.h" + +namespace routes { + +/// Register API routes (JSON endpoints) +void register_api(crow::SimpleApp& app); + +/// Register page + static-asset routes +void register_pages(crow::SimpleApp& app); + +/// Register system/version/echo + websocket routes +void register_system(crow::SimpleApp& app); + +/// Register reverse-proxy routes for sidecar services: +/// /api/diffusion/* -> CXAI_DIFFUSION_UPSTREAM +/// /api/demand/* -> CXAI_DEMAND_UPSTREAM +/// /api/lang/* -> CXAI_LANG_UPSTREAM +/// /api/slack/* -> CXAI_SLACK_UPSTREAM +void register_proxy(crow::SimpleApp& app); + +/// Register macOS app metadata routes (/api/mac/*). +void register_mac(crow::SimpleApp& app); + +} // namespace routes diff --git a/share/cxai-mac/CxAI ADE.app/Contents/Info.plist b/share/cxai-mac/CxAI ADE.app/Contents/Info.plist new file mode 100644 index 0000000..4b124ca --- /dev/null +++ b/share/cxai-mac/CxAI ADE.app/Contents/Info.plist @@ -0,0 +1,38 @@ + + + + + CFBundleDevelopmentRegion + English + CFBundleDisplayName + CxAI ADE + CFBundleExecutable + cx-ai + CFBundleIdentifier + com.cxai.cxai + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + CxAI ADE + CFBundlePackageType + APPL + CFBundleShortVersionString + 0.6.1 + CFBundleVersion + 0.6.1 + CSResourcesFileMapped + + LSApplicationCategoryType + public.app-category.developer-tools + LSMinimumSystemVersion + 10.15 + CFBundleIconFile + icon.icns + LSRequiresCarbon + + NSHighResolutionCapable + + NSMicrophoneUsageDescription + CxAI ADE uses your microphone for AI voice input. + + \ No newline at end of file diff --git a/share/cxai-mac/CxAI ADE.app/Contents/MacOS/cx-ai b/share/cxai-mac/CxAI ADE.app/Contents/MacOS/cx-ai new file mode 100755 index 0000000..ecadf52 Binary files /dev/null and b/share/cxai-mac/CxAI ADE.app/Contents/MacOS/cx-ai differ diff --git a/share/cxai-mac/CxAI ADE.app/Contents/Resources/icon.icns b/share/cxai-mac/CxAI ADE.app/Contents/Resources/icon.icns new file mode 100644 index 0000000..c91afe2 Binary files /dev/null and b/share/cxai-mac/CxAI ADE.app/Contents/Resources/icon.icns differ diff --git a/share/cxai-mac/CxAI-ADE-0.6.1-aarch64.app.tar.gz b/share/cxai-mac/CxAI-ADE-0.6.1-aarch64.app.tar.gz new file mode 100644 index 0000000..b2b060e Binary files /dev/null and b/share/cxai-mac/CxAI-ADE-0.6.1-aarch64.app.tar.gz differ diff --git a/share/cxai-mac/CxAI-ADE-0.6.1-aarch64.dmg b/share/cxai-mac/CxAI-ADE-0.6.1-aarch64.dmg new file mode 100644 index 0000000..ef9b5c8 Binary files /dev/null and b/share/cxai-mac/CxAI-ADE-0.6.1-aarch64.dmg differ diff --git a/share/cxai-mac/build-info.json b/share/cxai-mac/build-info.json new file mode 100644 index 0000000..4167f76 --- /dev/null +++ b/share/cxai-mac/build-info.json @@ -0,0 +1,17 @@ +{ + "name": "CxAI ADE", + "version": "0.6.1", + "identifier": "com.cxai.cxai", + "channel": "stable", + "sha": "104717b92c07", + "built_at": "2026-05-16T18:26:57Z", + "description": "Tauri 2 desktop shell for CxAI Studio. Built from CxAI Base/cargo.", + "artifacts": { + "app_tar_gz": "CxAI-ADE-0.6.1-aarch64.app.tar.gz", + "app_tar_gz_sha256": "b991db236a83328464bc3c0f5b0560d44f888a401faa153db975f4652a2ee160", + "dmg": "CxAI-ADE-0.6.1-aarch64.dmg", + "dmg_size_bytes": 4539552, + "dmg_sha256": "1a1d2e7cc85a24179e89447790c1c47df194c34945fb672be8ff57902bdd966e" + }, + "download_url": "/static/cxai-mac/CxAI-ADE-0.6.1-aarch64.dmg" +} diff --git a/src/main.cpp b/src/main.cpp new file mode 100644 index 0000000..a29ccd0 --- /dev/null +++ b/src/main.cpp @@ -0,0 +1,31 @@ +#include "crow.h" +#include "routes.h" +#include +#include +#include + +int main() { + crow::SimpleApp app; + + // Serve static files from ./static + // Crow serves them at /static/ + + // Register route groups + routes::register_pages(app); + routes::register_api(app); + routes::register_system(app); + routes::register_proxy(app); + routes::register_mac(app); + + // Start server (port overridable via CXWEBAPP_PORT) + int port = 8080; + if (const char* p = std::getenv("CXWEBAPP_PORT")) { + try { port = std::stoi(p); } catch (...) {} + } + CROW_LOG_INFO << "CxWebApp listening on :" << port; + app.port(static_cast(port)) + .multithreaded() + .run(); + + return 0; +} diff --git a/src/routes/api.cpp b/src/routes/api.cpp new file mode 100644 index 0000000..a571076 --- /dev/null +++ b/src/routes/api.cpp @@ -0,0 +1,110 @@ +#include "crow.h" +#include "routes.h" +#include +#include +#include +#include + +namespace routes { + +namespace { + +struct Item { + int id; + std::string name; + std::string description; +}; + +std::mutex g_mutex; +std::vector g_items = { + {1, "Widget", "A useful widget"}, + {2, "Gadget", "A fancy gadget"}, + {3, "Gizmo", "A mysterious gizmo"}, +}; +int g_next_id = 4; + +crow::json::wvalue item_to_json(const Item& item) { + crow::json::wvalue j; + j["id"] = item.id; + j["name"] = item.name; + j["description"] = item.description; + return j; +} + +} // anonymous namespace + +void register_api(crow::SimpleApp& app) { + + // Health check + CROW_ROUTE(app, "/api/health") + ([]() { + crow::json::wvalue res; + res["status"] = "ok"; + auto now = std::chrono::system_clock::now(); + auto epoch = std::chrono::duration_cast( + now.time_since_epoch()) + .count(); + res["timestamp"] = epoch; + return res; + }); + + // List all items + CROW_ROUTE(app, "/api/items") + ([]() { + std::lock_guard lock(g_mutex); + crow::json::wvalue res; + std::vector arr; + arr.reserve(g_items.size()); + for (const auto& item : g_items) { + arr.push_back(item_to_json(item)); + } + res["items"] = std::move(arr); + res["count"] = static_cast(g_items.size()); + return res; + }); + + // Get single item + CROW_ROUTE(app, "/api/items/") + ([](int id) { + std::lock_guard lock(g_mutex); + auto it = std::find_if(g_items.begin(), g_items.end(), + [id](const Item& i) { return i.id == id; }); + if (it == g_items.end()) { + return crow::response(404, "Item not found"); + } + return crow::response(item_to_json(*it)); + }); + + // Create item + CROW_ROUTE(app, "/api/items").methods(crow::HTTPMethod::POST) + ([](const crow::request& req) { + auto body = crow::json::load(req.body); + if (!body || !body.has("name")) { + return crow::response(400, R"({"error":"name is required"})"); + } + std::lock_guard lock(g_mutex); + Item item; + item.id = g_next_id++; + item.name = body["name"].s(); + item.description = body.has("description") ? std::string(body["description"].s()) : ""; + g_items.push_back(item); + auto resp = crow::response(201, item_to_json(item)); + resp.add_header("Content-Type", "application/json"); + return resp; + }); + + // Delete item + CROW_ROUTE(app, "/api/items/").methods(crow::HTTPMethod::DELETE) + ([](int id) { + std::lock_guard lock(g_mutex); + auto it = std::find_if(g_items.begin(), g_items.end(), + [id](const Item& i) { return i.id == id; }); + if (it == g_items.end()) { + return crow::response(404, R"({"error":"not found"})"); + } + g_items.erase(it); + return crow::response(204); + }); +} + +} // namespace routes diff --git a/src/routes/mac.cpp b/src/routes/mac.cpp new file mode 100644 index 0000000..f61c781 --- /dev/null +++ b/src/routes/mac.cpp @@ -0,0 +1,79 @@ +// macOS bundle metadata: thin endpoint surface so CxWebApp can advertise the +// SwiftPM `apps/cxai-mac` app (build info + download link) without needing the +// actual .app to live inside this image. +// +// Configure with: +// CXAI_MAC_BUILD_INFO path to a build-info.json (default /opt/cxwebapp/share/cxai-mac/build-info.json) +// CXAI_MAC_DOWNLOAD_URL public URL to the .app.zip (returned in /api/mac/info) + +#include "crow.h" +#include "routes.h" + +#include +#include +#include +#include + +namespace routes { + +namespace { + +std::string slurp(const std::string& path) { + std::ifstream ifs(path, std::ios::binary); + if (!ifs.is_open()) return ""; + std::ostringstream ss; + ss << ifs.rdbuf(); + return ss.str(); +} + +std::string env_or(const char* k, const char* fallback) { + if (const char* v = std::getenv(k)) return std::string(v); + return std::string(fallback); +} + +} // anonymous namespace + +void register_mac(crow::SimpleApp& app) { + + CROW_ROUTE(app, "/api/mac/healthz") + ([]() { + crow::json::wvalue r; + r["ok"] = true; + r["service"] = "cxai-mac"; + return r; + }); + + CROW_ROUTE(app, "/api/mac/info") + ([]() { + const std::string path = env_or( + "CXAI_MAC_BUILD_INFO", + "/opt/cxwebapp/share/cxai-mac/build-info.json"); + std::string body = slurp(path); + crow::json::wvalue r; + if (body.empty()) { + r["error"] = "build_info_unavailable"; + r["path"] = path; + auto resp = crow::response(404, r); + resp.add_header("Content-Type", "application/json"); + return resp; + } + // body is already JSON; pass through verbatim while still wrapping with + // our envelope. + auto loaded = crow::json::load(body); + crow::json::wvalue info; + if (loaded) { + info = crow::json::wvalue(loaded); + } else { + info["raw"] = body; + } + crow::json::wvalue out; + out["build"] = std::move(info); + out["download_url"] = env_or("CXAI_MAC_DOWNLOAD_URL", ""); + out["bundle_id"] = env_or("CXAI_MAC_BUNDLE_ID", "com.cxai.cxai"); + auto resp = crow::response(200, out); + resp.add_header("Content-Type", "application/json"); + return resp; + }); +} + +} // namespace routes diff --git a/src/routes/pages.cpp b/src/routes/pages.cpp new file mode 100644 index 0000000..fa86728 --- /dev/null +++ b/src/routes/pages.cpp @@ -0,0 +1,97 @@ +#include "crow.h" +#include "routes.h" +#include +#include +#include +#include + +namespace routes { + +namespace { + +std::string static_dir() { + if (const char* env = std::getenv("CXWEBAPP_STATIC_DIR")) { + return std::string(env); + } +#ifdef CXWEBAPP_STATIC_DIR_DEFAULT + return std::string(CXWEBAPP_STATIC_DIR_DEFAULT); +#else + return "static"; +#endif +} + +std::string read_file(const std::string& path) { + std::ifstream ifs(path, std::ios::binary); + if (!ifs.is_open()) return ""; + std::ostringstream ss; + ss << ifs.rdbuf(); + return ss.str(); +} + +std::string mime_for(const std::string& path) { + auto ends = [&](const char* s) { + size_t n = std::char_traits::length(s); + return path.size() >= n && path.compare(path.size() - n, n, s) == 0; + }; + if (ends(".html")) return "text/html; charset=utf-8"; + if (ends(".css")) return "text/css; charset=utf-8"; + if (ends(".js")) return "application/javascript; charset=utf-8"; + if (ends(".mjs")) return "application/javascript; charset=utf-8"; + if (ends(".json")) return "application/json; charset=utf-8"; + if (ends(".svg")) return "image/svg+xml"; + if (ends(".png")) return "image/png"; + if (ends(".jpg") || ends(".jpeg")) return "image/jpeg"; + if (ends(".gif")) return "image/gif"; + if (ends(".ico")) return "image/x-icon"; + if (ends(".woff2")) return "font/woff2"; + if (ends(".woff")) return "font/woff"; + if (ends(".map")) return "application/json; charset=utf-8"; + if (ends(".txt")) return "text/plain; charset=utf-8"; + return "application/octet-stream"; +} + +bool is_safe(const std::string& path) { + if (path.empty()) return false; + if (path.front() == '/') return false; + if (path.find("..") != std::string::npos) return false; + if (path.find('\0') != std::string::npos) return false; + return true; +} + +crow::response serve_static(const std::string& rel) { + if (!is_safe(rel)) return crow::response(400, "bad path"); + const std::string full = static_dir() + "/" + rel; + auto body = read_file(full); + if (body.empty()) return crow::response(404, "not found"); + crow::response resp(body); + resp.add_header("Content-Type", mime_for(rel)); + resp.add_header("Cache-Control", "public, max-age=300"); + return resp; +} + +} // anonymous namespace + +void register_pages(crow::SimpleApp& app) { + + CROW_ROUTE(app, "/") + ([]() { return serve_static("index.html"); }); + + CROW_ROUTE(app, "/favicon.ico") + ([]() { return serve_static("favicon.svg"); }); + + // Crow's `` matches a single segment. Register depths 1..3. + CROW_ROUTE(app, "/static/") + ([](const std::string& a) { return serve_static(a); }); + + CROW_ROUTE(app, "/static//") + ([](const std::string& a, const std::string& b) { + return serve_static(a + "/" + b); + }); + + CROW_ROUTE(app, "/static///") + ([](const std::string& a, const std::string& b, const std::string& c) { + return serve_static(a + "/" + b + "/" + c); + }); +} + +} // namespace routes diff --git a/src/routes/proxy.cpp b/src/routes/proxy.cpp new file mode 100644 index 0000000..696d3ab --- /dev/null +++ b/src/routes/proxy.cpp @@ -0,0 +1,240 @@ +// Reverse-proxy routes for the 4 sidecar services. Uses cpp-httplib as the +// upstream client (single-header, no extra system packages). +// +// Each prefix is registered explicitly with CROW_ROUTE so Crow can extract +// the `` tail at compile time. Upstream URLs are read from env at +// startup; missing envs make that prefix return 503. +// +// We deliberately do NOT enable cpp-httplib's OpenSSL support: all upstreams +// are internal HTTP services on the same docker network. The macro must not +// be defined at all (defining it to 0 still triggers the openssl/err.h +// include because httplib.h tests with #ifdef, not the value). + +#ifdef CPPHTTPLIB_OPENSSL_SUPPORT +#undef CPPHTTPLIB_OPENSSL_SUPPORT +#endif +#include + +#include "crow.h" +#include "routes.h" + +#include +#include +#include +#include +#include +#include + +namespace routes { + +namespace { + +struct Upstream { + std::string scheme; + std::string host; + int port = 0; +}; + +std::optional parse_upstream(const std::string& url) { + if (url.empty()) return std::nullopt; + Upstream up; + std::string rest; + if (url.rfind("https://", 0) == 0) { + up.scheme = "https"; + rest = url.substr(8); + up.port = 443; + } else if (url.rfind("http://", 0) == 0) { + up.scheme = "http"; + rest = url.substr(7); + up.port = 80; + } else { + return std::nullopt; + } + auto slash = rest.find('/'); + std::string hostport = slash == std::string::npos ? rest : rest.substr(0, slash); + auto colon = hostport.find(':'); + if (colon == std::string::npos) { + up.host = hostport; + } else { + up.host = hostport.substr(0, colon); + try { up.port = std::stoi(hostport.substr(colon + 1)); } catch (...) {} + } + if (up.host.empty()) return std::nullopt; + return up; +} + +std::optional env_upstream(const char* var) { + const char* v = std::getenv(var); + if (!v || !*v) return std::nullopt; + return parse_upstream(std::string(v)); +} + +const std::unordered_set& hop_by_hop() { + static const std::unordered_set h = { + "connection", "keep-alive", "proxy-authenticate", "proxy-authorization", + "te", "trailers", "transfer-encoding", "upgrade", "host", + "content-length", + }; + return h; +} + +std::string to_lower(std::string s) { + for (auto& c : s) c = static_cast(::tolower(static_cast(c))); + return s; +} + +crow::response service_unavailable(const std::string& name) { + crow::json::wvalue j; + j["error"] = "upstream_unavailable"; + j["service"] = name; + auto resp = crow::response(503, j); + resp.add_header("Content-Type", "application/json"); + return resp; +} + +httplib::Headers forward_headers(const crow::request& req) { + httplib::Headers h; + for (const auto& kv : req.headers) { + if (hop_by_hop().count(to_lower(kv.first))) continue; + h.emplace(kv.first, kv.second); + } + return h; +} + +crow::response from_httplib(const httplib::Result& r, const std::string& service) { + if (!r) { + crow::json::wvalue j; + j["error"] = "upstream_error"; + j["service"] = service; + j["detail"] = httplib::to_string(r.error()); + auto resp = crow::response(502, j); + resp.add_header("Content-Type", "application/json"); + return resp; + } + crow::response out(r->status, r->body); + for (const auto& kv : r->headers) { + if (hop_by_hop().count(to_lower(kv.first))) continue; + out.add_header(kv.first, kv.second); + } + return out; +} + +crow::response forward(const Upstream& up, const std::string& service, + const crow::request& req, const std::string& tail) { + httplib::Client cli(up.host, up.port); + cli.set_connection_timeout(5, 0); + cli.set_read_timeout(120, 0); + cli.set_write_timeout(30, 0); + auto headers = forward_headers(req); + + std::string upstream_path = "/" + tail; + auto qpos = req.raw_url.find('?'); + if (qpos != std::string::npos) { + upstream_path += req.raw_url.substr(qpos); + } + + std::string content_type = "application/octet-stream"; + auto it = req.headers.find("Content-Type"); + if (it != req.headers.end()) content_type = it->second; + + switch (req.method) { + case crow::HTTPMethod::Get: + return from_httplib(cli.Get(upstream_path.c_str(), headers), service); + case crow::HTTPMethod::Delete: + return from_httplib(cli.Delete(upstream_path.c_str(), headers), service); + case crow::HTTPMethod::Head: + return from_httplib(cli.Head(upstream_path.c_str(), headers), service); + case crow::HTTPMethod::Post: + return from_httplib( + cli.Post(upstream_path.c_str(), headers, req.body, content_type.c_str()), + service); + case crow::HTTPMethod::Put: + return from_httplib( + cli.Put(upstream_path.c_str(), headers, req.body, content_type.c_str()), + service); + case crow::HTTPMethod::Patch: + return from_httplib( + cli.Patch(upstream_path.c_str(), headers, req.body, content_type.c_str()), + service); + default: + return crow::response(405, "method not allowed via proxy"); + } +} + +struct UpstreamSet { + std::optional diffusion; + std::optional demand; + std::optional lang; + std::optional slack; +}; +const UpstreamSet& upstreams() { + static UpstreamSet s; + static std::once_flag once; + std::call_once(once, [] { + s.diffusion = env_upstream("CXAI_DIFFUSION_UPSTREAM"); + s.demand = env_upstream("CXAI_DEMAND_UPSTREAM"); + s.lang = env_upstream("CXAI_LANG_UPSTREAM"); + s.slack = env_upstream("CXAI_SLACK_UPSTREAM"); + }); + return s; +} + +#define PROXY_PREFIX(APP, PREFIX, NAME, UPGETTER) \ + CROW_ROUTE((APP), PREFIX).methods( \ + crow::HTTPMethod::Get, crow::HTTPMethod::Post, crow::HTTPMethod::Put, \ + crow::HTTPMethod::Delete, crow::HTTPMethod::Patch, crow::HTTPMethod::Head) \ + ([](const crow::request& req) { \ + const auto& up = UPGETTER(); \ + if (!up) return service_unavailable(NAME); \ + return forward(*up, NAME, req, ""); \ + }); \ + CROW_ROUTE((APP), PREFIX "/").methods( \ + crow::HTTPMethod::Get, crow::HTTPMethod::Post, crow::HTTPMethod::Put, \ + crow::HTTPMethod::Delete, crow::HTTPMethod::Patch, crow::HTTPMethod::Head) \ + ([](const crow::request& req, std::string tail) { \ + const auto& up = UPGETTER(); \ + if (!up) return service_unavailable(NAME); \ + return forward(*up, NAME, req, tail); \ + }); + +const std::optional& up_diffusion() { return upstreams().diffusion; } +const std::optional& up_demand() { return upstreams().demand; } +const std::optional& up_lang() { return upstreams().lang; } +const std::optional& up_slack() { return upstreams().slack; } + +} // anonymous namespace + +void register_proxy(crow::SimpleApp& app) { + + CROW_ROUTE(app, "/api/services") + ([]() { + crow::json::wvalue r; + auto add = [&](const char* name, const std::optional& up, + const char* prefix) { + crow::json::wvalue item; + item["service"] = std::string(name); + item["prefix"] = std::string(prefix); + item["configured"] = static_cast(up); + if (up) { + item["upstream_host"] = up->host; + item["upstream_port"] = up->port; + item["upstream_scheme"] = up->scheme; + } + return item; + }; + std::vector arr; + arr.push_back(add("diffusion", upstreams().diffusion, "/api/diffusion")); + arr.push_back(add("demand", upstreams().demand, "/api/demand")); + arr.push_back(add("lang", upstreams().lang, "/api/lang")); + arr.push_back(add("slack", upstreams().slack, "/api/slack")); + r["services"] = std::move(arr); + return r; + }); + + PROXY_PREFIX(app, "/api/diffusion", "diffusion", up_diffusion) + PROXY_PREFIX(app, "/api/demand", "demand", up_demand) + PROXY_PREFIX(app, "/api/lang", "lang", up_lang) + PROXY_PREFIX(app, "/api/slack", "slack", up_slack) +} + +} // namespace routes diff --git a/src/routes/system.cpp b/src/routes/system.cpp new file mode 100644 index 0000000..4918803 --- /dev/null +++ b/src/routes/system.cpp @@ -0,0 +1,84 @@ +#include "crow.h" +#include "routes.h" +#include +#include +#include +#include +#include +#include + +namespace routes { + +namespace { + +std::string env_or(const char* k, const char* fallback) { + if (const char* v = std::getenv(k)) return std::string(v); + return std::string(fallback); +} + +long long boot_epoch() { + static long long boot = std::time(nullptr); + return boot; +} + +} // anonymous namespace + +void register_system(crow::SimpleApp& app) { + + // /api/version — build identity + CROW_ROUTE(app, "/api/version") + ([]() { + crow::json::wvalue r; + r["name"] = "CxWebApp"; + r["version"] = env_or("CXWEBAPP_VERSION", "1.1.0"); + r["git_sha"] = env_or("CXWEBAPP_GIT_SHA", "unknown"); + r["build_time"] = env_or("CXWEBAPP_BUILD_TIME", ""); + return r; + }); + + // /api/system — runtime/system facts (safe to expose, no secrets) + CROW_ROUTE(app, "/api/system") + ([]() { + crow::json::wvalue r; + struct utsname u{}; + if (uname(&u) == 0) { + r["sysname"] = u.sysname; + r["release"] = u.release; + r["machine"] = u.machine; + r["nodename"] = u.nodename; + } + char host[256] = {0}; + if (gethostname(host, sizeof(host) - 1) == 0) { + r["hostname"] = std::string(host); + } + r["pid"] = static_cast(::getpid()); + r["uptime_seconds"] = static_cast(std::time(nullptr) - boot_epoch()); + r["cxx_standard"] = static_cast(__cplusplus); + return r; + }); + + // /api/echo — POST anything, get it back (debug helper) + CROW_ROUTE(app, "/api/echo").methods(crow::HTTPMethod::POST) + ([](const crow::request& req) { + crow::json::wvalue r; + r["method"] = "POST"; + r["len"] = static_cast(req.body.size()); + r["body"] = req.body; + return crow::response(r); + }); + + // /ws/echo — minimal WebSocket echo room (per-connection state) + CROW_WEBSOCKET_ROUTE(app, "/ws/echo") + .onopen([](crow::websocket::connection& conn) { + conn.send_text("welcome"); + }) + .onmessage([](crow::websocket::connection& conn, + const std::string& data, bool is_binary) { + if (is_binary) conn.send_binary(data); + else conn.send_text(data); + }) + .onclose([](crow::websocket::connection&, + const std::string&) {}); +} + +} // namespace routes diff --git a/static/css/style.css b/static/css/style.css new file mode 100644 index 0000000..e8cdbf5 --- /dev/null +++ b/static/css/style.css @@ -0,0 +1,117 @@ +/* Small layer of project-specific styling that sits on top of Tailwind CDN. */ + +.tab { + padding: 0.5rem 0.875rem; + font-size: 0.875rem; + font-weight: 500; + color: rgb(100 116 139); + border-bottom: 2px solid transparent; + white-space: nowrap; + transition: color 0.15s, border-color 0.15s; +} +.tab:hover { color: rgb(30 41 59); } +:is(.dark) .tab:hover { color: rgb(226 232 240); } + +.tab-active { + color: #7c5cff; + border-bottom-color: #7c5cff; +} + +.card { + background-color: rgb(255 255 255); + border: 1px solid rgb(226 232 240); + border-radius: 0.75rem; + padding: 1.25rem; + box-shadow: 0 1px 2px rgb(0 0 0 / 0.04); +} +:is(.dark) .card { + background-color: rgb(15 23 42 / 0.6); + border-color: rgb(30 41 59); +} + +.card-label { + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: rgb(100 116 139); +} +.card-value { + font-size: 1.875rem; + font-weight: 600; + margin-top: 0.25rem; +} +.card-sub { + font-size: 0.75rem; + color: rgb(100 116 139); + margin-top: 0.25rem; +} + +.input { + padding: 0.5rem 0.75rem; + border: 1px solid rgb(203 213 225); + border-radius: 0.5rem; + background-color: rgb(255 255 255); + font-size: 0.875rem; + outline: none; + transition: border-color 0.15s, box-shadow 0.15s; +} +.input:focus { + border-color: #7c5cff; + box-shadow: 0 0 0 3px rgb(124 92 255 / 0.2); +} +:is(.dark) .input { + background-color: rgb(15 23 42); + border-color: rgb(51 65 85); + color: rgb(226 232 240); +} + +.btn-primary { + padding: 0.5rem 1rem; + border-radius: 0.5rem; + background-color: #7c5cff; + color: white; + font-size: 0.875rem; + font-weight: 500; + transition: background-color 0.15s; +} +.btn-primary:hover { background-color: #6a4ae6; } +.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; } + +.btn-secondary { + padding: 0.5rem 1rem; + border-radius: 0.5rem; + border: 1px solid rgb(203 213 225); + background-color: transparent; + color: rgb(51 65 85); + font-size: 0.875rem; + font-weight: 500; +} +.btn-secondary:hover { background-color: rgb(241 245 249); } +:is(.dark) .btn-secondary { + border-color: rgb(51 65 85); + color: rgb(226 232 240); +} +:is(.dark) .btn-secondary:hover { background-color: rgb(30 41 59); } + +.pane.hidden { display: none; } + +.item-row { + padding: 0.75rem 0.25rem; + display: flex; + align-items: center; + gap: 0.75rem; +} +.item-row .id { + font-size: 0.75rem; + color: rgb(148 163 184); + font-family: ui-monospace, monospace; + min-width: 2.5rem; +} +.item-row .name { font-weight: 500; } +.item-row .desc { color: rgb(100 116 139); font-size: 0.875rem; flex: 1; } +.item-row .del { + color: rgb(239 68 68); + font-size: 0.75rem; + cursor: pointer; +} +.item-row .del:hover { text-decoration: underline; } diff --git a/static/favicon.svg b/static/favicon.svg new file mode 100644 index 0000000..ec52b30 --- /dev/null +++ b/static/favicon.svg @@ -0,0 +1,3 @@ + + + diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..f72c322 --- /dev/null +++ b/static/index.html @@ -0,0 +1,329 @@ + + + + + + + CxWebApp · Console + + + + + + +
+ + +
+
+
+ + + +
+
+

CxWebApp

+

C++ / Crow · loading…

+
+ + checking + + +
+ + + +
+ +
+ + +
+
+
+
Health
+
+
last checked: never
+
+
+
Uptime
+
+
seconds since first request
+
+
+
Items
+
+
tracked in-memory
+
+
+ +
+
+

Recent activity

+ +
+

+        
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+
+ CxWebApp + build · — +
+
+
+ + + + diff --git a/static/js/app.js b/static/js/app.js new file mode 100644 index 0000000..2cc21dd --- /dev/null +++ b/static/js/app.js @@ -0,0 +1,532 @@ +// CxWebApp SPA — vanilla JS module. Talks to the C++ Crow backend. + +const $ = (s, root = document) => root.querySelector(s); +const $$ = (s, root = document) => Array.from(root.querySelectorAll(s)); + +const state = { + ws: null, + startedAt: Date.now(), + pollHandle: null, +}; + +// ----------------------------------------------------------------------------- +// Theme +// ----------------------------------------------------------------------------- +const initialTheme = localStorage.getItem("theme") || + (window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"); +document.documentElement.classList.toggle("dark", initialTheme === "dark"); + +$("#theme-toggle").addEventListener("click", () => { + const dark = document.documentElement.classList.toggle("dark"); + localStorage.setItem("theme", dark ? "dark" : "light"); +}); + +// ----------------------------------------------------------------------------- +// Tabs +// ----------------------------------------------------------------------------- +$$(".tab").forEach((btn) => { + btn.addEventListener("click", () => { + const tab = btn.dataset.tab; + $$(".tab").forEach(b => b.classList.toggle("tab-active", b === btn)); + $$(".pane").forEach(p => p.classList.toggle("hidden", p.dataset.pane !== tab)); + if (tab === "system") refreshSystem(); + if (tab === "items") loadItems(); + if (tab === "demand") refreshDemand(); + if (tab === "lang") refreshLang(); + if (tab === "slack") refreshSlack(); + if (tab === "mac") refreshMac(); + if (tab === "diffusion") refreshDiffusion(); + }); +}); + +// ----------------------------------------------------------------------------- +// Logging helper +// ----------------------------------------------------------------------------- +function log(msg) { + const pre = $("#d-log"); + if (!pre) return; + const ts = new Date().toISOString().split("T")[1].slice(0, 8); + pre.textContent = `[${ts}] ${msg}\n` + pre.textContent; + if (pre.textContent.length > 8000) { + pre.textContent = pre.textContent.slice(0, 8000); + } +} + +// ----------------------------------------------------------------------------- +// Health + dashboard +// ----------------------------------------------------------------------------- +async function loadHealth() { + try { + const r = await fetch("/api/health"); + const j = await r.json(); + $("#health-pill").textContent = "healthy"; + $("#health-pill").className = + "px-2.5 py-1 rounded-full text-xs font-medium bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-300"; + $("#d-health").textContent = "OK"; + $("#d-health-sub").textContent = "last checked: just now · ts=" + j.timestamp; + } catch (e) { + $("#health-pill").textContent = "down"; + $("#health-pill").className = + "px-2.5 py-1 rounded-full text-xs font-medium bg-rose-100 text-rose-800 dark:bg-rose-900/40 dark:text-rose-300"; + $("#d-health").textContent = "DOWN"; + log("health check failed: " + e.message); + } +} + +async function loadVersionHeader() { + try { + const r = await fetch("/api/version"); + const j = await r.json(); + $("#header-sub").textContent = `C++ / Crow · ${j.name} ${j.version}`; + $("#footer-build").textContent = `build · ${j.git_sha || "?"} ${j.build_time || ""}`.trim(); + } catch (_) { + $("#header-sub").textContent = "C++ / Crow"; + } +} + +// ----------------------------------------------------------------------------- +// Items +// ----------------------------------------------------------------------------- +async function loadItems() { + const list = $("#items-list"); + try { + const r = await fetch("/api/items"); + const j = await r.json(); + $("#d-items").textContent = String(j.count ?? (j.items || []).length); + if (!list) return; + if (!j.items || j.items.length === 0) { + list.innerHTML = `
No items yet.
`; + return; + } + list.innerHTML = j.items.map(it => ` +
+ #${it.id} + ${escapeHtml(it.name)} + ${escapeHtml(it.description || "")} + delete +
`).join(""); + $$(".item-row .del").forEach(b => { + b.addEventListener("click", async (e) => { + const id = e.target.closest(".item-row").dataset.id; + await fetch("/api/items/" + id, { method: "DELETE" }); + log(`deleted item #${id}`); + loadItems(); + }); + }); + } catch (e) { + if (list) list.innerHTML = `
${escapeHtml(e.message)}
`; + } +} + +$("#add-form").addEventListener("submit", async (e) => { + e.preventDefault(); + const name = $("#item-name").value.trim(); + const desc = $("#item-desc").value.trim(); + if (!name) return; + const r = await fetch("/api/items", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name, description: desc }), + }); + if (r.ok) { + $("#item-name").value = ""; + $("#item-desc").value = ""; + log(`added "${name}"`); + loadItems(); + } else { + log("add failed: " + r.status); + } +}); + +// ----------------------------------------------------------------------------- +// System pane +// ----------------------------------------------------------------------------- +async function refreshSystem() { + try { + const [v, s] = await Promise.all([ + fetch("/api/version").then(r => r.json()), + fetch("/api/system").then(r => r.json()), + ]); + $("#sys-version").textContent = JSON.stringify(v, null, 2); + $("#sys-system").textContent = JSON.stringify(s, null, 2); + if (s.uptime_seconds !== undefined) { + $("#d-uptime").textContent = formatUptime(s.uptime_seconds); + } + } catch (e) { + $("#sys-version").textContent = "error: " + e.message; + } +} + +// ----------------------------------------------------------------------------- +// WebSocket +// ----------------------------------------------------------------------------- +function wsConnect() { + if (state.ws && state.ws.readyState <= 1) return; + const proto = location.protocol === "https:" ? "wss:" : "ws:"; + const url = `${proto}//${location.host}/ws/echo`; + const ws = new WebSocket(url); + state.ws = ws; + setWsState("connecting…"); + ws.onopen = () => { setWsState("connected"); appendWs(`< connected to ${url}`); }; + ws.onclose = () => { setWsState("disconnected"); appendWs("< closed"); }; + ws.onerror = () => { appendWs("! error"); }; + ws.onmessage = (e) => appendWs("< " + e.data); +} +function setWsState(s) { $("#ws-state").textContent = s; } +function appendWs(line) { + const pre = $("#ws-log"); + pre.textContent += line + "\n"; + pre.scrollTop = pre.scrollHeight; +} + +$("#ws-connect").addEventListener("click", wsConnect); +$("#ws-disconnect").addEventListener("click", () => state.ws && state.ws.close()); +$("#ws-form").addEventListener("submit", (e) => { + e.preventDefault(); + const v = $("#ws-input").value; + if (!v) return; + if (!state.ws || state.ws.readyState !== 1) { + appendWs("! not connected"); + return; + } + state.ws.send(v); + appendWs("> " + v); + $("#ws-input").value = ""; +}); + +// ----------------------------------------------------------------------------- +// API Explorer +// ----------------------------------------------------------------------------- +$("#api-send").addEventListener("click", async () => { + const method = $("#api-method").value; + const path = $("#api-path").value; + const body = $("#api-body").value.trim(); + const opts = { method, headers: {} }; + if (method !== "GET" && body) { + opts.body = body; + try { JSON.parse(body); opts.headers["Content-Type"] = "application/json"; } + catch { opts.headers["Content-Type"] = "text/plain"; } + } + const t0 = performance.now(); + try { + const r = await fetch(path, opts); + const dt = (performance.now() - t0).toFixed(0); + const text = await r.text(); + let pretty = text; + try { pretty = JSON.stringify(JSON.parse(text), null, 2); } catch (_) {} + $("#api-status").textContent = `${r.status} ${r.statusText} · ${dt}ms · ${text.length} bytes`; + $("#api-out").textContent = pretty; + } catch (e) { + $("#api-status").textContent = "error"; + $("#api-out").textContent = e.message; + } +}); +$("#api-clear").addEventListener("click", () => { + $("#api-out").textContent = ""; + $("#api-status").textContent = "—"; +}); + +// ----------------------------------------------------------------------------- +// Polling +// ----------------------------------------------------------------------------- +$("#d-refresh").addEventListener("click", () => { + loadHealth(); loadItems(); refreshSystem(); +}); + +function startPolling() { + if (state.pollHandle) clearInterval(state.pollHandle); + state.pollHandle = setInterval(() => { + loadHealth(); + if (!$("[data-pane=system]").classList.contains("hidden")) refreshSystem(); + }, 10_000); +} + +// ----------------------------------------------------------------------------- +// Helpers +// ----------------------------------------------------------------------------- +function escapeHtml(s) { + return String(s).replace(/[&<>"']/g, c => ( + { "&":"&", "<":"<", ">":">", '"':""", "'":"'" }[c] + )); +} +function formatUptime(sec) { + sec = Math.max(0, sec | 0); + const h = Math.floor(sec / 3600); + const m = Math.floor((sec % 3600) / 60); + const s = sec % 60; + if (h) return `${h}h ${m}m`; + if (m) return `${m}m ${s}s`; + return `${s}s`; +} + +// ----------------------------------------------------------------------------- +// Boot +// ----------------------------------------------------------------------------- +loadHealth(); +loadItems(); +loadVersionHeader(); +refreshSystem(); +startPolling(); +log("ready"); + +// ============================================================================= +// Sidecar integrations: diffusion, demand, lang, slack, mac +// ============================================================================= +async function jget(url) { const r = await fetch(url); return parseResp(r); } +async function jpost(url, body) { + const r = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body ?? {}), + }); + return parseResp(r); +} +async function parseResp(r) { + const text = await r.text(); + let data; + try { data = JSON.parse(text); } catch { data = { raw: text }; } + if (!r.ok) { + const err = new Error(`${r.status} ${r.statusText}`); + err.status = r.status; + err.body = data; + throw err; + } + return data; +} + +// ── diffusion ──────────────────────────────────────────────────────────────── +async function refreshDiffusion() { + try { + const h = await jget("/api/diffusion/healthz"); + $("#diff-status").textContent = `upstream ok=${h.ok} backend=${h.backend ?? "?"}`; + } catch (e) { + $("#diff-status").textContent = "upstream unavailable: " + (e.message || e); + } +} + +$("#diff-form")?.addEventListener("submit", async (e) => { + e.preventDefault(); + $("#diff-status").textContent = "generating…"; + const body = { + prompt: $("#diff-prompt").value, + negative_prompt: $("#diff-neg").value, + steps: Number($("#diff-steps").value) || 20, + width: Number($("#diff-w").value) || 512, + height: Number($("#diff-h").value) || 512, + cfg_scale: Number($("#diff-cfg").value) || 7, + }; + const sampler = $("#diff-sampler").value.trim(); + if (sampler) body.sampler_name = sampler; + try { + const r = await jpost("/api/diffusion/v1/generate", body); + const gallery = $("#diff-gallery"); + gallery.innerHTML = (r.images || []).map(b64 => + `` + ).join(""); + $("#diff-meta").textContent = JSON.stringify({ + seeds: r.seeds, duration_s: r.duration_s, backend_url: r.backend_url, + }, null, 2); + $("#diff-status").textContent = `ok · ${r.duration_s?.toFixed?.(2) ?? "?"}s · ${(r.images||[]).length} img`; + } catch (e) { + $("#diff-status").textContent = "error: " + (e.message || e); + $("#diff-meta").textContent = JSON.stringify(e.body ?? {}, null, 2); + } +}); +$("#diff-models")?.addEventListener("click", async () => { + try { $("#diff-meta").textContent = JSON.stringify(await jget("/api/diffusion/v1/models"), null, 2); } + catch (e) { $("#diff-meta").textContent = e.message; } +}); +$("#diff-samplers")?.addEventListener("click", async () => { + try { $("#diff-meta").textContent = JSON.stringify(await jget("/api/diffusion/v1/samplers"), null, 2); } + catch (e) { $("#diff-meta").textContent = e.message; } +}); +$("#diff-progress")?.addEventListener("click", async () => { + try { $("#diff-meta").textContent = JSON.stringify(await jget("/api/diffusion/v1/progress"), null, 2); } + catch (e) { $("#diff-meta").textContent = e.message; } +}); + +// ── demand ─────────────────────────────────────────────────────────────────── +async function refreshDemand() { + try { + const platforms = (await jget("/api/demand/platforms")).platforms || []; + const sel = $("#demand-platform"); + if (sel && sel.children.length === 0) { + sel.innerHTML = `` + + platforms.map(p => ``).join(""); + } + await loadDemandJobs(); + await loadDemandReports(); + } catch (e) { + $("#demand-status").textContent = "upstream unavailable: " + (e.message || e); + } +} +async function loadDemandJobs() { + try { + const r = await jget("/api/demand/runs"); + const html = (r.jobs || []).map(j => { + const dur = j.finished_at && j.started_at + ? ((j.finished_at - j.started_at).toFixed(1) + "s") + : (j.status === "running" ? "…" : "—"); + return `
+ ${j.id} + ${j.status} + ${dur} +
${escapeHtml(j.report_path || j.error || "")}
+
`; + }).join("") || `
no jobs yet
`; + $("#demand-jobs").innerHTML = html; + } catch (e) { + $("#demand-jobs").innerHTML = `
${escapeHtml(e.message)}
`; + } +} +async function loadDemandReports() { + try { + const r = await jget("/api/demand/reports"); + const items = (r.reports || []); + $("#demand-reports").innerHTML = items.map(it => + `` + ).join("") || `
no reports
`; + $$("#demand-reports .report-link").forEach(b => { + b.addEventListener("click", async () => { + try { + const data = await jget("/api/demand/reports/" + b.dataset.name); + $("#demand-report-body").textContent = JSON.stringify(data, null, 2); + } catch (e) { + $("#demand-report-body").textContent = e.message; + } + }); + }); + } catch (e) { + $("#demand-reports").innerHTML = `
${escapeHtml(e.message)}
`; + } +} +$("#demand-refresh")?.addEventListener("click", refreshDemand); +$("#demand-form")?.addEventListener("submit", async (e) => { + e.preventDefault(); + const body = { + designs_per_run: Number($("#demand-count").value) || 0, + top_trends: num($("#demand-top").value), + variations_per_trend: num($("#demand-var").value), + min_score: num($("#demand-min").value), + platform: $("#demand-platform").value || null, + dry_run: $("#demand-dry").checked, + upload: $("#demand-up").checked, + web_trends: $("#demand-web").checked, + quantum: $("#demand-q").checked, + }; + try { + const r = await jpost("/api/demand/runs", body); + $("#demand-status").textContent = `queued: ${r.job_id}`; + await loadDemandJobs(); + } catch (e) { + $("#demand-status").textContent = "error: " + (e.message || e); + } +}); + +// ── lang ───────────────────────────────────────────────────────────────────── +async function refreshLang() { + try { + const r = await jget("/api/lang/pipelines"); + const items = r.pipelines || []; + $("#lang-pipelines").innerHTML = items.map(p => + `
+ ${p.name} +
${escapeHtml(p.description || "")}
+
` + ).join(""); + const sel = $("#lang-pipeline"); + if (sel && sel.children.length === 0) { + sel.innerHTML = items.map(p => ``).join(""); + } + $("#lang-status").textContent = "ok"; + } catch (e) { + $("#lang-status").textContent = "upstream unavailable: " + (e.message || e); + } +} +$("#lang-run")?.addEventListener("click", async () => { + const name = $("#lang-pipeline").value; + let input = {}; + try { input = $("#lang-input").value.trim() ? JSON.parse($("#lang-input").value) : {}; } + catch (e) { $("#lang-status").textContent = "bad JSON: " + e.message; return; } + $("#lang-status").textContent = "running…"; + try { + const r = await jpost("/api/lang/pipelines/" + encodeURIComponent(name), { input }); + $("#lang-result").textContent = JSON.stringify(r, null, 2); + $("#lang-status").textContent = `ok · ${r.duration_ms}ms`; + } catch (e) { + $("#lang-result").textContent = JSON.stringify(e.body ?? { error: e.message }, null, 2); + $("#lang-status").textContent = "error " + (e.status ?? ""); + } +}); +$("#lang-health")?.addEventListener("click", async () => { + try { $("#lang-result").textContent = JSON.stringify(await jget("/api/lang/healthz"), null, 2); } + catch (e) { $("#lang-result").textContent = e.message; } +}); + +// ── slack ──────────────────────────────────────────────────────────────────── +async function refreshSlack() { + try { + const [h, info, mem, tools] = await Promise.all([ + jget("/api/slack/healthz").catch(e => ({ error: e.message })), + jget("/api/slack/info").catch(e => ({ error: e.message })), + jget("/api/slack/memory").catch(e => ({ error: e.message })), + jget("/api/slack/tools").catch(e => ({ error: e.message })), + ]); + $("#slack-health").textContent = JSON.stringify(h, null, 2); + $("#slack-info").textContent = JSON.stringify(info, null, 2); + $("#slack-memory").textContent = JSON.stringify(mem, null, 2); + $("#slack-tools").innerHTML = (tools.tools || []).map(t => + `
${t.name}${escapeHtml(t.description||"")}
` + ).join("") || "(no tools)"; + } catch (e) { + $("#slack-health").textContent = e.message; + } +} +$("#slack-refresh")?.addEventListener("click", refreshSlack); +$("#slack-form")?.addEventListener("submit", async (e) => { + e.preventDefault(); + const body = { + text: $("#slack-text").value, + channel: $("#slack-channel").value || "rest", + thread_ts: $("#slack-thread").value || "rest", + }; + $("#slack-reply").textContent = "thinking…"; + try { + const r = await jpost("/api/slack/respond", body); + $("#slack-reply").textContent = r.reply || JSON.stringify(r, null, 2); + } catch (e) { + $("#slack-reply").textContent = "error: " + (e.body?.detail || e.message); + } +}); + +// ── mac ────────────────────────────────────────────────────────────────────── +async function refreshMac() { + try { + const r = await jget("/api/mac/info"); + $("#mac-info").textContent = JSON.stringify(r, null, 2); + const a = $("#mac-download"); + if (r.download_url) { + a.href = r.download_url; + a.textContent = "Download " + (r.build?.name || "app"); + a.classList.remove("hidden"); + } else { + a.classList.add("hidden"); + } + } catch (e) { + $("#mac-info").textContent = e.message; + } +} +$("#mac-refresh")?.addEventListener("click", refreshMac); + +// helpers +function num(v) { + if (v === "" || v == null) return null; + const n = Number(v); + return Number.isFinite(n) ? n : null; +} +function statusClass(s) { + if (s === "completed") return "bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-300"; + if (s === "failed") return "bg-rose-100 text-rose-800 dark:bg-rose-900/40 dark:text-rose-300"; + if (s === "running") return "bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300"; + return "bg-slate-200 text-slate-700 dark:bg-slate-700 dark:text-slate-300"; +} diff --git a/swift-app/.gitignore b/swift-app/.gitignore new file mode 100644 index 0000000..642ae45 --- /dev/null +++ b/swift-app/.gitignore @@ -0,0 +1,7 @@ +.build/ +build/ +*.xcodeproj/ +*.xcworkspace/ +.DS_Store +*.swiftpm/ +Package.resolved diff --git a/swift-app/Makefile b/swift-app/Makefile new file mode 100644 index 0000000..7bdc0e4 --- /dev/null +++ b/swift-app/Makefile @@ -0,0 +1,25 @@ +.PHONY: help build run test app clean + +CONFIG ?= release + +help: + @echo "make build # swift build -c $(CONFIG)" + @echo "make run # swift run -c $(CONFIG) CxWebAppMac" + @echo "make test # swift test" + @echo "make app # bundle into build/CxWebAppMac.app" + @echo "make clean" + +build: + swift build -c $(CONFIG) + +run: + swift run -c $(CONFIG) CxWebAppMac + +test: + swift test + +app: + bash scripts/build-app.sh + +clean: + rm -rf .build build diff --git a/swift-app/Package.swift b/swift-app/Package.swift new file mode 100644 index 0000000..b2cd056 --- /dev/null +++ b/swift-app/Package.swift @@ -0,0 +1,50 @@ +// swift-tools-version:5.9 +// +// CxWebAppMac — a native macOS shell that wraps the C++ Crow backend +// in a WKWebView with proper window chrome, menu commands, and settings. +// +// Build: swift build -c release +// Run: .build/release/CxWebAppMac +// Bundle: bash scripts/build-app.sh (produces CxWebAppMac.app) +// +import PackageDescription + +let package = Package( + name: "CxWebAppMac", + platforms: [.macOS(.v13)], + products: [ + .executable(name: "CxWebAppMac", targets: ["CxWebAppMac"]), + ], + dependencies: [ + .package(url: "https://cxai-studio.com/git/CxAI-Project/CxLLM-SDK.git", branch: "main"), + ], + targets: [ + .executableTarget( + name: "CxWebAppMac", + dependencies: [ + .product(name: "CxCode", package: "CxLLM-SDK"), + .product(name: "CxAWS", package: "CxLLM-SDK"), + .product(name: "CxGit", package: "CxLLM-SDK"), + .product(name: "CxAgent", package: "CxLLM-SDK"), + .product(name: "CxMCP", package: "CxLLM-SDK"), + .product(name: "CxModels", package: "CxLLM-SDK"), + .product(name: "CxChat", package: "CxLLM-SDK"), + .product(name: "CxSPARenderer", package: "CxLLM-SDK"), + .product(name: "CxInstrument", package: "CxLLM-SDK"), + .product(name: "CxLangBridge", package: "CxLLM-SDK"), + ], + path: "Sources/CxWebAppMac", + linkerSettings: [ + .linkedFramework("AppKit"), + .linkedFramework("SwiftUI"), + .linkedFramework("WebKit"), + .linkedFramework("Combine"), + ] + ), + .testTarget( + name: "CxWebAppMacTests", + dependencies: ["CxWebAppMac"], + path: "Tests/CxWebAppMacTests" + ), + ] +) diff --git a/swift-app/README.md b/swift-app/README.md new file mode 100644 index 0000000..91fa9f5 --- /dev/null +++ b/swift-app/README.md @@ -0,0 +1,50 @@ +# CxWebAppMac + +Native macOS SwiftUI shell that wraps the CxWebApp C++/Crow backend in a +`WKWebView` with proper window chrome, menu commands, health monitoring, and +configurable backend URL. + +## Build & run + +```sh +cd swift-app +make build # swift build -c release +make run # launches a SwiftUI window +make test # runs AppSettingsTests +make app # produces build/CxWebAppMac.app +``` + +Override the backend at launch: + +```sh +CXWEBAPP_URL=https://cxwebapp.76-13-126-127.nip.io swift run -c release CxWebAppMac +``` + +## Layout + +``` +swift-app/ +├── Package.swift +├── Makefile +├── README.md +├── scripts/ +│ └── build-app.sh # SPM bin → .app bundle +├── Sources/CxWebAppMac/ +│ ├── CxWebAppMacApp.swift # @main, WindowGroup + Settings scene +│ ├── ContentView.swift # toolbar + WebView + status bar +│ ├── WebView.swift # WKWebView NSViewRepresentable +│ ├── SettingsView.swift # backend URL, presets, devtools toggle +│ ├── AppSettings.swift # persisted preferences +│ └── HealthMonitor.swift # polls /api/health +└── Tests/CxWebAppMacTests/ + └── AppSettingsTests.swift +``` + +## Commands (⌘ shortcuts) + +- ⌘R — reload +- ⌘[ / ⌘] — back / forward +- ⇧⌘H — home +- ⇧⌘C — copy URL +- ⇧⌘O — open in default browser +- ⌘, — Settings (backend URL, presets, developer extras) diff --git a/swift-app/Sources/CxWebAppMac/AppSettings.swift b/swift-app/Sources/CxWebAppMac/AppSettings.swift new file mode 100644 index 0000000..a773243 --- /dev/null +++ b/swift-app/Sources/CxWebAppMac/AppSettings.swift @@ -0,0 +1,32 @@ +import SwiftUI +import Combine + +/// App-wide settings persisted via @AppStorage and exposed as an +/// ObservableObject for views that need a single source of truth. +@MainActor +final class AppSettings: ObservableObject { + /// Default URL for new windows. Override via the Settings pane or + /// `CXWEBAPP_URL` env var at launch. + @Published var backendURL: String { + didSet { UserDefaults.standard.set(backendURL, forKey: "backendURL") } + } + + /// Auto-reload every N seconds. 0 = disabled. + @Published var autoReloadSeconds: Int { + didSet { UserDefaults.standard.set(autoReloadSeconds, forKey: "autoReloadSeconds") } + } + + /// Show the developer/inspector tools menu in the WebView. + @Published var developerExtras: Bool { + didSet { UserDefaults.standard.set(developerExtras, forKey: "developerExtras") } + } + + init() { + let env = ProcessInfo.processInfo.environment["CXWEBAPP_URL"] + self.backendURL = env + ?? UserDefaults.standard.string(forKey: "backendURL") + ?? "http://127.0.0.1:8085" + self.autoReloadSeconds = UserDefaults.standard.integer(forKey: "autoReloadSeconds") + self.developerExtras = UserDefaults.standard.bool(forKey: "developerExtras") + } +} diff --git a/swift-app/Sources/CxWebAppMac/ContentView.swift b/swift-app/Sources/CxWebAppMac/ContentView.swift new file mode 100644 index 0000000..60b73d9 --- /dev/null +++ b/swift-app/Sources/CxWebAppMac/ContentView.swift @@ -0,0 +1,137 @@ +import SwiftUI +import Combine + +/// Top-level view: NavigationSplitView with a CxLLM sidebar. +/// The original WebView surface lives in `WebContentView` and is the +/// default selection so existing behavior is preserved. +struct ContentView: View { + let webAction: PassthroughSubject + + @State private var selection: SidebarItem? = .web + + var body: some View { + NavigationSplitView { + List(SidebarItem.allCases, selection: $selection) { item in + Label(item.rawValue, systemImage: item.systemImage) + .tag(item) + } + .navigationTitle("CxLLM") + .frame(minWidth: 180) + } detail: { + switch selection ?? .web { + case .web: WebContentView(webAction: webAction) + case let other: CxLLMSectionView(item: other) + } + } + } +} + +struct WebContentView: View { + @EnvironmentObject var settings: AppSettings + @StateObject private var health = HealthMonitor() + + @State private var canGoBack = false + @State private var canGoForward = false + @State private var isLoading = false + @State private var pageTitle = "" + + let webAction: PassthroughSubject + + private var url: URL { URL(string: settings.backendURL) ?? URL(string: "about:blank")! } + + var body: some View { + VStack(spacing: 0) { + toolbar + Divider() + WebView( + url: url, + canGoBack: $canGoBack, + canGoForward: $canGoForward, + isLoading: $isLoading, + pageTitle: $pageTitle, + developerExtras: settings.developerExtras, + action: webAction + ) + Divider() + statusBar + } + .frame(minWidth: 800, minHeight: 600) + .onAppear { health.start(url: settings.backendURL) } + .onChange(of: settings.backendURL) { _ in + health.start(url: settings.backendURL) + } + } + + // MARK: - + + private var toolbar: some View { + HStack(spacing: 8) { + Button { webAction.send(.back) } label: { Image(systemName: "chevron.left") } + .disabled(!canGoBack) + Button { webAction.send(.forward) } label: { Image(systemName: "chevron.right") } + .disabled(!canGoForward) + Button { webAction.send(.reload) } label: { Image(systemName: "arrow.clockwise") } + Button { webAction.send(.home) } label: { Image(systemName: "house") } + + Divider().frame(height: 16) + + statusPill + + Text(settings.backendURL) + .lineLimit(1) + .truncationMode(.middle) + .foregroundStyle(.secondary) + .font(.system(.caption, design: .monospaced)) + + Spacer() + + if isLoading { ProgressView().scaleEffect(0.5).frame(width: 16, height: 16) } + + Button { webAction.send(.copyURL) } label: { Image(systemName: "doc.on.doc") } + .help("Copy URL") + Button { webAction.send(.openInBrowser) } label: { Image(systemName: "safari") } + .help("Open in default browser") + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .buttonStyle(.borderless) + } + + private var statusPill: some View { + let (label, color): (String, Color) = { + switch health.status { + case .unknown: return ("…", .gray) + case .up: return ("healthy", .green) + case .down: return ("down", .red) + } + }() + return HStack(spacing: 4) { + Circle().fill(color).frame(width: 8, height: 8) + Text(label).font(.caption) + } + .padding(.horizontal, 8).padding(.vertical, 2) + .background(.quaternary.opacity(0.6), in: Capsule()) + .help(health.lastError ?? "Backend reachable") + } + + private var statusBar: some View { + HStack(spacing: 12) { + Text(pageTitle.isEmpty ? "CxWebApp" : pageTitle) + .font(.caption) + .lineLimit(1) + Spacer() + if let t = health.lastChecked { + Text("checked " + Self.relative(t)) + .font(.caption2) + .foregroundStyle(.secondary) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 4) + } + + private static let relativeFmt: RelativeDateTimeFormatter = { + let f = RelativeDateTimeFormatter(); f.unitsStyle = .short; return f + }() + private static func relative(_ d: Date) -> String { relativeFmt.localizedString(for: d, relativeTo: Date()) } +} diff --git a/swift-app/Sources/CxWebAppMac/CxLLMSidebar.swift b/swift-app/Sources/CxWebAppMac/CxLLMSidebar.swift new file mode 100644 index 0000000..cd2ebe2 --- /dev/null +++ b/swift-app/Sources/CxWebAppMac/CxLLMSidebar.swift @@ -0,0 +1,89 @@ +// CxLLMSidebar — sidebar navigation for the CxLLM module surface, alongside the WebView. +// Phase 4 wiring: each section imports its CxLLM-SDK module and renders a status card. +// Real per-module UI lands in Phase 3. + +import SwiftUI +import CxCode +import CxAWS +import CxGit +import CxAgent +import CxMCP +import CxModels +import CxChat +import CxInstrument +import CxLangBridge + +enum SidebarItem: String, Identifiable, CaseIterable, Hashable { + case web = "Web" + case chat = "Chat" + case agent = "Agent" + case mcp = "MCP" + case models = "Models" + case code = "Code" + case aws = "AWS" + case git = "Git" + case bridge = "LangBridge" + case telemetry = "Telemetry" + + var id: String { rawValue } + + var systemImage: String { + switch self { + case .web: return "globe" + case .chat: return "bubble.left.and.bubble.right" + case .agent: return "person.crop.square.filled.and.at.rectangle" + case .mcp: return "antenna.radiowaves.left.and.right" + case .models: return "cube.transparent" + case .code: return "chevron.left.forwardslash.chevron.right" + case .aws: return "cloud" + case .git: return "arrow.triangle.branch" + case .bridge: return "link" + case .telemetry: return "waveform.path.ecg" + } + } +} + +struct CxLLMSectionView: View { + let item: SidebarItem + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + Label(item.rawValue, systemImage: item.systemImage) + .font(.title) + Divider() + switch item { + case .chat: moduleCard(name: "CxChat", version: "seed") + case .agent: moduleCard(name: "CxAgent", version: "seed") + case .mcp: moduleCard(name: "CxMCP", version: "seed") + case .models: moduleCard(name: "CxModels", version: "seed") + case .code: moduleCard(name: "CxCode", version: "seed") + case .aws: moduleCard(name: "CxAWS", version: "seed") + case .git: moduleCard(name: "CxGit", version: "seed") + case .bridge: moduleCard(name: "CxLangBridge", version: "seed") + case .telemetry: moduleCard(name: "CxInstrument", version: "seed") + case .web: EmptyView() + } + Spacer() + } + .padding(24) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + + @ViewBuilder + private func moduleCard(name: String, version: String) -> some View { + VStack(alignment: .leading, spacing: 8) { + Text(name).font(.headline) + Text("Imported from CxLLM-SDK. Phase 3 will replace this card with the real UI surface.") + .font(.callout).foregroundStyle(.secondary) + HStack(spacing: 12) { + Text("module").font(.caption2).foregroundStyle(.tertiary) + Text(name).font(.caption.monospaced()) + Text("•").font(.caption2).foregroundStyle(.tertiary) + Text("v\(version)").font(.caption.monospaced()) + } + .padding(.top, 4) + } + .padding(16) + .background(.quaternary.opacity(0.4), in: RoundedRectangle(cornerRadius: 10)) + } +} diff --git a/swift-app/Sources/CxWebAppMac/CxWebAppMacApp.swift b/swift-app/Sources/CxWebAppMac/CxWebAppMacApp.swift new file mode 100644 index 0000000..f605c67 --- /dev/null +++ b/swift-app/Sources/CxWebAppMac/CxWebAppMacApp.swift @@ -0,0 +1,37 @@ +import SwiftUI +import Combine + +@main +struct CxWebAppMacApp: App { + @StateObject private var settings = AppSettings() + private let webAction = PassthroughSubject() + + var body: some Scene { + WindowGroup("CxWebApp") { + ContentView(webAction: webAction) + .environmentObject(settings) + } + .windowToolbarStyle(.unified) + .commands { + CommandGroup(after: .toolbar) { + Button("Reload") { webAction.send(.reload) } + .keyboardShortcut("r", modifiers: [.command]) + Button("Back") { webAction.send(.back) } + .keyboardShortcut("[", modifiers: [.command]) + Button("Forward") { webAction.send(.forward) } + .keyboardShortcut("]", modifiers: [.command]) + Divider() + Button("Home") { webAction.send(.home) } + .keyboardShortcut("h", modifiers: [.command, .shift]) + Button("Copy URL") { webAction.send(.copyURL) } + .keyboardShortcut("c", modifiers: [.command, .shift]) + Button("Open in Browser") { webAction.send(.openInBrowser) } + .keyboardShortcut("o", modifiers: [.command, .shift]) + } + } + + Settings { + SettingsView().environmentObject(settings) + } + } +} diff --git a/swift-app/Sources/CxWebAppMac/HealthMonitor.swift b/swift-app/Sources/CxWebAppMac/HealthMonitor.swift new file mode 100644 index 0000000..3ba09c3 --- /dev/null +++ b/swift-app/Sources/CxWebAppMac/HealthMonitor.swift @@ -0,0 +1,55 @@ +import Foundation +import Combine + +/// Polls /api/health on the C++ backend. Drives the menu-bar indicator. +@MainActor +final class HealthMonitor: ObservableObject { + enum Status { case unknown, up, down } + + @Published private(set) var status: Status = .unknown + @Published private(set) var lastChecked: Date? + @Published private(set) var lastError: String? + + private var timer: Timer? + private let session = URLSession(configuration: .ephemeral) + + func start(url: String, every interval: TimeInterval = 10) { + timer?.invalidate() + check(url: url) + timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in + Task { @MainActor in self?.check(url: url) } + } + } + + func stop() { timer?.invalidate(); timer = nil } + + func check(url: String) { + guard let base = URL(string: url), + let healthURL = URL(string: "/api/health", relativeTo: base) else { + status = .down + lastError = "invalid url: \(url)" + return + } + var req = URLRequest(url: healthURL) + req.timeoutInterval = 5 + session.dataTask(with: req) { [weak self] _, resp, err in + Task { @MainActor in + guard let self else { return } + self.lastChecked = Date() + if let err = err { + self.status = .down + self.lastError = err.localizedDescription + return + } + let code = (resp as? HTTPURLResponse)?.statusCode ?? 0 + if (200..<300).contains(code) { + self.status = .up + self.lastError = nil + } else { + self.status = .down + self.lastError = "HTTP \(code)" + } + } + }.resume() + } +} diff --git a/swift-app/Sources/CxWebAppMac/SettingsView.swift b/swift-app/Sources/CxWebAppMac/SettingsView.swift new file mode 100644 index 0000000..657ed36 --- /dev/null +++ b/swift-app/Sources/CxWebAppMac/SettingsView.swift @@ -0,0 +1,50 @@ +import SwiftUI + +struct SettingsView: View { + @EnvironmentObject var settings: AppSettings + @State private var draftURL: String = "" + + var body: some View { + Form { + Section("Backend") { + TextField("URL", text: $draftURL, prompt: Text("http://127.0.0.1:8085")) + .textFieldStyle(.roundedBorder) + .frame(minWidth: 320) + HStack { + Button("Apply") { settings.backendURL = draftURL.trimmingCharacters(in: .whitespaces) } + .keyboardShortcut(.defaultAction) + Spacer() + Text("Override at launch with CXWEBAPP_URL=…") + .font(.caption).foregroundStyle(.secondary) + } + } + + Section("Behavior") { + Toggle("Enable WebKit developer extras (Inspect Element)", + isOn: $settings.developerExtras) + Stepper(value: $settings.autoReloadSeconds, in: 0...3600, step: 5) { + Text("Auto-reload every \(settings.autoReloadSeconds)s " + + (settings.autoReloadSeconds == 0 ? "(disabled)" : "")) + } + } + + Section("Presets") { + presetRow("Local", "http://127.0.0.1:8085") + presetRow("VPS (Caddy)", "https://cxwebapp.76-13-126-127.nip.io") + } + } + .padding(20) + .frame(width: 480) + .onAppear { draftURL = settings.backendURL } + } + + private func presetRow(_ label: String, _ url: String) -> some View { + HStack { + Text(label).frame(width: 100, alignment: .leading) + Text(url).font(.system(.caption, design: .monospaced)).foregroundStyle(.secondary) + Spacer() + Button("Use") { draftURL = url; settings.backendURL = url } + .controlSize(.small) + } + } +} diff --git a/swift-app/Sources/CxWebAppMac/WebView.swift b/swift-app/Sources/CxWebAppMac/WebView.swift new file mode 100644 index 0000000..2a99915 --- /dev/null +++ b/swift-app/Sources/CxWebAppMac/WebView.swift @@ -0,0 +1,94 @@ +import SwiftUI +import WebKit +import Combine + +/// SwiftUI wrapper around WKWebView. Exposes reload / navigation +/// commands through a Coordinator that holds the WKWebView reference. +struct WebView: NSViewRepresentable { + + let url: URL + @Binding var canGoBack: Bool + @Binding var canGoForward: Bool + @Binding var isLoading: Bool + @Binding var pageTitle: String + let developerExtras: Bool + + /// Token broadcast by `App` commands to trigger navigation actions. + let action: PassthroughSubject + + func makeCoordinator() -> Coordinator { Coordinator(self) } + + func makeNSView(context: Context) -> WKWebView { + let cfg = WKWebViewConfiguration() + cfg.websiteDataStore = .nonPersistent() + if developerExtras { + cfg.preferences.setValue(true, forKey: "developerExtrasEnabled") + } + let wv = WKWebView(frame: .zero, configuration: cfg) + wv.navigationDelegate = context.coordinator + wv.uiDelegate = context.coordinator + wv.allowsBackForwardNavigationGestures = true + wv.allowsMagnification = true + wv.load(URLRequest(url: url)) + + context.coordinator.bind(webView: wv) + return wv + } + + func updateNSView(_ wv: WKWebView, context: Context) { + // Reload if the URL prop changed (e.g. user edited it in Settings). + if wv.url?.absoluteString != url.absoluteString { + wv.load(URLRequest(url: url)) + } + } + + enum WebAction { case reload, back, forward, home, openInBrowser, copyURL } + + // ------------------------------------------------------------------------- + final class Coordinator: NSObject, WKNavigationDelegate, WKUIDelegate { + var parent: WebView + weak var webView: WKWebView? + private var cancellables = Set() + private var observers: [NSKeyValueObservation] = [] + + init(_ p: WebView) { parent = p } + + func bind(webView: WKWebView) { + self.webView = webView + observers.append(webView.observe(\.canGoBack) { [weak self] wv, _ in + Task { @MainActor in self?.parent.canGoBack = wv.canGoBack } + }) + observers.append(webView.observe(\.canGoForward) { [weak self] wv, _ in + Task { @MainActor in self?.parent.canGoForward = wv.canGoForward } + }) + observers.append(webView.observe(\.isLoading) { [weak self] wv, _ in + Task { @MainActor in self?.parent.isLoading = wv.isLoading } + }) + observers.append(webView.observe(\.title) { [weak self] wv, _ in + Task { @MainActor in self?.parent.pageTitle = wv.title ?? "" } + }) + + parent.action + .receive(on: DispatchQueue.main) + .sink { [weak self] act in self?.handle(act) } + .store(in: &cancellables) + } + + private func handle(_ a: WebAction) { + guard let wv = webView else { return } + switch a { + case .reload: wv.reload() + case .back: if wv.canGoBack { wv.goBack() } + case .forward: if wv.canGoForward { wv.goForward() } + case .home: wv.load(URLRequest(url: parent.url)) + case .openInBrowser: if let u = wv.url { NSWorkspace.shared.open(u) } + case .copyURL: + if let u = wv.url?.absoluteString { + let pb = NSPasteboard.general + pb.clearContents() + pb.setString(u, forType: .string) + } + } + } + } +} diff --git a/swift-app/Tests/CxWebAppMacTests/AppSettingsTests.swift b/swift-app/Tests/CxWebAppMacTests/AppSettingsTests.swift new file mode 100644 index 0000000..7a1eaf4 --- /dev/null +++ b/swift-app/Tests/CxWebAppMacTests/AppSettingsTests.swift @@ -0,0 +1,25 @@ +import XCTest +@testable import CxWebAppMac + +final class AppSettingsTests: XCTestCase { + + @MainActor + func testDefaultsWhenEmpty() { + UserDefaults.standard.removeObject(forKey: "backendURL") + UserDefaults.standard.removeObject(forKey: "autoReloadSeconds") + UserDefaults.standard.removeObject(forKey: "developerExtras") + + let s = AppSettings() + XCTAssertFalse(s.backendURL.isEmpty) + XCTAssertEqual(s.autoReloadSeconds, 0) + XCTAssertFalse(s.developerExtras) + } + + @MainActor + func testBackendURLPersists() { + let s = AppSettings() + s.backendURL = "http://127.0.0.1:9999" + let s2 = AppSettings() + XCTAssertEqual(s2.backendURL, "http://127.0.0.1:9999") + } +} diff --git a/swift-app/scripts/build-app.sh b/swift-app/scripts/build-app.sh new file mode 100644 index 0000000..0ce6242 --- /dev/null +++ b/swift-app/scripts/build-app.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +# Build CxWebAppMac.app from the SwiftPM target. +set -euo pipefail + +cd "$(dirname "$0")/.." + +CONFIG="${CONFIG:-release}" +APP="CxWebAppMac.app" +DST="build/$APP" + +echo "[1/3] swift build -c $CONFIG" +swift build -c "$CONFIG" + +BIN=$(swift build -c "$CONFIG" --show-bin-path)/CxWebAppMac +[[ -x "$BIN" ]] || { echo "FATAL: $BIN not found" >&2; exit 1; } + +echo "[2/3] assemble bundle at $DST" +rm -rf "$DST" +mkdir -p "$DST/Contents/MacOS" "$DST/Contents/Resources" +cp "$BIN" "$DST/Contents/MacOS/CxWebAppMac" +cat > "$DST/Contents/Info.plist" <<'PLIST' + + + + CFBundleIdentifier studio.cxai.cxwebapp.mac + CFBundleName CxWebApp + CFBundleExecutable CxWebAppMac + CFBundlePackageType APPL + CFBundleShortVersionString 1.1.0 + CFBundleVersion 1 + LSMinimumSystemVersion 13.0 + NSHighResolutionCapable + NSAppTransportSecurity + NSAllowsLocalNetworking + +PLIST + +echo "[3/3] codesign (ad-hoc, no notarization)" +codesign --force --sign - "$DST" 2>/dev/null || true + +echo +echo "Built: $DST" +echo "Run: open '$DST' (or: '$DST/Contents/MacOS/CxWebAppMac')"