feat(chats): reply language auto follows the owner's interface language

With the reply language on auto, the sentences the host writes in a chat
used the language detected from the sender, and the interface language
of the owner only when a server hook supplied it. No hook was wired, so
the interface language was never used.

The page of a connector add-on now sends its interface language with
each save (ui_language). The binding keeps it, so every frontend of a
cluster knows it. With auto, the host speaks that language when its
catalog has it, and reads the language of the sender only when the
owner's language is unknown or not in the catalog. A save without the
field keeps the stored value, and a value that is not a language tag is
ignored. A fixed reply language still wins.

The label of the Automatic option says what it does now, in all nine
languages of the page.

Assisted-by: Claude:claude-sonnet-5-5
This commit is contained in:
Ettore Di Giacinto committed 2026-11-05 09:25:21 +00:00
1 parent 0e89bf1488
commit 27556acbba
24 files changed
+208 -42

No files matched your search

+10 -3
View File
@@ -10,8 +10,15 @@ const enc = encodeURIComponent
// (kinds, access, preview) name it, so the host answers in it: the language
// of the interface may differ from the language of the browser.
let language = ''
// The interface language, English included: a save sends it to the host, which
// keeps it with the binding for the sentences the bot writes itself.
let uiLanguage = ''
// English is the host's own text, so it is not asked for.
export function setApiLanguage(tag) { language = typeof tag === 'string' && !/^en(-|$)/i.test(tag) ? tag : '' }
export function setApiLanguage(tag) {
uiLanguage = typeof tag === 'string' ? tag : ''
language = uiLanguage && !/^en(-|$)/i.test(uiLanguage) ? uiLanguage : ''
}
const withUILanguage = (body) => (uiLanguage && body && typeof body === 'object' ? { ...body, ui_language: uiLanguage } : body)
const localized = (path) => (language ? `${path}${path.includes('?') ? '&' : '?'}lang=${enc(language)}` : path)
async function call(path, init = {}) {
@@ -39,8 +46,8 @@ export const agentsApi = { list: () => call('/api/chats/agents') }
export const connectorsApi = {
list: () => call('/api/chats/connectors'),
kinds: () => call(localized('/api/chats/connectors/kinds')).then((r) => (r ? { ...r, capabilities: localizeCapabilities(r.capabilities) } : r)),
create: (body) => send('POST', '/api/chats/connectors', body),
update: (id, body) => send('PUT', `/api/chats/connectors/${enc(id)}`, body),
create: (body) => send('POST', '/api/chats/connectors', withUILanguage(body)),
update: (id, body) => send('PUT', `/api/chats/connectors/${enc(id)}`, withUILanguage(body)),
remove: (id) => send('DELETE', `/api/chats/connectors/${enc(id)}`),
enable: (id) => send('POST', `/api/chats/connectors/${enc(id)}/enable`, {}),
disable: (id) => send('POST', `/api/chats/connectors/${enc(id)}/disable`, {}),
@@ -15,7 +15,7 @@
"answer.windowRange": "Gib eine ganze Zahl von 1 bis {{max}} ein.",
"answer.languageLabel": "Antwortsprache",
"answer.languageHelp": "Die Hinweise, die der Bot selbst schreibt, etwa Bestätigungen und Absagen. Die Antworten des Modells folgen der Sprache der Unterhaltung.",
"answer.language.auto": "Automatisch (Sprache des Absenders)",
"answer.language.auto": "Automatisch (Sprache deiner Oberfläche, sonst die des Absenders)",
"answer.language.en": "English",
"answer.language.it": "Italiano",
"answer.language.de": "Deutsch",
@@ -15,7 +15,7 @@
"answer.windowRange": "Use a whole number from 1 to {{max}}.",
"answer.languageLabel": "Reply language",
"answer.languageHelp": "The notes the bot writes itself, like confirmations and refusals. The answers of the model follow the language of the conversation.",
"answer.language.auto": "Automatic (the language of the sender)",
"answer.language.auto": "Automatic (your interface language, else the language of the sender)",
"answer.language.en": "English",
"answer.language.it": "Italiano",
"answer.language.de": "Deutsch",
@@ -15,7 +15,7 @@
"answer.windowRange": "Usa un número entero del 1 al {{max}}.",
"answer.languageLabel": "Idioma de las respuestas",
"answer.languageHelp": "Las notas que el bot escribe por sí mismo, como confirmaciones y rechazos. Las respuestas del modelo siguen el idioma de la conversación.",
"answer.language.auto": "Automático (el idioma del remitente)",
"answer.language.auto": "Automático (el idioma de tu interfaz, si no el del remitente)",
"answer.language.en": "English",
"answer.language.it": "Italiano",
"answer.language.de": "Deutsch",
@@ -15,7 +15,7 @@
"answer.windowRange": "Saisissez un nombre entier de 1 à {{max}}.",
"answer.languageLabel": "Langue des réponses",
"answer.languageHelp": "Les messages que le bot écrit lui-même, comme les confirmations et les refus. Les réponses du modèle suivent la langue de la conversation.",
"answer.language.auto": "Automatique (la langue de l'expéditeur)",
"answer.language.auto": "Automatique (la langue de votre interface, sinon celle de l'expéditeur)",
"answer.language.en": "English",
"answer.language.it": "Italiano",
"answer.language.de": "Deutsch",
@@ -15,7 +15,7 @@
"answer.windowRange": "Gunakan bilangan bulat dari 1 sampai {{max}}.",
"answer.languageLabel": "Bahasa balasan",
"answer.languageHelp": "Catatan yang ditulis bot sendiri, seperti konfirmasi dan penolakan. Jawaban model mengikuti bahasa percakapan.",
"answer.language.auto": "Otomatis (bahasa pengirim)",
"answer.language.auto": "Otomatis (bahasa antarmuka Anda, jika tidak bahasa pengirim)",
"answer.language.en": "English",
"answer.language.it": "Italiano",
"answer.language.de": "Deutsch",
@@ -15,7 +15,7 @@
"answer.windowRange": "Usa un numero intero da 1 a {{max}}.",
"answer.languageLabel": "Lingua di risposta",
"answer.languageHelp": "Le note che il bot scrive da solo, come conferme e rifiuti. Le risposte del modello seguono la lingua della conversazione.",
"answer.language.auto": "Automatica (la lingua di chi scrive)",
"answer.language.auto": "Automatica (la lingua della tua interfaccia, altrimenti quella di chi scrive)",
"answer.language.en": "English",
"answer.language.it": "Italiano",
"answer.language.de": "Deutsch",
@@ -15,7 +15,7 @@
"answer.windowRange": "1부터 {{max}}까지의 정수를 입력하세요.",
"answer.languageLabel": "답변 언어",
"answer.languageHelp": "확인이나 거절처럼 봇이 직접 쓰는 안내문입니다. 모델의 답변은 대화의 언어를 따릅니다.",
"answer.language.auto": "자동 (보낸 사람의 언어)",
"answer.language.auto": "자동 (사용 중인 인터페이스 언어, 없으면 보낸 사람의 언어)",
"answer.language.en": "English",
"answer.language.it": "Italiano",
"answer.language.de": "Deutsch",
@@ -15,7 +15,7 @@
"answer.windowRange": "Use um número inteiro de 1 a {{max}}.",
"answer.languageLabel": "Idioma das respostas",
"answer.languageHelp": "As mensagens que o bot escreve por conta própria, como confirmações e recusas. As respostas do modelo seguem o idioma da conversa.",
"answer.language.auto": "Automático (o idioma do remetente)",
"answer.language.auto": "Automático (o idioma da sua interface, senão o do remetente)",
"answer.language.en": "English",
"answer.language.it": "Italiano",
"answer.language.de": "Deutsch",
@@ -15,7 +15,7 @@
"answer.windowRange": "请输入 1 到 {{max}} 的整数。",
"answer.languageLabel": "回复语言",
"answer.languageHelp": "机器人自己撰写的提示,例如确认和拒绝。模型的回答则使用对话所用的语言。",
"answer.language.auto": "自动(发送者使用的语言)",
"answer.language.auto": "自动(你的界面语言,否则使用发送者的语言)",
"answer.language.en": "English",
"answer.language.it": "Italiano",
"answer.language.de": "Deutsch",
+2 -2
View File
@@ -26,7 +26,7 @@ func connectorOut(i studiosvc.ConnectorInfo) schema.ChatsConnector {
}
return schema.ChatsConnector{Caps: capsOut(i.Caps), Grants: grantsOut(i.Grants), Paired: pairedOut(i.Paired), ID: i.ID, Name: i.Name, Kind: i.Kind, Addon: i.AddonID, Active: i.Active, Reason: i.Reason, AgentID: i.AgentID, CredentialsOnly: i.CredentialsOnly(), Enabled: i.Enabled, AcceptAny: i.AcceptAny,
Allow: allow, NotifyChat: i.NotifyChat, HasToken: i.HasToken, Created: i.Created,
ReplyMode: i.ReplyMode(), Conversation: i.ConversationMode(), WindowMinutes: i.WindowMinutes, SendFiles: i.SendsFiles(), ReplyLanguage: i.ReplyLang(), Options: i.Options,
ReplyMode: i.ReplyMode(), Conversation: i.ConversationMode(), WindowMinutes: i.WindowMinutes, SendFiles: i.SendsFiles(), ReplyLanguage: i.ReplyLang(), UILanguage: i.UILanguage, Options: i.Options,
Files: filesOut(i.Files), FileReceiveMaxBytes: i.FileReceiveMax, FileSendMaxBytes: i.FileSendMax, Resources: connectors.ResourceKeys(i.Resources), Channels: channelsOut(i.Channels), WriteMode: i.Writes(), Approval: approvalOut(i.Approval)}
}
@@ -130,7 +130,7 @@ func failAcceptAny(c echo.Context) error {
func connectorIn(req schema.ChatsConnectorWrite) studiosvc.ConnectorInput {
return studiosvc.ConnectorInput{Kind: req.Kind, Name: req.Name, Token: req.Token, AgentID: req.AgentID, Enabled: req.Enabled,
AcceptAny: req.AcceptAny, Allow: req.Allow, NotifyChat: req.NotifyChat,
Reply: req.ReplyMode, Conversation: req.Conversation, WindowMinutes: req.WindowMinutes, SendFiles: req.SendFiles, ReplyLanguage: req.ReplyLanguage, Options: req.Options,
Reply: req.ReplyMode, Conversation: req.Conversation, WindowMinutes: req.WindowMinutes, SendFiles: req.SendFiles, ReplyLanguage: req.ReplyLanguage, UILanguage: req.UILanguage, Options: req.Options,
FileReceiveMax: req.FileReceiveMaxBytes, FileSendMax: req.FileSendMaxBytes, Caps: req.Caps, Grants: grantsIn(req.Grants), Resources: req.Resources, Channels: channelsIn(req.Channels), WriteMode: req.WriteMode, Approval: approvalIn(req.Approval)}
}
@@ -449,6 +449,9 @@ test.describe('Connector permissions', () => {
await expect(page.getByTestId('connector-can-files.upload')).toContainText('Receive files')
expect(a.s.urls.some(u => u.endsWith('/api/chats/connectors/kinds?lang=it'))).toBe(true)
expect(a.s.urls.some(u => u.endsWith('/access/preview?lang=it'))).toBe(true)
// A save tells the host the interface language, for the sentences the bot writes itself.
await page.getByRole('button', { name: 'Salva' }).click()
await expect.poll(() => a.s.calls.find(c => c.method === 'PUT')?.body.ui_language).toBe('it')
})
test('a blocker held by the server settings links to Settings for an administrator and says to ask one otherwise', async ({ page }) => {
+9
View File
@@ -1336,6 +1336,10 @@ type ChatsConnector struct {
// language of the sender's message), en, it, de, es, fr or pt-BR. The
// answers of the model follow the conversation, not this setting.
ReplyLanguage string `json:"reply_language"`
// UILanguage is the interface language the owner's page last sent, such as
// it or pt-BR. With reply_language auto, the bot speaks in it before it
// reads the language of the sender. Absent when the server was not told.
UILanguage string `json:"ui_language,omitempty"`
// Options are the choices made for the options of the kind, by option id.
// An option that is absent has its default (see ChatsConnectorKind).
Options map[string]string `json:"options,omitempty"`
@@ -1667,6 +1671,11 @@ type ChatsConnectorWrite struct {
// ReplyLanguage is auto, en, it, de, es, fr or pt-BR. Absent means auto
// for a new binding and unchanged for an update.
ReplyLanguage *string `json:"reply_language,omitempty" enums:"auto,en,it,de,es,fr,pt-BR"`
// UILanguage is the interface language of the person who saves the
// binding (a language tag such as it or pt-BR). The page of the add-on
// sends it with each save. Absent or empty keeps the stored value; a value
// that is not a language tag is ignored.
UILanguage *string `json:"ui_language,omitempty"`
// SendFiles turns the sending of a turn's files on or off. Absent means
// on for a new binding and unchanged for an update.
SendFiles *bool `json:"send_files,omitempty"`
+26 -11
View File
@@ -13,9 +13,10 @@ import (
// The sentences that the host writes to people in a chat (see hostmsg) come
// in the language of the person. A binding has a reply language: a fixed
// language, or auto, which reads the language of the sender's messages. The
// last clear language of a sender is kept for langTTL, so a short answer such
// as "ok" or "yes" keeps the language of the conversation.
// language, or auto, which uses the interface language of the owner when the
// server knows it and otherwise reads the language of the sender's messages.
// The last clear language of a sender is kept for langTTL, so a short answer
// such as "ok" or "yes" keeps the language of the conversation.
//
// Every host sentence goes through msg (or a Voice made by voice). A guard
// test fails when a fixed English text reaches the send path any other way.
@@ -105,15 +106,19 @@ func (c *Connectors) noteLanguage(b connectors.Binding, m contract.ConnectorMess
}
// languageOf is the language of the sentences for a sender (or a chat, when
// the sender is not known): the setting of the binding when it names one, else
// the language the sender last wrote clearly, else the interface language of
// the owner when the server knows it, else English.
// the sender is not known): the setting of the binding when it names one. With
// auto it is the interface language of the owner when the server knows it and
// the catalog has it, else the language the sender last wrote clearly (the
// stop-word detection), else English.
func (c *Connectors) languageOf(ctx context.Context, b connectors.Binding, sender, chat string) string {
if set := b.ReplyLang(); set != hostmsg.Auto {
if l, ok := hostmsg.Normalize(set); ok {
return l
}
}
if l, ok := c.ownerLanguage(ctx, b); ok {
return l
}
now := c.o.Clock()
if sender != "" {
if l, ok := c.lang.get(langKey{b.ID, "s:" + sender}, now); ok {
@@ -125,14 +130,24 @@ func (c *Connectors) languageOf(ctx context.Context, b connectors.Binding, sende
return l
}
}
if c.o.OwnerLanguage != nil {
if l, ok := hostmsg.Normalize(c.o.OwnerLanguage(ctx, b.Owner)); ok {
return l
}
}
return hostmsg.English
}
// ownerLanguage is the interface language of the owner of a binding as a
// language of the catalog. The page that saved the binding sent it and the
// binding keeps it, so every frontend knows it. A server hook can supply it
// for a binding that has none. A tag the catalog lacks (Korean, for example)
// counts as unknown, and the detection decides.
func (c *Connectors) ownerLanguage(ctx context.Context, b connectors.Binding) (string, bool) {
if l, ok := hostmsg.Normalize(b.UILanguage); ok {
return l, true
}
if c.o.OwnerLanguage != nil {
return hostmsg.Normalize(c.o.OwnerLanguage(ctx, b.Owner))
}
return "", false
}
// voice speaks to a sender (and a chat) of a binding in their language.
func (c *Connectors) voice(ctx context.Context, b connectors.Binding, sender, chat string) hostmsg.Voice {
return hostmsg.Voice{Lang: c.languageOf(ctx, b, sender, chat)}
+3 -3
View File
@@ -90,9 +90,9 @@ type ConnectorOptions struct {
// policy, in bytes. Nil or zero: contract.DefaultFileCeilingBytes.
FileCeiling func(ctx context.Context) (int64, error)
// OwnerLanguage returns the interface language of an owner (a tag such as
// "it" or "pt-BR"), or "" when the server does not know it. It is the
// fallback of a connection whose reply language is auto and whose sender
// has not yet written a message that shows a language. Nil: English.
// "it" or "pt-BR"), or "" when the server does not know it. It is asked
// for a connection whose reply language is auto and that holds no language
// of its own (Binding.UILanguage). Nil: not known.
OwnerLanguage func(ctx context.Context, owner string) string
// OutboxPoll is how often a queued message looks again for room under
// the limit of its binding; zero means one second.
@@ -90,6 +90,19 @@ func Run(newStore NewStore) {
Expect(again.ReplyLanguage).To(BeEmpty())
Expect(again.ReplyLang()).To(Equal("auto"))
})
It("keeps the interface language of the owner, and reads a binding stored without one as unknown", func() {
s := newStore()
b := binding("ui", "u1", "Interface")
b.UILanguage = "zh-CN"
Expect(s.PutBinding(ctx, b)).To(Succeed())
got, err := s.GetBinding(ctx, "ui")
Expect(err).NotTo(HaveOccurred())
Expect(got.UILanguage).To(Equal("zh-CN"))
other := binding("none", "u1", "Plain")
Expect(s.PutBinding(ctx, other)).To(Succeed())
plain, _ := s.GetBinding(ctx, "none")
Expect(plain.UILanguage).To(BeEmpty())
})
It("accepts auto and the languages of the catalog, and refuses any other reply language", func() {
base := connectors.Binding{Name: "n", Kind: "k", AgentID: "a"}
for _, l := range []string{"", "auto", "en", "it", "de", "es", "fr", "pt-BR"} {
+4 -2
View File
@@ -30,6 +30,8 @@ type bindingRow struct {
FilesOff bool
// ReplyLanguage is added by AutoMigrate; a row stored before it reads as auto.
ReplyLanguage string `gorm:"size:8"`
// UILanguage is added by AutoMigrate; a row stored before it reads as unknown.
UILanguage string `gorm:"size:35"`
// A thread_context_off column that an earlier build added stays in the
// table, unread and unwritten: AutoMigrate never drops a column, and the
// column accepts NULL, so an insert without it works.
@@ -127,7 +129,7 @@ func NewGORM(db *gorm.DB) (Store, error) {
func (r bindingRow) binding() (Binding, error) {
b := Binding{ID: r.ID, Owner: r.Owner, Kind: r.Kind, AddonID: r.AddonID, Name: r.Name, AgentID: r.AgentID, Enabled: r.Enabled, AcceptAny: r.AcceptAny,
NotifyChat: r.NotifyChat, Reply: r.Reply, Conversation: r.Conversation, WindowMinutes: r.WindowMinutes, FilesOff: r.FilesOff, ReplyLanguage: r.ReplyLanguage, WriteMode: r.WriteMode,
NotifyChat: r.NotifyChat, Reply: r.Reply, Conversation: r.Conversation, WindowMinutes: r.WindowMinutes, FilesOff: r.FilesOff, ReplyLanguage: r.ReplyLanguage, UILanguage: r.UILanguage, WriteMode: r.WriteMode,
FileReceiveMax: r.FileReceiveMax, FileSendMax: r.FileSendMax,
Token: append([]byte(nil), r.Token...), Created: r.Created.UTC(), Updated: r.Updated.UTC()}
if r.Allow != "" {
@@ -174,7 +176,7 @@ func (s *gormStore) PutBinding(ctx context.Context, b Binding) error {
}
return s.db.WithContext(ctx).Save(&bindingRow{Approval: enc(b.Approval, b.Approval.IsZero()), WriteMode: b.WriteMode, Resources: enc(b.Resources, len(b.Resources) == 0), Channels: enc(b.Channels, len(b.Channels) == 0), Caps: enc(b.Caps, len(b.Caps) == 0), Grants: enc(b.Grants, len(b.Grants) == 0), Paired: enc(b.Paired, len(b.Paired) == 0), ID: b.ID, Owner: b.Owner, Kind: b.Kind, AddonID: b.AddonID, Name: b.Name, AgentID: b.AgentID, Enabled: b.Enabled,
AcceptAny: b.AcceptAny, Allow: allow, NotifyChat: b.NotifyChat, Reply: b.Reply, Conversation: b.Conversation, WindowMinutes: b.WindowMinutes,
FilesOff: b.FilesOff, ReplyLanguage: b.ReplyLanguage, Options: options, FileReceiveMax: b.FileReceiveMax, FileSendMax: b.FileSendMax,
FilesOff: b.FilesOff, ReplyLanguage: b.ReplyLanguage, UILanguage: b.UILanguage, Options: options, FileReceiveMax: b.FileReceiveMax, FileSendMax: b.FileSendMax,
Token: b.Token, Created: b.Created, Updated: b.Updated}).Error
}
+5
View File
@@ -157,6 +157,11 @@ type Binding struct {
// people in the chat: hostmsg.Auto (or empty) follows the language of the
// sender's message, any other value is a language of the catalog.
ReplyLanguage string
// UILanguage is the interface language of the owner, as the owner's page
// last sent it (a tag such as "it" or "pt-BR"), or empty when the server
// has not been told. With the reply language on auto, the host speaks in it
// before it tries to detect the language of the sender.
UILanguage string
// FilesOff keeps the files a turn makes from going to the sender. The
// zero value sends them, so a binding stored before the option sends
// them too.
@@ -8,6 +8,7 @@ import (
"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/hostmsg"
"github.com/mudler/LocalAI/core/services/studio/studiotest"
"github.com/mudler/LocalAI/core/services/studio/workspace"
"github.com/mudler/LocalAI/pkg/addons/contract"
@@ -72,14 +73,7 @@ var _ = Describe("the language of the host sentences in a chat", func() {
dm := func(text string) contract.ConnectorMessage {
return contract.ConnectorMessage{Chat: "11", Direct: true, Text: text}
}
// ask sends a direct message and returns what the bot wrote back last,
// after n messages have come.
ask := func(text string, n int) string {
ingest(dm(text))
waitFor("the answer", func() bool { return len(fake.texts("11", "")) >= n })
// The answer reaches the chat before the turn is marked finished, and a
// command that comes in that gap is refused as busy. Wait for the end of
// the turn, as a person does when they read the answer.
turnEnded := func() {
waitFor("the turn to end", func() bool {
chats, err := r.svc.List(ctx, alice, 50)
Expect(err).NotTo(HaveOccurred())
@@ -90,6 +84,16 @@ var _ = Describe("the language of the host sentences in a chat", func() {
}
return true
})
}
// ask sends a direct message and returns what the bot wrote back last,
// after n messages have come.
ask := func(text string, n int) string {
ingest(dm(text))
waitFor("the answer", func() bool { return len(fake.texts("11", "")) >= n })
// The answer reaches the chat before the turn is marked finished, and a
// command that comes in that gap is refused as busy. Wait for the end of
// the turn, as a person does when they read the answer.
turnEnded()
t := fake.texts("11", "")
return t[len(t)-1]
}
@@ -142,6 +146,55 @@ var _ = Describe("the language of the host sentences in a chat", func() {
Expect(ask("/new", 1)).To(HavePrefix("Nouvelle conversation démarrée."))
})
// refusalSentence sends a kind of message the host cannot read and returns
// the sentence it wrote for it. The refusal does not depend on a running
// turn, so it is never the "still answering" note.
refusalSentence := func() string {
before := len(fake.texts("11", ""))
ingest(contract.ConnectorMessage{Chat: "11", Direct: true, Unsupported: true})
waitFor("the refusal", func() bool { return len(fake.texts("11", "")) > before })
t := fake.texts("11", "")
return t[len(t)-1]
}
It("speaks the interface language the owner's page sent, before it reads the sender", func() {
setup(nil, nil)
saved, err := r.conns.UpdateConnector(ctx, alice, b.ID, studio.ConnectorInput{Kind: "chatty", Name: b.Name, AgentID: b.AgentID, Allow: []string{"11"}, Enabled: true, UILanguage: ptr("de-AT")})
Expect(err).NotTo(HaveOccurred())
Expect(saved.UILanguage).To(Equal("de-AT"))
ask("Ciao, puoi mandarmi il file di ieri?", 1)
Expect(refusalSentence()).To(Equal(hostmsg.Template("de", "unsupported")), "the owner's language comes before the detection")
})
It("keeps the stored interface language when a save sends none, and ignores a value that is no tag", func() {
setup(nil, nil)
update := func(lang *string) studio.ConnectorInfo {
got, err := r.conns.UpdateConnector(ctx, alice, b.ID, studio.ConnectorInput{Kind: "chatty", Name: b.Name, AgentID: b.AgentID, Allow: []string{"11"}, Enabled: true, UILanguage: lang})
Expect(err).NotTo(HaveOccurred())
return got
}
Expect(update(ptr("it")).UILanguage).To(Equal("it"))
Expect(update(nil).UILanguage).To(Equal("it"))
Expect(update(ptr("")).UILanguage).To(Equal("it"))
Expect(update(ptr("not a tag!")).UILanguage).To(Equal("it"))
Expect(update(ptr("fr")).UILanguage).To(Equal("fr"))
})
It("falls back to the detection when the owner's language is not one of the catalog or is unknown", func() {
setup(nil, nil)
_, err := r.conns.UpdateConnector(ctx, alice, b.ID, studio.ConnectorInput{Kind: "chatty", Name: b.Name, AgentID: b.AgentID, Allow: []string{"11"}, Enabled: true, UILanguage: ptr("ko")})
Expect(err).NotTo(HaveOccurred())
ask("Ciao, puoi mandarmi il file di ieri?", 1)
Expect(refusalSentence()).To(Equal(hostmsg.Template("it", "unsupported")))
})
It("lets a fixed reply language win over the interface language of the owner", func() {
setup(ptr("fr"), nil)
_, err := r.conns.UpdateConnector(ctx, alice, b.ID, studio.ConnectorInput{Kind: "chatty", Name: b.Name, AgentID: b.AgentID, Allow: []string{"11"}, Enabled: true, UILanguage: ptr("de")})
Expect(err).NotTo(HaveOccurred())
Expect(ask("/new", 1)).To(HavePrefix("Nouvelle conversation démarrée."))
})
It("asks and confirms a trigger in Italian, and the answers never reach the model", func() {
setup(ptr("it"), nil)
ingest(contract.ConnectorMessage{Chat: chatID, Thread: thread, Mention: true, Text: "propose"})
+10
View File
@@ -40,6 +40,11 @@ type ConnectorInput struct {
// the chat: hostmsg.Auto or a language code. Nil means auto for a new
// binding and unchanged for an update.
ReplyLanguage *string
// UILanguage is the interface language of the owner, sent by the page that
// saves the binding. Nil or empty leaves the stored value; a value that is
// not a language tag is ignored, because it is a hint and never a reason to
// refuse a save.
UILanguage *string
// SendFiles sends the files a turn makes to the sender. Nil means on for
// a new binding and unchanged for an update.
SendFiles *bool
@@ -85,6 +90,11 @@ func (in ConnectorInput) apply(b *connectors.Binding) {
b.ReplyLanguage = ""
}
}
if in.UILanguage != nil {
if tag := strings.TrimSpace(*in.UILanguage); tag != "" && contract.ValidLanguageTag(tag) {
b.UILanguage = tag
}
}
if in.SendFiles != nil {
b.FilesOff = !*in.SendFiles
}
+10 -4
View File
@@ -671,7 +671,7 @@ model answers in the language of the conversation, as it does without this setti
| Value | Language |
|---|---|
| `auto` (default) | The language of the sender, see below. |
| `auto` (default) | The interface language of the owner when the server knows it, else the language of the sender, see below. |
| `en` | English |
| `it` | Italian |
| `de` | German |
@@ -679,11 +679,17 @@ model answers in the language of the conversation, as it does without this setti
| `fr` | French |
| `pt-BR` | Portuguese (Brazil) |
With `auto`, LocalAI reads each message of the sender and scores it against the common
With `auto`, the bot first uses the interface language of the owner, when the catalog has it.
The page of the add-on sends that language with each save (`ui_language`, a tag such as `it`
or `pt-BR`) and the binding keeps it, so every frontend of a cluster knows it. A save that
sends none keeps the stored one, and a value that is not a language tag is ignored. When
the owner's language is not known, or the six languages above lack it (Korean, for
example), LocalAI reads each message of the sender and scores it against the common
words of the six languages. It needs no model and sends the text nowhere. A message that
does not show a language clearly (`ok`, `yes`, `thanks`, a number, an emoji) does not
change anything. The last clear language of a sender is kept in memory for 24 hours, and
a later short reply keeps it; a full sentence in another language moves it. A trigger
a later short reply keeps it; a full sentence in another language moves it. The reading
decides only when the owner's language is not known. A trigger
that writes to a chat on its own uses the language last seen in that chat. When nothing
is known, the host uses English. A fixed language ignores what the sender writes.
@@ -1201,7 +1207,7 @@ restart). The JSON store is for one process.
| Draft IDs | Per instance | A counter in the process that sends the replies gives each turn a new draft ID. Two frontends could give the same ID, but a draft only lives in one chat for one turn. |
| The chats of the answers (thread mode) | Shared | Each answer's message IDs are stored with the chat of each sender, so a reply finds its chat on any frontend. |
| Slack Socket Mode links and their queues | Per instance | The Slack add-on process keeps one link per polled binding, acknowledges each event at once and queues it until the next poll. An event that is queued when the process stops is lost. The followed threads and the "Working…" messages are kept there too. |
| The language of each sender | Per instance | With `reply_language` set to `auto`, the frontend that takes a message keeps the last clear language of the sender and of the chat in memory for 24 hours. A trigger that runs on another frontend does not know it and uses the interface language of the owner when the server has it, else English. A fixed language is stored with the binding and works everywhere. |
| The language of each sender | Per instance | With `reply_language` set to `auto`, the frontend that takes a message keeps the last clear language of the sender and of the chat in memory for 24 hours. A trigger that runs on another frontend does not know it and uses English. The interface language of the owner is stored with the binding, so it is the same on every frontend. A fixed language is stored with the binding and works everywhere. |
| Files marked by `attach_file` | Per instance | The marks live in the process that runs the turn and are sent by the process that sends the replies. A turn that ends on another frontend than the one that sends the answer loses its marks; the agent then sees no error, and the person asks again. |
### For add-on authors: sending files
+12
View File
@@ -20819,6 +20819,10 @@ const docTemplate = `{
"description": "SendFiles sends the files that a turn makes to the sender, after the\nanswer.",
"type": "boolean"
},
"ui_language": {
"description": "UILanguage is the interface language the owner's page last sent, such as\nit or pt-BR. With reply_language auto, the bot speaks in it before it\nreads the language of the sender. Absent when the server was not told.",
"type": "string"
},
"window_minutes": {
"type": "integer"
},
@@ -21966,6 +21970,10 @@ const docTemplate = `{
"description": "SendFiles sends the files that a turn makes to the sender, after the\nanswer.",
"type": "boolean"
},
"ui_language": {
"description": "UILanguage is the interface language the owner's page last sent, such as\nit or pt-BR. With reply_language auto, the bot speaks in it before it\nreads the language of the sender. Absent when the server was not told.",
"type": "string"
},
"window_minutes": {
"type": "integer"
},
@@ -22648,6 +22656,10 @@ const docTemplate = `{
"token": {
"type": "string"
},
"ui_language": {
"description": "UILanguage is the interface language of the person who saves the\nbinding (a language tag such as it or pt-BR). The page of the add-on\nsends it with each save. Absent or empty keeps the stored value; a value\nthat is not a language tag is ignored.",
"type": "string"
},
"window_minutes": {
"description": "WindowMinutes is the time window of the window mode, 1 to 10080.",
"type": "integer"
+12
View File
@@ -20816,6 +20816,10 @@
"description": "SendFiles sends the files that a turn makes to the sender, after the\nanswer.",
"type": "boolean"
},
"ui_language": {
"description": "UILanguage is the interface language the owner's page last sent, such as\nit or pt-BR. With reply_language auto, the bot speaks in it before it\nreads the language of the sender. Absent when the server was not told.",
"type": "string"
},
"window_minutes": {
"type": "integer"
},
@@ -21963,6 +21967,10 @@
"description": "SendFiles sends the files that a turn makes to the sender, after the\nanswer.",
"type": "boolean"
},
"ui_language": {
"description": "UILanguage is the interface language the owner's page last sent, such as\nit or pt-BR. With reply_language auto, the bot speaks in it before it\nreads the language of the sender. Absent when the server was not told.",
"type": "string"
},
"window_minutes": {
"type": "integer"
},
@@ -22645,6 +22653,10 @@
"token": {
"type": "string"
},
"ui_language": {
"description": "UILanguage is the interface language of the person who saves the\nbinding (a language tag such as it or pt-BR). The page of the add-on\nsends it with each save. Absent or empty keeps the stored value; a value\nthat is not a language tag is ignored.",
"type": "string"
},
"window_minutes": {
"description": "WindowMinutes is the time window of the window mode, 1 to 10080.",
"type": "integer"
+19
View File
@@ -3780,6 +3780,12 @@ definitions:
SendFiles sends the files that a turn makes to the sender, after the
answer.
type: boolean
ui_language:
description: |-
UILanguage is the interface language the owner's page last sent, such as
it or pt-BR. With reply_language auto, the bot speaks in it before it
reads the language of the sender. Absent when the server was not told.
type: string
window_minutes:
type: integer
write_mode:
@@ -4767,6 +4773,12 @@ definitions:
SendFiles sends the files that a turn makes to the sender, after the
answer.
type: boolean
ui_language:
description: |-
UILanguage is the interface language the owner's page last sent, such as
it or pt-BR. With reply_language auto, the bot speaks in it before it
reads the language of the sender. Absent when the server was not told.
type: string
window_minutes:
type: integer
write_mode:
@@ -5325,6 +5337,13 @@ definitions:
type: boolean
token:
type: string
ui_language:
description: |-
UILanguage is the interface language of the person who saves the
binding (a language tag such as it or pt-BR). The page of the add-on
sends it with each save. Absent or empty keeps the stored value; a value
that is not a language tag is ignored.
type: string
window_minutes:
description: WindowMinutes is the time window of the window mode, 1 to 10080.
type: integer