mirror of
https://github.com/mudler/LocalAI.git
synced 2026-11-06 12:33:16 -05:00
The host sent only the files a turn made or changed. A person who asked in a messaging app for a file that already existed got nothing. Add the attach_file tool. It is offered only in a chat that a connector started whose binding sends files, so a chat in Studio has no such tool. It takes a path as the file tools show it, checks the file against the chat's access (a scoped agent reaches only its folders), the type allowlist, the size limit and the count, and marks it. When it refuses, the result says why in plain words so the model can tell the person. sendFiles now sends the files the turn made, then the attached ones, each path once and at most five in all. It reads them with the chat's access resolved at send time. Delivery is the existing send_file op, so every connector add-on that implements it gets the feature with no code of its own. A failed send says "I could not send X" and adds the reason the add-on reports, cut to one short line. The marks are kept in the memory of the process, like the other per-turn state of the connector manager. Assisted-by: Claude Code:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
116 lines
4.6 KiB
Go
116 lines
4.6 KiB
Go
package studio
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"path"
|
|
"slices"
|
|
|
|
"github.com/mudler/LocalAI/core/services/studio/session"
|
|
"github.com/mudler/LocalAI/core/services/studio/workspace"
|
|
"github.com/mudler/LocalAI/pkg/addons/contract"
|
|
)
|
|
|
|
const attachFileName = "attach_file"
|
|
|
|
const attachFileDescription = "Give the person a file that already exists in your files: it is sent to them with your answer. " +
|
|
"Files you make or change in a turn are attached without this call; use it for a file from an earlier turn or from a folder you were given. " +
|
|
"path is the file's path as your file tools show it. A file that cannot be attached comes back with the reason: tell the person plainly."
|
|
|
|
const attachFileSchema = `{"type":"object","properties":{"path":{"type":"string","description":"The path of the file, such as /workspace/chat/report.docx."}},"required":["path"]}`
|
|
|
|
// attachTools returns attach_file for a chat that a connector started whose
|
|
// binding sends files. Any other chat has no such tool: a person in Studio
|
|
// sees the files of the chat there. The tool only marks the file; sendFiles
|
|
// reads and sends it after the answer, through the send_file op that any
|
|
// connector add-on serves.
|
|
func (s *Service) attachTools(ctx context.Context, x session.Access, rec session.ChatRecord) []session.Tool {
|
|
c := s.connectors
|
|
if c == nil || rec.Origin != session.OriginConnector || rec.Kind == session.KindSubAgent || !s.d.Files.Available() {
|
|
return nil
|
|
}
|
|
b, err := c.o.Store.GetBinding(ctx, rec.OriginID)
|
|
if err != nil || !b.SendsFiles() {
|
|
return nil
|
|
}
|
|
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)
|
|
},
|
|
}}
|
|
}
|
|
|
|
// attachFile checks the file against the chat's access and the limits of a
|
|
// 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) {
|
|
in, err := session.DecodeArgs[struct{ Path string }](raw)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
t, err := x.Place().Resolve(in.Path)
|
|
if err != nil {
|
|
return "", notFound(err, in.Path)
|
|
}
|
|
n, err := c.svc.d.Files.Store().Stat(ctx, x.Actor(), t.Abs)
|
|
if err != nil {
|
|
return "", notFound(err, in.Path)
|
|
}
|
|
name := contract.SafeFileName(n.Name)
|
|
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)
|
|
case !outboundAllowed(name):
|
|
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)
|
|
}
|
|
key := key(x.Actor().Principal, x.Actor().ChatID)
|
|
c.amu.Lock()
|
|
defer c.amu.Unlock()
|
|
marked := c.attached[key]
|
|
if slices.Contains(marked, t.Abs) {
|
|
return name + " is attached already.", nil
|
|
}
|
|
if len(marked) >= maxFilesPerTurn {
|
|
return "", fmt.Errorf("at most %d files go with one answer, and %d are marked already; tell the person which one is left out", maxFilesPerTurn, len(marked))
|
|
}
|
|
if c.attached == nil {
|
|
c.attached = map[string][]string{}
|
|
}
|
|
c.attached[key] = append(marked, t.Abs)
|
|
return name + " is attached: it is sent to the person after your answer. Do not give them a path.", nil
|
|
}
|
|
|
|
// takeAttached returns the files that the chat's turn marked, in the order of
|
|
// the calls, and forgets them.
|
|
func (c *Connectors) takeAttached(principal, chatID string) []string {
|
|
c.amu.Lock()
|
|
defer c.amu.Unlock()
|
|
k := key(principal, chatID)
|
|
out := c.attached[k]
|
|
delete(c.attached, k)
|
|
return out
|
|
}
|
|
|
|
// outboundAllowed says whether a file of this name goes to a sender.
|
|
func outboundAllowed(name string) bool {
|
|
_, ok := outboundType(name)
|
|
return ok
|
|
}
|
|
|
|
// notFound tells the model that a path is not a file it can reach. A path
|
|
// outside the chat's access and a path that does not exist look the same.
|
|
func notFound(err error, p string) error {
|
|
if errors.Is(err, workspace.ErrNotFound) || errors.Is(err, workspace.ErrReadOnly) {
|
|
return fmt.Errorf("%s was not found", p)
|
|
}
|
|
return fmt.Errorf("%s cannot be attached: %w", p, err)
|
|
}
|