diff --git a/.agents/coding-style.md b/.agents/coding-style.md index 75ab026c80..839621e543 100644 --- a/.agents/coding-style.md +++ b/.agents/coding-style.md @@ -50,6 +50,16 @@ Do not mix styles within a package. If you are extending tests in a package that This is enforced by `golangci-lint` via the `forbidigo` linter (see `.golangci.yml`); calls like `t.Errorf` / `t.Fatalf` / `t.Run` / `t.Skip` / `t.Logf` are flagged. Run `make lint` locally before submitting; the same check runs in CI (`.github/workflows/lint.yml`). +## Sentences the host writes to a chat + +A sentence that the host (not the model) writes to a person in a messaging chat is a key in +`core/services/studio/hostmsg/locales/*.json`, with a template for each of the six languages +(`en`, `it`, `de`, `es`, `fr`, `pt-BR`), and is written with `msg` or a `hostmsg.Voice` in +`core/services/studio`. Do not pass a fixed English string to `say`, `Post` or `Outgoing`: +`hostmsg_guard_test.go` fails. The catalog test checks that every key has every language and +the same `{placeholders}`. Text for the model (tool descriptions, notes of a turn) stays English +and is not in the catalog. + ## Outbound HTTP All outbound HTTP must go through `github.com/mudler/LocalAI/pkg/httpclient` rather than the standard library's default client. Use `httpclient.New(...)` (no body deadline — safe for streaming/SSE) or `httpclient.NewWithTimeout(d, ...)` (simple request/response). Both **refuse redirects by default** and set a TLS 1.2 floor. diff --git a/addons/internal/connector-ui/src/AnswerFields.jsx b/addons/internal/connector-ui/src/AnswerFields.jsx index 04776f5012..c6e373b378 100644 --- a/addons/internal/connector-ui/src/AnswerFields.jsx +++ b/addons/internal/connector-ui/src/AnswerFields.jsx @@ -5,7 +5,7 @@ // live here as defaults; an add-on can still change one in its namespace. import { Field, Hint, Select, Switch, TextInput } from './controls' import EN from './i18n/connector.en.json' -import { CONVERSATIONS, MAX_WINDOW_MINUTES, REPLY_MODES } from './connector' +import { CONVERSATIONS, MAX_WINDOW_MINUTES, REPLY_LANGUAGES, REPLY_MODES } from './connector' // text reads a string of the add-on's namespace, with the English default of // the shared bundle (i18n/connector.en.json). The translations of that bundle @@ -56,6 +56,14 @@ export default function AnswerFields({ form, setForm, t, kindInfo }) { )} )} + + {(p) => ( + + )} +
setForm({ ...form, send_files: on })}> {s('answer.files')} diff --git a/addons/internal/connector-ui/src/connector.js b/addons/internal/connector-ui/src/connector.js index 09fbc48757..fca91ee171 100644 --- a/addons/internal/connector-ui/src/connector.js +++ b/addons/internal/connector-ui/src/connector.js @@ -4,6 +4,8 @@ // The modes a binding answers with. The server has the same defaults. export const REPLY_MODES = ['off', 'progress', 'stream'] export const CONVERSATIONS = ['sender', 'chat', 'window', 'thread'] +// The language of the sentences the bot writes itself (auto follows the sender). +export const REPLY_LANGUAGES = ['auto', 'en', 'it', 'de', 'es', 'fr', 'pt-BR'] export const MAX_WINDOW_MINUTES = 7 * 24 * 60 // How the triggers that the agent proposes in a chat are approved, and the @@ -215,7 +217,7 @@ export function channelsProblem(list) { export function emptyConnector() { return { id: '', name: '', token: '', agent_id: '', enabled: true, accept_any: false, allow: [], notify_chat: '', - reply_mode: 'progress', conversation: 'sender', window_minutes: 60, send_files: true, + reply_mode: 'progress', conversation: 'sender', window_minutes: 60, send_files: true, reply_language: 'auto', options: {}, file_receive_mib: '', file_send_mib: '', approval: emptyApproval(), @@ -303,6 +305,7 @@ export function connectorPayload(form, kind, token = form.token, extra = {}) { reply_mode: form.reply_mode || 'progress', conversation: form.conversation || 'sender', send_files: form.send_files !== false, + reply_language: REPLY_LANGUAGES.includes(form.reply_language) ? form.reply_language : 'auto', // The choices for the options of the kind; a missing one has its default. options: Object.fromEntries(Object.entries(form.options || {}).filter(([, v]) => v)), file_receive_max_bytes: mibToBytes(form.file_receive_mib), diff --git a/addons/internal/connector-ui/src/i18n/connector.de.json b/addons/internal/connector-ui/src/i18n/connector.de.json index 2f34549adb..4ca8852cad 100644 --- a/addons/internal/connector-ui/src/i18n/connector.de.json +++ b/addons/internal/connector-ui/src/i18n/connector.de.json @@ -13,6 +13,15 @@ "answer.conversation.thread": "Neuer Chat pro Nachricht; Antworten laufen weiter", "answer.window": "Pause in Minuten", "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.en": "English", + "answer.language.it": "Italiano", + "answer.language.de": "Deutsch", + "answer.language.es": "Español", + "answer.language.fr": "Français", + "answer.language.pt-BR": "Português (Brasil)", "answer.files": "Dateien senden, die der Agent erstellt", "aside.title": "So funktioniert es", "aside.step1": "Gib der Verbindung einen Namen und ihr Token. Das Token wird versiegelt und nie wieder angezeigt.", diff --git a/addons/internal/connector-ui/src/i18n/connector.en.json b/addons/internal/connector-ui/src/i18n/connector.en.json index 2b5e81111d..e55e20ac77 100644 --- a/addons/internal/connector-ui/src/i18n/connector.en.json +++ b/addons/internal/connector-ui/src/i18n/connector.en.json @@ -13,6 +13,15 @@ "answer.conversation.thread": "New chat per message; replies go on", "answer.window": "Quiet time, in minutes", "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.en": "English", + "answer.language.it": "Italiano", + "answer.language.de": "Deutsch", + "answer.language.es": "Español", + "answer.language.fr": "Français", + "answer.language.pt-BR": "Português (Brasil)", "answer.files": "Send the files the agent makes", "aside.title": "How it works", "aside.step1": "Give the connection a name and its token. The token is sealed and never shown again.", diff --git a/addons/internal/connector-ui/src/i18n/connector.es.json b/addons/internal/connector-ui/src/i18n/connector.es.json index 1b6f81b2ac..c2e089d66e 100644 --- a/addons/internal/connector-ui/src/i18n/connector.es.json +++ b/addons/internal/connector-ui/src/i18n/connector.es.json @@ -13,6 +13,15 @@ "answer.conversation.thread": "Un chat nuevo por mensaje; las respuestas siguen en él", "answer.window": "Tiempo de silencio, en minutos", "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.en": "English", + "answer.language.it": "Italiano", + "answer.language.de": "Deutsch", + "answer.language.es": "Español", + "answer.language.fr": "Français", + "answer.language.pt-BR": "Português (Brasil)", "answer.files": "Enviar los archivos que crea el agente", "aside.title": "Cómo funciona", "aside.step1": "Dale un nombre a la conexión y su token. El token se guarda cifrado y no se vuelve a mostrar.", diff --git a/addons/internal/connector-ui/src/i18n/connector.fr.json b/addons/internal/connector-ui/src/i18n/connector.fr.json index 27104910fe..5d0179309d 100644 --- a/addons/internal/connector-ui/src/i18n/connector.fr.json +++ b/addons/internal/connector-ui/src/i18n/connector.fr.json @@ -13,6 +13,15 @@ "answer.conversation.thread": "Nouveau chat à chaque message ; les réponses suivent", "answer.window": "Temps d’inactivité, en minutes", "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.en": "English", + "answer.language.it": "Italiano", + "answer.language.de": "Deutsch", + "answer.language.es": "Español", + "answer.language.fr": "Français", + "answer.language.pt-BR": "Português (Brasil)", "answer.files": "Envoyer les fichiers créés par l’agent", "aside.title": "Comment ça marche", "aside.step1": "Donnez un nom à la connexion et saisissez son jeton. Le jeton est scellé et n’est plus jamais affiché.", diff --git a/addons/internal/connector-ui/src/i18n/connector.id.json b/addons/internal/connector-ui/src/i18n/connector.id.json index b369032dd9..664c54952e 100644 --- a/addons/internal/connector-ui/src/i18n/connector.id.json +++ b/addons/internal/connector-ui/src/i18n/connector.id.json @@ -13,6 +13,15 @@ "answer.conversation.thread": "Chat baru per pesan; balasan dilanjutkan", "answer.window": "Jeda tanpa pesan, dalam menit", "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.en": "English", + "answer.language.it": "Italiano", + "answer.language.de": "Deutsch", + "answer.language.es": "Español", + "answer.language.fr": "Français", + "answer.language.pt-BR": "Português (Brasil)", "answer.files": "Kirim file yang dibuat agen", "aside.title": "Cara kerja", "aside.step1": "Beri nama koneksi dan masukkan token-nya. Token disimpan terenkripsi dan tidak pernah ditampilkan lagi.", diff --git a/addons/internal/connector-ui/src/i18n/connector.it.json b/addons/internal/connector-ui/src/i18n/connector.it.json index 9c37640904..3f3ebae1a3 100644 --- a/addons/internal/connector-ui/src/i18n/connector.it.json +++ b/addons/internal/connector-ui/src/i18n/connector.it.json @@ -13,6 +13,15 @@ "answer.conversation.thread": "Nuova chat per messaggio; le risposte proseguono", "answer.window": "Pausa, in minuti", "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.en": "English", + "answer.language.it": "Italiano", + "answer.language.de": "Deutsch", + "answer.language.es": "Español", + "answer.language.fr": "Français", + "answer.language.pt-BR": "Português (Brasil)", "answer.files": "Invia i file creati dall'agente", "aside.title": "Come funziona", "aside.step1": "Dai un nome alla connessione e inserisci il suo token. Il token viene cifrato e non viene più mostrato.", diff --git a/addons/internal/connector-ui/src/i18n/connector.ko.json b/addons/internal/connector-ui/src/i18n/connector.ko.json index e0f20944e0..5a4216afdf 100644 --- a/addons/internal/connector-ui/src/i18n/connector.ko.json +++ b/addons/internal/connector-ui/src/i18n/connector.ko.json @@ -13,6 +13,15 @@ "answer.conversation.thread": "메시지마다 새 채팅, 답글은 이어짐", "answer.window": "조용한 시간(분)", "answer.windowRange": "1부터 {{max}}까지의 정수를 입력하세요.", + "answer.languageLabel": "답변 언어", + "answer.languageHelp": "확인이나 거절처럼 봇이 직접 쓰는 안내문입니다. 모델의 답변은 대화의 언어를 따릅니다.", + "answer.language.auto": "자동 (보낸 사람의 언어)", + "answer.language.en": "English", + "answer.language.it": "Italiano", + "answer.language.de": "Deutsch", + "answer.language.es": "Español", + "answer.language.fr": "Français", + "answer.language.pt-BR": "Português (Brasil)", "answer.files": "에이전트가 만든 파일 보내기", "aside.title": "작동 방식", "aside.step1": "연결의 이름과 토큰을 입력하세요. 토큰은 봉인되어 다시 표시되지 않습니다.", 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 d55b270a42..6221ab2cf6 100644 --- a/addons/internal/connector-ui/src/i18n/connector.pt-BR.json +++ b/addons/internal/connector-ui/src/i18n/connector.pt-BR.json @@ -13,6 +13,15 @@ "answer.conversation.thread": "Novo chat por mensagem; as respostas continuam", "answer.window": "Pausa, em minutos", "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.en": "English", + "answer.language.it": "Italiano", + "answer.language.de": "Deutsch", + "answer.language.es": "Español", + "answer.language.fr": "Français", + "answer.language.pt-BR": "Português (Brasil)", "answer.files": "Enviar os arquivos que o agente cria", "aside.title": "Como funciona", "aside.step1": "Dê um nome à conexão e informe o token. O token é guardado com segurança e nunca mais é mostrado.", 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 c9723abccc..e4d2100e2a 100644 --- a/addons/internal/connector-ui/src/i18n/connector.zh-CN.json +++ b/addons/internal/connector-ui/src/i18n/connector.zh-CN.json @@ -13,6 +13,15 @@ "answer.conversation.thread": "每条消息一个新对话,回复接着聊", "answer.window": "安静时间(分钟)", "answer.windowRange": "请输入 1 到 {{max}} 的整数。", + "answer.languageLabel": "回复语言", + "answer.languageHelp": "机器人自己撰写的提示,例如确认和拒绝。模型的回答则使用对话所用的语言。", + "answer.language.auto": "自动(发送者使用的语言)", + "answer.language.en": "English", + "answer.language.it": "Italiano", + "answer.language.de": "Deutsch", + "answer.language.es": "Español", + "answer.language.fr": "Français", + "answer.language.pt-BR": "Português (Brasil)", "answer.files": "发送智能体生成的文件", "aside.title": "工作原理", "aside.step1": "给连接起个名字,并填写它的 token。token 会被加密保存,之后不再显示。", diff --git a/addons/telegram/ui/test/connector.test.mjs b/addons/telegram/ui/test/connector.test.mjs index 6c5ee70678..a4e34f62e8 100644 --- a/addons/telegram/ui/test/connector.test.mjs +++ b/addons/telegram/ui/test/connector.test.mjs @@ -1,6 +1,7 @@ +import { readFileSync } from 'node:fs' import test from 'node:test' import assert from 'node:assert/strict' -import { approvalForm, approvalPayload, approvalProblem, bytesToMib, canSave, connectorForm, connectorPayload, emptyConnector, fileDirection, fileLimitProblem, formatBytes, mibToBytes, modeText, previewLimit, senderLine } from '../../../internal/connector-ui/src/connector.js' +import { approvalForm, approvalPayload, approvalProblem, bytesToMib, canSave, connectorForm, connectorPayload, emptyConnector, REPLY_LANGUAGES, fileDirection, fileLimitProblem, formatBytes, mibToBytes, modeText, previewLimit, senderLine } from '../../../internal/connector-ui/src/connector.js' test('chat connectors: the token goes only when it was typed', () => { const create = connectorPayload({ ...emptyConnector(), name: ' Bot ', token: ' 1:abc ', agent_id: 'a1', allow: ['11', ' 11', '@ada_l', ''] }, 'telegram') @@ -184,3 +185,30 @@ test('chat connectors: approval acts replace the whole object, so a mode change assert.equal(approvalProblem({ mode: 'ask', acts: ['act.send'] }), '') assert.equal(approvalProblem({ mode: 'ask', acts: ['act.bogus'] }), 'acts') }) + +test('chat connectors: a new binding follows the language of the sender', () => { + assert.equal(emptyConnector().reply_language, 'auto') + assert.deepEqual(REPLY_LANGUAGES, ['auto', 'en', 'it', 'de', 'es', 'fr', 'pt-BR']) +}) + +test('chat connectors: the request carries the reply language, and an unknown one becomes auto', () => { + const base = { ...emptyConnector(), name: 'B', agent_id: 'a' } + assert.equal(connectorPayload(base, 'telegram').reply_language, 'auto') + for (const l of REPLY_LANGUAGES) assert.equal(connectorPayload({ ...base, reply_language: l }, 'telegram').reply_language, l) + assert.equal(connectorPayload({ ...base, reply_language: 'klingon' }, 'telegram').reply_language, 'auto') + assert.equal(connectorPayload({ ...base, reply_language: undefined }, 'telegram').reply_language, 'auto') +}) + +test('chat connectors: the form of a saved binding keeps its reply language, and an older one reads as auto', () => { + assert.equal(connectorForm({ id: 'b1', name: 'B', agent_id: 'a', reply_language: 'de' }).reply_language, 'de') + assert.equal(connectorForm({ id: 'b1', name: 'B', agent_id: 'a' }).reply_language, 'auto') +}) + +test('chat connectors: the shared words label every reply language and say what the setting changes', () => { + const en = JSON.parse(readFileSync(new URL('../../../internal/connector-ui/src/i18n/connector.en.json', import.meta.url), 'utf8')) + for (const l of REPLY_LANGUAGES) assert.ok(en[`answer.language.${l}`], `no label for ${l}`) + assert.equal(en['answer.languageLabel'], 'Reply language') + assert.equal(en['answer.languageHelp'], 'The notes the bot writes itself, like confirmations and refusals. The answers of the model follow the language of the conversation.') + const src = readFileSync(new URL('../../../internal/connector-ui/src/AnswerFields.jsx', import.meta.url), 'utf8') + assert.ok(src.includes('data-testid="connector-reply-language"')) +}) diff --git a/core/http/endpoints/studio/connectors.go b/core/http/endpoints/studio/connectors.go index 935ac31f39..dcc950c4bc 100644 --- a/core/http/endpoints/studio/connectors.go +++ b/core/http/endpoints/studio/connectors.go @@ -25,7 +25,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(), Options: i.Options, + ReplyMode: i.ReplyMode(), Conversation: i.ConversationMode(), WindowMinutes: i.WindowMinutes, SendFiles: i.SendsFiles(), ReplyLanguage: i.ReplyLang(), 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)} } @@ -129,7 +129,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, Options: req.Options, + Reply: req.ReplyMode, Conversation: req.Conversation, WindowMinutes: req.WindowMinutes, SendFiles: req.SendFiles, ReplyLanguage: req.ReplyLanguage, 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)} } @@ -206,7 +206,7 @@ func (h *Handlers) ConnectorKinds(c echo.Context) error { // CreateConnector creates a binding. // @Summary Create a connector binding. -// @Description Needs the kind (the platform), the bot token (kept sealed, never returned) and an agent of yours that names a model. The token is checked with the platform when its add-on runs: a token that the platform refuses is a 400. reply_mode (off, progress, stream; default progress), conversation (sender, chat, window with window_minutes, thread; default sender) and send_files (default true) say how the bot answers. The allowlist is empty at first, so nobody is accepted: add chat IDs, user IDs or @usernames, or set accept_any (anyone who can message the bot then uses the agent's tools; the request that turns it on must also carry confirm_accept_any set to "accept any message", or it is a 400). At most 10 bindings. +// @Description Needs the kind (the platform), the bot token (kept sealed, never returned) and an agent of yours that names a model. The token is checked with the platform when its add-on runs: a token that the platform refuses is a 400. reply_mode (off, progress, stream; default progress), conversation (sender, chat, window with window_minutes, thread; default sender), send_files (default true) and reply_language (auto, en, it, de, es, fr, pt-BR; default auto, the language of the sentences the bot writes itself) say how the bot answers. The allowlist is empty at first, so nobody is accepted: add chat IDs, user IDs or @usernames, or set accept_any (anyone who can message the bot then uses the agent's tools; the request that turns it on must also carry confirm_accept_any set to "accept any message", or it is a 400). At most 10 bindings. // @Tags chats // @Param request body schema.ChatsConnectorWrite true "the binding" // @Success 201 {object} schema.ChatsConnector "The binding" @@ -251,7 +251,7 @@ func (h *Handlers) GetConnector(c echo.Context) error { // UpdateConnector replaces a binding. // @Summary Replace a connector binding. -// @Description The body replaces every field except the token, which is kept unless the request has a new one, and send_files, which is kept when it is absent. A new token is checked with the platform: a token that the platform refuses is a 400 and nothing is saved. +// @Description The body replaces every field except the token, which is kept unless the request has a new one, and send_files and reply_language, which are kept when absent. A new token is checked with the platform: a token that the platform refuses is a 400 and nothing is saved. // @Tags chats // @Param id path string true "the binding's id" // @Param request body schema.ChatsConnectorWrite true "the binding" diff --git a/core/http/react-ui/e2e/telegram-addon.spec.js b/core/http/react-ui/e2e/telegram-addon.spec.js index a42ea143d6..6196138212 100644 --- a/core/http/react-ui/e2e/telegram-addon.spec.js +++ b/core/http/react-ui/e2e/telegram-addon.spec.js @@ -123,6 +123,9 @@ test.describe('Telegram add-on', () => { 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) + // The notes the bot writes itself follow the sender unless a language is chosen. + await expect(page.getByTestId('connector-reply-language')).toHaveValue('auto') + await expect(page.getByText('The notes the bot writes itself, like confirmations and refusals.')).toBeVisible() await page.getByLabel('Name', { exact: true }).fill('Live bot') await page.getByLabel('Bot token').fill('555:secret-token') await page.getByLabel('Agent', { exact: true }).selectOption('a1') @@ -130,11 +133,12 @@ test.describe('Telegram add-on', () => { await page.getByTestId('connector-conversation').selectOption('window') await page.getByTestId('connector-window').fill('45') await page.getByTestId('connector-files').uncheck() + await page.getByTestId('connector-reply-language').selectOption('it') await page.getByRole('button', { name: 'Save' }).click() await expect(page.getByTestId('connector-answer-summary')).toHaveText('Answers: live answer · new chat after 45 min') const post = a.s.calls.find(c => c.method === 'POST' && c.url === '/api/chats/connectors').body - expect(post).toMatchObject({ reply_mode: 'stream', conversation: 'window', window_minutes: 45, send_files: false }) + expect(post).toMatchObject({ reply_mode: 'stream', conversation: 'window', window_minutes: 45, send_files: false, reply_language: 'it' }) expect(a.s.items[0]).toMatchObject({ reply_mode: 'stream', conversation: 'window', window_minutes: 45, send_files: false }) // Edit: the form shows what the API holds; the thread mode drops the window. @@ -143,13 +147,15 @@ test.describe('Telegram add-on', () => { await expect(page.getByTestId('connector-conversation')).toHaveValue('window') await expect(page.getByTestId('connector-window')).toHaveValue('45') await expect(page.getByTestId('connector-files')).not.toBeChecked() + await expect(page.getByTestId('connector-reply-language')).toHaveValue('it') + await page.getByTestId('connector-reply-language').selectOption('pt-BR') await page.getByTestId('connector-reply').selectOption('off') await page.getByTestId('connector-conversation').selectOption('thread') await page.getByTestId('connector-files').check() await page.getByRole('button', { name: 'Save' }).click() await expect(page.getByTestId('connector-answer-summary')).toHaveText('Answers: typing · a chat per message') const put = a.s.calls.filter(c => c.method === 'PUT').at(-1).body - expect(put).toMatchObject({ reply_mode: 'off', conversation: 'thread', send_files: true }) + expect(put).toMatchObject({ reply_mode: 'off', conversation: 'thread', send_files: true, reply_language: 'pt-BR' }) expect(put).not.toHaveProperty('window_minutes') expect(a.s.items[0]).toMatchObject({ reply_mode: 'off', conversation: 'thread', send_files: true }) }) diff --git a/core/http/routes/studio_connectors_test.go b/core/http/routes/studio_connectors_test.go index 20b61e0f43..0f522133d2 100644 --- a/core/http/routes/studio_connectors_test.go +++ b/core/http/routes/studio_connectors_test.go @@ -264,6 +264,31 @@ var _ = Describe("Chats connectors endpoints", func() { } }) + It("stores the reply language, keeps it when an update leaves it out, and refuses an unknown one", func() { + ag := agent("alice", "Language", "test-model") + rec := create("alice", map[string]any{"name": "Language bot", "token": specToken, "agent_id": ag}) + Expect(rec.Code).To(Equal(http.StatusCreated), rec.Body.String()) + Expect(decodeJSON(rec)).To(HaveKeyWithValue("reply_language", "auto"), "the default follows the sender") + id := decodeJSON(rec)["id"].(string) + rec = s.do("alice", http.MethodPut, "/api/chats/connectors/"+id, map[string]any{"name": "Language bot", "agent_id": ag, "reply_language": "it"}) + Expect(rec.Code).To(Equal(http.StatusOK), rec.Body.String()) + rec = s.do("alice", http.MethodGet, "/api/chats/connectors/"+id, nil) + Expect(decodeJSON(rec)).To(HaveKeyWithValue("reply_language", "it")) + rec = s.do("alice", http.MethodPut, "/api/chats/connectors/"+id, map[string]any{"name": "Language bot", "agent_id": ag}) + Expect(rec.Code).To(Equal(http.StatusOK), rec.Body.String()) + Expect(decodeJSON(rec)).To(HaveKeyWithValue("reply_language", "it"), "an update without the field keeps it") + rec = s.do("alice", http.MethodPut, "/api/chats/connectors/"+id, map[string]any{"name": "Language bot", "agent_id": ag, "reply_language": "pt-BR"}) + Expect(rec.Code).To(Equal(http.StatusOK), rec.Body.String()) + Expect(decodeJSON(rec)).To(HaveKeyWithValue("reply_language", "pt-BR")) + rec = s.do("alice", http.MethodPut, "/api/chats/connectors/"+id, map[string]any{"name": "Language bot", "agent_id": ag, "reply_language": "auto"}) + Expect(rec.Code).To(Equal(http.StatusOK), rec.Body.String()) + Expect(decodeJSON(rec)).To(HaveKeyWithValue("reply_language", "auto")) + for _, bad := range []string{"klingon", "pt", "IT", "it-IT"} { + rec = s.do("alice", http.MethodPut, "/api/chats/connectors/"+id, map[string]any{"name": "Language bot", "agent_id": ag, "reply_language": bad}) + Expect(rec.Code).To(Equal(http.StatusBadRequest), "%q: %s", bad, rec.Body.String()) + } + }) + It("checks a new token with the platform and refuses one that the platform refuses", func() { studioVerifyTokens = true DeferCleanup(func() { studioVerifyTokens = false }) diff --git a/core/schema/chats.go b/core/schema/chats.go index 74e178bfa0..fa8361cb59 100644 --- a/core/schema/chats.go +++ b/core/schema/chats.go @@ -1307,6 +1307,11 @@ type ChatsConnector struct { // SendFiles sends the files that a turn makes to the sender, after the // answer. SendFiles bool `json:"send_files"` + // ReplyLanguage is the language of the sentences the bot writes itself in + // the chat (confirmations, refusals, notes about files): auto (the + // 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"` // 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"` @@ -1619,6 +1624,9 @@ type ChatsConnectorWrite struct { Conversation string `json:"conversation,omitempty"` // WindowMinutes is the time window of the window mode, 1 to 10080. WindowMinutes int `json:"window_minutes,omitempty"` + // 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"` // 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/connectorchange.go b/core/services/studio/connectorchange.go index 797b78459d..77ead6daa2 100644 --- a/core/services/studio/connectorchange.go +++ b/core/services/studio/connectorchange.go @@ -11,6 +11,7 @@ import ( "github.com/mudler/LocalAI/core/services/studio/builtins" "github.com/mudler/LocalAI/core/services/studio/connectors" + "github.com/mudler/LocalAI/core/services/studio/hostmsg" "github.com/mudler/LocalAI/core/services/studio/proposals" "github.com/mudler/LocalAI/core/services/studio/session" "github.com/mudler/LocalAI/core/services/studio/triggers" @@ -22,18 +23,7 @@ import ( // answers it there with a short word, exactly as for a trigger. The host reads // the answer before any model turn, so the text never reaches the model. -// Sentences the bot says about a change proposal. const ( - sentChangeNoRight = "Only the owner can confirm this." - sentChangeNotYou = "Only the person who asked can confirm this." - sentChangeNothing = "There is nothing here to undo." - sentChangeCannot = "I could not undo it: %s" - - // undoWord is the one word that takes back a change that was just applied - // from the chat. A bare "no" or "cancel" after a save does not: it may - // mean anything. - undoWord = "undo" - // changeExcerptRunes bounds the text of a proposal that is written to a chat. changeExcerptRunes = 500 // changeExcerptLines bounds the lines of it. @@ -52,31 +42,31 @@ func ChangeCode(id string) string { } // kindWords says what a kind of change is, for a person who reads it in a chat. -func kindWords(k contract.ChangeKind) string { +func kindWords(v hostmsg.Voice, k contract.ChangeKind) string { switch k { case contract.ChangeMemory: - return "a note in the memory" + return v.T("change.kind.memory") case contract.ChangeProfile: - return "a change of the rules of this agent" + return v.T("change.kind.profile") case contract.ChangeSkill: - return "a skill" + return v.T("change.kind.skill") case contract.ChangePage: - return "a page" + return v.T("change.kind.page") case contract.ChangeDashboard: - return "a change of the Home dashboard" + return v.T("change.kind.dashboard") } - return "a change" + return v.T("change.kind.other") } // scopeWords says where a change applies. -func scopeWords(s contract.ChangeScope) string { +func scopeWords(v hostmsg.Voice, s contract.ChangeScope) string { switch s { case contract.ScopeAgent: - return "this agent only" + return v.T("change.scope.agent") case contract.ScopeChannel: - return "this conversation only" + return v.T("change.scope.channel") } - return "every chat of the owner" + return v.T("change.scope.all") } // addedLines returns the non-empty lines of next that cur does not have, in @@ -114,30 +104,29 @@ func excerptOf(lines []string) string { // proposalChatText is what the host writes in the chat where a change was // proposed: what will be saved, and how to answer. The model's words are // quoted, never trusted: mentions that notify everyone are broken. -func (h *Changes) proposalChatText(ctx context.Context, p contract.ChangeProposal) string { +func (h *Changes) proposalChatText(ctx context.Context, v hostmsg.Voice, p contract.ChangeProposal) string { var shown string if kind, ok := h.kinds.Lookup(string(p.Kind)); ok { switch p.Kind { case contract.ChangePage: - shown = "a page with its files" + shown = v.T("change.page_files") default: if st, err := h.snapshot(ctx, p.Owner, kind, p.Target, p.Content); err == nil { if add := addedLines(st.current, p.Content); len(add) > 0 { shown = excerptOf(add) } else { - shown = "(it only removes lines)" + shown = v.T("change.only_removes") } } } } var b strings.Builder - fmt.Fprintf(&b, "I can save %s (%s): %s", kindWords(p.Kind), scopeWords(p.Scope), p.Summary) + b.WriteString(v.T("change.propose", "kind", kindWords(v, p.Kind), "scope", scopeWords(v, p.Scope), "summary", p.Summary)) if shown != "" { b.WriteString("\n" + shown) } code := ChangeCode(p.ID) - fmt.Fprintf(&b, "\nNothing is saved until you answer. Reply \"yes\" to save it or \"no\" to drop it, here, within 24 hours. "+ - "If more than one waits, reply \"confirm %s\" or \"cancel %s\".", code, code) + b.WriteString("\n" + v.T("change.ask", "code", code)) return neutralMentions.ReplaceAllString(b.String(), "${1}​${2}") } @@ -189,7 +178,8 @@ func (h *Changes) connectorProposed(ctx context.Context, c builtins.Chat, p cont chat, thread, ok = conns.PlaceOf(ctx, c.OriginID, c.Actor.ChatID) } if ok { - if _, err := conns.Post(ctx, Post{Binding: c.OriginID, Chat: chat, Thread: thread, Text: h.proposalChatText(ctx, p)}); err == nil { + v := conns.voiceOf(ctx, c.OriginID, p.Source.Sender, chat) + if _, err := conns.Post(ctx, Post{Binding: c.OriginID, Chat: chat, Thread: thread, Text: h.proposalChatText(ctx, v, p)}); err == nil { return head + " The host wrote the proposal in this chat, with what will be saved; the person answers there with yes or no, and it expires in 24 hours. " + "Say in one short sentence that you asked; do not repeat the text and do not say it is saved." + autoNote } else { @@ -235,9 +225,9 @@ func (c *Connectors) mayAnswerChange(ctx context.Context, b connectors.Binding, // answerChange confirms or drops the waiting proposal p for the sender of m. A // sender who may not gets one short sentence; the proposal keeps waiting. -func (c *Connectors) answerChange(ctx context.Context, b connectors.Binding, m contract.ConnectorMessage, p contract.ChangeProposal, verb string, bare bool) (string, bool) { +func (c *Connectors) answerChange(ctx context.Context, b connectors.Binding, m contract.ConnectorMessage, p contract.ChangeProposal, verb string, bare bool, v hostmsg.Voice) (string, bool) { ch := c.svc.changes - detail := "by text from sender " + m.SenderID + detail := byTextFrom(m.SenderID) if !c.mayAnswerChange(ctx, b, m, p) { ch.audit(ctx, proposals.ActionRefuse, p, 0, "not allowed; "+detail) if bare && p.Source.Sender == m.SenderID { @@ -245,38 +235,35 @@ func (c *Connectors) answerChange(ctx context.Context, b connectors.Binding, m c // to answer: an ordinary "ok" is just a message. return "", false } - return sentChangeNoRight, true + return v.T("change.no_right"), true } // In a chat of two the other listed sender may answer; in a shared chat // only the sender whose message made the proposal. if !m.Direct && p.Source.Sender != "" && p.Source.Sender != m.SenderID { ch.audit(ctx, proposals.ActionRefuse, p, 0, "not the sender who asked; "+detail) - return sentChangeNotYou, true + return v.T("change.not_you"), true } if verb == "cancel" { if _, err := ch.Dismiss(ctx, b.Owner, p.ID); err != nil { - return fmt.Sprintf(sentProposalFailed, changeErr(err)), true + return v.T("proposal.failed", "error", changeErr(err)), true } - return sentProposalCancel, true + return v.T("proposal.cancelled"), true } done, err := ch.Confirm(ctx, b.Owner, p.ID) if err != nil { ch.audit(ctx, proposals.ActionRefuse, p, 0, "confirm failed; "+detail) - return fmt.Sprintf(sentProposalFailed, changeErr(err)), true + return v.T("proposal.failed", "error", changeErr(err)), true } - return fmt.Sprintf("%s: %s. Reply \"undo\" within %d minutes to take it back.", savedWord(done.Kind), done.Summary, int(cancelWindow/time.Minute)), true -} - -func savedWord(k contract.ChangeKind) string { - if k == contract.ChangeMemory { - return "Saved" + key := "change.done" + if done.Kind == contract.ChangeMemory { + key = "change.saved" } - return "Done" + return v.T(key, "summary", done.Summary, "minutes", int(cancelWindow/time.Minute)), true } // undoChange takes back the change that was applied last in this place, by the // word "undo", when the sender may answer proposals here. -func (c *Connectors) undoChange(ctx context.Context, b connectors.Binding, m contract.ConnectorMessage, here []contract.ChangeProposal) (string, bool) { +func (c *Connectors) undoChange(ctx context.Context, b connectors.Binding, m contract.ConnectorMessage, here []contract.ChangeProposal, v hostmsg.Voice) (string, bool) { ch := c.svc.changes now := c.o.Clock() var last *contract.ChangeProposal @@ -292,21 +279,21 @@ func (c *Connectors) undoChange(ctx context.Context, b connectors.Binding, m con p := *last if !c.mayAnswerChange(ctx, b, m, p) { ch.audit(ctx, proposals.ActionRefuse, p, 0, "undo not allowed; by text from sender "+m.SenderID) - return sentChangeNoRight, true + return v.T("change.no_right"), true } if !m.Direct && p.Source.Sender != "" && p.Source.Sender != m.SenderID { - return sentChangeNotYou, true + return v.T("change.not_you"), true } if _, err := ch.Revert(ctx, b.Owner, p.ID); err != nil { - return fmt.Sprintf(sentChangeCannot, changeErr(err)), true + return v.T("change.cannot", "error", changeErr(err)), true } - return fmt.Sprintf("Removed: %s.", p.Summary), true + return v.T("change.removed", "summary", p.Summary), true } // answerChanges handles a short answer when change proposals wait in this // place or one was just applied. handled false leaves the message to the // trigger path and then to the agent. -func (c *Connectors) answerChanges(ctx context.Context, b connectors.Binding, m contract.ConnectorMessage, verb, code string, isUndo bool) (string, bool) { +func (c *Connectors) answerChanges(ctx context.Context, b connectors.Binding, m contract.ConnectorMessage, verb, code string, isUndo bool, v hostmsg.Voice) (string, bool) { here := c.hereChanges(ctx, b, m) if len(here) == 0 { return "", false @@ -332,18 +319,18 @@ func (c *Connectors) answerChanges(ctx context.Context, b connectors.Binding, m if !c.mayAnswerChange(ctx, b, m, p) { return "", false } - return sentProposalGone, true + return v.T("proposal.gone"), true } - return c.answerChange(ctx, b, m, p, verb, false) + return c.answerChange(ctx, b, m, p, verb, false, v) } return "", false } if len(waiting) == 0 { if isUndo { - return c.undoChange(ctx, b, m, here) + return c.undoChange(ctx, b, m, here, v) } if len(lapsed) > 0 && c.mayAnswerChange(ctx, b, m, lapsed[0]) { - return sentProposalGone, true + return v.T("proposal.gone"), true } return "", false } @@ -351,13 +338,13 @@ func (c *Connectors) answerChanges(ctx context.Context, b connectors.Binding, m // exactly one does. others := c.svc.triggers.waitingHere(ctx, PhraseFrom{Binding: b.ID, Owner: b.Owner, Chat: m.Chat, Thread: m.Thread}) if len(waiting) == 1 && len(others) == 0 { - return c.answerChange(ctx, b, m, waiting[0], verb, true) + return c.answerChange(ctx, b, m, waiting[0], verb, true, v) } if !c.mayAnswerChange(ctx, b, m, waiting[0]) { return "", false } var sb strings.Builder - sb.WriteString("More than one is waiting here. Say which, for example \"confirm CODE\" or \"cancel CODE\":") + sb.WriteString(v.T("proposal.multi")) for _, p := range waiting { fmt.Fprintf(&sb, "\n%s: %q", ChangeCode(p.ID), p.Summary) } diff --git a/core/services/studio/connectorconfirm.go b/core/services/studio/connectorconfirm.go index 5dad7c6872..7bf48aac49 100644 --- a/core/services/studio/connectorconfirm.go +++ b/core/services/studio/connectorconfirm.go @@ -4,6 +4,7 @@ import ( "context" "github.com/mudler/LocalAI/core/services/studio/connectors" + "github.com/mudler/LocalAI/core/services/studio/hostmsg" "github.com/mudler/LocalAI/pkg/addons/contract" ) @@ -14,9 +15,9 @@ import ( // the agent as usual. The text is the sender's own message: nothing the agent // read or fetched can reach this path. Who may answer is decided by the // capability gate, not here. -func (c *Connectors) answerPhrase(ctx context.Context, b connectors.Binding, m contract.ConnectorMessage, say func(string)) bool { +func (c *Connectors) answerPhrase(ctx context.Context, b connectors.Binding, m contract.ConnectorMessage, say func(string), v hostmsg.Voice) bool { verb, code, ok := ParseReply(m.Text) - isUndo := normalizeReply(m.Text) == undoWord + isUndo := isUndoReply(m.Text) if !ok { return false } @@ -24,7 +25,7 @@ func (c *Connectors) answerPhrase(ctx context.Context, b connectors.Binding, m c // looked at first, since a bare word with a change waiting must not be // taken by a trigger alone. They return handled false when nothing waits // here, or when the code is not theirs. - if reply, handled := c.answerChanges(ctx, b, m, verb, code, isUndo); handled { + if reply, handled := c.answerChanges(ctx, b, m, verb, code, isUndo, v); handled { say(reply) return true } @@ -33,7 +34,7 @@ func (c *Connectors) answerPhrase(ctx context.Context, b connectors.Binding, m c return false } reply, handled := h.AnswerByPhrase(ctx, PhraseFrom{Binding: b.ID, Owner: b.Owner, Agent: b.AgentID, - Chat: m.Chat, Thread: m.Thread, Sender: m.SenderID, Username: m.SenderUsername, Roles: m.Roles}, verb, code) + Chat: m.Chat, Thread: m.Thread, Sender: m.SenderID, Username: m.SenderUsername, Roles: m.Roles, Voice: v}, verb, code) if handled { say(reply) } diff --git a/core/services/studio/connectorfiles.go b/core/services/studio/connectorfiles.go index 0dd8b6cec1..2a839bfc1b 100644 --- a/core/services/studio/connectorfiles.go +++ b/core/services/studio/connectorfiles.go @@ -3,7 +3,6 @@ package studio import ( "context" "errors" - "fmt" "path" "strings" "time" @@ -118,30 +117,31 @@ func (c *Connectors) sendFiles(b connectors.Binding, token string, m contract.Co return } files := c.outgoing(ctx, b, x, chatID, turn, since) + v := c.voice(ctx, b, m.SenderID, m.Chat) var notes []string var sent int64 for i, f := range files { if i == maxFilesPerTurn { - notes = append(notes, fmt.Sprintf("%d more files are in the chat's files.", len(files)-i)) + notes = append(notes, v.T("file.more", "n", len(files)-i)) break } mimeType, ok := outboundType(f.name) if !ok { if f.made { - notes = append(notes, fmt.Sprintf("I made %s, but files of this type are not sent here.", f.name)) + notes = append(notes, v.T("file.made_not_sent", "name", f.name)) } else { - notes = append(notes, fmt.Sprintf("%s was not sent: files of this type are not sent here.", f.name)) + notes = append(notes, v.T("file.type_not_sent", "name", f.name)) } continue } - if note := c.sendHandled(ctx, b, token, m, x, f, mimeType, limits, &sent); note != "" { + if note := c.sendHandled(ctx, v, b, token, m, x, f, mimeType, limits, &sent); note != "" { notes = append(notes, note) } } if sent == 0 && len(notes) == 0 && promisesFile(answer) { // The answer showed a file and none went: the sender must not wait // for it. The answer itself is not changed. - notes = append(notes, noteNoFile) + notes = append(notes, v.T("file.promise")) } if len(notes) > 0 { c.say(b, token, m.Chat, m.Thread, strings.Join(notes, "\n")) diff --git a/core/services/studio/connectorlang.go b/core/services/studio/connectorlang.go new file mode 100644 index 0000000000..241d618615 --- /dev/null +++ b/core/services/studio/connectorlang.go @@ -0,0 +1,170 @@ +package studio + +import ( + "context" + "sync" + "time" + + "github.com/mudler/LocalAI/core/services/studio/connectors" + "github.com/mudler/LocalAI/core/services/studio/hostmsg" + "github.com/mudler/LocalAI/core/services/studio/triggers" + "github.com/mudler/LocalAI/pkg/addons/contract" +) + +// 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. +// +// 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. + +const ( + // langTTL is how long the language of a sender is kept after the last + // message that showed or kept it. + langTTL = 24 * time.Hour + // langMax bounds the memory; the oldest entries go first when it is full. + langMax = 4096 +) + +type langKey struct{ binding, who string } + +type langEntry struct { + lang string + at time.Time +} + +// langMemory is the last clear language of each sender and each chat. A trigger +// writes to a chat without a sender, and finds the language by the chat. +type langMemory struct { + mu sync.Mutex + m map[langKey]langEntry +} + +func (l *langMemory) get(k langKey, now time.Time) (string, bool) { + l.mu.Lock() + defer l.mu.Unlock() + e, ok := l.m[k] + if !ok || now.Sub(e.at) >= langTTL { + return "", false + } + return e.lang, true +} + +func (l *langMemory) set(k langKey, lang string, now time.Time) { + l.mu.Lock() + defer l.mu.Unlock() + if l.m == nil { + l.m = map[langKey]langEntry{} + } + if len(l.m) >= langMax { + for key, e := range l.m { + if now.Sub(e.at) >= langTTL { + delete(l.m, key) + } + } + if len(l.m) >= langMax { + clear(l.m) + } + } + l.m[k] = langEntry{lang: lang, at: now} +} + +// learn reads one message of a sender. A message in a clear language sets it, +// but a message that only barely shows another language than the one the +// sender had does not move it (hostmsg.SwitchScore): "thanks" in an Italian +// conversation stays Italian. A message that says nothing keeps the language +// and renews its time. +func (l *langMemory) learn(binding, sender, chat, text string, now time.Time) { + d := hostmsg.Detect(text) + for _, k := range []langKey{{binding, "s:" + sender}, {binding, "c:" + chat}} { + if k.who == "s:" || k.who == "c:" { + continue + } + cur, ok := l.get(k, now) + switch { + case d.Lang == "" && ok: + l.set(k, cur, now) + case d.Lang == "": + case !ok || cur == d.Lang || d.Score >= hostmsg.SwitchScore: + l.set(k, d.Lang, now) + default: + l.set(k, cur, now) + } + } +} + +// noteLanguage lets the sender's message teach the host their language. It +// reads the text and keeps nothing of it. +func (c *Connectors) noteLanguage(b connectors.Binding, m contract.ConnectorMessage) { + if b.ReplyLang() != hostmsg.Auto { + return + } + c.lang.learn(b.ID, m.SenderID, m.Chat, m.Text, c.o.Clock()) +} + +// 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. +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 + } + } + now := c.o.Clock() + if sender != "" { + if l, ok := c.lang.get(langKey{b.ID, "s:" + sender}, now); ok { + return l + } + } + if chat != "" { + if l, ok := c.lang.get(langKey{b.ID, "c:" + chat}, now); ok { + return l + } + } + if c.o.OwnerLanguage != nil { + if l, ok := hostmsg.Normalize(c.o.OwnerLanguage(ctx, b.Owner)); ok { + return l + } + } + return hostmsg.English +} + +// 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)} +} + +// voiceOf is voice for a binding known by its ID. A binding that cannot be read +// gets the English voice. +func (c *Connectors) voiceOf(ctx context.Context, bindingID, sender, chat string) hostmsg.Voice { + b, err := c.o.Store.GetBinding(ctx, bindingID) + if err != nil { + return hostmsg.Voice{Lang: hostmsg.English} + } + return c.voice(ctx, b, sender, chat) +} + +// voiceOf is the voice of the chat where a trigger was proposed: it has no +// sender, so the language of the chat decides. +func (h *Triggers) voiceOf(ctx context.Context, t triggers.Trigger) hostmsg.Voice { + if c := h.svc.connectors; c != nil { + return c.voiceOf(ctx, t.Route.Binding, "", t.Route.Chat) + } + return hostmsg.Voice{} +} + +// msg is the one function that turns a key of the host message catalog into the +// sentence for a sender of a binding. args are name and value pairs for the +// places of the template. The guard test (hostmsg_guard_test.go) fails when a +// sentence for a chat is written any other way. +func (c *Connectors) msg(ctx context.Context, b connectors.Binding, sender, key string, args ...any) string { + return c.voice(ctx, b, sender, "").T(key, args...) +} + +// byTextFrom is the detail of an audit line for an answer that came by text. +// It is for the log and never reaches a chat. +func byTextFrom(sender string) string { return "by text from sender " + sender } diff --git a/core/services/studio/connectorlang_internal_test.go b/core/services/studio/connectorlang_internal_test.go new file mode 100644 index 0000000000..7590d0619f --- /dev/null +++ b/core/services/studio/connectorlang_internal_test.go @@ -0,0 +1,82 @@ +package studio + +import ( + "time" + + "github.com/mudler/LocalAI/core/services/studio/hostmsg" + "github.com/mudler/LocalAI/core/services/studio/triggers" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("the words of a schedule in each language", func() { + parse := func(s string) triggers.Schedule { + sch, err := triggers.ParseSchedule(s) + Expect(err).NotTo(HaveOccurred()) + return sch + } + It("reads the same in English as Schedule.String", func() { + for _, s := range []string{"daily 09:00", "weekdays 08:00", "weekly mon,wed 18:30", "weekly sun 07:05", "monthly 5 07:00", "once 2026-12-24 10:00", "*/5 * * * *"} { + sch := parse(s) + Expect(scheduleWords(hostmsg.Voice{}, sch)).To(Equal(sch.String()), s) + } + }) + It("is worded by the voice", func() { + it, de := hostmsg.In("it"), hostmsg.In("de") + Expect(scheduleWords(it, parse("daily 09:00"))).To(Equal("Ogni giorno alle 09:00")) + Expect(scheduleWords(it, parse("weekly mon,wed 18:30"))).To(Equal("Ogni lunedì e mercoledì alle 18:30")) + Expect(scheduleWords(it, parse("monthly 5 07:00"))).To(Equal("Il giorno 5 di ogni mese alle 07:00")) + Expect(scheduleWords(de, parse("weekdays 08:00"))).To(Equal("Jeden Werktag um 08:00")) + Expect(scheduleWords(hostmsg.In("fr"), parse("weekly tue,thu,fri 06:00"))).To(Equal("Chaque mardi, jeudi et vendredi à 06:00")) + }) +}) + +var _ = Describe("sizes and words for a person", func() { + It("keeps the English sizes and writes the decimal mark of the language", func() { + Expect(formatFileSizeIn(hostmsg.Voice{}, 1536)).To(Equal("1.5 KiB")) + Expect(formatFileSizeIn(hostmsg.In("it"), 1536)).To(Equal("1,5 KiB")) + Expect(formatFileSizeIn(hostmsg.In("de"), 1<<20)).To(Equal("1 MiB")) + Expect(formatFileSizeIn(hostmsg.In("fr"), 1)).To(Equal("1 octet")) + Expect(formatFileSizeIn(hostmsg.In("es"), 300)).To(Equal("300 bytes")) + }) + It("lowers the first letter of a word that is not ASCII", func() { + Expect(lowerFirst("Ögni giorno")).To(Equal("ögni giorno")) + Expect(lowerFirst("")).To(Equal("")) + }) + It("words the minimum gap in each language", func() { + Expect(gapTextIn(hostmsg.In("it"), 5*time.Minute)).To(Equal("5 minuti")) + Expect(gapText(time.Minute)).To(Equal("minute")) + }) +}) + +var _ = Describe("the memory of the language of a sender", func() { + now := time.Date(2026, 11, 4, 12, 0, 0, 0, time.UTC) + It("keeps a language through short replies and moves it only for a sentence", func() { + var m langMemory + m.learn("b", "s", "c", "Ciao, puoi mandarmi il file di ieri?", now) + get := func(at time.Time) string { l, _ := m.get(langKey{"b", "s:s"}, at); return l } + Expect(get(now)).To(Equal("it")) + for _, short := range []string{"ok", "thanks", "yes", "👍", "7K2Q", ""} { + m.learn("b", "s", "c", short, now.Add(time.Minute)) + Expect(get(now.Add(time.Minute))).To(Equal("it"), short) + } + m.learn("b", "s", "c", "Can you send me the report from yesterday please?", now.Add(2*time.Minute)) + Expect(get(now.Add(2 * time.Minute))).To(Equal("en")) + }) + It("forgets after a day, but a message that says nothing renews it", func() { + var m langMemory + m.learn("b", "s", "c", "Hola, puedes enviarme el archivo de ayer?", now) + get := func(at time.Time) string { l, _ := m.get(langKey{"b", "s:s"}, at); return l } + m.learn("b", "s", "c", "vale", now.Add(20*time.Hour)) + Expect(get(now.Add(40*time.Hour))).To(Equal("es"), "renewed at 20h, so alive at 40h") + Expect(get(now.Add(46 * time.Hour))).To(BeEmpty()) + }) + It("finds the language of a chat for a trigger that has no sender", func() { + var m langMemory + m.learn("b", "s", "chat-1", "Bonjour, tu peux m'envoyer le fichier d'hier ?", now) + l, ok := m.get(langKey{"b", "c:chat-1"}, now) + Expect([]any{l, ok}).To(Equal([]any{"fr", true})) + _, ok = m.get(langKey{"other", "c:chat-1"}, now) + Expect(ok).To(BeFalse(), "another connection has its own memory") + }) +}) diff --git a/core/services/studio/connectorlimits.go b/core/services/studio/connectorlimits.go index 8b82ee92fa..4ff8552982 100644 --- a/core/services/studio/connectorlimits.go +++ b/core/services/studio/connectorlimits.go @@ -9,6 +9,7 @@ import ( "strings" "github.com/mudler/LocalAI/core/services/studio/connectors" + "github.com/mudler/LocalAI/core/services/studio/hostmsg" "github.com/mudler/LocalAI/pkg/addons/contract" ) @@ -174,57 +175,74 @@ func (c *Connectors) checkOwnerLimits(ctx context.Context, b connectors.Binding, } // why names the layer that sets the limit, in words a person knows. -func (l FileLimit) why(kindTitle string) string { +func (l FileLimit) why(kindTitle string) string { return l.whyIn(hostmsg.Voice{}, kindTitle) } + +// whyIn is why in the language of the voice. +func (l FileLimit) whyIn(v hostmsg.Voice, kindTitle string) string { switch l.Layer { case LayerOwner: - return "limit set for this bot" + return v.T("file.why_owner") case LayerAddon: - return "limit of " + cmp.Or(kindTitle, "this add-on") + if kindTitle == "" { + kindTitle = v.T("file.why_addon_default") + } + return v.T("file.why_addon", "kind", kindTitle) default: - return "limit of the platform" + return v.T("file.why_platform") } } // sentence is the limit with the layer that sets it, such as // "1 MiB (limit of this add-on version)". -func (l FileLimit) sentence(kindTitle string) string { - return formatFileSize(l.Bytes) + " (" + l.why(kindTitle) + ")" +func (l FileLimit) sentence(kindTitle string) string { return l.sentenceIn(hostmsg.Voice{}, kindTitle) } + +// sentenceIn is sentence in the language of the voice. +func (l FileLimit) sentenceIn(v hostmsg.Voice, kindTitle string) string { + return v.T("file.limit", "size", formatFileSizeIn(v, l.Bytes), "why", l.whyIn(v, kindTitle)) } // formatFileSize writes a size in bytes, KiB, MiB, GiB or TiB, with at most // one decimal. -func formatFileSize(n int64) string { +func formatFileSize(n int64) string { return formatFileSizeIn(hostmsg.Voice{}, n) } + +// formatFileSizeIn writes a size for a person who reads the language of the +// voice: the word for bytes and the decimal mark of the language. +func formatFileSizeIn(v hostmsg.Voice, n int64) string { if n == 1 { - return "1 byte" + return v.T("size.byte") } if n < 1<<10 { - return strconv.FormatInt(n, 10) + " bytes" + return v.T("size.bytes", "n", strconv.FormatInt(n, 10)) } units := []string{"KiB", "MiB", "GiB", "TiB"} - v := float64(n) / 1024 + val := float64(n) / 1024 i := 0 - for v >= 1024 && i < len(units)-1 { - v /= 1024 + for val >= 1024 && i < len(units)-1 { + val /= 1024 i++ } - return strings.TrimSuffix(strconv.FormatFloat(v, 'f', 1, 64), ".0") + " " + units[i] + out := strings.TrimSuffix(strconv.FormatFloat(val, 'f', 1, 64), ".0") + " " + units[i] + if v.Lang != "" && v.Lang != hostmsg.English { + out = strings.Replace(out, ".", ",", 1) + } + return out } // Sentences to people. Each is built from the limit in effect. // sentFileTooBig answers a person whose file the bot cannot read. size is // zero when the add-on did not say how large the file is. -func sentFileTooBig(l FileLimits, size int64) string { +func (c *Connectors) sentFileTooBig(v hostmsg.Voice, l FileLimits, size int64) string { seen := "" if size > 0 { - seen = " (" + formatFileSize(size) + ")" + seen = " (" + formatFileSizeIn(v, size) + ")" } - return fmt.Sprintf("That file is too large%s: I can read files of up to %s here (%s).", seen, formatFileSize(l.Receive.Bytes), l.Receive.why(l.KindTitle)) + return v.T("file.too_big", "seen", seen, "max", formatFileSizeIn(v, l.Receive.Bytes), "why", l.Receive.whyIn(v, l.KindTitle)) } // noteSendTooBig tells a person that a file the agent made stays in the chat. -func noteSendTooBig(name string, l FileLimits) string { - return fmt.Sprintf("%s is too large to send here (the limit is %s). It is in the chat's files.", name, l.Send.sentence(l.KindTitle)) +func noteSendTooBig(v hostmsg.Voice, name string, l FileLimits) string { + return v.T("file.send_too_big", "name", name, "limit", l.Send.sentenceIn(v, l.KindTitle)) } // refuseAttachTooBig is the text that the attach tool gives the model. diff --git a/core/services/studio/connectorlive.go b/core/services/studio/connectorlive.go index 6ad98517b0..8d07c029c6 100644 --- a/core/services/studio/connectorlive.go +++ b/core/services/studio/connectorlive.go @@ -9,14 +9,13 @@ import ( "unicode/utf8" "github.com/mudler/LocalAI/core/services/studio/connectors" + "github.com/mudler/LocalAI/core/services/studio/hostmsg" "github.com/mudler/LocalAI/core/services/studio/session" "github.com/mudler/LocalAI/pkg/addons/contract" ) // What a sender sees while a turn runs. const ( - // workingText is the working message before anything else is known. - workingText = "Working…" // cursorMark ends a live preview: the answer is still being written. cursorMark = " ▉" // typingEvery is how often typing is shown again: a platform shows it @@ -39,6 +38,8 @@ const ( // conversation a draft. One reply goroutine owns it, so it needs no lock. type liveReply struct { mode string + // v speaks the fixed words of the preview in the sender's language. + v hostmsg.Voice // id is the working message. It is "" when the mode is off, while // drafts show the preview, when the message could not be sent, or after // an edit failed: the answer then goes as a new message. @@ -96,7 +97,7 @@ func (l *liveReply) observe(ev session.Event) { } // The text before a tool call is a preamble; the answer comes after. l.answer.Reset() - l.status, l.reasoning = "Using "+ev.Tool.Name+"…", false + l.status, l.reasoning = l.v.T("live.using", "tool", ev.Tool.Name), false case session.KindText: l.answer.WriteString(ev.Text) case session.KindReasoning: @@ -118,15 +119,15 @@ func (l *liveReply) view() string { t = strings.TrimSpace(l.status) } if t == "" { - t = workingText + t = l.v.T("live.working") } return tailRunes(t, maxPreviewRunes-utf8.RuneCountInString(cursorMark)) + cursorMark case connectors.ReplyProgress: if len(l.tools) > 0 { - return workingText + "\n• " + strings.Join(l.tools, "\n• ") + return l.v.T("live.working") + "\n• " + strings.Join(l.tools, "\n• ") } } - return workingText + return l.v.T("live.working") } // tailRunes keeps the last n runes of s. @@ -162,7 +163,7 @@ func (c *Connectors) livePaceOf(b connectors.Binding) livePace { // conversation on a platform with drafts it is a draft, else a working // message. func (c *Connectors) startLive(b connectors.Binding, token string, m contract.ConnectorMessage, pace livePace) *liveReply { - l := &liveReply{mode: b.ReplyMode()} + l := &liveReply{mode: b.ReplyMode(), v: c.voice(context.Background(), b, m.SenderID, m.Chat)} // Typing goes after the working message, which it names: a platform // without typing would otherwise post a working message of its own. defer func() { c.typing(b, token, m, l.id) }() diff --git a/core/services/studio/connectornew.go b/core/services/studio/connectornew.go index 778a5d7718..7d531939c7 100644 --- a/core/services/studio/connectornew.go +++ b/core/services/studio/connectornew.go @@ -16,13 +16,6 @@ import ( // 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 @@ -38,11 +31,11 @@ func isNewCommand(m contract.ConnectorMessage) bool { // 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)) { +func (c *Connectors) newChat(ctx context.Context, b connectors.Binding, m contract.ConnectorMessage, tell func(string, ...any)) { mode := b.ConversationMode() if mode == connectors.ConversationThread { c.audit(b, m.SenderID, "chat.new", "thread mode") - say(sentNewEach) + tell("new.each") return } key := c.conversationOf(ctx, b, m) @@ -51,7 +44,7 @@ func (c *Connectors) newChat(ctx context.Context, b connectors.Binding, m contra // 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) + tell("new.shared") return } // A turn that runs keeps its chat: the reset waits for the sender to ask @@ -62,14 +55,14 @@ func (c *Connectors) newChat(ctx context.Context, b connectors.Binding, m contra c.smu.Unlock() if busy { c.audit(b, m.SenderID, "chat.new", "busy") - say(sentNewBusy) + tell("new.busy") 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) + tell("unavailable") return } c.audit(b, m.SenderID, "chat.new", "reset") - say(sentNewDone) + tell("new.done") } diff --git a/core/services/studio/connectorpairing.go b/core/services/studio/connectorpairing.go index b894e74334..ca24aecb1a 100644 --- a/core/services/studio/connectorpairing.go +++ b/core/services/studio/connectorpairing.go @@ -29,11 +29,6 @@ const ( // before the host stops looking at that sender's messages for an hour. pairingAttempts = 5 pairingWindow = time.Hour - - sentPaired = "Paired. This account can now use the sensitive features that you enabled for this bot." - sentPairingBad = "That code is not valid. It may have expired or been used. Ask for a new one." - sentPairingFull = "This bot already has the most paired accounts. Remove one in the settings, then pair again." - sentPairingDirect = "Pairing works in a direct message only." ) var pairingShape = regexp.MustCompile(`^LA-[A-Z0-9]{8}$`) @@ -137,7 +132,7 @@ func (c *Connectors) pairingFailed(s *senderState) { // tryPairing handles a message that has the shape of a pairing code. It // returns true when the message was a pairing attempt: the caller must not // start a turn for it. The sender is already on the allowlist. -func (c *Connectors) tryPairing(ctx context.Context, b connectors.Binding, m contract.ConnectorMessage, st *senderState, say func(string)) bool { +func (c *Connectors) tryPairing(ctx context.Context, b connectors.Binding, m contract.ConnectorMessage, st *senderState, tell func(string, ...any)) bool { code := strings.ToUpper(strings.TrimSpace(m.Text)) if !pairingShape.MatchString(code) { return false @@ -150,28 +145,28 @@ func (c *Connectors) tryPairing(ctx context.Context, b connectors.Binding, m con // A code written in a group is spent in public: it is not tried. c.audit(b, m.SenderID, "pairing.attempt", "not direct") c.pairingFailed(st) - say(sentPairingDirect) + tell("pairing.direct") return true } ok, err := c.o.Store.ConsumePairing(ctx, b.ID, pairingHash(b.ID, code), c.o.Clock()) if err != nil { c.log.Warn("studio: could not check a pairing code", "binding", b.ID, "error", err) - say(sentUnavailable) + tell("unavailable") return true } if !ok { c.audit(b, m.SenderID, "pairing.attempt", "refused") c.pairingFailed(st) - say(sentPairingBad) + tell("pairing.bad") return true } if err := c.addPaired(ctx, b.ID, m.SenderID); err != nil { c.audit(b, m.SenderID, "pairing.attempt", err.Error()) - say(sentPairingFull) + tell("pairing.full") return true } c.audit(b, m.SenderID, "pairing.attempt", "paired") - say(sentPaired) + tell("pairing.done") return true } diff --git a/core/services/studio/connectorpromise.go b/core/services/studio/connectorpromise.go index a58b7c0b47..0e4f7dd172 100644 --- a/core/services/studio/connectorpromise.go +++ b/core/services/studio/connectorpromise.go @@ -5,10 +5,6 @@ import ( "unicode" ) -// noteNoFile is what the sender reads when the answer showed a file and none -// went with it. -const noteNoFile = "I could not attach the file. Ask me again and I will send it." - // paperclip is the marker a model writes in place of a file it did not send. const paperclip = "📎" diff --git a/core/services/studio/connectors.go b/core/services/studio/connectors.go index f242899c2b..2ab5952707 100644 --- a/core/services/studio/connectors.go +++ b/core/services/studio/connectors.go @@ -15,6 +15,7 @@ import ( "github.com/mudler/LocalAI/core/http/auth" "github.com/mudler/LocalAI/core/services/studio/connectors" + "github.com/mudler/LocalAI/core/services/studio/hostmsg" "github.com/mudler/LocalAI/core/services/studio/media" "github.com/mudler/LocalAI/core/services/studio/notify" "github.com/mudler/LocalAI/core/services/studio/session" @@ -22,27 +23,9 @@ import ( "github.com/mudler/LocalAI/pkg/addons/contract" ) -// Sentences the bot says. They name no internal error. +// The sentences the bot says are keys of the host message catalog (see +// hostmsg and msg). They name no internal error. const ( - sentNoUpload = "You may not send files to this bot." - sentUnsupported = "I cannot read this kind of message. Please send text, a photo, a file or a voice message." - sentTooLong = "That message is too long. Please send less than 8,000 characters." - sentRate = "Too many messages: please wait a minute." - sentUnavailable = "This bot is not available right now." - sentNoAnswer = "The agent had nothing to say." - sentStopped = "The turn was stopped." - sentInterrupted = "The agent was interrupted. Please send your message again." - sentFailed = "The agent stopped because of an error." - sentFolderLimit = "This agent cannot start because its file access exceeds the folder limit. Ask an administrator to reduce the shared folders available to you, then try again." - sentNoFolder = "This agent cannot work right now: its folder is missing." - sentNoFile = "I could not read the file you sent." - sentNoFileAbility = "I cannot take files on this platform." - sentFileType = "I cannot read files of this type here." - sentFileTotal = "Those files are too large together: please send fewer of them." - sentNoFiles = "I can only read the text of your message here: files are turned off." - sentNoListen = "I cannot listen to voice messages here: no speech-to-text model is set up. Please write your message." - sentNoTranscript = "I could not understand your voice message. Please send it again, or write it." - // voiceMark starts the transcript of a voice message in the text of a // turn, so the agent knows that the sender spoke and did not write. voiceMark = "[Voice message, transcribed]" @@ -106,6 +89,11 @@ type ConnectorOptions struct { // FileCeiling reads the host ceiling of a connector file from the owner // 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. + 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. OutboxPoll time.Duration @@ -149,6 +137,8 @@ type Connectors struct { outbox outboxes // text the host writes on its own, by binding + lang langMemory // the last clear language of each sender and chat + gmu sync.Mutex gates []CapabilitySwitch // extra switches of a capability (see AddCapabilitySwitch) @@ -716,26 +706,29 @@ func (c *Connectors) handle(ctx context.Context, b connectors.Binding, token str if !allowed { return // ignored: not answered, and in the sender log for the owner } + c.noteLanguage(b, m) + v := c.voice(ctx, b, senderID, m.Chat) say := func(text string) { c.say(b, token, m.Chat, m.Thread, text) } + tell := func(key string, args ...any) { say(v.T(key, args...)) } st := c.sender(b.ID, senderID) switch ok, notice := c.allow(st); { case !ok && notice: - say(sentRate) + tell("rate") return case !ok: return } // A pairing code is checked before any model turn and never reaches the // model or the log. - if c.tryPairing(ctx, b, m, st, say) { + if c.tryPairing(ctx, b, m, st, tell) { return } switch { case m.Unsupported && len(m.Media) == 0: - say(sentUnsupported) + tell("unsupported") return case utf8.RuneCountInString(m.Text) > maxMessageRunes: - say(sentTooLong) + tell("too_long") return } if c.svc.d.Users != nil { @@ -744,22 +737,22 @@ func (c *Connectors) handle(ctx context.Context, b connectors.Binding, token str if errors.Is(err, ErrNoUser) { c.disable(b, "its owner no longer exists") } - say(sentUnavailable) + tell("unavailable") return } if c.svc.d.Allowed != nil && !c.svc.d.Allowed(owner, auth.FeatureConnectors) { - say(sentUnavailable) + tell("unavailable") return } } // The new chat command is decided here too, and the model never reads it. if isNewCommand(m) { - c.newChat(ctx, b, m, say) + c.newChat(ctx, b, m, tell) 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) { + if len(m.Media) == 0 && c.answerPhrase(ctx, b, m, say, v) { return } // A scoped agent whose folder is missing does not run, on any frontend @@ -767,7 +760,7 @@ func (c *Connectors) handle(ctx context.Context, b connectors.Binding, token str // again once the folder is back. if _, err := c.svc.agentScope(ctx, b.Owner, b.AgentID); errors.Is(err, ErrAgentFolderMissing) { c.log.Warn("studio: a connector message was refused: the agent's folder is missing", "binding", b.ID) - say(sentNoFolder) + tell("no_folder") return } // The files are read before the chat is made: a message that cannot @@ -776,11 +769,11 @@ func (c *Connectors) handle(ctx context.Context, b connectors.Binding, token str var up []session.Upload if len(m.Media) > 0 { if d := c.Authorize(ctx, b, m, contract.CapFilesUpload); !d.Allowed { - say(sentNoUpload) + tell("no_upload") return } var ok bool - if m.Text, up, ok = c.inputs(ctx, b, token, m, say); !ok { + if m.Text, up, ok = c.inputs(ctx, b, token, m, say, v); !ok { return } } @@ -794,16 +787,16 @@ func (c *Connectors) handle(ctx context.Context, b connectors.Binding, token str c.disable(b, "its agent has no model") case errors.Is(err, ErrAgentFolderLimit): c.log.Warn("studio: a connector message exceeds the folder limit", "binding", b.ID) - say(sentFolderLimit) + tell("folder_limit") return case errors.Is(err, ErrAgentFolderMissing): c.log.Warn("studio: a connector message was refused: the agent's folder is missing", "binding", b.ID) - say(sentNoFolder) + tell("no_folder") return default: c.log.Warn("studio: could not make the chat of a sender", "binding", b.ID, "error", err) } - say(sentUnavailable) + tell("unavailable") return } c.rememberPlace(ctx, b, chatID, m) @@ -825,8 +818,8 @@ func (c *Connectors) handle(ctx context.Context, b connectors.Binding, token str // about a file that could not be read or was too large. It reports false when // the message must not start a turn: nothing is left to send, or a voice // message cannot be transcribed. -func (c *Connectors) inputs(ctx context.Context, b connectors.Binding, token string, m contract.ConnectorMessage, say func(string)) (string, []session.Upload, bool) { - got, note := c.media(ctx, b, token, m.Media) +func (c *Connectors) inputs(ctx context.Context, b connectors.Binding, token string, m contract.ConnectorMessage, say func(string), v hostmsg.Voice) (string, []session.Upload, bool) { + got, note := c.media(ctx, b, token, m.Media, v) if note != "" { say(note) } @@ -837,7 +830,7 @@ func (c *Connectors) inputs(ctx context.Context, b connectors.Binding, token str ) for _, f := range got { if media.IsAudio(f.Name) { - t, refusal := c.transcribe(ctx, b, f.Name, f.body(c)) + t, refusal := c.transcribe(ctx, b, f.Name, f.body(c), v) if refusal != "" { say(refusal) return "", nil, false @@ -852,7 +845,7 @@ func (c *Connectors) inputs(ctx context.Context, b connectors.Binding, token str up = append(up, session.Upload{Name: f.Name, Body: f.body(c)}) } if filesOff { - say(sentNoFiles) + say(v.T("no_files")) } text := m.Text if len(voices) > 0 { @@ -872,17 +865,17 @@ func (c *Connectors) inputs(ctx context.Context, b connectors.Binding, token str // model as a voice note attached in a chat, as the owner of the binding: the // owner's model allowlist and quota apply. It returns the sentence for the // sender when it cannot. -func (c *Connectors) transcribe(ctx context.Context, b connectors.Binding, name string, body io.Reader) (string, string) { +func (c *Connectors) transcribe(ctx context.Context, b connectors.Binding, name string, body io.Reader, v hostmsg.Voice) (string, string) { m := c.svc.d.Media if !m.CanTranscribe() { - return "", sentNoListen + return "", v.T("no_listen") } tctx, cancel := context.WithTimeout(ctx, transcribeTimeout) defer cancel() t, err := m.Transcribe(tctx, principal(b.Owner), name, body) if t = strings.TrimSpace(t); err != nil || t == "" { c.log.Warn("studio: could not transcribe the voice message of a connector", "binding", b.ID, "kind", b.Kind, "error", err) - return "", sentNoTranscript + return "", v.T("no_transcript") } return t, "" } @@ -893,7 +886,7 @@ func (c *Connectors) transcribe(ctx context.Context, b connectors.Binding, name // sent; the chat saves it in its own folder. The add-on writes each file to the // host in chunks by a handle (see connectorxfer.go). A kind that declares no // way to receive files gets the sender a plain sentence. -func (c *Connectors) media(ctx context.Context, b connectors.Binding, token string, refs []string) ([]inboundFile, string) { +func (c *Connectors) media(ctx context.Context, b connectors.Binding, token string, refs []string, v hostmsg.Voice) ([]inboundFile, string) { var ( out []inboundFile note string @@ -902,7 +895,7 @@ func (c *Connectors) media(ctx context.Context, b connectors.Binding, token stri start = time.Now() ) if limits.Receive.Bytes <= 0 { - return nil, sentNoFileAbility + return nil, v.T("no_file_ability") } for _, ref := range refs { req := contract.ConnectorRequest{Op: contract.ConnectorOpMedia, Kind: b.Kind, Token: token, Media: ref} @@ -917,26 +910,26 @@ func (c *Connectors) media(ctx context.Context, b connectors.Binding, token stri var ce *contract.ConnectorError switch { case errors.Is(ferr, errFileTooLarge) || (errors.As(err, &ce) && ce.Code == contract.ConnectorTooLarge): - note = sentFileTooBig(limits, f.Size) + note = c.sentFileTooBig(v, limits, f.Size) c.auditFile(b, "in", f.Size, "too_large", limits.Receive, time.Since(start)) continue case ferr != nil: c.log.Warn("studio: could not read the file of a connector message", "binding", b.ID, "kind", b.Kind, "error", ferr) if note == "" { - note = sentNoFile + note = v.T("no_file") } c.auditFile(b, "in", 0, "failed", limits.Receive, time.Since(start)) continue } if !typeAllowed(limits.ReceiveTypes, f.MIME) { c.log.Warn("studio: a connector file has a type the kind does not declare", "binding", b.ID, "kind", b.Kind) - note = sentFileType + note = v.T("file_type") c.auditFile(b, "in", f.Size, "type", limits.Receive, time.Since(start)) continue } // Files of one message may not add up to more than a few times the limit. if total += f.Size; total > limits.Receive.Bytes*fileTurnFactor { - note = sentFileTotal + note = v.T("file_total") c.auditFile(b, "in", f.Size, "total", limits.Receive, time.Since(start)) continue } @@ -1013,7 +1006,7 @@ func (c *Connectors) deliver(ctx context.Context, b connectors.Binding, token st st.active, st.grant = false, nil c.smu.Unlock() c.svc.access.clear(chatID) - c.say(b, token, m.Chat, m.Thread, sentUnavailable) + c.say(b, token, m.Chat, m.Thread, c.voice(ctx, b, m.SenderID, m.Chat).T("unavailable")) return } } @@ -1025,15 +1018,16 @@ func (c *Connectors) deliver(ctx context.Context, b connectors.Binding, token st c.smu.Unlock() c.svc.access.clear(chatID) } - text := sentFailed + v := c.voice(ctx, b, m.SenderID, m.Chat) + text := v.T("failed") if errors.Is(err, ErrAgentFolderLimit) { c.log.Warn("studio: a connector message exceeds the folder limit", "binding", b.ID) - text = sentFolderLimit + text = v.T("folder_limit") } if errors.Is(err, ErrAgentFolderMissing) { // The folder went away between the check and the turn. c.log.Warn("studio: a connector message was refused: the agent's folder is missing", "binding", b.ID) - text = sentNoFolder + text = v.T("no_folder") } c.say(b, token, m.Chat, m.Thread, text) return @@ -1115,7 +1109,7 @@ func (c *Connectors) reply(b connectors.Binding, token string, m contract.Connec if !ended { continue } - answer := c.answerOf(b, chatID, errText) + answer := c.answerOf(b, chatID, errText, c.voice(context.Background(), b, m.SenderID, m.Chat)) ids := c.finish(b, token, m, l, answer) c.remember(b, m, chatID, ids) c.sendFiles(b, token, m, chatID, turn, since, answer) @@ -1151,7 +1145,7 @@ func (c *Connectors) remember(b connectors.Binding, m contract.ConnectorMessage, } // answerOf is what the sender reads for the turn that just ended. -func (c *Connectors) answerOf(b connectors.Binding, chatID, errText string) string { +func (c *Connectors) answerOf(b connectors.Binding, chatID, errText string, v hostmsg.Voice) string { var info store.TurnInfo // The event comes before the store records the outcome, so look again for // up to five seconds. Close ends the wait at once. @@ -1174,12 +1168,12 @@ wait: } switch info.Last { case store.TurnStopped: - return sentStopped + return v.T("stopped") case store.TurnInterrupted: - return sentInterrupted + return v.T("interrupted") case store.TurnFailed: if errText == "" { - return sentFailed + return v.T("failed") } return errText } @@ -1188,7 +1182,7 @@ wait: if t := strings.TrimSpace(c.svc.finalText(fctx, chatID)); t != "" { return t } - return sentNoAnswer + return v.T("no_answer") } func isAgentMissing(err error) bool { diff --git a/core/services/studio/connectors/connectorstest/suite.go b/core/services/studio/connectors/connectorstest/suite.go index 914598cb43..045ffbf81b 100644 --- a/core/services/studio/connectors/connectorstest/suite.go +++ b/core/services/studio/connectors/connectorstest/suite.go @@ -71,6 +71,38 @@ func Run(newStore NewStore) { Expect(got.ReplyMode()).To(Equal(connectors.ReplyProgress)) Expect(got.SendsFiles()).To(BeTrue()) }) + It("keeps the reply language, and reads a binding stored without one as auto", func() { + s := newStore() + b := binding("lang", "u1", "Language") + b.ReplyLanguage = "pt-BR" + Expect(s.PutBinding(ctx, b)).To(Succeed()) + got, err := s.GetBinding(ctx, "lang") + Expect(err).NotTo(HaveOccurred()) + Expect(got.ReplyLanguage).To(Equal("pt-BR")) + Expect(got.ReplyLang()).To(Equal("pt-BR")) + list, err := s.ListBindings(ctx, "u1") + Expect(err).NotTo(HaveOccurred()) + Expect(list).To(HaveLen(1)) + Expect(list[0].ReplyLanguage).To(Equal("pt-BR")) + got.ReplyLanguage = "" + Expect(s.PutBinding(ctx, got)).To(Succeed()) + again, _ := s.GetBinding(ctx, "lang") + Expect(again.ReplyLanguage).To(BeEmpty()) + Expect(again.ReplyLang()).To(Equal("auto")) + }) + 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"} { + b := base + b.ReplyLanguage = l + Expect(connectors.Validate(b)).To(Succeed(), l) + } + for _, l := range []string{"pt", "IT", "ja", "it-IT", "xx"} { + b := base + b.ReplyLanguage = l + Expect(connectors.Validate(b)).NotTo(Succeed(), l) + } + }) It("keeps the options of the kind", func() { s := newStore() b := binding("opt", "u1", "Options") diff --git a/core/services/studio/connectors/gorm.go b/core/services/studio/connectors/gorm.go index 56e9116bc9..a8427f36fb 100644 --- a/core/services/studio/connectors/gorm.go +++ b/core/services/studio/connectors/gorm.go @@ -28,6 +28,8 @@ type bindingRow struct { Conversation string `gorm:"size:16"` WindowMinutes int FilesOff bool + // ReplyLanguage is added by AutoMigrate; a row stored before it reads as auto. + ReplyLanguage string `gorm:"size:8"` // 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. @@ -125,7 +127,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, WriteMode: r.WriteMode, + NotifyChat: r.NotifyChat, Reply: r.Reply, Conversation: r.Conversation, WindowMinutes: r.WindowMinutes, FilesOff: r.FilesOff, ReplyLanguage: r.ReplyLanguage, 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 != "" { @@ -172,7 +174,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, Options: options, FileReceiveMax: b.FileReceiveMax, FileSendMax: b.FileSendMax, + FilesOff: b.FilesOff, ReplyLanguage: b.ReplyLanguage, 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 c450ccd162..b6f3921422 100644 --- a/core/services/studio/connectors/types.go +++ b/core/services/studio/connectors/types.go @@ -20,6 +20,7 @@ import ( "unicode" "unicode/utf8" + "github.com/mudler/LocalAI/core/services/studio/hostmsg" "github.com/mudler/LocalAI/pkg/addons/contract" ) @@ -152,6 +153,10 @@ type Binding struct { Conversation string // WindowMinutes is the time window of ConversationWindow. WindowMinutes int + // ReplyLanguage is the language of the sentences that the host writes to + // 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 // 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. @@ -400,6 +405,9 @@ func ValidateKind(b Binding, agentOptional bool) error { default: return bad("the reply mode must be %s, %s or %s", ReplyOff, ReplyProgress, ReplyStream) } + if !hostmsg.ValidSetting(b.ReplyLanguage) { + return bad("the reply language must be auto, %s", strings.Join(hostmsg.Languages, ", ")) + } switch b.Conversation { case "", ConversationSender, ConversationChat, ConversationThread: case ConversationWindow: @@ -490,6 +498,15 @@ func (b Binding) ConversationMode() string { return b.Conversation } +// ReplyLang is the reply language setting of a binding, with the default +// filled in: hostmsg.Auto or a language code. +func (b Binding) ReplyLang() string { + if b.ReplyLanguage == "" { + return hostmsg.Auto + } + return b.ReplyLanguage +} + // SendsFiles tells whether the files a turn makes go to the sender. func (b Binding) SendsFiles() bool { return !b.FilesOff } diff --git a/core/services/studio/connectors_language_test.go b/core/services/studio/connectors_language_test.go new file mode 100644 index 0000000000..794f798237 --- /dev/null +++ b/core/services/studio/connectors_language_test.go @@ -0,0 +1,166 @@ +package studio_test + +import ( + "context" + "strings" + "time" + + "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/studiotest" + "github.com/mudler/LocalAI/core/services/studio/workspace" + "github.com/mudler/LocalAI/pkg/addons/contract" + "github.com/mudler/LocalAI/tests/studiobot" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// The sentences that the host writes in a chat come in the language of the +// person: the reply language of the connection, or with auto the language of +// the sender's messages, which a short reply such as "ok" does not change. +var _ = Describe("the language of the host sentences in a chat", func() { + ctx := context.Background() + const ( + chatID = "C1" + thread = "T1" + ) + var ( + sh *shared + fake *postAddon + gate *capSwitch + r *connRig + b connectors.Binding + seq int + ) + script := func(_ workspace.Actor, text string) []studiotest.ToolStep { + if strings.HasSuffix(text, "propose") { + return []studiotest.ToolStep{{Tool: "propose_trigger", Args: `{"name":"Report","prompt":"RUN write it","when":"daily 08:00","time_zone":"UTC","delivery":{"target":"origin"}}`}, {Say: "proposed"}} + } + return []studiotest.ToolStep{{Say: "ok"}} + } + unpaired := false + BeforeEach(func() { unpaired = false }) + setup := func(lang *string, opt func(*studio.ConnectorOptions)) { + sh, fake, gate = newShared(), &postAddon{}, &capSwitch{} + tr := studiobot.NewTransport(studiobot.Addon{ID: "chat-addon", Kind: contract.ConnectorKind{ID: "chatty", Title: "Chatty", Delivery: contract.DeliveryPull}, Runtime: fake}) + r = newConnRigOpts(sh, connStore(), tr, script, nil, func(o *studio.ConnectorOptions) { + o.OutboxPoll = 10 * time.Millisecond + o.RatePerMinute = 100 + if opt != nil { + opt(o) + } + }) + ag, err := r.svc.SaveAgent(ctx, alice, agents.Agent{Name: "Worker", Model: "m", Builtins: []string{studio.CapTriggersPropose}}) + Expect(err).NotTo(HaveOccurred()) + r.agent = ag + r.svc.SetCapabilityGate(gate) + gate.set(studio.CapTriggersPropose, true) + gate.set(studio.CapProposalsConfirm, true) + r.untrusted = unpaired + b = r.bind(studio.ConnectorInput{Kind: "chatty", Token: "tok", Allow: []string{"11"}, ReplyLanguage: lang, + Approval: &connectors.Approval{Acts: []string{"act.files"}}}) + seq = 0 + } + ptr := func(s string) *string { return &s } + ingest := func(m contract.ConnectorMessage) { + seq++ + m.MessageID, m.SenderID, m.SenderName = "m"+string(rune('a'+seq)), "11", "Cy" + Expect(r.conns.Ingest(ctx, "chat-addon", contract.ConnectorInbound{BindingID: b.ID, ConnectorMessage: m})).To(Succeed()) + sh.clock.Advance(time.Second) + } + 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 }) + t := fake.texts("11", "") + return t[len(t)-1] + } + + It("writes the sentences in the language the connection names", func() { + unpaired = true + setup(ptr("it"), nil) + Expect(ask("/new", 1)).To(Equal("Nuova chat avviata. Non vedrò più i nostri messaggi precedenti.")) + ingest(contract.ConnectorMessage{Chat: chatID, Thread: thread, Mention: true, Text: "/new"}) + waitFor("the refusal", func() bool { return len(fake.texts(chatID, thread)) >= 1 }) + Expect(fake.texts(chatID, thread)[0]).To(Equal("Solo un account abbinato può avviare una nuova chat qui, perché tutti in questa conversazione la condividono.")) + ingest(contract.ConnectorMessage{Chat: "11", Direct: true, Unsupported: true}) + waitFor("the refusal", func() bool { return len(fake.texts("11", "")) >= 2 }) + Expect(fake.texts("11", "")[1]).To(Equal("Non riesco a leggere questo tipo di messaggio. Invia un testo, una foto, un file o un messaggio vocale.")) + Expect(ask(strings.Repeat("a", 8001), 3)).To(Equal("Il messaggio è troppo lungo. Invia meno di 8.000 caratteri.")) + Expect(ask("LA-ABCDEFGH", 4)).To(Equal("Questo codice non è valido. Potrebbe essere scaduto o già usato. Chiedine uno nuovo.")) + }) + + It("keeps a fixed language whatever the sender writes", func() { + setup(ptr("de"), nil) + Expect(ask("Hello, can you help me with this file please?", 1)).To(Equal("ok"), "the model answers in its own words") + Expect(ask("/new", 2)).To(HavePrefix("Neuer Chat gestartet")) + }) + + It("follows the language of the sender with auto, and a short reply does not change it", func() { + setup(nil, nil) + Expect(ask("Hello, can you send me the report please?", 1)).To(Equal("ok")) + Expect(ask("/new", 2)).To(Equal("Started a new chat. I will not see our earlier messages.")) + Expect(ask("Ciao, puoi mandarmi il file di ieri?", 3)).To(Equal("ok")) + Expect(ask("/new", 4)).To(HavePrefix("Nuova chat avviata.")) + Expect(ask("ok", 5)).To(Equal("ok")) + Expect(ask("/new", 6)).To(HavePrefix("Nuova chat avviata."), "ok does not move the language") + Expect(ask("thanks", 7)).To(Equal("ok")) + Expect(ask("/new", 8)).To(HavePrefix("Nuova chat avviata."), "one English word does not move it either") + Expect(ask("Kannst du mir bitte die Datei von gestern schicken?", 9)).To(Equal("ok")) + Expect(ask("/new", 10)).To(HavePrefix("Neuer Chat gestartet."), "a sentence in another language does") + }) + + It("forgets the language after a day and falls back to English", func() { + setup(nil, nil) + Expect(ask("Ciao, puoi mandarmi il file di ieri?", 1)).To(Equal("ok")) + sh.clock.Advance(25 * time.Hour) + Expect(ask("/new", 2)).To(HavePrefix("Started a new chat.")) + }) + + It("uses the interface language of the owner before English, when the server knows it", func() { + setup(nil, func(o *studio.ConnectorOptions) { + o.OwnerLanguage = func(context.Context, string) string { return "fr-CA" } + }) + 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"}) + waitFor("the proposal", func() bool { return len(fake.texts(chatID, thread)) >= 2 }) + text := fake.texts(chatID, thread)[0] + Expect(text).To(ContainSubstring("Proposta ")) + Expect(text).To(ContainSubstring("Permessi: sola lettura.")) + Expect(text).To(ContainSubstring("Non parte nulla finché non approvi. Rispondi \"sì\" per avviarlo o \"annulla\" per scartarlo")) + Expect(text).To(ContainSubstring("ogni giorno alle 08:00 (UTC)")) + before := len(fake.texts(chatID, thread)) + ingest(contract.ConnectorMessage{Chat: chatID, Thread: thread, Mention: true, Text: "sì"}) + waitFor("the confirmation", func() bool { return len(fake.texts(chatID, thread)) > before }) + got := fake.texts(chatID, thread) + Expect(got[len(got)-1]).To(HavePrefix("Confermato. \"Report\" è attivo: Ogni giorno alle 08:00 (UTC).")) + chats, err := r.svc.List(ctx, alice, 10) + Expect(err).NotTo(HaveOccurred()) + for _, c := range chats { + Expect(r.h.Sent(c.ID)).NotTo(ContainElement("sì"), "a confirmation is read by the host, not by the model") + } + }) + + It("cancels a proposal with the word of each language", func() { + for _, word := range []string{"annulla", "abbrechen", "cancelar", "annuler", "cancelar", "no"} { + setup(nil, nil) + ingest(contract.ConnectorMessage{Chat: chatID, Thread: thread, Mention: true, Text: "propose"}) + waitFor("the proposal", func() bool { return len(fake.texts(chatID, thread)) >= 2 }) + ingest(contract.ConnectorMessage{Chat: chatID, Thread: thread, Mention: true, Text: word}) + waitFor("the answer to "+word, func() bool { return len(fake.texts(chatID, thread)) >= 3 }) + Expect(fake.texts(chatID, thread)[2]).To(Equal("Cancelled."), word) + ps, err := r.host.Proposals(ctx, alice) + Expect(err).NotTo(HaveOccurred()) + Expect(ps).To(BeEmpty(), word) + } + }) +}) diff --git a/core/services/studio/connectorsvc.go b/core/services/studio/connectorsvc.go index 964cdab286..6c36e9efb9 100644 --- a/core/services/studio/connectorsvc.go +++ b/core/services/studio/connectorsvc.go @@ -10,6 +10,7 @@ import ( "time" "github.com/mudler/LocalAI/core/services/studio/connectors" + "github.com/mudler/LocalAI/core/services/studio/hostmsg" "github.com/mudler/LocalAI/pkg/addons/contract" ) @@ -35,6 +36,10 @@ type ConnectorInput struct { Reply string Conversation string WindowMinutes int + // ReplyLanguage is the language of the sentences that the host writes in + // the chat: hostmsg.Auto or a language code. Nil means auto for a new + // binding and unchanged for an update. + ReplyLanguage *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 @@ -74,6 +79,12 @@ func (in ConnectorInput) apply(b *connectors.Binding) { if b.Conversation != connectors.ConversationWindow { b.WindowMinutes = 0 } + if in.ReplyLanguage != nil { + b.ReplyLanguage = *in.ReplyLanguage + if b.ReplyLanguage == hostmsg.Auto { + b.ReplyLanguage = "" + } + } if in.SendFiles != nil { b.FilesOff = !*in.SendFiles } diff --git a/core/services/studio/connectorxfer.go b/core/services/studio/connectorxfer.go index af4522f0ee..8af9b45bbc 100644 --- a/core/services/studio/connectorxfer.go +++ b/core/services/studio/connectorxfer.go @@ -5,13 +5,13 @@ import ( "crypto/rand" "encoding/hex" "errors" - "fmt" "io" "net/http" "strings" "time" "github.com/mudler/LocalAI/core/services/studio/connectors" + "github.com/mudler/LocalAI/core/services/studio/hostmsg" "github.com/mudler/LocalAI/core/services/studio/session" "github.com/mudler/LocalAI/core/services/studio/workspace" "github.com/mudler/LocalAI/pkg/addons/contract" @@ -130,7 +130,7 @@ func (c *Connectors) auditFile(b connectors.Binding, dir string, size int64, res // It returns the sentence for the sender when the file cannot go, and "" // when it went or is not named (gone, empty). sent counts the bytes of the // turn so far. -func (c *Connectors) sendHandled(ctx context.Context, b connectors.Binding, token string, m contract.ConnectorMessage, x session.Access, f outFile, mimeType string, l FileLimits, sent *int64) string { +func (c *Connectors) sendHandled(ctx context.Context, v hostmsg.Voice, b connectors.Binding, token string, m contract.ConnectorMessage, x session.Access, f outFile, mimeType string, l FileLimits, sent *int64) string { start := time.Now() n, err := c.svc.d.Files.Store().Stat(ctx, x.Actor(), f.path) switch { @@ -138,16 +138,16 @@ func (c *Connectors) sendHandled(ctx context.Context, b connectors.Binding, toke return "" // gone since, or outside the chat's access: not named case err != nil: c.log.Warn("studio: could not read a file for a connector", "binding", b.ID, "error", err) - return fmt.Sprintf("I could not read %s.", f.name) + return v.T("file.read_fail", "name", f.name) case n.Kind == workspace.KindDir || n.Blob == "" || n.Size == 0: return "" // an empty file says nothing, and platforms refuse it case n.Size > l.Send.Bytes: c.auditFile(b, "out", n.Size, "too_large", l.Send, time.Since(start)) - return noteSendTooBig(f.name, l) + return noteSendTooBig(v, f.name, l) case !typeAllowed(l.SendTypes, mimeType): - return fmt.Sprintf("%s was not sent: files of this type are not sent here.", f.name) + return v.T("file.type_not_sent", "name", f.name) case *sent+n.Size > l.Send.Bytes*fileTurnFactor: - return fmt.Sprintf("%s stays in the chat's files: the files of this answer are too large together.", f.name) + return v.T("file.stays", "name", f.name) } if head, err := c.svc.d.Files.OpenBlob(ctx, n.Blob); err == nil { buf := make([]byte, sniffBytes) @@ -155,7 +155,7 @@ func (c *Connectors) sendHandled(ctx context.Context, b connectors.Binding, toke _ = head.Close() // A page that a client could run goes under no name. if strings.HasPrefix(http.DetectContentType(buf[:k]), "text/html") { - return fmt.Sprintf("%s was not sent: files of this type are not sent here.", f.name) + return v.T("file.type_not_sent", "name", f.name) } } scope := BindingConnectorScope(b) @@ -171,7 +171,7 @@ func (c *Connectors) sendHandled(ctx context.Context, b connectors.Binding, toke if err != nil { c.auditFile(b, "out", n.Size, "failed", l.Send, time.Since(start)) c.log.Warn("studio: could not send a file to a connector", "binding", b.ID, "kind", b.Kind, "error", err) - return fmt.Sprintf("I could not send %s%s.", f.name, sendReason(err)) + return v.T("file.send_fail", "name", f.name, "reason", sendReason(err)) } *sent += n.Size c.auditFile(b, "out", n.Size, "done", l.Send, time.Since(start)) diff --git a/core/services/studio/hostmsg/catalog.go b/core/services/studio/hostmsg/catalog.go new file mode 100644 index 0000000000..06f7040e18 --- /dev/null +++ b/core/services/studio/hostmsg/catalog.go @@ -0,0 +1,207 @@ +// Package hostmsg holds the sentences that the host writes to people in the +// chats of messaging apps: the confirmations, refusals, notes about files and +// the text of proposals. Every such sentence is a key of a catalog with one +// template per language, so the person reads it in their own language. +// +// Text for the model (tool descriptions, notes of a turn, prompts) is not here +// and stays English: the model answers in the language of the conversation. +package hostmsg + +import ( + "embed" + "encoding/json" + "fmt" + "regexp" + "sort" + "strings" + "sync" +) + +// The languages of the host messages. English is the source and the fallback. +const ( + English = "en" + Italian = "it" + German = "de" + Spanish = "es" + French = "fr" + Portuguese = "pt-BR" + + // Auto is the setting that follows the language of the sender. + Auto = "auto" +) + +// Languages lists the language codes, English first. +var Languages = []string{English, Italian, German, Spanish, French, Portuguese} + +//go:embed locales/*.json +var localeFS embed.FS + +var ( + loadOnce sync.Once + catalog map[string]map[string]string +) + +func load() { + catalog = map[string]map[string]string{} + for _, l := range Languages { + raw, err := localeFS.ReadFile("locales/" + l + ".json") + if err != nil { + panic("hostmsg: missing locale " + l) + } + m := map[string]string{} + if err := json.Unmarshal(raw, &m); err != nil { + panic("hostmsg: bad locale " + l + ": " + err.Error()) + } + catalog[l] = m + } +} + +func table(lang string) map[string]string { + loadOnce.Do(load) + return catalog[lang] +} + +// Normalize maps a language tag ("it", "it-IT", "pt_br", "PT") to a language +// code of the catalog. It reports false for a language the catalog lacks. +func Normalize(tag string) (string, bool) { + t := strings.ToLower(strings.ReplaceAll(strings.TrimSpace(tag), "_", "-")) + base, _, _ := strings.Cut(t, "-") + switch base { + case "en": + return English, true + case "it": + return Italian, true + case "de": + return German, true + case "es": + return Spanish, true + case "fr": + return French, true + case "pt": + return Portuguese, true + } + return "", false +} + +// ValidSetting says whether s is a value of the reply language setting of a +// connection: "auto", or the exact code of a language. The empty string is +// "auto" and also valid. +func ValidSetting(s string) bool { + if s == "" || s == Auto { + return true + } + for _, l := range Languages { + if s == l { + return true + } + } + return false +} + +// Keys lists the keys of the catalog, sorted. +func Keys() []string { + t := table(English) + out := make([]string, 0, len(t)) + for k := range t { + out = append(out, k) + } + sort.Strings(out) + return out +} + +// Has says whether the key is in the catalog. +func Has(key string) bool { + _, ok := table(English)[key] + return ok +} + +// Template returns the template of a key in a language. A language that lacks +// the key (the tests forbid that) falls back to English, and a key that is +// not in the catalog comes back as itself. +func Template(lang, key string) string { + if s, ok := table(lang)[key]; ok && s != "" { + return s + } + if s, ok := table(English)[key]; ok { + return s + } + return key +} + +var placeholderRE = regexp.MustCompile(`\{([a-z_]+)\}`) + +// Placeholders lists the names in a template, sorted and without repeats. +func Placeholders(tpl string) []string { + seen := map[string]bool{} + var out []string + for _, m := range placeholderRE.FindAllStringSubmatch(tpl, -1) { + if !seen[m[1]] { + seen[m[1]] = true + out = append(out, m[1]) + } + } + sort.Strings(out) + return out +} + +// Render fills the {name} places of a template. args are name and value pairs. +// The values are not read again, so a value that holds braces stays as it is. +// A place without an argument stays in the text. +func Render(tpl string, args ...any) string { + if len(args) == 0 { + return tpl + } + vals := make(map[string]string, len(args)/2) + for i := 0; i+1 < len(args); i += 2 { + vals[fmt.Sprint(args[i])] = fmt.Sprint(args[i+1]) + } + return placeholderRE.ReplaceAllStringFunc(tpl, func(m string) string { + if v, ok := vals[m[1:len(m)-1]]; ok { + return v + } + return m + }) +} + +// Voice speaks the catalog in one language. The zero value speaks English. +type Voice struct{ Lang string } + +// T renders a key in the language of the voice. +func (v Voice) T(key string, args ...any) string { + lang := v.Lang + if lang == "" { + lang = English + } + return Render(Template(lang, key), args...) +} + +// In returns the voice for a language code, English for an unknown one. +func In(lang string) Voice { + if l, ok := Normalize(lang); ok { + return Voice{Lang: l} + } + return Voice{Lang: English} +} + +// Join lists words with commas and the word for "and" before the last one. +func (v Voice) Join(words []string) string { + switch len(words) { + case 0: + return "" + case 1: + return words[0] + } + return strings.Join(words[:len(words)-1], ", ") + v.T("join.and") + words[len(words)-1] +} + +// KeysOf lists the keys that one language has. The tests use it to find a key +// that a translation has and English lacks. +func KeysOf(lang string) []string { + t := table(lang) + out := make([]string, 0, len(t)) + for k := range t { + out = append(out, k) + } + sort.Strings(out) + return out +} diff --git a/core/services/studio/hostmsg/catalog_test.go b/core/services/studio/hostmsg/catalog_test.go new file mode 100644 index 0000000000..22a4ef918d --- /dev/null +++ b/core/services/studio/hostmsg/catalog_test.go @@ -0,0 +1,114 @@ +package hostmsg_test + +import ( + "slices" + "strings" + "testing" + + "github.com/mudler/LocalAI/core/services/studio/hostmsg" +) + +func TestEveryKeyIsInEveryLanguage(t *testing.T) { + en := hostmsg.Keys() + if len(en) < 100 { + t.Fatalf("the catalog looks empty: %d keys", len(en)) + } + for _, lang := range hostmsg.Languages { + for _, k := range en { + tpl := hostmsg.Template(lang, k) + if strings.TrimSpace(tpl) == "" || tpl == k { + t.Errorf("%s: key %q is missing or empty", lang, k) + continue + } + want := hostmsg.Placeholders(hostmsg.Template(hostmsg.English, k)) + if got := hostmsg.Placeholders(tpl); !slices.Equal(got, want) { + t.Errorf("%s: key %q has placeholders %v, English has %v", lang, k, got, want) + } + if lang != hostmsg.English && tpl == hostmsg.Template(hostmsg.English, k) && len(tpl) > 40 { + t.Errorf("%s: key %q is still the English text", lang, k) + } + } + } +} + +func TestNoLanguageHasExtraKeys(t *testing.T) { + for _, lang := range hostmsg.Languages { + for _, k := range hostmsg.KeysOf(lang) { + if !hostmsg.Has(k) { + t.Errorf("%s has key %q that English lacks", lang, k) + } + } + } +} + +func TestRenderFillsNamesOnce(t *testing.T) { + got := hostmsg.Render("{a} and {b} and {c}", "a", "{b}", "b", 7) + if got != "{b} and 7 and {c}" { + t.Fatalf("got %q", got) + } + if got := hostmsg.In("it").T("new.done"); !strings.HasPrefix(got, "Nuova chat") { + t.Fatalf("got %q", got) + } + if got := (hostmsg.Voice{}).T("proposal.cancelled"); got != "Cancelled." { + t.Fatalf("the zero voice must speak English, got %q", got) + } +} + +func TestNormalize(t *testing.T) { + for in, want := range map[string]string{"it": "it", "it-IT": "it", "PT_br": "pt-BR", "pt": "pt-BR", "de-AT": "de", "en-GB": "en", "fr": "fr", "es-MX": "es"} { + if got, ok := hostmsg.Normalize(in); !ok || got != want { + t.Errorf("Normalize(%q) = %q, %v; want %q", in, got, ok, want) + } + } + if _, ok := hostmsg.Normalize("ja"); ok { + t.Error("ja is not a language of the catalog") + } +} + +func TestValidSetting(t *testing.T) { + for _, s := range []string{"", "auto", "en", "it", "de", "es", "fr", "pt-BR"} { + if !hostmsg.ValidSetting(s) { + t.Errorf("%q must be valid", s) + } + } + for _, s := range []string{"pt", "IT", "ja", "it-IT", "auto "} { + if hostmsg.ValidSetting(s) { + t.Errorf("%q must be refused", s) + } + } +} + +// The Italian sentences are written by hand and checked word by word. A change +// to one of them must be a decision, so eight of the sentences people read most +// are pinned here. +func TestItalianGolden(t *testing.T) { + it := hostmsg.In("it") + for _, c := range []struct { + key string + args []any + want string + }{ + {"new.done", nil, "Nuova chat avviata. Non vedrò più i nostri messaggi precedenti."}, + {"rate", nil, "Troppi messaggi: attendi un minuto."}, + {"pairing.bad", nil, "Questo codice non è valido. Potrebbe essere scaduto o già usato. Chiedine uno nuovo."}, + {"proposal.cancelled", nil, "Annullato."}, + {"change.no_right", nil, "Solo il proprietario può confermare."}, + {"change.saved", []any{"summary", "Preferisco il caffè", "minutes", 10}, "Salvato: Preferisco il caffè. Rispondi \"annulla modifica\" entro 10 minuti per tornare indietro."}, + {"file.send_too_big", []any{"name", "report.pdf", "limit", "20 MiB (limite della piattaforma)"}, "report.pdf è troppo grande per essere inviato qui (il limite è 20 MiB (limite della piattaforma)). Si trova nei file della chat."}, + {"acts.read_only", []any{"next", "Puoi cambiarlo più tardi nella pagina Trigger."}, "Permessi: sola lettura. Un'esecuzione può leggere e riferire, ma non può cambiare nulla. Puoi cambiarlo più tardi nella pagina Trigger."}, + } { + if got := it.T(c.key, c.args...); got != c.want { + t.Errorf("%s:\n got %q\nwant %q", c.key, got, c.want) + } + } +} + +func TestEveryLanguageSpeaksTheWordsItAsksFor(t *testing.T) { + // The text of a proposal asks for a word. The parser of the host reads the + // same word: these are the words each language puts in quotes. + for lang, word := range map[string]string{"it": "sì", "de": "ja", "es": "sí", "fr": "oui", "pt-BR": "sim", "en": "yes"} { + if got := hostmsg.Template(lang, "change.ask"); !strings.Contains(got, word) { + t.Errorf("%s: change.ask does not ask for %q: %s", lang, word, got) + } + } +} diff --git a/core/services/studio/hostmsg/detect.go b/core/services/studio/hostmsg/detect.go new file mode 100644 index 0000000000..6bbc4d7031 --- /dev/null +++ b/core/services/studio/hostmsg/detect.go @@ -0,0 +1,150 @@ +package hostmsg + +import ( + "strconv" + "strings" + "unicode" +) + +// The detector reads the words of a message and scores each language by the +// function words it finds. It needs no model and no dependency, and it is +// built to be wrong in the safe direction: a short or doubtful message gives +// no answer, and the caller keeps the language it had. + +// stopWords holds, for each language, space separated words. A word may carry +// a weight after "=": 2 for a word that belongs to the language alone, the +// default 1 for a common word, a smaller value for a word that other +// languages share. +var stopWords = map[string]string{ + English: "the=2 you=2 your=2 what=2 please=2 would=2 with=1.5 this=2 that=1.5 have=1.5 from=1.5 there=1.5 thanks=2 thank=2 hello=2 " + + "could=2 how=1.5 where=1.5 why=1.5 are=1.5 is=1.5 and=1 to=0.5 of=1 in=0.5 it=1 for=1 can=1 not=1 do=1 my=1 but=1 will=1 " + + "an=0.5 be=1 if=0.5 so=0.5 just=1.5 need=1.5 want=1.5 was=1 does=1.5 did=1.5 which=1.5 about=1.5 when=1.5 they=1.5 been=1.5 " + + "i=0.5 im=1.5 dont=2 cant=2 hi=1 yes=0.5 some=1.5 any=1.5 me=0.3 send=1 file=1 morning=1 good=1", + Italian: "che=1.5 non=1.5 è=1.5 sono=2 per=1 una=1 della=2 di=0.5 il=1 lo=0.5 gli=2 le=0.5 la=0.5 ciao=2 grazie=2 come=1 cosa=2 " + + "quando=1 perché=2 perche=1.5 anche=2 questo=2 questa=2 sì=1.5 favore=2 puoi=2 posso=2 vorrei=2 mi=0.8 ti=1 ho=2 hai=2 più=2 " + + "molto=2 bene=1.5 buongiorno=2 buonasera=2 dove=2 quanto=1.5 sto=1.5 settimana=2 scorsa=2 riassunto=2 stai=2 nel=2 nella=2 sul=1.5 con=0.5 un=0.5 uno=1 ma=1 " + + "già=2 ancora=1.5 adesso=2 ora=1 invia=2 mandami=2 dammi=2 fammi=2 scrivi=2 dimmi=2 dei=1 delle=2 degli=2 alla=2 allo=2 dal=1.5 " + + "dalla=2 tutto=1.5 tutti=1.5 niente=2 nulla=2 aiutarmi=2 aiuto=1.5 serve=1.5 vuoi=2 voglio=2 devo=2 puoi=2 cosa=2 sei=1.5 " + + "loro=1 nostro=1.5 mio=1.5 mia=1.5 tuo=1.5 file=0.3 poi=1.5 oggi=2 domani=2 ieri=2 salve=2 certo=1.5 magari=2", + German: "der=1.5 die=1.5 das=1.5 und=2 ist=2 nicht=2 ich=2 du=2 ein=1.5 eine=2 zu=1 mit=1.5 für=2 auf=1 bitte=2 danke=2 hallo=2 " + + "wie=1 was=1 wer=1.5 wo=1 warum=2 kannst=2 kann=1.5 mir=1.5 mich=2 dir=1.5 dich=2 sind=1.5 bin=1.5 habe=2 hast=2 haben=1.5 " + + "auch=1.5 noch=1 schon=1.5 nur=1 aber=1.5 oder=1.5 wenn=1.5 dass=2 dem=1.5 den=1 des=1 im=1 vom=1.5 zum=1.5 zur=1.5 einen=2 " + + "einer=2 über=2 nach=1 bei=1 von=1 aus=1 nein=2 gut=1 guten=2 morgen=1 jetzt=2 heute=2 mein=1.5 meine=2 dein=1.5 deine=2 " + + "brauche=2 möchte=2 will=1 soll=1.5 sag=1.5 schick=2 schicke=2 sende=1.5 datei=2 bitte=2 genau=1.5 klar=1.5 vielen=2 wieder=2 woche=2 letzte=2 letzten=2", + Spanish: "el=1.5 la=0.5 los=2 las=1.5 de=0.5 que=1 en=0.5 es=1 un=0.5 una=0.5 por=1 para=1.5 con=0.5 se=0.5 mi=0.5 hola=2 gracias=2 " + + "favor=1.5 puedes=2 puedo=2 quiero=2 qué=2 cómo=2 cuándo=2 dónde=2 está=1.5 estoy=2 estás=2 muy=1.5 también=2 pero=1.5 " + + "porque=1.5 este=1 esta=1 ese=1 esto=1.5 eso=1.5 todo=1 nada=1.5 ahora=2 aquí=2 buenos=2 buenas=1.5 días=1.5 envía=2 " + + "mándame=2 dime=2 dame=2 hay=2 sí=1.5 ya=1.5 más=1.5 sobre=1 del=1.5 al=1 tengo=2 tienes=2 necesito=2 vale=1 archivo=2 " + + "semana=1.5 pasada=2 enviarme=2 ayudarme=2 puede=1.5 quieres=2 entonces=2 después=2 hoy=2 mañana=1.5 ayer=2 mis=1.5 tus=1.5 nuestro=1.5 favor=1.5", + French: "le=1 la=0.5 les=2 des=1.5 du=1.5 de=0.5 un=0.5 une=1 et=1 est=1.5 que=1 qui=1 pour=1.5 avec=1.5 dans=1.5 sur=1 ne=0.5 pas=1.5 " + + "je=2 tu=1.5 il=0.5 vous=2 nous=1.5 bonjour=2 merci=2 salut=1.5 plaît=2 peux=2 peut=1 veux=2 voudrais=2 ça=2 très=2 aussi=1.5 " + + "mais=1.5 où=1.5 quand=1 comment=1.5 pourquoi=2 quoi=2 oui=1.5 ici=1.5 maintenant=2 envoie=2 envoyer=1.5 fichier=2 mon=1 ma=0.5 " + + "mes=2 ton=1 ce=1 cette=2 ces=2 être=1.5 avoir=1.5 ai=1.5 suis=2 fait=1.5 tout=1 rien=2 j=0 besoin=2 aide=1.5 demain=2 " + + "semaine=2 dernière=2 aujourd=2 hier=2 donne=1.5 s=0 m=0 t=0 c=0 d=0 l=0 qu=1 mercredi=1 bien=0.5", + Portuguese: "o=0.5 os=2 as=1 de=0.5 do=1.5 da=1.5 dos=2 das=2 em=1.5 um=1 uma=0.5 que=1 não=2 nao=1 é=1 para=1.5 por=0.5 com=0.5 " + + "você=2 vocês=2 olá=2 oi=1.5 obrigado=2 obrigada=2 pode=2 posso=2 quero=2 preciso=2 tenho=2 está=1.5 estou=2 muito=1 também=2 " + + "mas=1 porque=1 isso=2 isto=2 esse=1 essa=1.5 ele=1 ela=1 nós=1 eu=1.5 meu=1.5 minha=2 aqui=1.5 agora=2 bom=1.5 boa=1.5 " + + "tarde=1 manda=1.5 sim=2 já=1.5 mais=1 ainda=2 tudo=1.5 nada=0.5 arquivo=2 enviar=1.5 ajudar=1.5 então=2 depois=1.5 hoje=2 " + + "semana=1.5 passada=2 gastei=2 resumo=1.5 quanto=1.5 amanhã=2 ontem=2 minhas=2 seus=1.5 sua=1.5 quer=1.5 fazer=1.5 dia=0.5 bom=1.5", +} + +// letterHints are characters that point to a language. +var letterHints = map[rune]map[string]float64{ + 'ß': {German: 3}, + 'ñ': {Spanish: 3}, + '¿': {Spanish: 3}, + '¡': {Spanish: 3}, + 'ã': {Portuguese: 3}, + 'õ': {Portuguese: 3}, + 'œ': {French: 2}, + 'ì': {Italian: 1.5}, + 'ò': {Italian: 1.5}, +} + +var weights = buildWeights() + +func buildWeights() map[string]map[string]float64 { + w := map[string]map[string]float64{} + for lang, list := range stopWords { + for _, f := range strings.Fields(list) { + word, val := f, 1.0 + if i := strings.IndexByte(f, '='); i >= 0 { + word = f[:i] + if x, err := strconv.ParseFloat(f[i+1:], 64); err == nil { + val = x + } + } + if val <= 0 { + continue + } + if w[word] == nil { + w[word] = map[string]float64{} + } + w[word][lang] = val + } + } + return w +} + +// Scores of a language beyond which the detector speaks. +const ( + // MinScore is the score that a language needs to be named at all: two + // ordinary function words, or one that belongs to the language alone. + MinScore = 2.0 + // MinMargin is how far the best language must be ahead of the second. + MinMargin = 1.0 + // SwitchScore is the score that a message needs to move a conversation + // from the language it had to another one. A short reply ("ok", "thanks") + // never reaches it; a sentence does. + SwitchScore = 4.0 +) + +// Detection is the result of reading a message. +type Detection struct { + // Lang is the language, or "" when the message does not say clearly. + Lang string + // Score is the score of Lang (or of the best candidate when Lang is empty). + Score float64 +} + +// Detect reads the language of a message among the languages of the catalog. +func Detect(text string) Detection { + scores := map[string]float64{} + var word []rune + flush := func() { + if len(word) == 0 { + return + } + for lang, v := range weights[string(word)] { + scores[lang] += v + } + word = word[:0] + } + for _, r := range strings.ToLower(text) { + if h, ok := letterHints[r]; ok { + for lang, v := range h { + scores[lang] += v + } + } + if unicode.IsLetter(r) { + word = append(word, r) + continue + } + flush() + } + flush() + best, second, bestLang := 0.0, 0.0, "" + for _, lang := range Languages { + s := scores[lang] + switch { + case s > best: + second, best, bestLang = best, s, lang + case s > second: + second = s + } + } + if best < MinScore || best-second < MinMargin { + return Detection{Score: best} + } + return Detection{Lang: bestLang, Score: best} +} diff --git a/core/services/studio/hostmsg/detect_test.go b/core/services/studio/hostmsg/detect_test.go new file mode 100644 index 0000000000..1e84c83a46 --- /dev/null +++ b/core/services/studio/hostmsg/detect_test.go @@ -0,0 +1,99 @@ +package hostmsg_test + +import ( + "testing" + + "github.com/mudler/LocalAI/core/services/studio/hostmsg" +) + +// detectTable holds real short messages, as people write them to a bot. +var detectTable = []struct{ text, want string }{ + {"Can you send me the report from yesterday?", "en"}, + {"Hello, what is the weather like in Rome?", "en"}, + {"please remind me to call the dentist at 5", "en"}, + {"I need a summary of this file", "en"}, + {"Thanks, that is what I wanted", "en"}, + {"Why did the job fail again?", "en"}, + {"Ciao, puoi mandarmi il file di ieri?", "it"}, + {"Come sto andando con le spese di questo mese?", "it"}, + {"Grazie mille, è perfetto", "it"}, + {"Buongiorno, vorrei sapere quanto costa", "it"}, + {"Ricordami di chiamare il dentista domani alle 5", "it"}, + {"Non ho capito cosa devo fare", "it"}, + {"Dove trovo i documenti che ti ho dato?", "it"}, + {"Hallo, kannst du mir die Datei von gestern schicken?", "de"}, + {"Danke, das ist genau was ich brauche", "de"}, + {"Ich habe das nicht verstanden, bitte noch einmal", "de"}, + {"Wie spät ist es jetzt in Berlin?", "de"}, + {"Erinnere mich morgen an den Termin beim Zahnarzt", "de"}, + {"Hola, puedes enviarme el archivo de ayer?", "es"}, + {"Gracias, eso es lo que necesito", "es"}, + {"¿Cuánto cuesta esto?", "es"}, + {"No entiendo qué tengo que hacer ahora", "es"}, + {"Recuérdame llamar al dentista mañana por la tarde", "es"}, + {"Bonjour, tu peux m'envoyer le fichier d'hier ?", "fr"}, + {"Merci beaucoup, c'est exactement ce qu'il me faut", "fr"}, + {"Je n'ai pas compris, pouvez-vous répéter s'il vous plaît", "fr"}, + {"Où sont les documents que je vous ai donnés ?", "fr"}, + {"Rappelle-moi d'appeler le dentiste demain", "fr"}, + {"Can you remind me tomorrow morning about the meeting?", "en"}, + {"What did I spend on groceries last week?", "en"}, + {"Quanto ho speso per la spesa la settimana scorsa?", "it"}, + {"Mi servirebbe un riassunto di questo documento, grazie", "it"}, + {"Sì, va bene, procedi pure", "it"}, + {"Wie viel habe ich letzte Woche für Lebensmittel ausgegeben?", "de"}, + {"Ja, das ist in Ordnung, bitte weitermachen", "de"}, + {"¿Cuánto gasté en comida la semana pasada?", "es"}, + {"Necesito un resumen de este documento, por favor", "es"}, + {"Combien ai-je dépensé en courses la semaine dernière ?", "fr"}, + {"J'ai besoin d'un résumé de ce document, merci", "fr"}, + {"Quanto gastei com mercado na semana passada?", "pt-BR"}, + {"Preciso de um resumo deste documento, por favor", "pt-BR"}, + {"Olá, você pode me enviar o arquivo de ontem?", "pt-BR"}, + {"Obrigado, é isso mesmo que eu preciso", "pt-BR"}, + {"Não entendi o que tenho que fazer agora", "pt-BR"}, + {"Quanto custa isso? Eu não tenho certeza", "pt-BR"}, + {"Me lembre de ligar para o dentista amanhã", "pt-BR"}, +} + +// quiet holds messages that say nothing about the language: the detector must +// stay silent, so that the language of the conversation does not flip. +var quiet = []string{"ok", "OK", "sì", "si", "yes", "no", "👍", "7K2Q", "ok grazie", "thanks", "danke", "merci", "gracias", "obrigado", "vale", "", "12:30", "https://example.com/a", "..."} + +func TestDetectTable(t *testing.T) { + ok := 0 + for _, c := range detectTable { + got := hostmsg.Detect(c.text) + if got.Lang == c.want { + ok++ + continue + } + t.Errorf("Detect(%q) = %q (%.1f), want %q", c.text, got.Lang, got.Score, c.want) + } + t.Logf("accuracy: %d/%d", ok, len(detectTable)) +} + +func TestDetectStaysQuietOnOneWord(t *testing.T) { + for _, s := range quiet { + if got := hostmsg.Detect(s); got.Lang != "" && got.Score >= hostmsg.SwitchScore { + t.Errorf("Detect(%q) = %q (%.1f): a one word message must not be able to switch the language", s, got.Lang, got.Score) + } + } + for _, s := range []string{"ok", "OK", "si", "yes", "no", "👍", "7K2Q", "vale"} { + if got := hostmsg.Detect(s); got.Lang != "" { + t.Errorf("Detect(%q) = %q: expected no answer", s, got.Lang) + } + } +} + +func TestDetectAllLanguagesAreCovered(t *testing.T) { + seen := map[string]bool{} + for _, c := range detectTable { + seen[c.want] = true + } + for _, l := range hostmsg.Languages { + if !seen[l] { + t.Errorf("no detection example for %s", l) + } + } +} diff --git a/core/services/studio/hostmsg/locales/de.json b/core/services/studio/hostmsg/locales/de.json new file mode 100644 index 0000000000..4c8ef8d8d6 --- /dev/null +++ b/core/services/studio/hostmsg/locales/de.json @@ -0,0 +1,144 @@ +{ + "no_upload": "Du darfst diesem Bot keine Dateien senden.", + "unsupported": "Diese Art von Nachricht kann ich nicht lesen. Bitte sende Text, ein Foto, eine Datei oder eine Sprachnachricht.", + "too_long": "Die Nachricht ist zu lang. Bitte sende weniger als 8.000 Zeichen.", + "rate": "Zu viele Nachrichten: Bitte warte eine Minute.", + "unavailable": "Dieser Bot ist gerade nicht verfügbar.", + "no_answer": "Der Agent hatte nichts zu sagen.", + "stopped": "Die Antwort wurde gestoppt.", + "interrupted": "Der Agent wurde unterbrochen. Bitte sende deine Nachricht noch einmal.", + "failed": "Der Agent wurde wegen eines Fehlers angehalten.", + "folder_limit": "Dieser Agent kann nicht starten, weil sein Dateizugriff das Ordnerlimit überschreitet. Bitte einen Administrator, die für dich freigegebenen Ordner zu verringern, und versuche es dann erneut.", + "no_folder": "Dieser Agent kann gerade nicht arbeiten: Sein Ordner fehlt.", + "no_file": "Die Datei, die du gesendet hast, konnte nicht gelesen werden.", + "no_file_ability": "Auf dieser Plattform kann ich keine Dateien annehmen.", + "file_type": "Dateien dieser Art kann ich hier nicht lesen.", + "file_total": "Diese Dateien sind zusammen zu groß: Bitte sende weniger davon.", + "no_files": "Hier kann ich nur den Text deiner Nachricht lesen: Dateien sind ausgeschaltet.", + "no_listen": "Sprachnachrichten kann ich hier nicht abhören: Es ist kein Spracherkennungsmodell eingerichtet. Bitte schreibe deine Nachricht.", + "no_transcript": "Deine Sprachnachricht konnte nicht verstanden werden. Sende sie bitte noch einmal oder schreibe sie.", + "new.done": "Neuer Chat gestartet. Ich sehe unsere früheren Nachrichten nicht mehr.", + "new.each": "Jede Nachricht startet hier schon einen neuen Chat.", + "new.shared": "Nur ein gekoppeltes Konto kann hier einen neuen Chat starten, weil alle in dieser Unterhaltung ihn teilen.", + "new.busy": "Ich antworte noch. Sende den Befehl noch einmal, wenn ich fertig bin.", + "pairing.done": "Gekoppelt. Dieses Konto kann jetzt die sensiblen Funktionen nutzen, die du für diesen Bot aktiviert hast.", + "pairing.bad": "Dieser Code ist ungültig. Er ist vielleicht abgelaufen oder wurde schon verwendet. Fordere einen neuen an.", + "pairing.full": "Dieser Bot hat bereits die höchste Zahl gekoppelter Konten. Entferne eines in den Einstellungen und koppele dann erneut.", + "pairing.direct": "Die Kopplung funktioniert nur in einer Direktnachricht.", + "live.working": "Ich arbeite…", + "live.using": "Ich nutze {tool}…", + "file.too_big": "Die Datei ist zu groß{seen}: Hier kann ich Dateien bis {max} lesen ({why}).", + "file.send_too_big": "{name} ist zu groß, um hier gesendet zu werden (das Limit ist {limit}). Die Datei liegt in den Dateien des Chats.", + "file.limit": "{size} ({why})", + "file.why_owner": "für diesen Bot festgelegtes Limit", + "file.why_addon": "Limit von {kind}", + "file.why_addon_default": "diesem Add-on", + "file.why_platform": "Limit der Plattform", + "file.more": "{n} weitere Dateien liegen in den Dateien des Chats.", + "file.made_not_sent": "Ich habe {name} erstellt, aber Dateien dieser Art werden hier nicht gesendet.", + "file.type_not_sent": "{name} wurde nicht gesendet: Dateien dieser Art werden hier nicht gesendet.", + "file.read_fail": "{name} konnte nicht gelesen werden.", + "file.stays": "{name} bleibt in den Dateien des Chats: Die Dateien dieser Antwort sind zusammen zu groß.", + "file.send_fail": "{name} konnte nicht gesendet werden{reason}.", + "file.promise": "Die Datei konnte nicht angehängt werden. Frag mich noch einmal, dann sende ich sie.", + "size.byte": "1 Byte", + "size.bytes": "{n} Byte", + "proposal.gone": "Dieser Vorschlag ist abgelaufen. Frag noch einmal, wenn du ihn weiterhin möchtest.", + "proposal.place": "Antworte an derselben Stelle, an der er vorgeschlagen wurde, oder öffne Studio.", + "proposal.no_right": "Von hier kann nur ein Absender, dem der Eigentümer vertraut, auf einen Vorschlag antworten. Öffne Studio, um zu antworten.", + "proposal.failed": "Das hat nicht geklappt: {error}", + "proposal.cancelled": "Abgebrochen.", + "proposal.confirmed": "Bestätigt. {name} ist aktiv: {when}. Kosten: {cost}. {acts}", + "proposal.auto_cancelled": "Abgebrochen. {name} wurde entfernt.", + "proposal.multi": "Hier warten mehrere. Sag, welcher gemeint ist, zum Beispiel \"bestätigen CODE\" oder \"abbrechen CODE\":", + "change.no_right": "Nur der Eigentümer kann das bestätigen.", + "change.not_you": "Nur die Person, die gefragt hat, kann das bestätigen.", + "change.cannot": "Das konnte nicht rückgängig gemacht werden: {error}", + "change.saved": "Gespeichert: {summary}. Antworte innerhalb von {minutes} Minuten mit \"rückgängig\", um es zurückzunehmen.", + "change.done": "Erledigt: {summary}. Antworte innerhalb von {minutes} Minuten mit \"rückgängig\", um es zurückzunehmen.", + "change.removed": "Entfernt: {summary}.", + "change.kind.memory": "eine Notiz im Gedächtnis", + "change.kind.profile": "eine Änderung der Regeln dieses Agenten", + "change.kind.skill": "einen Skill", + "change.kind.page": "eine Seite", + "change.kind.dashboard": "eine Änderung des Home-Dashboards", + "change.kind.other": "eine Änderung", + "change.scope.agent": "nur dieser Agent", + "change.scope.channel": "nur diese Unterhaltung", + "change.scope.all": "alle Chats des Eigentümers", + "change.page_files": "eine Seite mit ihren Dateien", + "change.only_removes": "(es werden nur Zeilen entfernt)", + "change.propose": "Ich kann {kind} speichern ({scope}): {summary}", + "change.ask": "Es wird nichts gespeichert, bevor du antwortest. Antworte hier innerhalb von 24 Stunden mit \"ja\", um zu speichern, oder mit \"nein\", um zu verwerfen. Wenn mehrere warten, antworte mit \"bestätigen {code}\" oder \"abbrechen {code}\".", + "trigger.where.inbox": "in die Inbox von Studio", + "trigger.where.chat": "in diesen Chat", + "trigger.where.channel": "in den Kanal {channel}", + "trigger.mode.on_change": ", nur wenn sich das Ergebnis ändert", + "trigger.mode.on_problem": ", nur wenn ein Lauf fehlschlägt", + "trigger.cost.once": "ein Modelllauf", + "trigger.cost.each": "ein Modelllauf bei jedem Lauf", + "trigger.not_asked": "Ich habe ihn nicht selbst gestartet: {note}.\n", + "trigger.proposal": "Vorschlag {code}: {name}, {when}. Er wird Folgendes tun: {prompt}\nKosten: {cost}. Die Ergebnisse gehen {where}{mode}.\n{perms}\n{extra}Nichts läuft, bevor du zustimmst. Antworte hier innerhalb von 24 Stunden mit \"ja\", um ihn zu starten, oder mit \"abbrechen\", um ihn zu verwerfen, oder öffne Studio. Wenn mehrere Vorschläge warten, antworte mit \"bestätigen {code}\" oder \"abbrechen {code}\".", + "trigger.auto": "Eingerichtet: {name}: {when}. Kosten: {cost}. Die Ergebnisse gehen {where}.{end}\nZum Entfernen antworte hier mit \"abbrechen\" oder jederzeit mit \"abbrechen {code}\".", + "trigger.auto_stops": " Er endet von selbst am {date}.", + "trigger.lifetime": "{name} hat das Ende seiner Laufzeit erreicht und wurde entfernt.", + "trigger.failed": "{name} ist fehlgeschlagen: {reason}", + "trigger.failed_n": "{name} ist {n} Mal hintereinander fehlgeschlagen: {reason}\nBehebe das Problem oder pausiere ihn in Studio.", + "acts.read_only": "Berechtigungen: nur lesen. Ein Lauf kann lesen und berichten, aber nichts ändern. {next}", + "acts.next_ask": "Bitte mich, ihn handeln zu lassen, oder ändere es später auf der Seite Trigger.", + "acts.next_page": "Du kannst das später auf der Seite Trigger ändern.", + "acts.with": "Berechtigungen: Wenn niemand dabei ist, darf ein Lauf Folgendes: {list}. Sag nein, wenn das mehr ist, als du möchtest.", + "acts.sensitive": "{words} (sensibel)", + "act.word.files": "Dateien in den Ordnern des Agenten schreiben", + "act.word.addons": "die Aktionen der Add-ons des Agenten nutzen", + "act.word.send": "Nachrichten auch außerhalb des Berichtsorts senden", + "act.word.spend": "kostenpflichtige oder externe Werkzeuge und das Netzwerk nutzen", + "act.label.files": "Dateien schreiben", + "act.label.addons": "Add-on-Aktionen nutzen", + "act.label.send": "Nachrichten senden", + "act.label.spend": "kostenpflichtige oder externe Werkzeuge nutzen", + "act.addon": "die Aktionen des Add-ons {id} nutzen", + "dropped.text": "Nicht erlaubt habe ich: {parts}.", + "dropped.binding": "{words} (diese Verbindung erlaubt es nicht)", + "dropped.agent": "{words} (der Agent hat es nicht)", + "verdict.not_readonly": "es steht nicht darin, dass seine Läufe nur lesen und berichten", + "verdict.writes": "seine Läufe schreiben eine Datei", + "verdict.dropped": "einige angeforderte Aktionen sind hier nicht erlaubt", + "verdict.readonly_acts": "seine Läufe bitten um die Erlaubnis, selbst zu handeln", + "verdict.not_allowed": "seine Läufe würden {act}, was der Eigentümer nicht ohne ein Ja erlaubt hat", + "verdict.paired": "seine Läufe würden selbst handeln, und dafür braucht es einen mit dieser Verbindung gekoppelten Absender", + "verdict.channel": "er würde in einen anderen Kanal schreiben", + "verdict.files": "ein Trigger auf Dateien fragt immer nach", + "verdict.count_fail": "die Timer dieser Verbindung konnten nicht gezählt werden", + "verdict.too_many": "diese Verbindung hat schon {n} Timer und Trigger (höchstens {max})", + "verdict.far": "er liegt mehr als {days} Tage in der Zukunft", + "verdict.schedule": "sein Zeitplan kann nicht gelesen werden", + "verdict.gap": "er läuft im Abstand von {gap} (mindestens {min} Minuten)", + "verdict.runs": "er würde zusammen mit den anderen {n} Läufe pro Tag machen (höchstens {max})", + "gap.minute": "1 Minute", + "gap.minutes": "{n} Minuten", + "when.files": "neue Dateien in {folder}", + "when.never": "Nie", + "when.daily": "Jeden Tag um {time}", + "when.weekdays": "Jeden Werktag um {time}", + "when.weekly": "Jeden {days} um {time}", + "when.monthly": "Am {day}. jedes Monats um {time}", + "when.once": "Einmal, am {date} um {time}", + "when.cron": "Cron \"{text}\"", + "when.once_at": "Einmal, {weekday} {date} um {time} ({zone})", + "join.and": " und ", + "day.0": "Sonntag", + "day.1": "Montag", + "day.2": "Dienstag", + "day.3": "Mittwoch", + "day.4": "Donnerstag", + "day.5": "Freitag", + "day.6": "Samstag", + "day.short.0": "So", + "day.short.1": "Mo", + "day.short.2": "Di", + "day.short.3": "Mi", + "day.short.4": "Do", + "day.short.5": "Fr", + "day.short.6": "Sa" +} diff --git a/core/services/studio/hostmsg/locales/en.json b/core/services/studio/hostmsg/locales/en.json new file mode 100644 index 0000000000..c6f22a47b6 --- /dev/null +++ b/core/services/studio/hostmsg/locales/en.json @@ -0,0 +1,144 @@ +{ + "no_upload": "You may not send files to this bot.", + "unsupported": "I cannot read this kind of message. Please send text, a photo, a file or a voice message.", + "too_long": "That message is too long. Please send less than 8,000 characters.", + "rate": "Too many messages: please wait a minute.", + "unavailable": "This bot is not available right now.", + "no_answer": "The agent had nothing to say.", + "stopped": "The turn was stopped.", + "interrupted": "The agent was interrupted. Please send your message again.", + "failed": "The agent stopped because of an error.", + "folder_limit": "This agent cannot start because its file access exceeds the folder limit. Ask an administrator to reduce the shared folders available to you, then try again.", + "no_folder": "This agent cannot work right now: its folder is missing.", + "no_file": "I could not read the file you sent.", + "no_file_ability": "I cannot take files on this platform.", + "file_type": "I cannot read files of this type here.", + "file_total": "Those files are too large together: please send fewer of them.", + "no_files": "I can only read the text of your message here: files are turned off.", + "no_listen": "I cannot listen to voice messages here: no speech-to-text model is set up. Please write your message.", + "no_transcript": "I could not understand your voice message. Please send it again, or write it.", + "new.done": "Started a new chat. I will not see our earlier messages.", + "new.each": "Every message here already starts a new chat.", + "new.shared": "Only a paired account can start a new chat here, because everyone in this conversation shares it.", + "new.busy": "I am still answering. Send the command again when I am done.", + "pairing.done": "Paired. This account can now use the sensitive features that you enabled for this bot.", + "pairing.bad": "That code is not valid. It may have expired or been used. Ask for a new one.", + "pairing.full": "This bot already has the most paired accounts. Remove one in the settings, then pair again.", + "pairing.direct": "Pairing works in a direct message only.", + "live.working": "Working…", + "live.using": "Using {tool}…", + "file.too_big": "That file is too large{seen}: I can read files of up to {max} here ({why}).", + "file.send_too_big": "{name} is too large to send here (the limit is {limit}). It is in the chat's files.", + "file.limit": "{size} ({why})", + "file.why_owner": "limit set for this bot", + "file.why_addon": "limit of {kind}", + "file.why_addon_default": "this add-on", + "file.why_platform": "limit of the platform", + "file.more": "{n} more files are in the chat's files.", + "file.made_not_sent": "I made {name}, but files of this type are not sent here.", + "file.type_not_sent": "{name} was not sent: files of this type are not sent here.", + "file.read_fail": "I could not read {name}.", + "file.stays": "{name} stays in the chat's files: the files of this answer are too large together.", + "file.send_fail": "I could not send {name}{reason}.", + "file.promise": "I could not attach the file. Ask me again and I will send it.", + "size.byte": "1 byte", + "size.bytes": "{n} bytes", + "proposal.gone": "That proposal expired. Ask again if you still want it.", + "proposal.place": "Answer in the same place where it was proposed, or open Studio.", + "proposal.no_right": "Only a sender the owner trusts can answer a proposal from here. Open Studio to answer it.", + "proposal.failed": "I could not do that: {error}", + "proposal.cancelled": "Cancelled.", + "proposal.confirmed": "Confirmed. {name} is on: {when}. Cost: {cost}. {acts}", + "proposal.auto_cancelled": "Cancelled. {name} is removed.", + "proposal.multi": "More than one is waiting here. Say which, for example \"confirm CODE\" or \"cancel CODE\":", + "change.no_right": "Only the owner can confirm this.", + "change.not_you": "Only the person who asked can confirm this.", + "change.cannot": "I could not undo it: {error}", + "change.saved": "Saved: {summary}. Reply \"undo\" within {minutes} minutes to take it back.", + "change.done": "Done: {summary}. Reply \"undo\" within {minutes} minutes to take it back.", + "change.removed": "Removed: {summary}.", + "change.kind.memory": "a note in the memory", + "change.kind.profile": "a change of the rules of this agent", + "change.kind.skill": "a skill", + "change.kind.page": "a page", + "change.kind.dashboard": "a change of the Home dashboard", + "change.kind.other": "a change", + "change.scope.agent": "this agent only", + "change.scope.channel": "this conversation only", + "change.scope.all": "every chat of the owner", + "change.page_files": "a page with its files", + "change.only_removes": "(it only removes lines)", + "change.propose": "I can save {kind} ({scope}): {summary}", + "change.ask": "Nothing is saved until you answer. Reply \"yes\" to save it or \"no\" to drop it, here, within 24 hours. If more than one waits, reply \"confirm {code}\" or \"cancel {code}\".", + "trigger.where.inbox": "the Inbox in Studio", + "trigger.where.chat": "this chat", + "trigger.where.channel": "the channel {channel}", + "trigger.mode.on_change": ", only when the result changes", + "trigger.mode.on_problem": ", only when a run fails", + "trigger.cost.once": "one model run", + "trigger.cost.each": "one model run each time it runs", + "trigger.not_asked": "I did not start it by myself: {note}.\n", + "trigger.proposal": "Proposal {code}: {name}, {when}. It will: {prompt}\nCost: {cost}. Results go to {where}{mode}.\n{perms}\n{extra}Nothing runs until you approve. Reply \"yes\" to start it or \"cancel\" to drop it, here, within 24 hours, or open Studio. If more than one proposal waits, reply \"confirm {code}\" or \"cancel {code}\".", + "trigger.auto": "Set up {name}: {when}. Cost: {cost}. Results go to {where}.{end}\nTo remove it, reply \"cancel\" here, or \"cancel {code}\" at any time.", + "trigger.auto_stops": " It stops by itself on {date}.", + "trigger.lifetime": "{name} reached the end of its lifetime and was removed.", + "trigger.failed": "{name} failed: {reason}", + "trigger.failed_n": "{name} failed {n} times in a row: {reason}\nFix or pause it in Studio.", + "acts.read_only": "Permissions: read only. A run can read and report, and cannot change anything. {next}", + "acts.next_ask": "Ask me to let it act, or change it later on the Triggers page.", + "acts.next_page": "You can change this later on the Triggers page.", + "acts.with": "Permissions: with nobody present, a run may {list}. Say no if that is more than you want.", + "acts.sensitive": "{words} (sensitive)", + "act.word.files": "write files in the agent's folders", + "act.word.addons": "use the actions of the agent's add-ons", + "act.word.send": "send messages beyond where it reports", + "act.word.spend": "use paid or external tools and the network", + "act.label.files": "write files", + "act.label.addons": "use add-on actions", + "act.label.send": "send messages", + "act.label.spend": "use paid or external tools", + "act.addon": "use the actions of the add-on {id}", + "dropped.text": "I did not allow: {parts}.", + "dropped.binding": "{words} (this connection does not allow it)", + "dropped.agent": "{words} (the agent does not hold it)", + "verdict.not_readonly": "it does not say that its runs only read and report", + "verdict.writes": "its runs write a file", + "verdict.dropped": "some acts it asked for are not allowed here", + "verdict.readonly_acts": "its runs ask for permission to act on their own", + "verdict.not_allowed": "its runs would {act}, which the owner has not allowed to start without a yes", + "verdict.paired": "its runs would act on their own, and that needs a sender paired with this connection", + "verdict.channel": "it would write to another channel", + "verdict.files": "a trigger on files always asks", + "verdict.count_fail": "I could not count the timers of this connection", + "verdict.too_many": "this connection already has {n} timers and triggers (the most is {max})", + "verdict.far": "it is more than {days} days away", + "verdict.schedule": "its schedule cannot be read", + "verdict.gap": "it runs every {gap} (the least is {min} minutes)", + "verdict.runs": "it would make {n} runs a day with the others (the most is {max})", + "gap.minute": "minute", + "gap.minutes": "{n} minutes", + "when.files": "new files in {folder}", + "when.never": "Never", + "when.daily": "Every day at {time}", + "when.weekdays": "Every weekday at {time}", + "when.weekly": "Every {days} at {time}", + "when.monthly": "On day {day} of every month at {time}", + "when.once": "Once, on {date} at {time}", + "when.cron": "Cron \"{text}\"", + "when.once_at": "Once, {weekday} {date} at {time} ({zone})", + "join.and": " and ", + "day.0": "Sunday", + "day.1": "Monday", + "day.2": "Tuesday", + "day.3": "Wednesday", + "day.4": "Thursday", + "day.5": "Friday", + "day.6": "Saturday", + "day.short.0": "Sun", + "day.short.1": "Mon", + "day.short.2": "Tue", + "day.short.3": "Wed", + "day.short.4": "Thu", + "day.short.5": "Fri", + "day.short.6": "Sat" +} diff --git a/core/services/studio/hostmsg/locales/es.json b/core/services/studio/hostmsg/locales/es.json new file mode 100644 index 0000000000..bf508ab35a --- /dev/null +++ b/core/services/studio/hostmsg/locales/es.json @@ -0,0 +1,144 @@ +{ + "no_upload": "No puedes enviar archivos a este bot.", + "unsupported": "No puedo leer este tipo de mensaje. Envía texto, una foto, un archivo o un mensaje de voz.", + "too_long": "El mensaje es demasiado largo. Envía menos de 8.000 caracteres.", + "rate": "Demasiados mensajes: espera un minuto.", + "unavailable": "Este bot no está disponible en este momento.", + "no_answer": "El agente no tenía nada que decir.", + "stopped": "La respuesta se detuvo.", + "interrupted": "Se interrumpió el agente. Vuelve a enviar tu mensaje.", + "failed": "El agente se detuvo por un error.", + "folder_limit": "Este agente no puede arrancar porque su acceso a archivos supera el límite de carpetas. Pide a un administrador que reduzca las carpetas compartidas disponibles para ti y vuelve a intentarlo.", + "no_folder": "Este agente no puede trabajar ahora: falta su carpeta.", + "no_file": "No se pudo leer el archivo que enviaste.", + "no_file_ability": "En esta plataforma no puedo recibir archivos.", + "file_type": "Aquí no puedo leer archivos de este tipo.", + "file_total": "Esos archivos juntos son demasiado grandes: envía menos.", + "no_files": "Aquí solo puedo leer el texto de tu mensaje: los archivos están desactivados.", + "no_listen": "Aquí no puedo escuchar mensajes de voz: no hay ningún modelo de voz a texto configurado. Escribe tu mensaje.", + "no_transcript": "No se pudo entender tu mensaje de voz. Envíalo de nuevo o escríbelo.", + "new.done": "He iniciado un chat nuevo. Ya no veré nuestros mensajes anteriores.", + "new.each": "Cada mensaje aquí ya inicia un chat nuevo.", + "new.shared": "Solo una cuenta emparejada puede iniciar un chat nuevo aquí, porque todos en esta conversación lo comparten.", + "new.busy": "Todavía estoy respondiendo. Vuelve a enviar el comando cuando termine.", + "pairing.done": "Emparejado. Esta cuenta ya puede usar las funciones sensibles que activaste para este bot.", + "pairing.bad": "Ese código no es válido. Puede que haya caducado o que ya se haya usado. Pide uno nuevo.", + "pairing.full": "Este bot ya tiene el máximo de cuentas emparejadas. Quita una en los ajustes y vuelve a emparejar.", + "pairing.direct": "El emparejamiento solo funciona en un mensaje directo.", + "live.working": "Trabajando…", + "live.using": "Usando {tool}…", + "file.too_big": "Ese archivo es demasiado grande{seen}: aquí puedo leer archivos de hasta {max} ({why}).", + "file.send_too_big": "{name} es demasiado grande para enviarlo aquí (el límite es {limit}). Está en los archivos del chat.", + "file.limit": "{size} ({why})", + "file.why_owner": "límite fijado para este bot", + "file.why_addon": "límite de {kind}", + "file.why_addon_default": "este add-on", + "file.why_platform": "límite de la plataforma", + "file.more": "Hay {n} archivos más en los archivos del chat.", + "file.made_not_sent": "He creado {name}, pero aquí no se envían archivos de este tipo.", + "file.type_not_sent": "No se envió {name}: aquí no se envían archivos de este tipo.", + "file.read_fail": "No se pudo leer {name}.", + "file.stays": "{name} se queda en los archivos del chat: los archivos de esta respuesta juntos son demasiado grandes.", + "file.send_fail": "No se pudo enviar {name}{reason}.", + "file.promise": "No se pudo adjuntar el archivo. Pídemelo de nuevo y lo enviaré.", + "size.byte": "1 byte", + "size.bytes": "{n} bytes", + "proposal.gone": "Esa propuesta ha caducado. Vuelve a pedirla si todavía la quieres.", + "proposal.place": "Responde en el mismo lugar donde se propuso, o abre Studio.", + "proposal.no_right": "Desde aquí solo puede responder a una propuesta un remitente de confianza del propietario. Abre Studio para responder.", + "proposal.failed": "No se pudo hacer: {error}", + "proposal.cancelled": "Cancelado.", + "proposal.confirmed": "Confirmado. {name} está activo: {when}. Coste: {cost}. {acts}", + "proposal.auto_cancelled": "Cancelado. Se quitó {name}.", + "proposal.multi": "Hay más de uno esperando aquí. Indica cuál, por ejemplo \"confirmar CODIGO\" o \"cancelar CODIGO\":", + "change.no_right": "Solo el propietario puede confirmar esto.", + "change.not_you": "Solo la persona que lo pidió puede confirmar esto.", + "change.cannot": "No se pudo deshacer: {error}", + "change.saved": "Guardado: {summary}. Responde \"deshacer\" en los próximos {minutes} minutos para revertirlo.", + "change.done": "Hecho: {summary}. Responde \"deshacer\" en los próximos {minutes} minutos para revertirlo.", + "change.removed": "Quitado: {summary}.", + "change.kind.memory": "una nota en la memoria", + "change.kind.profile": "un cambio en las reglas de este agente", + "change.kind.skill": "una skill", + "change.kind.page": "una página", + "change.kind.dashboard": "un cambio en el panel de Home", + "change.kind.other": "un cambio", + "change.scope.agent": "solo este agente", + "change.scope.channel": "solo esta conversación", + "change.scope.all": "todos los chats del propietario", + "change.page_files": "una página con sus archivos", + "change.only_removes": "(solo quita líneas)", + "change.propose": "Puedo guardar {kind} ({scope}): {summary}", + "change.ask": "No se guarda nada hasta que respondas. Responde \"sí\" para guardarlo o \"no\" para descartarlo, aquí, en un plazo de 24 horas. Si hay más de uno esperando, responde \"confirmar {code}\" o \"cancelar {code}\".", + "trigger.where.inbox": "a la Inbox de Studio", + "trigger.where.chat": "a este chat", + "trigger.where.channel": "al canal {channel}", + "trigger.mode.on_change": ", solo cuando cambia el resultado", + "trigger.mode.on_problem": ", solo cuando falla una ejecución", + "trigger.cost.once": "una ejecución del modelo", + "trigger.cost.each": "una ejecución del modelo cada vez que se ejecuta", + "trigger.not_asked": "No lo puse en marcha por mi cuenta: {note}.\n", + "trigger.proposal": "Propuesta {code}: {name}, {when}. Hará esto: {prompt}\nCoste: {cost}. Los resultados van {where}{mode}.\n{perms}\n{extra}No se ejecuta nada hasta que apruebes. Responde \"sí\" para ponerlo en marcha o \"cancelar\" para descartarlo, aquí, en un plazo de 24 horas, o abre Studio. Si hay más de una propuesta esperando, responde \"confirmar {code}\" o \"cancelar {code}\".", + "trigger.auto": "He configurado {name}: {when}. Coste: {cost}. Los resultados van {where}.{end}\nPara quitarlo, responde \"cancelar\" aquí, o \"cancelar {code}\" en cualquier momento.", + "trigger.auto_stops": " Se detiene solo el {date}.", + "trigger.lifetime": "{name} llegó al final de su vida útil y se quitó.", + "trigger.failed": "{name} falló: {reason}", + "trigger.failed_n": "{name} falló {n} veces seguidas: {reason}\nArréglalo o ponlo en pausa en Studio.", + "acts.read_only": "Permisos: solo lectura. Una ejecución puede leer e informar, pero no puede cambiar nada. {next}", + "acts.next_ask": "Pídeme que le deje actuar, o cámbialo más tarde en la página Disparadores.", + "acts.next_page": "Puedes cambiarlo más tarde en la página Disparadores.", + "acts.with": "Permisos: sin nadie presente, una ejecución puede {list}. Di que no si es más de lo que quieres.", + "acts.sensitive": "{words} (sensible)", + "act.word.files": "escribir archivos en las carpetas del agente", + "act.word.addons": "usar las acciones de los add-ons del agente", + "act.word.send": "enviar mensajes fuera de donde informa", + "act.word.spend": "usar herramientas de pago o externas y la red", + "act.label.files": "escribir archivos", + "act.label.addons": "usar acciones de add-ons", + "act.label.send": "enviar mensajes", + "act.label.spend": "usar herramientas de pago o externas", + "act.addon": "usar las acciones del add-on {id}", + "dropped.text": "No permití: {parts}.", + "dropped.binding": "{words} (esta conexión no lo permite)", + "dropped.agent": "{words} (el agente no lo tiene)", + "verdict.not_readonly": "no dice que sus ejecuciones solo leen e informan", + "verdict.writes": "sus ejecuciones escriben un archivo", + "verdict.dropped": "algunas acciones que pidió no están permitidas aquí", + "verdict.readonly_acts": "sus ejecuciones piden permiso para actuar por su cuenta", + "verdict.not_allowed": "sus ejecuciones tendrían que {act}, y el propietario no lo ha permitido sin un sí", + "verdict.paired": "sus ejecuciones actuarían por su cuenta, y eso necesita un remitente emparejado con esta conexión", + "verdict.channel": "escribiría en otro canal", + "verdict.files": "un disparador sobre archivos siempre pregunta", + "verdict.count_fail": "no se pudieron contar los temporizadores de esta conexión", + "verdict.too_many": "esta conexión ya tiene {n} temporizadores y disparadores (el máximo es {max})", + "verdict.far": "es dentro de más de {days} días", + "verdict.schedule": "no se puede leer su programación", + "verdict.gap": "se ejecuta cada {gap} (el mínimo es {min} minutos)", + "verdict.runs": "haría {n} ejecuciones al día con los demás (el máximo es {max})", + "gap.minute": "minuto", + "gap.minutes": "{n} minutos", + "when.files": "archivos nuevos en {folder}", + "when.never": "Nunca", + "when.daily": "Cada día a las {time}", + "when.weekdays": "Cada día laborable a las {time}", + "when.weekly": "Cada {days} a las {time}", + "when.monthly": "El día {day} de cada mes a las {time}", + "when.once": "Una vez, el {date} a las {time}", + "when.cron": "Cron \"{text}\"", + "when.once_at": "Una vez, {weekday} {date} a las {time} ({zone})", + "join.and": " y ", + "day.0": "domingo", + "day.1": "lunes", + "day.2": "martes", + "day.3": "miércoles", + "day.4": "jueves", + "day.5": "viernes", + "day.6": "sábado", + "day.short.0": "dom", + "day.short.1": "lun", + "day.short.2": "mar", + "day.short.3": "mié", + "day.short.4": "jue", + "day.short.5": "vie", + "day.short.6": "sáb" +} diff --git a/core/services/studio/hostmsg/locales/fr.json b/core/services/studio/hostmsg/locales/fr.json new file mode 100644 index 0000000000..636917fee3 --- /dev/null +++ b/core/services/studio/hostmsg/locales/fr.json @@ -0,0 +1,144 @@ +{ + "no_upload": "Vous ne pouvez pas envoyer de fichiers à ce bot.", + "unsupported": "Je ne peux pas lire ce type de message. Envoyez du texte, une photo, un fichier ou un message vocal.", + "too_long": "Ce message est trop long. Envoyez moins de 8 000 caractères.", + "rate": "Trop de messages : patientez une minute.", + "unavailable": "Ce bot n'est pas disponible pour le moment.", + "no_answer": "L'agent n'avait rien à dire.", + "stopped": "La réponse a été arrêtée.", + "interrupted": "L'agent a été interrompu. Renvoyez votre message.", + "failed": "L'agent s'est arrêté à cause d'une erreur.", + "folder_limit": "Cet agent ne peut pas démarrer, car son accès aux fichiers dépasse la limite de dossiers. Demandez à un administrateur de réduire les dossiers partagés mis à votre disposition, puis réessayez.", + "no_folder": "Cet agent ne peut pas travailler pour le moment : son dossier est introuvable.", + "no_file": "Le fichier que vous avez envoyé n'a pas pu être lu.", + "no_file_ability": "Je ne peux pas recevoir de fichiers sur cette plateforme.", + "file_type": "Je ne peux pas lire ici les fichiers de ce type.", + "file_total": "Ces fichiers sont trop volumineux ensemble : envoyez-en moins.", + "no_files": "Ici, je ne peux lire que le texte de votre message : les fichiers sont désactivés.", + "no_listen": "Je ne peux pas écouter les messages vocaux ici : aucun modèle de transcription n'est configuré. Écrivez votre message.", + "no_transcript": "Votre message vocal n'a pas pu être compris. Renvoyez-le, ou écrivez-le.", + "new.done": "Nouvelle conversation démarrée. Je ne verrai plus nos messages précédents.", + "new.each": "Chaque message ici démarre déjà une nouvelle conversation.", + "new.shared": "Seul un compte appairé peut démarrer une nouvelle conversation ici, car tous les participants de cet échange la partagent.", + "new.busy": "Je réponds encore. Renvoyez la commande quand j'aurai terminé.", + "pairing.done": "Appairage réussi. Ce compte peut désormais utiliser les fonctions sensibles que vous avez activées pour ce bot.", + "pairing.bad": "Ce code n'est pas valide. Il a peut-être expiré ou déjà été utilisé. Demandez-en un nouveau.", + "pairing.full": "Ce bot a déjà le nombre maximal de comptes appairés. Supprimez-en un dans les paramètres, puis appairez à nouveau.", + "pairing.direct": "L'appairage ne fonctionne que dans un message direct.", + "live.working": "Je travaille…", + "live.using": "J'utilise {tool}…", + "file.too_big": "Ce fichier est trop volumineux{seen} : ici, je peux lire des fichiers jusqu'à {max} ({why}).", + "file.send_too_big": "{name} est trop volumineux pour être envoyé ici (la limite est de {limit}). Il se trouve dans les fichiers de la conversation.", + "file.limit": "{size} ({why})", + "file.why_owner": "limite définie pour ce bot", + "file.why_addon": "limite de {kind}", + "file.why_addon_default": "cet add-on", + "file.why_platform": "limite de la plateforme", + "file.more": "{n} autres fichiers se trouvent dans les fichiers de la conversation.", + "file.made_not_sent": "J'ai créé {name}, mais les fichiers de ce type ne sont pas envoyés ici.", + "file.type_not_sent": "{name} n'a pas été envoyé : les fichiers de ce type ne sont pas envoyés ici.", + "file.read_fail": "{name} n'a pas pu être lu.", + "file.stays": "{name} reste dans les fichiers de la conversation : les fichiers de cette réponse sont trop volumineux ensemble.", + "file.send_fail": "{name} n'a pas pu être envoyé{reason}.", + "file.promise": "Le fichier n'a pas pu être joint. Redemandez-le-moi et je l'enverrai.", + "size.byte": "1 octet", + "size.bytes": "{n} octets", + "proposal.gone": "Cette proposition a expiré. Redemandez-la si vous la voulez toujours.", + "proposal.place": "Répondez à l'endroit où elle a été proposée, ou ouvrez Studio.", + "proposal.no_right": "D'ici, seul un expéditeur de confiance du propriétaire peut répondre à une proposition. Ouvrez Studio pour y répondre.", + "proposal.failed": "Ce n'était pas possible : {error}", + "proposal.cancelled": "Annulé.", + "proposal.confirmed": "Confirmé. {name} est actif : {when}. Coût : {cost}. {acts}", + "proposal.auto_cancelled": "Annulé. {name} a été supprimé.", + "proposal.multi": "Plusieurs propositions attendent ici. Précisez laquelle, par exemple « confirmer CODE » ou « annuler CODE » :", + "change.no_right": "Seul le propriétaire peut confirmer cela.", + "change.not_you": "Seule la personne qui l'a demandé peut confirmer cela.", + "change.cannot": "L'annulation n'a pas pu se faire : {error}", + "change.saved": "Enregistré : {summary}. Répondez « annuler la modification » dans les {minutes} minutes pour revenir en arrière.", + "change.done": "Fait : {summary}. Répondez « annuler la modification » dans les {minutes} minutes pour revenir en arrière.", + "change.removed": "Supprimé : {summary}.", + "change.kind.memory": "une note dans la mémoire", + "change.kind.profile": "une modification des règles de cet agent", + "change.kind.skill": "un skill", + "change.kind.page": "une page", + "change.kind.dashboard": "une modification du tableau de bord Home", + "change.kind.other": "une modification", + "change.scope.agent": "cet agent uniquement", + "change.scope.channel": "cette conversation uniquement", + "change.scope.all": "toutes les conversations du propriétaire", + "change.page_files": "une page avec ses fichiers", + "change.only_removes": "(cela ne fait que retirer des lignes)", + "change.propose": "Je peux enregistrer {kind} ({scope}) : {summary}", + "change.ask": "Rien n'est enregistré avant votre réponse. Répondez « oui » pour l'enregistrer ou « non » pour l'abandonner, ici, dans les 24 heures. Si plusieurs attendent, répondez « confirmer {code} » ou « annuler {code} ».", + "trigger.where.inbox": "dans l'Inbox de Studio", + "trigger.where.chat": "dans cette conversation", + "trigger.where.channel": "dans le canal {channel}", + "trigger.mode.on_change": ", seulement quand le résultat change", + "trigger.mode.on_problem": ", seulement quand une exécution échoue", + "trigger.cost.once": "une exécution du modèle", + "trigger.cost.each": "une exécution du modèle à chaque exécution", + "trigger.not_asked": "Je ne l'ai pas démarré de moi-même : {note}.\n", + "trigger.proposal": "Proposition {code} : {name}, {when}. Il fera ceci : {prompt}\nCoût : {cost}. Les résultats arrivent {where}{mode}.\n{perms}\n{extra}Rien ne s'exécute avant votre accord. Répondez « oui » pour le démarrer ou « annuler » pour l'abandonner, ici, dans les 24 heures, ou ouvrez Studio. Si plusieurs propositions attendent, répondez « confirmer {code} » ou « annuler {code} ».", + "trigger.auto": "Mis en place : {name}, {when}. Coût : {cost}. Les résultats arrivent {where}.{end}\nPour le supprimer, répondez « annuler » ici, ou « annuler {code} » à tout moment.", + "trigger.auto_stops": " Il s'arrête de lui-même le {date}.", + "trigger.lifetime": "{name} est arrivé au terme de sa durée de vie et a été supprimé.", + "trigger.failed": "{name} a échoué : {reason}", + "trigger.failed_n": "{name} a échoué {n} fois de suite : {reason}\nCorrigez-le ou mettez-le en pause dans Studio.", + "acts.read_only": "Permissions : lecture seule. Une exécution peut lire et rendre compte, mais ne peut rien modifier. {next}", + "acts.next_ask": "Demandez-moi de le laisser agir, ou modifiez-le plus tard sur la page Déclencheurs.", + "acts.next_page": "Vous pourrez le modifier plus tard sur la page Déclencheurs.", + "acts.with": "Permissions : sans personne sur place, une exécution peut {list}. Répondez non si c'est plus que ce que vous voulez.", + "acts.sensitive": "{words} (sensible)", + "act.word.files": "écrire des fichiers dans les dossiers de l'agent", + "act.word.addons": "utiliser les actions des add-ons de l'agent", + "act.word.send": "envoyer des messages ailleurs que là où elle rend compte", + "act.word.spend": "utiliser des outils payants ou externes et le réseau", + "act.label.files": "écrire des fichiers", + "act.label.addons": "utiliser des actions d'add-ons", + "act.label.send": "envoyer des messages", + "act.label.spend": "utiliser des outils payants ou externes", + "act.addon": "utiliser les actions de l'add-on {id}", + "dropped.text": "Je n'ai pas autorisé : {parts}.", + "dropped.binding": "{words} (cette connexion ne l'autorise pas)", + "dropped.agent": "{words} (l'agent ne l'a pas)", + "verdict.not_readonly": "il n'indique pas que ses exécutions se limitent à lire et à rendre compte", + "verdict.writes": "ses exécutions écrivent un fichier", + "verdict.dropped": "certaines actions demandées ne sont pas autorisées ici", + "verdict.readonly_acts": "ses exécutions demandent la permission d'agir d'elles-mêmes", + "verdict.not_allowed": "ses exécutions devraient {act}, ce que le propriétaire n'a pas autorisé sans un oui", + "verdict.paired": "ses exécutions agiraient d'elles-mêmes, ce qui demande un expéditeur appairé à cette connexion", + "verdict.channel": "il écrirait dans un autre canal", + "verdict.files": "un déclencheur sur des fichiers demande toujours", + "verdict.count_fail": "les minuteurs de cette connexion n'ont pas pu être comptés", + "verdict.too_many": "cette connexion a déjà {n} minuteurs et déclencheurs (le maximum est {max})", + "verdict.far": "c'est dans plus de {days} jours", + "verdict.schedule": "son planning ne peut pas être lu", + "verdict.gap": "il s'exécute à {gap} d'intervalle (le minimum est {min} minutes)", + "verdict.runs": "il ferait {n} exécutions par jour avec les autres (le maximum est {max})", + "gap.minute": "1 minute", + "gap.minutes": "{n} minutes", + "when.files": "nouveaux fichiers dans {folder}", + "when.never": "Jamais", + "when.daily": "Tous les jours à {time}", + "when.weekdays": "Tous les jours ouvrés à {time}", + "when.weekly": "Chaque {days} à {time}", + "when.monthly": "Le {day} de chaque mois à {time}", + "when.once": "Une fois, le {date} à {time}", + "when.cron": "Cron \"{text}\"", + "when.once_at": "Une fois, {weekday} {date} à {time} ({zone})", + "join.and": " et ", + "day.0": "dimanche", + "day.1": "lundi", + "day.2": "mardi", + "day.3": "mercredi", + "day.4": "jeudi", + "day.5": "vendredi", + "day.6": "samedi", + "day.short.0": "dim", + "day.short.1": "lun", + "day.short.2": "mar", + "day.short.3": "mer", + "day.short.4": "jeu", + "day.short.5": "ven", + "day.short.6": "sam" +} diff --git a/core/services/studio/hostmsg/locales/it.json b/core/services/studio/hostmsg/locales/it.json new file mode 100644 index 0000000000..9ac1f61b10 --- /dev/null +++ b/core/services/studio/hostmsg/locales/it.json @@ -0,0 +1,144 @@ +{ + "no_upload": "Non puoi inviare file a questo bot.", + "unsupported": "Non riesco a leggere questo tipo di messaggio. Invia un testo, una foto, un file o un messaggio vocale.", + "too_long": "Il messaggio è troppo lungo. Invia meno di 8.000 caratteri.", + "rate": "Troppi messaggi: attendi un minuto.", + "unavailable": "Questo bot non è disponibile in questo momento.", + "no_answer": "L'agente non aveva nulla da dire.", + "stopped": "La risposta è stata fermata.", + "interrupted": "L'agente è stato interrotto. Invia di nuovo il messaggio.", + "failed": "L'agente si è fermato a causa di un errore.", + "folder_limit": "Questo agente non può avviarsi perché l'accesso ai file supera il limite delle cartelle. Chiedi a un amministratore di ridurre le cartelle condivise a tua disposizione, poi riprova.", + "no_folder": "Questo agente non può lavorare ora: la sua cartella non è stata trovata.", + "no_file": "Non è stato possibile leggere il file che hai inviato.", + "no_file_ability": "Su questa piattaforma non posso ricevere file.", + "file_type": "Qui non posso leggere file di questo tipo.", + "file_total": "Questi file insieme sono troppo grandi: inviane meno.", + "no_files": "Qui posso leggere solo il testo del tuo messaggio: i file sono disattivati.", + "no_listen": "Qui non posso ascoltare i messaggi vocali: non è configurato nessun modello di trascrizione. Scrivi il tuo messaggio.", + "no_transcript": "Non è stato possibile capire il tuo messaggio vocale. Invialo di nuovo, oppure scrivilo.", + "new.done": "Nuova chat avviata. Non vedrò più i nostri messaggi precedenti.", + "new.each": "Ogni messaggio qui avvia già una nuova chat.", + "new.shared": "Solo un account abbinato può avviare una nuova chat qui, perché tutti in questa conversazione la condividono.", + "new.busy": "Sto ancora rispondendo. Invia di nuovo il comando quando ho finito.", + "pairing.done": "Abbinamento riuscito. Questo account può ora usare le funzioni sensibili che hai attivato per questo bot.", + "pairing.bad": "Questo codice non è valido. Potrebbe essere scaduto o già usato. Chiedine uno nuovo.", + "pairing.full": "Questo bot ha già il numero massimo di account abbinati. Rimuovine uno nelle impostazioni, poi abbina di nuovo.", + "pairing.direct": "L'abbinamento funziona solo in un messaggio diretto.", + "live.working": "Sto lavorando…", + "live.using": "Uso {tool}…", + "file.too_big": "Il file è troppo grande{seen}: qui posso leggere file fino a {max} ({why}).", + "file.send_too_big": "{name} è troppo grande per essere inviato qui (il limite è {limit}). Si trova nei file della chat.", + "file.limit": "{size} ({why})", + "file.why_owner": "limite impostato per questo bot", + "file.why_addon": "limite di {kind}", + "file.why_addon_default": "questo add-on", + "file.why_platform": "limite della piattaforma", + "file.more": "Altri {n} file si trovano nei file della chat.", + "file.made_not_sent": "Ho creato {name}, ma qui i file di questo tipo non vengono inviati.", + "file.type_not_sent": "{name} non è stato inviato: qui i file di questo tipo non vengono inviati.", + "file.read_fail": "Non è stato possibile leggere {name}.", + "file.stays": "{name} resta nei file della chat: i file di questa risposta sono troppo grandi insieme.", + "file.send_fail": "Non è stato possibile inviare {name}{reason}.", + "file.promise": "Non è stato possibile allegare il file. Chiedimelo di nuovo e lo invierò.", + "size.byte": "1 byte", + "size.bytes": "{n} byte", + "proposal.gone": "Questa proposta è scaduta. Chiedila di nuovo se ti serve ancora.", + "proposal.place": "Rispondi nello stesso posto in cui è stata proposta, oppure apri Studio.", + "proposal.no_right": "Da qui può rispondere a una proposta solo un mittente di cui il proprietario si fida. Apri Studio per rispondere.", + "proposal.failed": "Operazione non riuscita: {error}", + "proposal.cancelled": "Annullato.", + "proposal.confirmed": "Confermato. {name} è attivo: {when}. Costo: {cost}. {acts}", + "proposal.auto_cancelled": "Annullato. {name} è stato rimosso.", + "proposal.multi": "Qui ce n'è più di uno in attesa. Indica quale, ad esempio \"conferma CODICE\" oppure \"annulla CODICE\":", + "change.no_right": "Solo il proprietario può confermare.", + "change.not_you": "Solo la persona che l'ha chiesto può confermare.", + "change.cannot": "Non è stato possibile annullare la modifica: {error}", + "change.saved": "Salvato: {summary}. Rispondi \"annulla modifica\" entro {minutes} minuti per tornare indietro.", + "change.done": "Fatto: {summary}. Rispondi \"annulla modifica\" entro {minutes} minuti per tornare indietro.", + "change.removed": "Rimosso: {summary}.", + "change.kind.memory": "una nota nella memoria", + "change.kind.profile": "una modifica alle regole di questo agente", + "change.kind.skill": "una skill", + "change.kind.page": "una pagina", + "change.kind.dashboard": "una modifica alla dashboard Home", + "change.kind.other": "una modifica", + "change.scope.agent": "solo questo agente", + "change.scope.channel": "solo questa conversazione", + "change.scope.all": "tutte le chat del proprietario", + "change.page_files": "una pagina con i suoi file", + "change.only_removes": "(toglie soltanto delle righe)", + "change.propose": "Posso salvare {kind} ({scope}): {summary}", + "change.ask": "Non viene salvato nulla finché non rispondi. Rispondi \"sì\" per salvare o \"no\" per scartare, qui, entro 24 ore. Se ce n'è più di uno in attesa, rispondi \"conferma {code}\" oppure \"annulla {code}\".", + "trigger.where.inbox": "nell'Inbox di Studio", + "trigger.where.chat": "in questa chat", + "trigger.where.channel": "nel canale {channel}", + "trigger.mode.on_change": ", solo quando il risultato cambia", + "trigger.mode.on_problem": ", solo quando un'esecuzione fallisce", + "trigger.cost.once": "un'esecuzione del modello", + "trigger.cost.each": "un'esecuzione del modello a ogni avvio", + "trigger.not_asked": "Non l'ho avviato da solo: {note}.\n", + "trigger.proposal": "Proposta {code}: {name}, {when}. Farà questo: {prompt}\nCosto: {cost}. I risultati arrivano {where}{mode}.\n{perms}\n{extra}Non parte nulla finché non approvi. Rispondi \"sì\" per avviarlo o \"annulla\" per scartarlo, qui, entro 24 ore, oppure apri Studio. Se ci sono più proposte in attesa, rispondi \"conferma {code}\" oppure \"annulla {code}\".", + "trigger.auto": "Ho creato {name}: {when}. Costo: {cost}. I risultati arrivano {where}.{end}\nPer rimuoverlo, rispondi \"annulla\" qui, oppure \"annulla {code}\" in qualsiasi momento.", + "trigger.auto_stops": " Si ferma da solo il {date}.", + "trigger.lifetime": "{name} ha raggiunto la fine della sua durata ed è stato rimosso.", + "trigger.failed": "L'esecuzione di {name} non è riuscita: {reason}", + "trigger.failed_n": "{name}: {n} esecuzioni fallite di fila: {reason}\nCorreggilo o mettilo in pausa in Studio.", + "acts.read_only": "Permessi: sola lettura. Un'esecuzione può leggere e riferire, ma non può cambiare nulla. {next}", + "acts.next_ask": "Chiedimi di consentirgli di agire, oppure cambialo più tardi nella pagina Trigger.", + "acts.next_page": "Puoi cambiarlo più tardi nella pagina Trigger.", + "acts.with": "Permessi: quando nessuno è presente, un'esecuzione può {list}. Rispondi no se è più di quanto vuoi.", + "acts.sensitive": "{words} (sensibile)", + "act.word.files": "scrivere file nelle cartelle dell'agente", + "act.word.addons": "usare le azioni degli add-on dell'agente", + "act.word.send": "inviare messaggi anche fuori da dove riferisce", + "act.word.spend": "usare strumenti a pagamento o esterni e la rete", + "act.label.files": "scrivere file", + "act.label.addons": "usare le azioni degli add-on", + "act.label.send": "inviare messaggi", + "act.label.spend": "usare strumenti a pagamento o esterni", + "act.addon": "usare le azioni dell'add-on {id}", + "dropped.text": "Non ho consentito: {parts}.", + "dropped.binding": "{words} (questa connessione non lo consente)", + "dropped.agent": "{words} (l'agente non lo ha)", + "verdict.not_readonly": "non dice che le sue esecuzioni leggono e riferiscono soltanto", + "verdict.writes": "le sue esecuzioni scrivono un file", + "verdict.dropped": "alcune azioni richieste non sono consentite qui", + "verdict.readonly_acts": "le sue esecuzioni chiedono il permesso di agire da sole", + "verdict.not_allowed": "le sue esecuzioni dovrebbero {act}, cosa che il proprietario non ha consentito senza un sì", + "verdict.paired": "le sue esecuzioni agirebbero da sole, e serve un mittente abbinato a questa connessione", + "verdict.channel": "scriverebbe in un altro canale", + "verdict.files": "un trigger sui file chiede sempre conferma", + "verdict.count_fail": "non è stato possibile contare i timer di questa connessione", + "verdict.too_many": "questa connessione ha già {n} timer e trigger (il massimo è {max})", + "verdict.far": "è a più di {days} giorni da oggi", + "verdict.schedule": "non è possibile leggere la sua pianificazione", + "verdict.gap": "viene eseguito ogni {gap} (il minimo è {min} minuti)", + "verdict.runs": "farebbe {n} esecuzioni al giorno insieme agli altri (il massimo è {max})", + "gap.minute": "minuto", + "gap.minutes": "{n} minuti", + "when.files": "nuovi file in {folder}", + "when.never": "Mai", + "when.daily": "Ogni giorno alle {time}", + "when.weekdays": "Ogni giorno feriale alle {time}", + "when.weekly": "Ogni {days} alle {time}", + "when.monthly": "Il giorno {day} di ogni mese alle {time}", + "when.once": "Una volta, il {date} alle {time}", + "when.cron": "Cron \"{text}\"", + "when.once_at": "Una volta, {weekday} {date} alle {time} ({zone})", + "join.and": " e ", + "day.0": "domenica", + "day.1": "lunedì", + "day.2": "martedì", + "day.3": "mercoledì", + "day.4": "giovedì", + "day.5": "venerdì", + "day.6": "sabato", + "day.short.0": "dom", + "day.short.1": "lun", + "day.short.2": "mar", + "day.short.3": "mer", + "day.short.4": "gio", + "day.short.5": "ven", + "day.short.6": "sab" +} diff --git a/core/services/studio/hostmsg/locales/pt-BR.json b/core/services/studio/hostmsg/locales/pt-BR.json new file mode 100644 index 0000000000..249aa0bc64 --- /dev/null +++ b/core/services/studio/hostmsg/locales/pt-BR.json @@ -0,0 +1,144 @@ +{ + "no_upload": "Você não pode enviar arquivos para este bot.", + "unsupported": "Não consigo ler este tipo de mensagem. Envie texto, uma foto, um arquivo ou uma mensagem de voz.", + "too_long": "A mensagem é longa demais. Envie menos de 8.000 caracteres.", + "rate": "Mensagens demais: aguarde um minuto.", + "unavailable": "Este bot não está disponível no momento.", + "no_answer": "O agente não tinha nada a dizer.", + "stopped": "A resposta foi interrompida.", + "interrupted": "O agente foi interrompido. Envie sua mensagem de novo.", + "failed": "O agente parou por causa de um erro.", + "folder_limit": "Este agente não pode iniciar porque o acesso dele a arquivos excede o limite de pastas. Peça a um administrador que reduza as pastas compartilhadas disponíveis para você e tente de novo.", + "no_folder": "Este agente não pode trabalhar agora: a pasta dele não foi encontrada.", + "no_file": "Não foi possível ler o arquivo que você enviou.", + "no_file_ability": "Não consigo receber arquivos nesta plataforma.", + "file_type": "Não consigo ler aqui arquivos deste tipo.", + "file_total": "Esses arquivos juntos são grandes demais: envie menos deles.", + "no_files": "Aqui só consigo ler o texto da sua mensagem: os arquivos estão desativados.", + "no_listen": "Não consigo ouvir mensagens de voz aqui: nenhum modelo de transcrição está configurado. Escreva sua mensagem.", + "no_transcript": "Não foi possível entender sua mensagem de voz. Envie-a de novo ou escreva.", + "new.done": "Novo chat iniciado. Não verei mais as nossas mensagens anteriores.", + "new.each": "Cada mensagem aqui já inicia um novo chat.", + "new.shared": "Só uma conta pareada pode iniciar um novo chat aqui, porque todos nesta conversa o compartilham.", + "new.busy": "Ainda estou respondendo. Envie o comando de novo quando eu terminar.", + "pairing.done": "Pareado. Esta conta agora pode usar os recursos sensíveis que você ativou para este bot.", + "pairing.bad": "Esse código não é válido. Ele pode ter expirado ou já ter sido usado. Peça um novo.", + "pairing.full": "Este bot já tem o máximo de contas pareadas. Remova uma nas configurações e pareie de novo.", + "pairing.direct": "O pareamento só funciona em uma mensagem direta.", + "live.working": "Trabalhando…", + "live.using": "Usando {tool}…", + "file.too_big": "Esse arquivo é grande demais{seen}: aqui consigo ler arquivos de até {max} ({why}).", + "file.send_too_big": "{name} é grande demais para ser enviado aqui (o limite é {limit}). Ele está nos arquivos do chat.", + "file.limit": "{size} ({why})", + "file.why_owner": "limite definido para este bot", + "file.why_addon": "limite de {kind}", + "file.why_addon_default": "este add-on", + "file.why_platform": "limite da plataforma", + "file.more": "Mais {n} arquivos estão nos arquivos do chat.", + "file.made_not_sent": "Criei {name}, mas arquivos deste tipo não são enviados aqui.", + "file.type_not_sent": "{name} não foi enviado: arquivos deste tipo não são enviados aqui.", + "file.read_fail": "Não foi possível ler {name}.", + "file.stays": "{name} fica nos arquivos do chat: os arquivos desta resposta juntos são grandes demais.", + "file.send_fail": "Não foi possível enviar {name}{reason}.", + "file.promise": "Não foi possível anexar o arquivo. Peça de novo e eu o envio.", + "size.byte": "1 byte", + "size.bytes": "{n} bytes", + "proposal.gone": "Essa proposta expirou. Peça de novo se ainda quiser.", + "proposal.place": "Responda no mesmo lugar em que ela foi proposta, ou abra o Studio.", + "proposal.no_right": "Daqui, só um remetente em que o proprietário confia pode responder a uma proposta. Abra o Studio para responder.", + "proposal.failed": "Não foi possível fazer isso: {error}", + "proposal.cancelled": "Cancelado.", + "proposal.confirmed": "Confirmado. {name} está ativo: {when}. Custo: {cost}. {acts}", + "proposal.auto_cancelled": "Cancelado. {name} foi removido.", + "proposal.multi": "Há mais de uma esperando aqui. Diga qual, por exemplo \"confirmar CODIGO\" ou \"cancelar CODIGO\":", + "change.no_right": "Só o proprietário pode confirmar isso.", + "change.not_you": "Só a pessoa que pediu pode confirmar isso.", + "change.cannot": "Não foi possível desfazer: {error}", + "change.saved": "Salvo: {summary}. Responda \"desfazer\" em até {minutes} minutos para voltar atrás.", + "change.done": "Pronto: {summary}. Responda \"desfazer\" em até {minutes} minutos para voltar atrás.", + "change.removed": "Removido: {summary}.", + "change.kind.memory": "uma nota na memória", + "change.kind.profile": "uma alteração nas regras deste agente", + "change.kind.skill": "uma skill", + "change.kind.page": "uma página", + "change.kind.dashboard": "uma alteração no painel Home", + "change.kind.other": "uma alteração", + "change.scope.agent": "só este agente", + "change.scope.channel": "só esta conversa", + "change.scope.all": "todos os chats do proprietário", + "change.page_files": "uma página com os arquivos dela", + "change.only_removes": "(apenas remove linhas)", + "change.propose": "Posso salvar {kind} ({scope}): {summary}", + "change.ask": "Nada é salvo até você responder. Responda \"sim\" para salvar ou \"não\" para descartar, aqui, em até 24 horas. Se houver mais de uma esperando, responda \"confirmar {code}\" ou \"cancelar {code}\".", + "trigger.where.inbox": "para a Inbox do Studio", + "trigger.where.chat": "para este chat", + "trigger.where.channel": "para o canal {channel}", + "trigger.mode.on_change": ", só quando o resultado muda", + "trigger.mode.on_problem": ", só quando uma execução falha", + "trigger.cost.once": "uma execução do modelo", + "trigger.cost.each": "uma execução do modelo a cada vez que roda", + "trigger.not_asked": "Não o iniciei por conta própria: {note}.\n", + "trigger.proposal": "Proposta {code}: {name}, {when}. Ele vai fazer isto: {prompt}\nCusto: {cost}. Os resultados vão {where}{mode}.\n{perms}\n{extra}Nada roda até você aprovar. Responda \"sim\" para iniciá-lo ou \"cancelar\" para descartá-lo, aqui, em até 24 horas, ou abra o Studio. Se houver mais de uma proposta esperando, responda \"confirmar {code}\" ou \"cancelar {code}\".", + "trigger.auto": "Configurei {name}: {when}. Custo: {cost}. Os resultados vão {where}.{end}\nPara removê-lo, responda \"cancelar\" aqui, ou \"cancelar {code}\" a qualquer momento.", + "trigger.auto_stops": " Ele para sozinho em {date}.", + "trigger.lifetime": "{name} chegou ao fim da sua vida útil e foi removido.", + "trigger.failed": "{name} falhou: {reason}", + "trigger.failed_n": "{name} falhou {n} vezes seguidas: {reason}\nCorrija-o ou pause-o no Studio.", + "acts.read_only": "Permissões: somente leitura. Uma execução pode ler e relatar, mas não pode alterar nada. {next}", + "acts.next_ask": "Peça para eu deixá-lo agir, ou altere-o depois na página Gatilhos.", + "acts.next_page": "Você pode alterar isso depois na página Gatilhos.", + "acts.with": "Permissões: sem ninguém presente, uma execução pode {list}. Diga não se isso for mais do que você quer.", + "acts.sensitive": "{words} (sensível)", + "act.word.files": "gravar arquivos nas pastas do agente", + "act.word.addons": "usar as ações dos add-ons do agente", + "act.word.send": "enviar mensagens além de onde ela relata", + "act.word.spend": "usar ferramentas pagas ou externas e a rede", + "act.label.files": "gravar arquivos", + "act.label.addons": "usar ações de add-ons", + "act.label.send": "enviar mensagens", + "act.label.spend": "usar ferramentas pagas ou externas", + "act.addon": "usar as ações do add-on {id}", + "dropped.text": "Não permiti: {parts}.", + "dropped.binding": "{words} (esta conexão não permite)", + "dropped.agent": "{words} (o agente não tem)", + "verdict.not_readonly": "ele não diz que suas execuções só leem e relatam", + "verdict.writes": "suas execuções gravam um arquivo", + "verdict.dropped": "algumas ações pedidas não são permitidas aqui", + "verdict.readonly_acts": "suas execuções pedem permissão para agir por conta própria", + "verdict.not_allowed": "suas execuções teriam de {act}, e o proprietário não permitiu isso sem um sim", + "verdict.paired": "suas execuções agiriam por conta própria, e isso exige um remetente pareado com esta conexão", + "verdict.channel": "ele escreveria em outro canal", + "verdict.files": "um gatilho sobre arquivos sempre pergunta", + "verdict.count_fail": "não foi possível contar os temporizadores desta conexão", + "verdict.too_many": "esta conexão já tem {n} temporizadores e gatilhos (o máximo é {max})", + "verdict.far": "está a mais de {days} dias de distância", + "verdict.schedule": "não é possível ler o agendamento dele", + "verdict.gap": "ele roda a cada {gap} (o mínimo é {min} minutos)", + "verdict.runs": "ele faria {n} execuções por dia com os outros (o máximo é {max})", + "gap.minute": "minuto", + "gap.minutes": "{n} minutos", + "when.files": "novos arquivos em {folder}", + "when.never": "Nunca", + "when.daily": "Todo dia às {time}", + "when.weekdays": "Todo dia útil às {time}", + "when.weekly": "Toda semana: {days} às {time}", + "when.monthly": "No dia {day} de todo mês às {time}", + "when.once": "Uma vez, em {date} às {time}", + "when.cron": "Cron \"{text}\"", + "when.once_at": "Uma vez, {weekday} {date} às {time} ({zone})", + "join.and": " e ", + "day.0": "domingo", + "day.1": "segunda-feira", + "day.2": "terça-feira", + "day.3": "quarta-feira", + "day.4": "quinta-feira", + "day.5": "sexta-feira", + "day.6": "sábado", + "day.short.0": "dom", + "day.short.1": "seg", + "day.short.2": "ter", + "day.short.3": "qua", + "day.short.4": "qui", + "day.short.5": "sex", + "day.short.6": "sáb" +} diff --git a/core/services/studio/hostmsg_guard_test.go b/core/services/studio/hostmsg_guard_test.go new file mode 100644 index 0000000000..20970b3546 --- /dev/null +++ b/core/services/studio/hostmsg_guard_test.go @@ -0,0 +1,293 @@ +package studio + +import ( + "go/ast" + "go/parser" + gotoken "go/token" + "path/filepath" + "regexp" + "slices" + "strconv" + "strings" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/mudler/LocalAI/core/services/studio/hostmsg" +) + +// Every sentence that the host writes to a person in a chat is a key of the +// host message catalog (hostmsg), spoken in the language of the person through +// msg or a Voice. These checks fail when a fixed text goes to the send path +// any other way, or when a key is not in the catalog. +// +// What is not covered on purpose, because the reader is the model and not the +// person: tool descriptions and tool results (talkbacktool.go, attachfile.go, +// the model branches of triggertools.go, changetools.go and +// connectorchange.go), the note of the held back tools (connectordenied.go), +// the thread block (connectorthread.go), the note of the chat +// (connectorturn.go), the reasons of the capability table (connectorreasons.go) +// and the log and audit lines. They stay English. +var _ = Describe("the sentences the host writes to a chat", func() { + var ( + fset *gotoken.FileSet + parsed map[string]*ast.File + ) + letters := regexp.MustCompile(`[A-Za-z]{2,}`) + + BeforeEach(func() { + fset = gotoken.NewFileSet() + parsed = map[string]*ast.File{} + names, err := filepath.Glob("*.go") + Expect(err).NotTo(HaveOccurred()) + for _, n := range names { + if strings.HasSuffix(n, "_test.go") { + continue + } + f, err := parser.ParseFile(fset, n, nil, 0) + Expect(err).NotTo(HaveOccurred()) + parsed[n] = f + } + Expect(parsed).NotTo(BeEmpty()) + }) + + // calleeName gives the last name of a called function: say, c.say, Post. + calleeName := func(e ast.Expr) string { + switch x := e.(type) { + case *ast.Ident: + return x.Name + case *ast.SelectorExpr: + return x.Sel.Name + } + return "" + } + + // isKeyCall says whether a call speaks the catalog: v.T(key, name, value, ...), + // tell(key, ...) or msg(ctx, binding, sender, key, ...). It returns the + // values of the arguments, which are the only parts that may hold text. + isKeyCall := func(x *ast.CallExpr) (values []ast.Expr, ok bool) { + switch calleeName(x.Fun) { + case "T", "tell": + for i := 2; i < len(x.Args); i += 2 { + values = append(values, x.Args[i]) + } + return values, len(x.Args) > 0 + case "msg": + for i := 5; i < len(x.Args); i += 2 { + values = append(values, x.Args[i]) + } + return values, len(x.Args) > 3 + } + return nil, false + } + + // fixedText reports the first fixed text in an expression that goes to a + // chat: a string literal that has a word, or a name of the old sentence + // constants. + var fixedTextOf func(e ast.Expr) (string, gotoken.Pos) + fixedText := func(e ast.Expr) (string, gotoken.Pos) { return fixedTextOf(e) } + fixedTextOf = func(e ast.Expr) (string, gotoken.Pos) { + var ( + found string + at gotoken.Pos + ) + ast.Inspect(e, func(n ast.Node) bool { + if found != "" { + return false + } + switch x := n.(type) { + case *ast.CallExpr: + if values, ok := isKeyCall(x); ok { + for _, v := range values { + if s, p := fixedTextOf(v); s != "" && found == "" { + found, at = s, p + } + } + return false + } + case *ast.BasicLit: + if x.Kind == gotoken.STRING { + if s, _ := strconv.Unquote(x.Value); letters.MatchString(s) && !hostmsg.Has(s) { + found, at = s, x.Pos() + } + } + case *ast.Ident: + if strings.HasPrefix(x.Name, "sent") && len(x.Name) > 4 && x.Name[4] >= 'A' && x.Name[4] <= 'Z' { + found, at = x.Name, x.Pos() + } + } + return found == "" + }) + return found, at + } + + // sinks are the places where text is written to a chat. + sinks := func(f *ast.File, visit func(text ast.Expr, what string)) { + ast.Inspect(f, func(n ast.Node) bool { + switch x := n.(type) { + case *ast.CallExpr: + // say(text) of the handler, c.say(b, token, chat, thread, text) + // and the say of triggers. + if calleeName(x.Fun) == "say" && len(x.Args) > 0 { + visit(x.Args[len(x.Args)-1], "say") + } + case *ast.CompositeLit: + name := "" + switch t := x.Type.(type) { + case *ast.Ident: + name = t.Name + case *ast.SelectorExpr: + name = t.Sel.Name + } + if name != "Post" && name != "Outgoing" { + return true + } + for _, el := range x.Elts { + if kv, ok := el.(*ast.KeyValueExpr); ok { + if id, ok := kv.Key.(*ast.Ident); ok && id.Name == "Text" { + visit(kv.Value, name+".Text") + } + } + } + } + return true + }) + } + + It("reach the send path only as catalog keys", func() { + count := 0 + for name, f := range parsed { + sinks(f, func(text ast.Expr, what string) { + count++ + if s, at := fixedText(text); s != "" { + Fail(fset.Position(at).String() + " (" + name + "): " + strconv.Quote(s) + " goes to " + what + + " as a fixed text; add a key to hostmsg/locales/*.json and write it with msg or a Voice") + } + }) + } + Expect(count).To(BeNumerically(">", 5), "the guard found no send path; it is out of date") + }) + + It("use only keys that are in the catalog", func() { + used := map[string]bool{} + prefixes := map[string]bool{} + for _, f := range parsed { + ast.Inspect(f, func(n ast.Node) bool { + if l, ok := n.(*ast.BasicLit); ok && l.Kind == gotoken.STRING { + s, _ := strconv.Unquote(l.Value) + used[s] = true + } + return true + }) + } + // The package of the catalog speaks some keys itself (Voice.Join). + own, err := parser.ParseFile(fset, "hostmsg/catalog.go", nil, 0) + Expect(err).NotTo(HaveOccurred()) + ast.Inspect(own, func(n ast.Node) bool { + if l, ok := n.(*ast.BasicLit); ok && l.Kind == gotoken.STRING { + s, _ := strconv.Unquote(l.Value) + used[s] = true + } + return true + }) + for name, f := range parsed { + ast.Inspect(f, func(n ast.Node) bool { + call, ok := n.(*ast.CallExpr) + if !ok || len(call.Args) == 0 { + return true + } + fn := calleeName(call.Fun) + key := call.Args[0] + if fn == "msg" && len(call.Args) > 3 { + key = call.Args[3] // msg(ctx, binding, sender, key, ...) + } else if fn != "T" && fn != "tell" { + return true + } + switch k := key.(type) { + case *ast.BasicLit: + s, _ := strconv.Unquote(k.Value) + Expect(hostmsg.Has(s)).To(BeTrue(), "%s (%s): %q is not a key of the host message catalog", fset.Position(k.Pos()), name, s) + case *ast.BinaryExpr: + if l, ok := k.X.(*ast.BasicLit); ok { + s, _ := strconv.Unquote(l.Value) + prefixes[s] = true + } + } + return true + }) + } + // A key that nothing uses is a sentence nobody can reach: drop it, so + // the catalog does not hold dead translations. + for _, k := range hostmsg.Keys() { + if used[k] { + continue + } + byPrefix := false + for p := range prefixes { + byPrefix = byPrefix || strings.HasPrefix(k, p) + } + Expect(byPrefix).To(BeTrue(), "the key %q is in the catalog but no code uses it", k) + } + }) + + // producers build the sentences of the host. They may hold no fixed + // English text except in a log, an audit line or an error for the log. + producers := []string{ + "newChat", "tryPairing", "answerPhrase", "answerChange", "undoChange", "answerChanges", "proposalChatText", + "sendFiles", "sendHandled", "media", "transcribe", "sentFileTooBig", "noteSendTooBig", "answerOf", "inputs", + "AnswerByPhrase", "answerBare", "answerProposal", "cancelAuto", "proposalText", "autoText", "costText", "whenTextIn", + "summaryIn", "scheduleWords", "actsText", "actWords", "actLabel", "TextIn", "autoVerdict", "actsVerdict", + "resultsGo", "gapTextIn", "formatFileSizeIn", "whyIn", "sentenceIn", "kindWords", "scopeWords", "view", "observe", + "reportFailure", "sweepTimers", + } + quiet := []string{"Info", "Warn", "Error", "Debug", "audit", "New", "Errorf", "bad", "connectorBad", "Is", "Wrap"} + + It("hold no fixed English text where a person reads it", func() { + seen := map[string]bool{} + var bad []string + for name, f := range parsed { + for _, d := range f.Decls { + fd, ok := d.(*ast.FuncDecl) + if !ok || fd.Body == nil || !slices.Contains(producers, fd.Name.Name) { + continue + } + seen[fd.Name.Name] = true + var walk func(n ast.Node, inQuiet bool) + walk = func(n ast.Node, inQuiet bool) { + ast.Inspect(n, func(c ast.Node) bool { + switch x := c.(type) { + case *ast.CallExpr: + if slices.Contains(quiet, calleeName(x.Fun)) { + return false // a log line, an audit line or an error for the log + } + if values, ok := isKeyCall(x); ok { + for _, v := range values { + walk(v, inQuiet) + } + return false + } + case *ast.BasicLit: + if x.Kind != gotoken.STRING { + return true + } + s, _ := strconv.Unquote(x.Value) + if wordy.MatchString(s) && !hostmsg.Has(s) { + bad = append(bad, fset.Position(x.Pos()).String()+" ("+name+"."+fd.Name.Name+"): "+strconv.Quote(s)) + } + } + return true + }) + } + walk(fd.Body, false) + } + } + Expect(bad).To(BeEmpty(), "these are fixed texts in functions that write to a person; use a catalog key") + for _, p := range producers { + Expect(seen).To(HaveKey(p), "the guard lists %s but no such function exists; update the list", p) + } + }) +}) + +// wordy matches a text with two words, which is a sentence and not a key, a +// format or a separator. +var wordy = regexp.MustCompile(`[A-Za-z]{2,}[ ,.:;]+[A-Za-z]{2,}`) diff --git a/core/services/studio/triggeracts.go b/core/services/studio/triggeracts.go index 552ab02556..c29ba781cf 100644 --- a/core/services/studio/triggeracts.go +++ b/core/services/studio/triggeracts.go @@ -2,12 +2,12 @@ package studio import ( "context" - "fmt" "slices" "strings" "github.com/mudler/LocalAI/core/services/studio/builtins" "github.com/mudler/LocalAI/core/services/studio/connectors" + "github.com/mudler/LocalAI/core/services/studio/hostmsg" "github.com/mudler/LocalAI/core/services/studio/triggers" ) @@ -100,19 +100,19 @@ func sameActs(a, b []string) bool { } // actWords says in plain words what an act lets an unattended run do. -func actWords(name string) string { +func actWords(v hostmsg.Voice, name string) string { switch name { case builtins.ClassFiles: - return "write files in the agent's folders" + return v.T("act.word.files") case builtins.ClassAddons: - return "use the actions of the agent's add-ons" + return v.T("act.word.addons") case builtins.ClassSend: - return "send messages beyond where it reports" + return v.T("act.word.send") case builtins.ClassSpend: - return "use paid or external tools and the network" + return v.T("act.word.spend") } if id, ok := strings.CutPrefix(name, builtins.ClassAddons+":"); ok { - return "use the actions of the add-on " + id + return v.T("act.addon", "id", id) } return name } @@ -128,19 +128,19 @@ func actSensitive(name string) bool { } // actLabel names an act in a few plain words, for the text of a proposal. -func actLabel(name string) string { +func actLabel(v hostmsg.Voice, name string) string { switch name { case builtins.ClassFiles: - return "write files" + return v.T("act.label.files") case builtins.ClassAddons: - return "use add-on actions" + return v.T("act.label.addons") case builtins.ClassSend: - return "send messages" + return v.T("act.label.send") case builtins.ClassSpend: - return "use paid or external tools" + return v.T("act.label.spend") } if id, ok := strings.CutPrefix(name, builtins.ClassAddons+":"); ok { - return "use the actions of the add-on " + id + return v.T("act.addon", "id", id) } return name } @@ -159,7 +159,10 @@ type DroppedActs struct { func (d DroppedActs) Empty() bool { return len(d.Binding) == 0 && len(d.Agent) == 0 } // Text is one sentence that names the dropped acts, or "" when none. -func (d DroppedActs) Text() string { +func (d DroppedActs) Text() string { return d.TextIn(hostmsg.Voice{}) } + +// TextIn is Text in the language of the voice. +func (d DroppedActs) TextIn(v hostmsg.Voice) string { if d.Empty() { return "" } @@ -167,17 +170,17 @@ func (d DroppedActs) Text() string { words := func(names []string) string { w := make([]string, len(names)) for i, n := range names { - w[i] = actLabel(n) + w[i] = actLabel(v, n) } return strings.Join(w, ", ") } if len(d.Binding) > 0 { - parts = append(parts, words(d.Binding)+" (this connection does not allow it)") + parts = append(parts, v.T("dropped.binding", "words", words(d.Binding))) } if len(d.Agent) > 0 { - parts = append(parts, words(d.Agent)+" (the agent does not hold it)") + parts = append(parts, v.T("dropped.agent", "words", words(d.Agent))) } - return "I did not allow: " + strings.Join(parts, "; ") + "." + return v.T("dropped.text", "parts", strings.Join(parts, "; ")) } // limitConnectorActs reduces what a model asked for in a chat of a messaging @@ -217,31 +220,25 @@ func effectiveActs(t triggers.Trigger) []string { return held } -// Next steps after a read only proposal. -const ( - readOnlyNextAsk = "Ask me to let it act, or change it later on the Triggers page." - readOnlyNextPage = "You can change this later on the Triggers page." -) - // actsText is one sentence about what the runs of a proposed trigger may do. // It is on the text of a proposal in a chat of a messaging app, so the person // reads it before they answer yes. canAsk says that the binding lets runs act, // so the person can ask the agent for it; a read only text then says so. -func actsText(t triggers.Trigger, canAsk bool) string { +func actsText(v hostmsg.Voice, t triggers.Trigger, canAsk bool) string { held := effectiveActs(t) if t.Route.ReadOnly || len(held) == 0 { - next := readOnlyNextPage + next := v.T("acts.next_page") if canAsk { - next = readOnlyNextAsk + next = v.T("acts.next_ask") } - return "Permissions: read only. A run can read and report, and cannot change anything. " + next + return v.T("acts.read_only", "next", next) } words := make([]string, len(held)) for i, n := range held { - words[i] = actWords(n) + words[i] = actWords(v, n) if actSensitive(n) { - words[i] += " (sensitive)" + words[i] = v.T("acts.sensitive", "words", words[i]) } } - return fmt.Sprintf("Permissions: with nobody present, a run may %s. Say no if that is more than you want.", strings.Join(words, "; ")) + return v.T("acts.with", "list", strings.Join(words, "; ")) } diff --git a/core/services/studio/triggerapproval.go b/core/services/studio/triggerapproval.go index 81207d3302..c47f22dc45 100644 --- a/core/services/studio/triggerapproval.go +++ b/core/services/studio/triggerapproval.go @@ -9,8 +9,11 @@ import ( "strconv" "strings" "time" + "unicode" + "unicode/utf8" "github.com/mudler/LocalAI/core/services/studio/connectors" + "github.com/mudler/LocalAI/core/services/studio/hostmsg" "github.com/mudler/LocalAI/core/services/studio/proposals" "github.com/mudler/LocalAI/core/services/studio/session" "github.com/mudler/LocalAI/core/services/studio/triggers" @@ -148,52 +151,52 @@ func (h *Triggers) runsPerDay(t triggers.Trigger, now time.Time) int { // autoVerdict says why a proposal may not be made without asking under the // approval of its binding; empty means it may. The checks are the owner's // limits and, for the read-only mode, what the proposal says about itself. -func (h *Triggers) autoVerdict(ctx context.Context, a workspace.Actor, ap connectors.Approval, t triggers.Trigger, dr DroppedActs) string { +func (h *Triggers) autoVerdict(ctx context.Context, v hostmsg.Voice, a workspace.Actor, ap connectors.Approval, t triggers.Trigger, dr DroppedActs) string { lim := ap.Limits() now := h.o.Clock() if ap.ModeOrDefault() == connectors.ApprovalAutoReadonly { switch { case !t.Route.ReadOnly: - return "it does not say that its runs only read and report" + return v.T("verdict.not_readonly") case t.Writes != "" || t.Widget != "": - return "its runs write a file" + return v.T("verdict.writes") } } if !dr.Empty() { // The model asked for more than is allowed here. The person must see // what is left out before anything starts. - return "some acts it asked for are not allowed here" + return v.T("verdict.dropped") } - if why := h.actsVerdict(ctx, a, ap, t); why != "" { + if why := h.actsVerdict(ctx, v, a, ap, t); why != "" { return why } if t.Route.TargetOrDefault() == triggers.TargetChannel { - return "it would write to another channel" + return v.T("verdict.channel") } if t.Kind != triggers.KindSchedule { - return "a trigger on files always asks" + return v.T("verdict.files") } all, err := h.o.Store.ListTriggers(ctx, t.Owner) if err != nil { - return "I could not count the timers of this connection" + return v.T("verdict.count_fail") } live := autoLive(all, t.Route.Binding) if len(live) >= lim.MaxPending { - return fmt.Sprintf("this connection already has %d timers and triggers (the most is %d)", len(live), lim.MaxPending) + return v.T("verdict.too_many", "n", len(live), "max", lim.MaxPending) } days := time.Duration(lim.MaxLifetimeDays) * 24 * time.Hour if at, ok := t.OnceAt(); ok { if at.Sub(now) > days { - return fmt.Sprintf("it is more than %d days away", lim.MaxLifetimeDays) + return v.T("verdict.far", "days", lim.MaxLifetimeDays) } } else { sch, perr := triggers.ParseSchedule(t.Schedule) loc, lerr := t.Location() if perr != nil || lerr != nil { - return "its schedule cannot be read" + return v.T("verdict.schedule") } if gap := sch.MinGap(now, loc); gap > 0 && gap < time.Duration(lim.MinGapMinutes)*time.Minute { - return fmt.Sprintf("it runs every %s (the least is %d minutes)", gapText(gap), lim.MinGapMinutes) + return v.T("verdict.gap", "gap", gapTextIn(v, gap), "min", lim.MinGapMinutes) } } used := 0 @@ -201,7 +204,7 @@ func (h *Triggers) autoVerdict(ctx context.Context, a workspace.Actor, ap connec used += h.runsPerDay(l, now) } if need := h.runsPerDay(capped(t, lim), now); used+need > lim.MaxRunsPerDay { - return fmt.Sprintf("it would make %d runs a day with the others (the most is %d)", used+need, lim.MaxRunsPerDay) + return v.T("verdict.runs", "n", used+need, "max", lim.MaxRunsPerDay) } return "" } @@ -216,23 +219,23 @@ func (h *Triggers) autoVerdict(ctx context.Context, a workspace.Actor, ap connec // also be paired: a role or the allowlist alone is not enough. // // The limits of the approval apply on top, as for any trigger. -func (h *Triggers) actsVerdict(ctx context.Context, a workspace.Actor, ap connectors.Approval, t triggers.Trigger) string { +func (h *Triggers) actsVerdict(ctx context.Context, v hostmsg.Voice, a workspace.Actor, ap connectors.Approval, t triggers.Trigger) string { acts := effectiveActs(t) if len(acts) == 0 { return "" } if ap.ModeOrDefault() == connectors.ApprovalAutoReadonly { - return "its runs ask for permission to act on their own" + return v.T("verdict.readonly_acts") } var sensitive bool for _, n := range acts { if !ap.AllowsAct(n) { - return "its runs would " + actLabel(n) + ", which the owner has not allowed to start without a yes" + return v.T("verdict.not_allowed", "act", actLabel(v, n)) } sensitive = sensitive || actSensitive(n) } if sensitive && !h.senderPaired(ctx, a, t) { - return "its runs would act on their own, and that needs a sender paired with this connection" + return v.T("verdict.paired") } return "" } @@ -288,7 +291,7 @@ func (h *Triggers) autoCreate(ctx context.Context, ap connectors.Approval, p tri return triggers.Trigger{}, false, err } h.audit(ctx, proposals.ActionConfirm, t, "automatic, mode "+ap.ModeOrDefault()) - return t, h.say(ctx, t, h.autoText(t, dr)), nil + return t, h.say(ctx, t, h.autoText(h.voiceOf(ctx, t), t, dr)), nil } // say writes text in the chat and thread where a trigger was proposed. @@ -306,55 +309,52 @@ func (h *Triggers) say(ctx context.Context, t triggers.Trigger, text string) boo // costText says what a run costs, for the text of a proposal: every run is a // model turn. -func (h *Triggers) costText(t triggers.Trigger) string { +func (h *Triggers) costText(v hostmsg.Voice, t triggers.Trigger) string { if t.OneOff() { - return "one model run" + return v.T("trigger.cost.once") } - return "one model run each time it runs" + return v.T("trigger.cost.each") } // autoText is what the host writes in the chat when it made a trigger without // asking. -func (h *Triggers) autoText(t triggers.Trigger, dr DroppedActs) string { - where := "the Inbox in Studio" - switch t.Route.TargetOrDefault() { - case triggers.TargetOrigin: - where = "this chat" - case triggers.TargetChannel: - where = "the channel " + t.Route.Channel - } +func (h *Triggers) autoText(v hostmsg.Voice, t triggers.Trigger, dr DroppedActs) string { end := "" if !t.Route.Until.IsZero() { - end = fmt.Sprintf(" It stops by itself on %s.", t.Route.Until.UTC().Format("2006-01-02")) + end = v.T("trigger.auto_stops", "date", t.Route.Until.UTC().Format("2006-01-02")) } - if d := dr.Text(); d != "" { + if d := dr.TextIn(v); d != "" { end += " " + d } if acts := effectiveActs(t); len(acts) > 0 { - end += " " + actsText(t, false) + end += " " + actsText(v, t, false) } - return fmt.Sprintf("Set up %q: %s. Cost: %s. Results go to %s.%s\nTo remove it, reply \"cancel\" here, or \"cancel %s\" at any time.", - t.Name, lowerFirst(h.whenText(t)), h.costText(t), where, end, t.Route.Code) + return v.T("trigger.auto", "name", strconv.Quote(t.Name), "when", lowerFirst(h.whenTextIn(v, t)), "cost", h.costText(v, t), "where", resultsGo(v, t), "end", end, "code", t.Route.Code) } // whenText is summary with the day of a one-off trigger spelled out. -func (h *Triggers) whenText(t triggers.Trigger) string { +func (h *Triggers) whenText(t triggers.Trigger) string { return h.whenTextIn(hostmsg.Voice{}, t) } + +// whenTextIn is whenText in the language of the voice. +func (h *Triggers) whenTextIn(v hostmsg.Voice, t triggers.Trigger) string { if at, ok := t.OnceAt(); ok { loc, _ := t.Location() zone := t.TZ if zone == "" { zone = "UTC" } - return "Once, " + at.In(loc).Format("Mon 2006-01-02 at 15:04") + " (" + zone + ")" + local := at.In(loc) + return v.T("when.once_at", "weekday", v.T("day.short."+strconv.Itoa(int(local.Weekday()))), "date", local.Format("2006-01-02"), "time", local.Format("15:04"), "zone", zone) } - return h.summary(t) + return h.summaryIn(v, t) } func lowerFirst(s string) string { - if s == "" { + r, n := utf8.DecodeRuneInString(s) + if n == 0 { return s } - return strings.ToLower(s[:1]) + s[1:] + return string(unicode.ToLower(r)) + s[n:] } // ---- Spent and ended triggers ---- @@ -403,7 +403,7 @@ func (h *Triggers) sweepTimers(ctx context.Context) { case !t.Route.Until.IsZero() && !now.Before(t.Route.Until): if h.DeleteTrigger(ctx, t.Owner, t.ID) == nil { h.audit(ctx, proposals.ActionExpire, t, "lifetime ended; removed") - h.say(ctx, t, fmt.Sprintf("%q reached the end of its lifetime and was removed.", t.Name)) + h.say(ctx, t, h.voiceOf(ctx, t).T("trigger.lifetime", "name", strconv.Quote(t.Name))) } case t.OneOff(): at, _ := t.OnceAt() @@ -437,7 +437,7 @@ func (h *Triggers) afterPropose(ctx context.Context, a workspace.Actor, t trigge note := "" if c := h.svc.connectors; c != nil && fresh { if ap := c.ApprovalOf(ctx, t.Route.Binding); ap.Auto() { - note = h.autoVerdict(ctx, a, ap, t, dr) + note = h.autoVerdict(ctx, h.voiceOf(ctx, t), a, ap, t, dr) if note == "" { made, wrote, err := h.autoCreate(ctx, ap, t, dr) if err == nil { diff --git a/core/services/studio/triggerreply.go b/core/services/studio/triggerreply.go index 89b0d4530d..5aa3657289 100644 --- a/core/services/studio/triggerreply.go +++ b/core/services/studio/triggerreply.go @@ -3,6 +3,7 @@ package studio import ( "errors" "regexp" + "slices" "strconv" "strings" "time" @@ -13,12 +14,44 @@ import ( // optional code), so a longer message always goes to the agent. Words of other // languages are optional; these cover the common ones. var ( - yesWords = wordSet("yes", "y", "yep", "yeah", "ok", "okay", "sure", "confirm", "approve", "approved", "go ahead", "do it", - "👍", "✅", "si", "sì", "ja", "oui", "vale", "ok grazie") - noWords = wordSet("no", "n", "nope", "cancel", "stop", "drop it", "no thanks", "no thank you", "never mind", "nevermind", "undo", - "👎", "❌", "nein", "non", "annulla") + yesWords = wordSet(slices.Concat( + []string{"yes", "y", "yep", "yeah", "ok", "okay", "sure", "confirm", "approve", "approved", "go ahead", "do it", "👍", "✅"}, + // Italian + []string{"si", "sì", "certo", "certamente", "conferma", "confermo", "confermato", "va bene", "d'accordo", "daccordo", "perfetto", "procedi", "salva", "avvia", "ok grazie", "ok va bene"}, + // German + []string{"ja", "jawohl", "klar", "natürlich", "bestätigen", "bestätige", "bestätigt", "einverstanden", "passt", "mach das", "speichern", "starten", "ok danke"}, + // Spanish + []string{"sí", "claro", "vale", "confirmar", "confirmo", "confirma", "de acuerdo", "adelante", "hazlo", "guardar", "guárdalo", "ok gracias"}, + // French + []string{"oui", "ouais", "d'accord", "daccord", "bien sûr", "confirmer", "confirme", "je confirme", "vas-y", "enregistre", "enregistrer", "ok merci"}, + // Portuguese (Brazil) + []string{"sim", "pode", "pode salvar", "beleza", "certo", "confirmar", "confirma", "de acordo", "bora", "salvar", "ok obrigado", "ok obrigada"}, + )...) + noWords = wordSet(slices.Concat( + []string{"no", "n", "nope", "cancel", "stop", "drop it", "no thanks", "no thank you", "never mind", "nevermind", "undo", "👎", "❌"}, + // Italian + []string{"annulla", "annullare", "annullato", "lascia stare", "no grazie", "scarta", "ferma"}, + // German + []string{"nein", "nö", "abbrechen", "abbruch", "stopp", "lass es", "lass gut sein", "nein danke", "verwerfen"}, + // Spanish + []string{"cancelar", "cancela", "déjalo", "olvídalo", "no gracias", "descartar"}, + // French + []string{"non", "annuler", "annule", "laisse tomber", "non merci", "oublie", "abandonner"}, + // Portuguese (Brazil) + []string{"não", "nao", "cancelar", "cancela", "deixa pra lá", "não obrigado", "nao obrigado", "descartar", "esquece"}, + )...) + + // undoWords take back a change that was just applied from the chat. A + // bare "no" or "cancel" after a save does not: it may mean anything. Each + // language has its own phrase, the one its confirmation text asks for. + undoWords = wordSet("undo", "annulla modifica", "annulla la modifica", "rückgängig", "rückgängig machen", "deshacer", + "annuler la modification", "défaire", "desfazer") ) +// isUndoReply says whether the whole message is the word that takes back the +// change that was just applied. +func isUndoReply(text string) bool { return undoWords[normalizeReply(text)] } + func wordSet(w ...string) map[string]bool { m := make(map[string]bool, len(w)) for _, x := range w { @@ -61,7 +94,7 @@ func replyVerb(t string) (string, bool) { switch { case yesWords[t]: return "confirm", true - case noWords[t]: + case noWords[t], undoWords[t]: return "cancel", true } return "", false diff --git a/core/services/studio/triggerreply_language_test.go b/core/services/studio/triggerreply_language_test.go new file mode 100644 index 0000000000..755b77c253 --- /dev/null +++ b/core/services/studio/triggerreply_language_test.go @@ -0,0 +1,112 @@ +package studio_test + +import ( + "strings" + + "github.com/mudler/LocalAI/core/services/studio" + "github.com/mudler/LocalAI/core/services/studio/hostmsg" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// The words that answer a proposal are read in every language the host speaks. +// The host asks with these words, so each must be understood when it comes back. +var _ = Describe("the short answers to a proposal", func() { + yes := map[string][]string{ + "en": {"yes", "ok", "confirm"}, + "it": {"sì", "si", "certo", "conferma", "va bene", "confermo"}, + "de": {"ja", "klar", "bestätigen", "einverstanden"}, + "es": {"sí", "claro", "vale", "confirmar", "de acuerdo"}, + "fr": {"oui", "d'accord", "confirmer", "bien sûr"}, + "pt-BR": {"sim", "pode", "confirmar", "beleza"}, + } + no := map[string][]string{ + "en": {"no", "cancel", "stop"}, + "it": {"no", "annulla", "annullare", "no grazie"}, + "de": {"nein", "abbrechen", "stopp"}, + "es": {"no", "cancelar", "no gracias"}, + "fr": {"non", "annuler", "non merci"}, + "pt-BR": {"não", "cancelar", "nao"}, + } + undo := []string{"undo", "annulla modifica", "rückgängig", "deshacer", "annuler la modification", "desfazer"} + + It("understands a yes in each language", func() { + for lang, words := range yes { + for _, w := range words { + verb, code, ok := studio.ParseReply(w) + Expect(ok).To(BeTrue(), "%s: %q", lang, w) + Expect(verb).To(Equal("confirm"), "%s: %q", lang, w) + Expect(code).To(BeEmpty()) + verb, code, ok = studio.ParseReply(w + " 7k2q") + Expect([]any{verb, code, ok}).To(Equal([]any{"confirm", "7K2Q", true}), "%s: %q with a code", lang, w) + } + } + }) + + It("understands a no in each language", func() { + for lang, words := range no { + for _, w := range words { + verb, _, ok := studio.ParseReply(w + ".") + Expect(ok).To(BeTrue(), "%s: %q", lang, w) + Expect(verb).To(Equal("cancel"), "%s: %q", lang, w) + } + } + }) + + It("understands the word that takes a change back, and the phrase the host asks for", func() { + for _, w := range undo { + verb, _, ok := studio.ParseReply(w) + Expect(ok).To(BeTrue(), "%q", w) + Expect(verb).To(Equal("cancel")) + } + }) + + It("reads the verbs that the host teaches in its own text", func() { + // The text asks "confirmar CODIGO", "bestätigen CODE" and so on: each + // verb of the catalog must come back as an answer. + for _, lang := range hostmsg.Languages { + for _, key := range []string{"change.ask", "trigger.proposal", "trigger.auto", "proposal.multi"} { + text := hostmsg.Template(lang, key) + for _, verb := range quotedWords(text) { + if verb == "" { + continue + } + _, _, ok := studio.ParseReply(verb) + Expect(ok).To(BeTrue(), "%s %s asks for %q, which the host does not read", lang, key, verb) + } + } + } + }) + + It("does not take a sentence for an answer", func() { + for _, s := range []string{"sì ma prima dimmi il costo", "can you do it tomorrow", "nein danke, später vielleicht", "no sé", "oui mais plus tard"} { + _, _, ok := studio.ParseReply(s) + Expect(ok).To(BeFalse(), "%q", s) + } + }) +}) + +// quotedWords lists the words between double quotes in a template, without a +// code placeholder or the capital word that stands for a code. +func quotedWords(tpl string) []string { + tpl = strings.NewReplacer("« ", "\"", " »", "\"").Replace(tpl) + var out []string + for { + i := strings.IndexByte(tpl, '"') + if i < 0 { + return out + } + rest := tpl[i+1:] + j := strings.IndexByte(rest, '"') + if j < 0 { + return out + } + w := rest[:j] + tpl = rest[j+1:] + // "confirm {code}" and "confirm CODE": keep the verb only. + if k := strings.IndexByte(w, ' '); k >= 0 { + w = w[:k] + } + out = append(out, w) + } +} diff --git a/core/services/studio/triggerroute.go b/core/services/studio/triggerroute.go index c23b5666da..0c974c178f 100644 --- a/core/services/studio/triggerroute.go +++ b/core/services/studio/triggerroute.go @@ -6,10 +6,12 @@ import ( "errors" "fmt" "regexp" + "strconv" "strings" "time" "unicode/utf8" + "github.com/mudler/LocalAI/core/services/studio/hostmsg" "github.com/mudler/LocalAI/core/services/studio/notify" "github.com/mudler/LocalAI/core/services/studio/proposals" "github.com/mudler/LocalAI/core/services/studio/triggers" @@ -146,15 +148,6 @@ func (h *Triggers) expireProposals(ctx context.Context) { // ---- Confirmation by text ---- -// Sentences the bot says about a proposal. -const ( - sentProposalGone = "That proposal expired. Ask again if you still want it." - sentProposalPlace = "Answer in the same place where it was proposed, or open Studio." - sentProposalNoRight = "Only a sender the owner trusts can answer a proposal from here. Open Studio to answer it." - sentProposalFailed = "I could not do that: %s" - sentProposalCancel = "Cancelled." -) - // lapsedWindow is how long after a proposal lapsed a bare word in its thread is // answered with the news that it lapsed, and not sent to the agent. const lapsedWindow = 15 * time.Minute @@ -179,6 +172,9 @@ type PhraseFrom struct { Username string // Roles are the roles of the sender that the kind declares. Roles []string + // Voice speaks the answer in the language of the sender. The zero value + // speaks English. + Voice hostmsg.Voice } // AnswerByPhrase answers a waiting proposal from a short answer: a word with @@ -197,7 +193,7 @@ func (h *Triggers) AnswerByPhrase(ctx context.Context, from PhraseFrom, verb, co if code == "" { return h.answerBare(ctx, from, verb, all) } - detail := "by text from sender " + from.Sender + detail := byTextFrom(from.Sender) for _, t := range all { if t.Route.Binding != from.Binding || t.Route.Code != code { continue @@ -254,14 +250,14 @@ func (h *Triggers) answerBare(ctx context.Context, from PhraseFrom, verb string, if len(waiting)+len(fresh) == 0 && len(lapsed) == 0 { return "", false } - detail := "by text from sender " + from.Sender + detail := byTextFrom(from.Sender) if len(waiting)+len(fresh) == 0 { // The proposal this word answers has just lapsed: say so, so the person // does not think it started. if !h.canAnswer(ctx, from) { return "", false } - return sentProposalGone, true + return from.Voice.T("proposal.gone"), true } if !h.canAnswer(ctx, from) { // An ordinary "ok" from someone who cannot answer is just a message. @@ -275,7 +271,7 @@ func (h *Triggers) answerBare(ctx context.Context, from PhraseFrom, verb string, return h.cancelAuto(ctx, from, fresh[0], detail) } var b strings.Builder - b.WriteString("More than one is waiting here. Say which, for example \"confirm CODE\" or \"cancel CODE\":") + b.WriteString(from.Voice.T("proposal.multi")) for _, t := range append(waiting, fresh...) { fmt.Fprintf(&b, "\n%s: %q", t.Route.Code, t.Name) } @@ -286,83 +282,86 @@ func (h *Triggers) answerBare(ctx context.Context, from PhraseFrom, verb string, func (h *Triggers) answerProposal(ctx context.Context, from PhraseFrom, p triggers.Trigger, verb, detail string) (string, bool) { if p.Expired(h.o.Clock()) { h.lapse(ctx, p) - return sentProposalGone, true + return from.Voice.T("proposal.gone"), true } if !sameThread(p, from) { h.audit(ctx, proposals.ActionRefuse, p, "wrong place; "+detail) - return sentProposalPlace, true + return from.Voice.T("proposal.place"), true } if !h.canAnswer(ctx, from) { h.audit(ctx, proposals.ActionRefuse, p, "not allowed; "+detail) - return sentProposalNoRight, true + return from.Voice.T("proposal.no_right"), true } if verb == "cancel" { if err := h.DismissProposal(ctx, p.Owner, p.ID); err != nil { - return fmt.Sprintf(sentProposalFailed, toolErr(err)), true + return from.Voice.T("proposal.failed", "error", toolErr(err)), true } h.audit(ctx, proposals.ActionDismiss, p, detail) - return sentProposalCancel, true + return from.Voice.T("proposal.cancelled"), true } t, err := h.ConfirmProposal(ctx, p.Owner, p.ID, "") if err != nil { h.audit(ctx, proposals.ActionRefuse, p, "confirm failed; "+detail) - return fmt.Sprintf(sentProposalFailed, toolErr(err)), true + return from.Voice.T("proposal.failed", "error", toolErr(err)), true } h.audit(ctx, proposals.ActionConfirm, t, detail) - return fmt.Sprintf("Confirmed. %q is on: %s. Cost: %s. %s", t.Name, h.whenText(t), h.costText(t), actsText(t, false)), true + v := from.Voice + return v.T("proposal.confirmed", "name", strconv.Quote(t.Name), "when", h.whenTextIn(v, t), "cost", h.costText(v, t), "acts", actsText(v, t, false)), true } // cancelAuto removes a trigger that was made without asking. func (h *Triggers) cancelAuto(ctx context.Context, from PhraseFrom, t triggers.Trigger, detail string) (string, bool) { if !sameThread(t, from) { h.audit(ctx, proposals.ActionRefuse, t, "wrong place; "+detail) - return sentProposalPlace, true + return from.Voice.T("proposal.place"), true } if !h.canAnswer(ctx, from) { h.audit(ctx, proposals.ActionRefuse, t, "not allowed; "+detail) - return sentProposalNoRight, true + return from.Voice.T("proposal.no_right"), true } if err := h.DeleteTrigger(ctx, t.Owner, t.ID); err != nil { - return fmt.Sprintf(sentProposalFailed, toolErr(err)), true + return from.Voice.T("proposal.failed", "error", toolErr(err)), true } h.audit(ctx, proposals.ActionDismiss, t, "removed after automatic setup; "+detail) - return fmt.Sprintf("Cancelled. %q is removed.", t.Name), true + return from.Voice.T("proposal.auto_cancelled", "name", strconv.Quote(t.Name)), true } // proposalText is what the host writes in the chat where a proposal was made. // It shows what the run will do and costs, so a person can answer it from // there, and how to answer. -func (h *Triggers) proposalText(t triggers.Trigger, note string, dr DroppedActs, canAsk bool) string { +func (h *Triggers) proposalText(v hostmsg.Voice, t triggers.Trigger, note string, dr DroppedActs, canAsk bool) string { prompt := strings.Join(strings.Fields(t.Prompt), " ") if utf8.RuneCountInString(prompt) > 280 { prompt = string([]rune(prompt)[:280]) + "…" } - where := "the Inbox in Studio" - switch t.Route.TargetOrDefault() { - case triggers.TargetOrigin: - where = "this chat" - case triggers.TargetChannel: - where = "the channel " + t.Route.Channel - } var mode string switch t.Route.ModeOrDefault() { case triggers.ModeOnChange: - mode = ", only when the result changes" + mode = v.T("trigger.mode.on_change") case triggers.ModeOnProblem: - mode = ", only when a run fails" + mode = v.T("trigger.mode.on_problem") } - when := lowerFirst(h.whenText(t)) extra := "" if note != "" { - extra = "I did not start it by myself: " + note + ".\n" + extra = v.T("trigger.not_asked", "note", note) } - perms := actsText(t, canAsk) - if d := dr.Text(); d != "" { + perms := actsText(v, t, canAsk) + if d := dr.TextIn(v); d != "" { perms += " " + d } - return fmt.Sprintf("Proposal %s: %q, %s. It will: %s\nCost: %s. Results go to %s%s.\n%s\n%sNothing runs until you approve. Reply \"yes\" to start it or \"cancel\" to drop it, here, within 24 hours, or open Studio. "+ - "If more than one proposal waits, reply \"confirm %s\" or \"cancel %s\".", - t.Route.Code, t.Name, when, prompt, h.costText(t), where, mode, perms, extra, t.Route.Code, t.Route.Code) + return v.T("trigger.proposal", "code", t.Route.Code, "name", strconv.Quote(t.Name), "when", lowerFirst(h.whenTextIn(v, t)), "prompt", prompt, + "cost", h.costText(v, t), "where", resultsGo(v, t), "mode", mode, "perms", perms, "extra", extra) +} + +// resultsGo says where the results of a trigger go, for the text of a proposal. +func resultsGo(v hostmsg.Voice, t triggers.Trigger) string { + switch t.Route.TargetOrDefault() { + case triggers.TargetOrigin: + return v.T("trigger.where.chat") + case triggers.TargetChannel: + return v.T("trigger.where.channel", "channel", t.Route.Channel) + } + return v.T("trigger.where.inbox") } // Announce writes the proposal, with its code, in the chat where it was made. @@ -378,7 +377,7 @@ func (h *Triggers) announce(ctx context.Context, t triggers.Trigger, note string if c == nil || !t.Route.FromConnector() || t.Route.Chat == "" { return false } - _, err := c.Post(ctx, Post{Binding: t.Route.Binding, Chat: t.Route.Chat, Thread: t.Route.Thread, Text: h.proposalText(t, note, dr, h.canAskActs(ctx, t))}) + _, err := c.Post(ctx, Post{Binding: t.Route.Binding, Chat: t.Route.Chat, Thread: t.Route.Thread, Text: h.proposalText(c.voiceOf(ctx, t.Route.Binding, "", t.Route.Chat), t, note, dr, h.canAskActs(ctx, t))}) if err != nil { h.log.Warn("studio: could not write a proposal to its chat", "trigger", t.ID, "error", err) return false @@ -517,7 +516,7 @@ func (h *Triggers) reportFailure(ctx context.Context, t triggers.Trigger, reason } if t.OneOff() { // A timer has no second run to wait for: say at once that it failed. - h.deliverOrigin(ctx, t, fmt.Sprintf("%s failed: %s", t.Name, reportText(reason)), func() {}) + h.deliverOrigin(ctx, t, h.voiceOf(ctx, t).T("trigger.failed", "name", t.Name, "reason", reportText(reason)), func() {}) return } runs, err := h.o.Store.ListRuns(ctx, t.ID, failureAlertAfter) @@ -540,6 +539,6 @@ func (h *Triggers) reportFailure(ctx context.Context, t triggers.Trigger, reason } h.alerted[t.ID] = now h.mu.Unlock() - body := fmt.Sprintf("%s failed %d times in a row: %s\nFix or pause it in Studio.", t.Name, failureAlertAfter, reportText(reason)) + body := h.voiceOf(ctx, t).T("trigger.failed_n", "name", t.Name, "n", failureAlertAfter, "reason", reportText(reason)) h.deliverOrigin(ctx, t, body, func() {}) } diff --git a/core/services/studio/triggers/schedule.go b/core/services/studio/triggers/schedule.go index 22d2cc4c5e..30a267b448 100644 --- a/core/services/studio/triggers/schedule.go +++ b/core/services/studio/triggers/schedule.go @@ -489,3 +489,45 @@ func (s Schedule) MinGap(from time.Time, loc *time.Location) time.Duration { } return gap } + +// SchedulePart names the shape of a schedule, so a caller can word it in a +// language of its own (String gives the English words). +type SchedulePart struct { + // Kind is "daily", "weekdays", "weekly", "monthly", "once", "cron", or "" + // for a schedule that never fires. + Kind string + // Hour and Minute are the time of day of the kinds that have one. + Hour, Minute int + // Weekdays lists the days of a weekly schedule, Monday first, with 0 for + // Sunday. + Weekdays []int + // Day is the day of the month of a monthly schedule. + Day int + // Year, Month and DayOfMonth are the date of a once schedule. + Year, Month, DayOfMonth int + // Text is the canonical text of the schedule, for a cron expression. + Text string +} + +// Part describes the shape of the schedule. +func (s Schedule) Part() SchedulePart { + p := SchedulePart{Kind: s.kind, Text: s.text} + if s.kind == "" { + return p + } + if len(s.hours) > 0 && len(s.mins) > 0 { + p.Hour, p.Minute = s.hours[0], s.mins[0] + } + switch s.kind { + case "weekly": + p.Weekdays = s.weekdays() + case "monthly": + p.Day = firstBit(uint64(s.dom)) + case "once": + p.Year, p.Month, p.DayOfMonth = s.onceY, s.onceM, s.onceD + case "daily", "weekdays": + default: + p.Kind = "cron" + } + return p +} diff --git a/core/services/studio/triggerscrud.go b/core/services/studio/triggerscrud.go index ba3b003600..4f7cf31de1 100644 --- a/core/services/studio/triggerscrud.go +++ b/core/services/studio/triggerscrud.go @@ -11,6 +11,7 @@ import ( "github.com/mudler/LocalAI/core/services/studio/agents" "github.com/mudler/LocalAI/core/services/studio/builtins" "github.com/mudler/LocalAI/core/services/studio/files" + "github.com/mudler/LocalAI/core/services/studio/hostmsg" "github.com/mudler/LocalAI/core/services/studio/proposals" "github.com/mudler/LocalAI/core/services/studio/triggers" "github.com/mudler/LocalAI/core/services/studio/wellknown" @@ -486,15 +487,18 @@ func gapAgo(d time.Duration) string { return "only " + gapText(d) + " away" } -func gapText(d time.Duration) string { +func gapText(d time.Duration) string { return gapTextIn(hostmsg.Voice{}, d) } + +// gapTextIn is gapText in the language of the voice. +func gapTextIn(v hostmsg.Voice, d time.Duration) string { if d < time.Minute { return d.Round(time.Second).String() } if m := int(d / time.Minute); m < 60 { if m == 1 { - return "minute" + return v.T("gap.minute") } - return fmt.Sprintf("%d minutes", m) + return v.T("gap.minutes", "n", m) } return d.Round(time.Minute).String() } diff --git a/core/services/studio/triggertools.go b/core/services/studio/triggertools.go index cf37f3664a..13e817f492 100644 --- a/core/services/studio/triggertools.go +++ b/core/services/studio/triggertools.go @@ -7,10 +7,12 @@ import ( "fmt" "slices" "sort" + "strconv" "strings" "github.com/mudler/LocalAI/core/http/auth" "github.com/mudler/LocalAI/core/services/studio/builtins" + "github.com/mudler/LocalAI/core/services/studio/hostmsg" "github.com/mudler/LocalAI/core/services/studio/session" "github.com/mudler/LocalAI/core/services/studio/triggers" "github.com/mudler/LocalAI/core/services/studio/workspace" @@ -606,21 +608,50 @@ func (h *Triggers) changeCard(ctx context.Context, a workspace.Actor, rec trigge // "New files in /Contracts". func (h *Triggers) Summary(t triggers.Trigger) string { return h.summary(t) } -func (h *Triggers) summary(t triggers.Trigger) string { +func (h *Triggers) summary(t triggers.Trigger) string { return h.summaryIn(hostmsg.Voice{}, t) } + +// summaryIn is summary in the language of the voice. +func (h *Triggers) summaryIn(v hostmsg.Voice, t triggers.Trigger) string { if t.Kind == triggers.KindFile { - return "new files in " + h.RelFolder(t) + return v.T("when.files", "folder", h.RelFolder(t)) } sch, err := triggers.ParseSchedule(t.Schedule) if err != nil { return t.Schedule } - s := sch.String() + s := scheduleWords(v, sch) if t.TZ != "" { s += " (" + t.TZ + ")" } return s } +// scheduleWords words a schedule in the language of the voice. For English it +// is the same text as Schedule.String. +func scheduleWords(v hostmsg.Voice, s triggers.Schedule) string { + p := s.Part() + hm := fmt.Sprintf("%02d:%02d", p.Hour, p.Minute) + switch p.Kind { + case "": + return v.T("when.never") + case "daily": + return v.T("when.daily", "time", hm) + case "weekdays": + return v.T("when.weekdays", "time", hm) + case "weekly": + days := make([]string, len(p.Weekdays)) + for i, d := range p.Weekdays { + days[i] = v.T("day." + strconv.Itoa(d)) + } + return v.T("when.weekly", "days", v.Join(days), "time", hm) + case "monthly": + return v.T("when.monthly", "day", p.Day, "time", hm) + case "once": + return v.T("when.once", "date", fmt.Sprintf("%04d-%02d-%02d", p.Year, p.Month, p.DayOfMonth), "time", hm) + } + return v.T("when.cron", "text", p.Text) +} + // connectorRoute is the place a trigger proposed in the connector chat c comes // from: the binding, and the chat and thread where the person wrote. A chat // with no known place cannot propose, since the proposal could not be shown. diff --git a/docs/content/features/chats-connectors.md b/docs/content/features/chats-connectors.md index 3549140d40..561f888002 100644 --- a/docs/content/features/chats-connectors.md +++ b/docs/content/features/chats-connectors.md @@ -504,8 +504,9 @@ reads nothing. ## How the bot answers -Each binding has three settings, under **How it answers** on the add-on page and in -the API as `reply_mode`, `conversation` (with `window_minutes`) and `send_files`. +Each binding has four settings, under **How it answers** on the add-on page and in +the API as `reply_mode`, `conversation` (with `window_minutes`), `send_files` and +`reply_language`. ### While the agent works (`reply_mode`) @@ -659,6 +660,43 @@ the sender attached. An update without `send_files` keeps the stored value. +### Reply language (`reply_language`) + +The bot writes some sentences itself, without the model: the confirmation of a new chat, +the questions and answers about proposals and changes, the refusals (a file that is too +large, a message that is too long, too many messages, a sender who may not do this), the +notes about files, the pairing replies, and the "Working…" message of the progress mode. +These come in the language of the person. The answers of the model are not affected: the +model answers in the language of the conversation, as it does without this setting. + +| Value | Language | +|---|---| +| `auto` (default) | The language of the sender, see below. | +| `en` | English | +| `it` | Italian | +| `de` | German | +| `es` | Spanish | +| `fr` | French | +| `pt-BR` | Portuguese (Brazil) | + +With `auto`, 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 +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. + +The words that answer a proposal are read in all six languages whatever the setting: +`yes`, `sì`, `ja`, `sí`, `oui`, `sim` confirm; `no`, `annulla`, `nein`, `cancelar`, +`annuler`, `não` drop; and each language has a phrase for undo (`undo`, `annulla +modifica`, `rückgängig`, `deshacer`, `annuler la modification`, `desfazer`). A reply is +always read by the host before any model turn, so it never reaches the model. + +The value is stored with the binding. An update without `reply_language` keeps the stored +value; an unknown value is a 400. Instructions to the model (tool descriptions, the note +about held back tools, the thread block) stay in English. + ## Who may use the bot The allowlist decides. It is empty when you make a binding, so the bot accepts @@ -897,6 +935,9 @@ app, LocalAI writes the question in that chat. The model does not write it, and not send the person to Studio. Messaging chats always ask first; this only gives the answer a place. +- **The language.** The question and the replies come in the reply language of the + binding ([Reply language](#reply-language-reply_language)); the examples here are in + English. - **The question.** For example: `I can save a note in the memory (every chat of the owner): Standup time`, then the lines that will be added (at most 8 lines or 500 characters), then `Reply "yes" to save it or "no" to drop it, here, within 24 hours. If @@ -906,8 +947,10 @@ answer a place. for triggers: `yes`, `ok`, `confirm` to save, `no`, `cancel` to drop. LocalAI reads it before any model turn and the model never sees it. The reply says what happened, for example `Saved: Standup time. Reply "undo" within 30 minutes to take it back.` -- **Undo.** The word `undo`, alone, within 30 minutes of a save that was made from the - chat, restores the earlier text (`Removed: ...`). Any other word does not undo. +- **Undo.** The word `undo` (or the phrase of the language, see + [Reply language](#reply-language-reply_language)), alone, within 30 minutes of a save + that was made from the chat, restores the earlier text (`Removed: ...`). Any other + word does not undo. - **Several waiting.** A bare word answers only when exactly one proposal (a change or a trigger) waits in that chat and thread. Otherwise the bot lists the codes and the person writes `confirm CODE` or `cancel CODE`. @@ -1124,6 +1167,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. | | 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 a8f2982dd4..3c7760c335 100644 --- a/swagger/docs.go +++ b/swagger/docs.go @@ -5732,7 +5732,7 @@ const docTemplate = `{ } }, "post": { - "description": "Needs the kind (the platform), the bot token (kept sealed, never returned) and an agent of yours that names a model. The token is checked with the platform when its add-on runs: a token that the platform refuses is a 400. reply_mode (off, progress, stream; default progress), conversation (sender, chat, window with window_minutes, thread; default sender) and send_files (default true) say how the bot answers. The allowlist is empty at first, so nobody is accepted: add chat IDs, user IDs or @usernames, or set accept_any (anyone who can message the bot then uses the agent's tools; the request that turns it on must also carry confirm_accept_any set to \"accept any message\", or it is a 400). At most 10 bindings.", + "description": "Needs the kind (the platform), the bot token (kept sealed, never returned) and an agent of yours that names a model. The token is checked with the platform when its add-on runs: a token that the platform refuses is a 400. reply_mode (off, progress, stream; default progress), conversation (sender, chat, window with window_minutes, thread; default sender), send_files (default true) and reply_language (auto, en, it, de, es, fr, pt-BR; default auto, the language of the sentences the bot writes itself) say how the bot answers. The allowlist is empty at first, so nobody is accepted: add chat IDs, user IDs or @usernames, or set accept_any (anyone who can message the bot then uses the agent's tools; the request that turns it on must also carry confirm_accept_any set to \"accept any message\", or it is a 400). At most 10 bindings.", "tags": [ "chats" ], @@ -5814,7 +5814,7 @@ const docTemplate = `{ } }, "put": { - "description": "The body replaces every field except the token, which is kept unless the request has a new one, and send_files, which is kept when it is absent. A new token is checked with the platform: a token that the platform refuses is a 400 and nothing is saved.", + "description": "The body replaces every field except the token, which is kept unless the request has a new one, and send_files and reply_language, which are kept when absent. A new token is checked with the platform: a token that the platform refuses is a 400 and nothing is saved.", "tags": [ "chats" ], @@ -20610,6 +20610,10 @@ const docTemplate = `{ "description": "Reason says in plain words why the binding is not active.", "type": "string" }, + "reply_language": { + "description": "ReplyLanguage is the language of the sentences the bot writes itself in\nthe chat (confirmations, refusals, notes about files): auto (the\nlanguage of the sender's message), en, it, de, es, fr or pt-BR. The\nanswers of the model follow the conversation, not this setting.", + "type": "string" + }, "reply_mode": { "description": "ReplyMode is what a sender sees while a turn runs: off (typing, then\nthe answer), progress (a working message that names the tools as they\nstart) or stream (a working message that shows the answer as it is\nwritten). The answer replaces the working message.", "type": "string" @@ -21714,6 +21718,10 @@ const docTemplate = `{ "description": "Reason says in plain words why the binding is not active.", "type": "string" }, + "reply_language": { + "description": "ReplyLanguage is the language of the sentences the bot writes itself in\nthe chat (confirmations, refusals, notes about files): auto (the\nlanguage of the sender's message), en, it, de, es, fr or pt-BR. The\nanswers of the model follow the conversation, not this setting.", + "type": "string" + }, "reply_mode": { "description": "ReplyMode is what a sender sees while a turn runs: off (typing, then\nthe answer), progress (a working message that names the tools as they\nstart) or stream (a working message that shows the answer as it is\nwritten). The answer replaces the working message.", "type": "string" @@ -22362,6 +22370,19 @@ const docTemplate = `{ "type": "string" } }, + "reply_language": { + "description": "ReplyLanguage is auto, en, it, de, es, fr or pt-BR. Absent means auto\nfor a new binding and unchanged for an update.", + "type": "string", + "enum": [ + "auto", + "en", + "it", + "de", + "es", + "fr", + "pt-BR" + ] + }, "reply_mode": { "description": "ReplyMode is off, progress or stream; empty means progress.", "type": "string" diff --git a/swagger/swagger.json b/swagger/swagger.json index 7247cb19d0..2552367309 100644 --- a/swagger/swagger.json +++ b/swagger/swagger.json @@ -5729,7 +5729,7 @@ } }, "post": { - "description": "Needs the kind (the platform), the bot token (kept sealed, never returned) and an agent of yours that names a model. The token is checked with the platform when its add-on runs: a token that the platform refuses is a 400. reply_mode (off, progress, stream; default progress), conversation (sender, chat, window with window_minutes, thread; default sender) and send_files (default true) say how the bot answers. The allowlist is empty at first, so nobody is accepted: add chat IDs, user IDs or @usernames, or set accept_any (anyone who can message the bot then uses the agent's tools; the request that turns it on must also carry confirm_accept_any set to \"accept any message\", or it is a 400). At most 10 bindings.", + "description": "Needs the kind (the platform), the bot token (kept sealed, never returned) and an agent of yours that names a model. The token is checked with the platform when its add-on runs: a token that the platform refuses is a 400. reply_mode (off, progress, stream; default progress), conversation (sender, chat, window with window_minutes, thread; default sender), send_files (default true) and reply_language (auto, en, it, de, es, fr, pt-BR; default auto, the language of the sentences the bot writes itself) say how the bot answers. The allowlist is empty at first, so nobody is accepted: add chat IDs, user IDs or @usernames, or set accept_any (anyone who can message the bot then uses the agent's tools; the request that turns it on must also carry confirm_accept_any set to \"accept any message\", or it is a 400). At most 10 bindings.", "tags": [ "chats" ], @@ -5811,7 +5811,7 @@ } }, "put": { - "description": "The body replaces every field except the token, which is kept unless the request has a new one, and send_files, which is kept when it is absent. A new token is checked with the platform: a token that the platform refuses is a 400 and nothing is saved.", + "description": "The body replaces every field except the token, which is kept unless the request has a new one, and send_files and reply_language, which are kept when absent. A new token is checked with the platform: a token that the platform refuses is a 400 and nothing is saved.", "tags": [ "chats" ], @@ -20607,6 +20607,10 @@ "description": "Reason says in plain words why the binding is not active.", "type": "string" }, + "reply_language": { + "description": "ReplyLanguage is the language of the sentences the bot writes itself in\nthe chat (confirmations, refusals, notes about files): auto (the\nlanguage of the sender's message), en, it, de, es, fr or pt-BR. The\nanswers of the model follow the conversation, not this setting.", + "type": "string" + }, "reply_mode": { "description": "ReplyMode is what a sender sees while a turn runs: off (typing, then\nthe answer), progress (a working message that names the tools as they\nstart) or stream (a working message that shows the answer as it is\nwritten). The answer replaces the working message.", "type": "string" @@ -21711,6 +21715,10 @@ "description": "Reason says in plain words why the binding is not active.", "type": "string" }, + "reply_language": { + "description": "ReplyLanguage is the language of the sentences the bot writes itself in\nthe chat (confirmations, refusals, notes about files): auto (the\nlanguage of the sender's message), en, it, de, es, fr or pt-BR. The\nanswers of the model follow the conversation, not this setting.", + "type": "string" + }, "reply_mode": { "description": "ReplyMode is what a sender sees while a turn runs: off (typing, then\nthe answer), progress (a working message that names the tools as they\nstart) or stream (a working message that shows the answer as it is\nwritten). The answer replaces the working message.", "type": "string" @@ -22359,6 +22367,19 @@ "type": "string" } }, + "reply_language": { + "description": "ReplyLanguage is auto, en, it, de, es, fr or pt-BR. Absent means auto\nfor a new binding and unchanged for an update.", + "type": "string", + "enum": [ + "auto", + "en", + "it", + "de", + "es", + "fr", + "pt-BR" + ] + }, "reply_mode": { "description": "ReplyMode is off, progress or stream; empty means progress.", "type": "string" diff --git a/swagger/swagger.yaml b/swagger/swagger.yaml index 1d9829d10f..4413cf9b12 100644 --- a/swagger/swagger.yaml +++ b/swagger/swagger.yaml @@ -3761,6 +3761,13 @@ definitions: reason: description: Reason says in plain words why the binding is not active. type: string + reply_language: + description: |- + ReplyLanguage is the language of the sentences the bot writes itself in + the chat (confirmations, refusals, notes about files): auto (the + 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. + type: string reply_mode: description: |- ReplyMode is what a sender sees while a turn runs: off (typing, then @@ -4690,6 +4697,13 @@ definitions: reason: description: Reason says in plain words why the binding is not active. type: string + reply_language: + description: |- + ReplyLanguage is the language of the sentences the bot writes itself in + the chat (confirmations, refusals, notes about files): auto (the + 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. + type: string reply_mode: description: |- ReplyMode is what a sender sees while a turn runs: off (typing, then @@ -5213,6 +5227,19 @@ definitions: Options are the choices for the options of the kind, by option id. Absent means the defaults for a new binding and unchanged for an update. type: object + reply_language: + description: |- + ReplyLanguage is auto, en, it, de, es, fr or pt-BR. Absent means auto + for a new binding and unchanged for an update. + enum: + - auto + - en + - it + - de + - es + - fr + - pt-BR + type: string reply_mode: description: ReplyMode is off, progress or stream; empty means progress. type: string @@ -17050,12 +17077,13 @@ paths: returned) and an agent of yours that names a model. The token is checked with the platform when its add-on runs: a token that the platform refuses is a 400. reply_mode (off, progress, stream; default progress), conversation (sender, - chat, window with window_minutes, thread; default sender) and send_files (default - true) say how the bot answers. The allowlist is empty at first, so nobody - is accepted: add chat IDs, user IDs or @usernames, or set accept_any (anyone - who can message the bot then uses the agent''s tools; the request that turns - it on must also carry confirm_accept_any set to "accept any message", or it - is a 400). At most 10 bindings.' + chat, window with window_minutes, thread; default sender), send_files (default + true) and reply_language (auto, en, it, de, es, fr, pt-BR; default auto, the + language of the sentences the bot writes itself) say how the bot answers. + The allowlist is empty at first, so nobody is accepted: add chat IDs, user + IDs or @usernames, or set accept_any (anyone who can message the bot then + uses the agent''s tools; the request that turns it on must also carry confirm_accept_any + set to "accept any message", or it is a 400). At most 10 bindings.' parameters: - description: the binding in: body @@ -17108,9 +17136,9 @@ paths: - chats put: description: 'The body replaces every field except the token, which is kept - unless the request has a new one, and send_files, which is kept when it is - absent. A new token is checked with the platform: a token that the platform - refuses is a 400 and nothing is saved.' + unless the request has a new one, and send_files and reply_language, which + are kept when absent. A new token is checked with the platform: a token that + the platform refuses is a 400 and nothing is saved.' parameters: - description: the binding's id in: path