mirror of
https://github.com/MervinPraison/PraisonAIUI.git
synced 2026-11-13 05:01:37 -10:00
Compare commits
No files matched your search
@@ -36,7 +36,7 @@ curl http://localhost:8082/health/live
|
||||
|
||||
### Readiness Check: `/health/ready`
|
||||
|
||||
Verifies the server is ready to handle requests. Performs parallel health checks on all features with timeout protection.
|
||||
Verifies the server is ready to handle requests. Performs parallel health checks on all features with timeout protection. The provider check runs concurrently and is bounded by the same 0.5s timeout, so a slow provider cannot hang the response (or a cold `/api/health`).
|
||||
|
||||
**Use for:**
|
||||
- Kubernetes readiness probes
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Example 28 — Full Extensibility Showcase
|
||||
# Example 29 — Full Extensibility Showcase
|
||||
|
||||
Demonstrates **every** extension point in PraisonAIUI.
|
||||
|
||||
@@ -45,6 +45,9 @@ The `timeline` component and the `custom-view` page are client-side extensions.
|
||||
> **Note:** `aiui.set_custom_js(path)` injects client-side JS from a file path at page load.
|
||||
> See `docs/features/how-to-add-a-feature.md` for the extension API contract.
|
||||
|
||||
> **Tip:** if a custom view renders its own heading, call
|
||||
> `aiui.set_dashboard(page_header=False)` to hide the default page title/description header.
|
||||
|
||||
## Testing the form action
|
||||
|
||||
1. Navigate to **Contact Form**
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Example 28 — Full Extensibility Showcase.
|
||||
"""Example 29 — Full Extensibility Showcase.
|
||||
|
||||
Demonstrates EVERY extension point in PraisonAIUI:
|
||||
|
||||
@@ -203,10 +203,9 @@ async def custom_view_fallback():
|
||||
aiui.callout(
|
||||
title="Plugin not loaded",
|
||||
content=(
|
||||
"This page is meant to be rendered client-side. "
|
||||
"Open DevTools → Console and paste the contents of "
|
||||
"plugin.js (in this example folder) to "
|
||||
"activate the custom view."
|
||||
"This page is meant to be rendered client-side by "
|
||||
"plugin.js, which app.py loads via aiui.set_custom_js(). "
|
||||
"If you see this, check that /custom.js loaded."
|
||||
),
|
||||
variant="warning",
|
||||
),
|
||||
|
||||
@@ -5,9 +5,11 @@
|
||||
* 1. window.aiui.registerComponent(type, renderFn) — adds a new component type
|
||||
* 2. window.aiui.registerView(pageId, render, cleanup) — adds a custom page view
|
||||
*
|
||||
* To load this file, paste its contents into the browser DevTools Console
|
||||
* after the page loads. (A Python-side API to inject custom JS is a planned
|
||||
* enhancement — see 28-full-extensibility/README.md.)
|
||||
* Loaded automatically by app.py, which serves it at /custom.js:
|
||||
*
|
||||
* aiui.set_custom_js(Path(__file__).with_name("plugin.js"))
|
||||
*
|
||||
* See 29-full-extensibility/README.md.
|
||||
*/
|
||||
|
||||
(function () {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "aiui"
|
||||
version = "0.3.152"
|
||||
version = "0.3.154"
|
||||
description = "YAML-driven website generator - CLI and compiler"
|
||||
readme = "README.md"
|
||||
license = {text = "MIT"}
|
||||
|
||||
@@ -6,7 +6,7 @@ import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
globalIgnores(['dist', 'src/components/ui', 'src/spike', 'dev']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
|
||||
Generated
+1883
-692
File diff suppressed because it is too large.
Load diff
@@ -27,10 +27,16 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"postbuild": "node scripts/gen-externals.mjs",
|
||||
"build:lib": "tsc -b && vite build --mode lib",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
"test": "node --experimental-strip-types --test \"src/agent-ui/**/*.test.ts\""
|
||||
"test": "node scripts/run-tests.mjs",
|
||||
"test:e2e": "playwright test -c e2e/playwright.config.ts",
|
||||
"size": "size-limit",
|
||||
"sync": "node scripts/sync-templates.mjs",
|
||||
"check:bundle": "node scripts/check-bundle-sync.mjs",
|
||||
"check:closure": "node scripts/check-entry-closure.mjs"
|
||||
},
|
||||
"keywords": [
|
||||
"react",
|
||||
@@ -63,6 +69,7 @@
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@assistant-ui/react": "0.15.36",
|
||||
"@radix-ui/react-accordion": "^1.2.12",
|
||||
"@radix-ui/react-avatar": "^1.1.11",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
@@ -82,6 +89,7 @@
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@streamdown/math": "1.0.3",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
@@ -89,31 +97,41 @@
|
||||
"cmdk": "^1.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"katex": "0.16.47",
|
||||
"lucide-react": "^0.563.0",
|
||||
"mermaid": "^11.17.2",
|
||||
"next-themes": "0.4.6",
|
||||
"radix-ui": "1.8.1",
|
||||
"react-day-picker": "^9.13.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-resizable-panels": "^4.5.4",
|
||||
"react-syntax-highlighter": "^16.1.1",
|
||||
"recharts": "2.15.4",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"shiki": "4.6.0",
|
||||
"sonner": "2.0.8",
|
||||
"streamdown": "2.7.1",
|
||||
"tailwind-merge": "^3.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@a2ui/react": "^0.10.0",
|
||||
"@a2ui/web_core": "^0.10.0",
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@playwright/test": "1.65.0",
|
||||
"@size-limit/file": "14.3.0",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/react": "^19.2.5",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"esbuild": "0.27.3",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"globals": "^16.5.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"size-limit": "14.3.0",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "~5.9.3",
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Post-build step (`npm run build` runs it as `postbuild`).
|
||||
*
|
||||
* Writes dist/aiui-externals.json: the named exports of every bare specifier a
|
||||
* plugin may import (ADR-0001 §8). The server builds its
|
||||
* /_aiui/externals/*.js ESM shims from this file, so the shims always match
|
||||
* the React version actually bundled instead of a hand-written list.
|
||||
*
|
||||
* {
|
||||
* "schema": 1,
|
||||
* "sdkVersion": 1,
|
||||
* "react": "19.2.4",
|
||||
* "modules": {
|
||||
* "react": ["Children", ...],
|
||||
* "react/jsx-runtime": [...],
|
||||
* "react-dom": [...],
|
||||
* "react-dom/client": [...],
|
||||
* "@aiui/sdk": [...] // SDK_EXPORTS from src/sdk/manifest.ts
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* Export lists are the union of React's production and development builds,
|
||||
* restricted to valid JS identifiers, sorted, without `default`.
|
||||
*
|
||||
* Usage: node scripts/gen-externals.mjs [--out <file>]
|
||||
*/
|
||||
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import * as esbuild from 'esbuild'
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const require = createRequire(path.join(ROOT, 'package.json'))
|
||||
const IDENT = /^[A-Za-z_$][\w$]*$/
|
||||
|
||||
function argValue(flag) {
|
||||
const i = process.argv.indexOf(flag)
|
||||
return i >= 0 ? process.argv[i + 1] : undefined
|
||||
}
|
||||
|
||||
const outFile = path.resolve(ROOT, argValue('--out') ?? 'dist/aiui-externals.json')
|
||||
|
||||
/** Load src/sdk/manifest.ts on its own (it has no runtime imports). */
|
||||
async function loadManifestModule() {
|
||||
const file = path.join(ROOT, 'src', 'sdk', 'manifest.ts')
|
||||
const { code } = await esbuild.transform(readFileSync(file, 'utf8'), {
|
||||
loader: 'ts',
|
||||
format: 'esm',
|
||||
target: 'node22',
|
||||
sourcefile: file,
|
||||
})
|
||||
if (/^\s*import\s/m.test(code)) {
|
||||
throw new Error('src/sdk/manifest.ts must not have runtime imports (gen-externals loads it standalone)')
|
||||
}
|
||||
return import(`data:text/javascript;base64,${Buffer.from(code).toString('base64')}`)
|
||||
}
|
||||
|
||||
/** Object.keys of a CommonJS package entry, loaded under a given NODE_ENV. */
|
||||
function exportsUnder(specifier, nodeEnv) {
|
||||
const prev = process.env.NODE_ENV
|
||||
process.env.NODE_ENV = nodeEnv
|
||||
try {
|
||||
const resolved = require.resolve(specifier)
|
||||
// The package entry picks ./cjs/*.{production,development}.js at require
|
||||
// time; drop it from the cache so the other build is loaded next time.
|
||||
delete require.cache[resolved]
|
||||
return Object.keys(require(specifier))
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env.NODE_ENV
|
||||
else process.env.NODE_ENV = prev
|
||||
}
|
||||
}
|
||||
|
||||
function exportNames(specifier) {
|
||||
const names = new Set([...exportsUnder(specifier, 'production'), ...exportsUnder(specifier, 'development')])
|
||||
names.delete('default')
|
||||
names.delete('__esModule')
|
||||
return [...names].filter((n) => IDENT.test(n)).sort()
|
||||
}
|
||||
|
||||
const manifest = await loadManifestModule()
|
||||
const { SHELL_EXTERNALS, SDK_EXPORTS, SDK_VERSION } = manifest
|
||||
|
||||
if (!Array.isArray(SHELL_EXTERNALS) || !Array.isArray(SDK_EXPORTS)) {
|
||||
throw new Error('src/sdk/manifest.ts must export SHELL_EXTERNALS and SDK_EXPORTS arrays')
|
||||
}
|
||||
const badSdk = SDK_EXPORTS.filter((n) => typeof n !== 'string' || !IDENT.test(n))
|
||||
if (badSdk.length) throw new Error(`SDK_EXPORTS has invalid identifiers: ${badSdk.join(', ')}`)
|
||||
if (new Set(SDK_EXPORTS).size !== SDK_EXPORTS.length) throw new Error('SDK_EXPORTS has duplicates')
|
||||
|
||||
const modules = {}
|
||||
for (const spec of SHELL_EXTERNALS) {
|
||||
if (spec === '@aiui/sdk') {
|
||||
modules[spec] = [...SDK_EXPORTS]
|
||||
continue
|
||||
}
|
||||
const names = exportNames(spec)
|
||||
if (names.length === 0) throw new Error(`no exports found for ${spec}`)
|
||||
modules[spec] = names
|
||||
}
|
||||
|
||||
for (const required of ['react', 'react/jsx-runtime', 'react-dom', 'react-dom/client', '@aiui/sdk']) {
|
||||
if (!modules[required]) throw new Error(`SHELL_EXTERNALS is missing ${required}`)
|
||||
}
|
||||
|
||||
const out = {
|
||||
schema: 1,
|
||||
sdkVersion: SDK_VERSION,
|
||||
react: require('react/package.json').version,
|
||||
modules,
|
||||
}
|
||||
|
||||
mkdirSync(path.dirname(outFile), { recursive: true })
|
||||
writeFileSync(outFile, `${JSON.stringify(out, null, 2)}\n`)
|
||||
const counts = Object.entries(modules)
|
||||
.map(([k, v]) => `${k}=${v.length}`)
|
||||
.join(' ')
|
||||
console.log(`gen-externals: wrote ${path.relative(ROOT, outFile)} (React ${out.react}; ${counts})`)
|
||||
@@ -0,0 +1,117 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Frontend unit test runner: `npm test [-- <path-substring> ...]`.
|
||||
*
|
||||
* Finds every src/**\/*.test.{ts,tsx}, bundles each one with esbuild into
|
||||
* node_modules/.cache/aiui-tests/ and runs the bundles with `node --test`.
|
||||
* Bundling is required: Node's type stripping cannot resolve extensionless
|
||||
* imports (`./protocol`), the `@/` alias, TSX or CSS imports.
|
||||
*
|
||||
* - npm packages stay external (Node loads them from node_modules, so React
|
||||
* has one copy and react-dom/server works).
|
||||
* - CSS imports (relative or from packages) become empty modules.
|
||||
* - `import.meta.env` is defined the way Vite would in a test build.
|
||||
* - Positional arguments filter test files by path substring.
|
||||
*
|
||||
* Exits 0 when no test file matches.
|
||||
*/
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { existsSync, readdirSync, rmSync, statSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import * as esbuild from 'esbuild'
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const SRC = path.join(ROOT, 'src')
|
||||
const OUT = path.join(ROOT, 'node_modules', '.cache', 'aiui-tests')
|
||||
const TEST_FILE = /\.test\.tsx?$/
|
||||
const SKIP_DIRS = new Set(['node_modules', 'dist'])
|
||||
|
||||
function findTests(dir) {
|
||||
const found = []
|
||||
for (const name of readdirSync(dir)) {
|
||||
if (SKIP_DIRS.has(name)) continue
|
||||
const full = path.join(dir, name)
|
||||
const st = statSync(full)
|
||||
if (st.isDirectory()) found.push(...findTests(full))
|
||||
else if (TEST_FILE.test(name)) found.push(full)
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
/** Any `*.css` import (including bare package paths) becomes an empty module. */
|
||||
const ignoreCss = {
|
||||
name: 'aiui-ignore-css',
|
||||
setup(build) {
|
||||
build.onResolve({ filter: /\.css(\?.*)?$/ }, (args) => ({ path: args.path, namespace: 'aiui-empty-css' }))
|
||||
build.onLoad({ filter: /.*/, namespace: 'aiui-empty-css' }, () => ({ contents: '', loader: 'js' }))
|
||||
},
|
||||
}
|
||||
|
||||
const filters = process.argv.slice(2).filter((a) => !a.startsWith('-'))
|
||||
const tests = findTests(SRC)
|
||||
.filter((f) => filters.length === 0 || filters.some((s) => path.relative(ROOT, f).includes(s)))
|
||||
.sort()
|
||||
|
||||
if (tests.length === 0) {
|
||||
console.log(filters.length ? `No test files match: ${filters.join(', ')}` : 'No test files found under src/.')
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
rmSync(OUT, { recursive: true, force: true })
|
||||
|
||||
try {
|
||||
await esbuild.build({
|
||||
entryPoints: tests,
|
||||
outdir: OUT,
|
||||
outbase: SRC,
|
||||
outExtension: { '.js': '.mjs' },
|
||||
bundle: true,
|
||||
splitting: false,
|
||||
platform: 'node',
|
||||
format: 'esm',
|
||||
target: 'node22',
|
||||
packages: 'external',
|
||||
tsconfig: path.join(ROOT, 'tsconfig.app.json'),
|
||||
alias: { '@': SRC },
|
||||
loader: {
|
||||
'.css': 'empty',
|
||||
'.svg': 'dataurl',
|
||||
'.png': 'dataurl',
|
||||
'.jpg': 'dataurl',
|
||||
'.gif': 'dataurl',
|
||||
'.webp': 'dataurl',
|
||||
},
|
||||
plugins: [ignoreCss],
|
||||
define: {
|
||||
'import.meta.env': JSON.stringify({ MODE: 'test', DEV: false, PROD: true, SSR: true, BASE_URL: '/' }),
|
||||
},
|
||||
jsx: 'automatic',
|
||||
sourcemap: 'inline',
|
||||
logLevel: 'warning',
|
||||
})
|
||||
} catch {
|
||||
// esbuild already printed the diagnostics.
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const bundles = tests.map((t) => {
|
||||
const rel = path.relative(SRC, t).replace(/\.tsx?$/, '.mjs')
|
||||
const out = path.join(OUT, rel)
|
||||
if (!existsSync(out)) {
|
||||
console.error(`run-tests: esbuild did not emit ${out}`)
|
||||
process.exit(1)
|
||||
}
|
||||
return out
|
||||
})
|
||||
|
||||
console.log(`run-tests: ${tests.length} test file(s): ${tests.map((t) => path.relative(ROOT, t)).join(', ')}`)
|
||||
const res = spawnSync(process.execPath, ['--enable-source-maps', '--test', ...bundles], {
|
||||
cwd: ROOT,
|
||||
stdio: 'inherit',
|
||||
})
|
||||
if (res.error) {
|
||||
console.error(res.error)
|
||||
process.exit(1)
|
||||
}
|
||||
process.exit(res.status ?? 1)
|
||||
@@ -0,0 +1,80 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Copy the Vite build (dist/) into the Python package's frontend templates.
|
||||
*
|
||||
* node scripts/sync-templates.mjs # -> src/praisonaiui/templates/frontend (tracked tree)
|
||||
* node scripts/sync-templates.mjs --dest <dir> # -> any directory, e.g. a temp dir
|
||||
*
|
||||
* Serve a --dest copy without touching the tracked tree:
|
||||
* AIUI_TEMPLATES_DIR=<dir> aiui run ...
|
||||
*
|
||||
* Steps:
|
||||
* 1. delete <dest>/assets (removes stale hashed chunks from earlier builds)
|
||||
* 2. copy dist/assets/**, dist/index.html, dist/icon.svg,
|
||||
* dist/aiui-manifest.json and dist/aiui-externals.json into <dest>
|
||||
* Anything else in <dest> (plugins/, dashboard-plugins/, chat.css, ...) is
|
||||
* left untouched.
|
||||
*/
|
||||
import { copyFileSync, cpSync, existsSync, mkdirSync, rmSync, statSync } from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const DIST = path.join(ROOT, 'dist')
|
||||
const DEFAULT_DEST = path.resolve(ROOT, '..', 'praisonaiui', 'templates', 'frontend')
|
||||
const ROOT_FILES = ['index.html', 'icon.svg', 'aiui-manifest.json', 'aiui-externals.json']
|
||||
|
||||
function fail(msg) {
|
||||
console.error(`sync-templates: ${msg}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
function parseDest(argv) {
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const a = argv[i]
|
||||
if (a === '--dest') {
|
||||
if (!argv[i + 1] || argv[i + 1].startsWith('--')) fail('--dest needs a directory')
|
||||
return path.resolve(argv[i + 1])
|
||||
}
|
||||
if (a.startsWith('--dest=')) return path.resolve(a.slice('--dest='.length))
|
||||
if (a === '-h' || a === '--help') {
|
||||
console.log('usage: node scripts/sync-templates.mjs [--dest <dir>]')
|
||||
process.exit(0)
|
||||
}
|
||||
fail(`unknown argument: ${a}`)
|
||||
}
|
||||
return DEFAULT_DEST
|
||||
}
|
||||
|
||||
const dest = parseDest(process.argv.slice(2))
|
||||
|
||||
// Guard against wiping something important with a typo'd --dest.
|
||||
const forbidden = new Set([path.parse(dest).root, os.homedir(), ROOT, path.resolve(ROOT, '..'), DIST])
|
||||
if (forbidden.has(dest)) fail(`refusing to sync into ${dest}`)
|
||||
|
||||
for (const required of ['index.html', 'assets/index.js', 'aiui-manifest.json']) {
|
||||
if (!existsSync(path.join(DIST, required))) fail(`dist/${required} is missing; run \`npm run build\` first`)
|
||||
}
|
||||
if (!existsSync(path.join(DIST, 'aiui-externals.json'))) {
|
||||
fail('dist/aiui-externals.json is missing; run `npm run build` (postbuild runs scripts/gen-externals.mjs)')
|
||||
}
|
||||
if (existsSync(dest) && !statSync(dest).isDirectory()) fail(`${dest} is not a directory`)
|
||||
|
||||
mkdirSync(dest, { recursive: true })
|
||||
const destAssets = path.join(dest, 'assets')
|
||||
rmSync(destAssets, { recursive: true, force: true })
|
||||
cpSync(path.join(DIST, 'assets'), destAssets, { recursive: true })
|
||||
|
||||
const copied = []
|
||||
for (const f of ROOT_FILES) {
|
||||
const src = path.join(DIST, f)
|
||||
if (!existsSync(src)) {
|
||||
console.warn(`sync-templates: dist/${f} not found, skipped`)
|
||||
continue
|
||||
}
|
||||
copyFileSync(src, path.join(dest, f))
|
||||
copied.push(f)
|
||||
}
|
||||
|
||||
console.log(`sync-templates: dist/assets + ${copied.join(', ')} -> ${dest}`)
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* CONTRACT STUB (WP0a). WP7 replaces this file with the `kind=blocks` page
|
||||
* renderer. Contract (ADR-0001 §5):
|
||||
* export default function BlockPage(p: { page: ShellPage }): JSX.Element
|
||||
*/
|
||||
import type { JSX } from 'react'
|
||||
import type { ShellPage } from '../sdk/manifest'
|
||||
|
||||
export default function BlockPage({ page }: { page: ShellPage }): JSX.Element {
|
||||
return (
|
||||
<div data-aiui-stub="block-page" data-page-id={page.id}>
|
||||
{page.title}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* CONTRACT STUB (WP0a). WP2 replaces this file with the assistant-ui chat page.
|
||||
* Contract (ADR-0001 §5):
|
||||
* export default function ChatPage(p: { page: ShellPage }): JSX.Element
|
||||
*/
|
||||
import type { JSX } from 'react'
|
||||
import type { ShellPage } from '../sdk/manifest'
|
||||
|
||||
export default function ChatPage({ page }: { page: ShellPage }): JSX.Element {
|
||||
return (
|
||||
<div data-aiui-stub="chat-page" data-page-id={page.id}>
|
||||
{page.title}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import App from './App.tsx'
|
||||
|
||||
/**
|
||||
* The pre-shell React UI (styles chat, docs, agents, playground, custom).
|
||||
* main.tsx lazy-loads this chunk unless <html data-aiui-shell="react"> is set.
|
||||
* Rendering is exactly what main.tsx did before the boot split.
|
||||
*/
|
||||
export function mountApp(el: HTMLElement) {
|
||||
createRoot(el).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* CONTRACT STUB (WP0a). WP4 replaces this file with the host that mounts a
|
||||
* legacy dashboard view through legacy/runtime.ts mountView().
|
||||
* Contract (ADR-0001 §5):
|
||||
* export function LegacyViewHost(p: { pageId: string; page?: ShellPage }): JSX.Element
|
||||
*/
|
||||
import type { JSX } from 'react'
|
||||
import type { ShellPage } from '../sdk/manifest'
|
||||
|
||||
export interface LegacyViewHostProps {
|
||||
pageId: string
|
||||
page?: ShellPage
|
||||
}
|
||||
|
||||
export function LegacyViewHost({ pageId, page }: LegacyViewHostProps): JSX.Element {
|
||||
return (
|
||||
<div className="aiui-legacy-host" data-legacy-page={pageId} data-status="stub" data-aiui-stub="legacy-view-host">
|
||||
{page?.title ?? pageId}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* CONTRACT STUB (WP0a). WP4 replaces this file with the loader that imports
|
||||
* /plugins/dashboard.js once and calls its initHostRuntime().
|
||||
* Contract (ADR-0001 §5):
|
||||
* ensureLegacyRuntime(): Promise<{ pages: unknown[]; mountView(id, el, {page, signal}): Promise<() => void> }>
|
||||
* preloadLegacyRuntime(): void
|
||||
* setLegacyNavigator(fn: (id: string) => void): void
|
||||
*/
|
||||
import type { ShellPage } from '../sdk/manifest'
|
||||
|
||||
export interface LegacyMountOptions {
|
||||
page?: ShellPage
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
export interface LegacyRuntime {
|
||||
pages: unknown[]
|
||||
mountView(pageId: string, el: HTMLElement, opts: LegacyMountOptions): Promise<() => void>
|
||||
}
|
||||
|
||||
export function ensureLegacyRuntime(): Promise<LegacyRuntime> {
|
||||
return Promise.reject(new Error('legacy runtime is not available in this build (WP4 stub)'))
|
||||
}
|
||||
|
||||
/** Warm the runtime import ahead of navigation. No-op in the stub. */
|
||||
export function preloadLegacyRuntime(): void {}
|
||||
|
||||
/** The shell installs its router here; legacy selectPage() calls go through it. */
|
||||
export function setLegacyNavigator(fn: (pageId: string) => void): void {
|
||||
void fn
|
||||
}
|
||||
@@ -1,10 +1,51 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
// Boot entry (assets/index.js). It stays tiny: it only decides which UI to
|
||||
// load, and both choices are lazy chunks.
|
||||
// <html data-aiui-shell="react"> (style: app) -> shell/mount
|
||||
// anything else (chat, docs, agents, playground, custom) -> legacy-app-entry
|
||||
// The vanilla `dashboard` style never loads this script at all.
|
||||
|
||||
// A deploy replaces the hashed chunks. A tab still running the previous
|
||||
// index.js then fails to fetch a lazy chunk; reload once to pick up the new
|
||||
// build. The guard is keyed by this entry's URL (it carries ?v=<build>), so a
|
||||
// chunk that is missing in the *current* build cannot cause a reload loop.
|
||||
const RELOAD_KEY = 'aiuiReloadedFor'
|
||||
window.addEventListener('vite:preloadError', (event) => {
|
||||
const build = import.meta.url
|
||||
try {
|
||||
if (sessionStorage.getItem(RELOAD_KEY) === build) return
|
||||
sessionStorage.setItem(RELOAD_KEY, build)
|
||||
} catch {
|
||||
return // no sessionStorage: never risk an unguarded reload loop
|
||||
}
|
||||
event.preventDefault()
|
||||
window.location.reload()
|
||||
})
|
||||
|
||||
const rootEl = document.getElementById('root')
|
||||
|
||||
function bootFailed(err: unknown) {
|
||||
console.error('[aiui] failed to load the UI bundle', err)
|
||||
if (rootEl && !rootEl.hasChildNodes()) {
|
||||
const msg = document.createElement('div')
|
||||
msg.setAttribute('role', 'alert')
|
||||
msg.style.cssText = 'padding:24px;font:14px system-ui,sans-serif'
|
||||
msg.textContent = 'The interface failed to load. Reload the page to try again.'
|
||||
rootEl.appendChild(msg)
|
||||
}
|
||||
}
|
||||
|
||||
// When the preloadError handler above has scheduled a reload, Vite resolves
|
||||
// the import with `undefined` instead of rejecting, hence the `m &&` guards.
|
||||
if (rootEl) {
|
||||
if (document.documentElement.dataset.aiuiShell === 'react') {
|
||||
import('./shell/mount')
|
||||
.then((m) => m && m.mountShell(rootEl))
|
||||
.catch(bootFailed)
|
||||
} else {
|
||||
import('./legacy-app-entry')
|
||||
.then((m) => m && m.mountApp(rootEl))
|
||||
.catch(bootFailed)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* CONTRACT STUB (WP0a). WP1 replaces this file with the lazy Streamdown
|
||||
* renderer. Contract (ADR-0001 §5):
|
||||
* export function Markdown(p: { children: string; variant?: 'chat'|'docs'; streaming?: boolean;
|
||||
* className?: string; components?: Record<string, ComponentType<any>> })
|
||||
* export const preloadMarkdown
|
||||
*/
|
||||
import type { ComponentType } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export interface MarkdownProps {
|
||||
children: string
|
||||
variant?: 'chat' | 'docs'
|
||||
streaming?: boolean
|
||||
className?: string
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
components?: Record<string, ComponentType<any>>
|
||||
}
|
||||
|
||||
/** Plain-text rendering until WP1 lands. */
|
||||
export function Markdown({ children, variant = 'chat', className }: MarkdownProps) {
|
||||
return (
|
||||
<div className={cn('aiui-markdown whitespace-pre-wrap', className)} data-variant={variant}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Start fetching the renderer chunk before it is needed. */
|
||||
// eslint-disable-next-line react-refresh/only-export-components -- contract export (ADR §5)
|
||||
export const preloadMarkdown = (): Promise<void> => Promise.resolve()
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* CONTRACT STUB (WP0a). WP7 replaces this file: installSdk() sets
|
||||
* window.__AIUI_SDK__, loadManifestPlugins() imports plugin modules.
|
||||
* Contract (ADR-0001 §5):
|
||||
* export function installSdk(): void
|
||||
* export function loadManifestPlugins(m: ShellManifest): Promise<void>
|
||||
*/
|
||||
import type { ShellManifest } from './manifest'
|
||||
|
||||
export function installSdk(): void {}
|
||||
|
||||
export async function loadManifestPlugins(manifest: ShellManifest): Promise<void> {
|
||||
void manifest
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* Shell manifest contract (ADR-0001 §5). Mirrors the server's
|
||||
* `_build_shell_manifest()` served inline as `<script id="aiui-boot">` and at
|
||||
* `GET /api/shell/manifest`.
|
||||
*
|
||||
* FROZEN CONTRACT: change only additively, and keep the server in sync.
|
||||
*
|
||||
* This module must stay free of runtime imports: scripts/gen-externals.mjs
|
||||
* transpiles it on its own to read SDK_EXPORTS at build time.
|
||||
*/
|
||||
|
||||
/** Major version of the browser plugin SDK (`window.__AIUI_SDK__.version`). */
|
||||
export const SDK_VERSION = 1
|
||||
|
||||
/**
|
||||
* Bare specifiers a plugin ESM bundle may import. The server's import map
|
||||
* points each one at an `/_aiui/externals/*.js` shim that re-exports the
|
||||
* shell's own instance from `window.__AIUI_SDK__.modules[specifier]`.
|
||||
*/
|
||||
export const SHELL_EXTERNALS = [
|
||||
'react',
|
||||
'react/jsx-runtime',
|
||||
'react-dom',
|
||||
'react-dom/client',
|
||||
'@aiui/sdk',
|
||||
] as const
|
||||
export type ShellExternal = (typeof SHELL_EXTERNALS)[number]
|
||||
|
||||
/**
|
||||
* The frozen named-export list of the `@aiui/sdk` module. The object installed
|
||||
* at `window.__AIUI_SDK__.modules['@aiui/sdk']` (WP7, sdk/install.ts) must have
|
||||
* exactly these keys, and the server's ESM shim re-exports exactly these names
|
||||
* (read from dist/aiui-externals.json, generated from this list).
|
||||
*/
|
||||
export const SDK_EXPORTS = [
|
||||
'SDK_VERSION',
|
||||
'registerPage',
|
||||
'registerBlock',
|
||||
'registerSlot',
|
||||
'registerToolRenderer',
|
||||
'registerView',
|
||||
'getPage',
|
||||
'getBlock',
|
||||
'getSlot',
|
||||
'getToolRenderers',
|
||||
'subscribe',
|
||||
'useRegistryVersion',
|
||||
'Markdown',
|
||||
] as const
|
||||
export type SdkExportName = (typeof SDK_EXPORTS)[number]
|
||||
|
||||
export type ShellPageKind = 'native' | 'react' | 'blocks' | 'legacy' | 'iframe'
|
||||
|
||||
export interface ShellPage {
|
||||
id: string
|
||||
title: string
|
||||
kind: ShellPageKind
|
||||
icon?: string
|
||||
group?: string
|
||||
order?: number
|
||||
description?: string
|
||||
apiEndpoint?: string | null
|
||||
actionEndpoint?: string
|
||||
module?: string
|
||||
css?: string
|
||||
integrity?: string
|
||||
layout?: string
|
||||
source?: string
|
||||
}
|
||||
|
||||
export type ShellPluginStatus = 'ok' | 'error' | 'disabled' | 'incompatible' | 'disabled-by-default'
|
||||
|
||||
export interface ShellManifest {
|
||||
sdk: number
|
||||
version: string
|
||||
style: string
|
||||
site: Record<string, unknown>
|
||||
layout: { sidebar?: unknown; pageHeader?: unknown }
|
||||
pages: ShellPage[]
|
||||
blocks: { type: string; module: string; integrity?: string; source: string }[]
|
||||
toolRenderers: { toolName: string; module: string; integrity?: string; source: string }[]
|
||||
plugins: { name: string; version?: string; status: ShellPluginStatus; error?: string }[]
|
||||
legacy: { customJs: string | null; viewsBase: string }
|
||||
}
|
||||
|
||||
export const BOOT_ELEMENT_ID = 'aiui-boot'
|
||||
export const MANIFEST_URL = '/api/shell/manifest'
|
||||
|
||||
function isRecord(v: unknown): v is Record<string, unknown> {
|
||||
return typeof v === 'object' && v !== null && !Array.isArray(v)
|
||||
}
|
||||
|
||||
function arrayOf<T>(v: unknown): T[] {
|
||||
return Array.isArray(v) ? (v as T[]) : []
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill every optional collection with a safe default so consumers can iterate
|
||||
* without guards. Returns null when `raw` is not a full manifest (no `pages`
|
||||
* array), e.g. the minimal `{sdk, version, style}` copy the server inlines
|
||||
* when auth is enforced.
|
||||
*/
|
||||
export function normalizeManifest(raw: unknown): ShellManifest | null {
|
||||
if (!isRecord(raw) || !Array.isArray(raw.pages)) return null
|
||||
const legacy = isRecord(raw.legacy) ? raw.legacy : {}
|
||||
return {
|
||||
sdk: typeof raw.sdk === 'number' ? raw.sdk : SDK_VERSION,
|
||||
version: typeof raw.version === 'string' ? raw.version : '',
|
||||
style: typeof raw.style === 'string' ? raw.style : 'app',
|
||||
site: isRecord(raw.site) ? raw.site : {},
|
||||
layout: isRecord(raw.layout) ? raw.layout : {},
|
||||
pages: arrayOf<ShellPage>(raw.pages).filter((p) => isRecord(p) && typeof p.id === 'string'),
|
||||
blocks: arrayOf(raw.blocks),
|
||||
toolRenderers: arrayOf(raw.toolRenderers),
|
||||
plugins: arrayOf(raw.plugins),
|
||||
legacy: {
|
||||
customJs: typeof legacy.customJs === 'string' ? legacy.customJs : null,
|
||||
viewsBase: typeof legacy.viewsBase === 'string' ? legacy.viewsBase : '/plugins/views/',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the manifest the server inlined as `<script id="aiui-boot"
|
||||
* type="application/json">`. Returns null when it is missing, unparseable or
|
||||
* partial (auth-enforced servers inline only `{sdk, version, style}`); the
|
||||
* shell then calls fetchManifest().
|
||||
*/
|
||||
export function readBootManifest(): ShellManifest | null {
|
||||
if (typeof document === 'undefined') return null
|
||||
const el = document.getElementById(BOOT_ELEMENT_ID)
|
||||
if (!el?.textContent) return null
|
||||
try {
|
||||
return normalizeManifest(JSON.parse(el.textContent))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/shell/manifest. `fetchImpl` lets the shell pass its auth-aware
|
||||
* fetch. Rejects on a non-2xx response or a body that is not a manifest.
|
||||
*/
|
||||
export async function fetchManifest(fetchImpl: typeof fetch = fetch): Promise<ShellManifest> {
|
||||
const res = await fetchImpl(MANIFEST_URL, {
|
||||
credentials: 'same-origin',
|
||||
headers: { Accept: 'application/json' },
|
||||
})
|
||||
if (!res.ok) throw new Error(`GET ${MANIFEST_URL} failed: ${res.status}`)
|
||||
const m = normalizeManifest(await res.json())
|
||||
if (!m) throw new Error(`GET ${MANIFEST_URL} returned an invalid manifest`)
|
||||
return m
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
/** Cache-bust string of the running build (style `app`; set inline by the server). */
|
||||
__AIUI_BUILD__?: string
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
// Contract tests for sdk/registry.ts and sdk/manifest.ts (WP0a).
|
||||
// Uses the `@/` alias and extensionless imports on purpose: scripts/run-tests.mjs
|
||||
// bundles every test with esbuild, which Node's type stripping alone cannot do.
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { createElement, Suspense } from 'react'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import {
|
||||
getBlock,
|
||||
getPage,
|
||||
getSlot,
|
||||
getToolRenderers,
|
||||
registerBlock,
|
||||
registerPage,
|
||||
registerSlot,
|
||||
registerToolRenderer,
|
||||
subscribe,
|
||||
type BlockComponent,
|
||||
type PageComponent,
|
||||
} from '@/sdk/registry'
|
||||
import { normalizeManifest, SDK_EXPORTS, SHELL_EXTERNALS, type ShellPage } from '@/sdk/manifest'
|
||||
|
||||
const page: ShellPage = { id: 'p', title: 'P', kind: 'react' }
|
||||
|
||||
test('registerPage stores a function component as-is and notifies subscribers', () => {
|
||||
let calls = 0
|
||||
const off = subscribe(() => {
|
||||
calls += 1
|
||||
})
|
||||
function Hello({ page }: { page: ShellPage }) {
|
||||
return createElement('span', null, `hello ${page.title}`)
|
||||
}
|
||||
registerPage('hello', Hello)
|
||||
assert.equal(getPage('hello'), Hello)
|
||||
assert.equal(calls, 1)
|
||||
registerPage('hello', Hello) // same component: no change, no notification
|
||||
assert.equal(calls, 1)
|
||||
off()
|
||||
registerPage('hello2', Hello)
|
||||
assert.equal(calls, 1, 'unsubscribed listener must not fire')
|
||||
assert.equal(renderToStaticMarkup(createElement(Hello, { page })), '<span>hello P</span>')
|
||||
})
|
||||
|
||||
test('registerPage treats a zero-arg arrow returning import() as a lazy loader', async () => {
|
||||
function Lazy({ page }: { page: ShellPage }) {
|
||||
return createElement('b', null, page.id)
|
||||
}
|
||||
registerPage('lazy', () => Promise.resolve({ default: Lazy as PageComponent }))
|
||||
const C = getPage('lazy')
|
||||
assert.ok(C)
|
||||
assert.notEqual(C, Lazy)
|
||||
// React.lazy object; resolve it the way React would.
|
||||
const payload = (C as unknown as { _payload: unknown; _init: (p: unknown) => unknown })
|
||||
try {
|
||||
payload._init(payload._payload)
|
||||
} catch (thenable) {
|
||||
await thenable
|
||||
}
|
||||
const resolved = payload._init(payload._payload)
|
||||
assert.equal(resolved, Lazy)
|
||||
const html = renderToStaticMarkup(createElement(Suspense, { fallback: 'x' }, createElement(C!, { page })))
|
||||
assert.equal(html, '<b>p</b>')
|
||||
})
|
||||
|
||||
test('blocks, tool renderers and slots round-trip; tool renderer snapshot is stable', () => {
|
||||
const Block = () => null
|
||||
registerBlock('metric', Block)
|
||||
assert.equal(getBlock('metric'), Block)
|
||||
assert.equal(getBlock('missing'), undefined)
|
||||
|
||||
const Weather = () => null
|
||||
registerToolRenderer('get_weather', Weather)
|
||||
const a = getToolRenderers()
|
||||
assert.equal(a.get_weather, Weather)
|
||||
assert.equal(getToolRenderers(), a, 'snapshot identity is stable without changes')
|
||||
registerSlot('header.right', Weather)
|
||||
assert.notEqual(getToolRenderers(), a, 'snapshot is rebuilt after any registry change')
|
||||
assert.equal(getSlot('header.right'), Weather)
|
||||
})
|
||||
|
||||
test('registration rejects bad ids and non-components', () => {
|
||||
assert.throws(() => registerPage('', (() => null) as unknown as PageComponent), TypeError)
|
||||
assert.throws(() => registerBlock('x', 42 as unknown as BlockComponent), TypeError)
|
||||
})
|
||||
|
||||
test('normalizeManifest fills defaults and rejects partial boot copies', () => {
|
||||
assert.equal(normalizeManifest({ sdk: 1, version: '1', style: 'app' }), null)
|
||||
assert.equal(normalizeManifest(null), null)
|
||||
const m = normalizeManifest({ pages: [{ id: 'chat', title: 'Chat', kind: 'react' }, { nope: 1 }] })
|
||||
assert.ok(m)
|
||||
assert.deepEqual(m.pages.map((p) => p.id), ['chat'])
|
||||
assert.deepEqual(m.blocks, [])
|
||||
assert.deepEqual(m.legacy, { customJs: null, viewsBase: '/plugins/views/' })
|
||||
})
|
||||
|
||||
test('SDK_EXPORTS and SHELL_EXTERNALS are frozen-shape lists', () => {
|
||||
assert.equal(new Set(SDK_EXPORTS).size, SDK_EXPORTS.length)
|
||||
for (const name of SDK_EXPORTS) assert.match(name, /^[A-Za-z_$][\w$]*$/)
|
||||
assert.deepEqual([...SHELL_EXTERNALS].sort(), ['@aiui/sdk', 'react', 'react-dom', 'react-dom/client', 'react/jsx-runtime'])
|
||||
})
|
||||
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* Browser-side registry for pages, blocks, tool renderers and slots
|
||||
* (ADR-0001 §5). The shell resolves a page id here first, then `kind=blocks`
|
||||
* (BlockPage), then the legacy host. Plugins reach these functions through
|
||||
* `@aiui/sdk` (window.__AIUI_SDK__).
|
||||
*
|
||||
* FROZEN CONTRACT: change only additively.
|
||||
*/
|
||||
import { lazy, useSyncExternalStore, type ComponentType } from 'react'
|
||||
import type { ShellPage } from './manifest'
|
||||
|
||||
export interface BlockNode {
|
||||
type: string
|
||||
props?: Record<string, unknown>
|
||||
children?: BlockNode[] | string
|
||||
}
|
||||
|
||||
export type PageComponent = ComponentType<{ page: ShellPage }>
|
||||
export type PageLoader = () => Promise<{ default: PageComponent }>
|
||||
export type BlockComponent = ComponentType<{ node: BlockNode; page?: ShellPage }>
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export type AnyComponent = ComponentType<any>
|
||||
|
||||
const pages = new Map<string, PageComponent>()
|
||||
const blocks = new Map<string, BlockComponent>()
|
||||
const toolRenderers = new Map<string, AnyComponent>()
|
||||
const slots = new Map<string, AnyComponent>()
|
||||
const listeners = new Set<() => void>()
|
||||
|
||||
let version = 0
|
||||
let toolRenderersSnapshot: Record<string, AnyComponent> | null = null
|
||||
|
||||
function emit() {
|
||||
version += 1
|
||||
toolRenderersSnapshot = null
|
||||
for (const fn of [...listeners]) {
|
||||
try {
|
||||
fn()
|
||||
} catch (err) {
|
||||
console.error('[aiui sdk] registry listener failed', err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function requireName(kind: string, name: unknown): string {
|
||||
if (typeof name !== 'string' || !name) {
|
||||
throw new TypeError(`[aiui sdk] ${kind}: expected a non-empty string id, got ${String(name)}`)
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
function requireComponent(kind: string, name: string, c: unknown) {
|
||||
const ok =
|
||||
typeof c === 'function' ||
|
||||
// memo / forwardRef / lazy components are objects with $$typeof
|
||||
(typeof c === 'object' && c !== null && '$$typeof' in c)
|
||||
if (!ok) throw new TypeError(`[aiui sdk] ${kind}('${name}'): expected a React component`)
|
||||
}
|
||||
|
||||
function setEntry<T>(map: Map<string, T>, kind: string, name: string, value: T) {
|
||||
const prev = map.get(name)
|
||||
if (prev === value) return
|
||||
if (prev !== undefined) console.warn(`[aiui sdk] ${kind}('${name}') replaces an earlier registration`)
|
||||
map.set(name, value)
|
||||
emit()
|
||||
}
|
||||
|
||||
function isThenable(v: unknown): v is PromiseLike<unknown> {
|
||||
return (
|
||||
(typeof v === 'object' || typeof v === 'function') &&
|
||||
v !== null &&
|
||||
typeof (v as { then?: unknown }).then === 'function'
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A zero-argument arrow function (no `prototype`) is treated as a lazy loader,
|
||||
* e.g. `registerPage('chat', () => import('./ChatPage'))`. Components declared
|
||||
* with `function`, classes, and memo/forwardRef/lazy objects are components.
|
||||
*/
|
||||
function looksLikeLoader(c: unknown): boolean {
|
||||
return typeof c === 'function' && c.length === 0 && !('prototype' in c && c.prototype)
|
||||
}
|
||||
|
||||
function lazyPage(load: () => unknown, fallbackComponent: PageComponent): PageComponent {
|
||||
return lazy(async () => {
|
||||
let result: unknown
|
||||
try {
|
||||
result = load()
|
||||
} catch {
|
||||
// Calling a zero-arg arrow *component* outside render can throw
|
||||
// (hooks). It was a component after all.
|
||||
return { default: fallbackComponent }
|
||||
}
|
||||
if (!isThenable(result)) return { default: fallbackComponent }
|
||||
const mod = await result
|
||||
const c =
|
||||
typeof mod === 'object' && mod !== null && 'default' in mod
|
||||
? (mod as { default: unknown }).default
|
||||
: mod
|
||||
requireComponent('registerPage', '<loader result>', c)
|
||||
return { default: c as PageComponent }
|
||||
}) as unknown as PageComponent
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a React page for a shell page id. `c` is a component, or a loader
|
||||
* returning `import()` of a module whose default export is the component.
|
||||
* Render the result of getPage() inside <Suspense>: a loader becomes a
|
||||
* React.lazy component.
|
||||
*/
|
||||
export function registerPage(id: string, c: PageComponent | PageLoader): void {
|
||||
const name = requireName('registerPage', id)
|
||||
requireComponent('registerPage', name, c)
|
||||
const component = looksLikeLoader(c) ? lazyPage(c as () => unknown, c as PageComponent) : (c as PageComponent)
|
||||
setEntry(pages, 'registerPage', name, component)
|
||||
}
|
||||
|
||||
export function registerBlock(type: string, c: BlockComponent): void {
|
||||
const name = requireName('registerBlock', type)
|
||||
requireComponent('registerBlock', name, c)
|
||||
setEntry(blocks, 'registerBlock', name, c)
|
||||
}
|
||||
|
||||
/** Feeds the chat Thread's `tools.by_name` renderers. */
|
||||
export function registerToolRenderer(toolName: string, c: AnyComponent): void {
|
||||
const name = requireName('registerToolRenderer', toolName)
|
||||
requireComponent('registerToolRenderer', name, c)
|
||||
setEntry(toolRenderers, 'registerToolRenderer', name, c)
|
||||
}
|
||||
|
||||
export function registerSlot(slot: string, c: AnyComponent): void {
|
||||
const name = requireName('registerSlot', slot)
|
||||
requireComponent('registerSlot', name, c)
|
||||
setEntry(slots, 'registerSlot', name, c)
|
||||
}
|
||||
|
||||
export function getPage(id: string): PageComponent | undefined {
|
||||
return pages.get(id)
|
||||
}
|
||||
|
||||
export function getBlock(type: string): BlockComponent | undefined {
|
||||
return blocks.get(type)
|
||||
}
|
||||
|
||||
/** Snapshot object; its identity changes only when the registry changes. */
|
||||
export function getToolRenderers(): Record<string, AnyComponent> {
|
||||
if (!toolRenderersSnapshot) toolRenderersSnapshot = Object.fromEntries(toolRenderers)
|
||||
return toolRenderersSnapshot
|
||||
}
|
||||
|
||||
export function getSlot(name: string): AnyComponent | undefined {
|
||||
return slots.get(name)
|
||||
}
|
||||
|
||||
/** Called after every registration change. Returns an unsubscribe function. */
|
||||
export function subscribe(fn: () => void): () => void {
|
||||
listeners.add(fn)
|
||||
return () => {
|
||||
listeners.delete(fn)
|
||||
}
|
||||
}
|
||||
|
||||
function getVersion() {
|
||||
return version
|
||||
}
|
||||
|
||||
/** Re-renders the caller whenever anything is (re-)registered. */
|
||||
export function useRegistryVersion(): number {
|
||||
return useSyncExternalStore(subscribe, getVersion, getVersion)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* CONTRACT STUB (WP0a). WP6 replaces this file with the React shell.
|
||||
* Contract (ADR-0001 §5): export function mountShell(el: HTMLElement): void
|
||||
*/
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components -- mount entry, not an HMR boundary
|
||||
function ShellPlaceholder() {
|
||||
return (
|
||||
<div className="aiui-app" data-aiui-stub="shell" role="status">
|
||||
Shell loading
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function mountShell(el: HTMLElement): void {
|
||||
createRoot(el).render(
|
||||
<StrictMode>
|
||||
<ShellPlaceholder />
|
||||
</StrictMode>,
|
||||
)
|
||||
}
|
||||
@@ -120,7 +120,7 @@ export interface TemplateConfig {
|
||||
export interface UIConfig {
|
||||
debug?: boolean
|
||||
site?: SiteConfig
|
||||
style?: 'docs' | 'chat' | 'agents' | 'playground' | 'custom' | 'dashboard'
|
||||
style?: 'docs' | 'chat' | 'agents' | 'playground' | 'custom' | 'dashboard' | 'app'
|
||||
layout?: LayoutConfig
|
||||
chat?: ChatConfig
|
||||
auth?: AuthConfig
|
||||
|
||||
+113
-6
@@ -1,24 +1,131 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import { defineConfig, type Plugin } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import path from 'path'
|
||||
|
||||
// Dev server: proxy every server-owned path to a running `aiui` backend so
|
||||
// `npm run dev` (and the dev/*.html harness pages) can talk to real APIs.
|
||||
const DEV_BACKEND = process.env.AIUI_DEV_BACKEND || 'http://127.0.0.1:8000'
|
||||
const PROXIED_PATHS = [
|
||||
'/api',
|
||||
'/run',
|
||||
'/cancel',
|
||||
'/starters',
|
||||
'/sessions',
|
||||
'/plugins',
|
||||
'/dashboard-plugins',
|
||||
'/_aiui',
|
||||
'/_plugins',
|
||||
'/custom.js',
|
||||
]
|
||||
|
||||
// React, ReactDOM and the scheduler get one stable chunk shared by the shell,
|
||||
// the legacy App path and every lazy route (size budgets glob for it).
|
||||
const VENDOR_REACT = /[\\/]node_modules[\\/](react|react-dom|scheduler)[\\/]/
|
||||
|
||||
// No chunk may import the entry. The server loads it as
|
||||
// /assets/index.js?v=<build>, and a chunk's `import "./index.js"` is a
|
||||
// different module URL, so the entry (and main.tsx's mount) would run twice.
|
||||
// Two things would otherwise create such an import:
|
||||
// - Vite's dynamic-import preload helper, emitted inside the entry by default
|
||||
// (moved to its own chunk below);
|
||||
// - a second import of the global stylesheet src/index.css (App.tsx has one),
|
||||
// which makes the importing chunk depend on the entry that owns the CSS.
|
||||
const ENTRY_FILE = 'assets/index.js'
|
||||
const PRELOAD_HELPER = 'vite/preload-helper'
|
||||
const MAIN_TSX = path.resolve(__dirname, 'src/main.tsx')
|
||||
const GLOBAL_CSS = path.resolve(__dirname, 'src/index.css')
|
||||
const NOOP_CSS_ID = '\0aiui-global-css-already-loaded'
|
||||
|
||||
/**
|
||||
* src/index.css is the entry's stylesheet (main.tsx imports it, the server
|
||||
* links /assets/index.css). In a build, any other module's import of it is
|
||||
* redundant and is resolved to an empty module.
|
||||
*/
|
||||
function globalCssOnlyFromEntry(): Plugin {
|
||||
return {
|
||||
name: 'aiui-global-css-only-from-entry',
|
||||
apply: 'build',
|
||||
enforce: 'pre',
|
||||
async resolveId(source, importer, options) {
|
||||
if (!importer || !/index\.css(\?.*)?$/.test(source)) return null
|
||||
if (importer.split('?')[0] === MAIN_TSX) return null
|
||||
const resolved = await this.resolve(source, importer, { ...options, skipSelf: true })
|
||||
if (resolved && resolved.id.split('?')[0] === GLOBAL_CSS) return NOOP_CSS_ID
|
||||
return null
|
||||
},
|
||||
load(id) {
|
||||
return id === NOOP_CSS_ID ? '' : null
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** Fail the build if any chunk imports the entry (see above). */
|
||||
function noEntryImports(): Plugin {
|
||||
return {
|
||||
name: 'aiui-no-entry-imports',
|
||||
apply: 'build',
|
||||
generateBundle(_options, bundle) {
|
||||
for (const chunk of Object.values(bundle)) {
|
||||
if (chunk.type !== 'chunk' || chunk.fileName === ENTRY_FILE) continue
|
||||
if (chunk.imports.includes(ENTRY_FILE) || chunk.dynamicImports.includes(ENTRY_FILE)) {
|
||||
this.error(
|
||||
`${chunk.fileName} imports ${ENTRY_FILE}; the entry is loaded with ?v= so it would be evaluated twice. ` +
|
||||
'Do not export from or import main.tsx, and import src/index.css only from main.tsx.',
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
plugins: [globalCssOnlyFromEntry(), react(), tailwindcss(), noEntryImports()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
// The chat WebSocket must be listed before the /api prefix rule.
|
||||
'/api/chat/ws': { target: DEV_BACKEND, ws: true, changeOrigin: true },
|
||||
...Object.fromEntries(PROXIED_PATHS.map((p) => [p, { target: DEV_BACKEND, changeOrigin: true }])),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
assetsDir: 'assets',
|
||||
// Vite manifest (not the PWA kind): maps source modules to emitted chunks.
|
||||
// Gates (closure check, size budgets) and the server resolve chunks with it.
|
||||
manifest: 'aiui-manifest.json',
|
||||
rollupOptions: {
|
||||
// Only the real app entry. dev/*.html harness pages are served by the dev
|
||||
// server but never built (and spike.html is a dev-only page too).
|
||||
input: path.resolve(__dirname, 'index.html'),
|
||||
output: {
|
||||
// Static filenames instead of hashed (cleaner for git)
|
||||
// The entry keeps a fixed name: the Python server references
|
||||
// /assets/index.js?v=<cache_bust> directly. Everything it loads lazily
|
||||
// is content-hashed (immutable caching, and no case-insensitive name
|
||||
// collisions such as shiki's markdown.js vs our Markdown.js on macOS).
|
||||
entryFileNames: 'assets/index.js',
|
||||
chunkFileNames: 'assets/[name].js',
|
||||
assetFileNames: 'assets/[name].[ext]',
|
||||
chunkFileNames: 'assets/[name]-[hash].js',
|
||||
assetFileNames: (assetInfo) => {
|
||||
const names = assetInfo.names ?? []
|
||||
const origins = assetInfo.originalFileNames ?? []
|
||||
// The entry's CSS (index.html -> main.tsx -> index.css) is linked by
|
||||
// the server as /assets/index.css, so it keeps its fixed name. Any
|
||||
// other asset (lazy-chunk CSS, fonts, images) is hashed.
|
||||
if (names.includes('index.css') && origins.includes('index.html')) {
|
||||
return 'assets/index.css'
|
||||
}
|
||||
return 'assets/[name]-[hash][extname]'
|
||||
},
|
||||
manualChunks: (id) => {
|
||||
if (VENDOR_REACT.test(id)) return 'vendor-react'
|
||||
if (id.includes(PRELOAD_HELPER)) return 'preload-helper'
|
||||
return undefined
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""Version information."""
|
||||
|
||||
__version__ = "0.3.152"
|
||||
__version__ = "0.3.154"
|
||||
+57
-10
@@ -276,9 +276,11 @@ def set_custom_js(path: str | Path) -> None:
|
||||
|
||||
Reads the file at ``path`` and serves it at ``/custom.js``. A
|
||||
``<script src="/custom.js">`` tag is injected into the host HTML
|
||||
*after* the plugin loader, so ``window.aiui`` and all registry APIs
|
||||
(``registerView``, ``registerComponent``) are ready when your code
|
||||
runs.
|
||||
*after* the plugin loader. ``window.aiui`` registry APIs
|
||||
(``registerView``, ``registerComponent``, ``registerSlot``,
|
||||
``registerSurfaceRenderer``) can be called directly at load time:
|
||||
calls made before the dashboard module is ready are queued and
|
||||
replayed, and a view registered for the active page re-renders it.
|
||||
|
||||
Use this to ship client-side extensions from your Python app without
|
||||
modifying the package bundle.
|
||||
@@ -802,11 +804,37 @@ def _build_html(style: str) -> str:
|
||||
)
|
||||
|
||||
|
||||
def _frontend_templates_dir() -> Path:
|
||||
"""Directory holding the built React frontend (``assets/``, ``aiui-manifest.json``,
|
||||
``aiui-externals.json``).
|
||||
|
||||
Defaults to the packaged ``templates/frontend``. ``AIUI_TEMPLATES_DIR`` points
|
||||
it at another build, e.g. one copied with
|
||||
``node scripts/sync-templates.mjs --dest <dir>``, without touching the
|
||||
packaged (tracked) files.
|
||||
"""
|
||||
override = os.environ.get("AIUI_TEMPLATES_DIR", "").strip()
|
||||
if override:
|
||||
return Path(override).expanduser().resolve()
|
||||
return Path(__file__).parent / "templates" / "frontend"
|
||||
|
||||
|
||||
def _custom_js_tag(cache_bust: int) -> str:
|
||||
"""Return a <script src="/custom.js"> tag if set_custom_js() was called."""
|
||||
if _custom_js is None:
|
||||
return ""
|
||||
return f'<script src="/custom.js?v={cache_bust}"></script>'
|
||||
return _CUSTOM_JS_QUEUE + f'<script src="/custom.js?v={cache_bust}"></script>'
|
||||
|
||||
|
||||
# Registration stubs for /custom.js: the dashboard module loads
|
||||
# asynchronously, so until it defines the real registry APIs these stubs
|
||||
# queue calls on ``window.aiui.__queue``; dashboard.js replays them.
|
||||
_CUSTOM_JS_QUEUE = (
|
||||
"<script>(function(a){a.__queue=a.__queue||[];"
|
||||
"['registerView','registerComponent','registerSlot','registerSurfaceRenderer']"
|
||||
".forEach(function(n){if(!a[n])a[n]=function(){a.__queue.push([n,arguments]);};});"
|
||||
"})(window.aiui=window.aiui||{});</script>"
|
||||
)
|
||||
|
||||
|
||||
async def _serve_custom_js(request: Request) -> Response:
|
||||
@@ -1095,11 +1123,18 @@ async def health_ready(request: Request) -> JSONResponse:
|
||||
|
||||
provider = get_provider()
|
||||
provider_info = {"name": type(provider).__name__}
|
||||
try:
|
||||
provider_health = await provider.health()
|
||||
provider_info.update(provider_health)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def _safe_provider() -> Dict[str, Any]:
|
||||
# Bounded like feature checks so a slow provider cannot hang readiness
|
||||
# (or the cold /api/health dashboard request that delegates here).
|
||||
try:
|
||||
result = await asyncio.wait_for(provider.health(), timeout=0.5)
|
||||
except Exception:
|
||||
return {}
|
||||
return result if isinstance(result, dict) else {}
|
||||
|
||||
# Start the provider check now so it runs concurrently with feature checks.
|
||||
provider_task = asyncio.ensure_future(_safe_provider())
|
||||
|
||||
features_health = {}
|
||||
sdk_gaps: list = []
|
||||
@@ -1138,6 +1173,8 @@ async def health_ready(request: Request) -> JSONResponse:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
provider_info.update(await provider_task)
|
||||
|
||||
payload: Dict[str, Any] = {
|
||||
"status": "ok",
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
@@ -3649,11 +3686,21 @@ def create_app(
|
||||
}
|
||||
|
||||
# Always mount built-in frontend plugins and assets BEFORE catch-all /
|
||||
_frontend_dir = Path(__file__).parent / "templates" / "frontend"
|
||||
_package_frontend_dir = Path(__file__).parent / "templates" / "frontend"
|
||||
_frontend_dir = _frontend_templates_dir()
|
||||
# Hand-written plugins/ ship with the package; an AIUI_TEMPLATES_DIR build
|
||||
# copy only replaces them if it carries its own plugins/ directory.
|
||||
_plugins_dir = _frontend_dir / "plugins"
|
||||
if not _plugins_dir.is_dir():
|
||||
_plugins_dir = _package_frontend_dir / "plugins"
|
||||
if static_dir and (Path(static_dir) / "plugins").is_dir():
|
||||
_plugins_dir = Path(static_dir) / "plugins"
|
||||
_assets_dir = _frontend_dir / "assets"
|
||||
if not _assets_dir.is_dir() and _frontend_dir != _package_frontend_dir:
|
||||
logging.getLogger(__name__).warning(
|
||||
"AIUI_TEMPLATES_DIR=%s has no assets/ directory; /assets will not be served",
|
||||
_frontend_dir,
|
||||
)
|
||||
|
||||
# Dynamic plugins.json route — MUST come before static /plugins mount
|
||||
# so the dynamic endpoint takes priority over the static file.
|
||||
|
||||
@@ -241,6 +241,10 @@ const aiuiSdk = {
|
||||
window.aiui = window.aiui || {};
|
||||
window.aiui.registerView = function(pageId, renderFn, cleanupFn) {
|
||||
VIEW_REGISTRY[pageId] = { render: renderFn, cleanup: cleanupFn || null };
|
||||
// Late registration for the page already on screen: re-render it.
|
||||
if (pageId === activePageId && document.getElementById('db-main-content')) {
|
||||
selectPage(pageId);
|
||||
}
|
||||
};
|
||||
window.aiui.views = VIEW_REGISTRY;
|
||||
window.aiui.registerComponent = function(type, renderFn) {
|
||||
@@ -291,6 +295,14 @@ const DASHBOARD_STYLE = `
|
||||
|
||||
/* Root becomes the flex container */
|
||||
#root { display: flex; min-height: 100vh; }
|
||||
/* With a shell:header slot: sidebar | (header row sized to content, page panel below). */
|
||||
#root.db-active:has(> #db-shell-header-slot) {
|
||||
display: grid; grid-template-columns: auto minmax(0, 1fr); grid-template-rows: auto minmax(0, 1fr);
|
||||
height: 100vh; min-height: 0;
|
||||
}
|
||||
#root.db-active:has(> #db-shell-header-slot) > .db-sidebar { grid-column: 1; grid-row: 1 / -1; }
|
||||
#db-shell-header-slot { grid-column: 2; grid-row: 1; min-width: 0; padding: 8px 40px 0; }
|
||||
#root.db-active:has(> #db-shell-header-slot) > .db-main { grid-column: 2; grid-row: 2; height: auto; min-height: 0; }
|
||||
|
||||
/* Sidebar */
|
||||
.db-sidebar {
|
||||
@@ -471,9 +483,8 @@ const DASHBOARD_STYLE = `
|
||||
.db-sidebar.db-sidebar-open { transform: translateX(0); visibility: visible; transition: transform 0.2s ease; }
|
||||
.db-mobile-overlay.db-visible { display: block; }
|
||||
.db-main { padding-top: 48px; }
|
||||
/* The shell header slot sits in the same flex row; give it its own line. */
|
||||
#root.db-active { flex-wrap: wrap; }
|
||||
#db-shell-header-slot { flex: 0 0 100%; margin-top: 48px; }
|
||||
/* Sidebar is fixed here; the header row sits below the fixed mobile bar. */
|
||||
#db-shell-header-slot { margin-top: 48px; padding: 8px 16px 0; }
|
||||
#db-shell-header-slot + .db-main { padding-top: 16px; }
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
@@ -1972,6 +1983,12 @@ function showDashboardInitError(message) {
|
||||
banner.textContent = message;
|
||||
}
|
||||
|
||||
// A render superseded by a newer selectPage never became active, so run its
|
||||
// own cleanup to release intervals/listeners it set up.
|
||||
function _supersededCleanup(fn) {
|
||||
if (fn) { try { fn(); } catch (e) {} }
|
||||
}
|
||||
|
||||
async function selectPage(pageId) {
|
||||
// Cleanup previous view if it has a cleanup function
|
||||
if (_activeCleanup) { try { _activeCleanup(); } catch(e) {} _activeCleanup = null; }
|
||||
@@ -2042,9 +2059,11 @@ async function selectPage(pageId) {
|
||||
|
||||
// Priority 1: Custom registered view
|
||||
if (VIEW_REGISTRY[pageId]) {
|
||||
const view = VIEW_REGISTRY[pageId];
|
||||
try {
|
||||
await VIEW_REGISTRY[pageId].render(container);
|
||||
_activeCleanup = VIEW_REGISTRY[pageId].cleanup || null;
|
||||
await view.render(container);
|
||||
if (!container.isConnected) { _supersededCleanup(view.cleanup); return; } // superseded by a newer selectPage
|
||||
_activeCleanup = view.cleanup || null;
|
||||
rendered = true;
|
||||
} catch (e) { console.warn(`View '${pageId}' render error:`, e); }
|
||||
}
|
||||
@@ -2055,17 +2074,21 @@ async function selectPage(pageId) {
|
||||
const viewUrl = BUILTIN_VIEWS[pageId] + '?v=' + Date.now();
|
||||
const mod = await import(viewUrl);
|
||||
if (mod.render) {
|
||||
if (!container.isConnected) return; // superseded by a newer selectPage
|
||||
await mod.render(container);
|
||||
if (!container.isConnected) { _supersededCleanup(mod.cleanup); return; }
|
||||
_activeCleanup = mod.cleanup || null;
|
||||
rendered = true;
|
||||
// Cache for future use
|
||||
VIEW_REGISTRY[pageId] = { render: mod.render, cleanup: mod.cleanup || null };
|
||||
// Cache for future use (never overwrite a view registered meanwhile)
|
||||
if (!VIEW_REGISTRY[pageId]) {
|
||||
VIEW_REGISTRY[pageId] = { render: mod.render, cleanup: mod.cleanup || null };
|
||||
}
|
||||
}
|
||||
} catch (e) { console.warn(`Built-in view '${pageId}' import error:`, e); }
|
||||
}
|
||||
|
||||
// Priority 3: Generic JSON viewer fallback
|
||||
if (!rendered) {
|
||||
if (!rendered && container.isConnected) {
|
||||
loadGenericViewer(page, container);
|
||||
}
|
||||
|
||||
@@ -3517,23 +3540,21 @@ window.aiui.registerSlot('shell:header', () => {
|
||||
window.aiui.registerSlot('shell:sidebar:footer', () => {
|
||||
const el = document.createElement('div');
|
||||
el.innerHTML = '<span class="db-health-dot" style="color:var(--db-text-dim)">●</span> <span class="db-health-label">Checking…</span>';
|
||||
fetch('/api/health').then((r) => r.json()).then((d) => {
|
||||
// Connection indicator uses side-effect-free liveness (#341). Deep
|
||||
// feature/SDK-gap diagnostics remain at /api/health (Debug view).
|
||||
fetch('/api/health/live').then((r) => r.json()).then((d) => {
|
||||
const ok = d && (d.status === 'ok' || d.status === 'healthy');
|
||||
const dot = el.querySelector('.db-health-dot');
|
||||
const label = el.querySelector('.db-health-label');
|
||||
const gaps = (d && d.sdk_gaps) || [];
|
||||
if (dot) dot.style.color = ok && !gaps.length ? '#22c55e' : (gaps.length ? '#f59e0b' : '#ef4444');
|
||||
if (label) {
|
||||
if (gaps.length) {
|
||||
label.textContent = `SDK gaps (${gaps.length})`;
|
||||
label.title = gaps.map((g) => `${g.feature}: ${g.message}`).join('\n');
|
||||
} else {
|
||||
label.textContent = ok ? (d.integrated ? 'Integrated' : 'Connected') : 'Degraded';
|
||||
label.title = '';
|
||||
}
|
||||
}
|
||||
if (dot) dot.style.color = ok ? '#22c55e' : '#ef4444';
|
||||
if (label) label.textContent = ok ? 'Connected' : 'Degraded';
|
||||
}).catch(() => { const label = el.querySelector('.db-health-label'); if (label) label.textContent = 'Offline'; });
|
||||
return el;
|
||||
});
|
||||
|
||||
// Replay registrations queued by the /custom.js stubs (see server.py).
|
||||
(window.aiui.__queue || []).splice(0).forEach(([name, args]) => {
|
||||
try { window.aiui[name](...args); } catch (e) { console.warn(`[AIUI] queued ${name} failed:`, e); }
|
||||
});
|
||||
|
||||
export { init, onContentChange };
|
||||
@@ -379,3 +379,17 @@ RADIUS_PRESETS = {
|
||||
def get_radius_value(preset: str = "md") -> str:
|
||||
"""Get radius CSS value from preset name."""
|
||||
return RADIUS_PRESETS.get(preset, RADIUS_PRESETS["md"])
|
||||
|
||||
|
||||
def get_seed_theme_css(
|
||||
preset: Optional[str] = None,
|
||||
radius: Optional[str] = None,
|
||||
accent: Optional[str] = None,
|
||||
density: Optional[str] = None,
|
||||
) -> str:
|
||||
"""Return the L0 seed CSS (``--seed-*``, ``--radius``, ``--density``) for a theme.
|
||||
|
||||
Contract stub (WP0a). WP0b replaces it with the real seed emitter
|
||||
(ADR-0001 §7); until then it emits nothing so callers can wire it in.
|
||||
"""
|
||||
return ""
|
||||
@@ -0,0 +1,6 @@
|
||||
===== style=dashboard =====
|
||||
<!doctype html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><script id="aiui-canonical-url">(function(){var p=location.pathname.replace(/\/+$/, '')||'/';if(p!==location.pathname){history.replaceState(null,'',p+location.search+location.hash);}})();</script><script id="aiui-theme-bootstrap">(function(){try{var t=localStorage.getItem('aiui-theme-preference');if(t==='dark'||(t==='system'&&matchMedia('(prefers-color-scheme: dark)').matches))document.documentElement.classList.add('dark');else if(t==='light')document.documentElement.classList.remove('dark');}catch(e){}})();</script><link rel="stylesheet" href="/assets/index.css?v=0"><style id="aiui-server-theme">:root{--db-accent:#71717a!important;--db-accent-glow:rgba(113,113,122,0.15)!important;--db-accent-rgb:113,113,122!important;--db-radius:0.5rem!important}</style><title>PraisonAI Dashboard</title></head><body style="margin:0"><div id="root"></div><script src="/plugins/plugin-loader.js?v=0"></script></body></html>
|
||||
===== style=chat =====
|
||||
<!doctype html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><script id="aiui-canonical-url">(function(){var p=location.pathname.replace(/\/+$/, '')||'/';if(p!==location.pathname){history.replaceState(null,'',p+location.search+location.hash);}})();</script><script id="aiui-theme-bootstrap">(function(){try{var t=localStorage.getItem('aiui-theme-preference');if(t==='dark'||(t==='system'&&matchMedia('(prefers-color-scheme: dark)').matches))document.documentElement.classList.add('dark');else if(t==='light')document.documentElement.classList.remove('dark');}catch(e){}})();</script><link rel="stylesheet" href="/assets/index.css?v=0"><title>PraisonAI</title></head><body style="margin:0"><div id="root"></div><script type="module" crossorigin src="/assets/index.js?v=0"></script><script src="/plugins/plugin-loader.js?v=0"></script></body></html>
|
||||
===== style=docs =====
|
||||
<!doctype html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><script id="aiui-canonical-url">(function(){var p=location.pathname.replace(/\/+$/, '')||'/';if(p!==location.pathname){history.replaceState(null,'',p+location.search+location.hash);}})();</script><script id="aiui-theme-bootstrap">(function(){try{var t=localStorage.getItem('aiui-theme-preference');if(t==='dark'||(t==='system'&&matchMedia('(prefers-color-scheme: dark)').matches))document.documentElement.classList.add('dark');else if(t==='light')document.documentElement.classList.remove('dark');}catch(e){}})();</script><link rel="stylesheet" href="/assets/index.css?v=0"><style id="aiui-anti-flicker">html,body{background:hsl(var(--background))!important;color:hsl(var(--foreground))}#root>*{opacity:0;transition:opacity .15s ease}</style><title>PraisonAI</title></head><body style="margin:0"><div id="root"></div><script type="module" crossorigin src="/assets/index.js?v=0"></script><script src="/plugins/plugin-loader.js?v=0"></script></body></html>
|
||||
@@ -122,3 +122,46 @@ class TestCustomJsServing:
|
||||
"custom.js must be injected AFTER plugin-loader.js so window.aiui "
|
||||
"APIs are available when custom.js runs"
|
||||
)
|
||||
|
||||
|
||||
# ── Registration lifecycle (issue #338) ───────────────────────────
|
||||
|
||||
_DASHBOARD_JS = (
|
||||
Path(srv.__file__).parent / "templates" / "frontend" / "plugins" / "dashboard.js"
|
||||
)
|
||||
|
||||
|
||||
class TestCustomJsRegistrationLifecycle:
|
||||
def test_queue_stub_injected_before_custom_js(self, tmp_js):
|
||||
"""Registry stubs must exist before /custom.js runs (dashboard loads async)."""
|
||||
srv.set_custom_js(tmp_js)
|
||||
srv.set_style("dashboard")
|
||||
html = TestClient(srv.create_app()).get("/").text
|
||||
stub_idx = html.find("__queue")
|
||||
assert stub_idx != -1, "registration queue stub must be injected"
|
||||
assert stub_idx < html.find("/custom.js")
|
||||
for name in ("registerView", "registerComponent", "registerSlot",
|
||||
"registerSurfaceRenderer"):
|
||||
assert name in html[stub_idx:html.find("/custom.js")]
|
||||
|
||||
def test_no_queue_stub_when_unset(self, client):
|
||||
assert "__queue" not in client.get("/").text
|
||||
|
||||
def test_dashboard_replays_queued_registrations(self):
|
||||
src = _DASHBOARD_JS.read_text(encoding="utf-8")
|
||||
replay_idx = src.find("window.aiui.__queue")
|
||||
assert replay_idx != -1, "dashboard.js must replay queued registrations"
|
||||
# Replay must run after `let activePageId` is initialised (no TDZ error).
|
||||
assert replay_idx > src.find("let activePageId")
|
||||
|
||||
def test_late_register_view_rerenders_active_page(self):
|
||||
src = _DASHBOARD_JS.read_text(encoding="utf-8")
|
||||
start = src.find("window.aiui.registerView = function")
|
||||
body = src[start:src.find("\n};", start)]
|
||||
assert "activePageId" in body and "selectPage(pageId)" in body
|
||||
|
||||
def test_superseded_render_runs_its_cleanup(self):
|
||||
"""A render replaced by a newer selectPage must still be cleaned up."""
|
||||
src = _DASHBOARD_JS.read_text(encoding="utf-8")
|
||||
assert "_supersededCleanup(view.cleanup)" in src
|
||||
assert "_supersededCleanup(mod.cleanup)" in src
|
||||
@@ -139,10 +139,19 @@ def test_replay_page_uses_own_svg_mark_not_emoji_heading():
|
||||
def test_dashboard_mobile_shell_gives_content_the_width():
|
||||
dash = (FRONTEND / "dashboard.js").read_text(encoding="utf-8")
|
||||
assert ".db-sidebar { position: fixed !important;" in dash
|
||||
assert "#db-shell-header-slot { flex: 0 0 100%;" in dash
|
||||
assert "#db-shell-header-slot { margin-top: 48px;" in dash
|
||||
assert ".db-main { padding: 64px 16px 16px; }" in dash
|
||||
|
||||
|
||||
def test_shell_header_is_a_content_sized_row_not_a_column():
|
||||
"""#339: the header slot must not take a flex column or stretch on mobile."""
|
||||
dash = (FRONTEND / "dashboard.js").read_text(encoding="utf-8")
|
||||
assert "#root.db-active:has(> #db-shell-header-slot) {" in dash
|
||||
assert "grid-template-rows: auto minmax(0, 1fr);" in dash
|
||||
assert "#db-shell-header-slot { grid-column: 2; grid-row: 1;" in dash
|
||||
assert "#root.db-active { flex-wrap: wrap; }" not in dash
|
||||
|
||||
|
||||
def test_theme_defaults_to_system():
|
||||
view = VIEW.read_text(encoding="utf-8")
|
||||
assert "@media (prefers-color-scheme:dark){.rp-root[data-theme=\"system\"]" in view
|
||||
|
||||
@@ -25,6 +25,8 @@ def client():
|
||||
|
||||
_srv._DEEP_HEALTH_CACHE = {}
|
||||
_srv._DEEP_HEALTH_CACHE_TIME = 0.0
|
||||
_srv._health_cache = {}
|
||||
_srv._health_cache_time = 0
|
||||
# Snapshot and clear the global feature registry so a feature leaked by an
|
||||
# earlier test (e.g. one that crashed before its cleanup) cannot slow down
|
||||
# /health and flake the deep-health timing/cache assertions under the full
|
||||
@@ -203,6 +205,97 @@ class TestHealthEndpoint:
|
||||
assert second.json().get("cached") is True
|
||||
assert duration < 0.1
|
||||
|
||||
def test_slow_provider_does_not_block_readiness(self, client):
|
||||
"""Cold /api/health and /health/ready bound a slow provider (#341)."""
|
||||
import asyncio
|
||||
import time
|
||||
|
||||
import praisonaiui.server as _srv
|
||||
|
||||
class _SlowProvider:
|
||||
async def health(self):
|
||||
await asyncio.sleep(5)
|
||||
return {"status": "ok"}
|
||||
|
||||
prev_provider = _srv._provider
|
||||
_srv._provider = _SlowProvider()
|
||||
_srv._health_cache = {}
|
||||
_srv._health_cache_time = 0
|
||||
try:
|
||||
for path in ("/api/health", "/health/ready"):
|
||||
start = time.perf_counter()
|
||||
response = client.get(path)
|
||||
duration = time.perf_counter() - start
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["status"] == "ok"
|
||||
assert data["provider"]["name"] == "_SlowProvider"
|
||||
assert duration < 2.0
|
||||
finally:
|
||||
_srv._provider = prev_provider
|
||||
_srv._health_cache = {}
|
||||
_srv._health_cache_time = 0
|
||||
|
||||
def test_non_dict_provider_health_does_not_500_readiness(self, client):
|
||||
"""A provider returning a non-dict from health() must not break /health/ready."""
|
||||
import praisonaiui.server as _srv
|
||||
|
||||
class _NoneProvider:
|
||||
async def health(self):
|
||||
return None
|
||||
|
||||
prev_provider = _srv._provider
|
||||
_srv._provider = _NoneProvider()
|
||||
try:
|
||||
response = client.get("/health/ready")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["provider"] == {"name": "_NoneProvider"}
|
||||
finally:
|
||||
_srv._provider = prev_provider
|
||||
|
||||
|
||||
class TestFooterLiveness:
|
||||
"""Dashboard footer uses side-effect-free liveness (#341)."""
|
||||
|
||||
def test_live_makes_no_provider_or_feature_calls(self, client):
|
||||
import praisonaiui.server as _srv
|
||||
from praisonaiui.features import _features
|
||||
|
||||
calls = {"provider": 0, "feature": 0}
|
||||
|
||||
class _Provider:
|
||||
async def health(self):
|
||||
calls["provider"] += 1
|
||||
return {}
|
||||
|
||||
class _Feature:
|
||||
async def health(self):
|
||||
calls["feature"] += 1
|
||||
return {"healthy": True}
|
||||
|
||||
original = _srv._provider
|
||||
_srv._provider = _Provider()
|
||||
_features["__count__"] = _Feature()
|
||||
try:
|
||||
assert client.get("/api/health/live").status_code == 200
|
||||
assert calls == {"provider": 0, "feature": 0}
|
||||
finally:
|
||||
_srv._provider = original
|
||||
_features.pop("__count__", None)
|
||||
|
||||
def test_sidebar_footer_uses_liveness_endpoint(self):
|
||||
from pathlib import Path
|
||||
|
||||
import praisonaiui
|
||||
|
||||
js = (
|
||||
Path(praisonaiui.__file__).parent
|
||||
/ "templates/frontend/plugins/dashboard.js"
|
||||
).read_text(encoding="utf-8")
|
||||
footer = js[js.index("shell:sidebar:footer"):]
|
||||
assert "fetch('/api/health/live')" in footer
|
||||
assert "fetch('/api/health')" not in footer
|
||||
|
||||
|
||||
class TestProviderHealthEndpoint:
|
||||
"""Tests for the provider health JSON route (#151)."""
|
||||
|
||||
Reference in new issue
Block a user