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:
parent
6d4388cf1b
commit
ef63718941
5 changed files with 423 additions and 35 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
214
sui/storages/agent/agent_test.go
Normal file
214
sui/storages/agent/agent_test.go
Normal 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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue