Refactor SUI Command Arguments and Enhance Default Template Handling

- Updated the `build` and `watch` commands to accept a single argument for `<sui>` and made the `<template>` argument optional, improving usability.
- Introduced a default template assignment for the `agent` SUI, ensuring a more intuitive setup for users.
- Enhanced error messages for command usage to provide clearer guidance on expected input format.
- Added new OpenAPI file to the bindata, improving the framework's capabilities for API integration.
This commit is contained in:
Max 2026-01-01 10:28:45 +08:00
parent d3ed830ec1
commit 097fb73416
24 changed files with 5233 additions and 230 deletions

View file

@ -23,8 +23,8 @@ var BuildCmd = &cobra.Command{
Short: L("Build the template"),
Long: L("Build the template"),
Run: func(cmd *cobra.Command, args []string) {
if len(args) < 2 {
fmt.Fprintln(os.Stderr, color.RedString(L("yao sui build <sui> <template> [data]")))
if len(args) < 1 {
fmt.Fprintln(os.Stderr, color.RedString(L("yao sui build <sui> [template] [data]")))
return
}
@ -38,7 +38,15 @@ var BuildCmd = &cobra.Command{
}
id := args[0]
template := args[1]
template := "default"
if len(args) >= 2 {
template = args[1]
}
// For agent SUI, use "agent" as default template
if id == "agent" && template == "default" {
template = "agent"
}
var sessionData map[string]interface{}
err = jsoniter.UnmarshalFromString(strings.TrimPrefix(data, "::"), &sessionData)

View file

@ -31,8 +31,8 @@ var WatchCmd = &cobra.Command{
Short: L("Auto-build when the template file changes"),
Long: L("Auto-build when the template file changes"),
Run: func(cmd *cobra.Command, args []string) {
if len(args) < 2 {
fmt.Fprintln(os.Stderr, color.RedString(L("yao sui watch <sui> <template> [data]")))
if len(args) < 1 {
fmt.Fprintln(os.Stderr, color.RedString(L("yao sui watch <sui> [template] [data]")))
return
}
@ -52,7 +52,15 @@ var WatchCmd = &cobra.Command{
}
id := args[0]
template := args[1]
template := "default"
if len(args) >= 2 {
template = args[1]
}
// For agent SUI, use "agent" as default template
if id == "agent" && template == "default" {
template = "agent"
}
var sessionData map[string]interface{}
err = jsoniter.UnmarshalFromString(strings.TrimPrefix(data, "::"), &sessionData)

File diff suppressed because one or more lines are too long

View file

@ -17,33 +17,11 @@ import (
// Guard is the OAuth guard middleware
func (s *Service) Guard(c *gin.Context) {
// Get the token from the request
token := s.getAccessToken(c)
// Validate the token
if token == "" {
response.RespondWithError(c, http.StatusUnauthorized, types.ErrTokenMissing)
c.Abort()
return
// Authenticate first (validates token and sets authorized info)
if !s.Authenticate(c) {
return // Authentication failed, response already sent
}
// Validate the token
claims, err := s.VerifyToken(token)
if err != nil {
response.RespondWithError(c, http.StatusUnauthorized, types.ErrInvalidToken)
c.Abort()
return
}
// Auto refresh the token
if claims.ExpiresAt.Before(time.Now()) {
s.tryAutoRefreshToken(c, claims)
}
// Set Authorized Info in context
sessionID := s.getSessionID(c)
authorized.SetInfo(c, claims, sessionID, s.UserID)
// Check if ACL is enabled
if acl.Global == nil || !acl.Global.Enabled() {
return
@ -66,6 +44,43 @@ func (s *Service) Guard(c *gin.Context) {
}
}
// Authenticate validates the token and sets authorized info in context
// This method only performs authentication without ACL checks
// Returns true if authentication succeeded, false otherwise
func (s *Service) Authenticate(c *gin.Context) bool {
// Get the token from the request
token := s.getAccessToken(c)
// Validate the token
if token == "" {
response.RespondWithError(c, http.StatusUnauthorized, types.ErrTokenMissing)
c.Abort()
return false
}
// Validate the token
claims, err := s.VerifyToken(token)
if err != nil {
response.RespondWithError(c, http.StatusUnauthorized, types.ErrInvalidToken)
c.Abort()
return false
}
// Auto refresh the token
if claims.ExpiresAt.Before(time.Now()) {
s.tryAutoRefreshToken(c, claims)
if c.IsAborted() {
return false
}
}
// Set Authorized Info in context
sessionID := s.getSessionID(c)
authorized.SetInfo(c, claims, sessionID, s.UserID)
return true
}
// GetAuthorizedInfo gets authorized info from context
// Deprecated: Use authorized.GetInfo(c) instead
func GetAuthorizedInfo(c *gin.Context) *types.AuthorizedInfo {

View file

@ -1,29 +1,103 @@
# SUI
# SUI - Simple User Interface
SUI is a full-stack web development tool that allows you to create web applications using HTML, CSS, and Typescript/JavaScript.
SUI is a full-stack web development framework that allows you to create web applications using HTML, CSS, and TypeScript/JavaScript without complex build tools.
It is designed to be simple and easy to use. If you are familiar with HTML, CSS, and Typescript/JavaScript, you can start using SUI right away.
## Features
No dependencies required, no build tools required, no complex frameworks required, just write your HTML, CSS, and TS/JS code, and you are good to go.
- **Page as Component**: Every page is a component, unifying the development model
- **Template Syntax**: Intuitive data binding, conditionals, and loops
- **Backend Scripts**: Server-side logic with TypeScript
- **Scoped Styles**: Automatic CSS scoping per component
- **i18n Support**: Built-in internationalization
- **Agent SUI**: Special configuration for AI Agent applications
## Demo Application
## Quick Start
[https://github.com/YaoApp/yao-startup-webapp](https://github.com/YaoApp/yao-startup-webapp)
### Directory Structure
## Commands
```
/templates/<template_name>/
├── __document.html # Global document template
├── __assets/ # Static assets
├── __locales/ # Locale files
└── <route>/ # Pages
└── <page>/
├── <page>.html # HTML template
├── <page>.css # Styles
├── <page>.ts # Frontend script
├── <page>.json # Data configuration
├── <page>.config # Page configuration
└── <page>.backend.ts # Backend script
```
- `yao sui watch` - Watch for changes in the templates directory and compile them into a single SUI file.
- `yao sui build` - Compile the templates into a single SUI file.
- `yao sui trans` - Generate the i18n translation files, and compile the templates. you can specify the translator automatically translate the text with `trans` attribute.
### Basic Page
## Usage
**`/home/home.html`**:
Comming soon...
```html
<div class="home">
<h1>{{ title }}</h1>
<p s:if="{{ showMessage }}">{{ message }}</p>
</div>
```
## About SUI
**`/home/home.json`**:
SUI is a part of the Yao project, which is a collection of tools for web development.
```json
{
"title": "Welcome",
"showMessage": true,
"message": "Hello, World!"
}
```
SUI name comes from the chinese word "随" which means "follow" or "accompany", it's one of the sixteenth hexagrams of the I Ching, and you can think of SUI as a tool that follows you in your web development journey.
### Commands
SUI name also comes from the word "SUI" which means "simple user interface", and you can think of SUI as a tool that makes it easy to create web applications.
```bash
# Build templates
yao sui build <sui> [template]
# Watch for changes
yao sui watch <sui> [template]
# Build Agent SUI
yao sui build agent
# Watch Agent SUI
yao sui watch agent
```
## Documentation
- [Template Syntax](docs/template-syntax.md) - Data binding, conditionals, loops
- [Components](docs/components.md) - Page as component, props, slots
- [Backend Scripts](docs/backend-scripts.md) - Server-side logic
- [Data Binding](docs/data-binding.md) - Built-in variables and functions
- [Event Handling](docs/event-handling.md) - Event binding and state management
- [Internationalization](docs/i18n.md) - Translation and localization
- [Frontend API](docs/frontend-api.md) - Component query, backend calls, render API
- [Agent SUI](docs/agent-sui.md) - AI Agent application setup
## Agent SUI
Agent SUI is designed for AI Agent applications with automatic page loading from assistants:
```
<app>/
├── agent/
│ └── template/ # Agent SUI template
│ ├── __document.html
│ ├── __assets/
│ └── pages/
└── assistants/
└── <name>/
└── pages/ # Assistant pages
```
Build with: `yao sui build agent`
See [Agent SUI Documentation](docs/agent-sui.md) for details.
## License
Apache-2.0

View file

@ -15,6 +15,8 @@ import (
"github.com/yaoapp/gou/runtime/v8/bridge"
"github.com/yaoapp/kun/log"
"github.com/yaoapp/yao/helper"
"github.com/yaoapp/yao/openapi/oauth"
"github.com/yaoapp/yao/openapi/oauth/authorized"
"rogchap.com/v8go"
)
@ -24,6 +26,7 @@ var Guards = map[string]func(c *Request) error{
"query-jwt": guardQueryJWT, // Get JWT Token from query string "__tk"
"cookie-jwt": guardCookieJWT, // Get JWT Token from cookie "__tk"
"cookie-trace": guardCookieTrace, // Set sid cookie
"oauth": guardOAuth, // OAuth 2.1 guard
}
// JWT Bearer JWT
@ -91,6 +94,34 @@ func guardCookieTrace(r *Request) error {
return nil
}
// OAuth 2.1 guard using openapi/oauth service
// This guard only authenticates the user without ACL checks (suitable for page rendering)
func guardOAuth(r *Request) error {
if r.context == nil {
return fmt.Errorf("Context is nil")
}
if oauth.OAuth == nil {
return fmt.Errorf("OAuth service not initialized")
}
c := r.context
// Authenticate only (validates token and sets authorized info, no ACL check)
if !oauth.OAuth.Authenticate(c) {
return fmt.Errorf("Not authenticated")
}
// Get authorized info from context
info := authorized.GetInfo(c)
if info != nil {
r.Sid = info.SessionID
r.Authorized = info.AuthorizedToMap()
}
return nil
}
// JWT Bearer JWT
func guardQueryJWT(r *Request) error {
if r.context == nil {

View file

@ -10,6 +10,7 @@ import (
"github.com/yaoapp/yao/config"
"github.com/yaoapp/yao/share"
"github.com/yaoapp/yao/sui/core"
"github.com/yaoapp/yao/sui/storages/agent"
"github.com/yaoapp/yao/sui/storages/azure"
"github.com/yaoapp/yao/sui/storages/local"
)
@ -29,6 +30,9 @@ func New(dsl *core.DSL) (core.SUI, error) {
case "azure":
return azure.New(dsl)
case "agent":
return agent.New(dsl)
default:
return nil, fmt.Errorf("%s is not a valid driver", dsl.Storage.Driver)
}
@ -55,10 +59,55 @@ func Load(cfg config.Config) error {
return err
}
// Auto-load Agent SUI if /agent directory exists and has pages
if err := loadAgentSUI(); err != nil {
log.Warn("[sui] Failed to load agent SUI: %s", err.Error())
}
buildRouteMatchers()
return registerAPI()
}
// loadAgentSUI automatically loads the agent SUI if /agent directory exists
func loadAgentSUI() error {
// Check if agent storage is available
if !agent.Exists() {
return nil // No agent directory, skip silently
}
// Check if there are any assistant pages
if !agent.HasAssistantPages() {
log.Debug("[sui] Agent directory exists but no assistant pages found")
return nil
}
// Create agent DSL
dsl := &core.DSL{
ID: "agent",
Name: "Agent",
Storage: &core.Storage{
Driver: "agent",
},
Public: &core.Public{
Root: "/agents",
Host: "/",
Index: "/index",
},
}
// Create agent SUI
sui, err := agent.New(dsl)
if err != nil {
return err
}
// Register the agent SUI
core.SUIs["agent"] = sui
log.Info("[sui] Agent SUI loaded successfully (public root: /agents)")
return nil
}
func loadFile(file string, id string) (core.SUI, error) {
dsl, err := core.Load(file, id)

View file

@ -35,8 +35,14 @@ func LibSUI() ([]byte, []byte, error) {
return nil, nil, err
}
// Read openapi source code from bindata
openapi, err := data.Read("libsui/openapi.ts")
if err != nil {
return nil, nil, err
}
// Merge the source code
source := fmt.Sprintf("%s\n%s\n%s\n%s", index, utils, yao, agent)
source := fmt.Sprintf("%s\n%s\n%s\n%s\n%s", index, utils, yao, agent, openapi)
// Build the source code
js, sm, err := transform.TypeScriptWithSourceMap(string(source), api.TransformOptions{

View file

@ -78,6 +78,12 @@ func (r *Request) NewData() Data {
data["$locale"] = r.Locale
data["$timezone"] = GetSystemTimezone()
data["$direction"] = "ltr"
// Add authorized information if available
if r.Authorized != nil {
data["$auth"] = r.Authorized
}
return data
}

View file

@ -104,6 +104,12 @@ func (script *Script) Call(r *Request, method string, args ...any) (interface{},
// Set the sid
ctx.Sid = r.Sid
// Set authorized information if available
if r.Authorized != nil {
ctx.WithAuthorized(r.Authorized)
}
res, err := ctx.Call(method, args...)
if err != nil {
return nil, err
@ -126,6 +132,12 @@ func (script *Script) BeforeRender(r *Request, props map[string]interface{}) (Da
// Set the sid
ctx.Sid = r.Sid
// Set authorized information if available
if r.Authorized != nil {
ctx.WithAuthorized(r.Authorized)
}
res, err := ctx.Call("BeforeRender", r, props)
if err != nil {
return nil, err

View file

@ -278,19 +278,20 @@ type BuildOption struct {
// Request is the struct for the request
type Request struct {
Method string `json:"method"`
AssetRoot string `json:"asset_root,omitempty"`
Referer string `json:"referer,omitempty"`
Payload map[string]interface{} `json:"payload,omitempty"`
Query url.Values `json:"query,omitempty"`
Params map[string]string `json:"params,omitempty"`
Headers url.Values `json:"headers,omitempty"`
Body interface{} `json:"body,omitempty"`
URL ReqeustURL `json:"url,omitempty"`
Sid string `json:"sid,omitempty"`
Theme any `json:"theme,omitempty"`
Locale any `json:"locale,omitempty"`
Script *Script `json:"-"`
Method string `json:"method"`
AssetRoot string `json:"asset_root,omitempty"`
Referer string `json:"referer,omitempty"`
Payload map[string]interface{} `json:"payload,omitempty"`
Query url.Values `json:"query,omitempty"`
Params map[string]string `json:"params,omitempty"`
Headers url.Values `json:"headers,omitempty"`
Body interface{} `json:"body,omitempty"`
URL ReqeustURL `json:"url,omitempty"`
Sid string `json:"sid,omitempty"`
Theme any `json:"theme,omitempty"`
Locale any `json:"locale,omitempty"`
Script *Script `json:"-"`
Authorized map[string]interface{} `json:"authorized,omitempty"` // OAuth authorized information
}
// RequestSource is the struct for the request

252
sui/docs/agent-sui.md Normal file
View file

@ -0,0 +1,252 @@
# Agent SUI
Agent SUI is a special SUI configuration designed for AI Agent applications. It automatically loads pages from the `/agent/template/` directory and individual assistant pages from `/assistants/<name>/pages/`.
## Directory Structure
```
<app>/
├── agent/
│ ├── agent.yao # Agent configuration
│ └── template/ # Agent SUI template directory
│ ├── template.json # Optional template configuration
│ ├── __document.html # Global document template
│ ├── __data.json # Global data
│ ├── __assets/ # Global assets (CSS, JS, images)
│ │ ├── css/
│ │ ├── js/
│ │ └── images/
│ ├── pages/ # Global agent pages (login, error, etc.)
│ │ └── login/
│ │ └── login.html
│ └── __locales/ # Internationalization
└── assistants/ # Assistants directory
├── demo/ # Assistant: demo
│ ├── assistant.yao # Assistant configuration
│ └── pages/ # Assistant-specific pages
│ ├── index/
│ │ ├── index.html
│ │ ├── index.css
│ │ └── index.ts
│ └── __assets/ # Optional assistant-specific assets
└── another/ # Assistant: another
├── assistant.yao
└── pages/
└── settings/
└── settings.html
```
## Route Mapping
| File Path | Public URL |
| -------------------------------------------------- | -------------------------- |
| `/agent/template/pages/login/login.html` | `/agents/login` |
| `/assistants/demo/pages/index/index.html` | `/agents/demo/index` |
| `/assistants/another/pages/settings/settings.html` | `/agents/another/settings` |
## Asset Paths
- **Global assets**: `/agents/assets/...``/agent/template/__assets/...`
- **Assistant assets**: `/agents/<assistant-id>/assets/...``/assistants/<assistant-id>/pages/__assets/...`
## Build Commands
```bash
# Build Agent SUI
yao sui build agent
# Watch Agent SUI for changes
yao sui watch agent
```
## Build Output
After running `yao sui build agent`, the following structure is generated:
```
<app>/public/
└── agents/ # Public root for Agent SUI
├── assets/ # Static assets
│ ├── libsui.min.js # SUI frontend SDK
│ ├── libsui.min.js.map # Source map
│ ├── css/ # From /agent/template/__assets/css/
│ ├── js/ # From /agent/template/__assets/js/
│ └── images/ # From /agent/template/__assets/images/
├── login.sui # Compiled page
├── login.cfg # Page configuration
├── demo/ # Assistant: demo
│ ├── index.sui # Compiled page
│ └── index.cfg # Page configuration
└── another/ # Assistant: another
├── settings.sui # Compiled page
└── settings.cfg # Page configuration
```
**File Types:**
| Extension | Description |
| --------- | ------------------------------------------------------- |
| `.sui` | Compiled HTML page (includes template, styles, scripts) |
| `.cfg` | Page configuration (JSON format) |
| `.jit` | JIT component (for dynamic loading) |
## Auto-Loading
Agent SUI is automatically loaded when:
1. The `/agent/template/` directory exists
2. At least one assistant has a `pages/` directory
No additional configuration is required.
## Document Template
Create `/agent/template/__document.html`:
```html
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<title>{{ $global.title }}</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/agents/assets/images/favicon.png" />
</head>
<body>
<div class="container">{{ __page }}</div>
</body>
</html>
```
## Global Data
Create `/agent/template/__data.json`:
```json
{
"title": "AI Agent",
"version": "1.0.0",
"theme": "light"
}
```
## Example Assistant Page
**`/assistants/demo/pages/index/index.html`**:
```html
<div id="demo-index" class="page">
<h1>{{ title }}</h1>
<div class="content">
<p>{{ description }}</p>
</div>
</div>
```
**`/assistants/demo/pages/index/index.json`**:
```json
{
"title": "Welcome",
"description": "This is a demo page"
}
```
**`/assistants/demo/pages/index/index.css`**:
```css
.page {
max-width: 800px;
margin: 0 auto;
padding: 24px;
}
```
## Page Configuration
Create `<page>.config` for page settings:
```json
{
"title": "Page Title",
"guard": "bearer-jwt",
"cache": 3600
}
```
## Backend Scripts
Each page can have a backend script:
**`/assistants/demo/pages/index/index.backend.ts`**:
```typescript
function BeforeRender(request: Request): Record<string, any> {
return {
user: Process("session.Get", "user"),
data: Process("models.data.Get", {}),
};
}
function ApiGetData(request: Request): any {
return Process("models.data.Get", {});
}
```
## Using Components
Pages can use other pages as components:
```html
<import s:as="Header" s:from="/shared/header" />
<import s:as="Footer" s:from="/shared/footer" />
<div class="page">
<header title="Demo" />
<main>
<p>Content here</p>
</main>
<footer />
</div>
```
## Accessing in Templates
Use standard SUI template syntax:
```html
<!-- Data binding -->
<h1>{{ title }}</h1>
<!-- Conditionals -->
<div s:if="{{ isLoggedIn }}">Welcome!</div>
<!-- Loops -->
<ul>
<li s:for="{{ items }}" s:for-item="item">{{ item.name }}</li>
</ul>
<!-- Events -->
<button s:on-click="handleClick">Click Me</button>
```
## Frontend Script
**`/assistants/demo/pages/index/index.ts`**:
```typescript
function index(component: HTMLElement) {
this.root = component;
this.store = new __sui_store(component);
this.handleClick = async (event: Event) => {
const data = await this.backend.ApiGetData();
console.log(data);
};
}
```

328
sui/docs/backend-scripts.md Normal file
View file

@ -0,0 +1,328 @@
# Backend Scripts
Backend scripts provide server-side logic for SUI pages, including data fetching, API endpoints, and helper functions.
## File Naming
Backend scripts use the naming convention `<page>.backend.ts` or `<page>.backend.js`:
```
/users/list/
├── list.html
├── list.css
├── list.ts
└── list.backend.ts # Backend script
```
## BeforeRender
The `BeforeRender` function is called before the page is rendered:
```typescript
function BeforeRender(
request: Request,
props?: Record<string, any>
): Record<string, any> {
return {
user: Process("session.Get", "user"),
items: Process("models.item.Get", { limit: 10 }),
};
}
```
### Parameters
- `request` - The HTTP request object
- `props` - Props passed when used as a component (optional)
### Return Value
Return an object that will be merged with page data:
```typescript
function BeforeRender(request: Request): Record<string, any> {
const userId = request.query.userId;
return {
user: Process("models.user.Find", userId),
posts: Process("models.post.Get", {
wheres: [{ column: "user_id", value: userId }],
}),
stats: {
views: 100,
likes: 50,
},
};
}
```
## API Methods
Functions prefixed with `Api` are exposed as callable endpoints:
```typescript
// Callable from frontend as: this.backend.ApiGetUsers()
function ApiGetUsers(request: Request): any[] {
return Process("models.user.Get", {});
}
// Callable from frontend as: this.backend.ApiCreateUser(name, email)
function ApiCreateUser(name: string, email: string, request: Request): any {
return Process("models.user.Create", { name, email });
}
// Callable from frontend as: this.backend.ApiDeleteUser(id)
function ApiDeleteUser(id: string, request: Request): boolean {
Process("models.user.Delete", id);
return true;
}
```
### Calling from Frontend
```typescript
function Page(component: HTMLElement) {
this.root = component;
this.loadUsers = async () => {
const users = await this.backend.ApiGetUsers();
console.log(users);
};
this.createUser = async () => {
const user = await this.backend.ApiCreateUser("John", "john@example.com");
console.log("Created:", user);
};
}
```
## Constants
Export constants to the frontend using `__sui_constants`:
```typescript
const __sui_constants = {
API_URL: "/api/v1",
MAX_ITEMS: 100,
SUPPORTED_FORMATS: ["jpg", "png", "gif"],
CONFIG: {
timeout: 5000,
retries: 3,
},
};
```
Access in frontend:
```typescript
function Page(component: HTMLElement) {
console.log(this.constants.API_URL); // "/api/v1"
console.log(this.constants.MAX_ITEMS); // 100
}
```
## Helpers
Export helper functions to the frontend using `__sui_helpers`:
```typescript
const __sui_helpers = ["formatDate", "formatCurrency", "validateEmail"];
function formatDate(date: string): string {
return new Date(date).toLocaleDateString();
}
function formatCurrency(amount: number, currency: string = "USD"): string {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency,
}).format(amount);
}
function validateEmail(email: string): boolean {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}
```
Access in frontend:
```typescript
function Page(component: HTMLElement) {
const formatted = this.helpers.formatDate("2024-01-15");
const price = this.helpers.formatCurrency(99.99);
const isValid = this.helpers.validateEmail("test@example.com");
}
```
## Request Object
The request object contains:
```typescript
interface Request {
method: string; // HTTP method
url: {
path: string;
host: string;
domain: string;
scheme: string;
};
query: Record<string, string>; // Query parameters
params: Record<string, string>; // Route parameters
payload: Record<string, any>; // POST body
headers: Record<string, string>; // HTTP headers
sid: string; // Session ID
theme: string; // Current theme
locale: string; // Current locale
authorized?: Record<string, any>; // OAuth info (when guard is "oauth")
}
```
### Example Usage
```typescript
function BeforeRender(request: Request): Record<string, any> {
// Access query parameters
const search = request.query.q;
const page = parseInt(request.query.page) || 1;
// Access route parameters
const userId = request.params.id;
// Access headers
const authToken = request.headers["Authorization"];
// Access session
const sessionId = request.sid;
return {
search,
page,
userId,
};
}
```
## Process Calls
Use `Process()` to call Yao processes:
```typescript
// Model operations
const users = Process("models.user.Get", { limit: 10 });
const user = Process("models.user.Find", userId);
Process("models.user.Save", userId, { name: "Updated" });
Process("models.user.Delete", userId);
// Custom scripts
const result = Process("scripts.utils.calculate", arg1, arg2);
// Session
const sessionUser = Process("session.Get", "user");
Process("session.Set", "key", "value");
// Flows
const output = Process("flows.myflow", input);
```
## Error Handling
```typescript
function ApiUpdateUser(id: string, data: any, request: Request): any {
try {
const user = Process("models.user.Find", id);
if (!user) {
throw new Error("User not found");
}
return Process("models.user.Save", id, data);
} catch (error) {
// Error will be returned to frontend
throw new Error(`Failed to update user: ${error.message}`);
}
}
```
## Complete Example
**`/users/profile/profile.backend.ts`**:
```typescript
// Constants exported to frontend
const __sui_constants = {
MAX_BIO_LENGTH: 500,
ALLOWED_AVATAR_TYPES: ["image/jpeg", "image/png"],
};
// Helper functions exported to frontend
const __sui_helpers = ["formatDate", "truncate"];
function formatDate(date: string): string {
return new Date(date).toLocaleDateString();
}
function truncate(text: string, length: number): string {
if (text.length <= length) return text;
return text.slice(0, length) + "...";
}
// Called before page render
function BeforeRender(request: Request): Record<string, any> {
const userId = request.params.id;
const user = Process("models.user.Find", userId);
if (!user) {
return { error: "User not found" };
}
const posts = Process("models.post.Get", {
wheres: [{ column: "user_id", value: userId }],
orders: [{ column: "created_at", option: "desc" }],
limit: 10,
});
return {
user,
posts,
isOwner: request.sid === user.session_id,
};
}
// API: Get user posts
function ApiGetPosts(userId: string, page: number, request: Request): any {
return Process("models.post.Paginate", {
wheres: [{ column: "user_id", value: userId }],
orders: [{ column: "created_at", option: "desc" }],
page,
pageSize: 10,
});
}
// API: Update profile
function ApiUpdateProfile(data: any, request: Request): any {
const sessionUser = Process("session.Get", "user");
if (!sessionUser) {
throw new Error("Not authenticated");
}
return Process("models.user.Save", sessionUser.id, {
name: data.name,
bio: data.bio?.slice(0, 500),
});
}
// API: Upload avatar
function ApiUploadAvatar(file: any, request: Request): any {
const sessionUser = Process("session.Get", "user");
if (!sessionUser) {
throw new Error("Not authenticated");
}
const result = Process("fs.system.Upload", file);
Process("models.user.Save", sessionUser.id, {
avatar: result.path,
});
return { avatar: result.path };
}
```

352
sui/docs/components.md Normal file
View file

@ -0,0 +1,352 @@
# Components
In SUI, **every page is a component**. Any page can be embedded into another page using the `is` attribute.
## Core Concept
When a page is used as a component:
1. The page's HTML becomes the component template
2. The page's CSS is automatically scoped
3. The page's TypeScript becomes the component class
4. The page's `backend.ts` provides server-side logic via `BeforeRender`
## Creating a Component
A component is just a page with a single root element:
**`/card/card.html`**:
```html
<div class="card">
<h3>{{ title }}</h3>
<div class="card-body">
<children></children>
</div>
</div>
```
**`/card/card.css`**:
```css
.card {
border: 1px solid #ddd;
border-radius: 8px;
padding: 16px;
}
.card h3 {
margin: 0 0 12px;
}
```
**`/card/card.ts`**:
```typescript
function card(component: HTMLElement) {
this.root = component;
this.store = new __sui_store(component);
this.props = new __sui_props(component);
}
```
## Using Components
### Basic Usage
Use the `is` attribute to embed a page as a component:
```html
<div is="/card" title="My Card">
<p>Card content goes here</p>
</div>
```
### With Import Alias
Use `<import>` for cleaner syntax:
```html
<import s:as="Card" s:from="/card" />
<import s:as="Button" s:from="/shared/button" />
<Card title="My Card">
<p>Content</p>
</Card>
<Button variant="primary">Click Me</Button>
```
## Props
Props are passed as attributes:
```html
<div
is="/user-card"
name="{{ user.name }}"
email="{{ user.email }}"
avatar="{{ user.avatar }}"
role="admin"
/>
```
Access props in the component script:
```typescript
function userCard(component: HTMLElement) {
this.root = component;
this.props = new __sui_props(component);
// Get single prop
const name = this.props.Get("name");
// Get all props
const allProps = this.props.List();
// { name: "John", email: "john@example.com", avatar: "...", role: "admin" }
}
```
Access props in backend script:
```typescript
function BeforeRender(
request: Request,
props: Record<string, any>
): Record<string, any> {
const userId = props.userId;
return {
user: Process("models.user.Find", userId),
};
}
```
## Children and Slots
### Children
Use `<children></children>` to render child content:
**Component (`/panel/panel.html`)**:
```html
<div class="panel">
<div class="panel-header">{{ title }}</div>
<div class="panel-body">
<children></children>
</div>
</div>
```
**Usage**:
```html
<div is="/panel" title="Settings">
<p>This content appears in the panel body</p>
<button>Save</button>
</div>
```
### Named Slots
Use `<slot name="xxx">` for multiple content areas:
**Component (`/modal/modal.html`)**:
```html
<div class="modal">
<div class="modal-header">
<slot name="header"></slot>
</div>
<div class="modal-body">
<children></children>
</div>
<div class="modal-footer">
<slot name="footer"></slot>
</div>
</div>
```
**Usage**:
```html
<div is="/modal">
<slot name="header">
<h2>Confirmation</h2>
</slot>
<p>Are you sure you want to proceed?</p>
<slot name="footer">
<button>Cancel</button>
<button>Confirm</button>
</slot>
</div>
```
## Dynamic Components
### Variable Component Route
```html
<div is="{{ '/widgets/' + widgetType }}" ...widgetProps></div>
```
### Dynamic Tag
```html
<dynamic route="/components/{{ componentName }}" />
```
## Component Script
### Structure
```typescript
function componentName(component: HTMLElement) {
// Root element
this.root = component;
// Data store (data-* attributes)
this.store = new __sui_store(component);
// Props (passed attributes)
this.props = new __sui_props(component);
// State management
this.state = new __sui_state(this);
// Backend API
this.backend = {
ApiMethod: async (...args) => {
/* ... */
},
};
// State watchers
this.watch = {
propertyName: (value, state) => {
// React to state changes
},
};
// Methods
this.handleClick = (event, data, context) => {
// Handle events
};
}
```
### Store API
```typescript
// String data
this.store.Get("key");
this.store.Set("key", "value");
// JSON data
this.store.GetJSON("items");
this.store.SetJSON("items", [{ id: 1 }]);
// Component data (from BeforeRender)
this.store.GetData();
```
### Props API
```typescript
// Get single prop
const value = this.props.Get("propName");
// Get all props
const props = this.props.List();
```
### State API
```typescript
// Set state (triggers watchers)
this.state.Set("count", 10);
// Watch state changes
this.watch = {
count: (value, state) => {
this.root.querySelector(".count").textContent = value;
// state.stopPropagation(); // Prevent bubbling to parent
},
};
```
## Nested Components
Components can include other components:
```html
<!-- /dashboard/dashboard.html -->
<div class="dashboard">
<div is="/shared/header" title="Dashboard" />
<div class="content">
<div is="/dashboard/stats" data="{{ stats }}" />
<div is="/dashboard/chart" type="line" data="{{ chartData }}" />
</div>
<div is="/shared/footer" />
</div>
```
## Component Backend Script
**`/user-card/user-card.backend.ts`**:
```typescript
function BeforeRender(
request: Request,
props: Record<string, any>
): Record<string, any> {
const userId = props.userId;
return {
user: Process("models.user.Find", userId),
permissions: Process("scripts.auth.GetPermissions", userId),
};
}
function ApiUpdateUser(userId: string, data: any, request: Request): any {
return Process("models.user.Save", userId, data);
}
```
## CSS Scoping
Component CSS is automatically scoped using namespace attributes:
**Original CSS**:
```css
.card {
border: 1px solid #ddd;
}
.card h3 {
color: #333;
}
```
**Compiled CSS** (scoped):
```css
[s:ns="ns_abc123"] .card {
border: 1px solid #ddd;
}
[s:ns="ns_abc123"] .card h3 {
color: #333;
}
```
## Important Notes
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()`)
3. **Scoped Styles**: CSS is automatically scoped to prevent conflicts
4. **Recursive Prevention**: SUI detects and prevents recursive component inclusion
5. **Script Naming**: Function name is derived from the route path

273
sui/docs/data-binding.md Normal file
View file

@ -0,0 +1,273 @@
# Data Binding
SUI provides built-in variables and functions for accessing request data and executing server-side logic.
## Built-in Variables
### Request Variables
| Variable | Description | Example |
| ---------- | -------------------- | ----------------------- |
| `$payload` | POST request body | `{{ $payload.name }}` |
| `$query` | URL query parameters | `{{ $query.search }}` |
| `$param` | Route parameters | `{{ $param.id }}` |
| `$cookie` | Request cookies | `{{ $cookie.session }}` |
### URL Variables
| Variable | Description | Example Value |
| ------------- | ----------- | --------------------------- |
| `$url.path` | URL path | `/users/123` |
| `$url.host` | Full host | `example.com:8080` |
| `$url.domain` | Domain only | `example.com` |
| `$url.scheme` | Protocol | `https` |
| `$url.url` | Full URL | `https://example.com/users` |
### Context Variables
| Variable | Description | Example Value |
| ------------ | ------------------------------ | -------------------- |
| `$theme` | Current theme | `light`, `dark` |
| `$locale` | Current locale | `en-us`, `zh-cn` |
| `$timezone` | System timezone | `Asia/Shanghai` |
| `$direction` | Text direction | `ltr`, `rtl` |
| `$global` | Global data from `__data.json` | `{ title: "App" }` |
| `$auth` | OAuth authorized info (if guard is `oauth`) | `{ user_id: "123" }` |
## Usage Examples
### Query Parameters
URL: `/search?q=hello&page=2`
```html
<h1>Search: {{ $query.q }}</h1>
<p>Page: {{ $query.page ?? 1 }}</p>
```
### Route Parameters
Route: `/users/[id]/posts/[postId]`
URL: `/users/123/posts/456`
```html
<h1>User {{ $param.id }}</h1>
<p>Post {{ $param.postId }}</p>
```
### POST Payload
```html
<form method="POST">
<input name="email" value="{{ $payload.email }}" />
<div s:if="{{ $payload.error }}">{{ $payload.error }}</div>
</form>
```
### Theme and Locale
```html
<html class="{{ $theme }}" lang="{{ $locale }}">
<body dir="{{ $direction }}">
<h1>{{ $global.title }}</h1>
</body>
</html>
```
## Data Configuration (`<page>.json`)
Define page data using JSON configuration:
### Static Data
```json
{
"title": "My Page",
"items": [
{ "id": 1, "name": "Item 1" },
{ "id": 2, "name": "Item 2" }
]
}
```
### Process Calls
```json
{
"$users": "models.user.Get",
"$settings": {
"process": "models.settings.Find",
"args": [1]
}
}
```
Keys starting with `$` trigger process calls. The result is available as the variable name (without `$`).
### Using Request Variables
```json
{
"$user": {
"process": "models.user.Find",
"args": ["$param.id"]
},
"searchQuery": "$query.q",
"currentPath": "$url.path"
}
```
Available request variables in JSON config:
- `$query.<name>` - Query parameters
- `$param.<name>` - Route parameters
- `$payload.<name>` - POST payload
- `$header.<name>` - Request headers
- `$url.path` / `$url.host` / `$url.domain` / `$url.scheme`
Note: `$header` is only available in JSON configuration, not in HTML templates.
### Complex Example
```json
{
"pageTitle": "User Profile",
"userId": "$param.id",
"$user": {
"process": "models.user.Find",
"args": ["$param.id"]
},
"$posts": {
"process": "models.post.Get",
"args": [
{
"wheres": [{ "column": "user_id", "value": "$param.id" }],
"limit": 10
}
]
},
"isOwner": "$query.edit == 'true'"
}
```
## Built-in Functions
### P\_() - Process Call
Call a Yao process directly in templates:
```html
<!-- Simple call -->
<span>{{ P_('utils.formatDate', createdAt) }}</span>
<!-- With multiple arguments -->
<span>{{ P_('utils.calculate', price, quantity, discount) }}</span>
<!-- In conditions -->
<div s:if="{{ P_('auth.hasPermission', 'admin') }}">Admin Panel</div>
```
### True() / False()
Check boolean values:
```html
<div s:if="{{ True(user) }}">User exists</div>
<div s:if="{{ False(error) }}">No error</div>
<!-- Equivalent to -->
<div s:if="{{ user != null && user != false && user != 0 }}">User exists</div>
```
### Empty()
Check if array or object is empty:
```html
<div s:if="{{ Empty(items) }}">No items</div>
<div s:if="{{ !Empty(items) }}">{{ items.length }} items found</div>
<!-- Works with objects too -->
<div s:if="{{ Empty(settings) }}">No settings configured</div>
```
## Global Data (`__data.json`)
Define global data available to all pages:
**`/templates/<template>/__data.json`**:
```json
{
"title": "My Application",
"version": "1.0.0",
"company": {
"name": "ACME Inc",
"email": "contact@acme.com"
},
"navigation": [
{ "label": "Home", "href": "/" },
{ "label": "About", "href": "/about" }
]
}
```
Access in templates:
```html
<title>{{ $global.title }}</title>
<footer>© {{ $global.company.name }}</footer>
<nav>
<a s:for="{{ $global.navigation }}" s:for-item="item" href="{{ item.href }}">
{{ item.label }}
</a>
</nav>
```
## Backend Script Data
Data returned from `BeforeRender` is merged with page data:
**`<page>.backend.ts`**:
```typescript
function BeforeRender(request: Request): Record<string, any> {
return {
user: Process("session.Get", "user"),
notifications: Process("models.notification.Get", {
wheres: [{ column: "read", value: false }],
limit: 5,
}),
serverTime: new Date().toISOString(),
};
}
```
**`<page>.html`**:
```html
<div s:if="{{ user }}">
Welcome, {{ user.name }}!
<span s:if="{{ !Empty(notifications) }}">
{{ notifications.length }} new notifications
</span>
</div>
<footer>Server time: {{ serverTime }}</footer>
```
## Data Priority
When the same key exists in multiple sources, priority is:
1. **BeforeRender** (highest) - Backend script data
2. **`<page>.json`** - Page data configuration
3. **`__data.json`** (lowest) - Global data
```typescript
// BeforeRender returns { title: "From Backend" }
// page.json has { title: "From JSON" }
// __data.json has { title: "Global Title" }
// Result: title = "From Backend"
```

407
sui/docs/event-handling.md Normal file
View file

@ -0,0 +1,407 @@
# Event Handling
SUI provides a declarative event binding system with state management and component communication.
## Event Binding
### Basic Events
Use `s:on-<event>` to bind events:
```html
<button s:on-click="handleClick">Click Me</button>
<input s:on-input="handleInput" />
<form s:on-submit="handleSubmit">...</form>
```
### Common Events
| Attribute | Event | Description |
| ----------------- | ---------- | ------------------ |
| `s:on-click` | click | Mouse click |
| `s:on-dblclick` | dblclick | Double click |
| `s:on-input` | input | Input value change |
| `s:on-change` | change | Value changed |
| `s:on-submit` | submit | Form submission |
| `s:on-focus` | focus | Element focused |
| `s:on-blur` | blur | Element lost focus |
| `s:on-keydown` | keydown | Key pressed |
| `s:on-keyup` | keyup | Key released |
| `s:on-mouseenter` | mouseenter | Mouse entered |
| `s:on-mouseleave` | mouseleave | Mouse left |
### Multiple Events
```html
<input
s:on-input="handleInput"
s:on-focus="handleFocus"
s:on-blur="handleBlur"
s:on-keydown="handleKeydown"
/>
```
## Passing Data
### Data Attributes
Use `s:data-*` to pass string data:
```html
<button
s:on-click="deleteItem"
s:data-id="{{ item.id }}"
s:data-name="{{ item.name }}"
>
Delete
</button>
```
### JSON Data
Use `s:json-*` to pass complex data:
```html
<button
s:on-click="editItem"
s:json-item="{{ item }}"
s:json-options="{{ { confirm: true, redirect: '/list' } }}"
>
Edit
</button>
```
## Event Handlers
### Handler Signature
```typescript
function Page(component: HTMLElement) {
this.root = component;
this.handleClick = (event: Event, data: any, context: EventContext) => {
// event - The DOM event
// data - Combined data from s:data-* and s:json-*
// context - Event context with element references
};
}
```
### EventContext
```typescript
interface EventContext {
rootElement: HTMLElement; // Component root element
targetElement: HTMLElement; // Element that triggered the event
}
```
### Example
```html
<div class="item-list">
<div s:for="{{ items }}" s:for-item="item">
<span>{{ item.name }}</span>
<button
s:on-click="deleteItem"
s:data-id="{{ item.id }}"
s:json-item="{{ item }}"
>
Delete
</button>
</div>
</div>
```
```typescript
function ItemList(component: HTMLElement) {
this.root = component;
this.deleteItem = async (event: Event, data: any, context: EventContext) => {
const id = data.id; // String from s:data-id
const item = data.item; // Object from s:json-item
if (confirm(`Delete ${item.name}?`)) {
await this.backend.ApiDeleteItem(id);
context.targetElement.closest(".item").remove();
}
};
}
```
## State Management
### State Object
```typescript
function Counter(component: HTMLElement) {
this.root = component;
this.state = new __sui_state(this);
// Initial state
this.state.Set("count", 0);
}
```
### State Watchers
React to state changes with watchers:
```typescript
function Counter(component: HTMLElement) {
this.root = component;
this.state = new __sui_state(this);
// Define watchers
this.watch = {
count: (value: number, state: State) => {
this.root.querySelector(".count").textContent = value;
},
items: (value: any[], state: State) => {
this.renderItems(value);
},
};
this.increment = () => {
const count = this.state.Get("count") || 0;
this.state.Set("count", count + 1); // Triggers watcher
};
}
```
### Stop Propagation
Prevent state changes from bubbling to parent:
```typescript
this.watch = {
localState: (value: any, state: State) => {
// Handle locally
this.updateUI(value);
// Stop propagation to parent components
state.stopPropagation();
},
};
```
## Store (Data Attributes)
Store manages `data-*` attributes on the component:
### Basic Usage
```typescript
function Card(component: HTMLElement) {
this.root = component;
this.store = new __sui_store(component);
// Get/Set string values
const id = this.store.Get("id");
this.store.Set("id", "123");
// Get/Set JSON values
const items = this.store.GetJSON("items");
this.store.SetJSON("items", [{ id: 1 }, { id: 2 }]);
}
```
### Component Data
Get data from BeforeRender:
```typescript
// Backend returns: { user: { name: "John" }, settings: {...} }
const data = this.store.GetData();
console.log(data.user.name); // "John"
```
## Custom Events
### Emit Events
```typescript
function ItemCard(component: HTMLElement) {
this.root = component;
this.selectItem = () => {
const item = this.store.GetJSON("item");
// Emit custom event
this.emit("item:selected", { item });
};
}
```
### Listen to Events
```typescript
function ItemList(component: HTMLElement) {
this.root = component;
// Listen to child events
this.root.addEventListener("item:selected", (e: CustomEvent) => {
const { item } = e.detail;
console.log("Selected:", item);
});
}
```
### State Change Events
Parent components can listen to state changes:
```typescript
function Parent(component: HTMLElement) {
this.root = component;
this.root.addEventListener("state:change", (e: CustomEvent) => {
const { key, value, target } = e.detail;
console.log(`State ${key} changed to ${value} in`, target);
});
}
```
## Form Handling
### Form Submit
```html
<form s:on-submit="handleSubmit">
<input name="email" type="email" required />
<input name="password" type="password" required />
<button type="submit">Login</button>
</form>
```
```typescript
function LoginForm(component: HTMLElement) {
this.root = component;
this.handleSubmit = async (event: Event) => {
event.preventDefault();
const form = event.target as HTMLFormElement;
const formData = new FormData(form);
const email = formData.get("email");
const password = formData.get("password");
try {
await this.backend.ApiLogin(email, password);
window.location.href = "/dashboard";
} catch (error) {
alert("Login failed");
}
};
}
```
### Input Binding
```html
<input type="text" s:on-input="handleInput" s:data-field="name" />
```
```typescript
function Form(component: HTMLElement) {
this.root = component;
this.formData = {};
this.handleInput = (event: Event, data: any) => {
const input = event.target as HTMLInputElement;
this.formData[data.field] = input.value;
};
}
```
## Keyboard Events
```html
<input s:on-keydown="handleKeydown" s:on-keyup="handleKeyup" />
```
```typescript
function Search(component: HTMLElement) {
this.root = component;
this.handleKeydown = (event: KeyboardEvent) => {
if (event.key === "Enter") {
this.search();
}
if (event.key === "Escape") {
this.clear();
}
};
}
```
## Complete Example
```html
<div class="todo-app">
<form s:on-submit="addTodo">
<input
name="title"
placeholder="Add todo..."
s:on-keydown="handleKeydown"
/>
<button type="submit">Add</button>
</form>
<ul class="todo-list">
<li s:for="{{ todos }}" s:for-item="todo">
<input
type="checkbox"
s:on-change="toggleTodo"
s:data-id="{{ todo.id }}"
s:attr-checked="{{ todo.completed }}"
/>
<span class="{{ todo.completed ? 'completed' : '' }}">
{{ todo.title }}
</span>
<button s:on-click="deleteTodo" s:data-id="{{ todo.id }}">×</button>
</li>
</ul>
</div>
```
```typescript
function TodoApp(component: HTMLElement) {
this.root = component;
this.state = new __sui_state(this);
this.store = new __sui_store(component);
this.watch = {
todos: (todos: any[]) => {
this.render("todoList", { todos });
},
};
this.addTodo = async (event: Event) => {
event.preventDefault();
const form = event.target as HTMLFormElement;
const input = form.querySelector("input") as HTMLInputElement;
if (input.value.trim()) {
const todo = await this.backend.ApiAddTodo(input.value);
const todos = this.state.Get("todos") || [];
this.state.Set("todos", [...todos, todo]);
input.value = "";
}
};
this.toggleTodo = async (event: Event, data: any) => {
const checkbox = event.target as HTMLInputElement;
await this.backend.ApiToggleTodo(data.id, checkbox.checked);
};
this.deleteTodo = async (event: Event, data: any) => {
await this.backend.ApiDeleteTodo(data.id);
const todos = this.state.Get("todos").filter((t) => t.id !== data.id);
this.state.Set("todos", todos);
};
}
```

379
sui/docs/frontend-api.md Normal file
View file

@ -0,0 +1,379 @@
# Frontend API
SUI provides a rich frontend API for component interaction, backend calls, and rendering.
## Component Query
### $$() Function
Get a component instance by selector or element:
```typescript
// By ID
const card = $$("#my-card");
// By element
const element = document.querySelector(".card");
const card = $$(element);
// Access component methods
card.toggle();
card.state.Set("expanded", true);
```
### Query Methods
```typescript
const component = $$("#my-component");
// Find child component (returns __Query wrapper)
const button = component.find("button");
// Query single element
const title = component.query(".title"); // Returns Element
// Query all elements
const items = component.queryAll(".item"); // Returns NodeList
```
## Backend Calls
### Via Component
```typescript
function Page(component: HTMLElement) {
this.root = component;
this.loadData = async () => {
// Call backend API methods
const users = await this.backend.ApiGetUsers();
const user = await this.backend.ApiGetUser(123);
const result = await this.backend.ApiCreateUser("John", "john@example.com");
};
}
```
### Direct Call
```typescript
// __sui_backend_call(route, headers, method, ...args)
const result = await __sui_backend_call(
"/users/list", // Page route
{ "X-Custom-Header": "value" }, // Custom headers
"ApiGetUsers", // Method name
{ page: 1, limit: 10 } // Arguments
);
```
## Render API
### Render Target
Define render targets in HTML:
```html
<div s:render="userList" class="user-list">
<!-- Content will be replaced here -->
</div>
```
### Render Method
```typescript
function Page(component: HTMLElement) {
this.root = component;
this.refreshUsers = async () => {
const users = await this.backend.ApiGetUsers();
// Render with data
await this.render("userList", { users });
};
}
```
### Render Options
```typescript
await this.render("targetName", data, {
replace: true, // Replace content (default: true)
showLoader: true, // Show loading indicator
withPageData: true, // Include page data in render context
route: "/custom/route", // Use custom route for rendering
});
```
## Yao SDK (Legacy)
The `Yao` class provides HTTP client functionality:
```typescript
const yao = new Yao();
// GET request
const data = await yao.Get("/api/users", { page: 1 });
// POST request
const result = await yao.Post("/api/users", { name: "John" });
// Download file
await yao.Download("/api/export", { format: "csv" }, "export.csv");
// Token management
const token = yao.Token();
yao.SetCookie("key", "value", 30); // 30 days
yao.DeleteCookie("key");
```
## OpenAPI Client (Recommended)
The `OpenAPI` client provides a modern HTTP client with type safety and error handling.
### Initialization
```typescript
const api = new OpenAPI({ baseURL: "/api" });
```
### HTTP Methods
```typescript
// GET
const response = await api.Get<User[]>("/users");
// POST
const response = await api.Post<User>("/users", {
name: "John",
email: "john@example.com",
});
// PUT
const response = await api.Put<User>("/users/123", {
name: "John Updated",
});
// DELETE
const response = await api.Delete<void>("/users/123");
```
### Error Handling
```typescript
const response = await api.Get<User[]>("/users");
if (api.IsError(response)) {
console.error(`Error: ${response.error.error_description}`);
return;
}
const users = response.data;
```
### Response Types
```typescript
interface APIResponse<T> {
data: T;
}
interface APIError {
error: {
error: string;
error_description: string;
};
}
```
## File API
### Initialization
```typescript
const api = new OpenAPI({ baseURL: "/api" });
const fileApi = new FileAPI(api);
```
### Upload
```typescript
const fileInput = document.querySelector<HTMLInputElement>("#file");
const file = fileInput.files[0];
// Upload with progress
const response = await fileApi.Upload(
file,
{
path: "documents",
groups: ["team-a"],
compressImage: true,
},
(progress) => {
console.log(`${progress.percentage}%`);
}
);
```
### Upload Multiple
```typescript
const responses = await fileApi.UploadMultiple(
Array.from(fileInput.files),
{ path: "uploads" },
(fileIndex, progress) => {
console.log(`File ${fileIndex}: ${progress.percentage}%`);
}
);
```
### File Operations
```typescript
// List files
const files = await fileApi.List({
page: 1,
pageSize: 20,
contentType: "image/*",
orderBy: "created_at desc",
});
// Get file info
const info = await fileApi.Retrieve("file-id");
// Download
const blob = await fileApi.Download("file-id");
if (!api.IsError(blob)) {
const url = URL.createObjectURL(blob.data);
window.open(url);
}
// Delete
await fileApi.Delete("file-id");
// Check existence
const exists = await fileApi.Exists("file-id");
```
### Utility Methods
```typescript
// Format file size
FileAPI.FormatSize(1024); // "1 KB"
FileAPI.FormatSize(1048576); // "1 MB"
// Get extension
FileAPI.GetExtension("doc.pdf"); // "pdf"
// Check type
FileAPI.IsImage("image/png"); // true
FileAPI.IsDocument("application/pdf"); // true
```
## Cross-Origin Support
```typescript
const api = new OpenAPI({ baseURL: "https://api.example.com" });
if (api.IsCrossOrigin()) {
console.log("Cross-origin API");
}
// Set CSRF token after login
const loginResponse = await api.Post("/auth/login", credentials);
if (!api.IsError(loginResponse) && loginResponse.data.csrf_token) {
api.SetCSRFToken(loginResponse.data.csrf_token);
}
// Clear tokens on logout
api.ClearTokens();
```
## Custom Events
### Emit
```typescript
function Card(component: HTMLElement) {
this.root = component;
this.select = () => {
this.emit("card:selected", { id: this.store.Get("id") });
};
}
```
### Listen
```typescript
function CardList(component: HTMLElement) {
this.root = component;
this.root.addEventListener("card:selected", (e: CustomEvent) => {
console.log("Selected:", e.detail.id);
});
}
```
### State Change Events
```typescript
// Listen to child state changes
this.root.addEventListener("state:change", (e: CustomEvent) => {
const { key, value, target } = e.detail;
console.log(`${key} = ${value}`);
});
```
## Complete Example
```typescript
function UserDashboard(component: HTMLElement) {
this.root = component;
this.store = new __sui_store(component);
this.state = new __sui_state(this);
// Initialize API
const api = new OpenAPI({ baseURL: "/api" });
const fileApi = new FileAPI(api);
// State watchers
this.watch = {
users: (users) => this.render("userList", { users }),
loading: (loading) => {
this.root.classList.toggle("loading", loading);
},
};
// Load users
this.loadUsers = async () => {
this.state.Set("loading", true);
const response = await api.Get<User[]>("/users");
if (!api.IsError(response)) {
this.state.Set("users", response.data);
}
this.state.Set("loading", false);
};
// Create user
this.createUser = async (event: Event, data: any) => {
const response = await this.backend.ApiCreateUser(data.name, data.email);
const users = this.state.Get("users");
this.state.Set("users", [...users, response]);
};
// Upload avatar
this.uploadAvatar = async (event: Event) => {
const input = event.target as HTMLInputElement;
const file = input.files[0];
const response = await fileApi.Upload(file, { path: "avatars" });
if (!api.IsError(response)) {
this.emit("avatar:uploaded", { url: response.data.url });
}
};
// Initialize
this.loadUsers();
}
```

280
sui/docs/i18n.md Normal file
View file

@ -0,0 +1,280 @@
# Internationalization (i18n)
SUI provides built-in support for internationalization with translation markers and locale files.
## Translation Markers
### Static Text
Use `s:trans` attribute for static text:
```html
<span s:trans>Hello World</span>
<button s:trans>Submit</button>
<p s:trans>Welcome to our application</p>
```
### In Expressions
Use `'::'` prefix in expressions:
```html
<span>{{ '::Welcome' }}</span>
<span>{{ '::Hello, ' + name }}</span>
<p>{{ '::You have ' + count + ' messages' }}</p>
```
### In Scripts
Use `__m()` function:
```html
<script>
const message = __m("Welcome back");
const greeting = __m("Hello, ") + userName;
alert(__m("Are you sure?"));
</script>
```
## Locale Files
### Directory Structure
```
/templates/<template>/
└── __locales/
├── en-us/
│ ├── home.yml
│ └── users/list.yml
└── zh-cn/
├── home.yml
└── users/list.yml
```
### File Format
**`__locales/zh-cn/home.yml`**:
```yaml
name: zh-cn
direction: ltr
timezone: +08:00
formatter: scripts.locale
messages:
Hello World: 你好世界
Welcome: 欢迎
"Hello, ": "你好,"
Submit: 提交
"You have %d messages": "你有 %d 条消息"
keys:
page_title: 首页
nav_home: 首页
nav_about: 关于
script_messages:
Welcome back: 欢迎回来
"Are you sure?": "你确定吗?"
```
### Sections
| Section | Description |
| ----------------- | ----------------------------------- |
| `name` | Locale identifier |
| `direction` | Text direction (`ltr` or `rtl`) |
| `timezone` | Timezone offset |
| `formatter` | Custom formatter process |
| `messages` | Translations for `s:trans` and `::` |
| `keys` | Named translation keys |
| `script_messages` | Translations for `__m()` |
## Using Translations
### HTML Templates
```html
<!-- Static translation -->
<h1 s:trans>Welcome to our site</h1>
<!-- Dynamic translation -->
<p>{{ '::Hello, ' + user.name }}</p>
<!-- With variables -->
<span>{{ '::You have ' + count + ' items' }}</span>
```
### Named Keys
Named keys are used internally for translation lookup. The `keys` section in locale files provides named references for translations that can be used programmatically.
### Scripts
```typescript
function Page(component: HTMLElement) {
this.root = component;
this.showMessage = () => {
const message = __m("Operation completed");
alert(message);
};
this.confirm = () => {
return confirm(__m("Are you sure you want to delete?"));
};
}
```
## Locale Detection
SUI detects locale from:
1. Cookie (`locale` or `umi_locale`)
2. Browser language
3. Default (`en-us`)
### Access Current Locale
```html
<html lang="{{ $locale }}">
<body dir="{{ $direction }}">
...
</body>
</html>
```
## Custom Formatter
Define a custom formatter process:
```yaml
# In locale file
formatter: scripts.locale.format
```
**`scripts/locale.js`**:
```javascript
function format(text, args) {
// Custom formatting logic
return text.replace(/%d/g, () => args.shift());
}
```
## RTL Support
For right-to-left languages:
```yaml
# __locales/ar/home.yml
name: ar
direction: rtl
timezone: +03:00
messages:
Hello: مرحبا
```
```html
<body dir="{{ $direction }}">
<!-- Content automatically flows RTL -->
</body>
```
## Building Translations
### Generate Translation Files
```bash
yao sui trans <sui> <template>
```
This command:
1. Scans all pages for translation markers
2. Generates/updates locale files
3. Builds the template
### Translation Workflow
1. Add `s:trans` or `::` markers to your HTML
2. Run `yao sui trans` to extract strings
3. Edit locale files to add translations
4. Build with `yao sui build`
## Complete Example
**`/home/home.html`**:
```html
<div class="home">
<h1 s:trans>Welcome to our application</h1>
<p>{{ '::Hello, ' + user.name }}</p>
<div class="stats">
<span>{{ '::You have ' + messageCount + ' messages' }}</span>
</div>
<nav>
<a href="/" s:trans>Home</a>
<a href="/about" s:trans>About</a>
<a href="/contact" s:trans>Contact</a>
</nav>
<button s:on-click="showWelcome" s:trans>Show Welcome</button>
</div>
<script>
function home(component) {
this.root = component;
this.showWelcome = () => {
alert(__m("Welcome to our site!"));
};
}
</script>
```
**`__locales/zh-cn/home.yml`**:
```yaml
name: zh-cn
direction: ltr
timezone: +08:00
messages:
Welcome to our application: 欢迎使用我们的应用
"Hello, ": "你好,"
"You have ": "你有 "
" messages": " 条消息"
Home: 首页
About: 关于
Contact: 联系我们
Show Welcome: 显示欢迎
script_messages:
"Welcome to our site!": "欢迎来到我们的网站!"
```
**`__locales/ja/home.yml`**:
```yaml
name: ja
direction: ltr
timezone: +09:00
messages:
Welcome to our application: アプリケーションへようこそ
"Hello, ": "こんにちは、"
"You have ": ""
" messages": " 件のメッセージがあります"
Home: ホーム
About: について
Contact: お問い合わせ
Show Welcome: ようこそを表示
script_messages:
"Welcome to our site!": "私たちのサイトへようこそ!"
```

292
sui/docs/template-syntax.md Normal file
View file

@ -0,0 +1,292 @@
# Template Syntax
SUI uses a simple template syntax for data binding, conditional rendering, and list iteration.
## Data Interpolation
Use double curly braces `{{ }}` to output data:
```html
<!-- Variable binding -->
<span>{{ name }}</span>
<span>{{ user.email }}</span>
<span>{{ items[0].title }}</span>
<!-- Default values (null coalescing) -->
<span>{{ title ?? 'Default Title' }}</span>
<span>{{ user.name ?? 'Anonymous' }}</span>
<!-- Expressions -->
<span>{{ price * quantity }}</span>
<span>{{ firstName + ' ' + lastName }}</span>
<span>{{ count > 0 ? 'Has items' : 'Empty' }}</span>
```
## Conditional Rendering
### Basic If
```html
<div s:if="{{ isActive }}">Active</div>
<div s:if="{{ count > 0 }}">Has items</div>
<div s:if="{{ user != null }}">Logged in</div>
```
### If-Elif-Else
```html
<div s:if="{{ status == 'active' }}">Active</div>
<div s:elif="{{ status == 'pending' }}">Pending</div>
<div s:elif="{{ status == 'suspended' }}">Suspended</div>
<div s:else>Unknown</div>
```
### Comparison Operators
| Operator | Description |
| -------- | --------------------- |
| `==` | Equal |
| `!=` | Not equal |
| `>` | Greater than |
| `<` | Less than |
| `>=` | Greater than or equal |
| `<=` | Less than or equal |
| `&&` | Logical AND |
| `\|\|` | Logical OR |
| `!` | Logical NOT |
### Examples
```html
<!-- Multiple conditions -->
<div s:if="{{ isAdmin && isActive }}">Admin Panel</div>
<div s:if="{{ age >= 18 || hasPermission }}">Access Granted</div>
<!-- Negation -->
<div s:if="{{ !isLoading }}">Content loaded</div>
<!-- Null checks -->
<div s:if="{{ user != null && user.verified }}">Verified User</div>
```
## List Rendering
### Basic Loop
```html
<ul>
<li s:for="{{ items }}" s:for-item="item">{{ item.name }}</li>
</ul>
```
### With Index
```html
<ul>
<li s:for="{{ items }}" s:for-item="item" s:for-index="index">
{{ index + 1 }}. {{ item.name }}
</li>
</ul>
```
### Nested Loops
```html
<div s:for="{{ categories }}" s:for-item="category">
<h3>{{ category.name }}</h3>
<ul>
<li s:for="{{ category.items }}" s:for-item="item">{{ item.title }}</li>
</ul>
</div>
```
### Loop with Conditional
```html
<div s:for="{{ users }}" s:for-item="user" s:if="{{ user.active }}">
{{ user.name }}
</div>
```
### Object Iteration
```html
<dl s:for="{{ settings }}" s:for-item="value" s:for-index="key">
<dt>{{ key }}</dt>
<dd>{{ value }}</dd>
</dl>
```
## Variable Assignment
Use `<s:set>` to define variables:
```html
<!-- Simple assignment -->
<s:set name="total" value="{{ price * quantity }}" />
<span>Total: {{ total }}</span>
<!-- Computed values -->
<s:set name="fullName" value="{{ firstName + ' ' + lastName }}" />
<s:set name="isExpensive" value="{{ price > 100 }}" />
<!-- From expressions -->
<s:set name="discountedPrice" value="{{ price * (1 - discount / 100) }}" />
```
## Attribute Binding
### Dynamic Attributes
```html
<input value="{{ formData.email }}" />
<a href="{{ '/user/' + userId }}">Profile</a>
<img src="{{ imageUrl }}" alt="{{ imageAlt }}" />
```
### Conditional Attributes
```html
<!-- Attribute with condition -->
<button s:attr-disabled="{{ !isValid }}">Submit</button>
<input s:attr-readonly="{{ isLocked }}" />
<div s:attr-hidden="{{ !showPanel }}">Panel</div>
<!-- Class binding -->
<div class="base {{ isActive ? 'active' : '' }}">Content</div>
```
### Spread Attributes
```html
<!-- Spread object as attributes -->
<div ...props></div>
<input ...inputAttrs />
```
## Raw HTML Output
By default, output is HTML-escaped. Use `s:raw` for raw HTML:
```html
<!-- Escaped (safe) -->
<div>{{ htmlContent }}</div>
<!-- Raw HTML (use with caution) -->
<div s:raw="true">{{ htmlContent }}</div>
```
## Expression Engine
SUI uses [Expr](https://expr-lang.org/) (v1.17) as the expression engine. Expr provides a powerful expression language with operators, functions, and more.
### SUI Custom Functions
| Function | Description | Example |
| --------------- | ------------------------------ | --------------------------------- |
| `P_(proc, ...)` | Call a Yao process | `{{ P_('models.user.Find', 1) }}` |
| `True(value)` | Check if value is truthy | `{{ True(user) }}` |
| `False(value)` | Check if value is falsy | `{{ False(error) }}` |
| `Empty(value)` | Check if array/object is empty | `{{ Empty(items) }}` |
### Expr Built-in Functions
Expr provides many built-in functions. Here are commonly used ones:
| Function | Description | Example |
| --------------------- | ------------------------------ | --------------------------------- |
| `len(array)` | Get length of array/string/map | `{{ len(items) }}` |
| `all(array, pred)` | Check if all elements match | `{{ all(users, .active) }}` |
| `any(array, pred)` | Check if any element matches | `{{ any(items, .price > 100) }}` |
| `one(array, pred)` | Check if exactly one matches | `{{ one(users, .admin) }}` |
| `none(array, pred)` | Check if no elements match | `{{ none(items, .deleted) }}` |
| `map(array, mapper)` | Transform array elements | `{{ map(users, .name) }}` |
| `filter(array, pred)` | Filter array by predicate | `{{ filter(items, .active) }}` |
| `find(array, pred)` | Find first matching element | `{{ find(users, .id == 1) }}` |
| `count(array, pred)` | Count matching elements | `{{ count(items, .price > 50) }}` |
| `sum(array)` | Sum of array elements | `{{ sum(prices) }}` |
| `mean(array)` | Average of array elements | `{{ mean(scores) }}` |
| `min(array)` | Minimum value | `{{ min(prices) }}` |
| `max(array)` | Maximum value | `{{ max(scores) }}` |
| `first(array)` | First element | `{{ first(items) }}` |
| `last(array)` | Last element | `{{ last(items) }}` |
| `take(array, n)` | Take first n elements | `{{ take(items, 5) }}` |
| `keys(map)` | Get map keys | `{{ keys(settings) }}` |
| `values(map)` | Get map values | `{{ values(settings) }}` |
| `contains(a, b)` | Check if a contains b | `{{ contains(name, 'test') }}` |
| `startsWith(s, pre)` | Check string prefix | `{{ startsWith(url, 'https') }}` |
| `endsWith(s, suf)` | Check string suffix | `{{ endsWith(file, '.pdf') }}` |
| `upper(s)` | Uppercase string | `{{ upper(name) }}` |
| `lower(s)` | Lowercase string | `{{ lower(email) }}` |
| `trim(s)` | Trim whitespace | `{{ trim(input) }}` |
| `split(s, sep)` | Split string | `{{ split(tags, ',') }}` |
| `join(array, sep)` | Join array to string | `{{ join(names, ', ') }}` |
| `int(v)` | Convert to integer | `{{ int(value) }}` |
| `float(v)` | Convert to float | `{{ float(value) }}` |
| `string(v)` | Convert to string | `{{ string(count) }}` |
| `now()` | Current time | `{{ now() }}` |
| `date(s)` | Parse date string | `{{ date('2024-01-01') }}` |
| `duration(s)` | Parse duration string | `{{ duration('1h30m') }}` |
For the complete list of built-in functions and operators, see the [Expr Language Definition](https://expr-lang.org/docs/language-definition).
### Examples
```html
<!-- SUI custom functions -->
<div s:if="{{ Empty(users) }}">No users found</div>
<span>{{ P_('utils.formatDate', createdAt) }}</span>
<!-- Array operations -->
<span>Total: {{ len(items) }} items</span>
<span>Active: {{ count(users, .active) }}</span>
<span>Sum: {{ sum(map(items, .price)) }}</span>
<!-- String operations -->
<span>{{ upper(first(split(name, ' '))) }}</span>
<!-- Filtering -->
<div s:for="{{ filter(items, .price > 100) }}" s:for-item="item">
{{ item.name }}
</div>
```
## String Operations
```html
<!-- Concatenation -->
<span>{{ 'Hello, ' + name + '!' }}</span>
<!-- Template literals (in expressions) -->
<a href="{{ '/users/' + userId + '/edit' }}">Edit</a>
```
## Arithmetic Operations
```html
<!-- Basic math -->
<span>{{ price * quantity }}</span>
<span>{{ total / count }}</span>
<span>{{ value + 10 }}</span>
<span>{{ index - 1 }}</span>
<!-- Percentage -->
<span>{{ (completed / total) * 100 }}%</span>
```
## Comments
HTML comments are preserved in output:
```html
<!-- This comment appears in output -->
```
## Whitespace Control
SUI preserves whitespace by default. For minified output, use build options:
```bash
yao sui build <sui> <template> # Minified (production)
yao sui build <sui> <template> -D # Preserved (development, --debug)
```

807
sui/libsui/openapi.ts Normal file
View file

@ -0,0 +1,807 @@
/**
* SUI OpenAPI Client
*
* A lightweight HTTP client for Yao OpenAPI endpoints.
* Adapted from CUI OpenAPI for use in SUI (browser-only, no build tools).
*
* Features:
* - RESTful API methods (GET, POST, PUT, DELETE, Upload)
* - Secure cookie authentication
* - CSRF protection
* - File upload with progress tracking
* - Cross-origin support
*
* Usage:
* const api = new OpenAPI({ baseURL: '/api' })
* const response = await api.Get('/users')
* if (api.IsError(response)) {
* console.error(response.error)
* } else {
* console.log(response.data)
* }
*/
// ============================================================================
// Types
// ============================================================================
interface OpenAPIConfig {
baseURL: string;
timeout?: number;
defaultHeaders?: Record<string, string>;
}
interface ErrorResponse {
error: string;
error_description?: string;
error_uri?: string;
[key: string]: any;
}
interface ApiResponse<T = any> {
data?: T;
error?: ErrorResponse;
status: number;
headers: Headers;
}
interface FileUploadOptions {
uploaderID?: string;
originalFilename?: string;
groups?: string[];
gzip?: boolean;
compressImage?: boolean;
compressSize?: number;
path?: string;
chunked?: boolean;
chunkSize?: number;
public?: boolean;
share?: "private" | "team";
}
interface FileListOptions {
uploaderID?: string;
page?: number;
pageSize?: number;
status?: string;
contentType?: string;
name?: string;
orderBy?: string;
select?: string[];
}
interface FileInfo {
file_id: string;
user_path: string;
path: string;
bytes: number;
created_at: number;
filename: string;
content_type: string;
status: string;
url?: string;
metadata?: Record<string, any>;
uploader?: string;
groups?: string[];
public?: boolean;
share?: "private" | "team";
}
interface FileListResponse {
data: FileInfo[];
total: number;
page: number;
pageSize: number;
totalPages: number;
}
interface FileExistsResponse {
exists: boolean;
fileId: string;
}
interface FileDeleteResponse {
message: string;
fileId: string;
}
type UploadProgressCallback = (progress: {
loaded: number;
total: number;
percentage: number;
}) => void;
// ============================================================================
// OpenAPI Client
// ============================================================================
class OpenAPI {
private config: OpenAPIConfig;
constructor(config: OpenAPIConfig) {
this.config = config;
}
private async handleResponse<T>(response: Response): Promise<ApiResponse<T>> {
const apiResponse: ApiResponse<T> = {
status: response.status,
headers: response.headers,
};
try {
const contentType = response.headers.get("content-type") || "";
if (contentType.includes("application/json")) {
const jsonData = await response.json();
if (!response.ok && jsonData.error) {
apiResponse.error = jsonData as ErrorResponse;
} else if (response.ok) {
apiResponse.data = jsonData as T;
} else {
apiResponse.error = {
error: "http_error",
error_description: `HTTP ${response.status}: ${response.statusText}`,
};
}
} else if (response.ok) {
const textData = await response.text();
apiResponse.data = textData as unknown as T;
} else {
const errorText = await response.text();
apiResponse.error = {
error: "http_error",
error_description:
errorText || `HTTP ${response.status}: ${response.statusText}`,
};
}
} catch (parseError) {
apiResponse.error = {
error: "parse_error",
error_description: `Failed to parse response: ${
parseError instanceof Error ? parseError.message : "Unknown error"
}`,
};
}
return apiResponse;
}
async Get<T = any>(
path: string,
query: Record<string, string> = {},
headersInit: Record<string, string> = {}
): Promise<ApiResponse<T>> {
const headers = { "Content-Type": "application/json", ...headersInit };
this.addCSRFToken(headers);
const queryString = new URLSearchParams(query).toString();
let url = `${this.config.baseURL}${path}`;
if (queryString) {
url += path.includes("?") ? `&${queryString}` : `?${queryString}`;
}
const response = await fetch(url, {
method: "GET",
headers,
credentials: "include",
});
return this.handleResponse<T>(response);
}
async Post<T = any>(
path: string,
payload: any,
headersInit: Record<string, string> = {}
): Promise<ApiResponse<T>> {
const headers = { "Content-Type": "application/json", ...headersInit };
this.addCSRFToken(headers);
const response = await fetch(`${this.config.baseURL}${path}`, {
method: "POST",
body: typeof payload === "object" ? JSON.stringify(payload) : payload,
headers,
credentials: "include",
});
return this.handleResponse<T>(response);
}
async Put<T = any>(
path: string,
payload: any,
headersInit: Record<string, string> = {}
): Promise<ApiResponse<T>> {
const headers = { "Content-Type": "application/json", ...headersInit };
this.addCSRFToken(headers);
const response = await fetch(`${this.config.baseURL}${path}`, {
method: "PUT",
body: typeof payload === "object" ? JSON.stringify(payload) : payload,
headers,
credentials: "include",
});
return this.handleResponse<T>(response);
}
async Delete<T = any>(
path: string,
headersInit: Record<string, string> = {},
payload?: any
): Promise<ApiResponse<T>> {
const headers = { "Content-Type": "application/json", ...headersInit };
this.addCSRFToken(headers);
const requestOptions: RequestInit = {
method: "DELETE",
headers,
credentials: "include",
};
if (payload !== undefined) {
requestOptions.body = JSON.stringify(payload);
}
const response = await fetch(
`${this.config.baseURL}${path}`,
requestOptions
);
return this.handleResponse<T>(response);
}
async Upload<T = any>(
path: string,
formData: FormData,
headersInit: Record<string, string> = {}
): Promise<ApiResponse<T>> {
const headers = { ...headersInit };
this.addCSRFToken(headers);
// Don't set Content-Type for FormData - browser sets it with boundary
const response = await fetch(`${this.config.baseURL}${path}`, {
method: "POST",
body: formData,
headers,
credentials: "include",
});
return this.handleResponse<T>(response);
}
// ============================================================================
// Helper Methods
// ============================================================================
IsError<T>(
response: ApiResponse<T>
): response is ApiResponse<T> & { error: ErrorResponse } {
return response.error !== undefined;
}
GetData<T>(response: ApiResponse<T>): T | null {
return response.data || null;
}
SetCSRFToken(token: string): void {
if (typeof localStorage !== "undefined") {
localStorage.setItem("csrf_token", token);
}
}
ClearTokens(): void {
if (typeof localStorage !== "undefined") {
localStorage.removeItem("csrf_token");
localStorage.removeItem("xsrf_token");
}
}
IsCrossOrigin(): boolean {
if (typeof window === "undefined") {
return false;
}
try {
const apiUrl = new URL(this.config.baseURL, window.location.origin);
return apiUrl.origin !== window.location.origin;
} catch {
return true;
}
}
getBaseURL(): string {
return this.config.baseURL;
}
// ============================================================================
// Private Methods
// ============================================================================
private addCSRFToken(headers: Record<string, string>): void {
// Try cookies
const cookieToken =
this.getSecureCookie("__Host-csrf_token") ||
this.getSecureCookie("__Secure-csrf_token") ||
this.getSecureCookie("__Host-xsrf_token") ||
this.getSecureCookie("__Secure-xsrf_token");
if (cookieToken) {
headers["X-CSRF-Token"] = cookieToken;
return;
}
// Try localStorage
if (typeof localStorage !== "undefined") {
const storedToken =
localStorage.getItem("csrf_token") ||
localStorage.getItem("xsrf_token");
if (storedToken) {
headers["X-CSRF-Token"] = storedToken;
return;
}
}
// Try meta tag
if (typeof document !== "undefined") {
const metaToken =
document
.querySelector('meta[name="csrf-token"]')
?.getAttribute("content") ||
document
.querySelector('meta[name="xsrf-token"]')
?.getAttribute("content");
if (metaToken) {
headers["X-CSRF-Token"] = metaToken;
}
}
}
private getSecureCookie(name: string): string | null {
if (typeof document === "undefined") {
return null;
}
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) {
const cookieValue = parts.pop()?.split(";").shift();
return cookieValue ? decodeURIComponent(cookieValue) : null;
}
return null;
}
}
// ============================================================================
// File API
// ============================================================================
class FileAPI {
private api: OpenAPI;
private defaultUploader: string;
constructor(api: OpenAPI, defaultUploader?: string) {
this.api = api;
this.defaultUploader = defaultUploader || "__yao.attachment";
}
async Upload(
file: File,
options: FileUploadOptions = {},
onProgress?: UploadProgressCallback
): Promise<ApiResponse<FileInfo>> {
const uploaderID = options.uploaderID || this.defaultUploader;
const shouldUseChunked =
options.chunked || file.size > (options.chunkSize || 2 * 1024 * 1024);
if (shouldUseChunked) {
return this.uploadChunked(uploaderID, file, options, onProgress);
}
const formData = new FormData();
formData.append("file", file);
if (options.originalFilename || file.name) {
formData.append(
"original_filename",
options.originalFilename || file.name
);
}
if (options.path) formData.append("path", options.path);
if (options.groups?.length)
formData.append("groups", options.groups.join(","));
if (options.gzip) formData.append("gzip", "true");
if (options.compressImage) formData.append("compress_image", "true");
if (options.compressSize)
formData.append("compress_size", options.compressSize.toString());
if (options.public !== undefined)
formData.append("public", options.public ? "true" : "false");
if (options.share) formData.append("share", options.share);
if (onProgress) {
return this.uploadWithProgress(uploaderID, formData, onProgress);
}
return this.api.Upload<FileInfo>(`/file/${uploaderID}`, formData);
}
async UploadMultiple(
files: File[],
options: FileUploadOptions = {},
onProgress?: (
fileIndex: number,
progress: { loaded: number; total: number; percentage: number }
) => void
): Promise<ApiResponse<FileInfo>[]> {
const uploadPromises = files.map((file, index) => {
const progressCallback = onProgress
? (progress: { loaded: number; total: number; percentage: number }) =>
onProgress(index, progress)
: undefined;
return this.Upload(file, options, progressCallback);
});
return Promise.all(uploadPromises);
}
async List(
options: FileListOptions = {}
): Promise<ApiResponse<FileListResponse>> {
const uploaderID = options.uploaderID || this.defaultUploader;
const params: Record<string, string> = {};
if (options.page) params.page = options.page.toString();
if (options.pageSize) params.page_size = options.pageSize.toString();
if (options.status) params.status = options.status;
if (options.contentType) params.content_type = options.contentType;
if (options.name) params.name = options.name;
if (options.orderBy) params.order_by = options.orderBy;
if (options.select?.length) params.select = options.select.join(",");
return this.api.Get<FileListResponse>(`/file/${uploaderID}`, params);
}
async Retrieve(
fileID: string,
uploaderID?: string
): Promise<ApiResponse<FileInfo>> {
if (!fileID) throw new Error("File ID is required");
const actualUploaderID = uploaderID || this.defaultUploader;
return this.api.Get<FileInfo>(
`/file/${actualUploaderID}/${encodeURIComponent(fileID)}`
);
}
async Delete(
fileID: string,
uploaderID?: string
): Promise<ApiResponse<FileDeleteResponse>> {
if (!fileID) throw new Error("File ID is required");
const actualUploaderID = uploaderID || this.defaultUploader;
return this.api.Delete<FileDeleteResponse>(
`/file/${actualUploaderID}/${encodeURIComponent(fileID)}`
);
}
async Download(
fileID: string,
uploaderID?: string
): Promise<ApiResponse<Blob>> {
if (!fileID) throw new Error("File ID is required");
const actualUploaderID = uploaderID || this.defaultUploader;
const url = `${this.api.getBaseURL()}/file/${actualUploaderID}/${encodeURIComponent(
fileID
)}/content`;
const response = await fetch(url, {
method: "GET",
credentials: "include",
});
const blob = await response.blob();
const apiResponse: ApiResponse<Blob> = {
data: blob,
status: response.status,
headers: response.headers,
};
if (!response.ok) {
apiResponse.error = {
error: "download_failed",
error_description: `Download failed with status ${response.status}`,
};
}
return apiResponse;
}
async Exists(
fileID: string,
uploaderID?: string
): Promise<ApiResponse<FileExistsResponse>> {
if (!fileID) throw new Error("File ID is required");
const actualUploaderID = uploaderID || this.defaultUploader;
return this.api.Get<FileExistsResponse>(
`/file/${actualUploaderID}/${encodeURIComponent(fileID)}/exists`
);
}
// Static utility methods
static FormatSize(bytes: number): string {
if (bytes === 0) return "0 Bytes";
const k = 1024;
const sizes = ["Bytes", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
}
static GetExtension(filename: string): string {
return filename.slice(((filename.lastIndexOf(".") - 1) >>> 0) + 2);
}
static IsImage(contentType: string): boolean {
return contentType.startsWith("image/");
}
static IsDocument(contentType: string): boolean {
const documentTypes = [
"application/pdf",
"application/msword",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/vnd.ms-excel",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"application/vnd.ms-powerpoint",
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
"text/plain",
"text/csv",
];
return documentTypes.includes(contentType);
}
// Private methods
private async uploadChunked(
uploaderID: string,
file: File,
options: FileUploadOptions = {},
onProgress?: UploadProgressCallback
): Promise<ApiResponse<FileInfo>> {
const chunkSize = options.chunkSize || 2 * 1024 * 1024;
const totalSize = file.size;
const totalChunks = Math.ceil(totalSize / chunkSize);
const fileUID = this.generateUID();
let lastResponse: ApiResponse<FileInfo> | null = null;
for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) {
const start = chunkIndex * chunkSize;
const end = Math.min(start + chunkSize - 1, totalSize - 1);
const chunkBlob = file.slice(start, end + 1);
const formData = new FormData();
formData.append("file", chunkBlob);
if (chunkIndex === 0) {
if (options.originalFilename || file.name) {
formData.append(
"original_filename",
options.originalFilename || file.name
);
}
if (options.path) formData.append("path", options.path);
if (options.groups?.length)
formData.append("groups", options.groups.join(","));
if (options.gzip) formData.append("gzip", "true");
if (options.compressImage) formData.append("compress_image", "true");
if (options.compressSize)
formData.append("compress_size", options.compressSize.toString());
if (options.public !== undefined)
formData.append("public", options.public ? "true" : "false");
if (options.share) formData.append("share", options.share);
}
const chunkResponse = await this.uploadChunk(
uploaderID,
formData,
start,
end,
totalSize,
fileUID
);
if (this.api.IsError(chunkResponse)) {
return chunkResponse;
}
lastResponse = chunkResponse;
if (onProgress) {
const loaded = end + 1;
const percentage = Math.round((loaded / totalSize) * 100);
onProgress({ loaded, total: totalSize, percentage });
}
}
return lastResponse!;
}
private uploadChunk(
uploaderID: string,
formData: FormData,
start: number,
end: number,
total: number,
uid: string
): Promise<ApiResponse<FileInfo>> {
return new Promise((resolve) => {
const xhr = new XMLHttpRequest();
xhr.addEventListener("load", () => {
try {
const response = JSON.parse(xhr.responseText);
const apiResponse: ApiResponse<FileInfo> = {
data: response.data || response,
status: xhr.status,
headers: new Headers(),
};
if (xhr.status >= 200 && xhr.status < 300) {
resolve(apiResponse);
} else {
apiResponse.error = response.error || {
error: "chunk_upload_failed",
error_description: `Chunk upload failed with status ${xhr.status}`,
};
resolve(apiResponse);
}
} catch {
resolve({
status: xhr.status,
headers: new Headers(),
error: {
error: "parse_error",
error_description: "Failed to parse chunk response",
},
});
}
});
xhr.addEventListener("error", () => {
resolve({
status: xhr.status || 0,
headers: new Headers(),
error: {
error: "network_error",
error_description: "Network error during chunk upload",
},
});
});
xhr.open("POST", `${this.api.getBaseURL()}/file/${uploaderID}`);
xhr.setRequestHeader("Content-Sync", "true");
xhr.setRequestHeader("Content-Uid", uid);
xhr.setRequestHeader("Content-Range", `bytes ${start}-${end}/${total}`);
const csrfToken = this.getCSRFToken();
if (csrfToken) {
xhr.setRequestHeader("X-CSRF-Token", csrfToken);
}
xhr.withCredentials = true;
xhr.send(formData);
});
}
private uploadWithProgress(
uploaderID: string,
formData: FormData,
onProgress: UploadProgressCallback
): Promise<ApiResponse<FileInfo>> {
return new Promise((resolve) => {
const xhr = new XMLHttpRequest();
xhr.upload.addEventListener("progress", (event) => {
if (event.lengthComputable) {
const percentage = Math.round((event.loaded / event.total) * 100);
onProgress({ loaded: event.loaded, total: event.total, percentage });
}
});
xhr.addEventListener("load", () => {
try {
const response = JSON.parse(xhr.responseText);
const apiResponse: ApiResponse<FileInfo> = {
data: response.data || response,
status: xhr.status,
headers: new Headers(),
};
if (xhr.status >= 200 && xhr.status < 300) {
resolve(apiResponse);
} else {
apiResponse.error = response.error || {
error: "upload_failed",
error_description: `Upload failed with status ${xhr.status}`,
};
resolve(apiResponse);
}
} catch {
resolve({
status: xhr.status,
headers: new Headers(),
error: {
error: "parse_error",
error_description: "Failed to parse response",
},
});
}
});
xhr.addEventListener("error", () => {
resolve({
status: xhr.status || 0,
headers: new Headers(),
error: {
error: "network_error",
error_description: "Network error during upload",
},
});
});
xhr.open("POST", `${this.api.getBaseURL()}/file/${uploaderID}`);
const csrfToken = this.getCSRFToken();
if (csrfToken) {
xhr.setRequestHeader("X-CSRF-Token", csrfToken);
}
xhr.withCredentials = true;
xhr.send(formData);
});
}
private getCSRFToken(): string | null {
if (typeof document !== "undefined") {
const cookies = document.cookie.split(";");
for (const cookie of cookies) {
const [name, value] = cookie.trim().split("=");
if (
name === "__Host-csrf_token" ||
name === "__Secure-csrf_token" ||
name === "__Host-xsrf_token" ||
name === "__Secure-xsrf_token"
) {
return decodeURIComponent(value);
}
}
}
if (typeof localStorage !== "undefined") {
return (
localStorage.getItem("csrf_token") || localStorage.getItem("xsrf_token")
);
}
return null;
}
private generateUID(): string {
// Simple unique ID generator (no external dependencies)
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0;
const v = c === "x" ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
}
// ============================================================================
// Global Registration for SUI
// ============================================================================
// Make available globally for SUI pages (no export, direct global assignment)
(window as any).OpenAPI = OpenAPI;
(window as any).FileAPI = FileAPI;

228
sui/storages/agent/agent.go Normal file
View file

@ -0,0 +1,228 @@
package agent
import (
"fmt"
"path/filepath"
"strings"
jsoniter "github.com/json-iterator/go"
"github.com/yaoapp/gou/fs"
"github.com/yaoapp/kun/log"
"github.com/yaoapp/yao/sui/core"
)
// New create a new agent sui storage
func New(dsl *core.DSL) (*Agent, error) {
// Use "app" filesystem which is rooted at application source directory
appFS, err := fs.Get("app")
if err != nil {
return nil, err
}
// Set default public settings for agent
if dsl.Public == nil {
dsl.Public = &core.Public{}
}
if dsl.Public.Root == "" {
dsl.Public.Root = "/agents"
}
if dsl.Public.Host == "" {
dsl.Public.Host = "/"
}
if dsl.Public.Index == "" {
dsl.Public.Index = "/index"
}
return &Agent{
root: "/agent/template",
assistantsRoot: "/assistants",
fs: appFS,
DSL: dsl,
}, nil
}
// GetTemplates get the templates (returns single agent template)
func (agent *Agent) GetTemplates() ([]core.ITemplate, error) {
tmpl, err := agent.GetTemplate("agent")
if err != nil {
return nil, err
}
return []core.ITemplate{tmpl}, nil
}
// GetTemplate get the template
func (agent *Agent) GetTemplate(id string) (core.ITemplate, error) {
if id != "agent" {
return nil, fmt.Errorf("Agent storage only supports 'agent' template, got: %s", id)
}
// Check if /agent directory exists
if !agent.fs.IsDir(agent.root) {
return nil, fmt.Errorf("Agent template directory not found: %s", agent.root)
}
// Create agent template
tmpl := &Template{
Root: agent.root,
agent: agent,
Template: &core.Template{
ID: "agent",
Name: "Agent",
Version: 1,
Screenshots: []string{},
Themes: []core.SelectOption{},
},
}
// Load template.json if exists
configFile := filepath.Join(agent.root, "template.json")
if agent.fs.IsFile(configFile) {
configBytes, err := agent.fs.ReadFile(configFile)
if err != nil {
return nil, err
}
err = jsoniter.Unmarshal(configBytes, tmpl.Template)
if err != nil {
return nil, err
}
}
// Load __document.html
documentFile := filepath.Join(agent.root, "__document.html")
if agent.fs.IsFile(documentFile) {
documentBytes, err := agent.fs.ReadFile(documentFile)
if err != nil {
return nil, err
}
tmpl.Document = documentBytes
}
// Load __data.json
dataFile := filepath.Join(agent.root, "__data.json")
if agent.fs.IsFile(dataFile) {
dataBytes, err := agent.fs.ReadFile(dataFile)
if err != nil {
return nil, err
}
tmpl.GlobalData = dataBytes
}
// Load build script
err := tmpl.loadBuildScript()
if err != nil {
log.Warn("[Agent] Failed to load build script: %v", err)
}
return tmpl, nil
}
// UploadTemplate upload the template (not supported for agent)
func (agent *Agent) UploadTemplate(src string, dst string) (core.ITemplate, error) {
return nil, fmt.Errorf("UploadTemplate is not supported for agent storage")
}
// PublicRootMatcher get the public root matcher
func (agent *Agent) PublicRootMatcher() *core.Matcher {
return &core.Matcher{Exact: agent.DSL.Public.Root}
}
// Setting get the setting
func (agent *Agent) Setting() (*core.Setting, error) {
return &core.Setting{
ID: agent.DSL.ID,
Guard: agent.DSL.Guard,
Option: map[string]interface{}{
"disableCodeEditor": true,
},
}, nil
}
// PublicRoot get the public root
func (agent *Agent) PublicRoot(data map[string]interface{}) (string, error) {
return agent.DSL.Public.Root, nil
}
// WithSid set the session id
func (agent *Agent) WithSid(sid string) {
agent.DSL.Sid = sid
}
// getAssistants get all assistant directories that have pages
func (agent *Agent) getAssistants() ([]string, error) {
if !agent.fs.IsDir(agent.assistantsRoot) {
return []string{}, nil
}
dirs, err := agent.fs.ReadDir(agent.assistantsRoot, false)
if err != nil {
return nil, err
}
assistants := []string{}
for _, dir := range dirs {
if !agent.fs.IsDir(dir) {
continue
}
// Check if this assistant has a pages directory
pagesDir := filepath.Join(dir, "pages")
if agent.fs.IsDir(pagesDir) {
name := filepath.Base(dir)
assistants = append(assistants, name)
}
}
return assistants, nil
}
// getAssistantPagesRoot get the pages root for an assistant
func (agent *Agent) getAssistantPagesRoot(assistantID string) string {
return filepath.Join(agent.assistantsRoot, assistantID, "pages")
}
// Exists check if the agent storage is available
func Exists() bool {
appFS, err := fs.Get("app")
if err != nil {
return false
}
return appFS.IsDir("/agent/template")
}
// HasAssistantPages check if any assistant has pages
func HasAssistantPages() bool {
appFS, err := fs.Get("app")
if err != nil {
return false
}
if !appFS.IsDir("/assistants") {
return false
}
dirs, err := appFS.ReadDir("/assistants", false)
if err != nil {
return false
}
for _, dir := range dirs {
if !appFS.IsDir(dir) {
continue
}
pagesDir := filepath.Join(dir, "pages")
if appFS.IsDir(pagesDir) {
return true
}
}
return false
}
// log helper
func init() {
_ = log.Debug
_ = strings.TrimPrefix
}

467
sui/storages/agent/page.go Normal file
View file

@ -0,0 +1,467 @@
package agent
import (
"fmt"
"os"
"path/filepath"
"time"
jsoniter "github.com/json-iterator/go"
"github.com/yaoapp/gou/application"
v8 "github.com/yaoapp/gou/runtime/v8"
"github.com/yaoapp/kun/log"
"github.com/yaoapp/yao/sui/core"
)
// Page wraps core.Page with agent-specific functionality
type Page struct {
*core.Page
tmpl *Template
assistantID string
pagesRoot string
}
// Load load the page content
func (page *Page) Load() error {
p := page.Page
fs := page.tmpl.agent.fs
// Set document from template
p.Document = page.tmpl.Document
// Read HTML
htmlFile := filepath.Join(p.Path, p.Codes.HTML.File)
if fs.IsFile(htmlFile) {
content, err := fs.ReadFile(htmlFile)
if err != nil {
return err
}
p.Codes.HTML.Code = string(content)
}
// Read CSS
cssFile := filepath.Join(p.Path, p.Codes.CSS.File)
if fs.IsFile(cssFile) {
content, err := fs.ReadFile(cssFile)
if err != nil {
return err
}
p.Codes.CSS.Code = string(content)
}
// Read JS
jsFile := filepath.Join(p.Path, p.Codes.JS.File)
if fs.IsFile(jsFile) {
content, err := fs.ReadFile(jsFile)
if err != nil {
return err
}
p.Codes.JS.Code = string(content)
}
// Read TS
tsFile := filepath.Join(p.Path, p.Codes.TS.File)
if fs.IsFile(tsFile) {
content, err := fs.ReadFile(tsFile)
if err != nil {
return err
}
p.Codes.TS.Code = string(content)
}
// Read DATA (JSON)
dataFile := filepath.Join(p.Path, p.Codes.DATA.File)
if fs.IsFile(dataFile) {
content, err := fs.ReadFile(dataFile)
if err != nil {
return err
}
p.Codes.DATA.Code = string(content)
}
// Read Config
confFile := filepath.Join(p.Path, p.Codes.CONF.File)
if fs.IsFile(confFile) {
content, err := fs.ReadFile(confFile)
if err != nil {
return err
}
p.Codes.CONF.Code = string(content)
// Parse config
var config core.PageConfig
if err := jsoniter.Unmarshal(content, &config); err == nil {
p.Config = &config
}
}
// Load backend script
err := page.loadScript()
if err != nil {
return err
}
return nil
}
// loadScript load the backend script
func (page *Page) loadScript() error {
p := page.Page
fs := page.tmpl.agent.fs
// Try .backend.ts first, then .backend.js
tsFile := filepath.Join(p.Path, fmt.Sprintf("%s.backend.ts", p.Name))
jsFile := filepath.Join(p.Path, fmt.Sprintf("%s.backend.js", p.Name))
var scriptFile string
if fs.IsFile(tsFile) {
scriptFile = tsFile
} else if fs.IsFile(jsFile) {
scriptFile = jsFile
}
if scriptFile == "" {
return nil
}
content, err := fs.ReadFile(scriptFile)
if err != nil {
return err
}
script, err := v8.MakeScript(content, scriptFile, 5*time.Second)
if err != nil {
return err
}
p.Script = &core.Script{Script: script}
return nil
}
// Get get the page info
func (page *Page) Get() *core.Page {
return page.Page
}
// GetConfig get the page config
func (page *Page) GetConfig() *core.PageConfig {
p := page.Page
if p.Config != nil {
return p.Config
}
// Try to load config if not loaded
fs := page.tmpl.agent.fs
confFile := filepath.Join(p.Path, p.Codes.CONF.File)
if fs.IsFile(confFile) {
content, err := fs.ReadFile(confFile)
if err != nil {
return nil
}
var config core.PageConfig
if err := jsoniter.Unmarshal(content, &config); err == nil {
p.Config = &config
return p.Config
}
}
return nil
}
// SaveTemp save the page temporarily (not supported for agent pages)
func (page *Page) SaveTemp(request *core.RequestSource) error {
return fmt.Errorf("SaveTemp is not supported for agent pages")
}
// Save save the page (not supported for agent pages)
func (page *Page) Save(request *core.RequestSource) error {
return fmt.Errorf("Save is not supported for agent pages")
}
// SaveAs save the page as (not supported for agent pages)
func (page *Page) SaveAs(route string, setting *core.PageSetting) (core.IPage, error) {
return nil, fmt.Errorf("SaveAs is not supported for agent pages")
}
// Remove remove the page (not supported for agent pages)
func (page *Page) Remove() error {
return fmt.Errorf("Remove is not supported for agent pages")
}
// SUI get the SUI interface
func (page *Page) SUI() (core.SUI, error) {
return page.tmpl.agent, nil
}
// Sid get the session id
func (page *Page) Sid() (string, error) {
return page.tmpl.agent.DSL.Sid, nil
}
// Template get the template
func (page *Page) Template() core.ITemplate {
return page.tmpl
}
// AssetScript get the script
func (page *Page) AssetScript() (*core.Asset, error) {
fs := page.tmpl.agent.fs
// Try .ts first, then .js
tsFile := filepath.Join(page.Path, page.Codes.TS.File)
if fs.IsFile(tsFile) {
tsCode, err := fs.ReadFile(tsFile)
if err != nil {
return nil, err
}
jsCode, _, err := page.CompileTS(tsCode, false)
if err != nil {
return nil, err
}
return &core.Asset{
Type: "text/javascript; charset=utf-8",
Content: []byte(jsCode),
}, nil
}
jsFile := filepath.Join(page.Path, page.Codes.JS.File)
if fs.IsFile(jsFile) {
jsCode, err := fs.ReadFile(jsFile)
if err != nil {
return nil, err
}
jsCode, _, err = page.CompileJS(jsCode, false)
if err != nil {
return nil, err
}
return &core.Asset{
Type: "text/javascript; charset=utf-8",
Content: jsCode,
}, nil
}
return nil, fmt.Errorf("%s script not found", page.Route)
}
// AssetStyle get the style
func (page *Page) AssetStyle() (*core.Asset, error) {
fs := page.tmpl.agent.fs
cssFile := filepath.Join(page.Path, page.Codes.CSS.File)
if fs.IsFile(cssFile) {
cssCode, err := fs.ReadFile(cssFile)
if err != nil {
return nil, err
}
cssCode, err = page.CompileCSS(cssCode, false)
if err != nil {
return nil, err
}
return &core.Asset{
Type: "text/css; charset=utf-8",
Content: cssCode,
}, nil
}
return nil, fmt.Errorf("%s style not found", page.Route)
}
// Build build the page
func (page *Page) Build(globalCtx *core.GlobalBuildContext, option *core.BuildOption) ([]string, error) {
ctx := core.NewBuildContext(globalCtx)
root := option.PublicRoot
if root == "" {
root = page.tmpl.agent.DSL.Public.Root
}
if option.AssetRoot == "" {
option.AssetRoot = filepath.Join(root, "assets")
}
page.Root = root
// Load page if not loaded
if page.Codes.HTML.Code == "" {
if err := page.Load(); err != nil {
return nil, err
}
}
html, config, warnings, err := page.Page.Compile(ctx, option)
if err != nil {
return warnings, fmt.Errorf("Compile the page %s error: %s", page.Route, err.Error())
}
// Save the html
err = page.writeHTML([]byte(html), option.Data)
if err != nil {
return warnings, fmt.Errorf("Write the page %s error: %s", page.Route, err.Error())
}
// Save the backend script file
err = page.writeBackendScript(option.Data)
if err != nil {
return warnings, fmt.Errorf("Write the backend script file error: %s", err.Error())
}
// Save the config file
err = page.writeConfig([]byte(config), option.Data)
if err != nil {
return warnings, err
}
return warnings, nil
}
// publicFile get the public file path
func (page *Page) publicFile(data map[string]interface{}) string {
root, err := page.tmpl.agent.DSL.PublicRoot(data)
if err != nil {
log.Error("publicFile: Get the public root error: %s. use %s", err.Error(), page.tmpl.agent.DSL.Public.Root)
root = page.tmpl.agent.DSL.Public.Root
}
return filepath.Join("/", "public", root, page.Route)
}
// writeHTML write the html to file
func (page *Page) writeHTML(html []byte, data map[string]interface{}) error {
htmlFile := fmt.Sprintf("%s.sui", page.publicFile(data))
htmlFileAbs := filepath.Join(application.App.Root(), htmlFile)
dir := filepath.Dir(htmlFileAbs)
if exist, _ := os.Stat(dir); exist == nil {
os.MkdirAll(dir, os.ModePerm)
}
err := os.WriteFile(htmlFileAbs, html, 0644)
if err != nil {
return err
}
core.RemoveCache(htmlFile)
return nil
}
// writeConfig write the config to file
func (page *Page) writeConfig(config []byte, data map[string]interface{}) error {
configFile := fmt.Sprintf("%s.cfg", page.publicFile(data))
configFileAbs := filepath.Join(application.App.Root(), configFile)
dir := filepath.Dir(configFileAbs)
if exist, _ := os.Stat(dir); exist == nil {
os.MkdirAll(dir, os.ModePerm)
}
err := os.WriteFile(configFileAbs, config, 0644)
if err != nil {
return err
}
return nil
}
// backendScriptSource get the backend script source
func (page *Page) backendScriptSource() (string, []byte, error) {
fs := page.tmpl.agent.fs
backendFile := filepath.Join(page.Path, fmt.Sprintf("%s.backend.ts", page.Name))
if !fs.IsFile(backendFile) {
backendFile = filepath.Join(page.Path, fmt.Sprintf("%s.backend.js", page.Name))
}
if !fs.IsFile(backendFile) {
return "", nil, nil
}
source, err := fs.ReadFile(backendFile)
if err != nil {
return "", nil, err
}
source = []byte(fmt.Sprintf("%s\n%s", source, core.BackendScript(page.Route)))
return backendFile, source, nil
}
// writeBackendScript write the backend script to file
func (page *Page) writeBackendScript(data map[string]interface{}) error {
file, source, err := page.backendScriptSource()
if err != nil {
return err
}
if source == nil {
return nil
}
ext := filepath.Ext(file)
scriptFile := fmt.Sprintf("%s.backend%s", page.publicFile(data), ext)
scriptFileAbs := filepath.Join(application.App.Root(), scriptFile)
dir := filepath.Dir(scriptFileAbs)
if exist, _ := os.Stat(dir); exist == nil {
os.MkdirAll(dir, os.ModePerm)
}
err = os.WriteFile(scriptFileAbs, []byte(source), 0644)
if err != nil {
return err
}
core.RemoveCache(scriptFile)
return nil
}
// BuildAsComponent build the page as component
func (page *Page) BuildAsComponent(globalCtx *core.GlobalBuildContext, option *core.BuildOption) ([]string, error) {
warnings := []string{}
if option.AssetRoot == "" {
root, err := page.tmpl.agent.DSL.PublicRoot(option.Data)
if err != nil {
return warnings, err
}
option.AssetRoot = root
}
// Load page if not loaded
if page.Codes.HTML.Code == "" {
if err := page.Load(); err != nil {
return nil, err
}
}
// BuildAsComponent needs a parent selection, which is handled by the caller
return warnings, nil
}
// Trans translate the page
func (page *Page) Trans(globalCtx *core.GlobalBuildContext, option *core.BuildOption) ([]string, error) {
warnings := []string{}
ctx := core.NewBuildContext(globalCtx)
_, _, messages, err := page.Page.Compile(ctx, option)
if err != nil {
return warnings, err
}
// Save translations - messages is []string, not map
log.Debug("[Agent] Page %s translation messages: %v", page.Route, messages)
return warnings, nil
}
// AssetRoot get the asset root for this page
func (page *Page) AssetRoot() string {
// If this is an assistant page, check for assistant-specific assets
if page.assistantID != "" {
assistantAssetsDir := filepath.Join(page.pagesRoot, "__assets")
if page.tmpl.agent.fs.IsDir(assistantAssetsDir) {
return fmt.Sprintf("/%s/assets", page.assistantID)
}
}
// Default to global agent assets
return "/assets"
}
// AssistantID get the assistant ID (empty for global agent pages)
func (page *Page) AssistantID() string {
return page.assistantID
}

View file

@ -0,0 +1,690 @@
package agent
import (
"fmt"
"io"
"net/url"
"os"
"path/filepath"
"strings"
"time"
"github.com/yaoapp/gou/application"
v8 "github.com/yaoapp/gou/runtime/v8"
"github.com/yaoapp/kun/log"
"github.com/yaoapp/yao/sui/core"
"golang.org/x/text/language"
)
// Template is the struct for the agent sui template
type Template struct {
Root string `json:"-"`
agent *Agent
locales []core.SelectOption
loaded map[string]core.IPage
*core.Template
}
// Pages get the pages from both /agent/pages and /assistants/*/pages
func (tmpl *Template) Pages() ([]core.IPage, error) {
pages := []core.IPage{}
// 1. Get pages from /agent/pages (global agent pages like login, error, etc.)
agentPagesDir := filepath.Join(tmpl.agent.root, "pages")
if tmpl.agent.fs.IsDir(agentPagesDir) {
agentPages, err := tmpl.getPagesFromDir(agentPagesDir, "")
if err != nil {
log.Error("[Agent] Failed to load agent pages: %v", err)
} else {
pages = append(pages, agentPages...)
}
}
// 2. Get pages from each assistant's pages directory
assistants, err := tmpl.agent.getAssistants()
if err != nil {
return nil, err
}
for _, assistantID := range assistants {
pagesDir := tmpl.agent.getAssistantPagesRoot(assistantID)
assistantPages, err := tmpl.getPagesFromDir(pagesDir, assistantID)
if err != nil {
log.Error("[Agent] Failed to load pages for assistant %s: %v", assistantID, err)
continue
}
pages = append(pages, assistantPages...)
}
return pages, nil
}
// getPagesFromDir get pages from a directory with optional route prefix
func (tmpl *Template) getPagesFromDir(dir string, routePrefix string) ([]core.IPage, error) {
exts := []string{"*.sui", "*.html", "*.htm", "*.page"}
pages := []core.IPage{}
tmpl.agent.fs.Walk(dir, func(root, file string, isdir bool) error {
name := filepath.Base(file)
if isdir {
if strings.HasPrefix(name, "__") || name == ".tmp" {
return filepath.SkipDir
}
return nil
}
if strings.HasPrefix(name, "__") {
return nil
}
page, err := tmpl.getPageFrom(file, dir, routePrefix)
if err != nil {
log.Error("[Agent] Get page error: %v", err)
return nil
}
pages = append(pages, page)
return nil
}, exts...)
return pages, nil
}
// getPageFrom create a page from file
func (tmpl *Template) getPageFrom(file, pagesRoot, assistantID string) (core.IPage, error) {
route := tmpl.getPageRoute(file, pagesRoot, assistantID)
return tmpl.getPage(route, file, pagesRoot, assistantID)
}
// getPageRoute get the route for a page
func (tmpl *Template) getPageRoute(file, pagesRoot, assistantID string) string {
// Get relative path from pages root
relPath := filepath.Dir(file[len(pagesRoot):])
// Add assistant prefix if this is an assistant page
if assistantID != "" {
return filepath.Join("/", assistantID, relPath)
}
return relPath
}
// getPage create a page object
func (tmpl *Template) getPage(route, file, pagesRoot, assistantID string) (core.IPage, error) {
path := filepath.Dir(file)
name := tmpl.getPageBase(route)
return &Page{
Page: &core.Page{
Route: route,
Path: path,
Name: name,
TemplateID: tmpl.ID,
SuiID: tmpl.agent.DSL.ID,
Codes: core.SourceCodes{
HTML: core.Source{File: fmt.Sprintf("%s%s", name, filepath.Ext(file))},
CSS: core.Source{File: fmt.Sprintf("%s.css", name)},
JS: core.Source{File: fmt.Sprintf("%s.js", name)},
DATA: core.Source{File: fmt.Sprintf("%s.json", name)},
TS: core.Source{File: fmt.Sprintf("%s.ts", name)},
LESS: core.Source{File: fmt.Sprintf("%s.less", name)},
CONF: core.Source{File: fmt.Sprintf("%s.config", name)},
},
},
tmpl: tmpl,
assistantID: assistantID,
pagesRoot: pagesRoot,
}, nil
}
func (tmpl *Template) getPageBase(route string) string {
return filepath.Base(route)
}
// Page get a specific page by route
func (tmpl *Template) Page(route string) (core.IPage, error) {
// Parse the route to determine if it's an assistant page or agent page
parts := strings.Split(strings.Trim(route, "/"), "/")
if len(parts) == 0 {
return nil, fmt.Errorf("Invalid route: %s", route)
}
// Check if first part is an assistant ID
assistants, err := tmpl.agent.getAssistants()
if err != nil {
return nil, err
}
assistantID := ""
pageRoute := route
pagesRoot := filepath.Join(tmpl.agent.root, "pages")
for _, ast := range assistants {
if parts[0] == ast {
assistantID = ast
pageRoute = "/" + strings.Join(parts[1:], "/")
pagesRoot = tmpl.agent.getAssistantPagesRoot(assistantID)
break
}
}
// Find the page file
pagePath := tmpl.getPagePath(pageRoute, pagesRoot)
exts := []string{".sui", ".html", ".htm", ".page"}
for _, ext := range exts {
file := fmt.Sprintf("%s%s", pagePath, ext)
if tmpl.agent.fs.IsFile(file) {
return tmpl.getPage(route, file, pagesRoot, assistantID)
}
}
return nil, fmt.Errorf("Page not found: %s", route)
}
func (tmpl *Template) getPagePath(route, pagesRoot string) string {
name := tmpl.getPageBase(route)
return filepath.Join(pagesRoot, route, name)
}
// PageExist check if page exists
func (tmpl *Template) PageExist(route string) bool {
_, err := tmpl.Page(route)
return err == nil
}
// RemovePage remove a page (not supported)
func (tmpl *Template) RemovePage(route string) error {
return fmt.Errorf("RemovePage is not supported for agent pages")
}
// GetPageFromAsset get page from asset
func (tmpl *Template) GetPageFromAsset(file string) (core.IPage, error) {
route := filepath.Dir(file)
return tmpl.Page(route)
}
// CreateEmptyPage create an empty page (not supported)
func (tmpl *Template) CreateEmptyPage(route string, setting *core.PageSetting) (core.IPage, error) {
return nil, fmt.Errorf("CreateEmptyPage is not supported for agent pages")
}
// CreatePage create a page from source (not supported for editing)
func (tmpl *Template) CreatePage(source string) core.IPage {
// This is used for rendering, we need to find the page by route
page, err := tmpl.Page(source)
if err != nil {
log.Error("[Agent] CreatePage error: %v", err)
return nil
}
return page
}
// GetRoot get the root path (returns agent root for assets, etc.)
func (tmpl *Template) GetRoot() string {
return tmpl.agent.root
}
// Asset get the asset (check agent assets first, then assistant assets)
func (tmpl *Template) Asset(file string, width, height uint) (*core.Asset, error) {
// First check in agent assets
agentFile := filepath.Join(tmpl.agent.root, "__assets", file)
if tmpl.agent.fs.IsFile(agentFile) {
return tmpl.readAsset(agentFile, width, height)
}
// If not found and this is an assistant-specific request, check assistant assets
// Format: /<assistant-id>/assets/...
parts := strings.SplitN(strings.TrimPrefix(file, "/"), "/", 2)
if len(parts) >= 2 {
assistantID := parts[0]
assetPath := parts[1]
// Check if this is a valid assistant
assistants, _ := tmpl.agent.getAssistants()
for _, ast := range assistants {
if ast == assistantID {
assistantAssetFile := filepath.Join(tmpl.agent.assistantsRoot, assistantID, "pages", "__assets", assetPath)
if tmpl.agent.fs.IsFile(assistantAssetFile) {
return tmpl.readAsset(assistantAssetFile, width, height)
}
break
}
}
}
return nil, fmt.Errorf("Asset %s not found", file)
}
// readAsset read asset from file
func (tmpl *Template) readAsset(file string, width, height uint) (*core.Asset, error) {
content, err := tmpl.agent.fs.ReadFile(file)
if err != nil {
return nil, err
}
typ, err := tmpl.agent.fs.MimeType(file)
if err != nil {
typ = "application/octet-stream"
}
return &core.Asset{Type: typ, Content: content}, nil
}
// Locales get the global locales
func (tmpl *Template) Locales() []core.SelectOption {
if tmpl.locales != nil {
return tmpl.locales
}
supportLocales := []core.SelectOption{}
localeMap := map[string]bool{}
// Check __locales directory
path := filepath.Join(tmpl.Root, "__locales")
if !tmpl.agent.fs.IsDir(path) {
return supportLocales
}
dirs, err := tmpl.agent.fs.ReadDir(path, false)
if err != nil {
return supportLocales
}
for _, dir := range dirs {
locale := filepath.Base(dir)
if localeMap[locale] {
continue
}
label := language.Make(locale).String()
localeMap[locale] = true
supportLocales = append(supportLocales, core.SelectOption{
Value: locale,
Label: label,
})
}
tmpl.locales = supportLocales
return tmpl.locales
}
// Themes get the global themes
func (tmpl *Template) Themes() []core.SelectOption {
return tmpl.Template.Themes
}
// Assets get the assets
func (tmpl *Template) Assets() []string {
return nil
}
// Glob the files
func (tmpl *Template) Glob(pattern string) ([]string, error) {
path := filepath.Join(tmpl.Root, pattern)
paths, err := tmpl.agent.fs.Glob(path)
if err != nil {
return nil, err
}
routes := []string{}
for _, p := range paths {
routes = append(routes, strings.TrimPrefix(p, tmpl.Root))
}
return routes, nil
}
// GlobRoutes the files
func (tmpl *Template) GlobRoutes(patterns []string, unique ...bool) ([]string, error) {
routes := []string{}
for _, pattern := range patterns {
paths, err := tmpl.Glob(pattern)
if err != nil {
return nil, err
}
for _, path := range paths {
if !tmpl.agent.fs.IsDir(filepath.Join(tmpl.Root, path)) {
continue
}
routes = append(routes, path)
}
}
if len(unique) > 0 && unique[0] {
mapRoutes := map[string]bool{}
for _, route := range routes {
mapRoutes[route] = true
}
routes = []string{}
for route := range mapRoutes {
routes = append(routes, route)
}
}
return routes, nil
}
// Reload the template
func (tmpl *Template) Reload() error {
return nil
}
// PageTree gets the page tree
func (tmpl *Template) PageTree(route string) ([]*core.PageTreeNode, error) {
return nil, nil
}
// MediaSearch search the asset
func (tmpl *Template) MediaSearch(query url.Values, page int, pageSize int) (core.MediaSearchResult, error) {
return core.MediaSearchResult{Data: []core.Media{}, Page: page, PageSize: pageSize}, nil
}
// AssetUpload upload the asset (not supported)
func (tmpl *Template) AssetUpload(reader io.Reader, name string) (string, error) {
return "", fmt.Errorf("AssetUpload is not supported for agent template")
}
// Block get the block (not supported)
func (tmpl *Template) Block(name string) (core.IBlock, error) {
return nil, fmt.Errorf("Block is not supported for agent template")
}
// Blocks get the blocks (not supported)
func (tmpl *Template) Blocks() ([]core.IBlock, error) {
return nil, nil
}
// BlockLayoutItems get block layout items
func (tmpl *Template) BlockLayoutItems() (*core.BlockLayoutItems, error) {
return nil, nil
}
// BlockMedia get block media
func (tmpl *Template) BlockMedia(id string) (*core.Asset, error) {
return nil, fmt.Errorf("BlockMedia is not supported for agent template")
}
// Component get the component (not supported)
func (tmpl *Template) Component(name string) (core.IComponent, error) {
return nil, fmt.Errorf("Component is not supported for agent template")
}
// Components get the components (not supported)
func (tmpl *Template) Components() ([]core.IComponent, error) {
return nil, nil
}
// SupportLocales get the support locales
func (tmpl *Template) SupportLocales() []string {
locales := tmpl.Locales()
result := make([]string, len(locales))
for i, locale := range locales {
result[i] = locale.Value
}
return result
}
// ExecBeforeBuildScripts execute the before build scripts
func (tmpl *Template) ExecBeforeBuildScripts() []core.TemplateScirptResult {
return nil
}
// ExecAfterBuildScripts execute the after build scripts
func (tmpl *Template) ExecAfterBuildScripts() []core.TemplateScirptResult {
return nil
}
// ExecBuildCompleteScripts execute the build complete scripts
func (tmpl *Template) ExecBuildCompleteScripts() []core.TemplateScirptResult {
return nil
}
// Build build the template
func (tmpl *Template) Build(option *core.BuildOption) ([]string, error) {
warnings := []string{}
// Execute before build scripts
tmpl.ExecBeforeBuildScripts()
root, err := tmpl.agent.DSL.PublicRoot(option.Data)
if err != nil {
log.Error("Build: Get the public root error: %s. use %s", err.Error(), tmpl.agent.DSL.Public.Root)
root = tmpl.agent.DSL.Public.Root
}
if option.AssetRoot == "" {
option.AssetRoot = filepath.Join(root, "assets")
}
option.PublicRoot = root
// Sync the assets
if err = tmpl.SyncAssets(option); err != nil {
return warnings, err
}
// Get all pages
pages, err := tmpl.Pages()
if err != nil {
return warnings, err
}
// Build global context
globalCtx := core.NewGlobalBuildContext(tmpl)
// Build each page
tmpl.loaded = map[string]core.IPage{}
for _, page := range pages {
if err := page.Load(); err != nil {
warnings = append(warnings, fmt.Sprintf("Failed to load page %s: %v", page.Get().Route, err))
continue
}
pageWarnings, err := page.Build(globalCtx, option)
if err != nil {
warnings = append(warnings, fmt.Sprintf("Failed to build page %s: %v", page.Get().Route, err))
continue
}
warnings = append(warnings, pageWarnings...)
tmpl.loaded[page.Get().Route] = page
}
// Add sui lib to the global
err = tmpl.UpdateJSSDK(option)
if err != nil {
return warnings, err
}
// Execute after build scripts
tmpl.ExecAfterBuildScripts()
return warnings, nil
}
// SyncAssets sync assets from template __assets to public
func (tmpl *Template) SyncAssets(option *core.BuildOption) error {
// Get source abs path
sourceRoot := filepath.Join(tmpl.agent.fs.Root(), tmpl.Root, "__assets")
if exist, _ := os.Stat(sourceRoot); exist == nil {
return nil
}
// Get target abs path
root, err := tmpl.agent.DSL.PublicRoot(option.Data)
if err != nil {
log.Error("SyncAssets: Get the public root error: %s. use %s", err.Error(), tmpl.agent.DSL.Public.Root)
root = tmpl.agent.DSL.Public.Root
}
targetRoot := filepath.Join(application.App.Root(), "public", root, "assets")
if exist, _ := os.Stat(targetRoot); exist == nil {
os.MkdirAll(targetRoot, os.ModePerm)
}
// Copy the assets
return tmpl.copyDir(sourceRoot, targetRoot)
}
// copyDir copy directory recursively
func (tmpl *Template) copyDir(src string, dst string) error {
return filepath.Walk(src, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
relPath, err := filepath.Rel(src, path)
if err != nil {
return err
}
targetPath := filepath.Join(dst, relPath)
if info.IsDir() {
return os.MkdirAll(targetPath, os.ModePerm)
}
// Copy file
data, err := os.ReadFile(path)
if err != nil {
return err
}
return os.WriteFile(targetPath, data, 0644)
})
}
// SyncAssetFile sync asset file
func (tmpl *Template) SyncAssetFile(file string, option *core.BuildOption) error {
sourceRoot := filepath.Join(tmpl.agent.fs.Root(), tmpl.Root, "__assets")
if exist, _ := os.Stat(sourceRoot); exist == nil {
return nil
}
root, err := tmpl.agent.DSL.PublicRoot(option.Data)
if err != nil {
log.Error("SyncAssetFile: Get the public root error: %s. use %s", err.Error(), tmpl.agent.DSL.Public.Root)
root = tmpl.agent.DSL.Public.Root
}
targetRoot := filepath.Join(application.App.Root(), "public", root, "assets")
sourceFile := filepath.Join(sourceRoot, file)
targetFile := filepath.Join(targetRoot, file)
// Create the target directory
dir := filepath.Dir(targetFile)
if exist, _ := os.Stat(dir); exist == nil {
os.MkdirAll(dir, os.ModePerm)
}
// Copy file
data, err := os.ReadFile(sourceFile)
if err != nil {
return err
}
return os.WriteFile(targetFile, data, 0644)
}
// UpdateJSSDK update the JS SDK (libsui.min.js)
func (tmpl *Template) UpdateJSSDK(option *core.BuildOption) error {
root, err := tmpl.agent.DSL.PublicRoot(option.Data)
if err != nil {
log.Error("UpdateJSSDK: Get the public root error: %s. use %s", err.Error(), tmpl.agent.DSL.Public.Root)
root = tmpl.agent.DSL.Public.Root
}
targetRoot := filepath.Join(application.App.Root(), "public", root, "assets")
if exist, _ := os.Stat(targetRoot); exist == nil {
os.MkdirAll(targetRoot, os.ModePerm)
}
// Get libsui source
libsui, libsuiMap, err := core.LibSUI()
if err != nil {
return err
}
// Write libsui.min.js
file := filepath.Join(targetRoot, "libsui.min.js")
err = os.WriteFile(file, libsui, 0644)
if err != nil {
return err
}
// Write libsui.min.js.map
mapFile := filepath.Join(targetRoot, "libsui.min.js.map")
err = os.WriteFile(mapFile, libsuiMap, 0644)
if err != nil {
return err
}
return nil
}
// Trans translate the template
func (tmpl *Template) Trans(option *core.BuildOption) ([]string, error) {
warnings := []string{}
// Get all pages
pages, err := tmpl.Pages()
if err != nil {
return warnings, err
}
// Build global context
globalCtx := core.NewGlobalBuildContext(tmpl)
// Translate each page
for _, page := range pages {
if err := page.Load(); err != nil {
warnings = append(warnings, fmt.Sprintf("Failed to load page %s: %v", page.Get().Route, err))
continue
}
pageWarnings, err := page.Trans(globalCtx, option)
if err != nil {
warnings = append(warnings, fmt.Sprintf("Failed to translate page %s: %v", page.Get().Route, err))
continue
}
warnings = append(warnings, pageWarnings...)
}
return warnings, nil
}
// loadBuildScript load the build script
func (tmpl *Template) loadBuildScript() error {
file, source, err := tmpl.backendScriptSource("__build.backend")
if err != nil {
return err
}
if file == "" {
return nil
}
script, err := v8.MakeScript(source, file, 5*time.Second)
if err != nil {
return err
}
tmpl.BuildScript = &core.Script{Script: script}
return nil
}
func (tmpl *Template) backendScriptSource(name string) (string, []byte, error) {
path := filepath.Join(tmpl.Root, fmt.Sprintf("%s.ts", name))
if !tmpl.agent.fs.IsFile(path) {
path = filepath.Join(tmpl.Root, fmt.Sprintf("%s.js", name))
}
if !tmpl.agent.fs.IsFile(path) {
return "", nil, nil
}
content, err := tmpl.agent.fs.ReadFile(path)
if err != nil {
return "", nil, err
}
return path, content, nil
}

View file

@ -0,0 +1,15 @@
package agent
import (
"github.com/yaoapp/gou/fs"
"github.com/yaoapp/yao/sui/core"
)
// Agent is the struct for the agent sui storage
// It extends local storage with special page loading from /assistants/<name>/pages/
type Agent struct {
root string // /agent
assistantsRoot string // /assistants
fs fs.FileSystem
*core.DSL
}