yao/sui/storages/agent/template.go
Max 0825e06dbe Implement Multi-Directory Watching and Enhance Locale File Handling
- Introduced the IWatchDirs interface to support watching multiple directories for changes, allowing templates to specify directories dynamically.
- Updated the watch command to handle multiple directories and display watched directories in the output.
- Added functionality to write locale files from a page's __locales directory to the public directory, improving localization support.
- Enhanced error handling during directory watching and locale file processing for better reliability and logging.
2026-01-03 14:39:57 +08:00

717 lines
18 KiB
Go

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
}
// GetWatchDirs returns all directories that should be watched for changes
// This implements the core.IWatchDirs interface
func (tmpl *Template) GetWatchDirs() []string {
dirs := []string{}
// 1. Add the main agent template directory
dirs = append(dirs, tmpl.agent.root)
// 2. Add each assistant's pages directory
assistants, err := tmpl.agent.getAssistants()
if err != nil {
return dirs
}
for _, assistantID := range assistants {
pagesDir := tmpl.agent.getAssistantPagesRoot(assistantID)
dirs = append(dirs, pagesDir)
}
return dirs
}
// GetWatchRoot returns "app" to indicate paths are relative to application source root
func (tmpl *Template) GetWatchRoot() string {
return "app"
}
// Asset get the asset (check agent assets first, then assistant assets)
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
}