mirror of
https://github.com/mudler/LocalAI.git
synced 2026-11-06 12:33:16 -05:00
A connector add-on no longer carries file bytes in the connector envelope. The ops media and send_file need a transfer handle, and the gateway refuses a call to them without one, or for a kind that declares no files. - The manifest field files.transfer, the inline file of a request and of an answer (ConnectorFile) and the 1 MiB cap (MaxConnectorFileBytes) are removed. The 2 MiB limit of a connector call between hosts drops to 1 MiB, because it holds text and no file. - A kind that declares no files moves no file: the host answers a sender who attaches one with a plain sentence and does not offer attach_file. A direction left out of a declared files block still gets the host ceiling. - The inline layer is gone from the file limit, from the API (inline_bytes) and from the Files section of the connector page. - The Telegram and Slack add-ons keep only the streaming paths. The tests moved to the handle path, and the studio test rig gives every connector add-on a table of handles. Assisted-by: Claude:claude-sonnet-5-5 go test node
267 lines
13 KiB
Go
267 lines
13 KiB
Go
package studio_test
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"path/filepath"
|
|
"slices"
|
|
"strings"
|
|
|
|
. "github.com/onsi/ginkgo/v2"
|
|
. "github.com/onsi/gomega"
|
|
|
|
"github.com/mudler/LocalAI/core/http/auth"
|
|
"github.com/mudler/LocalAI/core/services/studio"
|
|
"github.com/mudler/LocalAI/core/services/studio/files"
|
|
"github.com/mudler/LocalAI/core/services/studio/media"
|
|
"github.com/mudler/LocalAI/core/services/studio/provider"
|
|
"github.com/mudler/LocalAI/core/services/studio/session"
|
|
"github.com/mudler/LocalAI/core/services/studio/store"
|
|
"github.com/mudler/LocalAI/core/services/studio/studiotest"
|
|
"github.com/mudler/LocalAI/core/services/studio/workspace"
|
|
"github.com/mudler/LocalAI/core/services/studio/workspace/blob"
|
|
"github.com/mudler/LocalAI/core/services/studio/workspace/jsonstore"
|
|
)
|
|
|
|
func newFiles() *files.Service {
|
|
f, _ := newFilesWithBlobs()
|
|
return f
|
|
}
|
|
|
|
// newFilesWithBlobs is newFiles that also returns the blob store, for the
|
|
// table of file handles that connector specs share with the service.
|
|
func newFilesWithBlobs() (*files.Service, blob.Store) {
|
|
blobs, err := blob.NewFS(filepath.Join(GinkgoT().TempDir(), "blobs"))
|
|
Expect(err).NotTo(HaveOccurred())
|
|
ws, err := jsonstore.New(filepath.Join(GinkgoT().TempDir(), "workspace"), blobs, jsonstore.Options{Admin: files.AdminPrincipal})
|
|
Expect(err).NotTo(HaveOccurred())
|
|
DeferCleanup(func() { _ = ws.Close() })
|
|
return files.New(ws, files.WithBlobs(blobs)), blobs
|
|
}
|
|
|
|
func newServiceWithFiles(h *studiotest.GateHarness, f *files.Service) *studio.Service {
|
|
st, err := store.NewJSON("")
|
|
Expect(err).NotTo(HaveOccurred())
|
|
svc, err := studio.New(studio.Deps{Store: st, Provider: provider.New(), StateDir: GinkgoT().TempDir(), Harness: h, Files: f})
|
|
Expect(err).NotTo(HaveOccurred())
|
|
DeferCleanup(func() { _ = svc.Close(context.Background()) })
|
|
return svc
|
|
}
|
|
|
|
var _ = Describe("files", func() {
|
|
It("a chat gives its user a home folder when files are on", func() {
|
|
f := newFiles()
|
|
h := studiotest.NewGateHarness()
|
|
svc := newServiceWithFiles(h, f)
|
|
id := create(svc, alice)
|
|
sent(svc, alice, id, "hi")
|
|
until("the turn to start", func() bool { return len(h.Sent()) == 1 })
|
|
h.Gate <- struct{}{}
|
|
idle(svc, alice, id)
|
|
|
|
_, err := f.Store().Stat(context.Background(), workspace.Actor{Principal: alice}, session.HomePath(alice))
|
|
Expect(err).NotTo(HaveOccurred(), "the user's home does not exist after a chat")
|
|
_, err = f.Store().Stat(context.Background(), workspace.Actor{Principal: "bob"}, session.HomePath(alice))
|
|
Expect(err).To(MatchError(workspace.ErrNotFound), "another user sees the home: %v", err)
|
|
})
|
|
|
|
It("a chat mounts the folders shared with its owner, with the mode of the grant", func() {
|
|
ctx := context.Background()
|
|
f := newFiles()
|
|
h := studiotest.NewGateHarness()
|
|
svc := newServiceWithFiles(h, f)
|
|
team, err := svc.SharedFolders().Create(ctx, "", "Team")
|
|
Expect(err).NotTo(HaveOccurred())
|
|
_, err = svc.SharedFolders().SetMember(ctx, "", team.ID, files.EveryoneGroup, workspace.ModeRead)
|
|
Expect(err).NotTo(HaveOccurred())
|
|
id := create(svc, alice)
|
|
sent(svc, alice, id, "hi")
|
|
until("the turn to start", func() bool { return len(h.Sent()) == 1 })
|
|
h.Gate <- struct{}{}
|
|
idle(svc, alice, id)
|
|
env := h.Env()
|
|
Expect(env.Actor.Groups).To(ContainElement(files.EveryoneGroup))
|
|
Expect(env.SystemPrompt).To(ContainSubstring("/workspace/shared-Team: a folder shared with the user (read only)"))
|
|
})
|
|
|
|
It("a chat works without files", func() {
|
|
h := studiotest.NewGateHarness()
|
|
svc := newServiceWithFiles(h, files.Disabled("off for this test"))
|
|
id := create(svc, alice)
|
|
sent(svc, alice, id, "hi")
|
|
until("the turn to start", func() bool { return len(h.Sent()) == 1 })
|
|
h.Gate <- struct{}{}
|
|
idle(svc, alice, id)
|
|
Expect(turn(svc, alice, id).Last).To(Equal(store.TurnDone))
|
|
Expect(svc.Files().Available()).To(BeFalse(), "the service reports files as available")
|
|
})
|
|
})
|
|
|
|
func upload(name, body string) session.Upload {
|
|
return session.Upload{Name: name, Body: strings.NewReader(body)}
|
|
}
|
|
|
|
var _ = Describe("files", func() {
|
|
It("an attached file lands in the chat folder and the model gets its path", func() {
|
|
f := newFiles()
|
|
h := studiotest.NewGateHarness()
|
|
svc := newServiceWithFiles(h, f)
|
|
id := create(svc, alice)
|
|
res, err := svc.SendWithFiles(context.Background(), alice, id, "summarize this", []session.Upload{upload("notes.txt", "alpha beta")})
|
|
Expect(err).NotTo(HaveOccurred(), "SendWithFiles = %+v, %v", res, err)
|
|
Expect(res.Started).To(BeTrue(), "SendWithFiles = %+v, %v", res, err)
|
|
until("the turn to start", func() bool { return len(h.Sent()) == 1 })
|
|
sent := h.Sent()[0]
|
|
Expect(sent).To(ContainSubstring("summarize this"), "the model got %q, want the text and the guest path of notes.txt", sent)
|
|
Expect(sent).To(ContainSubstring("/workspace/home/Chats/"), "the model got %q, want the text and the guest path of notes.txt", sent)
|
|
Expect(sent).To(ContainSubstring("notes.txt"), "the model got %q, want the text and the guest path of notes.txt", sent)
|
|
// The file is in the user's chat folder, as the user.
|
|
kids, err := f.Store().List(context.Background(), workspace.Actor{Principal: alice}, session.ChatsPath(alice))
|
|
Expect(err).NotTo(HaveOccurred(), "chat folders = %v, %v, want one", kids, err)
|
|
Expect(kids).To(HaveLen(1), "chat folders = %v, %v, want one", kids, err)
|
|
inner, err := f.Store().List(context.Background(), workspace.Actor{Principal: alice}, kids[0].Path)
|
|
Expect(err).NotTo(HaveOccurred(), "chat folder holds %v, %v, want notes.txt", inner, err)
|
|
Expect(inner).To(HaveLen(1), "chat folder holds %v, %v, want notes.txt", inner, err)
|
|
Expect(inner[0].Name).To(Equal("notes.txt"), "chat folder holds %v, %v, want notes.txt", inner, err)
|
|
h.Gate <- struct{}{}
|
|
idle(svc, alice, id)
|
|
})
|
|
|
|
It("a file sent while a turn runs is saved and queued with its path", func() {
|
|
f := newFiles()
|
|
h := studiotest.NewGateHarness()
|
|
svc := newServiceWithFiles(h, f)
|
|
id := create(svc, alice)
|
|
sent(svc, alice, id, "start")
|
|
until("the turn to start", func() bool { return len(h.Sent()) == 1 })
|
|
res, err := svc.SendWithFiles(context.Background(), alice, id, "and this", []session.Upload{upload("x.txt", "extra")})
|
|
Expect(err).NotTo(HaveOccurred(), "SendWithFiles during a turn = %+v, %v; want a waiting message", res, err)
|
|
Expect(res.Started).To(BeFalse(), "SendWithFiles during a turn = %+v, %v; want a waiting message", res, err)
|
|
Expect(res.PendingID).NotTo(Equal(""), "SendWithFiles during a turn = %+v, %v; want a waiting message", res, err)
|
|
got := pendingTexts(svc, alice, id)
|
|
Expect(got).To(HaveLen(1), "waiting messages = %q, want the text and the guest path of x.txt", got)
|
|
Expect(got[0]).To(ContainSubstring("and this"), "waiting messages = %q, want the text and the guest path of x.txt", got)
|
|
Expect(got[0]).To(ContainSubstring("/workspace/home/Chats/"), "waiting messages = %q, want the text and the guest path of x.txt", got)
|
|
Expect(got[0]).To(ContainSubstring("x.txt"), "waiting messages = %q, want the text and the guest path of x.txt", got)
|
|
// The file is saved already, in the chat folder, as the user.
|
|
kids, err := f.Store().List(context.Background(), workspace.Actor{Principal: alice}, session.ChatsPath(alice))
|
|
Expect(err).NotTo(HaveOccurred(), "chat folders = %v, %v, want one", kids, err)
|
|
Expect(kids).To(HaveLen(1), "chat folders = %v, %v, want one", kids, err)
|
|
inner, err := f.Store().List(context.Background(), workspace.Actor{Principal: alice}, kids[0].Path)
|
|
Expect(err).NotTo(HaveOccurred(), "chat folder holds %v, %v, want x.txt", inner, err)
|
|
Expect(inner).To(HaveLen(1), "chat folder holds %v, %v, want x.txt", inner, err)
|
|
Expect(inner[0].Name).To(Equal("x.txt"), "chat folder holds %v, %v, want x.txt", inner, err)
|
|
h.Gate <- struct{}{}
|
|
idle(svc, alice, id)
|
|
})
|
|
|
|
It("a file cannot be attached when files are off", func() {
|
|
h := studiotest.NewGateHarness()
|
|
svc := newServiceWithFiles(h, files.Disabled("no data path"))
|
|
id := create(svc, alice)
|
|
_, err := svc.SendWithFiles(context.Background(), alice, id, "see", []session.Upload{upload("x.txt", "x")})
|
|
Expect(err).To(MatchError(files.ErrUnavailable), "SendWithFiles with files off = %v, want ErrUnavailable", err)
|
|
Expect(h.Sent()).To(BeEmpty(), "a turn started: %v", h.Sent())
|
|
})
|
|
|
|
It("the model and its sub agents get the file tools when files are on", func() {
|
|
f := newFiles()
|
|
svc, h := newToolService(func(a workspace.Actor, text string) []studiotest.ToolStep {
|
|
if text == "go" {
|
|
return []studiotest.ToolStep{{Tool: "spawn_agent", Args: `{"task":"child job","wait":true}`}, {Say: "done"}}
|
|
}
|
|
return []studiotest.ToolStep{{Say: "ok"}}
|
|
}, func(d *studio.Deps) { d.Files = f })
|
|
id := create(svc, alice)
|
|
sent(svc, alice, id, "go")
|
|
idle(svc, alice, id)
|
|
|
|
parent := h.ToolNames(id)
|
|
for _, want := range []string{"read_file", "write_file", "list_files", "move_file", "delete_file", "edit_file", "search_files", "spawn_agent"} {
|
|
Expect(contains(parent, want)).To(BeTrue(), "the chat's tools %v lack %s", parent, want)
|
|
}
|
|
kids := childrenOf(svc, alice, id)
|
|
Expect(kids).To(HaveLen(1), "children = %d, want 1", len(kids))
|
|
// The file tools are the one file toolset of a chat: a sub-agent that was
|
|
// not narrowed gets them too.
|
|
Expect(h.ToolNames(kids[0].ID)).To(ContainElements("read_file", "write_file", "edit_file", "list_files", "search_files"))
|
|
})
|
|
|
|
It("the model gets no file tools when files are off", func() {
|
|
svc, h := newToolService(func(a workspace.Actor, text string) []studiotest.ToolStep {
|
|
return []studiotest.ToolStep{{Say: "ok"}}
|
|
}, func(d *studio.Deps) { d.Files = files.Disabled("off") })
|
|
id := create(svc, alice)
|
|
sent(svc, alice, id, "go")
|
|
idle(svc, alice, id)
|
|
for _, name := range h.ToolNames(id) {
|
|
Expect(name).NotTo(HaveSuffix("_file"), "a chat with files off got the tool %s", name)
|
|
Expect(name).NotTo(Equal("list_files"), "a chat with files off got the tool %s", name)
|
|
Expect(name).NotTo(Equal("search_files"), "a chat with files off got the tool %s", name)
|
|
}
|
|
})
|
|
})
|
|
|
|
func contains(list []string, s string) bool {
|
|
return slices.Contains(list, s)
|
|
}
|
|
|
|
func newMediaService() (*media.Service, *provider.Provider) {
|
|
p := provider.New()
|
|
p.Transport().SetHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"text":"buy milk and call the dentist"}`))
|
|
}))
|
|
users := func(_ context.Context, id string) (*auth.User, error) {
|
|
return &auth.User{ID: id, Status: auth.StatusActive}, nil
|
|
}
|
|
return media.New(p, users, func(c media.Capability) string { return "any-model" }), p
|
|
}
|
|
|
|
var _ = Describe("files", func() {
|
|
It("a voice note becomes text in the message the model gets", func() {
|
|
f := newFiles()
|
|
m, p := newMediaService()
|
|
h := studiotest.NewGateHarness()
|
|
st, _ := store.NewJSON("")
|
|
svc, err := studio.New(studio.Deps{Store: st, Provider: p, StateDir: GinkgoT().TempDir(), Harness: h, Files: f, Media: m})
|
|
Expect(err).NotTo(HaveOccurred())
|
|
DeferCleanup(func() { _ = svc.Close(context.Background()) })
|
|
id := create(svc, alice)
|
|
_, err = svc.SendWithFiles(context.Background(), alice, id, "my day", []session.Upload{upload("memo.m4a", "AUDIO")})
|
|
Expect(err).NotTo(HaveOccurred())
|
|
until("the turn to start", func() bool { return len(h.Sent()) == 1 })
|
|
got := h.Sent()[0]
|
|
Expect(got).To(ContainSubstring("my day"), "the model got %q, want the text, the voice note's name and its transcript", got)
|
|
Expect(got).To(ContainSubstring("buy milk and call the dentist"), "the model got %q, want the text, the voice note's name and its transcript", got)
|
|
Expect(got).To(ContainSubstring("memo.m4a"), "the model got %q, want the text, the voice note's name and its transcript", got)
|
|
h.Gate <- struct{}{}
|
|
idle(svc, alice, id)
|
|
})
|
|
|
|
It("a voice note sent while a turn runs is transcribed and queued", func() {
|
|
f := newFiles()
|
|
m, p := newMediaService()
|
|
p.Transport().SetHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
_, _ = w.Write([]byte(`{"text":"buy milk"}`))
|
|
}))
|
|
h := studiotest.NewGateHarness()
|
|
st, _ := store.NewJSON("")
|
|
svc, err := studio.New(studio.Deps{Store: st, Provider: p, StateDir: GinkgoT().TempDir(), Harness: h, Files: f, Media: m})
|
|
Expect(err).NotTo(HaveOccurred())
|
|
DeferCleanup(func() { _ = svc.Close(context.Background()) })
|
|
id := create(svc, alice)
|
|
sent(svc, alice, id, "go")
|
|
until("the turn to start", func() bool { return len(h.Sent()) == 1 })
|
|
res, err := svc.SendWithFiles(context.Background(), alice, id, "x", []session.Upload{upload("memo.m4a", "A")})
|
|
Expect(err).NotTo(HaveOccurred(), "SendWithFiles = %+v, %v; want a waiting message", res, err)
|
|
Expect(res.PendingID).NotTo(Equal(""), "SendWithFiles = %+v, %v; want a waiting message", res, err)
|
|
got := pendingTexts(svc, alice, id)
|
|
Expect(got).To(HaveLen(1), "waiting messages = %q, want the transcript and the voice note's path", got)
|
|
Expect(got[0]).To(ContainSubstring("buy milk"), "waiting messages = %q, want the transcript and the voice note's path", got)
|
|
Expect(got[0]).To(ContainSubstring("memo.m4a"), "waiting messages = %q, want the transcript and the voice note's path", got)
|
|
h.Gate <- struct{}{}
|
|
idle(svc, alice, id)
|
|
})
|
|
})
|