Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
133 changes: 97 additions & 36 deletions src/facade/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,20 @@ Portable iframe facade for the Wippy frontend. Serves a thin HTML shell that loa
1. `index.html` is served as a static file via `http.static`
2. On load, it fetches `GET /api/public/facade/config` to get runtime configuration
3. Checks `localStorage` for an auth token, redirects to `login_path` if missing
4. Creates an iframe pointing to the CDN-hosted frontend bundle
5. Bridges auth and routing between the host page and the iframe via `postMessage`
4. Loads the Web Host bundle from CDN (`facade_url + '/module.js'`)
5. Calls `initWippyApp()` with auth, feature flags, and customization config
6. Shows inline loader/error UI during initialization (no external CSS dependencies)

API URL and WebSocket URL are derived from the `domain` requirement. If `domain` is empty, the browser falls back to `window.location`.
## Derived values

These fields are NOT configurable via requirements — they are computed at runtime:

| Field | Source | Description |
|---|---|---|
| `api_url` | `PUBLIC_API_URL` env var | Base URL for API calls. Falls back to `window.location.origin` in the browser if empty. |
| `ws_url` | Derived from `api_url` | WebSocket URL — `http://` → `ws://`, `https://` → `wss://` |
| `iframe_origin` | Extracted from `fe_facade_url` | Origin portion of facade URL (e.g. `https://web-host.wippy.ai`), used for `postMessage` security |
| `iframe_url` | `fe_facade_url` + `fe_entry_path` + `?waitForCustomConfig` | Full iframe URL passed to the Web Host |

## Requirements

Expand All @@ -25,36 +35,50 @@ API URL and WebSocket URL are derived from the `domain` requirement. If `domain`

| Requirement | Default | Description |
|---|---|---|
| `domain` | _(empty)_ | Canonical app domain (e.g. `localhost:8085`, `app.wippy.ai`). Derives API and WS URLs. |
| `fe_facade_url` | `https://web-host.wippy.ai/webcomponents-1.0.5` | CDN base URL for frontend bundle |
| `fe_entry_path` | `/iframe.html` | Entry point path within the bundle |
| `fe_facade_url` | `https://web-host.wippy.ai/webcomponents-1.0.12` | CDN base URL for the Web Host frontend bundle |
| `fe_entry_path` | `/iframe.html` | Iframe HTML entry point path (appended to `fe_facade_url`) |

### App Identity

| Requirement | Default | Description |
|---|---|---|
| `app_title` | `Wippy` | Sidebar title |
| `app_name` | `Wippy AI` | Full app name |
| `app_icon` | `wippy:logo` | Iconify icon reference |
Passed to the Web Host as `customization.i18n.app` — controls branding in sidebar and navigation.

| Requirement | Default | Config path | Description |
|---|---|---|---|
| `app_title` | `Wippy` | `customization.i18n.app.title` | Short title shown in sidebar header |
| `app_name` | `Wippy AI` | `customization.i18n.app.appName` | Full application name |
| `app_icon` | `wippy:logo` | `customization.i18n.app.icon` | Iconify icon reference (e.g. `custom:logo`, `tabler:home`) |

### Feature Flags

| Requirement | Default | Description |
|---|---|---|
| `show_admin` | `true` | Show admin panel and keeper controls in the sidebar |
| `start_nav_open` | `false` | Navigation drawer open by default (collapsed shows icons only) |
| `hide_nav_bar` | `false` | Completely hide the left navigation sidebar |
| `disable_right_panel` | `false` | Disable the right sidebar panel |
| `allow_select_model` | `false` | Allow LLM model selection |
| `session_type` | `non-persistent` | Chat session persistence (`non-persistent` or `persistent`) |
| `history_mode` | `hash` | Browser history mode (`hash` or `history`) |
Passed to the Web Host as `feature.*` — control UI behavior and visibility.

| Requirement | Default | Type | Description |
|---|---|---|---|
| `show_admin` | `true` | bool (`~= "false"`) | Show admin panel and keeper controls in the sidebar |
| `start_nav_open` | `false` | bool (`== "true"`) | Navigation drawer open by default (collapsed shows icons only) |
| `hide_nav_bar` | `false` | bool (`== "true"`) | Completely hide the left navigation sidebar |
| `disable_right_panel` | `false` | bool (`== "true"`) | Disable the right sidebar panel |
| `allow_select_model` | `false` | bool (`== "true"`) | Allow LLM model selection dropdown in chat |
| `session_type` | `non-persistent` | string | Chat session persistence (`non-persistent` or `cookie`) |
| `history_mode` | `hash` | string | Browser history mode (`hash` or `history`/`browser`) |

> **Boolean parsing:** `show_admin` defaults to `true` (any value except `"false"` is truthy). All other boolean flags default to `false` (only `"true"` is truthy). This is implemented in `config_handler.lua`.

### Auth & Theming
### Auth

| Requirement | Default | Description |
|---|---|---|
| `login_path` | `/login.html` | Unauthenticated redirect path |
| `custom_css` | Poppins font import | Custom CSS injected into iframe config |
| `login_path` | `/login.html` | Path to redirect unauthenticated users (no token in localStorage) |

### Theming & Customization

Passed to the Web Host as `customization.*` — control visual appearance across all host-rendered pages and web components.

| Requirement | Default | Config path | Description |
|---|---|---|---|
| `custom_css` | Poppins font `@import` | `customization.custom_css` | Raw CSS string injected as `<style>` into host and child iframes. Use for font imports, body font-family, and custom rules. |
| `css_variables` | `{}` | `customization.css_variables` | JSON object of CSS custom properties (e.g. `{"--p-primary":"#6366f1"}`). Injected as `:root { --key: value; }` overrides. Supports `@dark` and `@light` variants. |
| `icons` | `{}` | `customization.icons` | JSON object of custom Iconify icons (e.g. `{"logo":{"body":"<svg>...</svg>","width":24,"height":24}}`). Registered under `custom:` prefix — use as `custom:logo`. |

## Usage

Expand All @@ -68,13 +92,11 @@ API URL and WebSocket URL are derived from the `domain` requirement. If `domain`
value: app:gateway
- name: router
value: app:api.public
- name: domain
value: localhost:8085
```

Only override what differs from defaults.

### Page-only app (no chat sidebar)
### Themed page-only app (no chat sidebar)

```yaml
- name: hide_nav_bar
Expand All @@ -83,6 +105,19 @@ Only override what differs from defaults.
value: "true"
- name: show_admin
value: "false"
- name: custom_css
value: "@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap'); body { font-family: 'Inter', sans-serif; }"
- name: css_variables
value: '{"--p-primary":"#6366f1"}'
```

### Custom icons

```yaml
- name: icons
value: '{"logo":{"body":"<svg viewBox=\"0 0 24 24\"><path d=\"M12 2L2 22h20L12 2z\"/></svg>","width":24,"height":24}}'
- name: app_icon
value: "custom:logo"
```

## Config Response
Expand All @@ -91,30 +126,56 @@ Only override what differs from defaults.

```json
{
"facade_url": "https://web-host.wippy.ai/webcomponents-1.0.5",
"facade_url": "https://web-host.wippy.ai/webcomponents-1.0.12",
"iframe_origin": "https://web-host.wippy.ai",
"iframe_url": "https://web-host.wippy.ai/webcomponents-1.0.5/iframe.html?waitForCustomConfig",
"api_url": "http://localhost:8086",
"ws_url": "ws://localhost:8086",
"iframe_url": "https://web-host.wippy.ai/webcomponents-1.0.12/iframe.html?waitForCustomConfig",
"api_url": "http://localhost:8085",
"ws_url": "ws://localhost:8085",
"feature": {
"session_type": "non-persistent",
"history_mode": "hash",
"show_admin": false,
"show_admin": true,
"allow_select_model": false,
"start_nav_open": false,
"hide_nav_bar": false,
"disable_right_panel": false
},
"customization": {
"custom_css": "...",
"css_variables": [],
"i18n": { "app": { "title": "...", "icon": "...", "appName": "..." } },
"icons": []
"custom_css": "@import url('https://fonts.googleapis.com/css2?family=Poppins...');",
"css_variables": {},
"i18n": { "app": { "title": "Wippy", "icon": "wippy:logo", "appName": "Wippy AI" } },
"icons": {}
},
"login_path": "/api/public/auth/login"
"login_path": "/login.html"
}
```

### How `index.html` uses the config

```
fetch /api/public/facade/config
→ check localStorage for auth token → redirect to login_path if missing
→ import(facade_url + '/module.js') → load Web Host bundle from CDN
→ initWippyApp({ auth, feature, customization }, '#app')
→ listen for 'authExpired' and 'error' events → redirect to login_path
```

If any step fails (config fetch, CDN import, missing `initWippyApp`), the page shows a styled error message with a Retry button. No external CSS is required for the loader/error UI.

## Web Host options not exposed by facade

The Web Host (`initWippyApp`) supports additional options that are intentionally not wired as facade requirements because they are advanced or context-specific:

- `hideSessionSelector` — hide session picker dropdown
- `apiRoutes` — override API endpoint paths
- `axiosDefaults` — custom HTTP client defaults
- `routePrefix` — prefix for internal route links (facade passes `api_url` as `routePrefix`)
- `chat.convertPasteToFile` — auto-convert pasted content to file uploads
- `allowAdditionalTags` — HTML sanitizer tag whitelist
- `externalEvents` — cross-origin event bridging

These can be set by directly modifying `index.html` or by creating a custom facade entry.

## Publishing

```bash
Expand Down
2 changes: 1 addition & 1 deletion src/facade/_index.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ entries:
targets:
- entry: wippy.facade:fe_facade_url
path: .default
default: https://web-host.wippy.ai/webcomponents-1.0.5
default: https://web-host.wippy.ai/webcomponents-1.0.12

- name: fe_entry_path
kind: ns.requirement
Expand Down
2 changes: 1 addition & 1 deletion src/facade/config_handler_test.lua
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ local function define_tests()
end)

test.it("extracts iframe origin from facade URL", function()
local facade_url = "https://web-host.wippy.ai/webcomponents-1.0.5"
local facade_url = "https://web-host.wippy.ai/webcomponents-1.0.12"
local origin = facade_url:match("^(https?://[^/]+)")

test.eq(origin, "https://web-host.wippy.ai")
Expand Down
151 changes: 104 additions & 47 deletions src/facade/public/index.html
Original file line number Diff line number Diff line change
@@ -1,70 +1,127 @@
<!DOCTYPE html>
<html lang="en" class="dark">
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="color-scheme" content="light dark">
<title>Wippy</title>
<style>
html, body { padding: 0; margin: 0; }
.facade-loader {
display: flex; flex-direction: column; align-items: center; justify-content: center;
height: 100vh; gap: 16px; font-family: system-ui, -apple-system, sans-serif;
}
.facade-loader__spinner {
width: 28px; height: 28px;
border: 2.5px solid rgba(128,128,128,0.15);
border-top-color: rgba(128,128,128,0.5);
border-radius: 50%;
animation: facade-spin 0.7s linear infinite;
}
.facade-loader__text { font-size: 13px; color: rgba(128,128,128,0.6); }
.facade-error {
display: flex; flex-direction: column; align-items: center; justify-content: center;
height: 100vh; gap: 12px; padding: 24px; text-align: center;
font-family: system-ui, -apple-system, sans-serif;
}
.facade-error__title { font-size: 15px; font-weight: 600; }
.facade-error__detail { font-size: 13px; opacity: 0.6; max-width: 420px; word-break: break-word; }
.facade-error__retry {
margin-top: 8px; padding: 6px 18px; font-size: 13px;
background: rgba(128,128,128,0.1); border: 1px solid rgba(128,128,128,0.2);
border-radius: 6px; cursor: pointer; color: inherit;
}
.facade-error__retry:hover { background: rgba(128,128,128,0.18); }
@keyframes facade-spin { to { transform: rotate(360deg); } }
</style>
</head>
<body>
<div id="app">Loading...</div>
<div id="app">
<div class="facade-loader">
<div class="facade-loader__spinner"></div>
<div class="facade-loader__text">Loading...</div>
</div>
</div>
<script type="module">
const STORAGE_KEY = '@wippy_token_info';
const appEl = document.getElementById('app');

const res = await fetch('/api/public/facade/config');
if (!res.ok) { document.body.textContent = 'Failed to load config'; throw new Error('config fetch failed'); }
const cfg = await res.json();
function showError(title, detail) {
appEl.innerHTML =
'<div class="facade-error">' +
'<div class="facade-error__title">' + title + '</div>' +
(detail ? '<div class="facade-error__detail">' + detail + '</div>' : '') +
'<button class="facade-error__retry" onclick="location.reload()">Retry</button>' +
'</div>';
}

const stored = localStorage.getItem(STORAGE_KEY);
if (!stored) { window.location.href = cfg.login_path || '/login.html'; throw new Error('no token'); }
try {
const res = await fetch('/api/public/facade/config');
if (!res.ok) {
showError('Failed to load configuration', 'Server returned ' + res.status + '. Check that the backend is running.');
throw new Error('config fetch failed: ' + res.status);
}
const cfg = await res.json();

let token;
try { token = JSON.parse(stored).token; }
catch { window.location.href = cfg.login_path || '/login.html'; throw new Error('bad token'); }
const stored = localStorage.getItem(STORAGE_KEY);
if (!stored) { window.location.href = cfg.login_path || '/login.html'; throw new Error('no token'); }

const apiUrl = cfg.api_url || window.location.origin;
const wsProto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = cfg.ws_url || (wsProto + '//' + window.location.host);
let token;
try { token = JSON.parse(stored).token; }
catch { window.location.href = cfg.login_path || '/login.html'; throw new Error('bad token'); }

await import(cfg.facade_url + '/module.js');
const apiUrl = cfg.api_url || window.location.origin;
const wsProto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = cfg.ws_url || (wsProto + '//' + window.location.host);

const events = window.initWippyApp({
auth: { token, expiresAt: new Date(Date.now() + 86400000).toISOString() },
feature: {
session: { type: cfg.feature.session_type },
history: cfg.feature.history_mode,
env: { APP_API_URL: apiUrl, APP_WEBSOCKET_URL: wsUrl },
routePrefix: apiUrl,
showAdmin: cfg.feature.show_admin,
allowSelectModel: cfg.feature.allow_select_model,
startNavOpen: cfg.feature.start_nav_open,
hideNavBar: cfg.feature.hide_nav_bar,
disableRightPanel: cfg.feature.disable_right_panel,
},
customization: {
customCSS: cfg.customization.custom_css || '',
cssVariables: cfg.customization.css_variables || {},
i18n: cfg.customization.i18n || {},
icons: cfg.customization.icons || {},
},
}, '#app');
let mod;
try {
mod = await import(cfg.facade_url + '/module.js');
} catch (importErr) {
showError('Failed to load frontend bundle', 'Could not load ' + cfg.facade_url + '/module.js — ' + importErr.message);
throw importErr;
}

const loginPath = cfg.login_path || '/login.html';
if (typeof window.initWippyApp !== 'function') {
showError('Frontend bundle error', 'initWippyApp not found after loading module. The bundle may be incompatible.');
throw new Error('initWippyApp not found');
}

// Handle logout and session expiration — clean up token and redirect to login
events.on('authExpired', () => {
localStorage.removeItem(STORAGE_KEY);
window.location.href = loginPath;
});
const events = window.initWippyApp({
auth: { token, expiresAt: new Date(Date.now() + 86400000).toISOString() },
feature: {
session: { type: cfg.feature.session_type },
history: cfg.feature.history_mode,
env: { APP_API_URL: apiUrl, APP_WEBSOCKET_URL: wsUrl },
routePrefix: apiUrl,
showAdmin: cfg.feature.show_admin,
allowSelectModel: cfg.feature.allow_select_model,
startNavOpen: cfg.feature.start_nav_open,
hideNavBar: cfg.feature.hide_nav_bar,
disableRightPanel: cfg.feature.disable_right_panel,
},
customization: {
customCSS: cfg.customization.custom_css || '',
cssVariables: cfg.customization.css_variables || {},
i18n: cfg.customization.i18n || {},
icons: cfg.customization.icons || {},
},
}, '#app');

// Handle critical errors
events.on('error', (err) => {
console.error('Wippy critical error:', err);
localStorage.removeItem(STORAGE_KEY);
window.location.href = loginPath;
});
const loginPath = cfg.login_path || '/login.html';

events.on('authExpired', () => {
localStorage.removeItem(STORAGE_KEY);
window.location.href = loginPath;
});

events.on('error', (err) => {
console.error('Wippy critical error:', err);
localStorage.removeItem(STORAGE_KEY);
window.location.href = loginPath;
});
} catch (err) {
console.error('Facade initialization failed:', err);
}
</script>
</body>
</html>
Loading