334 lines
8.2 KiB
Go
334 lines
8.2 KiB
Go
package llm
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"io"
|
|
"io/fs"
|
|
"path"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"gitea.maximumdirect.net/eric/scriptorium"
|
|
)
|
|
|
|
type AssetSource struct {
|
|
FS fs.FS
|
|
Root string
|
|
}
|
|
|
|
type AssetRegistry struct {
|
|
prompts []AssetSource
|
|
schemas []AssetSource
|
|
}
|
|
|
|
type AssetHashPart struct {
|
|
FS fs.FS
|
|
Path string
|
|
}
|
|
|
|
func NewAssetRegistry() *AssetRegistry {
|
|
return &AssetRegistry{}
|
|
}
|
|
|
|
func (r *AssetRegistry) RegisterPromptFS(fsys fs.FS, root string) error {
|
|
if r == nil {
|
|
return fmt.Errorf("asset registry must not be nil")
|
|
}
|
|
source, err := newAssetSource(fsys, root)
|
|
if err != nil {
|
|
return fmt.Errorf("register prompt assets: %w", err)
|
|
}
|
|
r.prompts = append(r.prompts, source)
|
|
return nil
|
|
}
|
|
|
|
func (r *AssetRegistry) RegisterSchemaFS(fsys fs.FS, root string) error {
|
|
if r == nil {
|
|
return fmt.Errorf("asset registry must not be nil")
|
|
}
|
|
source, err := newAssetSource(fsys, root)
|
|
if err != nil {
|
|
return fmt.Errorf("register schema assets: %w", err)
|
|
}
|
|
r.schemas = append(r.schemas, source)
|
|
return nil
|
|
}
|
|
|
|
func (r *AssetRegistry) PromptFS() (fs.FS, error) {
|
|
if r == nil {
|
|
return nil, fmt.Errorf("asset registry must not be nil")
|
|
}
|
|
return flattenAssetSources(r.prompts)
|
|
}
|
|
|
|
func (r *AssetRegistry) SchemaFS() (fs.FS, error) {
|
|
if r == nil {
|
|
return nil, fmt.Errorf("asset registry must not be nil")
|
|
}
|
|
return flattenAssetSources(r.schemas)
|
|
}
|
|
|
|
func (r *AssetRegistry) ScriptoriumOptions() ([]scriptorium.Option, error) {
|
|
promptFS, err := r.PromptFS()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("prepare prompt assets: %w", err)
|
|
}
|
|
schemaFS, err := r.SchemaFS()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("prepare schema assets: %w", err)
|
|
}
|
|
return []scriptorium.Option{
|
|
scriptorium.WithPromptFS(promptFS, "."),
|
|
scriptorium.WithSchemaFS(schemaFS, "."),
|
|
}, nil
|
|
}
|
|
|
|
func HashAssets(parts []AssetHashPart) (string, error) {
|
|
if len(parts) == 0 {
|
|
return "", fmt.Errorf("asset hash requires at least one part")
|
|
}
|
|
hash := sha256.New()
|
|
for _, part := range parts {
|
|
cleanPath, err := cleanAssetPath(part.Path)
|
|
if err != nil {
|
|
return "", fmt.Errorf("hash asset %q: %w", part.Path, err)
|
|
}
|
|
data, err := fs.ReadFile(part.FS, cleanPath)
|
|
if err != nil {
|
|
return "", fmt.Errorf("read hash asset %s: %w", cleanPath, err)
|
|
}
|
|
if _, err := io.WriteString(hash, cleanPath); err != nil {
|
|
return "", err
|
|
}
|
|
if _, err := hash.Write([]byte{0}); err != nil {
|
|
return "", err
|
|
}
|
|
if _, err := hash.Write(data); err != nil {
|
|
return "", err
|
|
}
|
|
if _, err := hash.Write([]byte{0}); err != nil {
|
|
return "", err
|
|
}
|
|
}
|
|
return "sha256:" + hex.EncodeToString(hash.Sum(nil)), nil
|
|
}
|
|
|
|
func newAssetSource(fsys fs.FS, root string) (AssetSource, error) {
|
|
if fsys == nil {
|
|
return AssetSource{}, fmt.Errorf("filesystem must not be nil")
|
|
}
|
|
cleanRoot, err := cleanAssetRoot(root)
|
|
if err != nil {
|
|
return AssetSource{}, err
|
|
}
|
|
return AssetSource{FS: fsys, Root: cleanRoot}, nil
|
|
}
|
|
|
|
func flattenAssetSources(sources []AssetSource) (fs.FS, error) {
|
|
out := assetMapFS{}
|
|
for _, source := range sources {
|
|
if err := fs.WalkDir(source.FS, source.Root, func(name string, entry fs.DirEntry, walkErr error) error {
|
|
if walkErr != nil {
|
|
return walkErr
|
|
}
|
|
if entry.IsDir() {
|
|
return nil
|
|
}
|
|
rel := name
|
|
if source.Root != "." {
|
|
rel = strings.TrimPrefix(name, source.Root+"/")
|
|
}
|
|
rel, err := cleanAssetPath(rel)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if _, exists := out[rel]; exists {
|
|
return fmt.Errorf("duplicate asset path %q", rel)
|
|
}
|
|
data, err := fs.ReadFile(source.FS, name)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
out[rel] = append([]byte(nil), data...)
|
|
return nil
|
|
}); err != nil {
|
|
return nil, fmt.Errorf("walk asset root %s: %w", source.Root, err)
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func cleanAssetRoot(root string) (string, error) {
|
|
trimmed := strings.TrimSpace(root)
|
|
if trimmed == "" || trimmed == "." {
|
|
return ".", nil
|
|
}
|
|
return cleanAssetPath(trimmed)
|
|
}
|
|
|
|
func cleanAssetPath(name string) (string, error) {
|
|
trimmed := strings.TrimSpace(name)
|
|
if trimmed == "" {
|
|
return "", fmt.Errorf("path must not be empty")
|
|
}
|
|
cleaned := path.Clean(strings.TrimPrefix(trimmed, "/"))
|
|
if cleaned == "." || !fs.ValidPath(cleaned) {
|
|
return "", fmt.Errorf("invalid path %q", name)
|
|
}
|
|
return cleaned, nil
|
|
}
|
|
|
|
type assetMapFS map[string][]byte
|
|
|
|
func (m assetMapFS) Open(name string) (fs.File, error) {
|
|
cleaned, err := cleanOpenPath(name)
|
|
if err != nil {
|
|
return nil, &fs.PathError{Op: "open", Path: name, Err: err}
|
|
}
|
|
if data, ok := m[cleaned]; ok {
|
|
return &assetFile{
|
|
reader: bytes.NewReader(data),
|
|
info: assetFileInfo{name: path.Base(cleaned), size: int64(len(data))},
|
|
}, nil
|
|
}
|
|
entries := m.dirEntries(cleaned)
|
|
if entries != nil {
|
|
return &assetDir{name: path.Base(cleaned), entries: entries}, nil
|
|
}
|
|
return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrNotExist}
|
|
}
|
|
|
|
func (m assetMapFS) ReadFile(name string) ([]byte, error) {
|
|
cleaned, err := cleanOpenPath(name)
|
|
if err != nil {
|
|
return nil, &fs.PathError{Op: "readfile", Path: name, Err: err}
|
|
}
|
|
data, ok := m[cleaned]
|
|
if !ok {
|
|
return nil, &fs.PathError{Op: "readfile", Path: name, Err: fs.ErrNotExist}
|
|
}
|
|
return append([]byte(nil), data...), nil
|
|
}
|
|
|
|
func (m assetMapFS) ReadDir(name string) ([]fs.DirEntry, error) {
|
|
cleaned, err := cleanOpenPath(name)
|
|
if err != nil {
|
|
return nil, &fs.PathError{Op: "readdir", Path: name, Err: err}
|
|
}
|
|
entries := m.dirEntries(cleaned)
|
|
if entries == nil {
|
|
return nil, &fs.PathError{Op: "readdir", Path: name, Err: fs.ErrNotExist}
|
|
}
|
|
return entries, nil
|
|
}
|
|
|
|
func (m assetMapFS) dirEntries(dir string) []fs.DirEntry {
|
|
children := map[string]assetDirEntry{}
|
|
prefix := ""
|
|
if dir != "." {
|
|
prefix = dir + "/"
|
|
}
|
|
for name, data := range m {
|
|
if !strings.HasPrefix(name, prefix) {
|
|
continue
|
|
}
|
|
rest := strings.TrimPrefix(name, prefix)
|
|
if rest == "" {
|
|
continue
|
|
}
|
|
childName, _, hasSlash := strings.Cut(rest, "/")
|
|
entry := assetDirEntry{name: childName, dir: hasSlash}
|
|
if !hasSlash {
|
|
entry.size = int64(len(data))
|
|
}
|
|
children[childName] = entry
|
|
}
|
|
if len(children) == 0 {
|
|
return nil
|
|
}
|
|
names := make([]string, 0, len(children))
|
|
for name := range children {
|
|
names = append(names, name)
|
|
}
|
|
sort.Strings(names)
|
|
entries := make([]fs.DirEntry, 0, len(names))
|
|
for _, name := range names {
|
|
entries = append(entries, children[name])
|
|
}
|
|
return entries
|
|
}
|
|
|
|
func cleanOpenPath(name string) (string, error) {
|
|
if name == "." {
|
|
return ".", nil
|
|
}
|
|
return cleanAssetPath(name)
|
|
}
|
|
|
|
type assetFile struct {
|
|
reader *bytes.Reader
|
|
info assetFileInfo
|
|
}
|
|
|
|
func (f *assetFile) Stat() (fs.FileInfo, error) { return f.info, nil }
|
|
func (f *assetFile) Read(p []byte) (int, error) { return f.reader.Read(p) }
|
|
func (f *assetFile) Close() error { return nil }
|
|
|
|
type assetDir struct {
|
|
name string
|
|
offset int
|
|
entries []fs.DirEntry
|
|
}
|
|
|
|
func (d *assetDir) Stat() (fs.FileInfo, error) { return assetFileInfo{name: d.name, dir: true}, nil }
|
|
func (d *assetDir) Read([]byte) (int, error) { return 0, fmt.Errorf("cannot read directory") }
|
|
func (d *assetDir) Close() error { return nil }
|
|
func (d *assetDir) ReadDir(n int) ([]fs.DirEntry, error) {
|
|
if d.offset >= len(d.entries) {
|
|
return nil, io.EOF
|
|
}
|
|
end := len(d.entries)
|
|
if n > 0 && d.offset+n < end {
|
|
end = d.offset + n
|
|
}
|
|
out := append([]fs.DirEntry(nil), d.entries[d.offset:end]...)
|
|
d.offset = end
|
|
return out, nil
|
|
}
|
|
|
|
type assetDirEntry struct {
|
|
name string
|
|
dir bool
|
|
size int64
|
|
}
|
|
|
|
func (e assetDirEntry) Name() string { return e.name }
|
|
func (e assetDirEntry) IsDir() bool { return e.dir }
|
|
func (e assetDirEntry) Type() fs.FileMode { return e.InfoMode().Type() }
|
|
func (e assetDirEntry) Info() (fs.FileInfo, error) {
|
|
return assetFileInfo{name: e.name, dir: e.dir, size: e.size}, nil
|
|
}
|
|
func (e assetDirEntry) InfoMode() fs.FileMode {
|
|
if e.dir {
|
|
return fs.ModeDir | 0o555
|
|
}
|
|
return 0o444
|
|
}
|
|
|
|
type assetFileInfo struct {
|
|
name string
|
|
dir bool
|
|
size int64
|
|
}
|
|
|
|
func (i assetFileInfo) Name() string { return i.name }
|
|
func (i assetFileInfo) Size() int64 { return i.size }
|
|
func (i assetFileInfo) Mode() fs.FileMode { return assetDirEntry{dir: i.dir}.InfoMode() }
|
|
func (i assetFileInfo) ModTime() time.Time { return time.Time{} }
|
|
func (i assetFileInfo) IsDir() bool { return i.dir }
|
|
func (i assetFileInfo) Sys() any { return nil }
|