feat(connectors): add /new to start a fresh chat from the app

A connection kept one chat per person for good, with no way to ask for
a new one. A person can now send /new (or !new, for Slack, which keeps
messages that begin with a slash for itself) as the whole message.

The host reads the command after the pairing check and before any
model turn, so the agent never sees it and the logs record only the
ids. It drops the link from the person, or from the shared thread or
group, to its chat. The old chat stays in Studio. The link is in the
shared store, so it works with several frontends and for every
connector add-on.

In the chat mode and in a thread of a group, everyone shares the chat,
so only a paired sender may reset it. The thread mode answers that
every message already starts a new chat. A chat with a running turn is
left alone and the sender is asked to repeat the command later.

Telegram did not treat /new@BotName as addressed to the bot in a
group; it does now, and keeps the text. The connection page gets one
help line under Conversations.

Assisted-by: Claude:claude-sonnet-5-5
This commit is contained in:
Ettore Di Giacinto committed 2026-11-04 13:35:18 +00:00
1 parent af570ff17d
commit 1ae80d3a15
13 files changed
+468 -2

No files matched your search

@@ -19,6 +19,7 @@ export const ANSWER_TEXT = {
'answer.reply.stream': 'Live: the answer as it is written',
'answer.replyHelp': 'Progress and Live show one working message, and the answer replaces it. On a platform with drafts, Live shows a private chat a draft instead, and the answer follows it. A long answer goes on in new messages.',
'answer.conversationLabel': 'Conversations',
'answer.conversationHelp': 'Send /new (or !new) in the app to start a new chat.',
'answer.conversation.sender': 'One chat per person',
'answer.conversation.chat': 'One chat per group or channel',
'answer.conversation.window': 'Per person, new after a quiet time',
@@ -75,7 +76,7 @@ export default function AnswerFields({ form, setForm, t, kindInfo }) {
</Select>
)}
</Field>
<Field label={s('answer.conversationLabel')}>
<Field label={s('answer.conversationLabel')} hint={s('answer.conversationHelp')}>
{(p) => (
<Select {...p} data-testid="connector-conversation" value={form.conversation || 'sender'}
onChange={(e) => setForm({ ...form, conversation: e.target.value })}>
+12 -1
View File
@@ -358,10 +358,21 @@ func (t *API) addressed(ctx context.Context, token string, m tgMessage) (bool, s
// Entity offsets count UTF-16 units.
units := utf16.Encode([]rune(m.Text))
for _, e := range m.Entities {
if e.Type != "mention" || e.Offset < 0 || e.Length < 1 || e.Offset+e.Length > len(units) {
if e.Offset < 0 || e.Length < 1 || e.Offset+e.Length > len(units) {
continue
}
at := string(utf16.Decode(units[e.Offset : e.Offset+e.Length]))
// A command with the name of the bot ("/new@Bot") is a message to it.
// The text stays as it is: the host reads the command.
if e.Type == "bot_command" {
if _, to, ok := strings.Cut(at, "@"); ok && strings.EqualFold(to, name) {
return true, m.Text
}
continue
}
if e.Type != "mention" {
continue
}
if strings.EqualFold(at, "@"+name) {
rest := append(append([]uint16{}, units[:e.Offset]...), units[e.Offset+e.Length:]...)
return true, strings.Join(strings.Fields(string(utf16.Decode(rest))), " ")
+20
View File
@@ -100,6 +100,26 @@ var _ = Describe("the Bot API client", func() {
Expect(strings.Count(p, "/")).To(Equal(2))
})
It("reads a command that names the bot as addressed to it, and keeps the text", func() {
f := newFakeBot()
f.reply["getMe"] = func(map[string]any) (int, string) {
return 200, `{"ok":true,"result":{"id":123456,"username":"helper_bot"}}`
}
f.reply["getUpdates"] = func(map[string]any) (int, string) {
return 200, `{"ok":true,"result":[
{"update_id":30,"message":{"message_id":601,"chat":{"id":-300,"type":"supergroup"},"from":{"id":7,"first_name":"Bo"},"text":"/new@Helper_Bot","entities":[{"type":"bot_command","offset":0,"length":15}]}},
{"update_id":31,"message":{"message_id":602,"chat":{"id":-300,"type":"supergroup"},"from":{"id":7,"first_name":"Bo"},"text":"/new@other_bot","entities":[{"type":"bot_command","offset":0,"length":14}]}},
{"update_id":32,"message":{"message_id":603,"chat":{"id":-300,"type":"supergroup"},"from":{"id":7,"first_name":"Bo"},"text":"/new","entities":[{"type":"bot_command","offset":0,"length":4}]}}]}`
}
ups, err := f.client().Updates(ctx, secretToken, 30, 0)
Expect(err).NotTo(HaveOccurred())
Expect(ups).To(HaveLen(3))
Expect(ups[0].Mention).To(BeTrue())
Expect(ups[0].Text).To(Equal("/new@Helper_Bot"))
Expect(ups[1].Mention).To(BeFalse(), "another bot is named")
Expect(ups[2].Mention).To(BeFalse(), "a bare command in a group is not addressed")
})
It("reads the ID of a message, its reply, its topic and a mention of the bot in a group", func() {
f := newFakeBot()
f.reply["getMe"] = func(map[string]any) (int, string) {
@@ -120,6 +120,7 @@ test.describe('Telegram add-on', () => {
// A new binding shows progress, one chat per person, and sends files.
await expect(page.getByTestId('connector-reply')).toHaveValue('progress')
await expect(page.getByTestId('connector-conversation')).toHaveValue('sender')
await expect(page.getByText('Send /new (or !new) in the app to start a new chat.')).toBeVisible()
await expect(page.getByTestId('connector-files')).toBeChecked()
await expect(page.getByTestId('connector-window')).toHaveCount(0)
await page.getByLabel('Name', { exact: true }).fill('Live bot')
+75
View File
@@ -0,0 +1,75 @@
package studio
import (
"context"
"regexp"
"strings"
"github.com/mudler/LocalAI/core/services/studio/connectors"
"github.com/mudler/LocalAI/pkg/addons/contract"
)
// The new chat command lets a sender ask for a fresh chat from the app. The
// host reads it before any model turn, as it does a pairing code, so the text
// never reaches the agent. It only drops the mapping from the sender (or the
// shared conversation) to its chat: the old chat stays in Studio, and its
// memory, files and triggers stay as they are. Add-ons only deliver messages,
// so this works for every connector.
const (
sentNewDone = "Started a new chat. I will not see our earlier messages."
sentNewEach = "Every message here already starts a new chat."
sentNewShared = "Only a paired account can start a new chat here, because everyone in this conversation shares it."
sentNewBusy = "I am still answering. Send the command again when I am done."
)
// newCommand is the whole text of the command. The slash form is the one of
// Telegram, with the name of the bot after an at sign in a group. Slack takes
// a message that begins with a slash for its own commands, so the bang form
// is the one to use there. Anything around the command other than white space
// makes the message plain text.
var newCommand = regexp.MustCompile(`(?i)^(?:/new(?:@[a-z0-9_]{1,64})?|!new)$`)
// isNewCommand tells whether the message is the new chat command. A message
// with files is never a command.
func isNewCommand(m contract.ConnectorMessage) bool {
return len(m.Media) == 0 && newCommand.MatchString(strings.TrimSpace(m.Text))
}
// newChat handles the command. The caller has checked that the sender may
// talk to the bot. It never logs the text of the message.
func (c *Connectors) newChat(ctx context.Context, b connectors.Binding, m contract.ConnectorMessage, say func(string)) {
mode := b.ConversationMode()
if mode == connectors.ConversationThread {
c.audit(b, m.SenderID, "chat.new", "thread mode")
say(sentNewEach)
return
}
key := c.conversationOf(ctx, b, m)
// A conversation that other people write in changes for all of them: in
// the shared chat mode, and in a thread of a group. Only a paired sender
// may reset it. A direct conversation has the sender only.
if !m.Direct && (mode == connectors.ConversationChat || m.Thread != "") && !b.IsPaired(m.SenderID, c.o.Clock()) {
c.audit(b, m.SenderID, "chat.new", "not paired")
say(sentNewShared)
return
}
// A turn that runs keeps its chat: the reset waits for the sender to ask
// again once the answer is out.
st := c.sender(b.ID, "\x01"+key)
c.smu.Lock()
busy := st.active
c.smu.Unlock()
if busy {
c.audit(b, m.SenderID, "chat.new", "busy")
say(sentNewBusy)
return
}
if err := c.o.Store.ClearChat(ctx, b.ID, key); err != nil {
c.log.Warn("studio: could not start a new connector chat", "binding", b.ID, "error", err)
say(sentUnavailable)
return
}
c.audit(b, m.SenderID, "chat.new", "reset")
say(sentNewDone)
}
@@ -0,0 +1,39 @@
package studio
import (
"github.com/mudler/LocalAI/pkg/addons/contract"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("the new chat command", func() {
DescribeTable("tells a command from plain text",
func(text string, media int, want bool) {
m := contract.ConnectorMessage{Text: text}
for range media {
m.Media = append(m.Media, "f")
}
Expect(isNewCommand(m)).To(Equal(want))
},
Entry("the slash form", "/new", 0, true),
Entry("the bang form for Slack", "!new", 0, true),
Entry("any case", "/NEW", 0, true),
Entry("any case, bang form", "!New", 0, true),
Entry("white space around it", " /new \n", 0, true),
Entry("the name of the bot", "/new@Helper_Bot", 0, true),
Entry("the name of the bot, lower case", "/NEW@helper_bot", 0, true),
Entry("a bang with a bot name is not a command", "!new@Helper_Bot", 0, false),
Entry("an empty name", "/new@", 0, false),
Entry("text after it", "/new please summarise", 0, false),
Entry("text before it", "please /new", 0, false),
Entry("a longer word", "/news", 0, false),
Entry("a longer word, bang form", "!newer", 0, false),
Entry("a path", "/new/x", 0, false),
Entry("a space inside", "/ new", 0, false),
Entry("two commands", "/new /new", 0, false),
Entry("the word alone", "new", 0, false),
Entry("a quoted command", "\"/new\"", 0, false),
Entry("empty", "", 0, false),
Entry("a file with the text", "/new", 1, false),
)
})
+5
View File
@@ -752,6 +752,11 @@ func (c *Connectors) handle(ctx context.Context, b connectors.Binding, token str
return
}
}
// The new chat command is decided here too, and the model never reads it.
if isNewCommand(m) {
c.newChat(ctx, b, m, say)
return
}
// "confirm 7K2Q" or "cancel 7K2Q" answers a proposal that this binding made;
// it is decided here, before any model turn, and the model never reads it.
if len(m.Media) == 0 && c.answerPhrase(ctx, b, m, say) {
@@ -268,6 +268,25 @@ func Run(newStore NewStore) {
id, _, _ = s.ChatOf(ctx, "b1", "2")
Expect(id).To(Equal("c2"))
})
It("drops the mapping of a sender and leaves the others", func() {
s := newStore()
Expect(s.ClearChat(ctx, "b1", "1")).To(Succeed(), "no mapping is not an error")
_ = s.SetChat(ctx, "b1", "1", "c1")
_ = s.SetChat(ctx, "b1", "2", "c2")
_ = s.SetChat(ctx, "b2", "1", "c3")
Expect(s.ClearChat(ctx, "b1", "1")).To(Succeed())
_, ok, _ := s.ChatOf(ctx, "b1", "1")
Expect(ok).To(BeFalse())
id, ok, _ := s.ChatOf(ctx, "b1", "2")
Expect(ok).To(BeTrue())
Expect(id).To(Equal("c2"))
id, ok, _ = s.ChatOf(ctx, "b2", "1")
Expect(ok).To(BeTrue())
Expect(id).To(Equal("c3"))
_ = s.SetChat(ctx, "b1", "1", "c4")
id, _, _ = s.ChatOf(ctx, "b1", "1")
Expect(id).To(Equal("c4"), "a new chat can be mapped again")
})
It("sets the capabilities only while a binding has none", func() {
s := newStore()
Expect(s.PutBinding(ctx, binding("none", "u1", "None"))).To(Succeed())
+4
View File
@@ -296,6 +296,10 @@ func (s *gormStore) SetChat(ctx context.Context, binding, senderID, chatID strin
return s.db.WithContext(ctx).Save(&chatRow{Binding: binding, Sender: senderID, ChatID: chatID}).Error
}
func (s *gormStore) ClearChat(ctx context.Context, binding, senderID string) error {
return s.db.WithContext(ctx).Where("binding = ? AND sender = ?", binding, senderID).Delete(&chatRow{}).Error
}
func (s *gormStore) EnsureKey(ctx context.Context, generate func() ([]byte, error)) ([]byte, error) {
read := func() ([]byte, bool, error) {
var r keyRow
+11
View File
@@ -217,6 +217,17 @@ func (s *jsonStore) SetChat(_ context.Context, binding, senderID, chatID string)
return s.save()
}
func (s *jsonStore) ClearChat(_ context.Context, binding, senderID string) error {
s.mu.Lock()
defer s.mu.Unlock()
k := binding + "\x00" + senderID
if _, ok := s.d.Chats[k]; !ok {
return nil
}
delete(s.d.Chats, k)
return s.save()
}
func (s *jsonStore) EnsureKey(_ context.Context, generate func() ([]byte, error)) ([]byte, error) {
s.mu.Lock()
defer s.mu.Unlock()
+4
View File
@@ -282,6 +282,10 @@ type Store interface {
// ChatOf is the chat of a sender of a binding.
ChatOf(ctx context.Context, binding, senderID string) (string, bool, error)
SetChat(ctx context.Context, binding, senderID, chatID string) error
// ClearChat drops the mapping of a sender of a binding to its chat, so
// that the next message makes a new chat. The chat itself is not touched.
// It is not an error when there is no mapping.
ClearChat(ctx context.Context, binding, senderID string) error
// EnsureKey returns the sealing key. The first call stores what generate
// makes; every call after it, on any frontend, returns that key.
+246
View File
@@ -0,0 +1,246 @@
package studio_test
import (
"context"
"strings"
"time"
"github.com/mudler/LocalAI/core/services/studio"
"github.com/mudler/LocalAI/core/services/studio/connectors"
"github.com/mudler/LocalAI/core/services/studio/session"
"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/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
// The new chat command: the host reads it before any model turn, and it only
// drops the mapping from a conversation to its chat.
var _ = Describe("the new chat command of a connector", func() {
ctx := context.Background()
var (
sh *shared
bot *scriptedBot
st connectors.Store
r *connRig
b connectors.Binding
)
const (
done = "Started a new chat. I will not see our earlier messages."
each = "Every message here already starts a new chat."
sharedNo = "Only a paired account can start a new chat here, because everyone in this conversation shares it."
busy = "I am still answering. Send the command again when I am done."
)
setup := func(in studio.ConnectorInput) {
sh, bot, st = newShared(), newScriptedBot(), connStore()
r = newConnRig(sh, st, bot, connSay("ok"), func(context.Context) bool { return false })
b = r.bind(in)
}
dm := func(sender, text string) contract.ConnectorMessage {
return contract.ConnectorMessage{Chat: sender, Direct: true, SenderID: sender, SenderName: "S" + sender, Text: text}
}
group := func(sender, text string) contract.ConnectorMessage {
return contract.ConnectorMessage{Chat: "-600", Mention: true, SenderID: sender, SenderName: "S" + sender, Text: text}
}
topic := func(sender, thread, text string) contract.ConnectorMessage {
m := group(sender, text)
m.Thread = thread
return m
}
// send ingests a message on a frontend and waits for the next answer of the bot.
send := func(via *connRig, m contract.ConnectorMessage) {
n := len(bot.answers())
Expect(via.conns.Ingest(ctx, "telegram", contract.ConnectorInbound{BindingID: b.ID, ConnectorMessage: m})).To(Succeed())
Eventually(func() int { return len(bot.answers()) }, "10s").Should(Equal(n + 1))
sh.clock.Advance(20 * time.Second) // stays inside the rate limit of the sender
}
last := func() string { a := bot.answers(); return a[len(a)-1] }
chats := func() []session.ChatRecord {
l, err := r.svc.List(ctx, alice, 50)
Expect(err).NotTo(HaveOccurred())
return l
}
// sentTo lists what each chat received, oldest chat first.
sentTo := func() [][]string {
l := chats()
var out [][]string
for i := len(l) - 1; i >= 0; i-- {
out = append(out, r.h.Sent(l[i].ID))
}
return out
}
It("starts a new chat for the sender mode and keeps the old one", func() {
setup(studio.ConnectorInput{Allow: []string{"11"}})
send(r, dm("11", "one"))
Expect(chats()).To(HaveLen(1))
old := chats()[0].ID
send(r, dm("11", "/new"))
Expect(last()).To(Equal(done))
Expect(chats()).To(HaveLen(1), "the command makes no empty chat")
send(r, dm("11", "two"))
Expect(chats()).To(HaveLen(2))
Expect(sentTo()).To(Equal([][]string{{"one"}, {"two"}}), "the command never reaches a chat")
rec, err := r.svc.Get(ctx, alice, old)
Expect(err).NotTo(HaveOccurred(), "the old chat stays")
Expect(rec.ID).To(Equal(old))
send(r, dm("11", "three"))
Expect(sentTo()).To(Equal([][]string{{"one"}, {"two", "three"}}))
})
It("takes the bang form and the command of the bot in any case", func() {
setup(studio.ConnectorInput{Allow: []string{"11"}})
send(r, dm("11", "one"))
send(r, dm("11", "!NEW"))
Expect(last()).To(Equal(done))
send(r, dm("11", "two"))
send(r, dm("11", " /New@helper_bot \n"))
Expect(last()).To(Equal(done))
send(r, dm("11", "three"))
Expect(sentTo()).To(Equal([][]string{{"one"}, {"two"}, {"three"}}))
})
It("treats a command with other text as a plain message", func() {
setup(studio.ConnectorInput{Allow: []string{"11"}})
send(r, dm("11", "one"))
send(r, dm("11", "/new please summarise"))
Expect(last()).To(Equal("ok"), "the model answered")
Expect(sentTo()).To(Equal([][]string{{"one", "/new please summarise"}}))
})
It("starts a new chat after a command for the window mode", func() {
setup(studio.ConnectorInput{Allow: []string{"11"}, Conversation: connectors.ConversationWindow, WindowMinutes: 30})
send(r, dm("11", "one"))
send(r, dm("11", "/new"))
Expect(last()).To(Equal(done))
send(r, dm("11", "two"))
Expect(sentTo()).To(Equal([][]string{{"one"}, {"two"}}))
})
It("says that the thread mode needs no command, and changes nothing", func() {
setup(studio.ConnectorInput{Allow: []string{"11"}, Conversation: connectors.ConversationThread})
send(r, dm("11", "/new"))
Expect(last()).To(Equal(each))
Expect(chats()).To(BeEmpty())
})
It("lets a paired sender reset the shared chat of a group, for everyone", func() {
setup(studio.ConnectorInput{Allow: []string{"-600"}, Conversation: connectors.ConversationChat})
send(r, group("21", "first"))
send(r, group("11", "/new")) // 11 is paired by the rig
Expect(last()).To(Equal(done))
send(r, group("21", "second"))
Expect(sentTo()).To(Equal([][]string{{"first"}, {"second"}}))
send(r, group("22", "third"))
Expect(sentTo()).To(Equal([][]string{{"first"}, {"second", "third"}}), "the group shares the new chat")
})
It("refuses an unpaired member of a group to reset the shared chat", func() {
setup(studio.ConnectorInput{Allow: []string{"-600"}, Conversation: connectors.ConversationChat})
send(r, group("21", "first"))
send(r, group("21", "/new"))
Expect(last()).To(Equal(sharedNo))
send(r, group("22", "second"))
Expect(sentTo()).To(Equal([][]string{{"first", "second"}}), "the chat is the same")
})
It("resets only the chat of a thread, and only for a paired sender", func() {
setup(studio.ConnectorInput{Allow: []string{"-600"}})
send(r, topic("21", "77", "in one"))
send(r, topic("21", "78", "in two"))
send(r, topic("21", "77", "/new"))
Expect(last()).To(Equal(sharedNo), "the thread is shared by the people in it")
send(r, topic("11", "77", "/new"))
Expect(last()).To(Equal(done))
send(r, topic("21", "77", "again in one"))
send(r, topic("21", "78", "again in two"))
got := map[string]bool{}
for _, s := range sentTo() {
got[strings.Join(s, "|")] = true
}
Expect(got).To(Equal(map[string]bool{"in one": true, "in two|again in two": true, "again in one": true}))
})
It("lets a sender of a group reset their own chat in the sender mode", func() {
setup(studio.ConnectorInput{Allow: []string{"-600"}})
send(r, group("21", "mine"))
send(r, group("22", "his"))
send(r, group("21", "/new"))
Expect(last()).To(Equal(done))
send(r, group("21", "mine again"))
send(r, group("22", "his again"))
got := map[string]bool{}
for _, s := range sentTo() {
got[strings.Join(s, "|")] = true
}
Expect(got).To(Equal(map[string]bool{"mine": true, "his|his again": true, "mine again": true}))
})
It("gives a sender who is not listed nothing, and changes nothing", func() {
setup(studio.ConnectorInput{Allow: []string{"11"}})
send(r, dm("11", "one"))
n := len(bot.answers())
Expect(r.conns.Ingest(ctx, "telegram", contract.ConnectorInbound{BindingID: b.ID, ConnectorMessage: dm("99", "/new")})).To(Succeed())
Expect(r.conns.Ingest(ctx, "telegram", contract.ConnectorInbound{BindingID: b.ID, ConnectorMessage: contract.ConnectorMessage{Chat: "-600", SenderID: "11", Text: "/new"}})).To(Succeed()) // a group message that names no bot
Consistently(func() int { return len(bot.answers()) }, "300ms").Should(Equal(n))
send(r, dm("11", "two"))
Expect(sentTo()).To(Equal([][]string{{"one", "two"}}))
})
It("keeps the mapping while a turn of the chat runs", func() {
sh, bot, st = newShared(), newScriptedBot(), connStore()
release := make(chan struct{})
script := func(workspace.Actor, string) []studiotest.ToolStep {
return []studiotest.ToolStep{{Say: "Half"}, {Block: release}, {Say: " done."}}
}
r = newConnRig(sh, st, bot, script, func(context.Context) bool { return false })
b = r.bind(studio.ConnectorInput{Allow: []string{"11"}})
Expect(r.conns.Ingest(ctx, "telegram", contract.ConnectorInbound{BindingID: b.ID, ConnectorMessage: dm("11", "go")})).To(Succeed())
Eventually(func() int { return len(chats()) }, "10s").Should(Equal(1))
sh.clock.Advance(20 * time.Second)
send(r, dm("11", "/new"))
Expect(last()).To(Equal(busy))
close(release)
Eventually(func() string { a := bot.answers(); return a[len(a)-1] }, "10s").Should(Equal("Half done."))
sh.clock.Advance(20 * time.Second)
Eventually(func() bool {
n := len(bot.answers())
Expect(r.conns.Ingest(ctx, "telegram", contract.ConnectorInbound{BindingID: b.ID, ConnectorMessage: dm("11", "/new")})).To(Succeed())
Eventually(func() int { return len(bot.answers()) }, "10s").Should(Equal(n + 1))
sh.clock.Advance(20 * time.Second)
return last() == done
}, "10s", "50ms").Should(BeTrue())
Expect(chats()).To(HaveLen(1), "the chat of the turn is kept")
})
It("shares the reset between frontends through the store", func() {
setup(studio.ConnectorInput{Allow: []string{"11"}})
second := newConnRig(sh, st, bot, connSay("ok"), func(context.Context) bool { return false })
send(r, dm("11", "one"))
send(second, dm("11", "/new"))
Expect(last()).To(Equal(done))
send(r, dm("11", "two"))
send(second, dm("11", "three"))
l := chats()
Expect(l).To(HaveLen(2))
// Each frontend has its own model harness: add what both received.
got := map[string]bool{}
for _, c := range l {
got[strings.Join(append(r.h.Sent(c.ID), second.h.Sent(c.ID)...), "|")] = true
}
Expect(got).To(Equal(map[string]bool{"one": true, "two|three": true}))
})
It("writes no text of the message to the log", func() {
setup(studio.ConnectorInput{Allow: []string{"11"}})
send(r, dm("11", "one"))
send(r, dm("11", "/new"))
send(r, dm("11", "!new"))
logs := r.logs.String()
Expect(logs).To(ContainSubstring("chat.new"))
Expect(logs).To(ContainSubstring("sender=11"))
Expect(strings.ToLower(logs)).NotTo(ContainSubstring("/new"))
Expect(strings.ToLower(logs)).NotTo(ContainSubstring("!new"))
})
})
+30
View File
@@ -397,6 +397,36 @@ that part.
| `window` | As `sender`, but a message that comes `window_minutes` (1 to 10,080) or more after the same person's previous message starts a new chat. A thread keeps its chat. |
| `thread` | Each message starts a new chat. A reply to an answer of the bot, or to the message that started a chat, goes on in that chat; on Slack, so does a thread under either one. |
#### Start a new chat with `/new`
A person can ask for a new chat at any time. The person sends `/new` as a message to the
bot. Slack takes a message that begins with a slash for its own commands, so there the
person sends `!new`. The host reads the command before any model turn: the agent never
sees it, and the logs record only that a new chat started, with the ids, never the text.
The command is the whole message. The case does not matter, and white space around it is
ignored. Telegram's `/new@BotName` works when it names the bot. A message such as
`/new please summarise` is plain text and goes to the agent. A message with a file is
never a command. Only a person who may talk to the bot can use it; any other person gets
nothing, as for any message.
| Mode | What `/new` does |
|---|---|
| `sender`, `window` | The next message of the person starts a new chat. The old chat stays in Studio, with all its messages. |
| `chat` | The next message of anyone in the group starts a new chat, for everyone. Only a sender that the owner paired with the bot may do this. Anyone else gets a short refusal. |
| `thread` | Nothing to reset: the bot says that every message already starts a new chat. |
In a thread of the platform (a Slack thread, a Telegram forum topic) the command resets
only the chat of that thread. The people in a thread share its chat, so in a group a
paired sender must send it. In a private chat the person can always send it.
If the bot is still answering in that chat, it asks the person to send the command again
when it is done, so a running turn never loses its chat. The old chat is not deleted, and
memory and files stay as they are. A trigger whose delivery target is the origin chat
still points at the old chat. The command needs no support from the add-on: it works for
every connector, and with several frontends, because the link from a person to a chat is
in the shared store.
Some add-ons add settings of their own to a binding (for example Slack's **Threads the
bot follows**). They appear under **How it answers**, and in the API as `options`, a
map from the option id to a value. The add-on declares the allowed values, and an