Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
b13cbb5
feat(compose): reply with AI from the conversation and an instruction
prudenciooficial Oct 8, 2026
bff4bc0
feat(message): offer Reply with AI from the message itself
prudenciooficial Oct 8, 2026
4a156f1
Merge branch 'main' of https://github.com/maathimself/mailflow into f…
prudenciooficial Oct 9, 2026
913ba8f
Merge branch 'main' of https://github.com/maathimself/mailflow into f…
prudenciooficial Oct 9, 2026
843711e
fix(compose): Reply with AI signs off when the signature is turned off
prudenciooficial Oct 9, 2026
3f1f163
Merge branch 'main' of https://github.com/maathimself/mailflow into f…
prudenciooficial Oct 9, 2026
d130c8b
fix(compose): leave drafts out of the conversation Reply with AI reads
prudenciooficial Oct 9, 2026
9b4531f
Merge branch 'main' of https://github.com/maathimself/mailflow into f…
prudenciooficial Oct 13, 2026
be898fd
Merge branch 'main' of https://github.com/maathimself/mailflow into f…
prudenciooficial Oct 13, 2026
d62ca18
Merge branch 'main' of https://github.com/maathimself/mailflow into f…
prudenciooficial Oct 14, 2026
d0f34b3
Merge remote-tracking branch 'upstream/main' into feat/ai-reply
prudenciooficial Oct 14, 2026
16d429a
feat(compose): Write draft takes an instruction; Apply keeps blank lines
prudenciooficial Oct 14, 2026
2836a9e
Merge branch 'main' of https://github.com/maathimself/mailflow into f…
prudenciooficial Oct 16, 2026
dc4af9c
Merge branch 'main' of https://github.com/maathimself/mailflow into f…
prudenciooficial Oct 16, 2026
4be5c51
Merge branch 'main' of https://github.com/maathimself/mailflow into f…
prudenciooficial Oct 19, 2026
2b8b911
fix(compose): keep Reply with AI's email parsing linear on hostile input
prudenciooficial Oct 19, 2026
29a16d0
Merge remote-tracking branch 'upstream/main' into feat/ai-reply
prudenciooficial Oct 19, 2026
c0540a5
Merge branch 'main' of https://github.com/maathimself/mailflow into f…
prudenciooficial Oct 20, 2026
18ed503
fix(compose): ask for Reply with AI and Write draft under the right A…
prudenciooficial Oct 20, 2026
03c4400
Merge remote-tracking branch 'upstream/main' into feat/ai-reply
prudenciooficial Oct 20, 2026
2aef458
fix(compose): send Reply with AI the whole conversation up to the req…
prudenciooficial Oct 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion frontend/src/aiActions.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ export const AI_MESSAGE_MAX_CHARS = 32_000;

// Where to cut text that must end at or before `end`: the last paragraph break, line or
// sentence end, or space in the final tenth, else exactly at `end`.
function cutPoint(text, end) {
export function cutPoint(text, end) {
const floor = Math.floor(end * 0.9);
const para = text.lastIndexOf('\n\n', end - 2);
if (para >= floor) return para;
Expand Down
259 changes: 184 additions & 75 deletions frontend/src/components/ComposeModal.jsx

Large diffs are not rendered by default.

296 changes: 295 additions & 1 deletion frontend/src/components/ComposeModal.render.test.js

Large diffs are not rendered by default.

7 changes: 6 additions & 1 deletion frontend/src/components/ConversationMessageCard.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ function CardBtn({ onClick, children, disabled = false, title }) {
// Design from #317 by YunQue0912.
// `onUpdate(id, patch)` applies a flag change to the conversation's own copy of the message and to
// the list's, since the pane holds the thread apart from the list.
export default function ConversationMessageCard({ message, expanded, onToggle, onUpdate, aiEnabled = false, selected = false }) {
export default function ConversationMessageCard({ message, expanded, onToggle, onUpdate, aiEnabled = false, aiReplyEnabled = false, selected = false }) {
const { t } = useTranslation();
const accounts = useStore(s => s.accounts);
const openCompose = useStore(s => s.openCompose);
Expand Down Expand Up @@ -346,6 +346,11 @@ export default function ConversationMessageCard({ message, expanded, onToggle, o
<CardBtn onClick={() => openReplyFromMessage(message, { accounts, openCompose, getMessageBody: loadedBody, replyAll: true })}>
{t('message.replyAll')}
</CardBtn>
{aiReplyEnabled && (
<CardBtn onClick={() => openReplyFromMessage(message, { accounts, openCompose, getMessageBody: loadedBody, replyAll: false, aiReply: true })}>
{t('compose.toolbar.aiReply')}
</CardBtn>
)}
<CardBtn onClick={() => openForwardFromMessage(message, { openCompose, getMessageBody: loadedBody })}>
{t('message.forward')}
</CardBtn>
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/components/ConversationPane.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { useSwipeBack } from '../hooks/useSwipeBack.js';
import SwipePager from './SwipePager.jsx';
import { buildPrintDocument, openPrintWindow, printInWindow } from '../utils/printMessage.js';
import ConversationMessageCard from './ConversationMessageCard.jsx';
import { aiReplyAvailable } from '../utils/aiReply.js';
import ContextMenu from './ContextMenu.jsx';
import { prefetchBody } from '../utils/bodyCache.js';

Expand Down Expand Up @@ -72,6 +73,7 @@ export default function ConversationPane({ threadId, folder, unified = false, ac
const accounts = useStore(s => s.accounts);
const setSelectedMessage = useStore(s => s.setSelectedMessage);
const updateMessage = useStore(s => s.updateMessage);
const plaintextEmail = useStore(s => s.plaintextEmail);
const [messages, setMessages] = useState([]);
const [expanded, setExpanded] = useState(() => new Set());
const [error, setError] = useState(null);
Expand All @@ -85,6 +87,7 @@ export default function ConversationPane({ threadId, folder, unified = false, ac
api.ai.status().then(setAiStatus).catch(() => {});
}, []);
const aiEnabled = Boolean(aiStatus?.enabled && aiStatus?.features?.summarize);
const aiReplyEnabled = aiReplyAvailable(aiStatus, plaintextEmail);

// Read when the conversation arrives, without making the pick a reason to fetch it again.
const pickedRef = useRef(selectedMessageId);
Expand Down Expand Up @@ -374,6 +377,7 @@ export default function ConversationPane({ threadId, folder, unified = false, ac
onToggle={toggle}
onUpdate={updateConversationMessage}
aiEnabled={aiEnabled}
aiReplyEnabled={aiReplyEnabled}
/>
))}
</div>
Expand Down
19 changes: 18 additions & 1 deletion frontend/src/components/ConversationPane.render.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -354,7 +354,7 @@ describe('conversation actions', () => {
? { ok: true, status: 200, json: async () => ({ ok: true }) }
: { ok: false, status: 500, json: async () => ({ error: 'IMAP store failed' }) };
}
if (String(url).includes('/ai/status')) return { ok: true, status: 200, json: async () => ({ enabled: true, features: { summarize: true } }) };
if (String(url).includes('/ai/status')) return { ok: true, status: 200, json: async () => ({ enabled: true, features: { summarize: true, compose: true } }) };
if (String(url).includes('/ai/chat')) {
const sse = 'data: {"choices":[{"delta":{"content":"Short "}}]}\n\ndata: {"choices":[{"delta":{"content":"summary."}}]}\n\ndata: [DONE]\n\n';
return new Response(sse, { status: 200, headers: { 'Content-Type': 'text/event-stream' } });
Expand Down Expand Up @@ -443,6 +443,23 @@ describe('conversation actions', () => {
assert.doesNotMatch(card('m3').textContent, /Short summary/, 'and only on this message');
});

test('Reply with AI opens the reply to this message with the instruction panel', async () => {
const aiReply = button('m1', 'compose.toolbar.aiReply');
assert.ok(aiReply, 'an open message offers it when the AI can write replies');
await click(aiReply);
const data = useStore.getState().composeData;
assert.equal(data?.aiReply, true);
assert.equal(data.inReplyTo, '<1@x>');
await React.act(async () => { useStore.getState().closeCompose(); });
});

test('Reply with AI is not offered to a user who writes in plain text', async () => {
await React.act(async () => { useStore.setState({ plaintextEmail: true }); });
assert.equal(button('m1', 'compose.toolbar.aiReply'), undefined);
await React.act(async () => { useStore.setState({ plaintextEmail: false }); });
assert.ok(button('m1', 'compose.toolbar.aiReply'));
});

test('the print shortcut prints the selected message', async () => {
printWin = null;
await React.act(async () => { shortcutBus.emit('printMessage'); });
Expand Down
16 changes: 13 additions & 3 deletions frontend/src/components/MessagePane.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { isDarkTheme, canShowDark, showDark } from '../utils/darkScheme.js';
import { markMessageUnread } from '../utils/messageHotkeys.js';
import { BUILTIN_SUMMARIZE } from '../aiActions.js';
import { openReplyFromMessage, openForwardFromMessage, openForwardAsAttachmentFromMessage } from '../utils/composeFromMessage.js';
import { aiReplyAvailable } from '../utils/aiReply.js';
import MessageBodyView from './MessageBodyView.jsx';
import { copyToClipboard } from '../utils/clipboard.js';
import { saveSenderCategory } from '../utils/senderCategory.js';
Expand Down Expand Up @@ -75,7 +76,7 @@ export default function MessagePane({ windowMessageId = null, onWindowClose = nu
imageWhitelist, addToImageWhitelist, blockRemoteImages, threadMessages,
replyDefault, shortcuts, recentFolders, favoriteFolders, todoistConnected,
categorizationEnabled, setCategoryCounts, adjustCategoryCount,
aiActions, setShowAdmin, setAdminTab,
aiActions, setShowAdmin, setAdminTab, plaintextEmail,
} = useStore();

// Detached-window mode (#219): when a message id is passed in, this pane renders that
Expand Down Expand Up @@ -602,10 +603,10 @@ export default function MessagePane({ windowMessageId = null, onWindowClose = nu
// The body is already loaded here, so it is handed over rather than refetched.
const loadedBody = async () => body;

const handleReply = (replyAll = false) => {
const handleReply = (replyAll = false, { aiReply = false } = {}) => {
if (!message) return;
setShowReplyMenu(false);
openReplyFromMessage(message, { accounts, openCompose, getMessageBody: loadedBody, replyAll });
openReplyFromMessage(message, { accounts, openCompose, getMessageBody: loadedBody, replyAll, aiReply });
};

const handleForward = () => {
Expand Down Expand Up @@ -1461,6 +1462,15 @@ export default function MessagePane({ windowMessageId = null, onWindowClose = nu
</>)}
</div>

{aiReplyAvailable(aiStatus, plaintextEmail) && (
<PaneBtn onClick={() => handleReply(defaultReplyAll, { aiReply: true })} title={t('compose.toolbar.aiReply')}>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
<path d="m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z"/>
</svg>
{!isMobile && <span>{t('compose.toolbar.aiReply')}</span>}
</PaneBtn>
)}

<PaneBtn onClick={handleForward} title={isMobile ? t('message.forward') : `${t('message.forward')}${shortcutLabel('forward') ? ` (${shortcutLabel('forward')})` : ''}`}>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75">
<polyline points="15 17 20 12 15 7"/><path d="M4 18v-2a4 4 0 014-4h12"/>
Expand Down
42 changes: 42 additions & 0 deletions frontend/src/components/MessagePane.render.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -806,6 +806,48 @@ describe('move picker favorites', () => {
});
});

describe('Reply with AI in the toolbar', () => {
const button = () => document.querySelector('[title="compose.toolbar.aiReply"]');
let realStatus;
async function show({ compose, summarize = true }) {
api.ai.status = async () => ({ enabled: true, features: { compose, summarize } });
// A fresh mount: the pane asks for the AI status when it mounts.
await React.act(async () => { root.render(null); });
await React.act(async () => {
useStore.getState().setSelectedMessage('a1');
root.render(React.createElement(MessagePane));
});
await React.act(async () => { await new Promise(r => setTimeout(r, 0)); });
}
before(() => { realStatus = api.ai.status; });
after(() => {
api.ai.status = realStatus;
useStore.setState({ plaintextEmail: false });
useStore.getState().closeCompose();
});

test('opens the reply with the AI panel waiting for the instruction', async () => {
await show({ compose: true });
assert.ok(button(), 'offered when the AI can write replies');
await React.act(async () => { button().dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true })); });
await React.act(async () => { await new Promise(r => setTimeout(r, 0)); });
const data = useStore.getState().composeData;
assert.equal(data?.isReply, true);
assert.equal(data.aiReply, true);
assert.equal(data.subject, 'Re: First');
});

test('is not offered when the AI does not write emails, or the user writes in plain text', async () => {
await show({ compose: false });
assert.equal(button(), null);
await show({ compose: true, summarize: false });
assert.equal(button(), null, 'nor without summaries: the reply is written from received mail');
useStore.setState({ plaintextEmail: true });
await show({ compose: true });
assert.equal(button(), null);
});
});

// #572: the reading pane's own Delete and Archive open what takes the message's place, by the
// afterRemove setting, instead of leaving "Select a message to read".
describe('the next message opens after the pane removes the open one', () => {
Expand Down
8 changes: 8 additions & 0 deletions frontend/src/locales/cs.json
Original file line number Diff line number Diff line change
Expand Up @@ -561,6 +561,14 @@
"aiDismiss": "Zavřít",
"aiGenerating": "Generování…",
"aiError": "Chyba AI: {{message}}",
"aiReply": "Odpovědět pomocí AI",
"aiReplyPlaceholder": "Co má odpověď říct? Např. schval grafiku",
"aiReplyInstruction": "Pokyn pro odpověď AI",
"aiReplyGenerate": "Vygenerovat",
"aiReplyReading": "Čtení konverzace…",
"aiReplyPartial": "Podle nejnovější části konverzace: zbytek byl příliš dlouhý na odeslání AI.",
"aiWritePlaceholder": "Co má e-mail říct? Např. požádej o cenovou nabídku",
"aiWriteInstruction": "Pokyn pro koncept AI",
"aiTooLong": "tento koncept má {{count}} znaků, víc než {{max}}, které AI zpracuje. Zkraťte ho nebo odstraňte citovaný text a zkuste to znovu.",
"aiNotAllowed": "správce vypnul AI pro váš účet.",
"aiFeatureOff": "správce vypnul asistenta psaní AI."
Expand Down
8 changes: 8 additions & 0 deletions frontend/src/locales/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,14 @@
"aiDismiss": "Verwerfen",
"aiGenerating": "Wird erstellt…",
"aiError": "KI-Fehler: {{message}}",
"aiReply": "Mit KI antworten",
"aiReplyPlaceholder": "Was soll die Antwort sagen? Z. B. Entwurf freigeben",
"aiReplyInstruction": "Anweisung für die KI-Antwort",
"aiReplyGenerate": "Erstellen",
"aiReplyReading": "Unterhaltung wird gelesen…",
"aiReplyPartial": "Basiert auf dem neuesten Teil der Unterhaltung: Der Rest war zu lang, um ihn an die KI zu senden.",
"aiWritePlaceholder": "Was soll die E-Mail sagen? Z. B. um ein Angebot bitten",
"aiWriteInstruction": "Anweisung für den KI-Entwurf",
"aiTooLong": "Dieser Entwurf hat {{count}} Zeichen, mehr als die {{max}}, die die KI verarbeiten kann. Kürzen Sie ihn oder entfernen Sie den zitierten Text und versuchen Sie es erneut.",
"aiNotAllowed": "Ihr Administrator hat KI für Ihr Konto ausgeschaltet.",
"aiFeatureOff": "Ihr Administrator hat den KI-Schreibassistenten ausgeschaltet."
Expand Down
8 changes: 8 additions & 0 deletions frontend/src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,14 @@
"aiDismiss": "Dismiss",
"aiGenerating": "Generating…",
"aiError": "AI error: {{message}}",
"aiReply": "Reply with AI",
"aiReplyPlaceholder": "What should the reply say? E.g. approve the artwork",
"aiReplyInstruction": "Instruction for the AI reply",
"aiReplyGenerate": "Generate",
"aiReplyReading": "Reading the conversation…",
"aiReplyPartial": "Based on the latest part of the conversation: the rest was too long to send to the AI.",
"aiWritePlaceholder": "What should the email say? E.g. ask for a quote",
"aiWriteInstruction": "Instruction for the AI draft",
"aiTooLong": "this draft has {{count}} characters, more than the {{max}} the AI can take. Shorten it or remove the quoted text, then try again.",
"aiNotAllowed": "your administrator has turned AI off for your account.",
"aiFeatureOff": "your administrator has turned the AI writing assistant off."
Expand Down
8 changes: 8 additions & 0 deletions frontend/src/locales/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,14 @@
"aiDismiss": "Descartar",
"aiGenerating": "Generando…",
"aiError": "Error de IA: {{message}}",
"aiReply": "Responder con IA",
"aiReplyPlaceholder": "¿Qué debe decir la respuesta? P. ej.: aprobar el diseño",
"aiReplyInstruction": "Instrucción para la respuesta de la IA",
"aiReplyGenerate": "Generar",
"aiReplyReading": "Leyendo la conversación…",
"aiReplyPartial": "Basada en la parte más reciente de la conversación: el resto era demasiado largo para enviarlo a la IA.",
"aiWritePlaceholder": "¿Qué debe decir el correo? P. ej.: pedir un presupuesto",
"aiWriteInstruction": "Instrucción para el borrador de la IA",
"aiTooLong": "este borrador tiene {{count}} caracteres, más de los {{max}} que admite la IA. Acórtalo o elimina el texto citado y vuelve a intentarlo.",
"aiNotAllowed": "tu administrador ha desactivado la IA en tu cuenta.",
"aiFeatureOff": "tu administrador ha desactivado el asistente de redacción con IA."
Expand Down
8 changes: 8 additions & 0 deletions frontend/src/locales/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,14 @@
"aiDismiss": "Ignorer",
"aiGenerating": "Génération en cours…",
"aiError": "Erreur IA : {{message}}",
"aiReply": "Répondre avec l'IA",
"aiReplyPlaceholder": "Que doit dire la réponse ? Ex. : approuver la maquette",
"aiReplyInstruction": "Instruction pour la réponse de l'IA",
"aiReplyGenerate": "Générer",
"aiReplyReading": "Lecture de la conversation…",
"aiReplyPartial": "Basée sur la partie la plus récente de la conversation : le reste était trop long pour être envoyé à l'IA.",
"aiWritePlaceholder": "Que doit dire l'e-mail ? Ex. : demander un devis",
"aiWriteInstruction": "Instruction pour le brouillon de l'IA",
"aiTooLong": "ce brouillon contient {{count}} caractères, plus que les {{max}} que l'IA peut traiter. Raccourcissez-le ou supprimez le texte cité, puis réessayez.",
"aiNotAllowed": "votre administrateur a désactivé l'IA pour votre compte.",
"aiFeatureOff": "votre administrateur a désactivé l'aide à la rédaction par IA."
Expand Down
8 changes: 8 additions & 0 deletions frontend/src/locales/it.json
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,14 @@
"aiDismiss": "Ignora",
"aiGenerating": "Generazione in corso…",
"aiError": "Errore IA: {{message}}",
"aiReply": "Rispondi con l'IA",
"aiReplyPlaceholder": "Cosa deve dire la risposta? Es.: approva la grafica",
"aiReplyInstruction": "Istruzione per la risposta dell'IA",
"aiReplyGenerate": "Genera",
"aiReplyReading": "Lettura della conversazione…",
"aiReplyPartial": "Basata sulla parte più recente della conversazione: il resto era troppo lungo da inviare all'IA.",
"aiWritePlaceholder": "Cosa deve dire l'email? Es.: chiedere un preventivo",
"aiWriteInstruction": "Istruzione per la bozza dell'IA",
"aiTooLong": "questa bozza ha {{count}} caratteri, più dei {{max}} che l'IA può gestire. Accorciala o rimuovi il testo citato, poi riprova.",
"aiNotAllowed": "il tuo amministratore ha disattivato l'IA per il tuo account.",
"aiFeatureOff": "il tuo amministratore ha disattivato l'assistente di redazione IA."
Expand Down
8 changes: 8 additions & 0 deletions frontend/src/locales/ko.json
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,14 @@
"aiWriteDraft": "초안 작성",
"aiAssist": "AI 어시스트",
"aiError": "AI 오류: {{message}}",
"aiReply": "AI로 답장",
"aiReplyPlaceholder": "답장에 무엇을 쓸까요? 예: 디자인 승인",
"aiReplyInstruction": "AI 답장 지시",
"aiReplyGenerate": "생성",
"aiReplyReading": "대화를 읽는 중…",
"aiReplyPartial": "대화의 최근 부분을 바탕으로 했습니다. 나머지는 AI에 보내기에 너무 깁니다.",
"aiWritePlaceholder": "이메일에 무엇을 쓸까요? 예: 견적 요청",
"aiWriteInstruction": "AI 초안 지시",
"aiTooLong": "이 초안은 {{count}}자로, AI가 받을 수 있는 {{max}}자를 넘습니다. 내용을 줄이거나 인용된 텍스트를 지운 뒤 다시 시도하세요.",
"aiNotAllowed": "관리자가 이 계정에서 AI를 사용할 수 없도록 설정했습니다.",
"aiFeatureOff": "관리자가 AI 작성 도우미를 껐습니다.",
Expand Down
8 changes: 8 additions & 0 deletions frontend/src/locales/pl.json
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,14 @@
"aiDismiss": "Odrzuć",
"aiGenerating": "Generowanie…",
"aiError": "Błąd AI: {{message}}",
"aiReply": "Odpowiedz z AI",
"aiReplyPlaceholder": "Co ma zawierać odpowiedź? Np. zatwierdź projekt",
"aiReplyInstruction": "Polecenie dla odpowiedzi AI",
"aiReplyGenerate": "Generuj",
"aiReplyReading": "Czytanie konwersacji…",
"aiReplyPartial": "Na podstawie najnowszej części rozmowy: reszta była zbyt długa, by wysłać ją do AI.",
"aiWritePlaceholder": "Co ma zawierać e-mail? Np. poproś o wycenę",
"aiWriteInstruction": "Polecenie dla szkicu AI",
"aiTooLong": "ta wersja robocza ma {{count}} znaków, więcej niż {{max}}, które przyjmuje AI. Skróć ją lub usuń cytowany tekst i spróbuj ponownie.",
"aiNotAllowed": "administrator wyłączył AI na Twoim koncie.",
"aiFeatureOff": "administrator wyłączył asystenta tworzenia wiadomości AI."
Expand Down
8 changes: 8 additions & 0 deletions frontend/src/locales/ptBR.json
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,14 @@
"aiDismiss": "Dispensar",
"aiGenerating": "Gerando…",
"aiError": "Erro da IA: {{message}}",
"aiReply": "Responder com IA",
"aiReplyPlaceholder": "O que responder? Ex.: aprove a arte",
"aiReplyInstruction": "Instrução para a resposta da IA",
"aiReplyGenerate": "Gerar",
"aiReplyReading": "Lendo a conversa…",
"aiReplyPartial": "Baseada na parte mais recente da conversa: o restante era longo demais para enviar à IA.",
"aiWritePlaceholder": "O que escrever? Ex.: peça um orçamento",
"aiWriteInstruction": "Instrução para o rascunho da IA",
"aiTooLong": "este rascunho tem {{count}} caracteres, mais do que os {{max}} que a IA aceita. Encurte-o ou remova o texto citado e tente novamente.",
"aiNotAllowed": "o administrador desativou a IA na sua conta.",
"aiFeatureOff": "o administrador desativou o assistente de composição com IA."
Expand Down
Loading
Loading