Enhance File Handling and Input Parsing in Test Framework
- Introduced support for file attachments in test inputs using the `file://` protocol, allowing images, audio, and documents to be loaded and converted to appropriate formats. - Updated `ParseInput` and related functions to handle file references, ensuring seamless integration of file content into messages. - Enhanced error handling and path resolution for file loading, considering both relative paths and the `YAO_ROOT` environment variable. - Expanded documentation to include examples of file attachments and their usage in test cases, improving clarity for users.
This commit is contained in:
parent
97d0ad1d24
commit
7f44e442da
12 changed files with 1635 additions and 48 deletions
|
|
@ -272,6 +272,111 @@ The `options.metadata` field is passed to agent hooks. For example, a Create Hoo
|
|||
| `Message` | Single message | `{"role": "user", "content": "..."}` |
|
||||
| `[]Message` | Conversation history | `[{"role": "user", ...}, {"role": "assistant", ...}]` |
|
||||
|
||||
### File Attachments
|
||||
|
||||
Test inputs support file attachments (images, audio, documents) using the `file://` protocol. Files are loaded and converted to appropriate formats for the LLM.
|
||||
|
||||
**Supported file types:**
|
||||
|
||||
| Type | Extensions | Format |
|
||||
| ------ | ---------------------------------------------------------------------- | ------------------------------ |
|
||||
| Image | `.jpg`, `.jpeg`, `.png`, `.gif`, `.webp`, `.bmp` | Base64 data URL in `image_url` |
|
||||
| Audio | `.wav`, `.mp3`, `.flac`, `.ogg`, `.m4a` | Base64 in `input_audio` |
|
||||
| Doc | `.pdf`, `.doc`, `.docx`, `.xls`, `.xlsx`, `.txt`, `.csv`, `.json` | Base64 data URL in `file` |
|
||||
| Source | `.yao`, `.ts`, `.js`, `.go`, `.py`, `.rs`, `.java`, `.sql`, `.yaml`... | Base64 data URL in `file` |
|
||||
|
||||
**File path resolution:**
|
||||
|
||||
- **Relative paths**: Resolved relative to the JSONL input file's directory (for file mode) or current working directory (for message mode)
|
||||
- **Absolute paths**: Used as-is
|
||||
|
||||
**Example with image attachment:**
|
||||
|
||||
```jsonl
|
||||
{
|
||||
"id": "T001",
|
||||
"input": {
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Please analyze this invoice"
|
||||
},
|
||||
{
|
||||
"type": "image",
|
||||
"source": "file://fixtures/invoice.jpg"
|
||||
}
|
||||
]
|
||||
},
|
||||
"assert": {
|
||||
"type": "contains",
|
||||
"value": "amount"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Example with multiple attachments:**
|
||||
|
||||
```jsonl
|
||||
{
|
||||
"id": "T002",
|
||||
"input": {
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Process these receipts"
|
||||
},
|
||||
{
|
||||
"type": "image",
|
||||
"source": "file://fixtures/receipt1.png"
|
||||
},
|
||||
{
|
||||
"type": "image",
|
||||
"source": "file://fixtures/receipt2.png"
|
||||
},
|
||||
{
|
||||
"type": "file",
|
||||
"source": "file://fixtures/policy.pdf",
|
||||
"name": "expense_policy.pdf"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Example with audio:**
|
||||
|
||||
```jsonl
|
||||
{
|
||||
"id": "T003",
|
||||
"input": {
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Transcribe this audio"
|
||||
},
|
||||
{
|
||||
"type": "audio",
|
||||
"source": "file://fixtures/recording.wav"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Content part types:**
|
||||
|
||||
| Type | Fields | Description |
|
||||
| ----------- | --------------------------------------- | -------------------------------- |
|
||||
| `text` | `text` | Text content |
|
||||
| `image` | `source` (file://) or `url` | Image attachment |
|
||||
| `image_url` | `image_url: {url, detail?}` | Direct image URL (OpenAI format) |
|
||||
| `audio` | `source` (file://) or `data`, `format` | Audio attachment |
|
||||
| `file` | `source` (file://) or `url`, `filename` | Document attachment |
|
||||
| `data` | `data: {sources: [...]}` | Data source references |
|
||||
|
||||
## Assertions
|
||||
|
||||
Use `assert` for flexible validation. If `assert` is defined, it takes precedence over `expected`.
|
||||
|
|
|
|||
|
|
@ -255,27 +255,32 @@ func (a *Asserter) assertJSONPath(assertion *Assertion, output interface{}) *Ass
|
|||
result.Actual = actual
|
||||
|
||||
// Compare expected value with actual value
|
||||
// If expected is an array, check if actual matches ANY element (IN semantics)
|
||||
// First try direct comparison (handles array-to-array comparison)
|
||||
if validateOutput(actual, assertion.Value) {
|
||||
result.Passed = true
|
||||
result.Message = fmt.Sprintf("path '%s' equals expected value", assertion.Path)
|
||||
return result
|
||||
}
|
||||
|
||||
// If expected is an array and direct comparison failed, check if actual matches ANY element (IN semantics)
|
||||
// This is for cases like: expected: ["a", "b"], actual: "a" (actual is one of expected)
|
||||
if expectedArr, ok := assertion.Value.([]interface{}); ok {
|
||||
// Check if actual is one of the expected values
|
||||
for _, expectedItem := range expectedArr {
|
||||
if validateOutput(actual, expectedItem) {
|
||||
result.Passed = true
|
||||
result.Message = fmt.Sprintf("path '%s' equals one of expected values", assertion.Path)
|
||||
return result
|
||||
// Only apply IN semantics if actual is NOT an array (otherwise it was already compared above)
|
||||
if _, actualIsArr := actual.([]interface{}); !actualIsArr {
|
||||
for _, expectedItem := range expectedArr {
|
||||
if validateOutput(actual, expectedItem) {
|
||||
result.Passed = true
|
||||
result.Message = fmt.Sprintf("path '%s' equals one of expected values", assertion.Path)
|
||||
return result
|
||||
}
|
||||
}
|
||||
}
|
||||
result.Passed = false
|
||||
result.Message = fmt.Sprintf("path '%s': expected one of %v, got %v", assertion.Path, assertion.Value, actual)
|
||||
result.Message = fmt.Sprintf("path '%s': expected %v, got %v", assertion.Path, assertion.Value, actual)
|
||||
} else {
|
||||
// Direct comparison for non-array expected values
|
||||
if validateOutput(actual, assertion.Value) {
|
||||
result.Passed = true
|
||||
result.Message = fmt.Sprintf("path '%s' equals expected value", assertion.Path)
|
||||
} else {
|
||||
result.Passed = false
|
||||
result.Message = fmt.Sprintf("path '%s': expected %v, got %v", assertion.Path, assertion.Value, actual)
|
||||
}
|
||||
// Direct comparison already failed above
|
||||
result.Passed = false
|
||||
result.Message = fmt.Sprintf("path '%s': expected %v, got %v", assertion.Path, assertion.Value, actual)
|
||||
}
|
||||
|
||||
return result
|
||||
|
|
|
|||
305
agent/test/assert_test.go
Normal file
305
agent/test/assert_test.go
Normal file
|
|
@ -0,0 +1,305 @@
|
|||
package test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAsserter_JSONPath_ArrayEquality(t *testing.T) {
|
||||
asserter := NewAsserter()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
tc *Case
|
||||
output interface{}
|
||||
expected bool
|
||||
errMsg string
|
||||
}{
|
||||
{
|
||||
name: "array equals array - same content",
|
||||
tc: &Case{
|
||||
Assert: map[string]interface{}{
|
||||
"type": "json_path",
|
||||
"path": "search_types",
|
||||
"value": []interface{}{"db"},
|
||||
},
|
||||
},
|
||||
output: map[string]interface{}{
|
||||
"search_types": []interface{}{"db"},
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "array equals array - different content",
|
||||
tc: &Case{
|
||||
Assert: map[string]interface{}{
|
||||
"type": "json_path",
|
||||
"path": "search_types",
|
||||
"value": []interface{}{"db"},
|
||||
},
|
||||
},
|
||||
output: map[string]interface{}{
|
||||
"search_types": []interface{}{"web"},
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "array equals array - multiple elements",
|
||||
tc: &Case{
|
||||
Assert: map[string]interface{}{
|
||||
"type": "json_path",
|
||||
"path": "search_types",
|
||||
"value": []interface{}{"web", "db"},
|
||||
},
|
||||
},
|
||||
output: map[string]interface{}{
|
||||
"search_types": []interface{}{"web", "db"},
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "array equals array - different order",
|
||||
tc: &Case{
|
||||
Assert: map[string]interface{}{
|
||||
"type": "json_path",
|
||||
"path": "search_types",
|
||||
"value": []interface{}{"db", "web"},
|
||||
},
|
||||
},
|
||||
output: map[string]interface{}{
|
||||
"search_types": []interface{}{"web", "db"},
|
||||
},
|
||||
expected: false, // Order matters for array equality
|
||||
},
|
||||
{
|
||||
name: "scalar in array - match",
|
||||
tc: &Case{
|
||||
Assert: map[string]interface{}{
|
||||
"type": "json_path",
|
||||
"path": "status",
|
||||
"value": []interface{}{"active", "pending"},
|
||||
},
|
||||
},
|
||||
output: map[string]interface{}{
|
||||
"status": "active",
|
||||
},
|
||||
expected: true, // "active" is one of ["active", "pending"]
|
||||
},
|
||||
{
|
||||
name: "scalar in array - no match",
|
||||
tc: &Case{
|
||||
Assert: map[string]interface{}{
|
||||
"type": "json_path",
|
||||
"path": "status",
|
||||
"value": []interface{}{"active", "pending"},
|
||||
},
|
||||
},
|
||||
output: map[string]interface{}{
|
||||
"status": "inactive",
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "simple value comparison",
|
||||
tc: &Case{
|
||||
Assert: map[string]interface{}{
|
||||
"type": "json_path",
|
||||
"path": "need_search",
|
||||
"value": true,
|
||||
},
|
||||
},
|
||||
output: map[string]interface{}{
|
||||
"need_search": true,
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "nested path",
|
||||
tc: &Case{
|
||||
Assert: map[string]interface{}{
|
||||
"type": "json_path",
|
||||
"path": "result.count",
|
||||
"value": float64(5),
|
||||
},
|
||||
},
|
||||
output: map[string]interface{}{
|
||||
"result": map[string]interface{}{
|
||||
"count": float64(5),
|
||||
},
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "array index access",
|
||||
tc: &Case{
|
||||
Assert: map[string]interface{}{
|
||||
"type": "json_path",
|
||||
"path": "items[0].name",
|
||||
"value": "first",
|
||||
},
|
||||
},
|
||||
output: map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{"name": "first"},
|
||||
map[string]interface{}{"name": "second"},
|
||||
},
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
passed, errMsg := asserter.Validate(tt.tc, tt.output)
|
||||
if passed != tt.expected {
|
||||
t.Errorf("Expected passed=%v, got passed=%v, error: %s", tt.expected, passed, errMsg)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAsserter_Contains(t *testing.T) {
|
||||
asserter := NewAsserter()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
tc *Case
|
||||
output interface{}
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "string contains substring",
|
||||
tc: &Case{
|
||||
Assert: map[string]interface{}{
|
||||
"type": "contains",
|
||||
"value": "hello",
|
||||
},
|
||||
},
|
||||
output: "hello world",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "string does not contain",
|
||||
tc: &Case{
|
||||
Assert: map[string]interface{}{
|
||||
"type": "contains",
|
||||
"value": "goodbye",
|
||||
},
|
||||
},
|
||||
output: "hello world",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "JSON contains field",
|
||||
tc: &Case{
|
||||
Assert: map[string]interface{}{
|
||||
"type": "contains",
|
||||
"value": "success",
|
||||
},
|
||||
},
|
||||
output: map[string]interface{}{
|
||||
"status": "success",
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
passed, _ := asserter.Validate(tt.tc, tt.output)
|
||||
if passed != tt.expected {
|
||||
t.Errorf("Expected passed=%v, got passed=%v", tt.expected, passed)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAsserter_MultipleAssertions(t *testing.T) {
|
||||
asserter := NewAsserter()
|
||||
|
||||
tc := &Case{
|
||||
Assert: []interface{}{
|
||||
map[string]interface{}{
|
||||
"type": "json_path",
|
||||
"path": "need_search",
|
||||
"value": true,
|
||||
},
|
||||
map[string]interface{}{
|
||||
"type": "json_path",
|
||||
"path": "search_types",
|
||||
"value": []interface{}{"web"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
output := map[string]interface{}{
|
||||
"need_search": true,
|
||||
"search_types": []interface{}{"web"},
|
||||
}
|
||||
|
||||
passed, errMsg := asserter.Validate(tc, output)
|
||||
if !passed {
|
||||
t.Errorf("Expected all assertions to pass, got error: %s", errMsg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAsserter_Negate(t *testing.T) {
|
||||
asserter := NewAsserter()
|
||||
|
||||
tc := &Case{
|
||||
Assert: map[string]interface{}{
|
||||
"type": "contains",
|
||||
"value": "error",
|
||||
"negate": true,
|
||||
},
|
||||
}
|
||||
|
||||
output := "success message"
|
||||
|
||||
passed, _ := asserter.Validate(tc, output)
|
||||
if !passed {
|
||||
t.Error("Expected negated assertion to pass")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAsserter_Regex(t *testing.T) {
|
||||
asserter := NewAsserter()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
tc *Case
|
||||
output interface{}
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "regex matches",
|
||||
tc: &Case{
|
||||
Assert: map[string]interface{}{
|
||||
"type": "regex",
|
||||
"value": `\d{3}-\d{4}`,
|
||||
},
|
||||
},
|
||||
output: "Phone: 123-4567",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "regex does not match",
|
||||
tc: &Case{
|
||||
Assert: map[string]interface{}{
|
||||
"type": "regex",
|
||||
"value": `\d{3}-\d{4}`,
|
||||
},
|
||||
},
|
||||
output: "No phone number here",
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
passed, _ := asserter.Validate(tt.tc, tt.output)
|
||||
if passed != tt.expected {
|
||||
t.Errorf("Expected passed=%v, got passed=%v", tt.expected, passed)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -1,22 +1,123 @@
|
|||
package test
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"mime"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
jsoniter "github.com/json-iterator/go"
|
||||
"github.com/yaoapp/yao/agent/context"
|
||||
)
|
||||
|
||||
// FileProtocol is the protocol prefix for local file references
|
||||
const FileProtocol = "file://"
|
||||
|
||||
// SupportedImageExtensions lists supported image file extensions
|
||||
var SupportedImageExtensions = map[string]string{
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".png": "image/png",
|
||||
".gif": "image/gif",
|
||||
".webp": "image/webp",
|
||||
".bmp": "image/bmp",
|
||||
}
|
||||
|
||||
// SupportedAudioExtensions lists supported audio file extensions
|
||||
var SupportedAudioExtensions = map[string]string{
|
||||
".wav": "wav",
|
||||
".mp3": "mp3",
|
||||
".flac": "flac",
|
||||
".ogg": "ogg",
|
||||
".m4a": "m4a",
|
||||
}
|
||||
|
||||
// SupportedFileExtensions lists supported document file extensions
|
||||
var SupportedFileExtensions = map[string]string{
|
||||
// Documents
|
||||
".pdf": "application/pdf",
|
||||
".doc": "application/msword",
|
||||
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
".xls": "application/vnd.ms-excel",
|
||||
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
".txt": "text/plain",
|
||||
".csv": "text/csv",
|
||||
".json": "application/json",
|
||||
".xml": "application/xml",
|
||||
".html": "text/html",
|
||||
".htm": "text/html",
|
||||
".md": "text/markdown",
|
||||
|
||||
// Source code
|
||||
".yao": "application/json", // Yao DSL files (JSON-based)
|
||||
".ts": "text/typescript", // TypeScript
|
||||
".tsx": "text/typescript", // TypeScript JSX
|
||||
".js": "text/javascript", // JavaScript
|
||||
".jsx": "text/javascript", // JavaScript JSX
|
||||
".go": "text/x-go", // Go
|
||||
".py": "text/x-python", // Python
|
||||
".rs": "text/x-rust", // Rust
|
||||
".java": "text/x-java", // Java
|
||||
".c": "text/x-c", // C
|
||||
".cpp": "text/x-c++", // C++
|
||||
".h": "text/x-c", // C header
|
||||
".hpp": "text/x-c++", // C++ header
|
||||
".rb": "text/x-ruby", // Ruby
|
||||
".php": "text/x-php", // PHP
|
||||
".sh": "text/x-shellscript", // Shell script
|
||||
".bash": "text/x-shellscript", // Bash script
|
||||
".zsh": "text/x-shellscript", // Zsh script
|
||||
".sql": "text/x-sql", // SQL
|
||||
".yaml": "text/yaml", // YAML
|
||||
".yml": "text/yaml", // YAML
|
||||
".toml": "text/x-toml", // TOML
|
||||
".ini": "text/x-ini", // INI
|
||||
".conf": "text/plain", // Config files
|
||||
".css": "text/css", // CSS
|
||||
".scss": "text/x-scss", // SCSS
|
||||
".less": "text/x-less", // LESS
|
||||
".vue": "text/x-vue", // Vue
|
||||
".svelte": "text/x-svelte", // Svelte
|
||||
}
|
||||
|
||||
// InputOptions configures how input is parsed
|
||||
type InputOptions struct {
|
||||
// BaseDir is the base directory for resolving relative file paths
|
||||
// If empty, the current working directory is used
|
||||
BaseDir string
|
||||
}
|
||||
|
||||
// ParseInput converts various input formats to []context.Message
|
||||
// Supported formats:
|
||||
// - string: converted to single user message
|
||||
// - map (Message): single message with role and content
|
||||
// - []interface{} ([]Message): array of messages (conversation history)
|
||||
func ParseInput(input interface{}) ([]context.Message, error) {
|
||||
return ParseInputWithOptions(input, nil)
|
||||
}
|
||||
|
||||
// ParseInputWithOptions converts various input formats to []context.Message with options
|
||||
// Supported formats:
|
||||
// - string: converted to single user message
|
||||
// - map (Message): single message with role and content
|
||||
// - []interface{} ([]Message): array of messages (conversation history)
|
||||
//
|
||||
// File references in content parts (type="image", "file", "audio") with "source" field
|
||||
// starting with "file://" will be loaded and converted to appropriate format:
|
||||
// - Images: converted to base64 data URL in image_url field
|
||||
// - Audio: converted to base64 in input_audio field
|
||||
// - Files: converted to base64 data URL in file field
|
||||
func ParseInputWithOptions(input interface{}, opts *InputOptions) ([]context.Message, error) {
|
||||
if input == nil {
|
||||
return nil, fmt.Errorf("input is nil")
|
||||
}
|
||||
|
||||
if opts == nil {
|
||||
opts = &InputOptions{}
|
||||
}
|
||||
|
||||
switch v := input.(type) {
|
||||
case string:
|
||||
// Simple string input -> single user message
|
||||
|
|
@ -29,7 +130,7 @@ func ParseInput(input interface{}) ([]context.Message, error) {
|
|||
|
||||
case map[string]interface{}:
|
||||
// Single message object
|
||||
msg, err := parseMessageMap(v)
|
||||
msg, err := parseMessageMap(v, opts)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse message: %w", err)
|
||||
}
|
||||
|
|
@ -41,7 +142,7 @@ func ParseInput(input interface{}) ([]context.Message, error) {
|
|||
for i, item := range v {
|
||||
switch m := item.(type) {
|
||||
case map[string]interface{}:
|
||||
msg, err := parseMessageMap(m)
|
||||
msg, err := parseMessageMap(m, opts)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse message at index %d: %w", i, err)
|
||||
}
|
||||
|
|
@ -58,7 +159,7 @@ func ParseInput(input interface{}) ([]context.Message, error) {
|
|||
}
|
||||
|
||||
// parseMessageMap converts a map to context.Message
|
||||
func parseMessageMap(m map[string]interface{}) (*context.Message, error) {
|
||||
func parseMessageMap(m map[string]interface{}, opts *InputOptions) (*context.Message, error) {
|
||||
msg := &context.Message{}
|
||||
|
||||
// Parse role (required)
|
||||
|
|
@ -71,7 +172,12 @@ func parseMessageMap(m map[string]interface{}) (*context.Message, error) {
|
|||
|
||||
// Parse content (required)
|
||||
if content, ok := m["content"]; ok {
|
||||
msg.Content = content
|
||||
// Process content to handle file:// references
|
||||
processedContent, err := processContent(content, opts)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to process content: %w", err)
|
||||
}
|
||||
msg.Content = processedContent
|
||||
} else {
|
||||
return nil, fmt.Errorf("message missing 'content' field")
|
||||
}
|
||||
|
|
@ -108,6 +214,389 @@ func parseMessageMap(m map[string]interface{}) (*context.Message, error) {
|
|||
return msg, nil
|
||||
}
|
||||
|
||||
// processContent processes content to handle file:// references
|
||||
// Returns the processed content with files loaded and converted
|
||||
func processContent(content interface{}, opts *InputOptions) (interface{}, error) {
|
||||
switch v := content.(type) {
|
||||
case string:
|
||||
// Simple string content, no processing needed
|
||||
return v, nil
|
||||
|
||||
case []interface{}:
|
||||
// Array of content parts
|
||||
processedParts := make([]context.ContentPart, 0, len(v))
|
||||
for i, part := range v {
|
||||
if partMap, ok := part.(map[string]interface{}); ok {
|
||||
processedPart, err := processContentPart(partMap, opts)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to process content part at index %d: %w", i, err)
|
||||
}
|
||||
processedParts = append(processedParts, *processedPart)
|
||||
} else {
|
||||
return nil, fmt.Errorf("invalid content part type at index %d: expected object, got %T", i, part)
|
||||
}
|
||||
}
|
||||
return processedParts, nil
|
||||
|
||||
case map[string]interface{}:
|
||||
// Single content part
|
||||
processedPart, err := processContentPart(v, opts)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to process content part: %w", err)
|
||||
}
|
||||
return []context.ContentPart{*processedPart}, nil
|
||||
|
||||
default:
|
||||
return content, nil
|
||||
}
|
||||
}
|
||||
|
||||
// processContentPart processes a single content part map
|
||||
// Handles file:// references and converts them to appropriate format
|
||||
func processContentPart(partMap map[string]interface{}, opts *InputOptions) (*context.ContentPart, error) {
|
||||
partType, _ := partMap["type"].(string)
|
||||
|
||||
switch partType {
|
||||
case "text":
|
||||
text, _ := partMap["text"].(string)
|
||||
return &context.ContentPart{
|
||||
Type: context.ContentText,
|
||||
Text: text,
|
||||
}, nil
|
||||
|
||||
case "image":
|
||||
return processImagePart(partMap, opts)
|
||||
|
||||
case "image_url":
|
||||
// Already in correct format, just parse it
|
||||
return parseImageURLPart(partMap)
|
||||
|
||||
case "audio", "input_audio":
|
||||
return processAudioPart(partMap, opts)
|
||||
|
||||
case "file":
|
||||
return processFilePart(partMap, opts)
|
||||
|
||||
case "data":
|
||||
return parseDataPart(partMap)
|
||||
|
||||
default:
|
||||
// Unknown type, try to preserve as-is
|
||||
return parseGenericPart(partMap)
|
||||
}
|
||||
}
|
||||
|
||||
// processImagePart processes an image content part
|
||||
// Supports: source="file://path" for local files
|
||||
func processImagePart(partMap map[string]interface{}, opts *InputOptions) (*context.ContentPart, error) {
|
||||
source, hasSource := partMap["source"].(string)
|
||||
|
||||
// Check for file:// protocol
|
||||
if hasSource && strings.HasPrefix(source, FileProtocol) {
|
||||
filePath := strings.TrimPrefix(source, FileProtocol)
|
||||
return loadImageFile(filePath, opts)
|
||||
}
|
||||
|
||||
// Check for url field (already a URL or base64)
|
||||
if url, ok := partMap["url"].(string); ok {
|
||||
detail := context.DetailAuto
|
||||
if d, ok := partMap["detail"].(string); ok {
|
||||
detail = context.ImageDetailLevel(d)
|
||||
}
|
||||
return &context.ContentPart{
|
||||
Type: context.ContentImageURL,
|
||||
ImageURL: &context.ImageURL{
|
||||
URL: url,
|
||||
Detail: detail,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("image part requires 'source' (file://...) or 'url' field")
|
||||
}
|
||||
|
||||
// parseImageURLPart parses an image_url content part
|
||||
func parseImageURLPart(partMap map[string]interface{}) (*context.ContentPart, error) {
|
||||
imageURL, ok := partMap["image_url"].(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("image_url part requires 'image_url' object")
|
||||
}
|
||||
|
||||
url, _ := imageURL["url"].(string)
|
||||
detail := context.DetailAuto
|
||||
if d, ok := imageURL["detail"].(string); ok {
|
||||
detail = context.ImageDetailLevel(d)
|
||||
}
|
||||
|
||||
return &context.ContentPart{
|
||||
Type: context.ContentImageURL,
|
||||
ImageURL: &context.ImageURL{
|
||||
URL: url,
|
||||
Detail: detail,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// processAudioPart processes an audio content part
|
||||
// Supports: source="file://path" for local files
|
||||
func processAudioPart(partMap map[string]interface{}, opts *InputOptions) (*context.ContentPart, error) {
|
||||
source, hasSource := partMap["source"].(string)
|
||||
|
||||
// Check for file:// protocol
|
||||
if hasSource && strings.HasPrefix(source, FileProtocol) {
|
||||
filePath := strings.TrimPrefix(source, FileProtocol)
|
||||
return loadAudioFile(filePath, opts)
|
||||
}
|
||||
|
||||
// Check for data field (already base64)
|
||||
if data, ok := partMap["data"].(string); ok {
|
||||
format, _ := partMap["format"].(string)
|
||||
return &context.ContentPart{
|
||||
Type: context.ContentInputAudio,
|
||||
InputAudio: &context.InputAudio{
|
||||
Data: data,
|
||||
Format: format,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Check for input_audio field
|
||||
if inputAudio, ok := partMap["input_audio"].(map[string]interface{}); ok {
|
||||
data, _ := inputAudio["data"].(string)
|
||||
format, _ := inputAudio["format"].(string)
|
||||
return &context.ContentPart{
|
||||
Type: context.ContentInputAudio,
|
||||
InputAudio: &context.InputAudio{
|
||||
Data: data,
|
||||
Format: format,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("audio part requires 'source' (file://...) or 'data'/'input_audio' field")
|
||||
}
|
||||
|
||||
// processFilePart processes a file content part
|
||||
// Supports: source="file://path" for local files
|
||||
func processFilePart(partMap map[string]interface{}, opts *InputOptions) (*context.ContentPart, error) {
|
||||
source, hasSource := partMap["source"].(string)
|
||||
|
||||
// Check for file:// protocol
|
||||
if hasSource && strings.HasPrefix(source, FileProtocol) {
|
||||
filePath := strings.TrimPrefix(source, FileProtocol)
|
||||
name, _ := partMap["name"].(string)
|
||||
return loadFile(filePath, name, opts)
|
||||
}
|
||||
|
||||
// Check for url field (already a URL)
|
||||
if url, ok := partMap["url"].(string); ok {
|
||||
filename, _ := partMap["filename"].(string)
|
||||
if filename == "" {
|
||||
filename, _ = partMap["name"].(string)
|
||||
}
|
||||
return &context.ContentPart{
|
||||
Type: context.ContentFile,
|
||||
File: &context.FileAttachment{
|
||||
URL: url,
|
||||
Filename: filename,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Check for file field
|
||||
if file, ok := partMap["file"].(map[string]interface{}); ok {
|
||||
url, _ := file["url"].(string)
|
||||
filename, _ := file["filename"].(string)
|
||||
return &context.ContentPart{
|
||||
Type: context.ContentFile,
|
||||
File: &context.FileAttachment{
|
||||
URL: url,
|
||||
Filename: filename,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("file part requires 'source' (file://...), 'url', or 'file' field")
|
||||
}
|
||||
|
||||
// parseDataPart parses a data content part
|
||||
func parseDataPart(partMap map[string]interface{}) (*context.ContentPart, error) {
|
||||
data, ok := partMap["data"].(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("data part requires 'data' object")
|
||||
}
|
||||
|
||||
// Convert to DataContent
|
||||
dataContent := &context.DataContent{}
|
||||
|
||||
if sources, ok := data["sources"].([]interface{}); ok {
|
||||
dataContent.Sources = make([]context.DataSource, 0, len(sources))
|
||||
for _, src := range sources {
|
||||
if srcMap, ok := src.(map[string]interface{}); ok {
|
||||
ds := context.DataSource{}
|
||||
if t, ok := srcMap["type"].(string); ok {
|
||||
ds.Type = context.DataSourceType(t)
|
||||
}
|
||||
if id, ok := srcMap["id"].(string); ok {
|
||||
ds.ID = id
|
||||
}
|
||||
if name, ok := srcMap["name"].(string); ok {
|
||||
ds.Name = name
|
||||
}
|
||||
if filters, ok := srcMap["filters"].(map[string]interface{}); ok {
|
||||
ds.Filters = filters
|
||||
}
|
||||
if metadata, ok := srcMap["metadata"].(map[string]interface{}); ok {
|
||||
ds.Metadata = metadata
|
||||
}
|
||||
dataContent.Sources = append(dataContent.Sources, ds)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return &context.ContentPart{
|
||||
Type: context.ContentData,
|
||||
Data: dataContent,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// parseGenericPart tries to parse an unknown content part type
|
||||
func parseGenericPart(partMap map[string]interface{}) (*context.ContentPart, error) {
|
||||
partType, _ := partMap["type"].(string)
|
||||
|
||||
// Try to create a basic ContentPart
|
||||
part := &context.ContentPart{
|
||||
Type: context.ContentPartType(partType),
|
||||
}
|
||||
|
||||
// Try to extract text if present
|
||||
if text, ok := partMap["text"].(string); ok {
|
||||
part.Text = text
|
||||
}
|
||||
|
||||
return part, nil
|
||||
}
|
||||
|
||||
// loadImageFile loads an image file and converts it to a ContentPart
|
||||
func loadImageFile(filePath string, opts *InputOptions) (*context.ContentPart, error) {
|
||||
absPath := resolveFilePath(filePath, opts)
|
||||
|
||||
// Read file
|
||||
data, err := os.ReadFile(absPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read image file %s: %w", filePath, err)
|
||||
}
|
||||
|
||||
// Determine MIME type
|
||||
ext := strings.ToLower(filepath.Ext(absPath))
|
||||
mimeType, ok := SupportedImageExtensions[ext]
|
||||
if !ok {
|
||||
// Try to detect from extension
|
||||
mimeType = mime.TypeByExtension(ext)
|
||||
if mimeType == "" {
|
||||
mimeType = "application/octet-stream"
|
||||
}
|
||||
}
|
||||
|
||||
// Encode to base64 data URL
|
||||
b64Data := base64.StdEncoding.EncodeToString(data)
|
||||
dataURL := fmt.Sprintf("data:%s;base64,%s", mimeType, b64Data)
|
||||
|
||||
return &context.ContentPart{
|
||||
Type: context.ContentImageURL,
|
||||
ImageURL: &context.ImageURL{
|
||||
URL: dataURL,
|
||||
Detail: context.DetailAuto,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// loadAudioFile loads an audio file and converts it to a ContentPart
|
||||
func loadAudioFile(filePath string, opts *InputOptions) (*context.ContentPart, error) {
|
||||
absPath := resolveFilePath(filePath, opts)
|
||||
|
||||
// Read file
|
||||
data, err := os.ReadFile(absPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read audio file %s: %w", filePath, err)
|
||||
}
|
||||
|
||||
// Determine format from extension
|
||||
ext := strings.ToLower(filepath.Ext(absPath))
|
||||
format, ok := SupportedAudioExtensions[ext]
|
||||
if !ok {
|
||||
format = strings.TrimPrefix(ext, ".")
|
||||
}
|
||||
|
||||
// Encode to base64
|
||||
b64Data := base64.StdEncoding.EncodeToString(data)
|
||||
|
||||
return &context.ContentPart{
|
||||
Type: context.ContentInputAudio,
|
||||
InputAudio: &context.InputAudio{
|
||||
Data: b64Data,
|
||||
Format: format,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// loadFile loads a file and converts it to a ContentPart
|
||||
func loadFile(filePath string, name string, opts *InputOptions) (*context.ContentPart, error) {
|
||||
absPath := resolveFilePath(filePath, opts)
|
||||
|
||||
// Read file
|
||||
data, err := os.ReadFile(absPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read file %s: %w", filePath, err)
|
||||
}
|
||||
|
||||
// Determine filename
|
||||
filename := name
|
||||
if filename == "" {
|
||||
filename = filepath.Base(absPath)
|
||||
}
|
||||
|
||||
// Determine MIME type
|
||||
ext := strings.ToLower(filepath.Ext(absPath))
|
||||
mimeType, ok := SupportedFileExtensions[ext]
|
||||
if !ok {
|
||||
mimeType = mime.TypeByExtension(ext)
|
||||
if mimeType == "" {
|
||||
mimeType = "application/octet-stream"
|
||||
}
|
||||
}
|
||||
|
||||
// Encode to base64 data URL
|
||||
b64Data := base64.StdEncoding.EncodeToString(data)
|
||||
dataURL := fmt.Sprintf("data:%s;base64,%s", mimeType, b64Data)
|
||||
|
||||
return &context.ContentPart{
|
||||
Type: context.ContentFile,
|
||||
File: &context.FileAttachment{
|
||||
URL: dataURL,
|
||||
Filename: filename,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// resolveFilePath resolves a file path relative to the base directory
|
||||
// If the path is absolute, it's returned as-is
|
||||
// If BaseDir is empty, the current working directory is used
|
||||
func resolveFilePath(filePath string, opts *InputOptions) string {
|
||||
// If path is absolute, return as-is
|
||||
if filepath.IsAbs(filePath) {
|
||||
return filePath
|
||||
}
|
||||
|
||||
// If BaseDir is set, resolve relative to it
|
||||
if opts != nil && opts.BaseDir != "" {
|
||||
return filepath.Join(opts.BaseDir, filePath)
|
||||
}
|
||||
|
||||
// Otherwise, resolve relative to current working directory
|
||||
return filePath
|
||||
}
|
||||
|
||||
// parseToolCall converts a map to context.ToolCall
|
||||
func parseToolCall(m map[string]interface{}) (*context.ToolCall, error) {
|
||||
tc := &context.ToolCall{}
|
||||
|
|
|
|||
487
agent/test/input_test.go
Normal file
487
agent/test/input_test.go
Normal file
|
|
@ -0,0 +1,487 @@
|
|||
package test
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/yaoapp/yao/agent/context"
|
||||
)
|
||||
|
||||
func TestParseInput_String(t *testing.T) {
|
||||
input := "Hello world"
|
||||
messages, err := ParseInput(input)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseInput failed: %v", err)
|
||||
}
|
||||
|
||||
if len(messages) != 1 {
|
||||
t.Fatalf("Expected 1 message, got %d", len(messages))
|
||||
}
|
||||
|
||||
if messages[0].Role != context.RoleUser {
|
||||
t.Errorf("Expected role 'user', got '%s'", messages[0].Role)
|
||||
}
|
||||
|
||||
content, ok := messages[0].Content.(string)
|
||||
if !ok {
|
||||
t.Fatalf("Expected string content, got %T", messages[0].Content)
|
||||
}
|
||||
if content != "Hello world" {
|
||||
t.Errorf("Expected content 'Hello world', got '%s'", content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseInput_MessageMap(t *testing.T) {
|
||||
input := map[string]interface{}{
|
||||
"role": "user",
|
||||
"content": "Test message",
|
||||
}
|
||||
|
||||
messages, err := ParseInput(input)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseInput failed: %v", err)
|
||||
}
|
||||
|
||||
if len(messages) != 1 {
|
||||
t.Fatalf("Expected 1 message, got %d", len(messages))
|
||||
}
|
||||
|
||||
if messages[0].Role != context.RoleUser {
|
||||
t.Errorf("Expected role 'user', got '%s'", messages[0].Role)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseInput_MessageArray(t *testing.T) {
|
||||
input := []interface{}{
|
||||
map[string]interface{}{"role": "user", "content": "Hello"},
|
||||
map[string]interface{}{"role": "assistant", "content": "Hi there"},
|
||||
map[string]interface{}{"role": "user", "content": "Follow-up"},
|
||||
}
|
||||
|
||||
messages, err := ParseInput(input)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseInput failed: %v", err)
|
||||
}
|
||||
|
||||
if len(messages) != 3 {
|
||||
t.Fatalf("Expected 3 messages, got %d", len(messages))
|
||||
}
|
||||
|
||||
if messages[0].Role != context.RoleUser {
|
||||
t.Errorf("Expected first message role 'user', got '%s'", messages[0].Role)
|
||||
}
|
||||
if messages[1].Role != context.RoleAssistant {
|
||||
t.Errorf("Expected second message role 'assistant', got '%s'", messages[1].Role)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseInput_ContentParts(t *testing.T) {
|
||||
input := map[string]interface{}{
|
||||
"role": "user",
|
||||
"content": []interface{}{
|
||||
map[string]interface{}{"type": "text", "text": "Analyze this"},
|
||||
map[string]interface{}{"type": "image_url", "image_url": map[string]interface{}{
|
||||
"url": "https://example.com/image.jpg",
|
||||
"detail": "high",
|
||||
}},
|
||||
},
|
||||
}
|
||||
|
||||
messages, err := ParseInput(input)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseInput failed: %v", err)
|
||||
}
|
||||
|
||||
if len(messages) != 1 {
|
||||
t.Fatalf("Expected 1 message, got %d", len(messages))
|
||||
}
|
||||
|
||||
parts, ok := messages[0].Content.([]context.ContentPart)
|
||||
if !ok {
|
||||
t.Fatalf("Expected []ContentPart, got %T", messages[0].Content)
|
||||
}
|
||||
|
||||
if len(parts) != 2 {
|
||||
t.Fatalf("Expected 2 content parts, got %d", len(parts))
|
||||
}
|
||||
|
||||
if parts[0].Type != context.ContentText {
|
||||
t.Errorf("Expected first part type 'text', got '%s'", parts[0].Type)
|
||||
}
|
||||
if parts[0].Text != "Analyze this" {
|
||||
t.Errorf("Expected text 'Analyze this', got '%s'", parts[0].Text)
|
||||
}
|
||||
|
||||
if parts[1].Type != context.ContentImageURL {
|
||||
t.Errorf("Expected second part type 'image_url', got '%s'", parts[1].Type)
|
||||
}
|
||||
if parts[1].ImageURL == nil {
|
||||
t.Fatal("Expected ImageURL to be set")
|
||||
}
|
||||
if parts[1].ImageURL.URL != "https://example.com/image.jpg" {
|
||||
t.Errorf("Expected URL 'https://example.com/image.jpg', got '%s'", parts[1].ImageURL.URL)
|
||||
}
|
||||
if parts[1].ImageURL.Detail != context.DetailHigh {
|
||||
t.Errorf("Expected detail 'high', got '%s'", parts[1].ImageURL.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseInputWithOptions_FileProtocol_Image(t *testing.T) {
|
||||
// Create a temporary test image file
|
||||
tmpDir := t.TempDir()
|
||||
imgPath := filepath.Join(tmpDir, "test.png")
|
||||
|
||||
// Create a minimal PNG file (1x1 pixel, red)
|
||||
pngData := []byte{
|
||||
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, // PNG signature
|
||||
0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, // IHDR chunk
|
||||
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
|
||||
0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53,
|
||||
0xDE, 0x00, 0x00, 0x00, 0x0C, 0x49, 0x44, 0x41, // IDAT chunk
|
||||
0x54, 0x08, 0xD7, 0x63, 0xF8, 0xCF, 0xC0, 0x00,
|
||||
0x00, 0x00, 0x03, 0x00, 0x01, 0x00, 0x05, 0xFE,
|
||||
0xD4, 0xEF, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, // IEND chunk
|
||||
0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82,
|
||||
}
|
||||
if err := os.WriteFile(imgPath, pngData, 0644); err != nil {
|
||||
t.Fatalf("Failed to create test image: %v", err)
|
||||
}
|
||||
|
||||
input := map[string]interface{}{
|
||||
"role": "user",
|
||||
"content": []interface{}{
|
||||
map[string]interface{}{"type": "text", "text": "Analyze this image"},
|
||||
map[string]interface{}{"type": "image", "source": "file://test.png"},
|
||||
},
|
||||
}
|
||||
|
||||
opts := &InputOptions{BaseDir: tmpDir}
|
||||
messages, err := ParseInputWithOptions(input, opts)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseInputWithOptions failed: %v", err)
|
||||
}
|
||||
|
||||
if len(messages) != 1 {
|
||||
t.Fatalf("Expected 1 message, got %d", len(messages))
|
||||
}
|
||||
|
||||
parts, ok := messages[0].Content.([]context.ContentPart)
|
||||
if !ok {
|
||||
t.Fatalf("Expected []ContentPart, got %T", messages[0].Content)
|
||||
}
|
||||
|
||||
if len(parts) != 2 {
|
||||
t.Fatalf("Expected 2 content parts, got %d", len(parts))
|
||||
}
|
||||
|
||||
// Check image part
|
||||
imgPart := parts[1]
|
||||
if imgPart.Type != context.ContentImageURL {
|
||||
t.Errorf("Expected type 'image_url', got '%s'", imgPart.Type)
|
||||
}
|
||||
if imgPart.ImageURL == nil {
|
||||
t.Fatal("Expected ImageURL to be set")
|
||||
}
|
||||
if !strings.HasPrefix(imgPart.ImageURL.URL, "data:image/png;base64,") {
|
||||
t.Errorf("Expected base64 data URL, got '%s'", imgPart.ImageURL.URL[:50])
|
||||
}
|
||||
|
||||
// Verify the base64 content
|
||||
b64Part := strings.TrimPrefix(imgPart.ImageURL.URL, "data:image/png;base64,")
|
||||
decoded, err := base64.StdEncoding.DecodeString(b64Part)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to decode base64: %v", err)
|
||||
}
|
||||
if len(decoded) != len(pngData) {
|
||||
t.Errorf("Decoded data length mismatch: expected %d, got %d", len(pngData), len(decoded))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseInputWithOptions_FileProtocol_Audio(t *testing.T) {
|
||||
// Create a temporary test audio file
|
||||
tmpDir := t.TempDir()
|
||||
audioPath := filepath.Join(tmpDir, "test.wav")
|
||||
|
||||
// Create a minimal WAV file header
|
||||
wavData := []byte{
|
||||
0x52, 0x49, 0x46, 0x46, // "RIFF"
|
||||
0x24, 0x00, 0x00, 0x00, // File size - 8
|
||||
0x57, 0x41, 0x56, 0x45, // "WAVE"
|
||||
0x66, 0x6D, 0x74, 0x20, // "fmt "
|
||||
0x10, 0x00, 0x00, 0x00, // Subchunk1Size (16 for PCM)
|
||||
0x01, 0x00, // AudioFormat (1 = PCM)
|
||||
0x01, 0x00, // NumChannels (1 = mono)
|
||||
0x44, 0xAC, 0x00, 0x00, // SampleRate (44100)
|
||||
0x88, 0x58, 0x01, 0x00, // ByteRate
|
||||
0x02, 0x00, // BlockAlign
|
||||
0x10, 0x00, // BitsPerSample (16)
|
||||
0x64, 0x61, 0x74, 0x61, // "data"
|
||||
0x00, 0x00, 0x00, 0x00, // Subchunk2Size (0 = no data)
|
||||
}
|
||||
if err := os.WriteFile(audioPath, wavData, 0644); err != nil {
|
||||
t.Fatalf("Failed to create test audio: %v", err)
|
||||
}
|
||||
|
||||
input := map[string]interface{}{
|
||||
"role": "user",
|
||||
"content": []interface{}{
|
||||
map[string]interface{}{"type": "text", "text": "Transcribe this"},
|
||||
map[string]interface{}{"type": "audio", "source": "file://test.wav"},
|
||||
},
|
||||
}
|
||||
|
||||
opts := &InputOptions{BaseDir: tmpDir}
|
||||
messages, err := ParseInputWithOptions(input, opts)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseInputWithOptions failed: %v", err)
|
||||
}
|
||||
|
||||
parts, ok := messages[0].Content.([]context.ContentPart)
|
||||
if !ok {
|
||||
t.Fatalf("Expected []ContentPart, got %T", messages[0].Content)
|
||||
}
|
||||
|
||||
// Check audio part
|
||||
audioPart := parts[1]
|
||||
if audioPart.Type != context.ContentInputAudio {
|
||||
t.Errorf("Expected type 'input_audio', got '%s'", audioPart.Type)
|
||||
}
|
||||
if audioPart.InputAudio == nil {
|
||||
t.Fatal("Expected InputAudio to be set")
|
||||
}
|
||||
if audioPart.InputAudio.Format != "wav" {
|
||||
t.Errorf("Expected format 'wav', got '%s'", audioPart.InputAudio.Format)
|
||||
}
|
||||
if audioPart.InputAudio.Data == "" {
|
||||
t.Error("Expected base64 data to be set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseInputWithOptions_FileProtocol_File(t *testing.T) {
|
||||
// Create a temporary test file
|
||||
tmpDir := t.TempDir()
|
||||
pdfPath := filepath.Join(tmpDir, "document.pdf")
|
||||
|
||||
// Create a minimal PDF file
|
||||
pdfData := []byte("%PDF-1.4\n1 0 obj\n<<>>\nendobj\ntrailer\n<<>>\n%%EOF")
|
||||
if err := os.WriteFile(pdfPath, pdfData, 0644); err != nil {
|
||||
t.Fatalf("Failed to create test PDF: %v", err)
|
||||
}
|
||||
|
||||
input := map[string]interface{}{
|
||||
"role": "user",
|
||||
"content": []interface{}{
|
||||
map[string]interface{}{"type": "text", "text": "Analyze this document"},
|
||||
map[string]interface{}{"type": "file", "source": "file://document.pdf", "name": "my_doc.pdf"},
|
||||
},
|
||||
}
|
||||
|
||||
opts := &InputOptions{BaseDir: tmpDir}
|
||||
messages, err := ParseInputWithOptions(input, opts)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseInputWithOptions failed: %v", err)
|
||||
}
|
||||
|
||||
parts, ok := messages[0].Content.([]context.ContentPart)
|
||||
if !ok {
|
||||
t.Fatalf("Expected []ContentPart, got %T", messages[0].Content)
|
||||
}
|
||||
|
||||
// Check file part
|
||||
filePart := parts[1]
|
||||
if filePart.Type != context.ContentFile {
|
||||
t.Errorf("Expected type 'file', got '%s'", filePart.Type)
|
||||
}
|
||||
if filePart.File == nil {
|
||||
t.Fatal("Expected File to be set")
|
||||
}
|
||||
if filePart.File.Filename != "my_doc.pdf" {
|
||||
t.Errorf("Expected filename 'my_doc.pdf', got '%s'", filePart.File.Filename)
|
||||
}
|
||||
if !strings.HasPrefix(filePart.File.URL, "data:application/pdf;base64,") {
|
||||
t.Errorf("Expected base64 data URL with PDF mime type, got '%s'", filePart.File.URL[:40])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseInputWithOptions_FileProtocol_AbsolutePath(t *testing.T) {
|
||||
// Create a temporary test image file
|
||||
tmpDir := t.TempDir()
|
||||
imgPath := filepath.Join(tmpDir, "absolute.png")
|
||||
|
||||
// Create a minimal PNG file
|
||||
pngData := []byte{
|
||||
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A,
|
||||
0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52,
|
||||
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
|
||||
0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53,
|
||||
0xDE, 0x00, 0x00, 0x00, 0x0C, 0x49, 0x44, 0x41,
|
||||
0x54, 0x08, 0xD7, 0x63, 0xF8, 0xCF, 0xC0, 0x00,
|
||||
0x00, 0x00, 0x03, 0x00, 0x01, 0x00, 0x05, 0xFE,
|
||||
0xD4, 0xEF, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45,
|
||||
0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82,
|
||||
}
|
||||
if err := os.WriteFile(imgPath, pngData, 0644); err != nil {
|
||||
t.Fatalf("Failed to create test image: %v", err)
|
||||
}
|
||||
|
||||
// Use absolute path
|
||||
input := map[string]interface{}{
|
||||
"role": "user",
|
||||
"content": []interface{}{
|
||||
map[string]interface{}{"type": "image", "source": "file://" + imgPath},
|
||||
},
|
||||
}
|
||||
|
||||
// BaseDir should be ignored for absolute paths
|
||||
opts := &InputOptions{BaseDir: "/some/other/dir"}
|
||||
messages, err := ParseInputWithOptions(input, opts)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseInputWithOptions failed: %v", err)
|
||||
}
|
||||
|
||||
parts, ok := messages[0].Content.([]context.ContentPart)
|
||||
if !ok {
|
||||
t.Fatalf("Expected []ContentPart, got %T", messages[0].Content)
|
||||
}
|
||||
|
||||
if parts[0].Type != context.ContentImageURL {
|
||||
t.Errorf("Expected type 'image_url', got '%s'", parts[0].Type)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseInputWithOptions_FileNotFound(t *testing.T) {
|
||||
input := map[string]interface{}{
|
||||
"role": "user",
|
||||
"content": []interface{}{
|
||||
map[string]interface{}{"type": "image", "source": "file://nonexistent.png"},
|
||||
},
|
||||
}
|
||||
|
||||
opts := &InputOptions{BaseDir: t.TempDir()}
|
||||
_, err := ParseInputWithOptions(input, opts)
|
||||
if err == nil {
|
||||
t.Fatal("Expected error for non-existent file")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "failed to read image file") {
|
||||
t.Errorf("Expected 'failed to read image file' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveFilePath(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
filePath string
|
||||
baseDir string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "relative path with base dir",
|
||||
filePath: "fixtures/image.png",
|
||||
baseDir: "/app/tests",
|
||||
expected: "/app/tests/fixtures/image.png",
|
||||
},
|
||||
{
|
||||
name: "relative path without base dir",
|
||||
filePath: "fixtures/image.png",
|
||||
baseDir: "",
|
||||
expected: "fixtures/image.png",
|
||||
},
|
||||
{
|
||||
name: "absolute path ignores base dir",
|
||||
filePath: "/absolute/path/image.png",
|
||||
baseDir: "/app/tests",
|
||||
expected: "/absolute/path/image.png",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
opts := &InputOptions{BaseDir: tt.baseDir}
|
||||
result := resolveFilePath(tt.filePath, opts)
|
||||
if result != tt.expected {
|
||||
t.Errorf("Expected '%s', got '%s'", tt.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractTextContent(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
content interface{}
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "string content",
|
||||
content: "Hello world",
|
||||
expected: "Hello world",
|
||||
},
|
||||
{
|
||||
name: "content parts array",
|
||||
content: []interface{}{
|
||||
map[string]interface{}{"type": "text", "text": "First"},
|
||||
map[string]interface{}{"type": "image", "source": "file://test.png"},
|
||||
map[string]interface{}{"type": "text", "text": "Second"},
|
||||
},
|
||||
expected: "First\nSecond",
|
||||
},
|
||||
{
|
||||
name: "nil content",
|
||||
content: nil,
|
||||
expected: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := ExtractTextContent(tt.content)
|
||||
if result != tt.expected {
|
||||
t.Errorf("Expected '%s', got '%s'", tt.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSummarizeInput(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input interface{}
|
||||
maxLen int
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "short string",
|
||||
input: "Hello",
|
||||
maxLen: 10,
|
||||
expected: "Hello",
|
||||
},
|
||||
{
|
||||
name: "long string truncated",
|
||||
input: "This is a very long message that should be truncated",
|
||||
maxLen: 20,
|
||||
expected: "This is a very lo...",
|
||||
},
|
||||
{
|
||||
name: "message array - last user message",
|
||||
input: []interface{}{
|
||||
map[string]interface{}{"role": "user", "content": "First"},
|
||||
map[string]interface{}{"role": "assistant", "content": "Response"},
|
||||
map[string]interface{}{"role": "user", "content": "Last user message"},
|
||||
},
|
||||
maxLen: 50,
|
||||
expected: "Last user message",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := SummarizeInput(tt.input, tt.maxLen)
|
||||
if result != tt.expected {
|
||||
t.Errorf("Expected '%s', got '%s'", tt.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -25,8 +25,12 @@ func (l *JSONLLoader) Load() ([]*Case, error) {
|
|||
}
|
||||
|
||||
// LoadFile loads test cases from a JSONL file
|
||||
// If path is relative and YAO_ROOT is set, resolves relative to YAO_ROOT
|
||||
func (l *JSONLLoader) LoadFile(path string) ([]*Case, error) {
|
||||
file, err := os.Open(path)
|
||||
// Resolve path relative to YAO_ROOT if it's a relative path
|
||||
resolvedPath := ResolvePathWithYaoRoot(path)
|
||||
|
||||
file, err := os.Open(resolvedPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open file %s: %w", path, err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ func (r *PathResolver) ResolveFromCwd() (*AgentInfo, error) {
|
|||
|
||||
// ResolveFromPath resolves the agent by traversing up from the input file path
|
||||
// It looks for package.yao in parent directories
|
||||
// If YAO_ROOT is set, it also considers paths relative to YAO_ROOT
|
||||
func (r *PathResolver) ResolveFromPath(inputPath string) (*AgentInfo, error) {
|
||||
// Get absolute path
|
||||
absPath, err := filepath.Abs(inputPath)
|
||||
|
|
@ -82,6 +83,37 @@ func (r *PathResolver) ResolveFromPath(inputPath string) (*AgentInfo, error) {
|
|||
dir = parent
|
||||
}
|
||||
|
||||
// If YAO_ROOT is set, try resolving relative to it
|
||||
yaoRoot := os.Getenv("YAO_ROOT")
|
||||
if yaoRoot != "" {
|
||||
// Try the input path relative to YAO_ROOT
|
||||
relPath := inputPath
|
||||
// If inputPath is absolute, try to make it relative
|
||||
if filepath.IsAbs(inputPath) {
|
||||
// Check if inputPath is under YAO_ROOT
|
||||
if rel, err := filepath.Rel(yaoRoot, inputPath); err == nil && !strings.HasPrefix(rel, "..") {
|
||||
relPath = rel
|
||||
}
|
||||
}
|
||||
|
||||
// Traverse up from YAO_ROOT + relPath
|
||||
dir = filepath.Join(yaoRoot, filepath.Dir(relPath))
|
||||
for {
|
||||
packagePath := filepath.Join(dir, "package.yao")
|
||||
if _, err := os.Stat(packagePath); err == nil {
|
||||
// Found package.yao
|
||||
return r.loadAgentFromPath(dir, packagePath)
|
||||
}
|
||||
|
||||
// Move to parent directory, but don't go above YAO_ROOT
|
||||
parent := filepath.Dir(dir)
|
||||
if parent == dir || !strings.HasPrefix(parent, yaoRoot) {
|
||||
break
|
||||
}
|
||||
dir = parent
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("no package.yao found in path hierarchy of %s", inputPath)
|
||||
}
|
||||
|
||||
|
|
@ -180,7 +212,8 @@ func ValidateOptions(opts *Options) error {
|
|||
|
||||
// For file mode, check input file exists
|
||||
if opts.InputMode == InputModeFile {
|
||||
if _, err := os.Stat(opts.Input); os.IsNotExist(err) {
|
||||
resolvedPath := ResolvePathWithYaoRoot(opts.Input)
|
||||
if _, err := os.Stat(resolvedPath); os.IsNotExist(err) {
|
||||
return fmt.Errorf("input file not found: %s", opts.Input)
|
||||
}
|
||||
}
|
||||
|
|
@ -297,7 +330,9 @@ func MergeOptions(opts *Options, defaults *Options) *Options {
|
|||
// Format: {input_directory}/output-{timestamp}.jsonl
|
||||
// Timestamp format: YYYYMMDDHHMMSS
|
||||
func GenerateDefaultOutputPath(inputPath string) string {
|
||||
dir := filepath.Dir(inputPath)
|
||||
// Resolve input path considering YAO_ROOT
|
||||
resolvedPath := ResolvePathWithYaoRoot(inputPath)
|
||||
dir := filepath.Dir(resolvedPath)
|
||||
timestamp := time.Now().Format("20060102150405")
|
||||
filename := fmt.Sprintf("output-%s.jsonl", timestamp)
|
||||
return filepath.Join(dir, filename)
|
||||
|
|
@ -328,3 +363,19 @@ func CreateTestCaseFromMessage(message string) *Case {
|
|||
Input: message,
|
||||
}
|
||||
}
|
||||
|
||||
// ResolvePathWithYaoRoot resolves a file path relative to current directory
|
||||
// No fallback to YAO_ROOT - paths are always resolved from current working directory
|
||||
func ResolvePathWithYaoRoot(path string) string {
|
||||
// If path is absolute, return as-is
|
||||
if filepath.IsAbs(path) {
|
||||
return path
|
||||
}
|
||||
|
||||
// Resolve relative to current directory
|
||||
absPath, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return path
|
||||
}
|
||||
return absPath
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import (
|
|||
stdContext "context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
|
|
@ -105,8 +106,9 @@ func (r *Executor) RunDirect() (*Report, error) {
|
|||
defer cancel()
|
||||
ctx.Context = timeoutCtx
|
||||
|
||||
// Parse input to messages
|
||||
messages, err := tc.GetMessages()
|
||||
// Parse input to messages with file loading support
|
||||
inputOpts := r.getInputOptions()
|
||||
messages, err := tc.GetMessagesWithOptions(inputOpts)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse input: %w", err)
|
||||
}
|
||||
|
|
@ -320,8 +322,10 @@ func (r *Executor) runSingleTest(ast *assistant.Assistant, tc *Case, agentID str
|
|||
Options: tc.Options,
|
||||
}
|
||||
|
||||
// Parse input to messages
|
||||
messages, err := tc.GetMessages()
|
||||
// Parse input to messages with file loading support
|
||||
// BaseDir is derived from the input file directory
|
||||
inputOpts := r.getInputOptions()
|
||||
messages, err := tc.GetMessagesWithOptions(inputOpts)
|
||||
if err != nil {
|
||||
result.Status = StatusError
|
||||
result.Error = fmt.Sprintf("failed to parse input: %s", err.Error())
|
||||
|
|
@ -613,3 +617,19 @@ func validateOutput(actual, expected interface{}) bool {
|
|||
|
||||
return string(actualJSON) == string(expectedJSON)
|
||||
}
|
||||
|
||||
// getInputOptions returns InputOptions based on the runner configuration
|
||||
// BaseDir is derived from the input file directory (for file mode) or current working directory
|
||||
func (r *Executor) getInputOptions() *InputOptions {
|
||||
opts := &InputOptions{}
|
||||
|
||||
// For file mode, use the input file's directory as base
|
||||
if r.opts.InputMode == InputModeFile && r.opts.Input != "" {
|
||||
// Resolve path considering YAO_ROOT
|
||||
resolvedPath := ResolvePathWithYaoRoot(r.opts.Input)
|
||||
opts.BaseDir = filepath.Dir(resolvedPath)
|
||||
}
|
||||
// For message mode, BaseDir remains empty (uses current working directory)
|
||||
|
||||
return opts
|
||||
}
|
||||
|
|
|
|||
|
|
@ -492,6 +492,13 @@ func (tc *Case) GetMessages() ([]context.Message, error) {
|
|||
return ParseInput(tc.Input)
|
||||
}
|
||||
|
||||
// GetMessagesWithOptions converts the Input to a slice of context.Message with options
|
||||
// This handles all input formats: string, Message, []Message
|
||||
// It also processes file:// references in content parts
|
||||
func (tc *Case) GetMessagesWithOptions(opts *InputOptions) ([]context.Message, error) {
|
||||
return ParseInputWithOptions(tc.Input, opts)
|
||||
}
|
||||
|
||||
// GetTimeout returns the timeout duration for this test case
|
||||
// Returns the override timeout if set, otherwise returns the default
|
||||
func (tc *Case) GetTimeout(defaultTimeout time.Duration) time.Duration {
|
||||
|
|
|
|||
61
bin/yao-dev
Executable file
61
bin/yao-dev
Executable file
|
|
@ -0,0 +1,61 @@
|
|||
#!/bin/bash
|
||||
|
||||
# yao-dev - Run yao from source for real-time debugging
|
||||
# Uses go -C to compile from source while keeping current directory as app root
|
||||
|
||||
# Get the real path of this script (resolve symlinks)
|
||||
SCRIPT_PATH="${BASH_SOURCE[0]}"
|
||||
while [ -L "$SCRIPT_PATH" ]; do
|
||||
SCRIPT_DIR="$(cd "$(dirname "$SCRIPT_PATH")" && pwd)"
|
||||
SCRIPT_PATH="$(readlink "$SCRIPT_PATH")"
|
||||
# If the link is relative, resolve it relative to the directory
|
||||
[[ "$SCRIPT_PATH" != /* ]] && SCRIPT_PATH="$SCRIPT_DIR/$SCRIPT_PATH"
|
||||
done
|
||||
SCRIPT_DIR="$(cd "$(dirname "$SCRIPT_PATH")" && pwd)"
|
||||
|
||||
# YAO source directory is the parent of bin/
|
||||
YAO_SOURCE_DIR="$(dirname "$SCRIPT_DIR")"
|
||||
|
||||
if [ ! -d "$YAO_SOURCE_DIR" ]; then
|
||||
echo "Error: yao source directory not found at $YAO_SOURCE_DIR"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f "$YAO_SOURCE_DIR/go.mod" ]; then
|
||||
echo "Error: go.mod not found in $YAO_SOURCE_DIR, not a valid yao source directory"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Find app root by looking for app.yao in current directory or parent directories
|
||||
find_app_root() {
|
||||
local dir="$(pwd)"
|
||||
while [ "$dir" != "/" ]; do
|
||||
if [ -f "$dir/app.yao" ] || [ -f "$dir/app.json" ] || [ -f "$dir/app.jsonc" ]; then
|
||||
echo "$dir"
|
||||
return 0
|
||||
fi
|
||||
dir="$(dirname "$dir")"
|
||||
done
|
||||
# If not found, use current directory as fallback
|
||||
pwd
|
||||
}
|
||||
|
||||
# Set YAO_ROOT to app root directory
|
||||
export YAO_ROOT="$(find_app_root)"
|
||||
|
||||
# Convert relative file paths in arguments to absolute paths
|
||||
# This is needed because go -C changes the working directory
|
||||
ARGS=()
|
||||
for arg in "$@"; do
|
||||
# Check if it looks like a relative path
|
||||
if [[ "$arg" == ./* ]] || [[ "$arg" == ../* ]]; then
|
||||
# Always convert to absolute path based on current directory
|
||||
ARGS+=("$(pwd)/${arg#./}")
|
||||
else
|
||||
ARGS+=("$arg")
|
||||
fi
|
||||
done
|
||||
|
||||
# Use go -C to run from source directory while staying in current directory
|
||||
exec go -C "$YAO_SOURCE_DIR" run . "${ARGS[@]}"
|
||||
|
||||
|
|
@ -52,6 +52,7 @@ func L(words string) string {
|
|||
|
||||
// Boot sets the configuration
|
||||
func Boot() {
|
||||
// Use root from Init() unless appPath is explicitly specified
|
||||
root := config.Conf.Root
|
||||
if appPath != "" {
|
||||
r, err := filepath.Abs(appPath)
|
||||
|
|
@ -60,15 +61,13 @@ func Boot() {
|
|||
}
|
||||
root = r
|
||||
}
|
||||
|
||||
// Load .env file, preserving the correct root
|
||||
if envFile != "" {
|
||||
config.Conf = config.LoadFrom(envFile)
|
||||
config.Conf = config.LoadFromWithRoot(envFile, root)
|
||||
} else {
|
||||
config.Conf = config.LoadFrom(filepath.Join(root, ".env"))
|
||||
config.Conf = config.LoadFromWithRoot(filepath.Join(root, ".env"), root)
|
||||
}
|
||||
|
||||
if config.Conf.Mode == "production" {
|
||||
config.Production()
|
||||
} else if config.Conf.Mode == "development" {
|
||||
config.Development()
|
||||
}
|
||||
config.ApplyMode()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,52 +32,106 @@ func init() {
|
|||
|
||||
// Init setting
|
||||
func Init() {
|
||||
// Determine app root: YAO_ROOT env > find app.yao > current directory
|
||||
root := os.Getenv("YAO_ROOT")
|
||||
if root == "" {
|
||||
root = findAppRoot()
|
||||
}
|
||||
if root == "" {
|
||||
root = "."
|
||||
}
|
||||
|
||||
filename, _ := filepath.Abs(filepath.Join(".", ".env"))
|
||||
filename := filepath.Join(root, ".env")
|
||||
if _, err := os.Stat(filename); errors.Is(err, os.ErrNotExist) {
|
||||
Conf = Load()
|
||||
if Conf.Mode == "production" {
|
||||
Production()
|
||||
} else if Conf.Mode == "development" {
|
||||
Development()
|
||||
}
|
||||
Conf = LoadWithRoot(root)
|
||||
ApplyMode()
|
||||
return
|
||||
}
|
||||
|
||||
Conf = LoadFrom(filename)
|
||||
if Conf.Mode == "production" {
|
||||
// Load .env then override root if auto-detected
|
||||
Conf = LoadFromWithRoot(filename, root)
|
||||
ApplyMode()
|
||||
}
|
||||
|
||||
// ApplyMode applies production or development mode based on Conf.Mode
|
||||
func ApplyMode() {
|
||||
switch Conf.Mode {
|
||||
case "production":
|
||||
Production()
|
||||
} else if Conf.Mode == "development" {
|
||||
case "development":
|
||||
Development()
|
||||
}
|
||||
}
|
||||
|
||||
// findAppRoot finds the Yao application root directory by looking for app.yao
|
||||
// It traverses up from the current directory until it finds app.yao or reaches the filesystem root
|
||||
func findAppRoot() string {
|
||||
dir, err := os.Getwd()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
for {
|
||||
// Check for app.yao, app.json, or app.jsonc
|
||||
for _, appFile := range []string{"app.yao", "app.json", "app.jsonc"} {
|
||||
appFilePath := filepath.Join(dir, appFile)
|
||||
if _, err := os.Stat(appFilePath); err == nil {
|
||||
return dir
|
||||
}
|
||||
}
|
||||
|
||||
// Move to parent directory
|
||||
parent := filepath.Dir(dir)
|
||||
if parent == dir {
|
||||
// Reached root, no app.yao found
|
||||
break
|
||||
}
|
||||
dir = parent
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// LoadFrom 从配置项中加载
|
||||
func LoadFrom(envfile string) Config {
|
||||
return LoadFromWithRoot(envfile, "")
|
||||
}
|
||||
|
||||
// LoadFromWithRoot loads config from env file with optional root override
|
||||
func LoadFromWithRoot(envfile string, root string) Config {
|
||||
file, err := filepath.Abs(envfile)
|
||||
if err != nil {
|
||||
cfg := Load()
|
||||
cfg := LoadWithRoot(root)
|
||||
ReloadLog()
|
||||
return cfg
|
||||
}
|
||||
|
||||
// load from env
|
||||
godotenv.Overload(file)
|
||||
cfg := Load()
|
||||
cfg := LoadWithRoot(root)
|
||||
ReloadLog()
|
||||
return cfg
|
||||
}
|
||||
|
||||
// Load the config
|
||||
func Load() Config {
|
||||
return LoadWithRoot("")
|
||||
}
|
||||
|
||||
// LoadWithRoot loads config with an optional root override
|
||||
// If root is empty, uses YAO_ROOT env or current directory
|
||||
func LoadWithRoot(root string) Config {
|
||||
cfg := Config{}
|
||||
if err := env.Parse(&cfg); err != nil {
|
||||
exception.New("Can't read config %s", 500, err.Error()).Throw()
|
||||
}
|
||||
|
||||
// Root path
|
||||
cfg.Root, _ = filepath.Abs(cfg.Root)
|
||||
// Root path: use provided root > env YAO_ROOT > default "."
|
||||
if root != "" {
|
||||
cfg.Root, _ = filepath.Abs(root)
|
||||
} else {
|
||||
cfg.Root, _ = filepath.Abs(cfg.Root)
|
||||
}
|
||||
|
||||
// App Root
|
||||
if cfg.AppSource == "" {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue