Enhance OpenAPI Integration and API Routing

- Introduced support for OpenAPI mode, allowing dynamic routing and OAuth guards for API endpoints.
- Updated API root path handling to accommodate OpenAPI configurations, ensuring consistent URL structures.
- Added well-known routes for Yao metadata and OAuth discovery, improving API discoverability.
- Refactored middleware and guards to streamline OpenAPI integration, enhancing overall service functionality.
- Removed deprecated agent TypeScript file, simplifying the codebase and improving maintainability.
This commit is contained in:
Max 2026-01-04 15:46:09 +08:00
parent 05cb4b9199
commit 787954f0af
17 changed files with 597 additions and 823 deletions

View file

@ -24,6 +24,7 @@ import (
"github.com/yaoapp/kun/log"
"github.com/yaoapp/yao/config"
"github.com/yaoapp/yao/engine"
"github.com/yaoapp/yao/openapi"
ischedule "github.com/yaoapp/yao/schedule"
"github.com/yaoapp/yao/service"
"github.com/yaoapp/yao/setup"
@ -470,6 +471,11 @@ func printTasks(silent bool) {
}
func printApis(silent bool) {
// Determine API root based on OpenAPI mode
apiRoot := "/api"
if openapi.Server != nil {
apiRoot = openapi.Server.Config.BaseURL
}
if silent {
for _, api := range api.APIs {
@ -478,7 +484,7 @@ func printApis(silent bool) {
}
log.Info("[API] %s(%d)", api.ID, len(api.HTTP.Paths))
for _, p := range api.HTTP.Paths {
log.Info("%s %s %s", p.Method, filepath.Join("/api", api.HTTP.Group, p.Path), p.Process)
log.Info("%s %s %s", p.Method, filepath.Join(apiRoot, api.HTTP.Group, p.Path), p.Process)
}
}
for name, upgrader := range websocket.Upgraders { // WebSocket
@ -491,7 +497,14 @@ func printApis(silent bool) {
fmt.Println(color.WhiteString(L("APIs List")))
fmt.Println(color.WhiteString("---------------------------------"))
for _, api := range api.APIs { // API信息
// Show OpenAPI mode info if enabled
if openapi.Server != nil {
fmt.Println(color.CyanString("\nOpenAPI Mode: %s", apiRoot))
fmt.Println(color.WhiteString("Developer APIs: %s/api/*", apiRoot))
fmt.Println(color.WhiteString("Widgets: %s/__yao/*", apiRoot))
}
for _, api := range api.APIs { // API info
if len(api.HTTP.Paths) <= 0 {
continue
}
@ -505,7 +518,7 @@ func printApis(silent bool) {
for _, p := range api.HTTP.Paths {
fmt.Println(
colorMehtod(p.Method),
color.WhiteString(filepath.Join("/api", api.HTTP.Group, p.Path)),
color.WhiteString(filepath.Join(apiRoot, api.HTTP.Group, p.Path)),
"\tprocess:", p.Process)
}
}

File diff suppressed because one or more lines are too long

View file

@ -440,13 +440,40 @@ func (m *ScopeManager) addEndpointRule(method, path, action string, scopes []str
// Classify path type and add to appropriate index
if strings.Contains(path, "*") {
// Wildcard path
// Wildcard path - merge with existing if present (support multiple scopes per endpoint)
prefix := strings.TrimSuffix(path, "*")
matcher.wildcardPaths = append(matcher.wildcardPaths, &WildcardPath{
Pattern: path,
Prefix: prefix,
Endpoint: info,
})
merged := false
for _, existing := range matcher.wildcardPaths {
if existing.Pattern == path {
// Endpoint already exists, merge scopes and constraints
existing.Endpoint.RequiredScopes = append(existing.Endpoint.RequiredScopes, info.RequiredScopes...)
existing.Endpoint.OwnerOnly = existing.Endpoint.OwnerOnly || info.OwnerOnly
existing.Endpoint.CreatorOnly = existing.Endpoint.CreatorOnly || info.CreatorOnly
existing.Endpoint.EditorOnly = existing.Endpoint.EditorOnly || info.EditorOnly
existing.Endpoint.TeamOnly = existing.Endpoint.TeamOnly || info.TeamOnly
if info.Extra != nil {
if existing.Endpoint.Extra == nil {
existing.Endpoint.Extra = make(map[string]interface{})
}
for key, value := range info.Extra {
existing.Endpoint.Extra[key] = deepCopyValue(value)
}
}
log.Trace("[ACL] Merged wildcard endpoint %s %s: scopes=%v, owner=%v, team=%v",
method, path, existing.Endpoint.RequiredScopes, existing.Endpoint.OwnerOnly, existing.Endpoint.TeamOnly)
merged = true
break
}
}
if !merged {
matcher.wildcardPaths = append(matcher.wildcardPaths, &WildcardPath{
Pattern: path,
Prefix: prefix,
Endpoint: info,
})
log.Trace("[ACL] Added wildcard endpoint %s %s: scopes=%v, owner=%v, team=%v",
method, path, info.RequiredScopes, info.OwnerOnly, info.TeamOnly)
}
} else if strings.Contains(path, ":") {
// Parameter path - merge with existing if present (support multiple scopes per endpoint)
if existing := matcher.paramPaths[path]; existing != nil {
@ -718,25 +745,37 @@ func (m *ScopeManager) matchParameterPath(pattern, path string) bool {
return true
}
// expandUserScopes expands user scopes by resolving aliases
// expandUserScopes expands user scopes by resolving aliases recursively
// This allows nested aliases like: system:root -> *:*:* -> matches any scope
func (m *ScopeManager) expandUserScopes(scopes []string) []string {
var expanded []string
seen := make(map[string]bool)
for _, scope := range scopes {
// Use a queue for iterative expansion to avoid deep recursion
queue := make([]string, len(scopes))
copy(queue, scopes)
for len(queue) > 0 {
scope := queue[0]
queue = queue[1:]
// Skip if already processed
if seen[scope] {
continue
}
seen[scope] = true
// Check if it's an alias
if aliasScopes := m.aliasIndex[scope]; aliasScopes != nil {
// Add alias expansions to queue for further processing
for _, s := range aliasScopes {
if !seen[s] {
expanded = append(expanded, s)
seen[s] = true
queue = append(queue, s)
}
}
} else {
if !seen[scope] {
expanded = append(expanded, scope)
seen[scope] = true
}
// Not an alias, add to expanded list
expanded = append(expanded, scope)
}
}
@ -745,9 +784,15 @@ func (m *ScopeManager) expandUserScopes(scopes []string) []string {
// matchesWildcardScope checks if a user scope (potentially with wildcards) matches a required scope
// Supports patterns like:
// - *:*:* matches everything
// - *:*:* matches any 3-part scope (e.g., sui:run:execute)
// - *:*:*:* matches any 4-part scope (e.g., sui:run:execute:all)
// - *:*:*:*:* matches any 5-part scope
// - resource:*:* matches resource:action:level
// - resource:action:* matches resource:action:level
// - resource:*:*:* matches resource:action:level:sublevel
//
// The wildcard pattern must have the same number of parts as the required scope.
// Use multiple wildcard patterns in alias.yml to cover different scope depths.
func (m *ScopeManager) matchesWildcardScope(userScope, requiredScope string) bool {
// No wildcard, no match (exact match already checked)
if !strings.Contains(userScope, "*") {
@ -758,7 +803,7 @@ func (m *ScopeManager) matchesWildcardScope(userScope, requiredScope string) boo
userParts := strings.Split(userScope, ":")
requiredParts := strings.Split(requiredScope, ":")
// If lengths don't match and user scope isn't full wildcard, no match
// Lengths must match for wildcard matching
if len(userParts) != len(requiredParts) {
return false
}

View file

@ -1,6 +1,9 @@
package openapi
import "github.com/gin-gonic/gin"
import (
"github.com/gin-gonic/gin"
"github.com/yaoapp/yao/share"
)
// attachWellKnown attaches the well-known handlers to the router
func (openapi *OpenAPI) attachWellKnown(router *gin.Engine) {
@ -8,6 +11,9 @@ func (openapi *OpenAPI) attachWellKnown(router *gin.Engine) {
// OAuth Discovery and Metadata Endpoints
wellKnown := router.Group("/.well-known")
// Yao Configuration Metadata - for client discovery
wellKnown.GET("/yao", openapi.yaoMetadata)
// OAuth Authorization Server Metadata - RFC 8414 (Required by MCP)
wellKnown.GET("/oauth-authorization-server", openapi.oauthServerMetadata)
@ -18,6 +24,51 @@ func (openapi *OpenAPI) attachWellKnown(router *gin.Engine) {
wellKnown.GET("/oauth-protected-resource", openapi.oauthProtectedResourceMetadata)
}
// YaoMetadata represents the Yao server configuration metadata
type YaoMetadata struct {
// Application information
Name string `json:"name,omitempty"`
Version string `json:"version,omitempty"`
Description string `json:"description,omitempty"`
// OpenAPI configuration
OpenAPI string `json:"openapi"` // OpenAPI base URL (e.g., "/v1")
IssuerURL string `json:"issuer_url,omitempty"` // OAuth issuer URL
// Dashboard configuration
Dashboard string `json:"dashboard,omitempty"` // Admin dashboard root path
Optional map[string]interface{} `json:"optional,omitempty"` // Optional settings
// Developer information
Developer *share.Developer `json:"developer,omitempty"`
}
// yaoMetadata returns Yao server configuration metadata
func (openapi *OpenAPI) yaoMetadata(c *gin.Context) {
// Get admin root path
dashboard := share.App.AdminRoot
if dashboard == "" {
dashboard = "yao"
}
metadata := YaoMetadata{
Name: share.App.Name,
Version: share.App.Version,
Description: share.App.Description,
OpenAPI: openapi.Config.BaseURL,
IssuerURL: openapi.Config.OAuth.IssuerURL,
Dashboard: "/" + dashboard,
Optional: share.App.Optional,
}
// Include developer info if available
if share.App.Developer.ID != "" || share.App.Developer.Name != "" {
metadata.Developer = &share.App.Developer
}
c.JSON(200, metadata)
}
// oauthServerMetadata returns authorization server metadata - RFC 8414
func (openapi *OpenAPI) oauthServerMetadata(c *gin.Context) {}

87
service/dynamic.go Normal file
View file

@ -0,0 +1,87 @@
package service
import (
"fmt"
"strings"
"github.com/gin-gonic/gin"
"github.com/yaoapp/gou/api"
)
// DynamicAPIHandler is a dynamic API proxy handler that dispatches requests
// to the appropriate handler based on the route table.
// This enables hot-reloading of API definitions without server restart.
func DynamicAPIHandler(c *gin.Context) {
path := c.Param("path")
method := c.Request.Method
// Ensure path starts with /
if !strings.HasPrefix(path, "/") {
path = "/" + path
}
// Find handler from route table
apiDef, pathDef, handler, params, err := api.FindHandler(method, path)
if err != nil {
c.JSON(404, gin.H{"code": 404, "message": "API not found"})
c.Abort()
return
}
// Set path parameters to gin.Context
for key, value := range params {
c.Params = append(c.Params, gin.Param{Key: key, Value: value})
}
// Apply guard
guard := pathDef.Guard
if guard == "" {
guard = apiDef.HTTP.Guard
}
if guard != "" && guard != "-" {
if err := applyGuard(c, guard); err != nil {
return // Guard already handled the response
}
}
// Execute the actual handler
handler(c)
}
// applyGuard applies the guard middleware(s) to the request
func applyGuard(c *gin.Context, guardName string) error {
guards := strings.Split(guardName, ",")
for _, name := range guards {
name = strings.TrimSpace(name)
if name == "" || name == "-" {
continue
}
// Get guard from HTTPGuards (set at Start time)
if handler, has := api.HTTPGuards[name]; has {
handler(c)
if c.IsAborted() {
return fmt.Errorf("guard aborted")
}
continue
}
// Custom guard via process
api.ProcessGuard(name)(c)
if c.IsAborted() {
return fmt.Errorf("guard aborted")
}
}
return nil
}
// ReloadAPIs reloads all API definitions from the apis directory
// This function is thread-safe and can be called at runtime
func ReloadAPIs() error {
err := api.ReloadAPIs("apis")
if err != nil {
return err
}
return nil
}

83
service/dynamic_test.go Normal file
View file

@ -0,0 +1,83 @@
package service_test
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/yaoapp/gou/api"
"github.com/yaoapp/yao/config"
"github.com/yaoapp/yao/engine"
"github.com/yaoapp/yao/openapi"
"github.com/yaoapp/yao/service"
)
func TestDynamicAPIHandler(t *testing.T) {
gin.SetMode(gin.ReleaseMode)
cfg := config.Conf
cfg.Port = 0
_, err := engine.Load(cfg, engine.LoadOption{})
if err != nil {
t.Fatal(err)
}
// Temporarily disable OpenAPI for this test
savedOpenAPIServer := openapi.Server
openapi.Server = nil
defer func() { openapi.Server = savedOpenAPIServer }()
// Set up guards
api.SetGuards(service.Guards)
// Load and build route table
api.BuildRouteTable()
// Create test router
router := gin.New()
router.Any("/api/*path", service.DynamicAPIHandler)
// Test: API not found
response := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/nonexistent", nil)
router.ServeHTTP(response, req)
assert.Equal(t, 404, response.Code)
// Test: Exact match (app setting API)
response = httptest.NewRecorder()
req, _ = http.NewRequest("GET", "/api/__yao/app/setting", nil)
router.ServeHTTP(response, req)
// Note: This may return 403 if guard is not satisfied, which is expected
assert.True(t, response.Code == 200 || response.Code == 403)
}
func TestReloadAPIs(t *testing.T) {
gin.SetMode(gin.ReleaseMode)
cfg := config.Conf
cfg.Port = 0
_, err := engine.Load(cfg, engine.LoadOption{})
if err != nil {
t.Fatal(err)
}
// Initial build
api.BuildRouteTable()
// Reload should not error
err = service.ReloadAPIs()
assert.NoError(t, err)
}
func TestGuardSelection(t *testing.T) {
// Verify traditional Guards exist
assert.NotNil(t, service.Guards["bearer-jwt"])
assert.NotNil(t, service.Guards["cookie-jwt"])
assert.NotNil(t, service.Guards["cross-origin"])
// Note: OpenAPIGuards() requires oauth.OAuth to be initialized,
// which happens during engine load with OpenAPI config.
// The guard mapping is tested implicitly through integration tests.
}

View file

@ -6,7 +6,6 @@ import (
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/yaoapp/yao/helper"
"github.com/yaoapp/yao/openapi"
"github.com/yaoapp/yao/openapi/oauth"
"github.com/yaoapp/yao/widgets/chart"
@ -16,7 +15,7 @@ import (
"github.com/yaoapp/yao/widgets/table"
)
// Guards middlewares
// Guards middlewares for traditional JWT mode
var Guards = map[string]gin.HandlerFunc{
"bearer-jwt": guardBearerJWT, // Bearer JWT
"query-jwt": guardQueryJWT, // Get JWT Token from query string "__tk"
@ -30,6 +29,24 @@ var Guards = map[string]gin.HandlerFunc{
"widget-dashboard": dashboard.Guard, // Widget Dashboard Guard
}
// OpenAPIGuards returns middlewares for OpenAPI OAuth mode
// All JWT-related guards are mapped to OAuth for backward compatibility
// This is a function because oauth.OAuth is initialized at runtime
func OpenAPIGuards() map[string]gin.HandlerFunc {
return map[string]gin.HandlerFunc{
"bearer-jwt": oauth.OAuth.Guard, // JWT -> OAuth
"query-jwt": oauth.OAuth.Guard, // JWT -> OAuth
"cookie-jwt": oauth.OAuth.Guard, // JWT -> OAuth
"cookie-trace": oauth.OAuth.Guard, // Session -> OAuth (OAuth manages sessions)
"cross-origin": guardCrossOrigin, // CORS remains unchanged
"widget-table": table.Guard, // Widget Guard remains unchanged
"widget-list": list.Guard, // Widget List Guard
"widget-form": form.Guard, // Widget Form Guard
"widget-chart": chart.Guard, // Widget Chart Guard
"widget-dashboard": dashboard.Guard, // Widget Dashboard Guard
}
}
// guardCookieTrace set sid cookie
func guardCookieTrace(c *gin.Context) {
sid, err := c.Cookie("sid")
@ -43,16 +60,8 @@ func guardCookieTrace(c *gin.Context) {
c.Set("__sid", sid)
}
// Cookie Cookie JWT
// guardCookieJWT validates JWT token from cookie
func guardCookieJWT(c *gin.Context) {
// OpenAPI OAuth
if openapi.Server != nil {
guardOpenapiOauth(c)
return
}
// Backward compatibility
tokenString, err := c.Cookie("__tk")
if err != nil {
c.JSON(403, gin.H{"code": 403, "message": "Not Authorized"})
@ -70,16 +79,8 @@ func guardCookieJWT(c *gin.Context) {
c.Set("__sid", claims.SID)
}
// JWT Bearer JWT
// guardBearerJWT validates Bearer JWT token from Authorization header
func guardBearerJWT(c *gin.Context) {
// OpenAPI OAuth
if openapi.Server != nil {
guardOpenapiOauth(c)
return
}
// Backward compatibility
tokenString := c.Request.Header.Get("Authorization")
tokenString = strings.TrimSpace(strings.TrimPrefix(tokenString, "Bearer "))
if tokenString == "" {
@ -117,52 +118,3 @@ func guardCrossOrigin(c *gin.Context) {
}
c.Next()
}
// Openapi Oauth
func guardOpenapiOauth(c *gin.Context) {
s := oauth.OAuth
token := getAccessToken(c)
if token == "" {
c.JSON(403, gin.H{"code": 403, "message": "Not Authorized"})
c.Abort()
return
}
// Validate the token
_, err := s.VerifyToken(token)
if err != nil {
c.JSON(403, gin.H{"code": 403, "message": "Not Authorized"})
c.Abort()
return
}
// Get the session ID
sid := getSessionID(c)
if sid == "" {
c.JSON(403, gin.H{"code": 403, "message": "Not Authorized"})
c.Abort()
return
}
c.Set("__sid", sid)
}
func getAccessToken(c *gin.Context) string {
token := c.GetHeader("Authorization")
if token == "" || token == "Bearer undefined" {
cookie, err := c.Cookie("__Host-access_token")
if err != nil {
return ""
}
token = cookie
}
return strings.TrimPrefix(token, "Bearer ")
}
func getSessionID(c *gin.Context) string {
sid, err := c.Cookie("__Host-session_id")
if err != nil {
return ""
}
return sid
}

View file

@ -26,10 +26,16 @@ func withStaticFileServer(c *gin.Context) {
// Handle OpenAPI server
if openapi.Server != nil && openapi.Server.Config != nil && openapi.Server.Config.BaseURL != "" {
// OpenAPI base URL routes
if strings.HasPrefix(c.Request.URL.Path, openapi.Server.Config.BaseURL+"/") {
c.Next()
return
}
// Well-known routes (OAuth discovery, Yao metadata, etc.)
if strings.HasPrefix(c.Request.URL.Path, "/.well-known/") {
c.Next()
return
}
}
// Handle API & websocket

View file

@ -25,21 +25,39 @@ func Start(cfg config.Config) (*http.Server, error) {
router := gin.New()
router.Use(Middlewares...)
api.SetGuards(Guards)
api.SetRoutes(router, "/api", cfg.AllowFrom...)
var apiRoot string
if openapi.Server != nil {
// OpenAPI mode: use OAuth guards and dynamic routing
apiRoot = openapi.Server.Config.BaseURL
api.SetGuards(OpenAPIGuards())
// Developer APIs: use dynamic proxy (supports hot-reload)
router.Any(apiRoot+"/api/*path", DynamicAPIHandler)
// Widgets and system APIs: static registration
api.SetRoutes(router, apiRoot, cfg.AllowFrom...)
// Build route table for dynamic lookup
api.BuildRouteTable()
// Attach OpenAPI built-in features
openapi.Server.Attach(router)
} else {
// Traditional mode: unchanged
apiRoot = "/api"
api.SetGuards(Guards)
api.SetRoutes(router, "/api", cfg.AllowFrom...)
}
srv := http.New(router, http.Option{
Host: cfg.Host,
Port: cfg.Port,
Root: "/api",
Root: apiRoot,
Allows: cfg.AllowFrom,
Timeout: 5 * time.Second,
})
// OpenAPI Server
if openapi.Server != nil {
openapi.Server.Attach(router)
}
go func() {
err = srv.Start()
}()
@ -51,8 +69,21 @@ func Start(cfg config.Config) (*http.Server, error) {
func Restart(srv *http.Server, cfg config.Config) error {
router := gin.New()
router.Use(Middlewares...)
api.SetGuards(Guards)
api.SetRoutes(router, "/api", cfg.AllowFrom...)
if openapi.Server != nil {
// OpenAPI mode
baseURL := openapi.Server.Config.BaseURL
api.SetGuards(OpenAPIGuards())
router.Any(baseURL+"/api/*path", DynamicAPIHandler)
api.SetRoutes(router, baseURL, cfg.AllowFrom...)
api.BuildRouteTable()
openapi.Server.Attach(router)
} else {
// Traditional mode: unchanged
api.SetGuards(Guards)
api.SetRoutes(router, "/api", cfg.AllowFrom...)
}
srv.Reset(router)
return srv.Restart()
}

View file

@ -7,6 +7,7 @@ import (
"github.com/stretchr/testify/assert"
"github.com/yaoapp/yao/config"
"github.com/yaoapp/yao/engine"
"github.com/yaoapp/yao/openapi"
"github.com/yaoapp/yao/test"
)
@ -21,6 +22,11 @@ func TestStartStop(t *testing.T) {
t.Fatal(err)
}
// Temporarily disable OpenAPI for this test
savedOpenAPIServer := openapi.Server
openapi.Server = nil
defer func() { openapi.Server = savedOpenAPIServer }()
srv, err := Start(cfg)
if err != nil {
t.Fatal(err)

View file

@ -9,6 +9,7 @@ import (
"github.com/yaoapp/gou/server/http"
"github.com/yaoapp/yao/config"
"github.com/yaoapp/yao/engine"
"github.com/yaoapp/yao/openapi"
)
// Watch the application code change for hot update
@ -36,14 +37,25 @@ func Watch(srv *http.Server, interrupt chan uint8) (err error) {
fmt.Println(color.GreenString("[Watch] Model: %s changed (Please run yao migrate manually)", name))
}
// Restart
// API changes: hot reload or restart
if strings.HasPrefix(name, "/apis") {
err = Restart(srv, config.Conf)
if err != nil {
fmt.Println(color.RedString("[Watch] Restart: %s", err.Error()))
return
if openapi.Server != nil {
// OpenAPI mode: hot reload (no server restart needed)
err = ReloadAPIs()
if err != nil {
fmt.Println(color.RedString("[Watch] Reload APIs: %s", err.Error()))
return
}
fmt.Println(color.GreenString("[Watch] APIs Reloaded"))
} else {
// Traditional mode: restart server
err = Restart(srv, config.Conf)
if err != nil {
fmt.Println(color.RedString("[Watch] Restart: %s", err.Error()))
return
}
fmt.Println(color.GreenString("[Watch] Restart Completed"))
}
fmt.Println(color.GreenString("[Watch] Restart Completed"))
}
}, interrupt)

View file

@ -24,7 +24,6 @@ var dsl = []byte(`
"label": "Run",
"description": "Run the backend script, with Api prefix method",
"path": "/run/*route",
"guard": "-",
"method": "POST",
"process": "sui.Run",
"in": [":context", "$param.route", ":payload"],

View file

@ -114,6 +114,13 @@ func Run(process *process.Process) interface{} {
}
defer scriptCtx.Close()
// Pass authorized info to V8 context
if authorized := process.GetAuthorized(); authorized != nil {
if authMap := authorized.AuthorizedToMap(); len(authMap) > 0 {
scriptCtx.WithAuthorized(authMap)
}
}
global := scriptCtx.Global()
if !global.Has(prefix + method) {
exception.New("Method %s not found", 500, method).Throw()

View file

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

View file

@ -1,501 +0,0 @@
/**
* Yao AI Agent Pure JavaScript SDK
* @author Max<max@iqka.com>
* @maintainer https://yaoapps.com
*/
/**
* Message structure for agent responses
*/
interface AgentMessage {
text: string;
type?: string;
done?: boolean;
is_neo?: boolean;
assistant_id?: string;
assistant_name?: string;
assistant_avatar?: string;
props?: Record<string, any>;
tool_id?: string;
new?: boolean;
delta?: boolean;
result?: any;
previous_assistant_id?: string;
}
/**
* Done event data structure
*/
type AgentDoneData = AgentMessage[];
/**
* Event handler function types
*/
interface MessageHandler {
(message: AgentMessage): void;
}
interface DoneHandler {
(messages: AgentDoneData): void;
}
/**
* Event types that can be listened to
*/
type AgentEvent = "message" | "done";
/**
* Event handlers record type
*/
interface EventHandlers {
message?: MessageHandler;
done?: DoneHandler;
}
class Agent {
private host: string;
private token: string;
private events: EventHandlers;
private assistant_id: string;
private chat_id?: string;
private es: EventSource | null;
private context: Record<string, any>;
private silent?: boolean = false;
private history_visible?: boolean = false;
/**
* Agent constructor
* @param option Agent initialization options
*/
constructor(assistant_id: string, option: AgentOption) {
this.host = option.host || "/api/__yao/neo";
this.token = option.token;
this.events = {};
this.assistant_id = assistant_id;
this.chat_id = option.chat_id;
this.es = null;
this.context = option.context || {};
// Set silent mode, default is true
if (option.silent !== undefined) {
this.silent =
option.silent === true ||
option.silent === "true" ||
option.silent === 1 ||
option.silent === "1"
? false
: true;
}
// Set history visible mode, default is false
if (option.history_visible !== undefined) {
this.history_visible =
option.history_visible === true ||
option.history_visible === "true" ||
option.history_visible === 1 ||
option.history_visible === "1"
? true
: false;
}
}
/**
* Generate a chat ID
* @returns A unique chat ID in the format of chat_[timestamp]_[random]
*/
private makeChatID(): string {
const random = Math.random().toString(36).substring(2, 15);
const ts = Date.now();
return `chat_${ts}_${random}`;
}
/**
* Register an event handler
* @param event Event type to listen for ("message" or "done")
* @param handler Function to handle the event
* @returns The Agent instance for chaining
*/
On<E extends AgentEvent>(
event: E,
handler: E extends "message" ? MessageHandler : DoneHandler
): Agent {
if (event === "message") {
this.events.message = handler as MessageHandler;
} else if (event === "done") {
this.events.done = handler as DoneHandler;
}
return this;
}
/**
* Cancel the agent
*/
Cancel() {
if (this.es) {
this.es.close();
this.es = null;
}
}
/**
* Call the AI Agent
* @param input Text message or input object with text and optional attachments
* @param args Additional arguments to pass to the agent
*/
async Call(input: AgentInput, ...args: any[]): Promise<any> {
return new Promise((resolve, reject) => {
const messages: AgentMessage[] = [];
let lastAssistant = {
assistant_id: null as string | null,
assistant_name: null as string | null,
assistant_avatar: null as string | null,
};
// Process input content
let content: AgentInputContent;
if (typeof input === "string") {
content = { text: input };
} else {
content = { text: input.text };
if (input.attachments && input.attachments.length > 0) {
content.attachments = input.attachments.map((attachment) => ({
name: attachment.name,
url: attachment.url,
type: attachment.type,
content_type: attachment.content_type,
bytes: attachment.bytes,
created_at: attachment.created_at,
file_id: attachment.file_id,
chat_id: attachment.chat_id,
assistant_id: attachment.assistant_id,
description: attachment.description,
}));
}
}
// Add context to the content
const context = { ...this.context, args };
const contentRaw = encodeURIComponent(JSON.stringify(content));
const contextRaw = encodeURIComponent(JSON.stringify(context));
const token = this.token;
const silent = this.silent ? "true" : "false";
const history_visible = this.history_visible ? "true" : "false";
const chatId = this.chat_id || this.makeChatID();
const assistantParam = `&assistant_id=${this.assistant_id}`;
const status_endpoint = `${this.host}/status?content=${contentRaw}&context=${contextRaw}&token=${token}&chat_id=${chatId}${assistantParam}`;
const endpoint = `${this.host}?client_type=jssdk&content=${contentRaw}&context=${contextRaw}&token=${token}&silent=${silent}&history_visible=${history_visible}&chat_id=${chatId}${assistantParam}`;
const handleError = async (error: any) => {
try {
const response = await fetch(status_endpoint, {
credentials: "include",
headers: { Accept: "application/json" },
});
if (response.status === 200 || response.status === 201) return;
const data = await response.json().catch(() => ({
message: `HTTP ${response.status}`,
}));
let errorMessage = "Network error, please try again later";
if (data?.message) {
errorMessage = data.message;
} else if (error.message?.includes("401")) {
errorMessage = "Session expired: Please login again";
} else if (error.message?.includes("403")) {
errorMessage =
"Access denied: Please check your permissions or login again";
} else if (error.message?.includes("500")) {
errorMessage =
"Server error: The service is temporarily unavailable";
} else if (error.message?.includes("404")) {
errorMessage =
"AI service not found: Please check your configuration";
} else if (error.name === "TypeError") {
errorMessage =
"Connection failed: Please check your network connection";
}
const messageHandler = this.events["message"] as MessageHandler;
if (messageHandler) {
messageHandler({
text: errorMessage,
type: "error",
is_neo: true,
done: true,
});
}
return reject(new Error(errorMessage));
} catch (statusError) {
const messageHandler = this.events["message"] as MessageHandler;
if (messageHandler) {
messageHandler({
text: "Service unavailable, please try again later",
type: "error",
is_neo: true,
done: true,
});
}
return reject(
new Error("Service unavailable, please try again later")
);
}
};
try {
let last_type: string | null = null;
const es = new EventSource(endpoint, { withCredentials: true });
this.es = es;
es.onopen = () => {};
es.onmessage = ({ data }: { data: string }) => {
try {
const formated_data = JSON.parse(data);
if (!formated_data) return;
const messageHandler = this.events["message"] as MessageHandler;
if (!messageHandler) return;
const {
tool_id,
begin,
type,
end,
text,
props,
done,
assistant_id,
assistant_name,
assistant_avatar,
new: is_new,
delta,
result,
} = formated_data;
// Handle action message type
if (type === "action") {
const { namespace, primary, data_item, action, extra } =
props || {};
if (action && Array.isArray(action)) {
const actionMessage = {
text: text || "",
type: "action",
props: {
namespace: namespace || "chat",
primary: primary || "id",
data_item: data_item || {},
action,
extra,
},
is_neo: true,
done: !!done,
};
messages.push(actionMessage);
messageHandler(actionMessage);
if (done) {
const doneHandler = this.events["done"] as DoneHandler;
doneHandler?.(messages);
es.close();
}
return resolve(result);
}
}
// Check if we need to create a new message
const shouldCreateNewMessage =
(type !== last_type &&
(!done || (done === true && (text || props)))) || // if type changed or done is true and there is text or props
messages.length === 0 ||
(assistant_id &&
messages[messages.length - 1].assistant_id !== assistant_id) ||
(is_new && !delta); // Only create new message if it's new and not a delta update
// Update last type
last_type = type;
// Update assistant information
if (assistant_id) lastAssistant.assistant_id = assistant_id;
if (assistant_name) lastAssistant.assistant_name = assistant_name;
if (assistant_avatar)
lastAssistant.assistant_avatar = assistant_avatar;
if (shouldCreateNewMessage) {
// Mark the last message as done if it exists
if (messages.length > 0 && messages[messages.length - 1].is_neo) {
messages[messages.length - 1] = {
...messages[messages.length - 1],
done: true,
};
}
// Create new message with all original properties
const newMessage = {
text: text || "",
type: type || "text",
props,
is_neo: true,
new: is_new, // Only set new if it's from the original message
tool_id,
result: result,
assistant_id: lastAssistant.assistant_id || undefined,
assistant_name: lastAssistant.assistant_name || undefined,
assistant_avatar: lastAssistant.assistant_avatar || undefined,
};
messages.push(newMessage);
messageHandler(newMessage);
// If the message is done, close the event source
if (done) {
const doneHandler = this.events["done"] as DoneHandler;
doneHandler?.(messages);
es.close();
return resolve(result);
}
return;
}
// Get current message (we know it exists because we checked messages.length above)
const current_answer = messages[messages.length - 1];
// Set previous assistant id
if (messages.length > 1) {
const previous_message = messages[messages.length - 2];
if (previous_message.assistant_id) {
current_answer.previous_assistant_id =
previous_message.assistant_id;
}
}
// Handle message completion (done flag is set)
if (done) {
if (text) {
current_answer.text = text;
}
if (type) {
current_answer.type = type;
}
if (props) {
current_answer.props = props;
}
// Set result if available
if (result) {
current_answer.result = result;
}
// Mark all previous neo messages as done
for (let i = messages.length - 1; i >= 0; i--) {
const message = messages[i];
if (message.is_neo) {
if (message.done) break;
messages[i] = { ...message, done: true };
}
}
const doneHandler = this.events["done"] as DoneHandler;
doneHandler?.(messages);
es.close();
return resolve(result);
}
// Skip processing if no content to update
if (!text && !props && !type) return;
// Update props if available
if (props) {
if (type === "think" || type === "tool") {
current_answer.props = {
...(current_answer.props || {}),
id: tool_id,
begin,
end,
};
} else {
current_answer.props = props;
}
}
// Handle text content
if (text) {
if (delta) {
current_answer.text = (current_answer.text || "") + text;
if (text.startsWith("\r")) {
current_answer.text = text.replace("\r", "");
}
} else {
current_answer.text = text;
}
}
// Send current message to handler
messageHandler(current_answer);
} catch (err) {
const errorMessage =
err.message || JSON.stringify(err) || "未知错误";
console.error("Failed to parse message:", err);
reject(new Error(errorMessage));
}
};
es.onerror = (ev) => {
handleError(ev);
es.close();
};
} catch (error) {
handleError(error);
}
});
}
}
/**
* Attachment information for file uploads
*/
interface AgentAttachment {
name: string;
url: string;
type: string;
content_type: string;
bytes: number;
created_at: string;
file_id: string;
chat_id?: string;
assistant_id?: string;
description?: string;
}
/**
* Input content structure for agent calls
*/
interface AgentInputContent {
text: string;
attachments?: AgentAttachment[];
}
/**
* Input type for agent calls, can be either a string or a structured input
*/
type AgentInput =
| string
| {
text: string;
attachments?: AgentAttachment[];
};
/**
* Agent initialization options
*/
interface AgentOption {
host?: string;
token: string;
silent?: boolean | string | number;
history_visible?: boolean | string | number;
chat_id?: string;
context?: Record<string, any>;
}

View file

@ -274,7 +274,7 @@ async function __sui_backend_call(
method: string,
...args: any
): Promise<any> {
const url = `/api/__yao/sui/v1/run${route}`;
const url = `/v1/__yao/sui/v1/run${route}`;
headers = {
"Content-Type": "application/json",
Referer: window.location.href,
@ -369,7 +369,7 @@ async function __sui_render(
: option.route || window.location.pathname;
option.component = (routeAttr && comp.root.getAttribute("s:cn")) || "";
const url = `/api/__yao/sui/v1/render${route}`;
const url = `/v1/__yao/sui/v1/render${route}`;
const payload = { name, data: _data, option };
// merge the user data

View file

@ -505,10 +505,11 @@ func processXgen(process *process.Process) interface{} {
layout = new
}
apiBase := getAPIBase()
xgenLogin["entry"]["admin"] = admin.Layout.Entry
xgenLogin["admin"] = map[string]interface{}{
"captcha": "/api/__yao/login/admin/captcha?type=digit",
"login": "/api/__yao/login/admin",
"captcha": fmt.Sprintf("%s/__yao/login/admin/captcha?type=digit", apiBase),
"login": fmt.Sprintf("%s/__yao/login/admin", apiBase),
"layout": layout,
}
@ -541,10 +542,11 @@ func processXgen(process *process.Process) interface{} {
if new, ok := newLayout.(map[string]interface{}); ok {
layout = new
}
apiBase := getAPIBase()
xgenLogin["entry"]["user"] = user.Layout.Entry
xgenLogin["user"] = map[string]interface{}{
"captcha": "/api/__yao/login/user/captcha?type=digit",
"login": "/api/__yao/login/user",
"captcha": fmt.Sprintf("%s/__yao/login/user/captcha?type=digit", apiBase),
"login": fmt.Sprintf("%s/__yao/login/user", apiBase),
"layout": layout,
}
@ -690,12 +692,24 @@ func processXgen(process *process.Process) interface{} {
"kb": kbConfig,
}
// Set logo and favicon with dynamic API base
apiBase := getAPIBase()
if Setting.Logo != "" {
xgenSetting["logo"] = Setting.Logo
// Replace /api/ prefix with current API base if needed
logo := Setting.Logo
if strings.HasPrefix(logo, "/api/") {
logo = apiBase + strings.TrimPrefix(logo, "/api")
}
xgenSetting["logo"] = logo
}
if Setting.Favicon != "" {
xgenSetting["favicon"] = Setting.Favicon
// Replace /api/ prefix with current API base if needed
favicon := Setting.Favicon
if strings.HasPrefix(favicon, "/api/") {
favicon = apiBase + strings.TrimPrefix(favicon, "/api")
}
xgenSetting["favicon"] = favicon
}
setting, err := i18n.Trans(session.Lang(process, config.Conf.Lang), []string{"app.app"}, xgenSetting)
@ -726,20 +740,18 @@ func (dsl *DSL) replaceAdminRoot() error {
// icons
func (dsl *DSL) icons(cfg config.Config) {
dsl.Favicon = "/api/__yao/app/icons/app.ico"
dsl.Logo = "/api/__yao/app/icons/app.png"
apiBase := getAPIBase()
dsl.Favicon = fmt.Sprintf("%s/__yao/app/icons/app.ico", apiBase)
dsl.Logo = fmt.Sprintf("%s/__yao/app/icons/app.png", apiBase)
log.Trace("CFG %v", cfg.Root)
}
// favicon := filepath.Join(cfg.Root, "icons", "app.ico")
// if _, err := os.Stat(favicon); err == nil {
// dsl.Favicon = fmt.Sprintf("/api/__yao/app/icons/app.ico")
// }
// logo := filepath.Join(cfg.Root, "icons", "app.png")
// if _, err := os.Stat(logo); err == nil {
// dsl.Logo = fmt.Sprintf("/api/__yao/app/icons/app.png")
// }
// getAPIBase returns the API base path based on OpenAPI mode
func getAPIBase() string {
if openapi.Server != nil && openapi.Server.Config != nil && openapi.Server.Config.BaseURL != "" {
return openapi.Server.Config.BaseURL
}
return "/api"
}
// Permissions get the permission blacklist