- Registered new "opencode" and "opencode/cli" runners in the sandbox initialization. - Updated the `runExecStep` function to use the computer's working directory and set the HOME environment variable. - Refactored attachment processing in the Claude package to delegate to shared functionality, improving code maintainability.
251 lines
6.5 KiB
Go
251 lines
6.5 KiB
Go
package shared
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
agentContext "github.com/yaoapp/yao/agent/context"
|
|
"github.com/yaoapp/yao/attachment"
|
|
workspace "github.com/yaoapp/yao/tai/workspace"
|
|
)
|
|
|
|
// AttachmentResult holds the resolved attachment info after copying to workspace.
|
|
type AttachmentResult struct {
|
|
Path string // workspace-relative path (e.g. ".attachments/{chatID}/image.png")
|
|
ContentType string
|
|
Filename string
|
|
Bytes int
|
|
}
|
|
|
|
// PrepareAttachments resolves __yao.attachment:// URLs in user messages,
|
|
// copies actual files into the workspace .attachments/{chatID}/ directory,
|
|
// and returns processed messages plus a list of resolved file paths.
|
|
//
|
|
// The returned messages have multimodal content replaced with text references
|
|
// (for runners like Claude that need text-only). Callers that need the raw
|
|
// file paths (like OpenCode's --file) can use the returned []AttachmentResult.
|
|
func PrepareAttachments(ctx context.Context, messages []agentContext.Message, chatID string, ws workspace.FS) ([]agentContext.Message, []AttachmentResult, error) {
|
|
usedNames := make(map[string]int)
|
|
attachDir := ".attachments/" + chatID
|
|
var resolved []AttachmentResult
|
|
|
|
result := make([]agentContext.Message, len(messages))
|
|
copy(result, messages)
|
|
|
|
for i, msg := range result {
|
|
if msg.Role != "user" {
|
|
continue
|
|
}
|
|
|
|
parts, ok := msg.Content.([]interface{})
|
|
if !ok {
|
|
if typedParts, ok := msg.Content.([]agentContext.ContentPart); ok {
|
|
iparts := make([]interface{}, len(typedParts))
|
|
for j, p := range typedParts {
|
|
m := map[string]interface{}{"type": string(p.Type)}
|
|
if p.Text != "" {
|
|
m["text"] = p.Text
|
|
}
|
|
if p.ImageURL != nil {
|
|
m["image_url"] = map[string]interface{}{
|
|
"url": p.ImageURL.URL,
|
|
"detail": string(p.ImageURL.Detail),
|
|
}
|
|
}
|
|
if p.File != nil {
|
|
m["file"] = map[string]interface{}{
|
|
"url": p.File.URL,
|
|
"filename": p.File.Filename,
|
|
}
|
|
}
|
|
iparts[j] = m
|
|
}
|
|
parts = iparts
|
|
} else {
|
|
continue
|
|
}
|
|
}
|
|
|
|
if len(parts) == 0 {
|
|
continue
|
|
}
|
|
|
|
var textParts []string
|
|
|
|
for _, item := range parts {
|
|
m, ok := item.(map[string]interface{})
|
|
if !ok {
|
|
continue
|
|
}
|
|
partType, _ := m["type"].(string)
|
|
|
|
switch partType {
|
|
case "text":
|
|
if text, ok := m["text"].(string); ok && text != "" {
|
|
textParts = append(textParts, text)
|
|
}
|
|
|
|
case "image_url":
|
|
imgData, _ := m["image_url"].(map[string]interface{})
|
|
if imgData == nil {
|
|
continue
|
|
}
|
|
url, _ := imgData["url"].(string)
|
|
if url == "" {
|
|
continue
|
|
}
|
|
uploaderName, fileID, isWrapper := attachment.Parse(url)
|
|
if !isWrapper {
|
|
textParts = append(textParts, fmt.Sprintf("[Image: %s]", url))
|
|
continue
|
|
}
|
|
ar, ref, err := resolveAttachment(ctx, uploaderName, fileID, "", attachDir, usedNames, ws)
|
|
if err != nil {
|
|
textParts = append(textParts, "[Attached image: failed to load]")
|
|
continue
|
|
}
|
|
resolved = append(resolved, *ar)
|
|
textParts = append(textParts, ref)
|
|
|
|
case "file":
|
|
fileData, _ := m["file"].(map[string]interface{})
|
|
if fileData == nil {
|
|
continue
|
|
}
|
|
url, _ := fileData["url"].(string)
|
|
hintName, _ := fileData["filename"].(string)
|
|
if url == "" {
|
|
continue
|
|
}
|
|
uploaderName, fileID, isWrapper := attachment.Parse(url)
|
|
if !isWrapper {
|
|
textParts = append(textParts, fmt.Sprintf("[File: %s]", url))
|
|
continue
|
|
}
|
|
ar, ref, err := resolveAttachment(ctx, uploaderName, fileID, hintName, attachDir, usedNames, ws)
|
|
if err != nil {
|
|
textParts = append(textParts, "[Attached file: failed to load]")
|
|
continue
|
|
}
|
|
resolved = append(resolved, *ar)
|
|
textParts = append(textParts, ref)
|
|
}
|
|
}
|
|
|
|
if len(textParts) > 0 {
|
|
newMsg := result[i]
|
|
newMsg.Content = strings.Join(textParts, "\n\n")
|
|
result[i] = newMsg
|
|
}
|
|
}
|
|
|
|
return result, resolved, nil
|
|
}
|
|
|
|
// resolveAttachment gets the local path of an attachment and copies it into
|
|
// the workspace via ws.Copy("local:///abs/path", ".attachments/{chatID}/filename").
|
|
func resolveAttachment(
|
|
ctx context.Context,
|
|
uploaderName, fileID, hintName, attachDir string,
|
|
usedNames map[string]int,
|
|
ws workspace.FS,
|
|
) (*AttachmentResult, string, error) {
|
|
manager, exists := attachment.Managers[uploaderName]
|
|
if !exists {
|
|
return nil, "", fmt.Errorf("attachment manager not found: %s", uploaderName)
|
|
}
|
|
|
|
fileInfo, err := manager.Info(ctx, fileID)
|
|
if err != nil {
|
|
return nil, "", fmt.Errorf("failed to get file info: %w", err)
|
|
}
|
|
|
|
absPath, _, err := manager.LocalPath(ctx, fileID)
|
|
if err != nil {
|
|
return nil, "", fmt.Errorf("failed to get local path: %w", err)
|
|
}
|
|
|
|
filename := fileInfo.Filename
|
|
if filename == "" && hintName != "" {
|
|
filename = hintName
|
|
}
|
|
if filename == "" {
|
|
ext := ExtensionFromContentType(fileInfo.ContentType)
|
|
filename = fileID + ext
|
|
}
|
|
|
|
baseName := filename
|
|
if count, exists := usedNames[baseName]; exists {
|
|
ext := filepath.Ext(filename)
|
|
name := strings.TrimSuffix(filename, ext)
|
|
filename = fmt.Sprintf("%s_%d%s", name, count+1, ext)
|
|
usedNames[baseName] = count + 1
|
|
} else {
|
|
usedNames[baseName] = 0
|
|
}
|
|
|
|
dstPath := attachDir + "/" + filename
|
|
src := "local:///" + absPath
|
|
|
|
if _, err := ws.Copy(src, dstPath); err != nil {
|
|
return nil, "", fmt.Errorf("failed to copy attachment to workspace: %w", err)
|
|
}
|
|
|
|
sizeStr := FormatFileSize(fileInfo.Bytes)
|
|
ref := fmt.Sprintf("[Attached file: %s (%s, %s)]", dstPath, fileInfo.ContentType, sizeStr)
|
|
|
|
ar := &AttachmentResult{
|
|
Path: dstPath,
|
|
ContentType: fileInfo.ContentType,
|
|
Filename: filename,
|
|
Bytes: fileInfo.Bytes,
|
|
}
|
|
|
|
return ar, ref, nil
|
|
}
|
|
|
|
// ExtensionFromContentType maps common MIME types to file extensions.
|
|
func ExtensionFromContentType(contentType string) string {
|
|
switch contentType {
|
|
case "image/png":
|
|
return ".png"
|
|
case "image/jpeg":
|
|
return ".jpg"
|
|
case "image/gif":
|
|
return ".gif"
|
|
case "image/webp":
|
|
return ".webp"
|
|
case "image/svg+xml":
|
|
return ".svg"
|
|
case "application/pdf":
|
|
return ".pdf"
|
|
case "text/plain":
|
|
return ".txt"
|
|
case "text/html":
|
|
return ".html"
|
|
case "text/css":
|
|
return ".css"
|
|
case "text/javascript", "application/javascript":
|
|
return ".js"
|
|
case "application/json":
|
|
return ".json"
|
|
case "application/zip":
|
|
return ".zip"
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
// FormatFileSize returns a human-readable file size string.
|
|
func FormatFileSize(bytes int) string {
|
|
switch {
|
|
case bytes >= 1024*1024:
|
|
return fmt.Sprintf("%.1fMB", float64(bytes)/(1024*1024))
|
|
case bytes >= 1024:
|
|
return fmt.Sprintf("%.1fKB", float64(bytes)/1024)
|
|
default:
|
|
return fmt.Sprintf("%dB", bytes)
|
|
}
|
|
}
|