feat: initial CxWebApp (macOS shell + swift-app wired to CxLLM-SDK)
Some checks are pending
build-and-push / image (push) Waiting to run
Some checks are pending
build-and-push / image (push) Waiting to run
This commit is contained in:
commit
055e350108
10
.dockerignore
Normal file
10
.dockerignore
Normal file
@ -0,0 +1,10 @@
|
||||
build/
|
||||
out/
|
||||
.vscode/
|
||||
.idea/
|
||||
DerivedData/
|
||||
.cache/
|
||||
.DS_Store
|
||||
*.dSYM
|
||||
*.log
|
||||
node_modules/
|
||||
28
.gitea/workflows/build.yml
Normal file
28
.gitea/workflows/build.yml
Normal 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
19
.gitignore
vendored
Normal 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
88
CMakeLists.txt
Normal 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
43
Dockerfile
Normal 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
54
README.md
Normal 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
171
compose/cxai-stack.yaml
Normal 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
26
include/routes.h
Normal 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
|
||||
38
share/cxai-mac/CxAI ADE.app/Contents/Info.plist
Normal file
38
share/cxai-mac/CxAI ADE.app/Contents/Info.plist
Normal 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>
|
||||
BIN
share/cxai-mac/CxAI ADE.app/Contents/MacOS/cx-ai
Executable file
BIN
share/cxai-mac/CxAI ADE.app/Contents/MacOS/cx-ai
Executable file
Binary file not shown.
BIN
share/cxai-mac/CxAI ADE.app/Contents/Resources/icon.icns
Normal file
BIN
share/cxai-mac/CxAI ADE.app/Contents/Resources/icon.icns
Normal file
Binary file not shown.
BIN
share/cxai-mac/CxAI-ADE-0.6.1-aarch64.app.tar.gz
Normal file
BIN
share/cxai-mac/CxAI-ADE-0.6.1-aarch64.app.tar.gz
Normal file
Binary file not shown.
BIN
share/cxai-mac/CxAI-ADE-0.6.1-aarch64.dmg
Normal file
BIN
share/cxai-mac/CxAI-ADE-0.6.1-aarch64.dmg
Normal file
Binary file not shown.
17
share/cxai-mac/build-info.json
Normal file
17
share/cxai-mac/build-info.json
Normal 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
31
src/main.cpp
Normal 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
110
src/routes/api.cpp
Normal 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
79
src/routes/mac.cpp
Normal 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
97
src/routes/pages.cpp
Normal 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
240
src/routes/proxy.cpp
Normal 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
84
src/routes/system.cpp
Normal 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
117
static/css/style.css
Normal 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
3
static/favicon.svg
Normal 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
329
static/index.html
Normal 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
532
static/js/app.js
Normal 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 => (
|
||||
{ "&":"&", "<":"<", ">":">", '"':""", "'":"'" }[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
7
swift-app/.gitignore
vendored
Normal file
@ -0,0 +1,7 @@
|
||||
.build/
|
||||
build/
|
||||
*.xcodeproj/
|
||||
*.xcworkspace/
|
||||
.DS_Store
|
||||
*.swiftpm/
|
||||
Package.resolved
|
||||
25
swift-app/Makefile
Normal file
25
swift-app/Makefile
Normal 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
50
swift-app/Package.swift
Normal 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
50
swift-app/README.md
Normal 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)
|
||||
32
swift-app/Sources/CxWebAppMac/AppSettings.swift
Normal file
32
swift-app/Sources/CxWebAppMac/AppSettings.swift
Normal 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")
|
||||
}
|
||||
}
|
||||
137
swift-app/Sources/CxWebAppMac/ContentView.swift
Normal file
137
swift-app/Sources/CxWebAppMac/ContentView.swift
Normal 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()) }
|
||||
}
|
||||
89
swift-app/Sources/CxWebAppMac/CxLLMSidebar.swift
Normal file
89
swift-app/Sources/CxWebAppMac/CxLLMSidebar.swift
Normal 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))
|
||||
}
|
||||
}
|
||||
37
swift-app/Sources/CxWebAppMac/CxWebAppMacApp.swift
Normal file
37
swift-app/Sources/CxWebAppMac/CxWebAppMacApp.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
55
swift-app/Sources/CxWebAppMac/HealthMonitor.swift
Normal file
55
swift-app/Sources/CxWebAppMac/HealthMonitor.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
50
swift-app/Sources/CxWebAppMac/SettingsView.swift
Normal file
50
swift-app/Sources/CxWebAppMac/SettingsView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
94
swift-app/Sources/CxWebAppMac/WebView.swift
Normal file
94
swift-app/Sources/CxWebAppMac/WebView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
25
swift-app/Tests/CxWebAppMacTests/AppSettingsTests.swift
Normal file
25
swift-app/Tests/CxWebAppMacTests/AppSettingsTests.swift
Normal 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")
|
||||
}
|
||||
}
|
||||
43
swift-app/scripts/build-app.sh
Normal file
43
swift-app/scripts/build-app.sh
Normal 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')"
|
||||
Loading…
Reference in New Issue
Block a user