Enhance Attachment Process with Plain Text and UTF-8 Support

- Added tests for saving plain text and Chinese text directly, ensuring proper handling of content without data URI encoding.
- Updated the `parseDataURI` function to support plain text storage, returning the correct content type and data bytes.
- Improved the `getAssistants` method to support nested assistants, enhancing directory scanning for assistant pages.
- Refactored the `Page` method to accommodate assistant IDs with nested structures, improving route handling for pages.
This commit is contained in:
Max 2026-01-25 17:32:50 +08:00
parent 6d4388cf1b
commit ef63718941
5 changed files with 423 additions and 35 deletions

View file

@ -408,11 +408,12 @@ func processGetText(p *process.Process) interface{} {
// ============ Helper Functions ============
// parseDataURI parses a data URI or plain base64 string
// Returns content type, decoded data, and error
// parseDataURI parses content as either:
// 1. Data URI format: data:image/png;base64,xxxxx (decoded from base64)
// 2. Plain text: stored as-is with text/plain content type
//
// Returns content type, data bytes, and error
func parseDataURI(content string) (string, []byte, error) {
contentType := "application/octet-stream"
// Handle data URI format: data:image/png;base64,xxxxx
if strings.HasPrefix(content, "data:") {
// Split by comma to get the data part
@ -423,23 +424,27 @@ func parseDataURI(content string) (string, []byte, error) {
// Parse the header: data:image/png;base64
header := parts[0]
content = parts[1]
base64Content := parts[1]
// Extract content type from header
contentType := "application/octet-stream"
header = strings.TrimPrefix(header, "data:")
headerParts := strings.Split(header, ";")
if len(headerParts) > 0 && headerParts[0] != "" {
contentType = headerParts[0]
}
// Decode base64
data, err := base64.StdEncoding.DecodeString(base64Content)
if err != nil {
return "", nil, fmt.Errorf("failed to decode base64: %v", err)
}
return contentType, data, nil
}
// Decode base64
data, err := base64.StdEncoding.DecodeString(content)
if err != nil {
return "", nil, fmt.Errorf("failed to decode base64: %v", err)
}
return contentType, data, nil
// Plain text content - store as-is
return "text/plain", []byte(content), nil
}
// generateFilename generates a filename based on content type

View file

@ -214,6 +214,95 @@ func TestProcessSave(t *testing.T) {
t.Fatal("Expected error for invalid base64")
}
})
// Test 8: Save plain text directly (no data URI)
t.Run("SavePlainText", func(t *testing.T) {
content := "This is plain text content without data URI encoding."
p := process.New("attachment.Save", "data.local", content, "plain-text.txt")
result := processSave(p)
if err, ok := result.(error); ok {
t.Fatalf("Failed to save plain text: %v", err)
}
file, ok := result.(*File)
if !ok {
t.Fatalf("Expected *File, got %T", result)
}
if file.ID == "" {
t.Error("File ID should not be empty")
}
// Content type should be text/plain for plain text
if !strings.HasPrefix(file.ContentType, "text/plain") {
t.Errorf("Expected content type 'text/plain', got '%s'", file.ContentType)
}
// Read back and verify content
readP := process.New("attachment.Read", "data.local", file.ID)
readResult := processRead(readP)
dataURI, ok := readResult.(string)
if !ok {
t.Fatalf("Expected string, got %T: %v", readResult, readResult)
}
// Decode from data URI
parts := strings.SplitN(dataURI, ",", 2)
if len(parts) != 2 {
t.Fatalf("Invalid data URI format")
}
decoded, err := base64.StdEncoding.DecodeString(parts[1])
if err != nil {
t.Fatalf("Failed to decode: %v", err)
}
if string(decoded) != content {
t.Errorf("Content mismatch: expected %q, got %q", content, string(decoded))
}
})
// Test 9: Save Chinese text directly (UTF-8)
t.Run("SaveChineseText", func(t *testing.T) {
content := "这是一段中文内容测试UTF-8编码。\n第二行内容。"
p := process.New("attachment.Save", "data.local", content, "chinese.txt")
result := processSave(p)
if err, ok := result.(error); ok {
t.Fatalf("Failed to save Chinese text: %v", err)
}
file, ok := result.(*File)
if !ok {
t.Fatalf("Expected *File, got %T", result)
}
// Read back and verify content
readP := process.New("attachment.Read", "data.local", file.ID)
readResult := processRead(readP)
dataURI, ok := readResult.(string)
if !ok {
t.Fatalf("Expected string, got %T: %v", readResult, readResult)
}
// Decode from data URI
parts := strings.SplitN(dataURI, ",", 2)
if len(parts) != 2 {
t.Fatalf("Invalid data URI format")
}
decoded, err := base64.StdEncoding.DecodeString(parts[1])
if err != nil {
t.Fatalf("Failed to decode: %v", err)
}
if string(decoded) != content {
t.Errorf("Chinese content mismatch: expected %q, got %q", content, string(decoded))
}
})
}
func TestProcessRead(t *testing.T) {

View file

@ -150,37 +150,92 @@ func (agent *Agent) WithSid(sid string) {
agent.DSL.Sid = sid
}
// getAssistants get all assistant directories that have pages
// getAssistants get all assistant directories that have pages (supports nested assistants)
// Returns assistant IDs like: ["expense", "tasks", "tests.nested.demo"]
// Nested paths are joined with "." to form the assistant ID
func (agent *Agent) getAssistants() ([]string, error) {
if !agent.fs.IsDir(agent.assistantsRoot) {
return []string{}, nil
}
dirs, err := agent.fs.ReadDir(agent.assistantsRoot, false)
assistants := []string{}
err := agent.scanAssistantsRecursive(agent.assistantsRoot, "", &assistants)
if err != nil {
return nil, err
}
assistants := []string{}
for _, dir := range dirs {
if !agent.fs.IsDir(dir) {
continue
}
// Check if this assistant has a pages directory
pagesDir := filepath.Join(dir, "pages")
if agent.fs.IsDir(pagesDir) {
name := filepath.Base(dir)
assistants = append(assistants, name)
}
}
return assistants, nil
}
// scanAssistantsRecursive recursively scans directories for assistants with pages
// prefix is the accumulated path prefix (e.g., "tests.nested")
func (agent *Agent) scanAssistantsRecursive(dir string, prefix string, assistants *[]string) error {
dirs, err := agent.fs.ReadDir(dir, false)
if err != nil {
return err
}
for _, subdir := range dirs {
if !agent.fs.IsDir(subdir) {
continue
}
name := filepath.Base(subdir)
// Skip hidden directories and special directories
if strings.HasPrefix(name, ".") || strings.HasPrefix(name, "__") {
continue
}
// Build the assistant ID with prefix
assistantID := name
if prefix != "" {
assistantID = prefix + "." + name
}
// Check if this directory has a pages subdirectory
pagesDir := filepath.Join(subdir, "pages")
if agent.fs.IsDir(pagesDir) {
*assistants = append(*assistants, assistantID)
}
// Recursively scan subdirectories for nested assistants
// Only scan if there's no pages directory (to avoid scanning inside pages/)
// or if there are other subdirectories that might contain nested assistants
if !agent.fs.IsDir(pagesDir) {
err := agent.scanAssistantsRecursive(subdir, assistantID, assistants)
if err != nil {
log.Warn("[Agent] Error scanning subdirectory %s: %v", subdir, err)
continue
}
} else {
// Even if this has pages, check for nested assistants in other subdirectories
subdirs, _ := agent.fs.ReadDir(subdir, false)
for _, nested := range subdirs {
nestedName := filepath.Base(nested)
if agent.fs.IsDir(nested) && nestedName != "pages" &&
!strings.HasPrefix(nestedName, ".") &&
!strings.HasPrefix(nestedName, "__") {
err := agent.scanAssistantsRecursive(nested, assistantID+"."+nestedName, assistants)
if err != nil {
log.Warn("[Agent] Error scanning nested directory %s: %v", nested, err)
continue
}
}
}
}
}
return nil
}
// getAssistantPagesRoot get the pages root for an assistant
// assistantID can be "expense" or "tests.nested.demo"
// Returns the actual filesystem path like "/assistants/tests/nested/demo/pages"
func (agent *Agent) getAssistantPagesRoot(assistantID string) string {
return filepath.Join(agent.assistantsRoot, assistantID, "pages")
// Convert dot notation to path: "tests.nested.demo" -> "tests/nested/demo"
pathParts := strings.Split(assistantID, ".")
assistantPath := filepath.Join(pathParts...)
return filepath.Join(agent.assistantsRoot, assistantPath, "pages")
}
// Exists check if the agent storage is available
@ -192,7 +247,7 @@ func Exists() bool {
return appFS.IsDir("/agent/template")
}
// HasAssistantPages check if any assistant has pages
// HasAssistantPages check if any assistant has pages (supports nested assistants)
func HasAssistantPages() bool {
appFS, err := fs.Get("app")
if err != nil {
@ -203,19 +258,37 @@ func HasAssistantPages() bool {
return false
}
dirs, err := appFS.ReadDir("/assistants", false)
return hasAssistantPagesRecursive(appFS, "/assistants")
}
// hasAssistantPagesRecursive recursively checks for assistants with pages
func hasAssistantPagesRecursive(appFS fs.FileSystem, dir string) bool {
dirs, err := appFS.ReadDir(dir, false)
if err != nil {
return false
}
for _, dir := range dirs {
if !appFS.IsDir(dir) {
for _, subdir := range dirs {
if !appFS.IsDir(subdir) {
continue
}
pagesDir := filepath.Join(dir, "pages")
name := filepath.Base(subdir)
// Skip hidden directories and special directories
if strings.HasPrefix(name, ".") || strings.HasPrefix(name, "__") {
continue
}
// Check if this directory has a pages subdirectory
pagesDir := filepath.Join(subdir, "pages")
if appFS.IsDir(pagesDir) {
return true
}
// Recursively check subdirectories
if hasAssistantPagesRecursive(appFS, subdir) {
return true
}
}
return false

View file

@ -0,0 +1,214 @@
package agent
import (
"sort"
"testing"
"github.com/stretchr/testify/assert"
"github.com/yaoapp/yao/config"
"github.com/yaoapp/yao/sui/core"
"github.com/yaoapp/yao/test"
)
func TestAgentExists(t *testing.T) {
prepare(t)
defer clean()
exists := Exists()
assert.True(t, exists, "Agent template should exist")
}
func TestHasAssistantPages(t *testing.T) {
prepare(t)
defer clean()
hasPages := HasAssistantPages()
assert.True(t, hasPages, "Should have assistant pages")
}
func TestGetAssistants(t *testing.T) {
prepare(t)
defer clean()
agent := createAgent(t)
assistants, err := agent.getAssistants()
assert.Nil(t, err)
assert.NotEmpty(t, assistants)
// Sort for consistent comparison
sort.Strings(assistants)
// Should include both direct and nested assistants
// Direct: tests.sui-pages (has pages directly)
// Nested: tests.nested.demo (nested assistant with pages)
found := map[string]bool{}
for _, ast := range assistants {
found[ast] = true
}
assert.True(t, found["tests.sui-pages"], "Should find tests.sui-pages assistant")
assert.True(t, found["tests.nested.demo"], "Should find tests.nested.demo assistant")
}
func TestGetAssistantPagesRoot(t *testing.T) {
prepare(t)
defer clean()
agent := createAgent(t)
// Test direct assistant
root := agent.getAssistantPagesRoot("tests.sui-pages")
assert.Equal(t, "/assistants/tests/sui-pages/pages", root)
// Test nested assistant
root = agent.getAssistantPagesRoot("tests.nested.demo")
assert.Equal(t, "/assistants/tests/nested/demo/pages", root)
}
func TestGetTemplate(t *testing.T) {
prepare(t)
defer clean()
agent := createAgent(t)
tmpl, err := agent.GetTemplate("agent")
assert.Nil(t, err)
assert.NotNil(t, tmpl)
assert.Equal(t, "agent", tmpl.(*Template).ID)
}
func TestTemplatePages(t *testing.T) {
prepare(t)
defer clean()
agent := createAgent(t)
tmpl, err := agent.GetTemplate("agent")
assert.Nil(t, err)
pages, err := tmpl.Pages()
assert.Nil(t, err)
assert.NotEmpty(t, pages)
// Check that we have pages from nested assistants
routes := map[string]bool{}
for _, page := range pages {
routes[page.Get().Route] = true
}
// Should have pages from:
// 1. Agent global pages (/index)
// 2. Direct assistant (tests.sui-pages) -> /tests.sui-pages/dashboard
// 3. Nested assistant (tests.nested.demo) -> /tests.nested.demo/article
assert.True(t, routes["/index"], "Should have agent global page /index")
assert.True(t, routes["/tests.sui-pages/dashboard"], "Should have direct assistant page /tests.sui-pages/dashboard")
assert.True(t, routes["/tests.nested.demo/article"], "Should have nested assistant page /tests.nested.demo/article")
}
func TestTemplatePage(t *testing.T) {
prepare(t)
defer clean()
agent := createAgent(t)
tmpl, err := agent.GetTemplate("agent")
assert.Nil(t, err)
// Test getting agent global page
page, err := tmpl.Page("/index")
assert.Nil(t, err)
assert.NotNil(t, page)
assert.Equal(t, "/index", page.Get().Route)
// Test getting direct assistant page
page, err = tmpl.Page("/tests.sui-pages/dashboard")
assert.Nil(t, err)
assert.NotNil(t, page)
assert.Equal(t, "/tests.sui-pages/dashboard", page.Get().Route)
assert.Equal(t, "tests.sui-pages", page.(*Page).assistantID)
// Test getting nested assistant page
page, err = tmpl.Page("/tests.nested.demo/article")
assert.Nil(t, err)
assert.NotNil(t, page)
assert.Equal(t, "/tests.nested.demo/article", page.Get().Route)
assert.Equal(t, "tests.nested.demo", page.(*Page).assistantID)
// Test page not found
_, err = tmpl.Page("/non-existent/page")
assert.NotNil(t, err)
assert.Contains(t, err.Error(), "not found")
}
func TestPageLoad(t *testing.T) {
prepare(t)
defer clean()
agent := createAgent(t)
tmpl, err := agent.GetTemplate("agent")
assert.Nil(t, err)
// Test loading nested assistant page
page, err := tmpl.Page("/tests.nested.demo/article")
assert.Nil(t, err)
err = page.Load()
assert.Nil(t, err)
// Check that content was loaded
p := page.Get()
assert.NotEmpty(t, p.Codes.HTML.Code, "HTML code should be loaded")
assert.NotEmpty(t, p.Codes.CSS.Code, "CSS code should be loaded")
}
func TestPageBuild(t *testing.T) {
prepare(t)
defer clean()
agent := createAgent(t)
// Register the agent SUI so page build can find it
core.SUIs["agent"] = agent
tmpl, err := agent.GetTemplate("agent")
assert.Nil(t, err)
// Test building nested assistant page
page, err := tmpl.Page("/tests.nested.demo/article")
assert.Nil(t, err)
err = page.Load()
assert.Nil(t, err)
ctx := core.NewGlobalBuildContext(tmpl)
warnings, err := page.Build(ctx, &core.BuildOption{
PublicRoot: "/agents",
AssetRoot: "/agents/assets",
})
assert.Nil(t, err)
assert.Empty(t, warnings)
}
func prepare(t *testing.T) {
test.Prepare(t, config.Conf, "YAO_TEST_APPLICATION")
}
func clean() {
test.Clean()
}
func createAgent(t *testing.T) *Agent {
dsl := &core.DSL{
ID: "agent",
Name: "Agent",
Public: &core.Public{
Root: "/agents",
Host: "/",
Index: "/index",
},
}
agent, err := New(dsl)
if err != nil {
t.Fatalf("Failed to create agent: %v", err)
}
return agent
}

View file

@ -142,6 +142,11 @@ func (tmpl *Template) getPageBase(route string) string {
}
// Page get a specific page by route
// Route format: "/assistant-id/page-path" where assistant-id can contain dots for nested assistants
// Examples:
// - "/expense/test" -> assistant "expense", page "/test"
// - "/tests.nested.demo/article" -> assistant "tests.nested.demo", page "/article"
// - "/index" -> agent page (no assistant prefix)
func (tmpl *Template) Page(route string) (core.IPage, error) {
// Parse the route to determine if it's an assistant page or agent page
parts := strings.Split(strings.Trim(route, "/"), "/")
@ -150,7 +155,7 @@ func (tmpl *Template) Page(route string) (core.IPage, error) {
return nil, fmt.Errorf("Invalid route: %s", route)
}
// Check if first part is an assistant ID
// Check if first part is an assistant ID (may contain dots for nested assistants)
assistants, err := tmpl.agent.getAssistants()
if err != nil {
return nil, err
@ -160,6 +165,8 @@ func (tmpl *Template) Page(route string) (core.IPage, error) {
pageRoute := route
pagesRoot := filepath.Join(tmpl.agent.root, "pages")
// The first part of the route might be an assistant ID
// Assistant IDs can contain dots (e.g., "tests.nested.demo")
for _, ast := range assistants {
if parts[0] == ast {
assistantID = ast