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:
parent
05cb4b9199
commit
787954f0af
17 changed files with 597 additions and 823 deletions
19
cmd/start.go
19
cmd/start.go
|
|
@ -24,6 +24,7 @@ import (
|
||||||
"github.com/yaoapp/kun/log"
|
"github.com/yaoapp/kun/log"
|
||||||
"github.com/yaoapp/yao/config"
|
"github.com/yaoapp/yao/config"
|
||||||
"github.com/yaoapp/yao/engine"
|
"github.com/yaoapp/yao/engine"
|
||||||
|
"github.com/yaoapp/yao/openapi"
|
||||||
ischedule "github.com/yaoapp/yao/schedule"
|
ischedule "github.com/yaoapp/yao/schedule"
|
||||||
"github.com/yaoapp/yao/service"
|
"github.com/yaoapp/yao/service"
|
||||||
"github.com/yaoapp/yao/setup"
|
"github.com/yaoapp/yao/setup"
|
||||||
|
|
@ -470,6 +471,11 @@ func printTasks(silent bool) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func printApis(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 {
|
if silent {
|
||||||
for _, api := range api.APIs {
|
for _, api := range api.APIs {
|
||||||
|
|
@ -478,7 +484,7 @@ func printApis(silent bool) {
|
||||||
}
|
}
|
||||||
log.Info("[API] %s(%d)", api.ID, len(api.HTTP.Paths))
|
log.Info("[API] %s(%d)", api.ID, len(api.HTTP.Paths))
|
||||||
for _, p := range 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
|
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(L("APIs List")))
|
||||||
fmt.Println(color.WhiteString("---------------------------------"))
|
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 {
|
if len(api.HTTP.Paths) <= 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
@ -505,7 +518,7 @@ func printApis(silent bool) {
|
||||||
for _, p := range api.HTTP.Paths {
|
for _, p := range api.HTTP.Paths {
|
||||||
fmt.Println(
|
fmt.Println(
|
||||||
colorMehtod(p.Method),
|
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)
|
"\tprocess:", p.Process)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
355
data/bindata.go
355
data/bindata.go
File diff suppressed because one or more lines are too long
|
|
@ -440,13 +440,40 @@ func (m *ScopeManager) addEndpointRule(method, path, action string, scopes []str
|
||||||
|
|
||||||
// Classify path type and add to appropriate index
|
// Classify path type and add to appropriate index
|
||||||
if strings.Contains(path, "*") {
|
if strings.Contains(path, "*") {
|
||||||
// Wildcard path
|
// Wildcard path - merge with existing if present (support multiple scopes per endpoint)
|
||||||
prefix := strings.TrimSuffix(path, "*")
|
prefix := strings.TrimSuffix(path, "*")
|
||||||
matcher.wildcardPaths = append(matcher.wildcardPaths, &WildcardPath{
|
merged := false
|
||||||
Pattern: path,
|
for _, existing := range matcher.wildcardPaths {
|
||||||
Prefix: prefix,
|
if existing.Pattern == path {
|
||||||
Endpoint: info,
|
// 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, ":") {
|
} else if strings.Contains(path, ":") {
|
||||||
// Parameter path - merge with existing if present (support multiple scopes per endpoint)
|
// Parameter path - merge with existing if present (support multiple scopes per endpoint)
|
||||||
if existing := matcher.paramPaths[path]; existing != nil {
|
if existing := matcher.paramPaths[path]; existing != nil {
|
||||||
|
|
@ -718,25 +745,37 @@ func (m *ScopeManager) matchParameterPath(pattern, path string) bool {
|
||||||
return true
|
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 {
|
func (m *ScopeManager) expandUserScopes(scopes []string) []string {
|
||||||
var expanded []string
|
var expanded []string
|
||||||
seen := make(map[string]bool)
|
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
|
// Check if it's an alias
|
||||||
if aliasScopes := m.aliasIndex[scope]; aliasScopes != nil {
|
if aliasScopes := m.aliasIndex[scope]; aliasScopes != nil {
|
||||||
|
// Add alias expansions to queue for further processing
|
||||||
for _, s := range aliasScopes {
|
for _, s := range aliasScopes {
|
||||||
if !seen[s] {
|
if !seen[s] {
|
||||||
expanded = append(expanded, s)
|
queue = append(queue, s)
|
||||||
seen[s] = true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if !seen[scope] {
|
// Not an alias, add to expanded list
|
||||||
expanded = append(expanded, scope)
|
expanded = append(expanded, scope)
|
||||||
seen[scope] = true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -745,9 +784,15 @@ func (m *ScopeManager) expandUserScopes(scopes []string) []string {
|
||||||
|
|
||||||
// matchesWildcardScope checks if a user scope (potentially with wildcards) matches a required scope
|
// matchesWildcardScope checks if a user scope (potentially with wildcards) matches a required scope
|
||||||
// Supports patterns like:
|
// 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:*:* matches resource:action:level
|
||||||
// - resource:action:* 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 {
|
func (m *ScopeManager) matchesWildcardScope(userScope, requiredScope string) bool {
|
||||||
// No wildcard, no match (exact match already checked)
|
// No wildcard, no match (exact match already checked)
|
||||||
if !strings.Contains(userScope, "*") {
|
if !strings.Contains(userScope, "*") {
|
||||||
|
|
@ -758,7 +803,7 @@ func (m *ScopeManager) matchesWildcardScope(userScope, requiredScope string) boo
|
||||||
userParts := strings.Split(userScope, ":")
|
userParts := strings.Split(userScope, ":")
|
||||||
requiredParts := strings.Split(requiredScope, ":")
|
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) {
|
if len(userParts) != len(requiredParts) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
package openapi
|
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
|
// attachWellKnown attaches the well-known handlers to the router
|
||||||
func (openapi *OpenAPI) attachWellKnown(router *gin.Engine) {
|
func (openapi *OpenAPI) attachWellKnown(router *gin.Engine) {
|
||||||
|
|
@ -8,6 +11,9 @@ func (openapi *OpenAPI) attachWellKnown(router *gin.Engine) {
|
||||||
// OAuth Discovery and Metadata Endpoints
|
// OAuth Discovery and Metadata Endpoints
|
||||||
wellKnown := router.Group("/.well-known")
|
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)
|
// OAuth Authorization Server Metadata - RFC 8414 (Required by MCP)
|
||||||
wellKnown.GET("/oauth-authorization-server", openapi.oauthServerMetadata)
|
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)
|
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
|
// oauthServerMetadata returns authorization server metadata - RFC 8414
|
||||||
func (openapi *OpenAPI) oauthServerMetadata(c *gin.Context) {}
|
func (openapi *OpenAPI) oauthServerMetadata(c *gin.Context) {}
|
||||||
|
|
||||||
|
|
|
||||||
87
service/dynamic.go
Normal file
87
service/dynamic.go
Normal 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
83
service/dynamic_test.go
Normal 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.
|
||||||
|
}
|
||||||
|
|
@ -6,7 +6,6 @@ import (
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/yaoapp/yao/helper"
|
"github.com/yaoapp/yao/helper"
|
||||||
"github.com/yaoapp/yao/openapi"
|
|
||||||
"github.com/yaoapp/yao/openapi/oauth"
|
"github.com/yaoapp/yao/openapi/oauth"
|
||||||
|
|
||||||
"github.com/yaoapp/yao/widgets/chart"
|
"github.com/yaoapp/yao/widgets/chart"
|
||||||
|
|
@ -16,7 +15,7 @@ import (
|
||||||
"github.com/yaoapp/yao/widgets/table"
|
"github.com/yaoapp/yao/widgets/table"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Guards middlewares
|
// Guards middlewares for traditional JWT mode
|
||||||
var Guards = map[string]gin.HandlerFunc{
|
var Guards = map[string]gin.HandlerFunc{
|
||||||
"bearer-jwt": guardBearerJWT, // Bearer JWT
|
"bearer-jwt": guardBearerJWT, // Bearer JWT
|
||||||
"query-jwt": guardQueryJWT, // Get JWT Token from query string "__tk"
|
"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
|
"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
|
// guardCookieTrace set sid cookie
|
||||||
func guardCookieTrace(c *gin.Context) {
|
func guardCookieTrace(c *gin.Context) {
|
||||||
sid, err := c.Cookie("sid")
|
sid, err := c.Cookie("sid")
|
||||||
|
|
@ -43,16 +60,8 @@ func guardCookieTrace(c *gin.Context) {
|
||||||
c.Set("__sid", sid)
|
c.Set("__sid", sid)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cookie Cookie JWT
|
// guardCookieJWT validates JWT token from cookie
|
||||||
func guardCookieJWT(c *gin.Context) {
|
func guardCookieJWT(c *gin.Context) {
|
||||||
|
|
||||||
// OpenAPI OAuth
|
|
||||||
if openapi.Server != nil {
|
|
||||||
guardOpenapiOauth(c)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Backward compatibility
|
|
||||||
tokenString, err := c.Cookie("__tk")
|
tokenString, err := c.Cookie("__tk")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(403, gin.H{"code": 403, "message": "Not Authorized"})
|
c.JSON(403, gin.H{"code": 403, "message": "Not Authorized"})
|
||||||
|
|
@ -70,16 +79,8 @@ func guardCookieJWT(c *gin.Context) {
|
||||||
c.Set("__sid", claims.SID)
|
c.Set("__sid", claims.SID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// JWT Bearer JWT
|
// guardBearerJWT validates Bearer JWT token from Authorization header
|
||||||
func guardBearerJWT(c *gin.Context) {
|
func guardBearerJWT(c *gin.Context) {
|
||||||
|
|
||||||
// OpenAPI OAuth
|
|
||||||
if openapi.Server != nil {
|
|
||||||
guardOpenapiOauth(c)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Backward compatibility
|
|
||||||
tokenString := c.Request.Header.Get("Authorization")
|
tokenString := c.Request.Header.Get("Authorization")
|
||||||
tokenString = strings.TrimSpace(strings.TrimPrefix(tokenString, "Bearer "))
|
tokenString = strings.TrimSpace(strings.TrimPrefix(tokenString, "Bearer "))
|
||||||
if tokenString == "" {
|
if tokenString == "" {
|
||||||
|
|
@ -117,52 +118,3 @@ func guardCrossOrigin(c *gin.Context) {
|
||||||
}
|
}
|
||||||
c.Next()
|
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
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -26,10 +26,16 @@ func withStaticFileServer(c *gin.Context) {
|
||||||
|
|
||||||
// Handle OpenAPI server
|
// Handle OpenAPI server
|
||||||
if openapi.Server != nil && openapi.Server.Config != nil && openapi.Server.Config.BaseURL != "" {
|
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+"/") {
|
if strings.HasPrefix(c.Request.URL.Path, openapi.Server.Config.BaseURL+"/") {
|
||||||
c.Next()
|
c.Next()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Well-known routes (OAuth discovery, Yao metadata, etc.)
|
||||||
|
if strings.HasPrefix(c.Request.URL.Path, "/.well-known/") {
|
||||||
|
c.Next()
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle API & websocket
|
// Handle API & websocket
|
||||||
|
|
|
||||||
|
|
@ -25,21 +25,39 @@ func Start(cfg config.Config) (*http.Server, error) {
|
||||||
|
|
||||||
router := gin.New()
|
router := gin.New()
|
||||||
router.Use(Middlewares...)
|
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{
|
srv := http.New(router, http.Option{
|
||||||
Host: cfg.Host,
|
Host: cfg.Host,
|
||||||
Port: cfg.Port,
|
Port: cfg.Port,
|
||||||
Root: "/api",
|
Root: apiRoot,
|
||||||
Allows: cfg.AllowFrom,
|
Allows: cfg.AllowFrom,
|
||||||
Timeout: 5 * time.Second,
|
Timeout: 5 * time.Second,
|
||||||
})
|
})
|
||||||
|
|
||||||
// OpenAPI Server
|
|
||||||
if openapi.Server != nil {
|
|
||||||
openapi.Server.Attach(router)
|
|
||||||
}
|
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
err = srv.Start()
|
err = srv.Start()
|
||||||
}()
|
}()
|
||||||
|
|
@ -51,8 +69,21 @@ func Start(cfg config.Config) (*http.Server, error) {
|
||||||
func Restart(srv *http.Server, cfg config.Config) error {
|
func Restart(srv *http.Server, cfg config.Config) error {
|
||||||
router := gin.New()
|
router := gin.New()
|
||||||
router.Use(Middlewares...)
|
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)
|
srv.Reset(router)
|
||||||
return srv.Restart()
|
return srv.Restart()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import (
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/yaoapp/yao/config"
|
"github.com/yaoapp/yao/config"
|
||||||
"github.com/yaoapp/yao/engine"
|
"github.com/yaoapp/yao/engine"
|
||||||
|
"github.com/yaoapp/yao/openapi"
|
||||||
"github.com/yaoapp/yao/test"
|
"github.com/yaoapp/yao/test"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -21,6 +22,11 @@ func TestStartStop(t *testing.T) {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Temporarily disable OpenAPI for this test
|
||||||
|
savedOpenAPIServer := openapi.Server
|
||||||
|
openapi.Server = nil
|
||||||
|
defer func() { openapi.Server = savedOpenAPIServer }()
|
||||||
|
|
||||||
srv, err := Start(cfg)
|
srv, err := Start(cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import (
|
||||||
"github.com/yaoapp/gou/server/http"
|
"github.com/yaoapp/gou/server/http"
|
||||||
"github.com/yaoapp/yao/config"
|
"github.com/yaoapp/yao/config"
|
||||||
"github.com/yaoapp/yao/engine"
|
"github.com/yaoapp/yao/engine"
|
||||||
|
"github.com/yaoapp/yao/openapi"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Watch the application code change for hot update
|
// 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))
|
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") {
|
if strings.HasPrefix(name, "/apis") {
|
||||||
err = Restart(srv, config.Conf)
|
if openapi.Server != nil {
|
||||||
if err != nil {
|
// OpenAPI mode: hot reload (no server restart needed)
|
||||||
fmt.Println(color.RedString("[Watch] Restart: %s", err.Error()))
|
err = ReloadAPIs()
|
||||||
return
|
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)
|
}, interrupt)
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,6 @@ var dsl = []byte(`
|
||||||
"label": "Run",
|
"label": "Run",
|
||||||
"description": "Run the backend script, with Api prefix method",
|
"description": "Run the backend script, with Api prefix method",
|
||||||
"path": "/run/*route",
|
"path": "/run/*route",
|
||||||
"guard": "-",
|
|
||||||
"method": "POST",
|
"method": "POST",
|
||||||
"process": "sui.Run",
|
"process": "sui.Run",
|
||||||
"in": [":context", "$param.route", ":payload"],
|
"in": [":context", "$param.route", ":payload"],
|
||||||
|
|
|
||||||
|
|
@ -114,6 +114,13 @@ func Run(process *process.Process) interface{} {
|
||||||
}
|
}
|
||||||
defer scriptCtx.Close()
|
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()
|
global := scriptCtx.Global()
|
||||||
if !global.Has(prefix + method) {
|
if !global.Has(prefix + method) {
|
||||||
exception.New("Method %s not found", 500, method).Throw()
|
exception.New("Method %s not found", 500, method).Throw()
|
||||||
|
|
|
||||||
|
|
@ -29,12 +29,6 @@ func LibSUI() ([]byte, []byte, error) {
|
||||||
return nil, nil, err
|
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
|
// Read openapi source code from bindata
|
||||||
openapi, err := data.Read("libsui/openapi.ts")
|
openapi, err := data.Read("libsui/openapi.ts")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -42,7 +36,7 @@ func LibSUI() ([]byte, []byte, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Merge the source code
|
// 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
|
// Build the source code
|
||||||
js, sm, err := transform.TypeScriptWithSourceMap(string(source), api.TransformOptions{
|
js, sm, err := transform.TypeScriptWithSourceMap(string(source), api.TransformOptions{
|
||||||
|
|
|
||||||
|
|
@ -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>;
|
|
||||||
}
|
|
||||||
|
|
@ -274,7 +274,7 @@ async function __sui_backend_call(
|
||||||
method: string,
|
method: string,
|
||||||
...args: any
|
...args: any
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
const url = `/api/__yao/sui/v1/run${route}`;
|
const url = `/v1/__yao/sui/v1/run${route}`;
|
||||||
headers = {
|
headers = {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
Referer: window.location.href,
|
Referer: window.location.href,
|
||||||
|
|
@ -369,7 +369,7 @@ async function __sui_render(
|
||||||
: option.route || window.location.pathname;
|
: option.route || window.location.pathname;
|
||||||
option.component = (routeAttr && comp.root.getAttribute("s:cn")) || "";
|
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 };
|
const payload = { name, data: _data, option };
|
||||||
|
|
||||||
// merge the user data
|
// merge the user data
|
||||||
|
|
|
||||||
|
|
@ -505,10 +505,11 @@ func processXgen(process *process.Process) interface{} {
|
||||||
layout = new
|
layout = new
|
||||||
}
|
}
|
||||||
|
|
||||||
|
apiBase := getAPIBase()
|
||||||
xgenLogin["entry"]["admin"] = admin.Layout.Entry
|
xgenLogin["entry"]["admin"] = admin.Layout.Entry
|
||||||
xgenLogin["admin"] = map[string]interface{}{
|
xgenLogin["admin"] = map[string]interface{}{
|
||||||
"captcha": "/api/__yao/login/admin/captcha?type=digit",
|
"captcha": fmt.Sprintf("%s/__yao/login/admin/captcha?type=digit", apiBase),
|
||||||
"login": "/api/__yao/login/admin",
|
"login": fmt.Sprintf("%s/__yao/login/admin", apiBase),
|
||||||
"layout": layout,
|
"layout": layout,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -541,10 +542,11 @@ func processXgen(process *process.Process) interface{} {
|
||||||
if new, ok := newLayout.(map[string]interface{}); ok {
|
if new, ok := newLayout.(map[string]interface{}); ok {
|
||||||
layout = new
|
layout = new
|
||||||
}
|
}
|
||||||
|
apiBase := getAPIBase()
|
||||||
xgenLogin["entry"]["user"] = user.Layout.Entry
|
xgenLogin["entry"]["user"] = user.Layout.Entry
|
||||||
xgenLogin["user"] = map[string]interface{}{
|
xgenLogin["user"] = map[string]interface{}{
|
||||||
"captcha": "/api/__yao/login/user/captcha?type=digit",
|
"captcha": fmt.Sprintf("%s/__yao/login/user/captcha?type=digit", apiBase),
|
||||||
"login": "/api/__yao/login/user",
|
"login": fmt.Sprintf("%s/__yao/login/user", apiBase),
|
||||||
"layout": layout,
|
"layout": layout,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -690,12 +692,24 @@ func processXgen(process *process.Process) interface{} {
|
||||||
"kb": kbConfig,
|
"kb": kbConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set logo and favicon with dynamic API base
|
||||||
|
apiBase := getAPIBase()
|
||||||
if Setting.Logo != "" {
|
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 != "" {
|
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)
|
setting, err := i18n.Trans(session.Lang(process, config.Conf.Lang), []string{"app.app"}, xgenSetting)
|
||||||
|
|
@ -726,20 +740,18 @@ func (dsl *DSL) replaceAdminRoot() error {
|
||||||
|
|
||||||
// icons
|
// icons
|
||||||
func (dsl *DSL) icons(cfg config.Config) {
|
func (dsl *DSL) icons(cfg config.Config) {
|
||||||
|
apiBase := getAPIBase()
|
||||||
dsl.Favicon = "/api/__yao/app/icons/app.ico"
|
dsl.Favicon = fmt.Sprintf("%s/__yao/app/icons/app.ico", apiBase)
|
||||||
dsl.Logo = "/api/__yao/app/icons/app.png"
|
dsl.Logo = fmt.Sprintf("%s/__yao/app/icons/app.png", apiBase)
|
||||||
log.Trace("CFG %v", cfg.Root)
|
log.Trace("CFG %v", cfg.Root)
|
||||||
|
}
|
||||||
|
|
||||||
// favicon := filepath.Join(cfg.Root, "icons", "app.ico")
|
// getAPIBase returns the API base path based on OpenAPI mode
|
||||||
// if _, err := os.Stat(favicon); err == nil {
|
func getAPIBase() string {
|
||||||
// dsl.Favicon = fmt.Sprintf("/api/__yao/app/icons/app.ico")
|
if openapi.Server != nil && openapi.Server.Config != nil && openapi.Server.Config.BaseURL != "" {
|
||||||
// }
|
return openapi.Server.Config.BaseURL
|
||||||
|
}
|
||||||
// logo := filepath.Join(cfg.Root, "icons", "app.png")
|
return "/api"
|
||||||
// if _, err := os.Stat(logo); err == nil {
|
|
||||||
// dsl.Logo = fmt.Sprintf("/api/__yao/app/icons/app.png")
|
|
||||||
// }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Permissions get the permission blacklist
|
// Permissions get the permission blacklist
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue