Merge pull request #1409 from trheyi/main

Implement Multi-Directory Watching and Enhance Locale File Handling
This commit is contained in:
Max 2026-01-03 17:49:35 +08:00 committed by GitHub
commit 369d227bf2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 1685 additions and 390 deletions

View file

@ -184,6 +184,7 @@ ctx.Send({
- [Models](docs/models.md) - Assistant-scoped data models - [Models](docs/models.md) - Assistant-scoped data models
- [Search](docs/search.md) - Web, knowledge base, and database search - [Search](docs/search.md) - Web, knowledge base, and database search
- [Pages](docs/pages.md) - Web UI for agents (SUI framework) - [Pages](docs/pages.md) - Web UI for agents (SUI framework)
- [Iframe Integration](docs/iframe.md) - Iframe communication with CUI
- [Internationalization](docs/i18n.md) - Multi-language support - [Internationalization](docs/i18n.md) - Multi-language support
- [Testing](docs/testing.md) - Agent testing framework - [Testing](docs/testing.md) - Agent testing framework

300
agent/docs/iframe.md Normal file
View file

@ -0,0 +1,300 @@
# Iframe Integration
Agent Pages can be embedded in CUI via `/web/` routes. This document covers the iframe communication mechanism between embedded pages and the CUI host.
## Route Mapping
Pages are accessible via:
```
/web/<assistant-id>/<page-path>
```
Example:
| Page File | URL |
| -------------------------- | --------------------------------- |
| `pages/index/index.html` | `/web/my-assistant/index` |
| `pages/result/index.html` | `/web/my-assistant/result` |
| `pages/report/detail.html` | `/web/my-assistant/report/detail` |
## URL Parameters
CUI automatically injects context via URL parameters:
| Parameter | Value | Description |
| ---------- | ---------------------- | ------------- |
| `__theme` | `light` / `dark` | Current theme |
| `__locale` | `en-us`, `zh-cn`, etc. | User locale |
> **Note**: Authentication uses secure HTTP-only cookies, so `__token` parameter is not needed.
**Usage in page URL:**
```
/web/my-assistant/result?theme=__theme&locale=__locale
```
CUI replaces `__theme`, `__locale` with actual values before loading.
## Message Communication
### Receiving Setup Message
When the iframe loads, CUI sends a `setup` message:
```typescript
// In your page script
window.addEventListener("message", (e) => {
if (e.data.type === "setup") {
const { theme, locale } = e.data.message;
// Apply theme, set locale
document.documentElement.setAttribute("data-theme", theme);
}
});
```
### Sending Actions to CUI
Pages can trigger CUI actions via `postMessage` using the unified Action system:
```typescript
// Send action to parent CUI
window.parent.postMessage(
{
type: "action",
message: {
name: "notify.success",
payload: { message: "Operation completed" },
},
},
window.location.origin
);
```
### Action Types
#### Navigate
| Action | Description | Payload |
| --------------- | ------------------------------- | ------------------------------------------- |
| `navigate` | Open page in sidebar or new tab | `{ route, title?, icon?, query?, target? }` |
| `navigate.back` | Navigate back in history | - |
**Navigate Payload:**
| Field | Type | Required | Description |
| -------- | ------------------------ | -------- | ----------------------------------------------- |
| `route` | `string` | ✅ | Target route (`$dashboard/xxx`, `/xxx`, or URL) |
| `title` | `string` | - | Page title (shows title bar with back button) |
| `icon` | `string` | - | Tab icon (e.g., `material-folder`) |
| `query` | `Record<string, string>` | - | Query parameters |
| `target` | `'_self'` \| `'_blank'` | - | `_self` (sidebar) or `_blank` (new window) |
#### Notify
| Action | Description | Payload |
| ---------------- | ------------------------- | ------------------------------------------ |
| `notify.success` | Show success notification | `{ message, duration?, icon?, closable? }` |
| `notify.error` | Show error notification | `{ message, duration?, icon?, closable? }` |
| `notify.warning` | Show warning notification | `{ message, duration?, icon?, closable? }` |
| `notify.info` | Show info notification | `{ message, duration?, icon?, closable? }` |
#### App
| Action | Description |
| ----------------- | ------------------------ |
| `app.menu.reload` | Refresh application menu |
#### Modal
| Action | Description |
| ------------- | ----------------- |
| `modal.open` | Open modal dialog |
| `modal.close` | Close modal |
#### Table
| Action | Description |
| --------------- | -------------------- |
| `table.search` | Trigger table search |
| `table.refresh` | Refresh table data |
| `table.save` | Save table row |
| `table.delete` | Delete table row(s) |
#### Form
| Action | Description |
| ----------------- | --------------------- |
| `form.find` | Load form data by ID |
| `form.submit` | Submit form |
| `form.reset` | Reset form |
| `form.setFields` | Set form field values |
| `form.fullscreen` | Toggle fullscreen |
#### MCP (Client-side)
| Action | Description |
| ------------------- | ------------------ |
| `mcp.tool.call` | Execute MCP tool |
| `mcp.resource.read` | Read MCP resource |
| `mcp.resource.list` | List MCP resources |
| `mcp.prompt.get` | Get MCP prompt |
| `mcp.prompt.list` | List MCP prompts |
#### Event
| Action | Description |
| ------------ | ----------------- |
| `event.emit` | Emit custom event |
#### Confirm
| Action | Description |
| --------- | ------------------------ |
| `confirm` | Show confirmation dialog |
### Receiving Events from CUI
CUI can send messages to iframe via `web/sendMessage` event:
```typescript
// In your page script
window.addEventListener("message", (e) => {
const { type, message } = e.data;
switch (type) {
case "setup":
// Initial setup with theme, locale
break;
case "refresh":
// CUI requests page refresh
location.reload();
break;
case "data":
// CUI sends data update
handleDataUpdate(message);
break;
}
});
```
## Complete Example
### Page HTML (pages/result/index.html)
```html
<!DOCTYPE html>
<html>
<head>
<title>Result Page</title>
<script src="@assets/js/result.js"></script>
</head>
<body>
<div id="app"></div>
</body>
</html>
```
### Page Script (pages/result/result.ts)
```typescript
import { $Backend, Component, EventData } from "@yao/sui";
const self = this as Component;
// Helper: Send action to CUI parent
const sendAction = (name: string, payload?: any) => {
try {
window.parent.postMessage(
{ type: "action", message: { name, payload } },
window.location.origin
);
} catch (err) {
console.error("Failed to send action to parent:", err);
}
};
// Initialize message listener
function init() {
window.addEventListener("message", (e) => {
if (e.origin !== window.location.origin) return;
const { type, message } = e.data;
switch (type) {
case "setup":
// Apply theme, locale from CUI
document.documentElement.setAttribute("data-theme", message.theme);
break;
case "update":
// Handle data updates from CUI
console.log("Received update:", message);
break;
}
});
// Make helper available globally
(window as any).sendAction = sendAction;
}
init();
// Event handler: Show success notification
self.HandleSuccess = (event: Event, data: EventData) => {
sendAction("notify.success", { message: data.message || "Success!" });
};
// Event handler: Navigate to page
self.HandleNavigate = (event: Event, data: EventData) => {
sendAction("navigate", {
route: data.path,
title: data.title,
});
};
// Event handler: Close sidebar
self.HandleClose = () => {
sendAction("event.emit", { key: "app/closeSidebar", value: {} });
};
// Event handler: Call backend and display result
self.HandleQuery = async (event: Event, data: EventData) => {
try {
const result = await $Backend().Call("Query", data.id);
console.log(result);
} catch (error: any) {
sendAction("notify.error", { message: error.message });
}
};
```
## Triggering from Hooks
Open page in sidebar from agent hooks:
```typescript
function Next(ctx: agent.Context, payload: agent.Payload): agent.Next {
// Open result page in sidebar
ctx.Send({
type: "action",
props: {
name: "navigate",
payload: {
route: `/agents/my-assistant/result`,
title: "Results",
query: { id: resultId },
},
},
});
return null;
}
```
See [Pages](pages.md) for more details on triggering pages from hooks.
## Security Notes
1. **Same-origin only**: Messages are only processed from same-origin iframes
2. **Secure cookies**: Authentication uses HTTP-only cookies, no token in URL
3. **Validate messages**: Always validate message structure before processing

View file

@ -68,6 +68,16 @@ Map Yao Processes directly to MCP tools:
} }
``` ```
**HTTP (REST API)**
```json
{
"transport": "http",
"url": "https://mcp.example.com/api",
"authorization_token": "$ENV.TOKEN"
}
```
**SSE (Server-Sent Events)** **SSE (Server-Sent Events)**
```json ```json

View file

@ -148,16 +148,65 @@ function ApiGetData(request: Request): any {
**`/assistants/my-assistant/pages/index/index.ts`**: **`/assistants/my-assistant/pages/index/index.ts`**:
```typescript Frontend scripts can be written in two styles:
function index(component: HTMLElement) {
this.root = component;
this.store = new __sui_store(component);
this.handleClick = async (event: Event) => { **Style 1: Direct Code (Simple Pages)**
const data = await this.backend.ApiGetData({ id: 1 });
console.log(data); ```typescript
}; // Runs immediately when script loads
} document.addEventListener("DOMContentLoaded", () => {
const form = document.querySelector("#myForm") as HTMLFormElement;
form.addEventListener("submit", async (e) => {
e.preventDefault();
// Handle form submission
});
});
// Smooth scrolling for navigation
document.querySelectorAll('a[href^="#"]').forEach((anchor) => {
anchor.addEventListener("click", function (e) {
e.preventDefault();
const target = document.querySelector(this.getAttribute("href"));
target?.scrollIntoView({ behavior: "smooth" });
});
});
```
**Style 2: Component Pattern (Interactive Pages)**
```typescript
import { $Backend, Component, EventData } from "@yao/sui";
const self = this as Component;
// Event handler bound to s:on-click="HandleClick"
self.HandleClick = async (event: Event, data: EventData) => {
const result = await $Backend().Call("GetData", data.id);
console.log(result);
};
// Form submission handler
self.HandleSubmit = async (event: Event) => {
event.preventDefault();
const form = event.target as HTMLFormElement;
const formData = new FormData(form);
await $Backend().Call("Submit", Object.fromEntries(formData));
};
```
**Using Backend API:**
```typescript
import { $Backend, Yao } from "@yao/sui";
// Call backend method
const data = await $Backend().Call("MethodName", arg1, arg2);
// Direct API calls
const yao = new Yao();
const res = await yao.Get("/api/endpoint", { param: "value" });
await yao.Post("/api/endpoint", { data: "value" });
``` ```
### 6. Build and Run ### 6. Build and Run
@ -495,23 +544,103 @@ ctx.Send({
## Frontend API ## Frontend API
The SUI frontend SDK provides: ### Backend Calls
```typescript ```typescript
// Backend calls import { $Backend, Yao } from "@yao/sui";
const data = await this.backend.ApiMethodName(payload);
// State management // Call backend method defined in .backend.ts
this.store.Set("key", value); const data = await $Backend().Call("MethodName", arg1, arg2);
const value = this.store.Get("key");
// OpenAPI client (if using oauth guard) // Direct API calls
const response = await OpenAPI.Get("/api/endpoint"); const yao = new Yao();
await OpenAPI.Post("/api/endpoint", data); const res = await yao.Get("/api/endpoint", { query: "value" });
await yao.Post("/api/endpoint", { body: "data" });
``` ```
### State Management
```typescript
import { Component } from "@yao/sui";
const self = this as Component;
// Store values (per component instance)
self.store.Set("key", value);
const value = self.store.Get("key");
```
### Parent Communication (Iframe)
```typescript
// Helper: Send action to CUI parent
const sendAction = (name: string, payload?: any) => {
window.parent.postMessage(
{ type: "action", message: { name, payload } },
window.location.origin
);
};
// Usage
sendAction("notify.success", { message: "Done!" });
sendAction("navigate", {
route: "/agents/my-assistant/detail",
title: "Details",
});
// Receive messages from parent
window.addEventListener("message", (e) => {
if (e.origin !== window.location.origin) return;
const { type, message } = e.data;
if (type === "setup") {
document.documentElement.setAttribute("data-theme", message.theme);
}
});
```
## Iframe Communication
When pages are embedded in CUI via `/web/<assistant-id>/<page>`, they can communicate with the host:
### Receiving Context
```javascript
window.addEventListener("message", (e) => {
if (e.origin !== window.location.origin) return;
if (e.data.type === "setup") {
const { theme, locale } = e.data.message;
// Apply theme, set locale
document.documentElement.setAttribute("data-theme", theme);
}
});
```
### Sending Actions
```javascript
// Helper function
const sendAction = (name, payload) => {
window.parent.postMessage(
{ type: "action", message: { name, payload } },
window.location.origin
);
};
// Show notification
sendAction("notify.success", { message: "Done!" });
// Navigate to page
sendAction("navigate", {
route: "/agents/my-assistant/detail",
title: "Details",
});
```
See [Iframe Integration](iframe.md) for complete documentation.
## Related Documentation ## Related Documentation
- [Iframe Integration](iframe.md) - CUI iframe communication
- [SUI Template Syntax](../../sui/docs/template-syntax.md) - [SUI Template Syntax](../../sui/docs/template-syntax.md)
- [SUI Data Binding](../../sui/docs/data-binding.md) - [SUI Data Binding](../../sui/docs/data-binding.md)
- [SUI Components](../../sui/docs/components.md) - [SUI Components](../../sui/docs/components.md)

View file

@ -99,7 +99,20 @@ var WatchCmd = &cobra.Command{
return return
} }
go watch(root, func(event, name string) { // Get all directories to watch
watchDirs := []string{root}
if watchDirsProvider, ok := tmpl.(core.IWatchDirs); ok {
watchDirs = []string{}
watchRoot := cfg.DataRoot
if watchDirsProvider.GetWatchRoot() == "app" {
watchRoot = cfg.Root
}
for _, dir := range watchDirsProvider.GetWatchDirs() {
watchDirs = append(watchDirs, filepath.Join(watchRoot, dir))
}
}
go watchMultiple(watchDirs, func(event, name string) {
if event == "WRITE" || event == "CREATE" || event == "RENAME" { if event == "WRITE" || event == "CREATE" || event == "RENAME" {
// @Todo build single page and sync single asset file to public // @Todo build single page and sync single asset file to public
fmt.Print(color.WhiteString("Building... ")) fmt.Print(color.WhiteString("Building... "))
@ -134,6 +147,15 @@ var WatchCmd = &cobra.Command{
fmt.Println(color.WhiteString("Public Root: /public%s", publicRoot)) fmt.Println(color.WhiteString("Public Root: /public%s", publicRoot))
fmt.Println(color.WhiteString(" Template: %s", tmpl.GetRoot())) fmt.Println(color.WhiteString(" Template: %s", tmpl.GetRoot()))
fmt.Println(color.WhiteString(" Session: %s", strings.TrimLeft(data, "::"))) fmt.Println(color.WhiteString(" Session: %s", strings.TrimLeft(data, "::")))
fmt.Println(color.WhiteString("Watch Dirs:"))
for _, dir := range watchDirs {
// Show path relative to either app root or data root
displayDir := strings.TrimPrefix(dir, cfg.Root)
if displayDir == dir {
displayDir = strings.TrimPrefix(dir, cfg.DataRoot)
}
fmt.Println(color.WhiteString(" - %s", displayDir))
}
fmt.Println(color.WhiteString("-----------------------")) fmt.Println(color.WhiteString("-----------------------"))
fmt.Println(color.GreenString("Watching...")) fmt.Println(color.GreenString("Watching..."))
fmt.Println(color.GreenString("Press Ctrl+C to exit")) fmt.Println(color.GreenString("Press Ctrl+C to exit"))
@ -151,6 +173,116 @@ var WatchCmd = &cobra.Command{
}, },
} }
func watchMultiple(roots []string, handler func(event string, name string), interrupt chan uint8) error {
watcher, err := fsnotify.NewWatcher()
if err != nil {
return err
}
defer watcher.Close()
shutdown := make(chan bool, 1)
// Walk all root directories
watchedCount := 0
for _, root := range roots {
// Check if root exists
if _, err := os.Stat(root); os.IsNotExist(err) {
fmt.Println(color.YellowString("[Watch] Directory not found: %s", root))
continue
}
err = filepath.Walk(root, func(path string, info fs.FileInfo, err error) error {
if err != nil {
log.Warn("[Watch] Error accessing path %s: %v", path, err)
return nil // Skip this path and continue walking
}
if info.IsDir() {
if filepath.Base(path) == ".tmp" {
return filepath.SkipDir
}
err = watcher.Add(path)
if err != nil {
return err
}
watchedCount++
log.Info("[Watch] Watching: %s", path)
watched.Store(path, true)
}
return nil
})
if err != nil {
fmt.Println(color.YellowString("[Watch] Error walking root %s: %v", root, err))
}
}
fmt.Println(color.GreenString("[Watch] Total directories watched: %d", watchedCount))
go func() {
for {
select {
case <-shutdown:
log.Info("[Watch] handler exit")
return
case event, ok := <-watcher.Events:
if !ok {
interrupt <- 1
return
}
basname := filepath.Base(event.Name)
isdir := true
if strings.Contains(basname, ".") {
isdir = false
}
events := strings.Split(event.Op.String(), "|")
for _, eventType := range events {
// ADD / REMOVE Watching dir
if isdir {
switch eventType {
case "CREATE":
log.Info("[Watch] Watching: %s", event.Name)
watcher.Add(event.Name)
watched.Store(event.Name, true)
break
case "REMOVE":
log.Info("[Watch] Unwatching: %s", event.Name)
watcher.Remove(event.Name)
watched.Delete(event.Name)
break
}
continue
}
handler(eventType, event.Name)
log.Info("[Watch] %s %s", eventType, event.Name)
}
break
case err, ok := <-watcher.Errors:
if !ok {
interrupt <- 2
return
}
log.Error("[Watch] Error: %s", err.Error())
break
}
}
}()
for {
select {
case code := <-interrupt:
shutdown <- true
log.Info("[Watch] Exit(%d)", code)
fmt.Println(color.YellowString("[Watch] Exit(%d)", code))
return nil
}
}
}
func watch(root string, handler func(event string, name string), interrupt chan uint8) error { func watch(root string, handler func(event string, name string), interrupt chan uint8) error {
watcher, err := fsnotify.NewWatcher() watcher, err := fsnotify.NewWatcher()
if err != nil { if err != nil {
@ -160,6 +292,10 @@ func watch(root string, handler func(event string, name string), interrupt chan
shutdown := make(chan bool, 1) shutdown := make(chan bool, 1)
err = filepath.Walk(root, func(path string, info fs.FileInfo, err error) error { err = filepath.Walk(root, func(path string, info fs.FileInfo, err error) error {
if err != nil {
log.Warn("[Watch] Error accessing path %s: %v", path, err)
return nil // Skip this path and continue walking
}
if info.IsDir() { if info.IsDir() {
if filepath.Base(path) == ".tmp" { if filepath.Base(path) == ".tmp" {
return filepath.SkipDir return filepath.SkipDir

View file

@ -18,16 +18,18 @@ SUI is a full-stack web development framework that allows you to create web appl
``` ```
/templates/<template_name>/ /templates/<template_name>/
├── __document.html # Global document template ├── __document.html # Global document template
├── __assets/ # Static assets ├── __data.json # Global data (accessible via $global)
├── __assets/ # Static assets (reference via @assets/)
├── __locales/ # Locale files ├── __locales/ # Locale files
└── <route>/ # Pages └── pages/ # All pages go here
└── <page>/ └── <page>/ # Route = folder name (can be nested)
├── <page>.html # HTML template ├── <page>.html # HTML template (filename must match folder)
├── <page>.css # Styles ├── <page>.css # Styles
├── <page>.ts # Frontend script ├── <page>.ts # Frontend script
├── <page>.json # Data configuration ├── <page>.json # Data configuration
├── <page>.config # Page configuration ├── <page>.config # Page configuration
└── <page>.backend.ts # Backend script ├── <page>.backend.ts # Backend script
└── __locales/ # Page-level locale files
``` ```
### Basic Page ### Basic Page
@ -75,7 +77,7 @@ yao sui watch agent
- [Data Binding](docs/data-binding.md) - Built-in variables and functions - [Data Binding](docs/data-binding.md) - Built-in variables and functions
- [Event Handling](docs/event-handling.md) - Event binding and state management - [Event Handling](docs/event-handling.md) - Event binding and state management
- [Internationalization](docs/i18n.md) - Translation and localization - [Internationalization](docs/i18n.md) - Translation and localization
- [Frontend API](docs/frontend-api.md) - Component query, backend calls, render API - [Frontend API](docs/frontend-api.md) - Component query, backend calls, render API, CUI integration
- [Agent SUI](docs/agent-sui.md) - AI Agent application setup - [Agent SUI](docs/agent-sui.md) - AI Agent application setup
## Agent SUI ## Agent SUI
@ -85,13 +87,16 @@ Agent SUI is designed for AI Agent applications with automatic page loading from
``` ```
<app>/ <app>/
├── agent/ ├── agent/
│ └── template/ # Agent SUI template │ └── template/ # Agent SUI template (shared)
│ ├── __document.html │ ├── __document.html
│ ├── __data.json
│ ├── __assets/ │ ├── __assets/
│ └── pages/ │ └── pages/ # Global pages (401, 404, etc.)
│ └── <page>/
└── assistants/ └── assistants/
└── <name>/ └── <name>/
└── pages/ # Assistant pages └── pages/ # Assistant pages → /agents/<name>/<route>
└── <page>/
``` ```
Build with: `yao sui build agent` Build with: `yao sui build agent`

View file

@ -118,3 +118,13 @@ type IComponent interface {
Load() error Load() error
Source() string Source() string
} }
// IWatchDirs is an optional interface for templates that need to watch multiple directories
type IWatchDirs interface {
// GetWatchDirs returns all directories that should be watched for changes
// The returned paths are relative to the application source root (not data root)
GetWatchDirs() []string
// GetWatchRoot returns the root directory for watch paths
// Returns "app" for application source root, "data" for data root
GetWatchRoot() string
}

View file

@ -11,31 +11,28 @@ Agent SUI is a special SUI configuration designed for AI Agent applications. It
│ └── template/ # Agent SUI template directory │ └── template/ # Agent SUI template directory
│ ├── template.json # Optional template configuration │ ├── template.json # Optional template configuration
│ ├── __document.html # Global document template │ ├── __document.html # Global document template
│ ├── __data.json # Global data │ ├── __data.json # Global data (accessible via $global)
│ ├── __assets/ # Global assets (CSS, JS, images) │ ├── __assets/ # Global assets (reference via @assets/)
│ │ ├── css/ │ │ ├── css/
│ │ ├── js/ │ │ ├── js/
│ │ └── images/ │ │ └── images/
│ ├── pages/ # Global agent pages (login, error, etc.) │ ├── __locales/ # Global locale files
│ │ └── login/ │ └── pages/ # Global pages (401, 404, login, etc.)
│ │ └── login.html │ └── <page>/ # Route = folder name
│ └── __locales/ # Internationalization │ ├── <page>.html
│ ├── <page>.css
│ ├── <page>.ts
│ └── __locales/ # Page-level locale files
└── assistants/ # Assistants directory └── assistants/ # Assistants directory
├── demo/ # Assistant: demo └── <name>/ # Assistant
│ ├── package.yao # Assistant configuration ├── package.yao # Assistant configuration
│ └── pages/ # Assistant-specific pages └── pages/ # Assistant pages → /agents/<name>/<route>
│ ├── index/ └── <page>/ # Route = folder name (can be nested)
│ │ ├── index.html ├── <page>.html
│ │ ├── index.css ├── <page>.css
│ │ └── index.ts ├── <page>.ts
│ └── __assets/ # Optional assistant-specific assets └── __locales/
└── another/ # Assistant: another
├── package.yao
└── pages/
└── settings/
└── settings.html
``` ```
## Route Mapping ## Route Mapping
@ -237,16 +234,94 @@ Use standard SUI template syntax:
## Frontend Script ## Frontend Script
Frontend scripts can be written in two styles:
### Direct Style (Simple Pages)
```typescript
// Runs immediately when script loads
document.addEventListener("DOMContentLoaded", () => {
const form = document.querySelector("#myForm") as HTMLFormElement;
form.addEventListener("submit", async (e) => {
e.preventDefault();
// Handle submission
});
});
// Smooth scrolling
document.querySelectorAll('a[href^="#"]').forEach((anchor) => {
anchor.addEventListener("click", function (e) {
e.preventDefault();
const target = document.querySelector(this.getAttribute("href"));
target?.scrollIntoView({ behavior: "smooth" });
});
});
```
### Component Style (Interactive Pages)
**`/assistants/demo/pages/index/index.ts`**: **`/assistants/demo/pages/index/index.ts`**:
```typescript ```typescript
function index(component: HTMLElement) { import { $Backend, Component, EventData } from "@yao/sui";
this.root = component;
this.store = new __sui_store(component);
this.handleClick = async (event: Event) => { const self = this as Component;
const data = await this.backend.ApiGetData();
console.log(data); // Event handler bound to s:on-click="HandleClick"
}; self.HandleClick = async (event: Event, data: EventData) => {
} const result = await $Backend().Call("GetData", data.id);
console.log(result);
};
// Form submission
self.HandleSubmit = async (event: Event) => {
event.preventDefault();
const form = event.target as HTMLFormElement;
const formData = new FormData(form);
await $Backend().Call("Submit", Object.fromEntries(formData));
};
``` ```
## CUI Integration
When Agent SUI pages are embedded in CUI via `/web/` routes, they can communicate with the CUI host.
### Receiving Context
```typescript
window.addEventListener("message", (e) => {
if (e.origin !== window.location.origin) return;
if (e.data.type === "setup") {
const { theme, locale } = e.data.message;
document.documentElement.setAttribute("data-theme", theme);
}
});
```
### Sending Actions
```typescript
// Helper function
const sendAction = (name: string, payload?: any) => {
window.parent.postMessage(
{ type: "action", message: { name, payload } },
window.location.origin
);
};
// Show notification
sendAction("notify.success", { message: "Done!" });
// Navigate
sendAction("navigate", {
route: "/agents/demo/detail",
title: "Details",
});
// Close sidebar
sendAction("event.emit", { key: "app/closeSidebar", value: {} });
```
See [Frontend API - CUI Integration](frontend-api.md#cui-integration) for complete documentation.

View file

@ -14,6 +14,12 @@ Backend scripts use the naming convention `<page>.backend.ts` or `<page>.backend
└── list.backend.ts # Backend script └── list.backend.ts # Backend script
``` ```
## Important Notes
> **⚠️ No ES Module Exports**: Backend scripts do NOT support ES Module `export` syntax. Simply define functions directly - they will be automatically available based on naming conventions.
> **⚠️ `$param` Not Available**: Unlike HTML templates, you cannot use `$param.id` directly in backend scripts. Route parameters must be accessed via the `request.params` object passed to your functions.
## BeforeRender ## BeforeRender
The `BeforeRender` function is called before the page is rendered: The `BeforeRender` function is called before the page is rendered:
@ -58,20 +64,20 @@ function BeforeRender(request: Request): Record<string, any> {
## API Methods ## API Methods
Functions prefixed with `Api` are exposed as callable endpoints: Functions prefixed with `Api` are exposed as callable endpoints. The backend automatically adds the `Api` prefix, so frontend calls use the method name without the prefix:
```typescript ```typescript
// Callable from frontend as: this.backend.ApiGetUsers() // Callable from frontend as: $Backend().Call("GetUsers")
function ApiGetUsers(request: Request): any[] { function ApiGetUsers(request: Request): any[] {
return Process("models.user.Get", {}); return Process("models.user.Get", {});
} }
// Callable from frontend as: this.backend.ApiCreateUser(name, email) // Callable from frontend as: $Backend().Call("CreateUser", name, email)
function ApiCreateUser(name: string, email: string, request: Request): any { function ApiCreateUser(name: string, email: string, request: Request): any {
return Process("models.user.Create", { name, email }); return Process("models.user.Create", { name, email });
} }
// Callable from frontend as: this.backend.ApiDeleteUser(id) // Callable from frontend as: $Backend().Call("DeleteUser", id)
function ApiDeleteUser(id: string, request: Request): boolean { function ApiDeleteUser(id: string, request: Request): boolean {
Process("models.user.Delete", id); Process("models.user.Delete", id);
return true; return true;
@ -81,19 +87,20 @@ function ApiDeleteUser(id: string, request: Request): boolean {
### Calling from Frontend ### Calling from Frontend
```typescript ```typescript
function Page(component: HTMLElement) { import { $Backend, Component } from "@yao/sui";
this.root = component;
this.loadUsers = async () => { const self = this as Component;
const users = await this.backend.ApiGetUsers();
self.LoadUsers = async () => {
// Call "ApiGetUsers" in backend script (without "Api" prefix)
const users = await $Backend().Call("GetUsers");
console.log(users); console.log(users);
}; };
this.createUser = async () => { self.CreateUser = async () => {
const user = await this.backend.ApiCreateUser("John", "john@example.com"); const user = await $Backend().Call("CreateUser", "John", "john@example.com");
console.log("Created:", user); console.log("Created:", user);
}; };
}
``` ```
## Constants ## Constants
@ -115,10 +122,12 @@ const __sui_constants = {
Access in frontend: Access in frontend:
```typescript ```typescript
function Page(component: HTMLElement) { import { Component } from "@yao/sui";
console.log(this.constants.API_URL); // "/api/v1"
console.log(this.constants.MAX_ITEMS); // 100 const self = this as Component;
}
console.log(self.constants.API_URL); // "/api/v1"
console.log(self.constants.MAX_ITEMS); // 100
``` ```
## Helpers ## Helpers
@ -147,11 +156,13 @@ function validateEmail(email: string): boolean {
Access in frontend: Access in frontend:
```typescript ```typescript
function Page(component: HTMLElement) { import { Component } from "@yao/sui";
const formatted = this.helpers.formatDate("2024-01-15");
const price = this.helpers.formatCurrency(99.99); const self = this as Component;
const isValid = this.helpers.validateEmail("test@example.com");
} const formatted = self.helpers.formatDate("2024-01-15");
const price = self.helpers.formatCurrency(99.99);
const isValid = self.helpers.validateEmail("test@example.com");
``` ```
## Request Object ## Request Object
@ -243,6 +254,78 @@ function ApiUpdateUser(id: string, data: any, request: Request): any {
} }
``` ```
## Data Binding Methods (Called from `.json`)
In addition to `Api` prefixed methods (for frontend calls) and `BeforeRender`, you can define methods that are called directly from the page's `.json` configuration using the `@MethodName` syntax.
### Naming Convention
| Call Source | Function Name | Example Call |
| ---------------------------- | --------------- | ------------------------------- |
| Frontend `$Backend().Call()` | `ApiMethodName` | `$Backend().Call("MethodName")` |
| `.json` data binding | `MethodName` | `"$data": "@MethodName"` |
| Before render | `BeforeRender` | Automatic |
### How It Works
When using `@MethodName` in `.json`, SUI calls the backend function with the **Request object appended as the last argument**:
```typescript
// In .json: "$record": "@GetRecord"
// SUI internally calls: GetRecord(request)
function GetRecord(request: Request): any {
// Access route parameters via request.params
const id = request.params.id;
return Process("models.record.Find", id);
}
```
### With Additional Arguments
You can also pass arguments from `.json`:
```json
{
"$items": {
"process": "@GetItems",
"args": ["category_a", 10]
}
}
```
```typescript
// SUI calls: GetItems("category_a", 10, request)
// Arguments from .json come first, request is appended last
function GetItems(category: string, limit: number, request: Request): any[] {
return Process("models.item.Get", {
wheres: [{ column: "category", value: category }],
limit: limit,
});
}
```
### Common Pitfall: Accessing Route Parameters
**Wrong** - `$param` is not available in backend scripts:
```typescript
function GetRecord(): any {
const id = $param.id; // ReferenceError: $param is not defined
return Process("models.record.Find", id);
}
```
**Correct** - Use `request.params`:
```typescript
function GetRecord(request: Request): any {
const id = request.params.id; // Works!
return Process("models.record.Find", id);
}
```
## Complete Example ## Complete Example
**`/users/profile/profile.backend.ts`**: **`/users/profile/profile.backend.ts`**:

View file

@ -43,11 +43,13 @@ A component is just a page with a single root element:
**`/card/card.ts`**: **`/card/card.ts`**:
```typescript ```typescript
function card(component: HTMLElement) { import { Component } from "@yao/sui";
this.root = component;
this.store = new __sui_store(component); const self = this as Component;
this.props = new __sui_props(component);
} // self.root - Root element
// self.store - Data store
// self.props - Props from attributes
``` ```
## Using Components ## Using Components
@ -94,17 +96,16 @@ Props are passed as attributes:
Access props in the component script: Access props in the component script:
```typescript ```typescript
function userCard(component: HTMLElement) { import { Component } from "@yao/sui";
this.root = component;
this.props = new __sui_props(component);
// Get single prop const self = this as Component;
const name = this.props.Get("name");
// Get all props // Get single prop
const allProps = this.props.List(); const name = self.props.Get("name");
// { name: "John", email: "john@example.com", avatar: "...", role: "admin" }
} // Get all props
const allProps = self.props.List();
// { name: "John", email: "john@example.com", avatar: "...", role: "admin" }
``` ```
Access props in backend script: Access props in backend script:
@ -203,75 +204,68 @@ Use `<slot name="xxx">` for multiple content areas:
### Structure ### Structure
```typescript ```typescript
function componentName(component: HTMLElement) { import { $Backend, Component, EventData } from "@yao/sui";
// Root element
this.root = component;
// Data store (data-* attributes) const self = this as Component;
this.store = new __sui_store(component);
// Props (passed attributes) // self.root - Root element (HTMLElement)
this.props = new __sui_props(component); // self.store - Data store (data-* attributes)
// self.props - Props (passed attributes)
// self.state - State management
// State management // State watchers
this.state = new __sui_state(this); self.watch = {
propertyName: (value: any, state: any) => {
// Backend API
this.backend = {
ApiMethod: async (...args) => {
/* ... */
},
};
// State watchers
this.watch = {
propertyName: (value, state) => {
// React to state changes // React to state changes
}, },
}; };
// Methods // Event handlers (bound to s:on-click="HandleClick")
this.handleClick = (event, data, context) => { self.HandleClick = async (event: Event, data: EventData) => {
// Handle events const result = await $Backend().Call("Method", data.id);
}; // Handle result
} };
``` ```
### Store API ### Store API
```typescript ```typescript
import { Component } from "@yao/sui";
const self = this as Component;
// String data // String data
this.store.Get("key"); self.store.Get("key");
this.store.Set("key", "value"); self.store.Set("key", "value");
// JSON data // JSON data
this.store.GetJSON("items"); self.store.GetJSON("items");
this.store.SetJSON("items", [{ id: 1 }]); self.store.SetJSON("items", [{ id: 1 }]);
// Component data (from BeforeRender) // Component data (from BeforeRender)
this.store.GetData(); self.store.GetData();
``` ```
### Props API ### Props API
```typescript ```typescript
// Get single prop // Get single prop
const value = this.props.Get("propName"); const value = self.props.Get("propName");
// Get all props // Get all props
const props = this.props.List(); const props = self.props.List();
``` ```
### State API ### State API
```typescript ```typescript
// Set state (triggers watchers) // Set state (triggers watchers)
this.state.Set("count", 10); self.state.Set("count", 10);
// Watch state changes // Watch state changes
this.watch = { self.watch = {
count: (value, state) => { count: (value: number, state: any) => {
this.root.querySelector(".count").textContent = value; self.root.querySelector(".count")!.textContent = String(value);
// state.stopPropagation(); // Prevent bubbling to parent // state.stopPropagation(); // Prevent bubbling to parent
}, },
}; };
@ -346,7 +340,6 @@ Component CSS is automatically scoped using namespace attributes:
## Important Notes ## Important Notes
1. **Single Root Element**: Components must have exactly one root element 1. **Single Root Element**: Components must have exactly one root element
2. **Route as Identifier**: The page route becomes the component name (e.g., `/card``card()`) 2. **Scoped Styles**: CSS is automatically scoped to prevent conflicts
3. **Scoped Styles**: CSS is automatically scoped to prevent conflicts 3. **Recursive Prevention**: SUI detects and prevents recursive component inclusion
4. **Recursive Prevention**: SUI detects and prevents recursive component inclusion 4. **Component Pattern**: Use `const self = this as Component` to access component APIs
5. **Script Naming**: Function name is derived from the route path

View file

@ -150,6 +150,44 @@ Note: `$header` is only available in JSON configuration, not in HTML templates.
} }
``` ```
### Calling Backend Script Methods
Use the `@MethodName` syntax to call functions defined in the page's `.backend.ts` file:
```json
{
"$record": "@GetRecord",
"$items": {
"process": "@GetItems",
"args": ["active", 20]
}
}
```
**Important**: The Request object is automatically appended as the **last argument** to the backend function.
**`page.backend.ts`**:
```typescript
// Called from .json as: "$record": "@GetRecord"
// Receives: (request)
function GetRecord(request: Request): any {
const id = request.params.id; // Access route params via request
return Process("models.record.Find", id);
}
// Called from .json as: { "process": "@GetItems", "args": ["active", 20] }
// Receives: ("active", 20, request)
function GetItems(status: string, limit: number, request: Request): any[] {
return Process("models.item.Get", {
wheres: [{ column: "status", value: status }],
limit: limit,
});
}
```
> **⚠️ Common Mistake**: You cannot use `$param.id` directly in backend scripts. The `$param`, `$query`, etc. variables are only available in HTML templates and `.json` configurations. In backend scripts, access these values via the `request` parameter: `request.params.id`, `request.query.search`, etc.
## Built-in Functions ## Built-in Functions
### P\_() - Process Call ### P\_() - Process Call

View file

@ -76,23 +76,21 @@ Use `s:json-*` to pass complex data:
### Handler Signature ### Handler Signature
```typescript ```typescript
function Page(component: HTMLElement) { import { $Backend, Component, EventData } from "@yao/sui";
this.root = component;
this.handleClick = (event: Event, data: any, context: EventContext) => { const self = this as Component;
self.HandleClick = (event: Event, data: EventData) => {
// event - The DOM event // event - The DOM event
// data - Combined data from s:data-* and s:json-* // data - Combined data from s:data-* and s:json-*
// context - Event context with element references };
};
}
``` ```
### EventContext ### EventData
```typescript ```typescript
interface EventContext { interface EventData {
rootElement: HTMLElement; // Component root element [key: string]: any; // Data from s:data-* and s:json-* attributes
targetElement: HTMLElement; // Element that triggered the event
} }
``` ```
@ -103,7 +101,7 @@ interface EventContext {
<div s:for="{{ items }}" s:for-item="item"> <div s:for="{{ items }}" s:for-item="item">
<span>{{ item.name }}</span> <span>{{ item.name }}</span>
<button <button
s:on-click="deleteItem" s:on-click="DeleteItem"
s:data-id="{{ item.id }}" s:data-id="{{ item.id }}"
s:json-item="{{ item }}" s:json-item="{{ item }}"
> >
@ -114,19 +112,19 @@ interface EventContext {
``` ```
```typescript ```typescript
function ItemList(component: HTMLElement) { import { $Backend, Component, EventData } from "@yao/sui";
this.root = component;
this.deleteItem = async (event: Event, data: any, context: EventContext) => { const self = this as Component;
self.DeleteItem = async (event: Event, data: EventData) => {
const id = data.id; // String from s:data-id const id = data.id; // String from s:data-id
const item = data.item; // Object from s:json-item const item = data.item; // Object from s:json-item
if (confirm(`Delete ${item.name}?`)) { if (confirm(`Delete ${item.name}?`)) {
await this.backend.ApiDeleteItem(id); await $Backend().Call("DeleteItem", id);
context.targetElement.closest(".item").remove(); (event.target as HTMLElement).closest(".item")?.remove();
} }
}; };
}
``` ```
## State Management ## State Management
@ -134,13 +132,12 @@ function ItemList(component: HTMLElement) {
### State Object ### State Object
```typescript ```typescript
function Counter(component: HTMLElement) { import { Component } from "@yao/sui";
this.root = component;
this.state = new __sui_state(this);
// Initial state const self = this as Component;
this.state.Set("count", 0);
} // Initial state
self.state.Set("count", 0);
``` ```
### State Watchers ### State Watchers
@ -148,26 +145,25 @@ function Counter(component: HTMLElement) {
React to state changes with watchers: React to state changes with watchers:
```typescript ```typescript
function Counter(component: HTMLElement) { import { Component } from "@yao/sui";
this.root = component;
this.state = new __sui_state(this);
// Define watchers const self = this as Component;
this.watch = {
count: (value: number, state: State) => { // Define watchers
this.root.querySelector(".count").textContent = value; self.watch = {
count: (value: number) => {
self.root.querySelector(".count")!.textContent = String(value);
}, },
items: (value: any[], state: State) => { items: (value: any[]) => {
this.renderItems(value); renderItems(value);
}, },
}; };
this.increment = () => { self.Increment = () => {
const count = this.state.Get("count") || 0; const count = self.state.Get("count") || 0;
this.state.Set("count", count + 1); // Triggers watcher self.state.Set("count", count + 1); // Triggers watcher
}; };
}
``` ```
### Stop Propagation ### Stop Propagation
@ -175,10 +171,10 @@ function Counter(component: HTMLElement) {
Prevent state changes from bubbling to parent: Prevent state changes from bubbling to parent:
```typescript ```typescript
this.watch = { self.watch = {
localState: (value: any, state: State) => { localState: (value: any, state: any) => {
// Handle locally // Handle locally
this.updateUI(value); updateUI(value);
// Stop propagation to parent components // Stop propagation to parent components
state.stopPropagation(); state.stopPropagation();
@ -193,18 +189,17 @@ Store manages `data-*` attributes on the component:
### Basic Usage ### Basic Usage
```typescript ```typescript
function Card(component: HTMLElement) { import { Component } from "@yao/sui";
this.root = component;
this.store = new __sui_store(component);
// Get/Set string values const self = this as Component;
const id = this.store.Get("id");
this.store.Set("id", "123");
// Get/Set JSON values // Get/Set string values
const items = this.store.GetJSON("items"); const id = self.store.Get("id");
this.store.SetJSON("items", [{ id: 1 }, { id: 2 }]); self.store.Set("id", "123");
}
// Get/Set JSON values
const items = self.store.GetJSON("items");
self.store.SetJSON("items", [{ id: 1 }, { id: 2 }]);
``` ```
### Component Data ### Component Data
@ -213,7 +208,7 @@ Get data from BeforeRender:
```typescript ```typescript
// Backend returns: { user: { name: "John" }, settings: {...} } // Backend returns: { user: { name: "John" }, settings: {...} }
const data = this.store.GetData(); const data = self.store.GetData();
console.log(data.user.name); // "John" console.log(data.user.name); // "John"
``` ```
@ -222,30 +217,30 @@ console.log(data.user.name); // "John"
### Emit Events ### Emit Events
```typescript ```typescript
function ItemCard(component: HTMLElement) { import { Component } from "@yao/sui";
this.root = component;
this.selectItem = () => { const self = this as Component;
const item = this.store.GetJSON("item");
self.SelectItem = () => {
const item = self.store.GetJSON("item");
// Emit custom event // Emit custom event
this.emit("item:selected", { item }); self.emit("item:selected", { item });
}; };
}
``` ```
### Listen to Events ### Listen to Events
```typescript ```typescript
function ItemList(component: HTMLElement) { import { Component } from "@yao/sui";
this.root = component;
// Listen to child events const self = this as Component;
this.root.addEventListener("item:selected", (e: CustomEvent) => {
// Listen to child events
self.root.addEventListener("item:selected", (e: CustomEvent) => {
const { item } = e.detail; const { item } = e.detail;
console.log("Selected:", item); console.log("Selected:", item);
}); });
}
``` ```
### State Change Events ### State Change Events
@ -253,14 +248,14 @@ function ItemList(component: HTMLElement) {
Parent components can listen to state changes: Parent components can listen to state changes:
```typescript ```typescript
function Parent(component: HTMLElement) { import { Component } from "@yao/sui";
this.root = component;
this.root.addEventListener("state:change", (e: CustomEvent) => { const self = this as Component;
self.root.addEventListener("state:change", (e: CustomEvent) => {
const { key, value, target } = e.detail; const { key, value, target } = e.detail;
console.log(`State ${key} changed to ${value} in`, target); console.log(`State ${key} changed to ${value} in`, target);
}); });
}
``` ```
## Form Handling ## Form Handling
@ -268,7 +263,7 @@ function Parent(component: HTMLElement) {
### Form Submit ### Form Submit
```html ```html
<form s:on-submit="handleSubmit"> <form s:on-submit="HandleSubmit">
<input name="email" type="email" required /> <input name="email" type="email" required />
<input name="password" type="password" required /> <input name="password" type="password" required />
<button type="submit">Login</button> <button type="submit">Login</button>
@ -276,10 +271,11 @@ function Parent(component: HTMLElement) {
``` ```
```typescript ```typescript
function LoginForm(component: HTMLElement) { import { $Backend, Component } from "@yao/sui";
this.root = component;
this.handleSubmit = async (event: Event) => { const self = this as Component;
self.HandleSubmit = async (event: Event) => {
event.preventDefault(); event.preventDefault();
const form = event.target as HTMLFormElement; const form = event.target as HTMLFormElement;
@ -289,64 +285,63 @@ function LoginForm(component: HTMLElement) {
const password = formData.get("password"); const password = formData.get("password");
try { try {
await this.backend.ApiLogin(email, password); await $Backend().Call("Login", email, password);
window.location.href = "/dashboard"; window.location.href = "/dashboard";
} catch (error) { } catch (error) {
alert("Login failed"); alert("Login failed");
} }
}; };
}
``` ```
### Input Binding ### Input Binding
```html ```html
<input type="text" s:on-input="handleInput" s:data-field="name" /> <input type="text" s:on-input="HandleInput" s:data-field="name" />
``` ```
```typescript ```typescript
function Form(component: HTMLElement) { import { Component, EventData } from "@yao/sui";
this.root = component;
this.formData = {};
this.handleInput = (event: Event, data: any) => { const self = this as Component;
const formData: Record<string, string> = {};
self.HandleInput = (event: Event, data: EventData) => {
const input = event.target as HTMLInputElement; const input = event.target as HTMLInputElement;
this.formData[data.field] = input.value; formData[data.field] = input.value;
}; };
}
``` ```
## Keyboard Events ## Keyboard Events
```html ```html
<input s:on-keydown="handleKeydown" s:on-keyup="handleKeyup" /> <input s:on-keydown="HandleKeydown" s:on-keyup="HandleKeyup" />
``` ```
```typescript ```typescript
function Search(component: HTMLElement) { import { Component } from "@yao/sui";
this.root = component;
this.handleKeydown = (event: KeyboardEvent) => { const self = this as Component;
self.HandleKeydown = (event: KeyboardEvent) => {
if (event.key === "Enter") { if (event.key === "Enter") {
this.search(); search();
} }
if (event.key === "Escape") { if (event.key === "Escape") {
this.clear(); clear();
} }
}; };
}
``` ```
## Complete Example ## Complete Example
```html ```html
<div class="todo-app"> <div class="todo-app">
<form s:on-submit="addTodo"> <form s:on-submit="AddTodo">
<input <input
name="title" name="title"
placeholder="Add todo..." placeholder="Add todo..."
s:on-keydown="handleKeydown" s:on-keydown="HandleKeydown"
/> />
<button type="submit">Add</button> <button type="submit">Add</button>
</form> </form>
@ -355,53 +350,51 @@ function Search(component: HTMLElement) {
<li s:for="{{ todos }}" s:for-item="todo"> <li s:for="{{ todos }}" s:for-item="todo">
<input <input
type="checkbox" type="checkbox"
s:on-change="toggleTodo" s:on-change="ToggleTodo"
s:data-id="{{ todo.id }}" s:data-id="{{ todo.id }}"
s:attr-checked="{{ todo.completed }}" s:attr-checked="{{ todo.completed }}"
/> />
<span class="{{ todo.completed ? 'completed' : '' }}"> <span class="{{ todo.completed ? 'completed' : '' }}">
{{ todo.title }} {{ todo.title }}
</span> </span>
<button s:on-click="deleteTodo" s:data-id="{{ todo.id }}">×</button> <button s:on-click="DeleteTodo" s:data-id="{{ todo.id }}">×</button>
</li> </li>
</ul> </ul>
</div> </div>
``` ```
```typescript ```typescript
function TodoApp(component: HTMLElement) { import { $Backend, Component, EventData } from "@yao/sui";
this.root = component;
this.state = new __sui_state(this);
this.store = new __sui_store(component);
this.watch = { const self = this as Component;
self.watch = {
todos: (todos: any[]) => { todos: (todos: any[]) => {
this.render("todoList", { todos }); self.render("todoList", { todos });
}, },
}; };
this.addTodo = async (event: Event) => { self.AddTodo = async (event: Event) => {
event.preventDefault(); event.preventDefault();
const form = event.target as HTMLFormElement; const form = event.target as HTMLFormElement;
const input = form.querySelector("input") as HTMLInputElement; const input = form.querySelector("input") as HTMLInputElement;
if (input.value.trim()) { if (input.value.trim()) {
const todo = await this.backend.ApiAddTodo(input.value); const todo = await $Backend().Call("AddTodo", input.value);
const todos = this.state.Get("todos") || []; const todos = self.state.Get("todos") || [];
this.state.Set("todos", [...todos, todo]); self.state.Set("todos", [...todos, todo]);
input.value = ""; input.value = "";
} }
}; };
this.toggleTodo = async (event: Event, data: any) => { self.ToggleTodo = async (event: Event, data: EventData) => {
const checkbox = event.target as HTMLInputElement; const checkbox = event.target as HTMLInputElement;
await this.backend.ApiToggleTodo(data.id, checkbox.checked); await $Backend().Call("ToggleTodo", data.id, checkbox.checked);
}; };
this.deleteTodo = async (event: Event, data: any) => { self.DeleteTodo = async (event: Event, data: EventData) => {
await this.backend.ApiDeleteTodo(data.id); await $Backend().Call("DeleteTodo", data.id);
const todos = this.state.Get("todos").filter((t) => t.id !== data.id); const todos = self.state.Get("todos").filter((t: any) => t.id !== data.id);
this.state.Set("todos", todos); self.state.Set("todos", todos);
}; };
}
``` ```

View file

@ -38,29 +38,28 @@ const items = component.queryAll(".item"); // Returns NodeList
## Backend Calls ## Backend Calls
### Via Component ### Via $Backend
The backend automatically adds the `Api` prefix to method names, so you call without the prefix:
```typescript ```typescript
function Page(component: HTMLElement) { import { $Backend } from "@yao/sui";
this.root = component;
this.loadData = async () => { // Call backend API methods (backend functions are ApiGetUsers, ApiGetUser, ApiCreateUser)
// Call backend API methods const users = await $Backend().Call("GetUsers");
const users = await this.backend.ApiGetUsers(); const user = await $Backend().Call("GetUser", 123);
const user = await this.backend.ApiGetUser(123); const result = await $Backend().Call("CreateUser", "John", "john@example.com");
const result = await this.backend.ApiCreateUser("John", "john@example.com");
};
}
``` ```
### Direct Call ### Direct Call
```typescript ```typescript
// __sui_backend_call(route, headers, method, ...args) // __sui_backend_call(route, headers, method, ...args)
// Note: method name here also gets Api prefix added automatically
const result = await __sui_backend_call( const result = await __sui_backend_call(
"/users/list", // Page route "/users/list", // Page route
{ "X-Custom-Header": "value" }, // Custom headers { "X-Custom-Header": "value" }, // Custom headers
"ApiGetUsers", // Method name "GetUsers", // Method name (backend has ApiGetUsers)
{ page: 1, limit: 10 } // Arguments { page: 1, limit: 10 } // Arguments
); );
``` ```
@ -80,22 +79,22 @@ Define render targets in HTML:
### Render Method ### Render Method
```typescript ```typescript
function Page(component: HTMLElement) { import { $Backend, Component } from "@yao/sui";
this.root = component;
this.refreshUsers = async () => { const self = this as Component;
const users = await this.backend.ApiGetUsers();
self.RefreshUsers = async () => {
const users = await $Backend().Call("GetUsers");
// Render with data // Render with data
await this.render("userList", { users }); await self.render("userList", { users });
}; };
}
``` ```
### Render Options ### Render Options
```typescript ```typescript
await this.render("targetName", data, { await self.render("targetName", data, {
replace: true, // Replace content (default: true) replace: true, // Replace content (default: true)
showLoader: true, // Show loading indicator showLoader: true, // Show loading indicator
withPageData: true, // Include page data in render context withPageData: true, // Include page data in render context
@ -292,32 +291,32 @@ api.ClearTokens();
### Emit ### Emit
```typescript ```typescript
function Card(component: HTMLElement) { import { Component } from "@yao/sui";
this.root = component;
this.select = () => { const self = this as Component;
this.emit("card:selected", { id: this.store.Get("id") });
}; self.Select = () => {
} self.emit("card:selected", { id: self.store.Get("id") });
};
``` ```
### Listen ### Listen
```typescript ```typescript
function CardList(component: HTMLElement) { import { Component } from "@yao/sui";
this.root = component;
this.root.addEventListener("card:selected", (e: CustomEvent) => { const self = this as Component;
self.root.addEventListener("card:selected", (e: CustomEvent) => {
console.log("Selected:", e.detail.id); console.log("Selected:", e.detail.id);
}); });
}
``` ```
### State Change Events ### State Change Events
```typescript ```typescript
// Listen to child state changes // Listen to child state changes
this.root.addEventListener("state:change", (e: CustomEvent) => { self.root.addEventListener("state:change", (e: CustomEvent) => {
const { key, value, target } = e.detail; const { key, value, target } = e.detail;
console.log(`${key} = ${value}`); console.log(`${key} = ${value}`);
}); });
@ -326,54 +325,200 @@ this.root.addEventListener("state:change", (e: CustomEvent) => {
## Complete Example ## Complete Example
```typescript ```typescript
function UserDashboard(component: HTMLElement) { import { $Backend, Component, EventData } from "@yao/sui";
this.root = component;
this.store = new __sui_store(component);
this.state = new __sui_state(this);
// Initialize API const self = this as Component;
const api = new OpenAPI({ baseURL: "/api" });
const fileApi = new FileAPI(api);
// State watchers // Initialize API
this.watch = { const api = new OpenAPI({ baseURL: "/api" });
users: (users) => this.render("userList", { users }), const fileApi = new FileAPI(api);
loading: (loading) => {
this.root.classList.toggle("loading", loading); // State watchers
self.watch = {
users: (users: any[]) => self.render("userList", { users }),
loading: (loading: boolean) => {
self.root.classList.toggle("loading", loading);
}, },
}; };
// Load users // Load users
this.loadUsers = async () => { async function loadUsers() {
this.state.Set("loading", true); self.state.Set("loading", true);
const response = await api.Get<User[]>("/users"); const response = await api.Get<User[]>("/users");
if (!api.IsError(response)) { if (!api.IsError(response)) {
this.state.Set("users", response.data); self.state.Set("users", response.data);
} }
this.state.Set("loading", false); self.state.Set("loading", false);
}; }
// Create user // Create user
this.createUser = async (event: Event, data: any) => { self.CreateUser = async (event: Event, data: EventData) => {
const response = await this.backend.ApiCreateUser(data.name, data.email); const response = await $Backend().Call("CreateUser", data.name, data.email);
const users = this.state.Get("users"); const users = self.state.Get("users");
this.state.Set("users", [...users, response]); self.state.Set("users", [...users, response]);
}; };
// Upload avatar // Upload avatar
this.uploadAvatar = async (event: Event) => { self.UploadAvatar = async (event: Event) => {
const input = event.target as HTMLInputElement; const input = event.target as HTMLInputElement;
const file = input.files[0]; const file = input.files![0];
const response = await fileApi.Upload(file, { path: "avatars" }); const response = await fileApi.Upload(file, { path: "avatars" });
if (!api.IsError(response)) { if (!api.IsError(response)) {
this.emit("avatar:uploaded", { url: response.data.url }); self.emit("avatar:uploaded", { url: response.data.url });
} }
}; };
// Initialize // Initialize
this.loadUsers(); loadUsers();
} ```
## CUI Integration
When SUI pages are embedded in CUI via `/web/` routes, they can communicate with the CUI host.
### URL Parameters
CUI automatically replaces special parameter values:
| Value | Replaced With |
| ---------- | -------------------------------- |
| `__theme` | Current theme (`light` / `dark`) |
| `__locale` | Current locale (e.g., `en-us`) |
> **Note**: Authentication uses secure HTTP-only cookies, no token parameter needed.
### Receiving Messages from CUI
```typescript
window.addEventListener("message", (e) => {
// Only accept messages from same origin
if (e.origin !== window.location.origin) return;
const { type, message } = e.data;
switch (type) {
case "setup":
// Initial context from CUI
document.documentElement.setAttribute("data-theme", message.theme);
console.log("Locale:", message.locale);
break;
case "update":
// Data updates from CUI
handleUpdate(message);
break;
}
});
```
### Sending Actions to CUI
Use the unified Action system to trigger CUI operations:
```typescript
// Helper function
const sendAction = (name: string, payload?: any) => {
window.parent.postMessage(
{ type: "action", message: { name, payload } },
window.location.origin
);
};
// Show notification
sendAction("notify.success", { message: "Operation completed!" });
sendAction("notify.error", { message: "Something went wrong" });
// Navigate to page
sendAction("navigate", {
route: "/agents/my-app/detail",
title: "Details",
query: { id: "123" },
});
// Open in new tab
sendAction("navigate", {
route: "/agents/my-app/report",
target: "_blank",
});
// Refresh menu
sendAction("app.menu.reload");
// Close sidebar
sendAction("event.emit", { key: "app/closeSidebar", value: {} });
```
### Available Actions
| Category | Action | Description | Payload |
| -------- | ----------------- | ------------------------- | ------------------------------------------- |
| Navigate | `navigate` | Open page in sidebar/tab | `{ route, title?, icon?, query?, target? }` |
| | `navigate.back` | Go back in history | - |
| Notify | `notify.success` | Success notification | `{ message, duration?, closable? }` |
| | `notify.error` | Error notification | `{ message, duration?, closable? }` |
| | `notify.warning` | Warning notification | `{ message, duration?, closable? }` |
| | `notify.info` | Info notification | `{ message, duration?, closable? }` |
| App | `app.menu.reload` | Refresh application menu | - |
| Modal | `modal.open` | Open modal dialog | `{ ... }` |
| | `modal.close` | Close modal | - |
| Table | `table.search` | Trigger table search | `{ keywords }` |
| | `table.refresh` | Refresh table data | - |
| Form | `form.submit` | Submit form | - |
| | `form.reset` | Reset form | - |
| Event | `event.emit` | Emit custom event | `{ key, value }` |
| Confirm | `confirm` | Show confirmation dialog | `{ title, content }` |
### Complete Example
```typescript
import { $Backend, Component, EventData } from "@yao/sui";
const self = this as Component;
// Helper: Send action to CUI
const sendAction = (name: string, payload?: any) => {
window.parent.postMessage(
{ type: "action", message: { name, payload } },
window.location.origin
);
};
// Initialize CUI communication
function init() {
window.addEventListener("message", (e) => {
if (e.origin !== window.location.origin) return;
if (e.data.type === "setup") {
const { theme, locale } = e.data.message;
document.documentElement.setAttribute("data-theme", theme);
}
});
(window as any).sendAction = sendAction;
}
init();
// Event handlers
self.HandleSave = async (event: Event, data: EventData) => {
try {
await $Backend().Call("Save", data);
sendAction("notify.success", { message: "Saved successfully!" });
} catch (error: any) {
sendAction("notify.error", { message: error.message });
}
};
self.HandleViewDetail = (event: Event, data: EventData) => {
sendAction("navigate", {
route: `/agents/my-app/detail`,
title: "Details",
query: { id: data.id },
});
};
self.HandleClose = () => {
sendAction("event.emit", { key: "app/closeSidebar", value: {} });
};
``` ```

View file

@ -112,27 +112,42 @@ Named keys are used internally for translation lookup. The `keys` section in loc
### Scripts ### Scripts
```typescript ```typescript
function Page(component: HTMLElement) { import { Component } from "@yao/sui";
this.root = component;
this.showMessage = () => { const self = this as Component;
self.ShowMessage = () => {
const message = __m("Operation completed"); const message = __m("Operation completed");
alert(message); alert(message);
}; };
this.confirm = () => { self.Confirm = () => {
return confirm(__m("Are you sure you want to delete?")); return confirm(__m("Are you sure you want to delete?"));
}; };
}
``` ```
## Locale Detection ## Locale Detection
SUI detects locale from: SUI detects locale from the `locale` HTTP cookie on the server side.
1. Cookie (`locale` or `umi_locale`) **Important:** `s:trans` translations are server-side rendered. This means:
2. Browser language
3. Default (`en-us`) 1. The translation happens when the page is generated on the server
2. Changing locale via JavaScript only affects localStorage/client state
3. To apply locale changes to `s:trans` content, you must reload the page
```javascript
// To change locale and have s:trans reflect the change:
document.cookie = "locale=zh-CN;path=/;max-age=31536000";
location.reload(); // Required for server-side translations
```
**Cookie Priority:**
1. `locale` cookie (primary)
2. `umi_locale` cookie (fallback for CUI compatibility)
3. Browser language
4. Default (`en-us`)
### Access Current Locale ### Access Current Locale
@ -223,17 +238,17 @@ This command:
<a href="/contact" s:trans>Contact</a> <a href="/contact" s:trans>Contact</a>
</nav> </nav>
<button s:on-click="showWelcome" s:trans>Show Welcome</button> <button s:on-click="ShowWelcome" s:trans>Show Welcome</button>
</div> </div>
<script> <script>
function home(component) { import { Component } from "@yao/sui";
this.root = component;
this.showWelcome = () => { const self = this as Component;
self.ShowWelcome = () => {
alert(__m("Welcome to our site!")); alert(__m("Welcome to our site!"));
}; };
}
</script> </script>
``` ```

214
sui/docs/routing.md Normal file
View file

@ -0,0 +1,214 @@
# Routing
SUI supports file-system based routing with dynamic route parameters and URL rewriting.
## File-System Routing
Pages are organized in directories, with each directory containing a page's files:
```
/pages/
├── index/
│ ├── index.html
│ ├── index.css
│ └── index.ts
├── about/
│ ├── about.html
│ └── about.css
└── users/
├── users.html
└── [id]/ # Dynamic route
├── [id].html
├── [id].css
└── [id].ts
```
## Dynamic Routes
Use square brackets `[param]` to create dynamic route segments:
| Directory Structure | URL Pattern | Example URL |
| ------------------- | ---------------- | -------------------- |
| `/users/[id]/` | `/users/:id` | `/users/123` |
| `/posts/[slug]/` | `/posts/:slug` | `/posts/hello-world` |
| `/[category]/[id]/` | `/:category/:id` | `/electronics/456` |
### Accessing Route Parameters
**In HTML templates** - Use `$param`:
```html
<h1>User ID: {{ $param.id }}</h1>
<p>Category: {{ $param.category }}</p>
```
**In `.json` configuration**:
```json
{
"userId": "$param.id",
"$user": {
"process": "models.user.Find",
"args": ["$param.id"]
}
}
```
**In backend scripts** - Via Request object:
```typescript
function GetRecord(request: Request): any {
const id = request.params.id;
return Process("models.record.Find", id);
}
```
> **Note**: `$param` is NOT available as a global variable in backend scripts. You must access route parameters through the `request.params` object.
## URL Rewriting
SUI pages require URL rewriting to map clean URLs to `.sui` page files. Configure rewrite rules in `app.yao`:
```json
{
"public": {
"rewrite": [
{ "^\\/assets\\/(.*)$": "/assets/$1" },
{ "^\\/users\\/([^\\/]+)$": "/users/[id].sui" },
{ "^\\/(.*)$": "/$1.sui" }
]
}
}
```
### Rewrite Rule Syntax
Each rule is a JSON object with a regex pattern as the key and the target path as the value:
```json
{ "REGEX_PATTERN": "TARGET_PATH" }
```
- **REGEX_PATTERN**: A regular expression to match the incoming URL
- **TARGET_PATH**: The internal path to route to, can use capture groups (`$1`, `$2`, etc.)
### Rule Processing Order
Rules are processed **in order from top to bottom**. The first matching rule wins. Always place more specific rules before general ones.
### Common Patterns
#### Static Assets (Passthrough)
```json
{ "^\\/assets\\/(.*)$": "/assets/$1" }
```
Passes asset requests directly without modification.
#### Simple Dynamic Route
```json
{ "^\\/users\\/([^\\/]+)$": "/users/[id].sui" }
```
Maps `/users/123` to `/users/[id].sui`, making `123` available as `$param.id`.
#### Nested Dynamic Route
```json
{
"^\\/users\\/([^\\/]+)\\/posts\\/([^\\/]+)$": "/users/[id]/posts/[postId].sui"
}
```
Maps `/users/123/posts/456` to the nested page, with `$param.id = "123"` and `$param.postId = "456"`.
#### Catch-All for SUI Pages
```json
{ "^\\/(.*)$": "/$1.sui" }
```
Maps any URL to its corresponding `.sui` file. Place this **last** as a fallback.
#### Specific Page Override
```json
{ "^\\/dashboard\\/login(.*)$": "/dashboard/login.sui" },
{ "^\\/dashboard\\/(.*)$": "/dashboard/[id].sui" }
```
The login page is matched first (specific), then other dashboard pages use dynamic routing.
### Complete Example
```json
{
"public": {
"rewrite": [
// Static assets - passthrough
{ "^\\/assets\\/(.*)$": "/assets/$1" },
{ "^\\/images\\/(.*)$": "/images/$1" },
// Specific pages (before dynamic routes)
{ "^\\/blog\\/new$": "/blog/new.sui" },
{ "^\\/blog\\/([^\\/]+)\\/edit$": "/blog/[id]/edit.sui" },
// Dynamic routes
{ "^\\/blog\\/([^\\/]+)$": "/blog/[id].sui" },
{
"^\\/users\\/([^\\/]+)\\/posts\\/([^\\/]+)$": "/users/[id]/posts/[postId].sui"
},
{ "^\\/users\\/([^\\/]+)$": "/users/[id].sui" },
// Fallback - must be last
{ "^\\/(.*)$": "/$1.sui" }
]
}
}
```
### Regex Tips
| Pattern | Matches | Description |
| ----------- | ------------------ | ------------------------------------ |
| `([^\\/]+)` | Any segment | Matches characters until next `/` |
| `(.*)` | Everything | Matches any characters including `/` |
| `(\\d+)` | Numbers only | Matches numeric IDs |
| `([a-z-]+)` | Lowercase + hyphen | Matches slugs like `hello-world` |
### Debugging Rewrite Rules
1. Check the server logs for route matching information
2. Ensure regex escaping is correct (double backslashes in JSON: `\\/` for `/`)
3. Test specific URLs to verify capture groups work correctly
4. Remember that the `.sui` extension is internal - users access pages without it
## Route Parameters in Different Contexts
| Context | Access Method | Example |
| --------------- | ------------------- | ------------------------------- |
| HTML Template | `{{ $param.id }}` | `<h1>{{ $param.id }}</h1>` |
| `.json` Config | `"$param.id"` | `"userId": "$param.id"` |
| Backend Script | `request.params.id` | `const id = request.params.id;` |
| Frontend Script | Read from DOM | `document.body.dataset.id` |
### Frontend Access Pattern
Since frontend scripts run in the browser, route params aren't directly available. Pass them via data attributes:
**HTML**:
```html
<div id="page" data-id="{{ $param.id }}">
<!-- content -->
</div>
```
**Frontend TypeScript**:
```typescript
const pageEl = document.getElementById("page");
const id = pageEl?.dataset.id;
```

View file

@ -11,6 +11,7 @@ import (
v8 "github.com/yaoapp/gou/runtime/v8" v8 "github.com/yaoapp/gou/runtime/v8"
"github.com/yaoapp/kun/log" "github.com/yaoapp/kun/log"
"github.com/yaoapp/yao/sui/core" "github.com/yaoapp/yao/sui/core"
"gopkg.in/yaml.v3"
) )
// Page wraps core.Page with agent-specific functionality // Page wraps core.Page with agent-specific functionality
@ -315,6 +316,13 @@ func (page *Page) Build(globalCtx *core.GlobalBuildContext, option *core.BuildOp
return warnings, err return warnings, err
} }
// Write locale files from page's __locales directory
err = page.writeLocaleFiles(option.Data)
if err != nil {
log.Warn("[Agent] Write locale files error: %s", err.Error())
// Don't fail the build for locale errors
}
return warnings, nil return warnings, nil
} }
@ -465,3 +473,116 @@ func (page *Page) AssetRoot() string {
func (page *Page) AssistantID() string { func (page *Page) AssistantID() string {
return page.assistantID return page.assistantID
} }
// writeLocaleFiles writes locale files from page's __locales directory to public
func (page *Page) writeLocaleFiles(data map[string]interface{}) error {
fs := page.tmpl.agent.fs
// Check if page has __locales directory
localesDir := filepath.Join(page.Path, "__locales")
if !fs.IsDir(localesDir) {
return nil
}
// Get the public root
root, err := page.tmpl.agent.DSL.PublicRoot(data)
if err != nil {
log.Error("writeLocaleFiles: Get the public root error: %s. use %s", err.Error(), page.tmpl.agent.DSL.Public.Root)
root = page.tmpl.agent.DSL.Public.Root
}
// Read all locale files in __locales directory
files, err := fs.ReadDir(localesDir, false)
if err != nil {
return err
}
for _, file := range files {
// Skip directories
if fs.IsDir(file) {
continue
}
// Only process .yml files
if filepath.Ext(file) != ".yml" {
continue
}
// Get locale name (e.g., "zh-cn" from "zh-cn.yml")
localeName := filepath.Base(file)
localeName = localeName[:len(localeName)-4] // Remove .yml extension
// Read the locale file
content, err := fs.ReadFile(file)
if err != nil {
log.Error("[Agent] Read locale file error: %s", err.Error())
continue
}
// Parse the locale file
var localeData map[string]interface{}
err = yaml.Unmarshal(content, &localeData)
if err != nil {
log.Error("[Agent] Parse locale file error: %s", err.Error())
continue
}
// Convert to the format expected by core.Locale
locale := core.Locale{
Name: localeName,
Keys: map[string]string{},
Messages: map[string]string{},
ScriptMessages: map[string]string{},
}
// Extract messages
if messages, ok := localeData["messages"].(map[string]interface{}); ok {
for k, v := range messages {
if strVal, ok := v.(string); ok {
locale.Messages[k] = strVal
}
}
}
// Extract script_messages
if scriptMessages, ok := localeData["script_messages"].(map[string]interface{}); ok {
for k, v := range scriptMessages {
if strVal, ok := v.(string); ok {
locale.ScriptMessages[k] = strVal
}
}
}
// Extract timezone and direction
if tz, ok := localeData["timezone"].(string); ok {
locale.Timezone = tz
}
if dir, ok := localeData["direction"].(string); ok {
locale.Direction = dir
}
// Write to public/.locales/<locale>/<route>.yml
// page.Route may contain path like /expense/test, so we need to create nested directories
targetFile := filepath.Join(application.App.Root(), "public", root, ".locales", localeName, fmt.Sprintf("%s.yml", page.Route))
targetDir := filepath.Dir(targetFile)
if exist, _ := os.Stat(targetDir); exist == nil {
os.MkdirAll(targetDir, os.ModePerm)
}
localeContent, err := yaml.Marshal(locale)
if err != nil {
log.Error("[Agent] Marshal locale error: %s", err.Error())
continue
}
err = os.WriteFile(targetFile, localeContent, 0644)
if err != nil {
log.Error("[Agent] Write locale file error: %s", err.Error())
continue
}
log.Info("[Agent] Wrote locale file: %s", targetFile)
}
return nil
}

View file

@ -226,6 +226,33 @@ func (tmpl *Template) GetRoot() string {
return tmpl.agent.root return tmpl.agent.root
} }
// GetWatchDirs returns all directories that should be watched for changes
// This implements the core.IWatchDirs interface
func (tmpl *Template) GetWatchDirs() []string {
dirs := []string{}
// 1. Add the main agent template directory
dirs = append(dirs, tmpl.agent.root)
// 2. Add each assistant's pages directory
assistants, err := tmpl.agent.getAssistants()
if err != nil {
return dirs
}
for _, assistantID := range assistants {
pagesDir := tmpl.agent.getAssistantPagesRoot(assistantID)
dirs = append(dirs, pagesDir)
}
return dirs
}
// GetWatchRoot returns "app" to indicate paths are relative to application source root
func (tmpl *Template) GetWatchRoot() string {
return "app"
}
// Asset get the asset (check agent assets first, then assistant assets) // Asset get the asset (check agent assets first, then assistant assets)
func (tmpl *Template) Asset(file string, width, height uint) (*core.Asset, error) { func (tmpl *Template) Asset(file string, width, height uint) (*core.Asset, error) {
// First check in agent assets // First check in agent assets