mirror of
https://github.com/mudler/LocalAI.git
synced 2026-11-06 04:23:15 -05:00
feat(studio): build connector file limits and sentences from one resolver
The limit of a file is the smallest of the host ceiling, the ability the connector kind declares, the owner's setting for the binding and the cap of the inline path. One function decides it and names the layer that won. The sentences to the sender and to the model are built from that number and layer, in KiB or MiB, instead of a fixed 1 MB. A guard spec fails when these files carry a size or a byte count of their own. The owner can lower the limit of a binding; a value above the limit of the kind is refused. Assisted-by: Claude Code:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
13 files changed
+644
-17
No files matched your search
@@ -8,6 +8,7 @@ import (
|
||||
"path"
|
||||
"slices"
|
||||
|
||||
"github.com/mudler/LocalAI/core/services/studio/connectors"
|
||||
"github.com/mudler/LocalAI/core/services/studio/session"
|
||||
"github.com/mudler/LocalAI/core/services/studio/workspace"
|
||||
"github.com/mudler/LocalAI/pkg/addons/contract"
|
||||
@@ -38,7 +39,7 @@ func (s *Service) attachTools(ctx context.Context, x session.Access, rec session
|
||||
return []session.Tool{{
|
||||
Name: attachFileName, Description: attachFileDescription, Schema: json.RawMessage(attachFileSchema),
|
||||
Run: func(ctx context.Context, raw json.RawMessage) (string, error) {
|
||||
return c.attachFile(ctx, x, raw)
|
||||
return c.attachFile(ctx, x, b, raw)
|
||||
},
|
||||
}}
|
||||
}
|
||||
@@ -47,7 +48,7 @@ func (s *Service) attachTools(ctx context.Context, x session.Access, rec session
|
||||
// sender, and marks it for the answer. The check is repeated when the file is
|
||||
// sent, because the file or the access can change in between; this one lets
|
||||
// the model tell the person at once why a file cannot go.
|
||||
func (c *Connectors) attachFile(ctx context.Context, x session.Access, raw json.RawMessage) (string, error) {
|
||||
func (c *Connectors) attachFile(ctx context.Context, x session.Access, b connectors.Binding, raw json.RawMessage) (string, error) {
|
||||
in, err := session.DecodeArgs[struct{ Path string }](raw)
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -61,6 +62,7 @@ func (c *Connectors) attachFile(ctx context.Context, x session.Access, raw json.
|
||||
return "", notFound(err, in.Path)
|
||||
}
|
||||
name := contract.SafeFileName(n.Name)
|
||||
limits := c.FileLimits(ctx, b)
|
||||
switch {
|
||||
case n.Kind == workspace.KindDir:
|
||||
return "", fmt.Errorf("%s is a folder, not a file; attach the files in it one by one", in.Path)
|
||||
@@ -68,8 +70,8 @@ func (c *Connectors) attachFile(ctx context.Context, x session.Access, raw json.
|
||||
return "", fmt.Errorf("%s is not sent: files of this type (%s) are not sent here. Documents, data, pictures, sound, video and zip files are", name, path.Ext(name))
|
||||
case n.Size == 0:
|
||||
return "", fmt.Errorf("%s is empty, so there is nothing to send", name)
|
||||
case n.Size > contract.MaxConnectorFileBytes:
|
||||
return "", fmt.Errorf("%s is too large to send here (%d bytes; the limit is %d MB). Tell the person; offer to split it, to make a smaller file, or to paste the content", name, n.Size, contract.MaxConnectorFileBytes>>20)
|
||||
case n.Size > limits.Send.Bytes:
|
||||
return "", refuseAttachTooBig(name, n.Size, limits)
|
||||
}
|
||||
key := key(x.Actor().Principal, x.Actor().ChatID)
|
||||
c.amu.Lock()
|
||||
|
||||
@@ -136,6 +136,7 @@ func (c *Connectors) sendFiles(b connectors.Binding, token string, m contract.Co
|
||||
return
|
||||
}
|
||||
files := c.outgoing(ctx, b, x, chatID, turn, since)
|
||||
limits := c.FileLimits(ctx, b)
|
||||
var notes []string
|
||||
for i, f := range files {
|
||||
if i == maxFilesPerTurn {
|
||||
@@ -151,12 +152,12 @@ func (c *Connectors) sendFiles(b connectors.Binding, token string, m contract.Co
|
||||
}
|
||||
continue
|
||||
}
|
||||
data, err := c.svc.readChatFile(ctx, x, f.path, contract.MaxConnectorFileBytes)
|
||||
data, err := c.svc.readChatFile(ctx, x, f.path, limits.Send.Bytes)
|
||||
switch {
|
||||
case errors.Is(err, workspace.ErrNotFound):
|
||||
continue // gone since, or outside the chat's access: not named
|
||||
case errors.Is(err, errFileTooLarge):
|
||||
notes = append(notes, fmt.Sprintf("%s is too large to send here (the limit is 1 MB). It is in the chat's files.", f.name))
|
||||
notes = append(notes, noteSendTooBig(f.name, limits))
|
||||
continue
|
||||
case err != nil:
|
||||
c.log.Warn("studio: could not read a file for a connector", "binding", b.ID, "error", err)
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
package studio
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/mudler/LocalAI/core/services/studio/connectors"
|
||||
"github.com/mudler/LocalAI/pkg/addons/contract"
|
||||
)
|
||||
|
||||
// FileLayer names the layer that sets a file limit. The limit in effect is the
|
||||
// smallest of the layers, and the sentences to people name the one that won.
|
||||
type FileLayer string
|
||||
|
||||
const (
|
||||
// LayerOwner is the limit that the owner set for one binding.
|
||||
LayerOwner FileLayer = "owner"
|
||||
// LayerAddon is the ability that the connector kind declares.
|
||||
LayerAddon FileLayer = "addon"
|
||||
// LayerHost is the host ceiling of the owner policy.
|
||||
LayerHost FileLayer = "host"
|
||||
// LayerInline is the cap of the inline path, which carries the bytes in
|
||||
// the connector envelope. It applies until the bytes travel by
|
||||
// reference, and to every add-on that declares no ability.
|
||||
LayerInline FileLayer = "inline"
|
||||
)
|
||||
|
||||
// FileLimit is the largest file that may move in one direction, and the
|
||||
// layer that sets it.
|
||||
type FileLimit struct {
|
||||
Bytes int64
|
||||
Layer FileLayer
|
||||
}
|
||||
|
||||
// EffectiveFileLimit is the one place that decides how large a file may be.
|
||||
// It takes the smallest of the host ceiling (zero: the default), the ability
|
||||
// the kind declares (nil: none), the owner's limit for the binding (zero:
|
||||
// none) and the cap of the inline path. On a tie the layer that a person can
|
||||
// change first is named: the owner, the add-on, the host, then the inline cap.
|
||||
func EffectiveFileLimit(ceiling int64, declared *contract.FileAbility, owner int64) FileLimit {
|
||||
if ceiling <= 0 {
|
||||
ceiling = contract.DefaultFileCeilingBytes
|
||||
}
|
||||
limit := FileLimit{Bytes: contract.MaxConnectorFileBytes, Layer: LayerInline}
|
||||
for _, c := range []FileLimit{{ceiling, LayerHost}, abilityLimit(declared), {owner, LayerOwner}} {
|
||||
if c.Bytes > 0 && c.Bytes <= limit.Bytes {
|
||||
limit = c
|
||||
}
|
||||
}
|
||||
return limit
|
||||
}
|
||||
|
||||
func abilityLimit(a *contract.FileAbility) FileLimit {
|
||||
if a == nil {
|
||||
return FileLimit{}
|
||||
}
|
||||
return FileLimit{Bytes: a.MaxBytes, Layer: LayerAddon}
|
||||
}
|
||||
|
||||
// FileLimits are the limits of one binding.
|
||||
type FileLimits struct {
|
||||
Receive, Send FileLimit
|
||||
// ReceiveMax and SendMax are the most an owner may set: the ability of
|
||||
// the kind (the inline cap, if it declares none) under the host ceiling.
|
||||
ReceiveMax, SendMax int64
|
||||
// KindTitle is the name of the platform, for the sentences.
|
||||
KindTitle string
|
||||
}
|
||||
|
||||
// fileCeiling reads the host ceiling of the owner policy. A failure reads as
|
||||
// the default: the layers below still bind, and the ceiling never lifts them.
|
||||
func (c *Connectors) fileCeiling(ctx context.Context) int64 {
|
||||
if c.o.FileCeiling == nil {
|
||||
return contract.DefaultFileCeilingBytes
|
||||
}
|
||||
n, err := c.o.FileCeiling(ctx)
|
||||
if err != nil || n <= 0 {
|
||||
if err != nil {
|
||||
c.log.Warn("studio: the host ceiling of connector files is not readable", "error", err)
|
||||
}
|
||||
return contract.DefaultFileCeilingBytes
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// FileLimits resolves the limits of a binding now. The kind is read from the
|
||||
// add-ons that run; a binding whose add-on does not run has declared nothing.
|
||||
func (c *Connectors) FileLimits(ctx context.Context, b connectors.Binding) FileLimits {
|
||||
var files *contract.ConnectorFiles
|
||||
title := ""
|
||||
if kinds, err := c.o.Transport.Kinds(ctx); err == nil {
|
||||
if k, ok := kindFor(kinds, b); ok {
|
||||
files, title = k.Files, k.Title
|
||||
}
|
||||
}
|
||||
return resolveFileLimits(c.fileCeiling(ctx), files, title, b)
|
||||
}
|
||||
|
||||
func resolveFileLimits(ceiling int64, files *contract.ConnectorFiles, title string, b connectors.Binding) FileLimits {
|
||||
if files == nil {
|
||||
files = &contract.ConnectorFiles{}
|
||||
}
|
||||
l := FileLimits{KindTitle: title}
|
||||
l.Receive = EffectiveFileLimit(ceiling, files.Receive, b.FileReceiveMax)
|
||||
l.Send = EffectiveFileLimit(ceiling, files.Send, b.FileSendMax)
|
||||
l.ReceiveMax = ownerMax(ceiling, files.Receive)
|
||||
l.SendMax = ownerMax(ceiling, files.Send)
|
||||
return l
|
||||
}
|
||||
|
||||
// ownerMax is the largest value an owner may set: what the kind declares, or
|
||||
// the inline cap when it declares nothing, under the host ceiling.
|
||||
func ownerMax(ceiling int64, a *contract.FileAbility) int64 {
|
||||
if ceiling <= 0 {
|
||||
ceiling = contract.DefaultFileCeilingBytes
|
||||
}
|
||||
base := int64(contract.MaxConnectorFileBytes)
|
||||
if a != nil {
|
||||
base = a.MaxBytes
|
||||
}
|
||||
return min(base, ceiling)
|
||||
}
|
||||
|
||||
// checkOwnerLimits refuses a limit above what the kind allows: an owner can
|
||||
// only lower it. A kind that does not run has no limit to compare with.
|
||||
func (c *Connectors) checkOwnerLimits(ctx context.Context, b connectors.Binding, in ConnectorInput) error {
|
||||
// Only a value that this request writes is checked: a stored value that
|
||||
// the kind has lowered since must not block an unrelated update.
|
||||
if (in.FileReceiveMax == nil || *in.FileReceiveMax == 0) && (in.FileSendMax == nil || *in.FileSendMax == 0) {
|
||||
return nil
|
||||
}
|
||||
kinds, err := c.o.Transport.Kinds(ctx)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
k, ok := kindFor(kinds, b)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
l := resolveFileLimits(c.fileCeiling(ctx), k.Files, k.Title, b)
|
||||
for _, f := range []struct {
|
||||
what string
|
||||
set, allowed int64
|
||||
}{{"receive", b.FileReceiveMax, l.ReceiveMax}, {"send", b.FileSendMax, l.SendMax}} {
|
||||
if f.set > f.allowed {
|
||||
return connectorBad("the %s file limit can only lower the limit of the kind: at most %s", f.what, formatFileSize(f.allowed))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// why names the layer that sets the limit, in words a person knows.
|
||||
func (l FileLimit) why(kindTitle string) string {
|
||||
switch l.Layer {
|
||||
case LayerOwner:
|
||||
return "limit set for this bot"
|
||||
case LayerAddon:
|
||||
return "limit of " + cmp.Or(kindTitle, "this add-on")
|
||||
case LayerHost:
|
||||
return "limit of the platform"
|
||||
default:
|
||||
return "limit of this add-on version"
|
||||
}
|
||||
}
|
||||
|
||||
// sentence is the limit with the layer that sets it, such as
|
||||
// "1 MiB (limit of this add-on version)".
|
||||
func (l FileLimit) sentence(kindTitle string) string {
|
||||
return formatFileSize(l.Bytes) + " (" + l.why(kindTitle) + ")"
|
||||
}
|
||||
|
||||
// formatFileSize writes a size in bytes, KiB, MiB, GiB or TiB, with at most
|
||||
// one decimal.
|
||||
func formatFileSize(n int64) string {
|
||||
if n == 1 {
|
||||
return "1 byte"
|
||||
}
|
||||
if n < 1<<10 {
|
||||
return strconv.FormatInt(n, 10) + " bytes"
|
||||
}
|
||||
units := []string{"KiB", "MiB", "GiB", "TiB"}
|
||||
v := float64(n) / 1024
|
||||
i := 0
|
||||
for v >= 1024 && i < len(units)-1 {
|
||||
v /= 1024
|
||||
i++
|
||||
}
|
||||
return strings.TrimSuffix(strconv.FormatFloat(v, 'f', 1, 64), ".0") + " " + units[i]
|
||||
}
|
||||
|
||||
// Sentences to people. Each is built from the limit in effect.
|
||||
|
||||
// sentFileTooBig answers a person whose file the bot cannot read. size is
|
||||
// zero when the add-on did not say how large the file is.
|
||||
func sentFileTooBig(l FileLimits, size int64) string {
|
||||
seen := ""
|
||||
if size > 0 {
|
||||
seen = " (" + formatFileSize(size) + ")"
|
||||
}
|
||||
return fmt.Sprintf("That file is too large%s: I can read files of up to %s here (%s).", seen, formatFileSize(l.Receive.Bytes), l.Receive.why(l.KindTitle))
|
||||
}
|
||||
|
||||
// noteSendTooBig tells a person that a file the agent made stays in the chat.
|
||||
func noteSendTooBig(name string, l FileLimits) string {
|
||||
return fmt.Sprintf("%s is too large to send here (the limit is %s). It is in the chat's files.", name, l.Send.sentence(l.KindTitle))
|
||||
}
|
||||
|
||||
// refuseAttachTooBig is the text that the attach tool gives the model.
|
||||
func refuseAttachTooBig(name string, size int64, l FileLimits) error {
|
||||
return fmt.Errorf("%s is too large to send here (%s; the limit is %s). Tell the person; offer to split it, to make a smaller file, or to paste the content",
|
||||
name, formatFileSize(size), l.Send.sentence(l.KindTitle))
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package studio
|
||||
|
||||
import (
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
gotoken "go/token"
|
||||
"regexp"
|
||||
"strconv"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
// The file limit is decided in one place (EffectiveFileLimit) and written in
|
||||
// one place (connectorlimits.go). These files talk about file limits and must
|
||||
// not carry a number or a sentence of their own: a limit that is typed here
|
||||
// would be wrong as soon as an add-on or an owner sets another one.
|
||||
var _ = Describe("the files that use the file limit", func() {
|
||||
files := []string{"connectors.go", "connectorfiles.go", "attachfile.go", "connectorturn.go"}
|
||||
// Files with no other use for a large number.
|
||||
noLargeNumbers := []string{"connectorfiles.go", "attachfile.go", "connectorturn.go"}
|
||||
unit := regexp.MustCompile(`(?i)\b\d+(\.\d+)?\s*(bytes?|[kmgt]i?b)\b`)
|
||||
|
||||
parse := func(name string) (*gotoken.FileSet, *ast.File) {
|
||||
fset := gotoken.NewFileSet()
|
||||
f, err := parser.ParseFile(fset, name, nil, 0)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
return fset, f
|
||||
}
|
||||
|
||||
It("write no size in a sentence", func() {
|
||||
for _, name := range files {
|
||||
fset, f := parse(name)
|
||||
ast.Inspect(f, func(n ast.Node) bool {
|
||||
if lit, ok := n.(*ast.BasicLit); ok && lit.Kind == gotoken.STRING {
|
||||
s, _ := strconv.Unquote(lit.Value)
|
||||
Expect(unit.MatchString(s)).To(BeFalse(), "%s: %q names a size; build it from the limit in effect", fset.Position(lit.Pos()), s)
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
It("read the limit from EffectiveFileLimit, not from the contract constant", func() {
|
||||
for _, name := range files {
|
||||
fset, f := parse(name)
|
||||
ast.Inspect(f, func(n ast.Node) bool {
|
||||
if sel, ok := n.(*ast.SelectorExpr); ok {
|
||||
Expect(sel.Sel.Name).NotTo(Equal("MaxConnectorFileBytes"), "%s: the inline cap is a layer of EffectiveFileLimit", fset.Position(sel.Pos()))
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
It("hold no byte count", func() {
|
||||
for _, name := range noLargeNumbers {
|
||||
fset, f := parse(name)
|
||||
ast.Inspect(f, func(n ast.Node) bool {
|
||||
switch x := n.(type) {
|
||||
case *ast.BasicLit:
|
||||
if x.Kind == gotoken.INT {
|
||||
v, err := strconv.ParseInt(x.Value, 0, 64)
|
||||
Expect(err == nil && v < 1024).To(BeTrue(), "%s: %s looks like a byte count", fset.Position(x.Pos()), x.Value)
|
||||
}
|
||||
case *ast.BinaryExpr:
|
||||
Expect(x.Op).NotTo(Equal(gotoken.SHL), "%s: a shift builds a byte count", fset.Position(x.Pos()))
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,49 @@
|
||||
package studio
|
||||
|
||||
import (
|
||||
"github.com/mudler/LocalAI/pkg/addons/contract"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("the file limit in effect", func() {
|
||||
declared := func(n int64) *contract.FileAbility { return &contract.FileAbility{MaxBytes: n} }
|
||||
|
||||
DescribeTable("is the smallest of the host ceiling, the declared ability, the owner setting and the inline cap",
|
||||
func(ceiling int64, ability *contract.FileAbility, owner int64, want FileLimit) {
|
||||
Expect(EffectiveFileLimit(ceiling, ability, owner)).To(Equal(want))
|
||||
},
|
||||
Entry("an add-on that declares nothing gets the inline cap", int64(100<<20), nil, int64(0), FileLimit{Bytes: contract.MaxConnectorFileBytes, Layer: LayerInline}),
|
||||
Entry("a declared ability above the inline cap leaves the inline cap", int64(100<<20), declared(20<<20), int64(0), FileLimit{Bytes: contract.MaxConnectorFileBytes, Layer: LayerInline}),
|
||||
Entry("a declared ability below the inline cap wins", int64(100<<20), declared(256<<10), int64(0), FileLimit{Bytes: 256 << 10, Layer: LayerAddon}),
|
||||
Entry("the host ceiling below the rest wins", int64(128<<10), declared(256<<10), int64(0), FileLimit{Bytes: 128 << 10, Layer: LayerHost}),
|
||||
Entry("the owner setting below the rest wins", int64(100<<20), declared(512<<10), int64(64<<10), FileLimit{Bytes: 64 << 10, Layer: LayerOwner}),
|
||||
Entry("the owner setting cannot raise the limit", int64(100<<20), declared(256<<10), int64(50<<20), FileLimit{Bytes: 256 << 10, Layer: LayerAddon}),
|
||||
Entry("a tie names the layer the owner can change first", int64(100<<20), declared(256<<10), int64(256<<10), FileLimit{Bytes: 256 << 10, Layer: LayerOwner}),
|
||||
Entry("a ceiling of zero means the default ceiling", int64(0), declared(256<<10), int64(0), FileLimit{Bytes: 256 << 10, Layer: LayerAddon}),
|
||||
)
|
||||
|
||||
It("lets the host ceiling bind an add-on that declares nothing", func() {
|
||||
Expect(EffectiveFileLimit(10<<10, nil, 0)).To(Equal(FileLimit{Bytes: 10 << 10, Layer: LayerHost}))
|
||||
})
|
||||
|
||||
DescribeTable("writes a size as a person reads it",
|
||||
func(n int64, want string) { Expect(formatFileSize(n)).To(Equal(want)) },
|
||||
Entry("bytes", int64(900), "900 bytes"),
|
||||
Entry("one byte", int64(1), "1 byte"),
|
||||
Entry("KiB", int64(512<<10), "512 KiB"),
|
||||
Entry("a fraction of KiB", int64(1536), "1.5 KiB"),
|
||||
Entry("MiB", int64(1<<20), "1 MiB"),
|
||||
Entry("a fraction of MiB", int64(5<<20+512<<10), "5.5 MiB"),
|
||||
Entry("GiB", int64(3<<30), "3 GiB"),
|
||||
Entry("TiB", int64(1<<40), "1 TiB"),
|
||||
)
|
||||
|
||||
It("names the layer in words a person knows", func() {
|
||||
Expect(FileLimit{Bytes: 1 << 20, Layer: LayerInline}.sentence("Slack")).To(Equal("1 MiB (limit of this add-on version)"))
|
||||
Expect(FileLimit{Bytes: 1 << 20, Layer: LayerOwner}.sentence("Slack")).To(Equal("1 MiB (limit set for this bot)"))
|
||||
Expect(FileLimit{Bytes: 1 << 20, Layer: LayerHost}.sentence("Slack")).To(Equal("1 MiB (limit of the platform)"))
|
||||
Expect(FileLimit{Bytes: 1 << 20, Layer: LayerAddon}.sentence("Slack")).To(Equal("1 MiB (limit of Slack)"))
|
||||
Expect(FileLimit{Bytes: 1 << 20, Layer: LayerAddon}.sentence("")).To(Equal("1 MiB (limit of this add-on)"))
|
||||
})
|
||||
})
|
||||
@@ -36,7 +36,6 @@ const (
|
||||
sentNoFolder = "This agent cannot work right now: its folder is missing."
|
||||
sentNoFile = "I could not read the file you sent."
|
||||
sentNoFiles = "I can only read the text of your message here: files are turned off."
|
||||
sentFileTooBig = "That file is too large: I can read files of up to 1 MB."
|
||||
sentNoListen = "I cannot listen to voice messages here: no speech-to-text model is set up. Please write your message."
|
||||
sentNoTranscript = "I could not understand your voice message. Please send it again, or write it."
|
||||
|
||||
@@ -100,6 +99,9 @@ type ConnectorOptions struct {
|
||||
// is stored. Nil: tokens are not checked. The application sets
|
||||
// VerifyTokenWithMe; a test that fakes no platform leaves it nil.
|
||||
VerifyToken func(ctx context.Context, b connectors.Binding, token string) error
|
||||
// FileCeiling reads the host ceiling of a connector file from the owner
|
||||
// policy, in bytes. Nil or zero: contract.DefaultFileCeilingBytes.
|
||||
FileCeiling func(ctx context.Context) (int64, error)
|
||||
Logger *slog.Logger
|
||||
}
|
||||
|
||||
@@ -823,8 +825,9 @@ func (c *Connectors) transcribe(ctx context.Context, b connectors.Binding, f con
|
||||
// sent; the chat saves it in its own folder.
|
||||
func (c *Connectors) media(ctx context.Context, b connectors.Binding, token string, refs []string) ([]contract.ConnectorFile, string) {
|
||||
var (
|
||||
out []contract.ConnectorFile
|
||||
note string
|
||||
out []contract.ConnectorFile
|
||||
note string
|
||||
limits = c.FileLimits(ctx, b)
|
||||
)
|
||||
for _, ref := range refs {
|
||||
mctx, cancel := context.WithTimeout(ctx, 60*time.Second)
|
||||
@@ -836,8 +839,12 @@ func (c *Connectors) media(ctx context.Context, b connectors.Binding, token stri
|
||||
f := resp.File
|
||||
var ce *contract.ConnectorError
|
||||
switch {
|
||||
case (errors.As(err, &ce) && ce.Code == contract.ConnectorTooLarge) || (err == nil && f != nil && len(f.Data) > contract.MaxConnectorFileBytes):
|
||||
note = sentFileTooBig
|
||||
case (errors.As(err, &ce) && ce.Code == contract.ConnectorTooLarge) || (err == nil && f != nil && int64(len(f.Data)) > limits.Receive.Bytes):
|
||||
var size int64
|
||||
if f != nil {
|
||||
size = int64(len(f.Data))
|
||||
}
|
||||
note = sentFileTooBig(limits, size)
|
||||
continue
|
||||
case err != nil || f == nil || len(f.Data) == 0:
|
||||
c.log.Warn("studio: could not read the file of a connector message", "binding", b.ID, "kind", b.Kind, "error", err)
|
||||
|
||||
@@ -109,7 +109,7 @@ var _ = Describe("attach_file in a connector chat", func() {
|
||||
}
|
||||
Expect(res[0].Err.Error()).To(ContainSubstring("not found"))
|
||||
Expect(res[1].Err.Error()).To(ContainSubstring("too large"))
|
||||
Expect(res[1].Err.Error()).To(ContainSubstring("1 MB"))
|
||||
Expect(res[1].Err.Error()).To(ContainSubstring("1 MiB (limit of this add-on version)"))
|
||||
Expect(res[2].Err.Error()).To(ContainSubstring("not sent"))
|
||||
Expect(res[3].Err.Error()).To(ContainSubstring("folder"))
|
||||
Expect(bot.sentFiles()).To(BeEmpty())
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
package studio_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/mudler/LocalAI/addons/slack/slacktest"
|
||||
"github.com/mudler/LocalAI/addons/telegram/transport"
|
||||
"github.com/mudler/LocalAI/core/services/studio"
|
||||
"github.com/mudler/LocalAI/core/services/studio/agents"
|
||||
"github.com/mudler/LocalAI/core/services/studio/connectors"
|
||||
"github.com/mudler/LocalAI/core/services/studio/studiotest"
|
||||
"github.com/mudler/LocalAI/core/services/studio/workspace"
|
||||
"github.com/mudler/LocalAI/pkg/addons/contract"
|
||||
"github.com/mudler/LocalAI/tests/studiobot"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
// The file limit of a binding is the smallest of the host ceiling, the ability
|
||||
// that the kind declares, the owner's setting and the cap of the inline path.
|
||||
// The specs run the same checks over two real add-ons (Telegram and Slack, on
|
||||
// their fakes) and over a kind that only a fake declares, so nothing in the
|
||||
// host knows a platform.
|
||||
|
||||
// limitRig is a connector chat on one platform, as the specs of the limits
|
||||
// see it.
|
||||
type limitRig struct {
|
||||
r *connRig
|
||||
title string
|
||||
// ask sends the message from the person.
|
||||
ask func(text string)
|
||||
// files is how many files reached the person.
|
||||
files func() int
|
||||
// said are the messages the bot sent.
|
||||
said func() []string
|
||||
}
|
||||
|
||||
type limitSetup struct {
|
||||
declared *contract.ConnectorFiles
|
||||
input studio.ConnectorInput
|
||||
ceiling int64
|
||||
script func(workspace.Actor, string) []studiotest.ToolStep
|
||||
}
|
||||
|
||||
func (s limitSetup) options(o *studio.ConnectorOptions) {
|
||||
if s.ceiling > 0 {
|
||||
o.FileCeiling = func(context.Context) (int64, error) { return s.ceiling, nil }
|
||||
}
|
||||
}
|
||||
|
||||
var platforms = map[string]func(limitSetup) limitRig{
|
||||
"Telegram": func(s limitSetup) limitRig {
|
||||
bot := newScriptedBot()
|
||||
addon := studiobot.Telegram(&transport.API{BaseURL: bot.srv.URL, Client: bot.srv.Client()})
|
||||
addon.Kind.Files = s.declared
|
||||
r := newConnRigOpts(newShared(), connStore(), studiobot.NewTransport(addon), s.script, nil, s.options)
|
||||
r.bot = bot
|
||||
s.input.Allow = []string{"11"}
|
||||
r.bind(s.input)
|
||||
n := 0
|
||||
return limitRig{r: r, title: "Telegram",
|
||||
ask: func(text string) { n++; bot.say(n, 11, "Ada", "ada", text) },
|
||||
files: func() int { return len(bot.sentFiles()) },
|
||||
said: bot.answers}
|
||||
},
|
||||
"Slack": func(s limitSetup) limitRig {
|
||||
r, _ := newSlackRigDeclaring(s.script, studio.ConnectorInput{Allow: []string{"U1"}, FileReceiveMax: s.input.FileReceiveMax, FileSendMax: s.input.FileSendMax}, s.declared, s.options)
|
||||
return limitRig{r: r.connRig, title: "Slack",
|
||||
ask: func(text string) { r.slack.Push(slackApp, slacktest.DM("D1", "U1", text)) },
|
||||
files: func() int { return len(r.slack.Uploads(slackBot)) },
|
||||
said: func() []string {
|
||||
var out []string
|
||||
for _, p := range r.answers() {
|
||||
out = append(out, p.Text)
|
||||
}
|
||||
return out
|
||||
}}
|
||||
},
|
||||
}
|
||||
|
||||
// fakeRuntime is a connector add-on that a spec scripts.
|
||||
type fakeRuntime func(contract.ConnectorRequest) contract.ConnectorResponse
|
||||
|
||||
func (f fakeRuntime) Connector(_ context.Context, req contract.ConnectorRequest) (contract.ConnectorResponse, error) {
|
||||
return f(req), nil
|
||||
}
|
||||
|
||||
func ptr(n int64) *int64 { return &n }
|
||||
|
||||
var _ = Describe("file limits in a connector chat", func() {
|
||||
body := func(n int) string { return strings.Repeat("x", n) }
|
||||
// makeAndAttach writes a file of n bytes and attaches it by hand too.
|
||||
makeAndAttach := func(n int) func(workspace.Actor, string) []studiotest.ToolStep {
|
||||
return attachScript(map[string][]studiotest.ToolStep{
|
||||
"go": {write("/out/data.csv", body(n)), attach("/out/data.csv"), {Say: "Done."}},
|
||||
})
|
||||
}
|
||||
// refused returns the text that the model got when the file was too large.
|
||||
refused := func(r limitRig) string {
|
||||
res := r.r.h.ResultsFor("attach_file")
|
||||
Expect(res).To(HaveLen(1))
|
||||
Expect(res[0].Err).To(HaveOccurred())
|
||||
return res[0].Err.Error()
|
||||
}
|
||||
|
||||
DescribeTable("the layer that is smallest sets the limit and is named in the sentences",
|
||||
func(platform string, s limitSetup, size int, want string) {
|
||||
s.script = makeAndAttach(size)
|
||||
r := platforms[platform](s)
|
||||
r.ask("go")
|
||||
waitFor("the note about the file", func() bool { return len(r.said()) >= 2 })
|
||||
Expect(refused(r)).To(ContainSubstring(want), "what the model reads")
|
||||
Expect(strings.Join(r.said(), "\n")).To(ContainSubstring("the limit is "+want), "what the person reads")
|
||||
Expect(r.files()).To(BeZero(), "a file over the limit is not sent")
|
||||
},
|
||||
Entry("an add-on that declares a lower limit: Telegram", "Telegram", limitSetup{declared: &contract.ConnectorFiles{Send: &contract.FileAbility{MaxBytes: 300}}}, 400, "300 bytes (limit of Telegram)"),
|
||||
Entry("an add-on that declares a lower limit: Slack", "Slack", limitSetup{declared: &contract.ConnectorFiles{Send: &contract.FileAbility{MaxBytes: 300}}}, 400, "300 bytes (limit of Slack)"),
|
||||
Entry("the owner lowers the declared limit: Telegram", "Telegram", limitSetup{declared: &contract.ConnectorFiles{Send: &contract.FileAbility{MaxBytes: 300}}, input: studio.ConnectorInput{FileSendMax: ptr(100)}}, 150, "100 bytes (limit set for this bot)"),
|
||||
Entry("the owner lowers the declared limit: Slack", "Slack", limitSetup{declared: &contract.ConnectorFiles{Send: &contract.FileAbility{MaxBytes: 300}}, input: studio.ConnectorInput{FileSendMax: ptr(100)}}, 150, "100 bytes (limit set for this bot)"),
|
||||
Entry("the host ceiling is the smallest: Telegram", "Telegram", limitSetup{declared: &contract.ConnectorFiles{Send: &contract.FileAbility{MaxBytes: 300}}, ceiling: 50}, 100, "50 bytes (limit of the platform)"),
|
||||
Entry("the host ceiling is the smallest: Slack", "Slack", limitSetup{declared: &contract.ConnectorFiles{Send: &contract.FileAbility{MaxBytes: 300}}, ceiling: 50}, 100, "50 bytes (limit of the platform)"),
|
||||
Entry("an add-on that declares nothing: the inline cap, Telegram", "Telegram", limitSetup{}, 1<<20+10, "1 MiB (limit of this add-on version)"),
|
||||
Entry("an add-on that declares nothing: the inline cap, Slack", "Slack", limitSetup{}, 1<<20+10, "1 MiB (limit of this add-on version)"),
|
||||
Entry("an add-on that declares more than the inline path carries: the inline cap", "Telegram", limitSetup{declared: &contract.ConnectorFiles{Send: &contract.FileAbility{MaxBytes: 50 << 20}}}, 1<<20+10, "1 MiB (limit of this add-on version)"),
|
||||
)
|
||||
|
||||
DescribeTable("sends a file that is within the limit",
|
||||
func(platform string) {
|
||||
r := platforms[platform](limitSetup{
|
||||
declared: &contract.ConnectorFiles{Send: &contract.FileAbility{MaxBytes: 300}},
|
||||
input: studio.ConnectorInput{FileSendMax: ptr(200)},
|
||||
script: makeAndAttach(150),
|
||||
})
|
||||
r.ask("go")
|
||||
waitFor("the file", func() bool { return r.files() >= 1 })
|
||||
Expect(r.r.h.ResultsFor("attach_file")[0].Err).NotTo(HaveOccurred())
|
||||
},
|
||||
Entry("Telegram", "Telegram"),
|
||||
Entry("Slack", "Slack"),
|
||||
)
|
||||
|
||||
Describe("the model note", func() {
|
||||
It("states the limit in effect instead of a fixed number", func() {
|
||||
r := platforms["Telegram"](limitSetup{
|
||||
declared: &contract.ConnectorFiles{Send: &contract.FileAbility{MaxBytes: 300 << 10}},
|
||||
input: studio.ConnectorInput{FileSendMax: ptr(100 << 10)},
|
||||
script: connSay("ok"),
|
||||
})
|
||||
r.ask("hello")
|
||||
waitFor("the turn", func() bool { return len(r.said()) >= 1 })
|
||||
chats, err := r.r.svc.List(context.Background(), alice, 10)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(chats).To(HaveLen(1))
|
||||
Expect(r.r.h.TurnPrompt(chats[0].ID)).To(ContainSubstring("100 KiB (limit set for this bot) each"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("a kind that only a fake declares", func() {
|
||||
// chatty is a push kind with its own limits; the fake serves the op
|
||||
// media with a file of the size a spec sets.
|
||||
var (
|
||||
size atomic.Int64
|
||||
sent atomic.Value
|
||||
calls atomic.Int32
|
||||
)
|
||||
chatty := studiobot.Addon{ID: "chatty-addon",
|
||||
Kind: contract.ConnectorKind{ID: "chatty", Title: "Chatty", Delivery: contract.DeliveryPush,
|
||||
Files: &contract.ConnectorFiles{Receive: &contract.FileAbility{MaxBytes: 300}, Send: &contract.FileAbility{MaxBytes: 700}}},
|
||||
Runtime: fakeRuntime(func(req contract.ConnectorRequest) contract.ConnectorResponse {
|
||||
switch req.Op {
|
||||
case contract.ConnectorOpMedia:
|
||||
return contract.ConnectorResponse{File: &contract.ConnectorFile{Name: "in.txt", MIME: "text/plain", Data: []byte(strings.Repeat("y", int(size.Load())))}}
|
||||
case contract.ConnectorOpSend:
|
||||
calls.Add(1)
|
||||
sent.Store(req.Text)
|
||||
}
|
||||
return contract.ConnectorResponse{}
|
||||
})}
|
||||
|
||||
bindChatty := func(in studio.ConnectorInput) (*connRig, connectors.Binding, error) {
|
||||
r := newConnRigOpts(newShared(), connStore(), studiobot.NewTransport(chatty), connSay("ok"), nil, nil)
|
||||
in.Kind, in.Allow = "chatty", []string{"11"}
|
||||
if in.Name == "" {
|
||||
in.Name = "Chatty bot"
|
||||
}
|
||||
if in.Token == "" {
|
||||
in.Token = "tok"
|
||||
}
|
||||
in.Enabled = true
|
||||
if r.agent.ID == "" {
|
||||
ag, err := r.svc.SaveAgent(context.Background(), alice, agents.Agent{Name: "Support", Model: "m"})
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
r.agent = ag
|
||||
}
|
||||
in.AgentID = r.agent.ID
|
||||
info, err := r.conns.CreateConnector(context.Background(), alice, in)
|
||||
return r, info.Binding, err
|
||||
}
|
||||
|
||||
It("resolves the limits of its own declaration, with the title it declares", func() {
|
||||
r, b, err := bindChatty(studio.ConnectorInput{})
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
l := r.conns.FileLimits(context.Background(), b)
|
||||
Expect(l.Receive).To(Equal(studio.FileLimit{Bytes: 300, Layer: studio.LayerAddon}))
|
||||
Expect(l.Send).To(Equal(studio.FileLimit{Bytes: 700, Layer: studio.LayerAddon}))
|
||||
Expect([]int64{l.ReceiveMax, l.SendMax}).To(Equal([]int64{300, 700}))
|
||||
Expect(l.KindTitle).To(Equal("Chatty"))
|
||||
})
|
||||
|
||||
It("tells a sender whose file is over the limit of the kind which limit it is", func() {
|
||||
size.Store(400)
|
||||
r, b, err := bindChatty(studio.ConnectorInput{})
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(r.conns.Ingest(context.Background(), "chatty-addon", contract.ConnectorInbound{BindingID: b.ID,
|
||||
ConnectorMessage: contract.ConnectorMessage{Chat: "11", SenderID: "11", Direct: true, Media: []string{"m1"}}})).To(Succeed())
|
||||
waitFor("the sentence", func() bool { return calls.Load() >= 1 })
|
||||
Expect(sent.Load()).To(Equal("That file is too large (400 bytes): I can read files of up to 300 bytes here (limit of Chatty)."))
|
||||
})
|
||||
|
||||
It("lets the owner lower the limit for a bot, and the sentence says so", func() {
|
||||
size.Store(150)
|
||||
r, b, err := bindChatty(studio.ConnectorInput{FileReceiveMax: ptr(100)})
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(r.conns.FileLimits(context.Background(), b).Receive).To(Equal(studio.FileLimit{Bytes: 100, Layer: studio.LayerOwner}))
|
||||
Expect(r.conns.Ingest(context.Background(), "chatty-addon", contract.ConnectorInbound{BindingID: b.ID,
|
||||
ConnectorMessage: contract.ConnectorMessage{Chat: "11", SenderID: "11", Direct: true, Media: []string{"m1"}}})).To(Succeed())
|
||||
waitFor("the sentence", func() bool { return calls.Load() >= 1 })
|
||||
Expect(sent.Load()).To(Equal("That file is too large (150 bytes): I can read files of up to 100 bytes here (limit set for this bot)."))
|
||||
})
|
||||
|
||||
It("refuses a limit above the one of the kind: an owner can only lower it", func() {
|
||||
_, _, err := bindChatty(studio.ConnectorInput{FileSendMax: ptr(701)})
|
||||
Expect(err).To(MatchError(connectors.ErrInvalid))
|
||||
Expect(err).To(MatchError(ContainSubstring("at most 700 bytes")))
|
||||
_, _, err = bindChatty(studio.ConnectorInput{FileReceiveMax: ptr(300), FileSendMax: ptr(700)})
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
})
|
||||
|
||||
It("bounds what an owner may set by the host ceiling", func() {
|
||||
r := newConnRigOpts(newShared(), connStore(), studiobot.NewTransport(chatty), connSay("ok"), nil, func(o *studio.ConnectorOptions) {
|
||||
o.FileCeiling = func(context.Context) (int64, error) { return 200, nil }
|
||||
})
|
||||
ag, err := r.svc.SaveAgent(context.Background(), alice, agents.Agent{Name: "Support", Model: "m"})
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
_, err = r.conns.CreateConnector(context.Background(), alice, studio.ConnectorInput{Kind: "chatty", Name: "c", Token: "t", AgentID: ag.ID, FileSendMax: ptr(300)})
|
||||
Expect(err).To(MatchError(ContainSubstring("at most 200 bytes")))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"github.com/mudler/LocalAI/core/services/studio/connectors"
|
||||
"github.com/mudler/LocalAI/core/services/studio/studiotest"
|
||||
"github.com/mudler/LocalAI/core/services/studio/workspace"
|
||||
"github.com/mudler/LocalAI/pkg/addons/contract"
|
||||
"github.com/mudler/LocalAI/tests/studiobot"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
@@ -36,6 +37,12 @@ func newSlackRig(answer string, allow []string) (*slackRig, connectors.Binding)
|
||||
|
||||
// newSlackRigWith binds the Slack app with in, over a script.
|
||||
func newSlackRigWith(script func(workspace.Actor, string) []studiotest.ToolStep, in studio.ConnectorInput) (*slackRig, connectors.Binding) {
|
||||
return newSlackRigDeclaring(script, in, nil, nil)
|
||||
}
|
||||
|
||||
// newSlackRigDeclaring is newSlackRigWith for a Slack add-on whose kind
|
||||
// declares files, with options of the manager that the spec changes last.
|
||||
func newSlackRigDeclaring(script func(workspace.Actor, string) []studiotest.ToolStep, in studio.ConnectorInput, files *contract.ConnectorFiles, opt func(*studio.ConnectorOptions)) (*slackRig, connectors.Binding) {
|
||||
f := slacktest.New()
|
||||
DeferCleanup(f.Close)
|
||||
f.AddApp(slacktest.App{Bot: slackBot, AppToken: slackApp, UserID: "UBOT"})
|
||||
@@ -43,7 +50,9 @@ func newSlackRigWith(script func(workspace.Actor, string) []studiotest.ToolStep,
|
||||
f.AddUser("U2", "bo", "Bo")
|
||||
rt := slack.New(&slack.API{BaseURL: f.URL, FilesHost: f.FilesHost})
|
||||
DeferCleanup(rt.Close)
|
||||
r := newConnRigWith(newShared(), connStore(), studiobot.NewTransport(studiobot.Slack(rt)), script, nil)
|
||||
addon := studiobot.Slack(rt)
|
||||
addon.Kind.Files = files
|
||||
r := newConnRigOpts(newShared(), connStore(), studiobot.NewTransport(addon), script, nil, opt)
|
||||
in.Kind, in.Token = "slack", slackBot+" "+slackApp
|
||||
b := r.bind(in)
|
||||
waitFor("the Socket Mode link", func() bool { return f.Connected(slackApp) })
|
||||
|
||||
@@ -45,6 +45,10 @@ type ConnectorInput struct {
|
||||
// Options are the choices for the options of the kind. Nil means the
|
||||
// defaults for a new binding and unchanged for an update.
|
||||
Options map[string]string
|
||||
// FileReceiveMax and FileSendMax are the owner's limits, in bytes, for the
|
||||
// files the bot reads and sends. Nil: new binding none, update unchanged.
|
||||
// Zero clears the limit. A limit can only lower what the kind allows.
|
||||
FileReceiveMax, FileSendMax *int64
|
||||
}
|
||||
|
||||
// apply sets the fields of the input that an owner writes on a binding.
|
||||
@@ -63,6 +67,12 @@ func (in ConnectorInput) apply(b *connectors.Binding) {
|
||||
if in.ThreadContext != nil {
|
||||
b.ThreadContextOff = !*in.ThreadContext
|
||||
}
|
||||
if in.FileReceiveMax != nil {
|
||||
b.FileReceiveMax = *in.FileReceiveMax
|
||||
}
|
||||
if in.FileSendMax != nil {
|
||||
b.FileSendMax = *in.FileSendMax
|
||||
}
|
||||
}
|
||||
|
||||
// ErrTokenRefused means the platform refused the bot token of a binding.
|
||||
@@ -282,6 +292,9 @@ func (c *Connectors) CreateConnector(ctx context.Context, user string, in Connec
|
||||
if err := c.checkOptions(ctx, b); err != nil {
|
||||
return ConnectorInfo{}, err
|
||||
}
|
||||
if err := c.checkOwnerLimits(ctx, b, in); err != nil {
|
||||
return ConnectorInfo{}, err
|
||||
}
|
||||
if strings.TrimSpace(in.Token) == "" {
|
||||
return ConnectorInfo{}, connectorBad("the bot token is required")
|
||||
}
|
||||
@@ -344,6 +357,9 @@ func (c *Connectors) UpdateConnector(ctx context.Context, user, id string, in Co
|
||||
if err := c.checkOptions(ctx, b); err != nil {
|
||||
return ConnectorInfo{}, err
|
||||
}
|
||||
if err := c.checkOwnerLimits(ctx, b, in); err != nil {
|
||||
return ConnectorInfo{}, err
|
||||
}
|
||||
if err := c.checkAgent(ctx, old.Owner, in.AgentID); err != nil {
|
||||
return ConnectorInfo{}, err
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/mudler/LocalAI/core/services/studio/session"
|
||||
"github.com/mudler/LocalAI/pkg/addons/contract"
|
||||
)
|
||||
|
||||
// connectorBlock tells the model of a chat that a messaging app started where
|
||||
@@ -24,9 +23,11 @@ func (s *Service) connectorBlock(ctx context.Context, rec session.ChatRecord, ca
|
||||
}
|
||||
platform := "a messaging app"
|
||||
sends, known := false, false
|
||||
var limits FileLimits
|
||||
if c := s.connectors; c != nil {
|
||||
if b, err := c.o.Store.GetBinding(ctx, rec.OriginID); err == nil {
|
||||
sends, known = b.SendsFiles(), true
|
||||
limits = c.FileLimits(ctx, b)
|
||||
platform = b.Kind
|
||||
if kinds, err := c.o.Transport.Kinds(ctx); err == nil {
|
||||
if k, ok := kindFor(kinds, b); ok && k.Title != "" {
|
||||
@@ -46,8 +47,8 @@ func (s *Service) connectorBlock(ctx context.Context, rec session.ChatRecord, ca
|
||||
default:
|
||||
fmt.Fprintf(&sb, " Files you make or change in a turn are attached to your answer automatically. To give them a file that already exists, call %s with its path."+
|
||||
" Say the file is attached; never say you cannot send files, and never tell them a path."+
|
||||
" Up to %d files per turn, %d MB each; documents, data, images, audio, video and zip only, no scripts or HTML. If one cannot be attached, say so.",
|
||||
attachFileName, maxFilesPerTurn, contract.MaxConnectorFileBytes>>20)
|
||||
" Up to %d files per turn, %s each; documents, data, images, audio, video and zip only, no scripts or HTML. If one cannot be attached, say so.",
|
||||
attachFileName, maxFilesPerTurn, limits.Send.sentence(limits.KindTitle))
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
@@ -62,7 +62,7 @@ var _ = Describe("the turn block of a connector chat", func() {
|
||||
Expect(p1).To(ContainSubstring("attached to your answer automatically"))
|
||||
Expect(p1).To(ContainSubstring("call attach_file with its path"), "the way to give a file that exists")
|
||||
Expect(p1).To(ContainSubstring("never tell them a path"))
|
||||
Expect(p1).To(ContainSubstring("Up to 5 files per turn, 1 MB each"))
|
||||
Expect(p1).To(ContainSubstring("Up to 5 files per turn, 1 MiB (limit of this add-on version) each"))
|
||||
Expect(p1).NotTo(ContainSubstring("NOT sent"))
|
||||
Expect(strings.Count(p2, "This chat is on")).To(Equal(1), "a second turn does not repeat the block")
|
||||
Expect(p2).To(Equal(p1))
|
||||
|
||||
@@ -17,6 +17,9 @@ const (
|
||||
MaxDeclaredFileBytes int64 = 1 << 40
|
||||
// MaxFileTypes bounds the media types of one direction.
|
||||
MaxFileTypes = 32
|
||||
// DefaultFileCeilingBytes is the host ceiling of a file, 100 MB, when the
|
||||
// owner policy sets none.
|
||||
DefaultFileCeilingBytes int64 = 100 << 20
|
||||
)
|
||||
|
||||
// FileAbility is what a connector kind can move in one direction.
|
||||
|
||||
Reference in new issue
Block a user