mirror of
https://github.com/mudler/LocalAI.git
synced 2026-11-06 12:33:16 -05:00
A connector chat can now propose a trigger, the person answers it in the chat, and the run reports to the chat. The trigger tools propose_trigger and list_triggers are offered in a connector chat when a capability gate allows triggers.propose for the binding. The gate is a small interface on the service with a deny-all default, so nothing changes until the application sets one. Change and delete stay in Studio. A proposal from a chat stores the binding, chat and thread, a four character code and a 24 hour expiry. The host writes the proposal and the code in that chat. "confirm CODE" or "cancel CODE", as a whole message in the same chat and thread, is handled before any model turn and needs proposals.confirm. A binding may make 3 proposals an hour. Proposals that lapse are marked expired, and the proposal card shows that state. A trigger carries a delivery: the Inbox (default) or the chat of the proposal, with the modes always, on_change and on_problem. The host writes the last message of the run, cut and with broadcast mentions broken; a message of exactly [SILENT] writes nothing; three failures in a row raise one alert per 6 hours. A failed delivery falls back to the Inbox. Host messages of a binding go through an outbox of 20 a minute that queues the rest. While triggers.run is off, runs of such a trigger are skipped, not replayed, and the record is unchanged. The audit records each step with a hash and no text. Assisted-by: Claude:claude-sonnet-5-5 go test ginkgo eslint Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
261 lines
8.1 KiB
Go
261 lines
8.1 KiB
Go
package studio
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"slices"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/mudler/LocalAI/core/services/studio/triggers"
|
|
)
|
|
|
|
// ProposalState is what became of a trigger the chat proposed.
|
|
type ProposalState string
|
|
|
|
// The states of a proposal. A removed one has no state: the trigger is gone
|
|
// from the Triggers page and the lookup answers triggers.ErrNotFound.
|
|
const (
|
|
ProposalPending ProposalState = "pending"
|
|
ProposalAccepted ProposalState = "accepted"
|
|
ProposalDeclined ProposalState = "declined"
|
|
ProposalUndone ProposalState = "undone"
|
|
// ProposalExpired is a proposal that nobody answered in time.
|
|
ProposalExpired ProposalState = "expired"
|
|
)
|
|
|
|
// Why an accepted trigger cannot be undone. The codes are for the interface,
|
|
// which words them in the person's language; undoText is the English the
|
|
// refusal of an undo carries.
|
|
const (
|
|
UndoRan = "ran"
|
|
UndoEdited = "edited"
|
|
)
|
|
|
|
var undoText = map[string]string{
|
|
UndoRan: "It already ran once; remove it from the Triggers page.",
|
|
UndoEdited: "You edited it since; change or remove it on the Triggers page.",
|
|
}
|
|
|
|
// ProposalView is a proposal as the chat card shows it: its state, the record
|
|
// (the proposal itself, or the trigger that accepting made), when it was
|
|
// answered, and whether accepting can still be undone.
|
|
type ProposalView struct {
|
|
State ProposalState
|
|
Trigger triggers.Trigger
|
|
// At is when the proposal was answered; zero while it waits.
|
|
At time.Time
|
|
// CanUndo is set on an accepted trigger that nothing has used or changed.
|
|
// UndoBlocked says why not otherwise: UndoRan or UndoEdited.
|
|
CanUndo bool
|
|
UndoBlocked string
|
|
}
|
|
|
|
func stateOf(t triggers.Trigger) ProposalState {
|
|
switch {
|
|
case t.Pending():
|
|
return ProposalPending
|
|
case t.Outcome == triggers.OutcomeDeclined:
|
|
return ProposalDeclined
|
|
case t.Outcome == triggers.OutcomeUndone:
|
|
return ProposalUndone
|
|
case t.Outcome == triggers.OutcomeExpired:
|
|
return ProposalExpired
|
|
}
|
|
return ProposalAccepted
|
|
}
|
|
|
|
// ownedAny returns a record of the user, whether it is a proposal, an answered
|
|
// one or a trigger, but not a change record.
|
|
func (h *Triggers) ownedAny(ctx context.Context, user, id string) (triggers.Trigger, error) {
|
|
t, err := h.o.Store.GetTrigger(ctx, id)
|
|
if err != nil || t.Owner != principal(user) || t.IsChange() {
|
|
return triggers.Trigger{}, triggers.ErrNotFound
|
|
}
|
|
return t, nil
|
|
}
|
|
|
|
// undoBlock says why the trigger that accepting made cannot be taken back, or
|
|
// "" when it can: it ran (a run of any kind counts) or the person changed it.
|
|
func (h *Triggers) undoBlock(ctx context.Context, t triggers.Trigger) (string, error) {
|
|
if t.Touched {
|
|
return UndoEdited, nil
|
|
}
|
|
runs, err := h.o.Store.ListRuns(ctx, t.ID, 1)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if len(runs) > 0 {
|
|
return UndoRan, nil
|
|
}
|
|
return "", nil
|
|
}
|
|
|
|
// ProposalStatus reads the state of a proposal of the user. A proposal that
|
|
// became a trigger the user removed since, or one that is not theirs, is
|
|
// triggers.ErrNotFound.
|
|
func (h *Triggers) ProposalStatus(ctx context.Context, user, id string) (ProposalView, error) {
|
|
t, err := h.ownedAny(ctx, user, id)
|
|
if err != nil {
|
|
return ProposalView{}, err
|
|
}
|
|
if !t.Proposed && t.Outcome != triggers.OutcomeAccepted {
|
|
return ProposalView{}, triggers.ErrNotFound // a trigger nobody proposed
|
|
}
|
|
t = h.lapse(ctx, t)
|
|
v := ProposalView{State: stateOf(t), Trigger: t, At: t.OutcomeAt}
|
|
if v.State == ProposalAccepted {
|
|
block, err := h.undoBlock(ctx, t)
|
|
if err != nil {
|
|
return ProposalView{}, err
|
|
}
|
|
v.CanUndo, v.UndoBlocked = block == "", block
|
|
}
|
|
return v, nil
|
|
}
|
|
|
|
// UndoProposal takes back an accepted proposal: the trigger is switched off and
|
|
// leaves the Triggers page, and the proposal stays as a record of the answer so
|
|
// it can be scheduled again. It is refused with ErrCannotUndo once the trigger
|
|
// ran or the person changed it; the person then removes it on the Triggers page.
|
|
func (h *Triggers) UndoProposal(ctx context.Context, user, id string) (triggers.Trigger, error) {
|
|
h.wmu.Lock()
|
|
defer h.wmu.Unlock()
|
|
t, err := h.ownedAny(ctx, user, id)
|
|
if err != nil {
|
|
return triggers.Trigger{}, err
|
|
}
|
|
if t.Proposed {
|
|
return triggers.Trigger{}, ErrProposalAnswered
|
|
}
|
|
if t.Outcome != triggers.OutcomeAccepted {
|
|
return triggers.Trigger{}, triggers.ErrNotFound
|
|
}
|
|
block, err := h.undoBlock(ctx, t)
|
|
if err != nil {
|
|
return triggers.Trigger{}, err
|
|
}
|
|
if block != "" {
|
|
return triggers.Trigger{}, fmt.Errorf("%w: %s", ErrCannotUndo, undoText[block])
|
|
}
|
|
undone := t
|
|
undone.Enabled, undone.Proposed, undone.Touched = false, true, false
|
|
undone.Outcome, undone.OutcomeAt = triggers.OutcomeUndone, h.o.Clock().UTC()
|
|
if err := h.o.Store.PutTrigger(ctx, undone); err != nil {
|
|
return triggers.Trigger{}, err
|
|
}
|
|
// A pass may have started a run between the check and the write. Then the
|
|
// trigger is the person's now: put it back.
|
|
if block, err := h.undoBlock(ctx, t); err != nil || block != "" {
|
|
if perr := h.o.Store.PutTrigger(ctx, t); perr != nil {
|
|
return triggers.Trigger{}, perr
|
|
}
|
|
if err != nil {
|
|
return triggers.Trigger{}, err
|
|
}
|
|
return triggers.Trigger{}, fmt.Errorf("%w: %s", ErrCannotUndo, undoText[block])
|
|
}
|
|
h.Notify()
|
|
return undone, nil
|
|
}
|
|
|
|
// ReopenProposal makes a declined or undone proposal wait for an answer again.
|
|
func (h *Triggers) ReopenProposal(ctx context.Context, user, id string) (triggers.Trigger, error) {
|
|
h.wmu.Lock()
|
|
defer h.wmu.Unlock()
|
|
t, err := h.ownedAny(ctx, user, id)
|
|
if err != nil {
|
|
return triggers.Trigger{}, err
|
|
}
|
|
if !t.Proposed {
|
|
return triggers.Trigger{}, ErrProposalAnswered
|
|
}
|
|
if t.Pending() {
|
|
return t, nil
|
|
}
|
|
mine, err := h.o.Store.ListTriggers(ctx, t.Owner)
|
|
if err != nil {
|
|
return triggers.Trigger{}, err
|
|
}
|
|
pending := 0
|
|
now := h.o.Clock()
|
|
for _, m := range mine {
|
|
if m.Pending() && !m.Expired(now) {
|
|
pending++
|
|
}
|
|
}
|
|
if pending >= maxProposals {
|
|
return triggers.Trigger{}, ErrTooManyProposals
|
|
}
|
|
t.Outcome, t.OutcomeAt, t.Touched = "", time.Time{}, false
|
|
if !t.Route.Expires.IsZero() {
|
|
// The owner asked for it again: it waits a full time once more.
|
|
t.Route.Expires = h.o.Clock().UTC().Add(ProposalTTL)
|
|
}
|
|
if err := h.o.Store.PutTrigger(ctx, t); err != nil {
|
|
return triggers.Trigger{}, err
|
|
}
|
|
return t, nil
|
|
}
|
|
|
|
// maxProposalNotes is how many answered proposals of a chat the model hears of.
|
|
const maxProposalNotes = 3
|
|
|
|
// ProposalNote is one line for the model at the start of a turn: what the user
|
|
// did with the triggers it proposed in this chat, newest first. It is empty
|
|
// when there is nothing to say. The model gets the state, not the event, so a
|
|
// later turn still knows.
|
|
func (h *Triggers) ProposalNote(ctx context.Context, user, chatID string) string {
|
|
all, err := h.o.Store.ListTriggers(ctx, principal(user))
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
var mine []triggers.Trigger
|
|
for _, t := range all {
|
|
if t.Outcome == "" {
|
|
continue
|
|
}
|
|
if (t.IsChange() && t.Change.Chat == chatID) || (!t.IsChange() && t.SourceChatID == chatID) {
|
|
mine = append(mine, t)
|
|
}
|
|
}
|
|
if len(mine) == 0 {
|
|
return ""
|
|
}
|
|
slices.SortFunc(mine, func(a, b triggers.Trigger) int { return b.OutcomeAt.Compare(a.OutcomeAt) })
|
|
if len(mine) > maxProposalNotes {
|
|
mine = mine[:maxProposalNotes]
|
|
}
|
|
parts := make([]string, 0, len(mine))
|
|
again := false
|
|
for _, t := range mine {
|
|
name := t.Name
|
|
if len(name) > 60 {
|
|
name = name[:60]
|
|
}
|
|
if t.IsChange() {
|
|
if line := changeNote(t, name); line != "" {
|
|
parts = append(parts, line)
|
|
}
|
|
continue
|
|
}
|
|
switch t.Outcome {
|
|
case triggers.OutcomeAccepted:
|
|
parts = append(parts, fmt.Sprintf("%q accepted by the user and active (%s)", name, h.summary(t)))
|
|
case triggers.OutcomeDeclined:
|
|
parts = append(parts, fmt.Sprintf("%q declined by the user", name))
|
|
again = true
|
|
case triggers.OutcomeUndone:
|
|
parts = append(parts, fmt.Sprintf("%q undone by the user after they scheduled it, so it is not active", name))
|
|
again = true
|
|
case triggers.OutcomeExpired:
|
|
parts = append(parts, fmt.Sprintf("%q expired: nobody confirmed it in time, so it is not active", name))
|
|
}
|
|
}
|
|
line := "Trigger proposals and changes in this chat: " + strings.Join(parts, "; ") + "."
|
|
if again {
|
|
line += " Do not propose a declined or undone trigger again unless the user asks."
|
|
}
|
|
return line
|
|
}
|