feat: initial CxWebApp (macOS shell + swift-app wired to CxLLM-SDK)
Some checks are pending
build-and-push / image (push) Waiting to run

This commit is contained in:
CxAI Agent 2026-05-16 14:32:01 -05:00
commit 055e350108
37 changed files with 2810 additions and 0 deletions

10
.dockerignore Normal file
View File

@ -0,0 +1,10 @@
build/
out/
.vscode/
.idea/
DerivedData/
.cache/
.DS_Store
*.dSYM
*.log
node_modules/

View File

@ -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 }}

19
.gitignore vendored Normal file
View File

@ -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

88
CMakeLists.txt Normal file
View File

@ -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 $<TARGET_FILE_DIR:${PROJECT_NAME}>/static
COMMAND ${CMAKE_COMMAND} -E copy_directory
${CMAKE_SOURCE_DIR}/templates $<TARGET_FILE_DIR:${PROJECT_NAME}>/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)

43
Dockerfile Normal file
View File

@ -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"]

54
README.md Normal file
View File

@ -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
```

171
compose/cxai-stack.yaml Normal file
View File

@ -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

26
include/routes.h Normal file
View File

@ -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

View File

@ -0,0 +1,38 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>English</string>
<key>CFBundleDisplayName</key>
<string>CxAI ADE</string>
<key>CFBundleExecutable</key>
<string>cx-ai</string>
<key>CFBundleIdentifier</key>
<string>com.cxai.cxai</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>CxAI ADE</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>0.6.1</string>
<key>CFBundleVersion</key>
<string>0.6.1</string>
<key>CSResourcesFileMapped</key>
<true/>
<key>LSApplicationCategoryType</key>
<string>public.app-category.developer-tools</string>
<key>LSMinimumSystemVersion</key>
<string>10.15</string>
<key>CFBundleIconFile</key>
<string>icon.icns</string>
<key>LSRequiresCarbon</key>
<true/>
<key>NSHighResolutionCapable</key>
<true/>
<key>NSMicrophoneUsageDescription</key>
<string>CxAI ADE uses your microphone for AI voice input.</string>
</dict>
</plist>

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -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"
}

31
src/main.cpp Normal file
View File

@ -0,0 +1,31 @@
#include "crow.h"
#include "routes.h"
#include <cstdlib>
#include <stdexcept>
#include <string>
int main() {
crow::SimpleApp app;
// Serve static files from ./static
// Crow serves them at /static/<path>
// 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<uint16_t>(port))
.multithreaded()
.run();
return 0;
}

110
src/routes/api.cpp Normal file
View File

@ -0,0 +1,110 @@
#include "crow.h"
#include "routes.h"
#include <chrono>
#include <vector>
#include <mutex>
#include <algorithm>
namespace routes {
namespace {
struct Item {
int id;
std::string name;
std::string description;
};
std::mutex g_mutex;
std::vector<Item> 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<std::chrono::seconds>(
now.time_since_epoch())
.count();
res["timestamp"] = epoch;
return res;
});
// List all items
CROW_ROUTE(app, "/api/items")
([]() {
std::lock_guard<std::mutex> lock(g_mutex);
crow::json::wvalue res;
std::vector<crow::json::wvalue> 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<int>(g_items.size());
return res;
});
// Get single item
CROW_ROUTE(app, "/api/items/<int>")
([](int id) {
std::lock_guard<std::mutex> 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<std::mutex> 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/<int>").methods(crow::HTTPMethod::DELETE)
([](int id) {
std::lock_guard<std::mutex> 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

79
src/routes/mac.cpp Normal file
View File

@ -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 <cstdlib>
#include <fstream>
#include <sstream>
#include <string>
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

97
src/routes/pages.cpp Normal file
View File

@ -0,0 +1,97 @@
#include "crow.h"
#include "routes.h"
#include <cstdlib>
#include <fstream>
#include <sstream>
#include <string>
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<char>::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 `<string>` matches a single segment. Register depths 1..3.
CROW_ROUTE(app, "/static/<string>")
([](const std::string& a) { return serve_static(a); });
CROW_ROUTE(app, "/static/<string>/<string>")
([](const std::string& a, const std::string& b) {
return serve_static(a + "/" + b);
});
CROW_ROUTE(app, "/static/<string>/<string>/<string>")
([](const std::string& a, const std::string& b, const std::string& c) {
return serve_static(a + "/" + b + "/" + c);
});
}
} // namespace routes

240
src/routes/proxy.cpp Normal file
View File

@ -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 `<path>` 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 <httplib.h>
#include "crow.h"
#include "routes.h"
#include <cstdlib>
#include <mutex>
#include <optional>
#include <string>
#include <unordered_set>
#include <vector>
namespace routes {
namespace {
struct Upstream {
std::string scheme;
std::string host;
int port = 0;
};
std::optional<Upstream> 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<Upstream> 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<std::string>& hop_by_hop() {
static const std::unordered_set<std::string> 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<char>(::tolower(static_cast<unsigned char>(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<Upstream> diffusion;
std::optional<Upstream> demand;
std::optional<Upstream> lang;
std::optional<Upstream> 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 "/<path>").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<Upstream>& up_diffusion() { return upstreams().diffusion; }
const std::optional<Upstream>& up_demand() { return upstreams().demand; }
const std::optional<Upstream>& up_lang() { return upstreams().lang; }
const std::optional<Upstream>& 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<Upstream>& up,
const char* prefix) {
crow::json::wvalue item;
item["service"] = std::string(name);
item["prefix"] = std::string(prefix);
item["configured"] = static_cast<bool>(up);
if (up) {
item["upstream_host"] = up->host;
item["upstream_port"] = up->port;
item["upstream_scheme"] = up->scheme;
}
return item;
};
std::vector<crow::json::wvalue> 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

84
src/routes/system.cpp Normal file
View File

@ -0,0 +1,84 @@
#include "crow.h"
#include "routes.h"
#include <chrono>
#include <cstdlib>
#include <ctime>
#include <string>
#include <sys/utsname.h>
#include <unistd.h>
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<long>(::getpid());
r["uptime_seconds"] = static_cast<long>(std::time(nullptr) - boot_epoch());
r["cxx_standard"] = static_cast<int>(__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<int>(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

117
static/css/style.css Normal file
View File

@ -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; }

3
static/favicon.svg Normal file
View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#7c5cff" stroke-width="2">
<path d="M4 4h6v6H4zM14 4h6v6h-6zM4 14h6v6H4zM14 14h6v6h-6z"/>
</svg>

After

Width:  |  Height:  |  Size: 179 B

329
static/index.html Normal file
View File

@ -0,0 +1,329 @@
<!DOCTYPE html>
<html lang="en" class="h-full">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="dark light" />
<title>CxWebApp · Console</title>
<link rel="icon" href="/favicon.ico" type="image/svg+xml" />
<link rel="stylesheet" href="/static/css/style.css" />
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
darkMode: 'class',
theme: { extend: { colors: { brand: '#7c5cff' } } }
};
</script>
</head>
<body class="h-full bg-slate-50 text-slate-900 dark:bg-slate-950 dark:text-slate-100 antialiased">
<div id="app" class="min-h-full flex flex-col">
<!-- Top bar -->
<header class="border-b border-slate-200 dark:border-slate-800 bg-white/70 dark:bg-slate-900/70 backdrop-blur">
<div class="max-w-6xl mx-auto px-4 py-3 flex items-center gap-3">
<div class="w-9 h-9 rounded-lg bg-brand/20 grid place-items-center">
<svg viewBox="0 0 24 24" class="w-5 h-5 text-brand" fill="none" stroke="currentColor" stroke-width="2">
<path d="M4 4h6v6H4zM14 4h6v6h-6zM4 14h6v6H4zM14 14h6v6h-6z"/>
</svg>
</div>
<div class="flex-1">
<h1 class="font-semibold leading-tight">CxWebApp</h1>
<p class="text-xs text-slate-500" id="header-sub">C++ / Crow · loading…</p>
</div>
<span id="health-pill"
class="px-2.5 py-1 rounded-full text-xs font-medium bg-slate-200 text-slate-700 dark:bg-slate-800 dark:text-slate-300">
checking
</span>
<button id="theme-toggle"
class="ml-1 px-2 py-1 rounded-md text-xs border border-slate-300 dark:border-slate-700 hover:bg-slate-100 dark:hover:bg-slate-800">
theme
</button>
</div>
<!-- Tabs -->
<nav class="max-w-6xl mx-auto px-4 flex gap-1 overflow-x-auto">
<button data-tab="dashboard" class="tab tab-active">Dashboard</button>
<button data-tab="items" class="tab">Items</button>
<button data-tab="system" class="tab">System</button>
<button data-tab="diffusion" class="tab">Diffusion</button>
<button data-tab="demand" class="tab">Demand</button>
<button data-tab="lang" class="tab">LangSmith</button>
<button data-tab="slack" class="tab">Slack</button>
<button data-tab="mac" class="tab">macOS App</button>
<button data-tab="websocket" class="tab">WebSocket</button>
<button data-tab="api" class="tab">API Explorer</button>
<button data-tab="about" class="tab">About</button>
</nav>
</header>
<main class="max-w-6xl mx-auto px-4 py-6 w-full flex-1">
<!-- Dashboard -->
<section data-pane="dashboard" class="pane">
<div class="grid gap-4 md:grid-cols-3">
<div class="card">
<div class="card-label">Health</div>
<div class="card-value" id="d-health"></div>
<div class="card-sub" id="d-health-sub">last checked: never</div>
</div>
<div class="card">
<div class="card-label">Uptime</div>
<div class="card-value" id="d-uptime"></div>
<div class="card-sub">seconds since first request</div>
</div>
<div class="card">
<div class="card-label">Items</div>
<div class="card-value" id="d-items"></div>
<div class="card-sub">tracked in-memory</div>
</div>
</div>
<div class="mt-6 card">
<div class="flex items-center justify-between mb-3">
<h2 class="font-semibold">Recent activity</h2>
<button id="d-refresh" class="btn-secondary">Refresh</button>
</div>
<pre id="d-log" class="text-xs leading-relaxed text-slate-600 dark:text-slate-400 whitespace-pre-wrap h-48 overflow-auto"></pre>
</div>
</section>
<!-- Items -->
<section data-pane="items" class="pane hidden">
<div class="card">
<h2 class="font-semibold mb-3">Items</h2>
<form id="add-form" class="flex flex-wrap gap-2 mb-4">
<input type="text" id="item-name" placeholder="Name" required
class="input flex-1 min-w-[12rem]" />
<input type="text" id="item-desc" placeholder="Description"
class="input flex-1 min-w-[14rem]" />
<button type="submit" class="btn-primary">Add</button>
</form>
<div id="items-list" class="divide-y divide-slate-200 dark:divide-slate-800"></div>
</div>
</section>
<!-- System -->
<section data-pane="system" class="pane hidden">
<div class="grid gap-4 md:grid-cols-2">
<div class="card">
<h2 class="font-semibold mb-3">/api/version</h2>
<pre id="sys-version" class="text-xs"></pre>
</div>
<div class="card">
<h2 class="font-semibold mb-3">/api/system</h2>
<pre id="sys-system" class="text-xs"></pre>
</div>
</div>
</section>
<!-- Diffusion -->
<section data-pane="diffusion" class="pane hidden">
<div class="grid gap-4 md:grid-cols-2">
<div class="card">
<h2 class="font-semibold mb-3">Generate</h2>
<form id="diff-form" class="space-y-2">
<textarea id="diff-prompt" rows="3" class="input w-full" placeholder="prompt"></textarea>
<textarea id="diff-neg" rows="2" class="input w-full" placeholder="negative prompt"></textarea>
<div class="grid grid-cols-3 gap-2">
<input type="number" id="diff-steps" class="input" value="20" min="1" max="150" />
<input type="number" id="diff-w" class="input" value="512" min="64" max="2048" />
<input type="number" id="diff-h" class="input" value="512" min="64" max="2048" />
</div>
<div class="grid grid-cols-2 gap-2">
<input type="number" id="diff-cfg" class="input" value="7" step="0.5" min="1" max="30" />
<input type="text" id="diff-sampler" class="input" placeholder="sampler (e.g. Euler a)" />
</div>
<div class="flex gap-2">
<button type="submit" class="btn-primary">Generate</button>
<button type="button" id="diff-models" class="btn-secondary">List models</button>
<button type="button" id="diff-samplers" class="btn-secondary">Samplers</button>
<button type="button" id="diff-progress" class="btn-secondary">Progress</button>
</div>
</form>
<div id="diff-status" class="text-xs text-slate-500 mt-2">idle</div>
</div>
<div class="card">
<h2 class="font-semibold mb-3">Result</h2>
<div id="diff-gallery" class="grid grid-cols-2 gap-2"></div>
<pre id="diff-meta" class="text-xs mt-3 max-h-48 overflow-auto"></pre>
</div>
</div>
</section>
<!-- Demand -->
<section data-pane="demand" class="pane hidden">
<div class="grid gap-4 md:grid-cols-2">
<div class="card">
<h2 class="font-semibold mb-3">Start a run</h2>
<form id="demand-form" class="space-y-2">
<div class="grid grid-cols-2 gap-2">
<input type="number" id="demand-count" class="input" value="5" min="0" max="200" placeholder="designs per run" />
<select id="demand-platform" class="input"></select>
</div>
<div class="grid grid-cols-3 gap-2">
<input type="number" id="demand-top" class="input" placeholder="top trends" />
<input type="number" id="demand-var" class="input" placeholder="variations" />
<input type="number" id="demand-min" class="input" placeholder="min score" />
</div>
<div class="flex gap-3 text-sm items-center">
<label class="flex gap-1 items-center"><input type="checkbox" id="demand-dry" checked /> dry-run</label>
<label class="flex gap-1 items-center"><input type="checkbox" id="demand-up" /> upload</label>
<label class="flex gap-1 items-center"><input type="checkbox" id="demand-web" /> web-trends</label>
<label class="flex gap-1 items-center"><input type="checkbox" id="demand-q" /> quantum</label>
</div>
<button type="submit" class="btn-primary">Run</button>
</form>
<div id="demand-status" class="text-xs text-slate-500 mt-2">idle</div>
</div>
<div class="card">
<h2 class="font-semibold mb-3">Jobs</h2>
<button id="demand-refresh" class="btn-secondary mb-3">Refresh</button>
<div id="demand-jobs" class="space-y-2 text-sm max-h-64 overflow-auto"></div>
<h3 class="font-semibold mt-4 mb-2">Reports</h3>
<div id="demand-reports" class="space-y-1 text-xs max-h-48 overflow-auto"></div>
<pre id="demand-report-body" class="text-xs mt-3 max-h-64 overflow-auto"></pre>
</div>
</div>
</section>
<!-- LangSmith -->
<section data-pane="lang" class="pane hidden">
<div class="card">
<h2 class="font-semibold mb-3">Pipelines</h2>
<div id="lang-pipelines" class="space-y-2 text-sm"></div>
<div class="grid gap-2 md:grid-cols-2 mt-4">
<div>
<label class="text-xs text-slate-500">Selected pipeline</label>
<select id="lang-pipeline" class="input w-full"></select>
<label class="text-xs text-slate-500 mt-2 block">Input JSON</label>
<textarea id="lang-input" rows="6" class="input w-full font-mono text-xs"
placeholder='{ "circuit": "bell", "shots": 1024 }'></textarea>
<div class="flex gap-2 mt-2">
<button id="lang-run" class="btn-primary">Run</button>
<button id="lang-health" class="btn-secondary">Health</button>
</div>
<div id="lang-status" class="text-xs text-slate-500 mt-2">idle</div>
</div>
<div>
<label class="text-xs text-slate-500">Result</label>
<pre id="lang-result" class="text-xs h-64 overflow-auto bg-slate-900 text-slate-100 rounded-lg p-3"></pre>
</div>
</div>
</div>
</section>
<!-- Slack -->
<section data-pane="slack" class="pane hidden">
<div class="grid gap-4 md:grid-cols-2">
<div class="card">
<h2 class="font-semibold mb-3">Status</h2>
<pre id="slack-health" class="text-xs"></pre>
<h3 class="font-semibold mt-4 mb-2">Config</h3>
<pre id="slack-info" class="text-xs"></pre>
<h3 class="font-semibold mt-4 mb-2">Memory</h3>
<pre id="slack-memory" class="text-xs"></pre>
<button id="slack-refresh" class="btn-secondary mt-2">Refresh</button>
</div>
<div class="card">
<h2 class="font-semibold mb-3">Tools</h2>
<div id="slack-tools" class="text-xs max-h-48 overflow-auto"></div>
<h3 class="font-semibold mt-4 mb-2">Send (one-shot)</h3>
<form id="slack-form" class="space-y-2">
<textarea id="slack-text" rows="3" class="input w-full" placeholder="message text"></textarea>
<div class="grid grid-cols-2 gap-2">
<input id="slack-channel" class="input" value="rest" />
<input id="slack-thread" class="input" value="rest" />
</div>
<button type="submit" class="btn-primary">Respond</button>
</form>
<pre id="slack-reply" class="text-xs mt-3 max-h-48 overflow-auto"></pre>
</div>
</div>
</section>
<!-- macOS App -->
<section data-pane="mac" class="pane hidden">
<div class="card">
<h2 class="font-semibold mb-3">CxAI macOS App</h2>
<p class="text-sm text-slate-500 mb-3">
SwiftPM app shipped from <code>apps/cxai-mac</code>. The CxWebApp
container serves this build-info; download URL is configurable via
<code>CXAI_MAC_DOWNLOAD_URL</code>.
</p>
<pre id="mac-info" class="text-xs"></pre>
<a id="mac-download" class="btn-primary mt-3 inline-block hidden">Download .app</a>
<button id="mac-refresh" class="btn-secondary mt-3">Refresh</button>
</div>
</section>
<section data-pane="websocket" class="pane hidden">
<div class="card">
<h2 class="font-semibold mb-3">WebSocket Echo</h2>
<p class="text-sm text-slate-500 mb-3">
Connects to <code class="px-1 py-0.5 bg-slate-200 dark:bg-slate-800 rounded">/ws/echo</code> on the C++ Crow backend.
</p>
<div class="flex gap-2 mb-3">
<button id="ws-connect" class="btn-primary">Connect</button>
<button id="ws-disconnect" class="btn-secondary">Disconnect</button>
<span id="ws-state" class="text-sm self-center text-slate-500">disconnected</span>
</div>
<form id="ws-form" class="flex gap-2 mb-3">
<input type="text" id="ws-input" placeholder="message"
class="input flex-1" />
<button type="submit" class="btn-primary">Send</button>
</form>
<pre id="ws-log" class="text-xs h-64 overflow-auto bg-slate-900 text-emerald-300 rounded-lg p-3"></pre>
</div>
</section>
<!-- API Explorer -->
<section data-pane="api" class="pane hidden">
<div class="card">
<h2 class="font-semibold mb-3">API Explorer</h2>
<div class="flex gap-2 mb-3 flex-wrap">
<select id="api-method" class="input w-24">
<option>GET</option><option>POST</option><option>DELETE</option>
</select>
<input id="api-path" class="input flex-1 min-w-[16rem]" value="/api/health" />
<button id="api-send" class="btn-primary">Send</button>
</div>
<textarea id="api-body" rows="4" class="input w-full font-mono text-xs mb-3"
placeholder='{"name":"Sample","description":"From explorer"}'></textarea>
<div class="flex items-center justify-between mb-2">
<span id="api-status" class="text-xs text-slate-500"></span>
<button id="api-clear" class="text-xs text-slate-500 hover:underline">clear</button>
</div>
<pre id="api-out" class="text-xs h-64 overflow-auto bg-slate-900 text-slate-100 rounded-lg p-3"></pre>
</div>
</section>
<!-- About -->
<section data-pane="about" class="pane hidden">
<div class="card prose dark:prose-invert max-w-none">
<h2 class="font-semibold">About CxWebApp</h2>
<p class="text-sm">
A C++20 web application built on <a href="https://crowcpp.org" class="text-brand">Crow</a>,
running inside a multi-stage Debian container behind Caddy with Let's Encrypt TLS.
</p>
<ul class="text-sm list-disc pl-5 space-y-1">
<li><strong>Backend:</strong> C++20 / Crow v1.2.0 / Asio 1.30 / multi-threaded.</li>
<li><strong>Frontend:</strong> Vanilla JS + Tailwind (CDN). SPA shell with tabs.</li>
<li><strong>Native shell:</strong> SwiftPM macOS app in <code>swift-app/</code> wrapping this UI in a WKWebView with native menus.</li>
<li><strong>Container:</strong> <code>registry.76-13-126-127.nip.io/cxai/cxwebapp:latest</code></li>
</ul>
</div>
</section>
</main>
<footer class="border-t border-slate-200 dark:border-slate-800 mt-auto">
<div class="max-w-6xl mx-auto px-4 py-3 text-xs text-slate-500 flex justify-between">
<span>CxWebApp</span>
<span id="footer-build">build · —</span>
</div>
</footer>
</div>
<script type="module" src="/static/js/app.js"></script>
</body>
</html>

532
static/js/app.js Normal file
View File

@ -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 = `<div class="text-sm text-slate-500 py-3">No items yet.</div>`;
return;
}
list.innerHTML = j.items.map(it => `
<div class="item-row" data-id="${it.id}">
<span class="id">#${it.id}</span>
<span class="name">${escapeHtml(it.name)}</span>
<span class="desc">${escapeHtml(it.description || "")}</span>
<span class="del">delete</span>
</div>`).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 = `<div class="text-sm text-rose-500 py-3">${escapeHtml(e.message)}</div>`;
}
}
$("#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 => (
{ "&":"&amp;", "<":"&lt;", ">":"&gt;", '"':"&quot;", "'":"&#39;" }[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 =>
`<img src="data:image/png;base64,${b64}" class="rounded-lg border border-slate-200 dark:border-slate-700" />`
).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 = `<option value="">(default)</option>` +
platforms.map(p => `<option value="${p}">${p}</option>`).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 `<div class="border border-slate-200 dark:border-slate-700 rounded px-2 py-1">
<code class="text-xs">${j.id}</code>
<span class="ml-2 px-1.5 py-0.5 rounded text-xs ${statusClass(j.status)}">${j.status}</span>
<span class="text-xs text-slate-500 ml-2">${dur}</span>
<div class="text-xs text-slate-500">${escapeHtml(j.report_path || j.error || "")}</div>
</div>`;
}).join("") || `<div class="text-slate-500 text-xs">no jobs yet</div>`;
$("#demand-jobs").innerHTML = html;
} catch (e) {
$("#demand-jobs").innerHTML = `<div class="text-rose-500 text-xs">${escapeHtml(e.message)}</div>`;
}
}
async function loadDemandReports() {
try {
const r = await jget("/api/demand/reports");
const items = (r.reports || []);
$("#demand-reports").innerHTML = items.map(it =>
`<button data-name="${it.name}" class="report-link block text-left hover:underline">${it.name} <span class="text-slate-500">(${it.size}b)</span></button>`
).join("") || `<div class="text-slate-500">no reports</div>`;
$$("#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 = `<div class="text-rose-500">${escapeHtml(e.message)}</div>`;
}
}
$("#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 =>
`<div class="border border-slate-200 dark:border-slate-700 rounded px-2 py-1">
<strong>${p.name}</strong>
<div class="text-xs text-slate-500">${escapeHtml(p.description || "")}</div>
</div>`
).join("");
const sel = $("#lang-pipeline");
if (sel && sel.children.length === 0) {
sel.innerHTML = items.map(p => `<option value="${p.name}">${p.name}</option>`).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 =>
`<div><strong>${t.name}</strong> — <span class="text-slate-500">${escapeHtml(t.description||"")}</span></div>`
).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";
}

7
swift-app/.gitignore vendored Normal file
View File

@ -0,0 +1,7 @@
.build/
build/
*.xcodeproj/
*.xcworkspace/
.DS_Store
*.swiftpm/
Package.resolved

25
swift-app/Makefile Normal file
View File

@ -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

50
swift-app/Package.swift Normal file
View File

@ -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"
),
]
)

50
swift-app/README.md Normal file
View File

@ -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)

View File

@ -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")
}
}

View File

@ -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<WebView.WebAction, Never>
@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<WebView.WebAction, Never>
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()) }
}

View File

@ -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))
}
}

View File

@ -0,0 +1,37 @@
import SwiftUI
import Combine
@main
struct CxWebAppMacApp: App {
@StateObject private var settings = AppSettings()
private let webAction = PassthroughSubject<WebView.WebAction, Never>()
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)
}
}
}

View File

@ -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()
}
}

View File

@ -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)
}
}
}

View File

@ -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<WebAction, Never>
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<AnyCancellable>()
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)
}
}
}
}
}

View File

@ -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")
}
}

View File

@ -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'
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
<key>CFBundleIdentifier</key> <string>studio.cxai.cxwebapp.mac</string>
<key>CFBundleName</key> <string>CxWebApp</string>
<key>CFBundleExecutable</key> <string>CxWebAppMac</string>
<key>CFBundlePackageType</key> <string>APPL</string>
<key>CFBundleShortVersionString</key> <string>1.1.0</string>
<key>CFBundleVersion</key> <string>1</string>
<key>LSMinimumSystemVersion</key> <string>13.0</string>
<key>NSHighResolutionCapable</key> <true/>
<key>NSAppTransportSecurity</key>
<dict><key>NSAllowsLocalNetworking</key><true/></dict>
</dict></plist>
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')"