diff --git a/agent/llm/providers/anthropic/anthropic.go b/agent/llm/providers/anthropic/anthropic.go index a1843d5c..decfb949 100644 --- a/agent/llm/providers/anthropic/anthropic.go +++ b/agent/llm/providers/anthropic/anthropic.go @@ -362,6 +362,23 @@ func (p *Provider) streamWithRetry(ctx *context.Context, messages []context.Mess Index: event.Index, } startToolCallMessage(msgTracker, toolCallInfo, handler) + + // Send initial ChunkToolCall with id and function name + // to match OpenAI format so CUI can resolve tool name from stored chunks + if handler != nil { + toolCallData, _ := jsoniter.Marshal([]map[string]interface{}{ + { + "index": event.Index, + "id": event.ContentBlock.ID, + "type": "function", + "function": map[string]interface{}{ + "name": event.ContentBlock.Name, + }, + }, + }) + handler(message.ChunkToolCall, toolCallData) + incrementChunk(msgTracker) + } } } diff --git a/cmd/start.go b/cmd/start.go index d27df94a..9b221474 100644 --- a/cmd/start.go +++ b/cmd/start.go @@ -7,7 +7,6 @@ import ( "path/filepath" "strings" "syscall" - "time" "github.com/fatih/color" "github.com/spf13/cobra" @@ -82,32 +81,15 @@ var startCmd = &cobra.Command{ config.Development() } - startTime := time.Now() - // load the application engine - var progressCallback func(string, string) - if config.Conf.Mode == "development" { - fmt.Println(color.CyanString("Loading application engine...")) - progressCallback = func(name string, duration string) { - fmt.Printf(" %s %s %s\n", color.GreenString("✓"), name, color.GreenString("(%s)", duration)) - } - } - loadWarnings, err := engine.Load(config.Conf, engine.LoadOption{ Action: "start", - }, progressCallback) + }) if err != nil { fmt.Println(color.RedString(L("Load: %s"), err.Error())) os.Exit(1) } - loadDuration := time.Since(startTime) - if config.Conf.Mode == "development" { - fmt.Printf("\n%s Engine loaded successfully in %s\n\n", - color.GreenString("✓"), - color.CyanString("%v", loadDuration)) - } - port := fmt.Sprintf(":%d", config.Conf.Port) if port == ":80" { port = "" @@ -205,12 +187,20 @@ var startCmd = &cobra.Command{ fmt.Println(color.WhiteString("\n---------------------------------")) fmt.Println(color.WhiteString(L("Access Points"))) fmt.Println(color.WhiteString("---------------------------------")) + apiRoot := "/api" + if openapi.Server != nil { + apiRoot = openapi.Server.Config.BaseURL + } for _, endpoint := range endpoints { fmt.Println(color.CyanString("\n%s", endpoint.Interface)) fmt.Println(color.WhiteString("--------------------------")) fmt.Println(color.WhiteString(L("Website")), color.GreenString(" %s", endpoint.URL)) - fmt.Println(color.WhiteString(L("Admin")), color.GreenString(" %s/%s/login/admin", endpoint.URL, strings.Trim(root, "/"))) - fmt.Println(color.WhiteString(L("API")), color.GreenString(" %s/api", endpoint.URL)) + fmt.Println(color.WhiteString(L("Dashboard")), color.GreenString(" %s/%s/auth/entry", endpoint.URL, strings.Trim(root, "/"))) + if openapi.Server != nil { + fmt.Println(color.WhiteString(L("OpenAPI")), color.GreenString(" %s%s", endpoint.URL, apiRoot)) + } else { + fmt.Println(color.WhiteString(L("API")), color.GreenString(" %s%s", endpoint.URL, apiRoot)) + } } fmt.Println("") @@ -472,17 +462,15 @@ func printApis(silent bool) { return } + // Skip detailed API list when OpenAPI is enabled + if openapi.Server != nil { + return + } + fmt.Println(color.WhiteString("\n---------------------------------")) fmt.Println(color.WhiteString(L("APIs List"))) fmt.Println(color.WhiteString("---------------------------------")) - // 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 diff --git a/service/middleware.go b/service/middleware.go index 12771702..6589c92c 100644 --- a/service/middleware.go +++ b/service/middleware.go @@ -77,7 +77,6 @@ func withStaticFileServer(c *gin.Context) { // Sui file server if strings.HasSuffix(c.Request.URL.Path, ".sui") { - // Default index.sui if filepath.Base(c.Request.URL.Path) == ".sui" { c.Request.URL.Path = strings.TrimSuffix(c.Request.URL.Path, ".sui") + "index.sui" @@ -94,12 +93,17 @@ func withStaticFileServer(c *gin.Context) { if err != nil { if code == 301 || code == 302 { url := err.Error() - // fmt.Println("Redirect to: ", url) c.Redirect(code, url) c.Done() return } + // Guard already sent response (e.g., OAuth writes its own 401) + if c.Writer.Written() { + c.Done() + return + } + log.Error("Sui Render Error: %s", err.Error()) c.AbortWithStatusJSON(code, gin.H{"code": code, "message": err.Error()}) return diff --git a/sui/api/guards.go b/sui/api/guards.go index 74606341..b53ce74b 100644 --- a/sui/api/guards.go +++ b/sui/api/guards.go @@ -97,6 +97,8 @@ func guardCookieTrace(r *Request) error { // OAuth 2.1 guard - authentication only // This guard validates the token and sets authorized info // ACL checks are performed separately in Run() for API calls +// NOTE: This guard does NOT write HTTP responses on failure, so that +// the caller (Guard/apiGuard) can handle redirects or custom error responses. func guardOAuth(r *Request) error { if r.context == nil { return fmt.Errorf("Context is nil") @@ -108,11 +110,22 @@ func guardOAuth(r *Request) error { c := r.context - // Authenticate only (validates token and sets authorized info) - if !oauth.OAuth.Authenticate(c) { - return fmt.Errorf("Not authenticated") + // Check token first without writing response. + // oauth.Authenticate() writes JSON + aborts on failure, which prevents + // the caller from doing redirects. So we check the token manually first. + token := oauth.OAuth.GetAccessToken(c) + if token == "" { + return fmt.Errorf("Exception|401:Not authenticated") } + if _, err := oauth.OAuth.VerifyToken(token); err != nil { + return fmt.Errorf("Exception|401:Invalid or expired token") + } + + // Token is valid, now call Authenticate to set up the full context + // (session ID, authorized info, etc.). This will succeed since token is valid. + oauth.OAuth.Authenticate(c) + // Get authorized info from context info := authorized.GetInfo(c) if info != nil { diff --git a/sui/api/request.go b/sui/api/request.go index a49716f2..941fbd6d 100644 --- a/sui/api/request.go +++ b/sui/api/request.go @@ -237,6 +237,13 @@ func (r *Request) MakeCache() (*core.Cache, int, error) { guardRedirect = parts[1] } + // Fallback: if guard has no redirect, check template default redirect + if guardRedirect == "" && guard != "" && guard != "-" { + if defaultRedirect, has := core.DefaultGuardRedirects[guard]; has { + guardRedirect = defaultRedirect + } + } + // Cache store cacheStore = conf.CacheStore cacheTime = conf.Cache @@ -303,8 +310,8 @@ func (r *Request) MakeCache() (*core.Cache, int, error) { // Guard the page func (r *Request) Guard(c *core.Cache) (int, error) { - // Guard not set - if c.Guard == "" || r.context == nil { + // Guard not set or explicitly disabled + if c.Guard == "" || c.Guard == "-" || r.context == nil { return 200, nil } @@ -312,32 +319,27 @@ func (r *Request) Guard(c *core.Cache) (int, error) { if guard, has := Guards[c.Guard]; has { err := guard(r) if err != nil { - // Redirect the page (should refector before release) + // Redirect the page (takes priority over guard's own response) if c.GuardRedirect != "" { redirect := c.GuardRedirect - data := core.Data{} - // Here may have a security issue, should be refector, in the future. - // Copy the script pointer to the request For page backend script execution - r.Request.Script = c.Script - if c.Data != "" { - data, err = r.Request.ExecString(c.Data) - if err != nil { - return 500, fmt.Errorf("data error, please re-complie the page %s", err.Error()) - } + + // Append error code and message as query parameters + ex := exception.Err(err, 403) + msg := url.QueryEscape(ex.Message) + if strings.Contains(redirect, "?") { + redirect = fmt.Sprintf("%s&code=%d&message=%s", redirect, ex.Code, msg) + } else { + redirect = fmt.Sprintf("%s?code=%d&message=%s", redirect, ex.Code, msg) } - if c.Global != "" { - global, err := r.Request.ExecString(c.Global) - if err != nil { - return 500, fmt.Errorf("global data error, please re-complie the page %s", err.Error()) - } - data["$global"] = global - } - - redirect, _ = data.Replace(redirect) return 302, fmt.Errorf("%s", redirect) } + // Guard already sent response (e.g., OAuth writes its own 401) + if r.context != nil && r.context.IsAborted() { + return 403, err + } + // Return the error ex := exception.Err(err, 403) return ex.Code, fmt.Errorf("%s", ex.Message) @@ -348,6 +350,10 @@ func (r *Request) Guard(c *core.Cache) (int, error) { // Developer custom guard err := r.processGuard(c.Guard) if err != nil { + // Guard already sent response + if r.context != nil && r.context.IsAborted() { + return 403, err + } ex := exception.Err(err, 403) return ex.Code, fmt.Errorf("%s", ex.Message) } diff --git a/sui/core/interfaces.go b/sui/core/interfaces.go index 109274a4..fdf22edd 100644 --- a/sui/core/interfaces.go +++ b/sui/core/interfaces.go @@ -9,6 +9,11 @@ import ( // SUIs the loaded SUI instances var SUIs = map[string]SUI{} +// DefaultGuardRedirects stores default guard redirect URLs from template configs. +// Key is guard name (e.g. "oauth"), value is redirect URL (e.g. "/dashboard/auth/entry"). +// Registered by template loading (e.g. agent storage) and used by MakeCache as fallback. +var DefaultGuardRedirects = map[string]string{} + // RouteMatchers the route matchers for the SUI instance var RouteMatchers = map[*regexp.Regexp][][]*Matcher{} diff --git a/sui/core/types.go b/sui/core/types.go index 91bf556e..2f43fa25 100644 --- a/sui/core/types.go +++ b/sui/core/types.go @@ -185,8 +185,9 @@ type Template struct { GlobalData []byte `json:"-"` Scripts *TemplateScirpts `json:"scripts,omitempty"` Translator string `json:"translator,omitempty"` - BuildScript *Script `json:"-"` // __build.backend.ts / __build.backend.js - GlobalScript *Script `json:"-"` // __global.backend.ts / __global.backend.js + Config *PageSetting `json:"config,omitempty"` // Default page config (guard, api, etc.) + BuildScript *Script `json:"-"` // __build.backend.ts / __build.backend.js + GlobalScript *Script `json:"-"` // __global.backend.ts / __global.backend.js } // TemplateScirpts is the struct for the template scripts diff --git a/sui/storages/agent/agent.go b/sui/storages/agent/agent.go index edd4dbbf..d5eb3c95 100644 --- a/sui/storages/agent/agent.go +++ b/sui/storages/agent/agent.go @@ -90,6 +90,12 @@ func (agent *Agent) GetTemplate(id string) (core.ITemplate, error) { } } + // Register default guard redirect from template config + if tmpl.Template.Config != nil && strings.Contains(tmpl.Template.Config.Guard, ":") { + parts := strings.SplitN(tmpl.Template.Config.Guard, ":", 2) + core.DefaultGuardRedirects[parts[0]] = parts[1] + } + // Load __document.html documentFile := filepath.Join(agent.root, "__document.html") if agent.fs.IsFile(documentFile) { diff --git a/sui/storages/agent/page.go b/sui/storages/agent/page.go index 067bba4e..f85df7c3 100644 --- a/sui/storages/agent/page.go +++ b/sui/storages/agent/page.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "path/filepath" + "strings" "time" jsoniter "github.com/json-iterator/go" @@ -157,17 +158,52 @@ func (page *Page) GetConfig() *core.PageConfig { if fs.IsFile(confFile) { content, err := fs.ReadFile(confFile) if err != nil { - return nil + return page.mergeTemplateConfig(nil) } var config core.PageConfig if err := jsoniter.Unmarshal(content, &config); err == nil { p.Config = &config - return p.Config + return page.mergeTemplateConfig(p.Config) } } - return nil + return page.mergeTemplateConfig(nil) +} + +// mergeTemplateConfig merges template default config into page config (page config takes priority). +// Use guard: "-" in page config to explicitly disable guard inheritance. +func (page *Page) mergeTemplateConfig(cfg *core.PageConfig) *core.PageConfig { + tmplConfig := page.tmpl.Template.Config + if tmplConfig == nil { + return cfg + } + + if cfg == nil { + cfg = &core.PageConfig{PageSetting: *tmplConfig} + page.Page.Config = cfg + return cfg + } + + // Merge guard (page config takes priority, "-" means explicitly no guard) + if cfg.Guard == "" { + // Page has no guard, use template's guard (with redirect) + cfg.Guard = tmplConfig.Guard + } else if !strings.Contains(cfg.Guard, ":") && strings.Contains(tmplConfig.Guard, ":") { + // Page has guard without redirect (e.g. "oauth"), template has redirect (e.g. "oauth:/login") + // Inherit redirect from template if same guard type + tmplParts := strings.SplitN(tmplConfig.Guard, ":", 2) + if tmplParts[0] == cfg.Guard { + cfg.Guard = tmplConfig.Guard + } + } + + // Merge API guard config + if cfg.API == nil && tmplConfig.API != nil { + cfg.API = tmplConfig.API + } + + return cfg } // SaveTemp save the page temporarily (not supported for agent pages) @@ -293,6 +329,9 @@ func (page *Page) Build(globalCtx *core.GlobalBuildContext, option *core.BuildOp } } + // Merge template default config before compile (page config takes priority) + page.GetConfig() + html, config, warnings, err := page.Page.Compile(ctx, option) if err != nil { return warnings, fmt.Errorf("Compile the page %s error: %s", page.Route, err.Error()) @@ -444,6 +483,9 @@ func (page *Page) Trans(globalCtx *core.GlobalBuildContext, option *core.BuildOp warnings := []string{} ctx := core.NewBuildContext(globalCtx) + // Merge template default config before compile + page.GetConfig() + _, _, messages, err := page.Page.Compile(ctx, option) if err != nil { return warnings, err diff --git a/widgets/app/app.go b/widgets/app/app.go index 3dc77881..36eb98cf 100644 --- a/widgets/app/app.go +++ b/widgets/app/app.go @@ -580,6 +580,36 @@ func processXgen(process *process.Process) interface{} { // agentConfig["connectors"] = connector.AIConnectors } + // External tools availability (safe subset for frontend) + toolsConfig := map[string]interface{}{} + if share.Tools != nil { + safeTool := func(info *share.ExtToolInfo) map[string]interface{} { + if info == nil { + return map[string]interface{}{"available": false} + } + return map[string]interface{}{ + "available": info.Available, + "name": info.Name, + } + } + toolsConfig["ffmpeg"] = safeTool(share.Tools.FFmpeg) + toolsConfig["ffprobe"] = safeTool(share.Tools.FFprobe) + toolsConfig["pdftoppm"] = safeTool(share.Tools.Pdftoppm) + toolsConfig["mutool"] = safeTool(share.Tools.Mutool) + toolsConfig["imagemagick"] = safeTool(share.Tools.ImageMagick) + + if share.Tools.Docker != nil { + docker := map[string]interface{}{ + "available": share.Tools.Docker.Available, + "name": "docker", + } + if share.Tools.Docker.Mode != "" { + docker["mode"] = share.Tools.Docker.Mode + } + toolsConfig["docker"] = docker + } + } + // OpenAPI Settings openapiConfig := map[string]interface{}{} if openapi.Server != nil { @@ -688,6 +718,7 @@ func processXgen(process *process.Process) interface{} { "optional": Setting.Optional, "login": xgenLogin, "agent": agentConfig, + "tools": toolsConfig, "openapi": openapiConfig, "kb": kbConfig, }