diff --git a/addons/internal/connector-ui/src/api.js b/addons/internal/connector-ui/src/api.js index f7db6b0870..e84830e51d 100644 --- a/addons/internal/connector-ui/src/api.js +++ b/addons/internal/connector-ui/src/api.js @@ -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`, {}), diff --git a/addons/internal/connector-ui/src/i18n/connector.de.json b/addons/internal/connector-ui/src/i18n/connector.de.json index 4ca8852cad..d0f5c6995e 100644 --- a/addons/internal/connector-ui/src/i18n/connector.de.json +++ b/addons/internal/connector-ui/src/i18n/connector.de.json @@ -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", diff --git a/addons/internal/connector-ui/src/i18n/connector.en.json b/addons/internal/connector-ui/src/i18n/connector.en.json index e55e20ac77..038dd6b243 100644 --- a/addons/internal/connector-ui/src/i18n/connector.en.json +++ b/addons/internal/connector-ui/src/i18n/connector.en.json @@ -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", diff --git a/addons/internal/connector-ui/src/i18n/connector.es.json b/addons/internal/connector-ui/src/i18n/connector.es.json index c2e089d66e..eaf189183f 100644 --- a/addons/internal/connector-ui/src/i18n/connector.es.json +++ b/addons/internal/connector-ui/src/i18n/connector.es.json @@ -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", diff --git a/addons/internal/connector-ui/src/i18n/connector.fr.json b/addons/internal/connector-ui/src/i18n/connector.fr.json index 5d0179309d..7fe557aaa8 100644 --- a/addons/internal/connector-ui/src/i18n/connector.fr.json +++ b/addons/internal/connector-ui/src/i18n/connector.fr.json @@ -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", diff --git a/addons/internal/connector-ui/src/i18n/connector.id.json b/addons/internal/connector-ui/src/i18n/connector.id.json index 664c54952e..770e8d5b36 100644 --- a/addons/internal/connector-ui/src/i18n/connector.id.json +++ b/addons/internal/connector-ui/src/i18n/connector.id.json @@ -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", diff --git a/addons/internal/connector-ui/src/i18n/connector.it.json b/addons/internal/connector-ui/src/i18n/connector.it.json index 3f3ebae1a3..f5501dbd6e 100644 --- a/addons/internal/connector-ui/src/i18n/connector.it.json +++ b/addons/internal/connector-ui/src/i18n/connector.it.json @@ -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", diff --git a/addons/internal/connector-ui/src/i18n/connector.ko.json b/addons/internal/connector-ui/src/i18n/connector.ko.json index 5a4216afdf..3ee4172749 100644 --- a/addons/internal/connector-ui/src/i18n/connector.ko.json +++ b/addons/internal/connector-ui/src/i18n/connector.ko.json @@ -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", diff --git a/addons/internal/connector-ui/src/i18n/connector.pt-BR.json b/addons/internal/connector-ui/src/i18n/connector.pt-BR.json index 6221ab2cf6..0fa29c00fb 100644 --- a/addons/internal/connector-ui/src/i18n/connector.pt-BR.json +++ b/addons/internal/connector-ui/src/i18n/connector.pt-BR.json @@ -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", diff --git a/addons/internal/connector-ui/src/i18n/connector.zh-CN.json b/addons/internal/connector-ui/src/i18n/connector.zh-CN.json index e4d2100e2a..783a8a3337 100644 --- a/addons/internal/connector-ui/src/i18n/connector.zh-CN.json +++ b/addons/internal/connector-ui/src/i18n/connector.zh-CN.json @@ -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", diff --git a/core/http/endpoints/studio/connectors.go b/core/http/endpoints/studio/connectors.go index dc812ebd81..c7604f0017 100644 --- a/core/http/endpoints/studio/connectors.go +++ b/core/http/endpoints/studio/connectors.go @@ -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)} } diff --git a/core/http/react-ui/e2e/connector-permissions.spec.js b/core/http/react-ui/e2e/connector-permissions.spec.js index 1b950ea26e..c382273592 100644 --- a/core/http/react-ui/e2e/connector-permissions.spec.js +++ b/core/http/react-ui/e2e/connector-permissions.spec.js @@ -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 }) => { diff --git a/core/schema/chats.go b/core/schema/chats.go index 2feadfbf5a..cc8549e921 100644 --- a/core/schema/chats.go +++ b/core/schema/chats.go @@ -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"` diff --git a/core/services/studio/connectorlang.go b/core/services/studio/connectorlang.go index 241d618615..ac668ed34a 100644 --- a/core/services/studio/connectorlang.go +++ b/core/services/studio/connectorlang.go @@ -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)} diff --git a/core/services/studio/connectors.go b/core/services/studio/connectors.go index d55a625e9c..c0d406ae83 100644 --- a/core/services/studio/connectors.go +++ b/core/services/studio/connectors.go @@ -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. diff --git a/core/services/studio/connectors/connectorstest/suite.go b/core/services/studio/connectors/connectorstest/suite.go index 045ffbf81b..497b93b018 100644 --- a/core/services/studio/connectors/connectorstest/suite.go +++ b/core/services/studio/connectors/connectorstest/suite.go @@ -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"} { diff --git a/core/services/studio/connectors/gorm.go b/core/services/studio/connectors/gorm.go index a8427f36fb..c558b5eb4c 100644 --- a/core/services/studio/connectors/gorm.go +++ b/core/services/studio/connectors/gorm.go @@ -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 } diff --git a/core/services/studio/connectors/types.go b/core/services/studio/connectors/types.go index b6f3921422..965c4f069a 100644 --- a/core/services/studio/connectors/types.go +++ b/core/services/studio/connectors/types.go @@ -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. diff --git a/core/services/studio/connectors_language_test.go b/core/services/studio/connectors_language_test.go index 8785e39149..c323a9266e 100644 --- a/core/services/studio/connectors_language_test.go +++ b/core/services/studio/connectors_language_test.go @@ -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"}) diff --git a/core/services/studio/connectorsvc.go b/core/services/studio/connectorsvc.go index 6c36e9efb9..4fe79a6ab3 100644 --- a/core/services/studio/connectorsvc.go +++ b/core/services/studio/connectorsvc.go @@ -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 } diff --git a/docs/content/features/chats-connectors.md b/docs/content/features/chats-connectors.md index e10c2e8163..1d267a27e1 100644 --- a/docs/content/features/chats-connectors.md +++ b/docs/content/features/chats-connectors.md @@ -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 diff --git a/swagger/docs.go b/swagger/docs.go index 6aea3dcd3f..429ffda99a 100644 --- a/swagger/docs.go +++ b/swagger/docs.go @@ -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" diff --git a/swagger/swagger.json b/swagger/swagger.json index 92c8c1dbea..2dd3baffc7 100644 --- a/swagger/swagger.json +++ b/swagger/swagger.json @@ -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" diff --git a/swagger/swagger.yaml b/swagger/swagger.yaml index 1f17000839..2f2c7bea31 100644 --- a/swagger/swagger.yaml +++ b/swagger/swagger.yaml @@ -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