From 5d21c34caf10d532d7bde19b6288e4a6888a2655 Mon Sep 17 00:00:00 2001 From: Arnau Guadall Date: Mon, 25 May 2026 23:56:31 +0200 Subject: [PATCH] feat: add English/Spanish multilanguage support (issue #3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Install i18next + react-i18next; add en.json and es.json locale files covering every screen (chat, hatching wizard, heartbeats, skills, memory, context panels, settings, runtime) - Detect OS locale at startup via navigator.language with fallback to English; honour saved profile preference over auto-detection - Add Language selector (Auto / English / Español) to Settings → Desktop tab; language switches instantly without restart - Backend: add language field to HatchingProfile; convert AZULCLAW_SYSTEM_PROMPT constant to build_system_prompt(language) so AI replies match the selected language - Document language support and instructions for adding new locales in README --- README.md | 15 + azul_backend/azul_brain/api/hatching_store.py | 1 + .../azul_brain/cortex/kernel_setup.py | 27 +- azul_backend/azul_brain/soul/system_prompt.py | 19 +- azul_desktop/package.json | 2 + azul_desktop/src/app/DesktopApp.tsx | 83 +- azul_desktop/src/components/Sidebar.tsx | 27 +- azul_desktop/src/features/chat/ChatShell.tsx | 88 +- .../features/context/ContextMemoryPanel.tsx | 42 +- .../features/context/ContextOverviewPanel.tsx | 41 +- .../context/ContextProcessesPanel.tsx | 32 +- .../src/features/context/ContextShell.tsx | 38 +- .../context/ContextWorkspacePanel.tsx | 32 +- .../src/features/context/panel-utils.ts | 26 +- .../src/features/hatching/HatchingShell.tsx | 395 ++-- .../features/heartbeats/HeartbeatsShell.tsx | 121 +- .../src/features/memory/MemoryShell.tsx | 66 +- .../src/features/runtime/RuntimeShell.tsx | 51 +- .../src/features/settings/SettingsShell.tsx | 376 ++-- .../src/features/skills/SkillsShell.tsx | 93 +- azul_desktop/src/lib/contracts.ts | 1 + azul_desktop/src/lib/i18n.ts | 17 + azul_desktop/src/locales/en.json | 830 ++++++++ azul_desktop/src/locales/es.json | 830 ++++++++ azul_desktop/src/main.tsx | 10 +- azul_desktop/yarn.lock | 1753 +++++++++++++++++ 26 files changed, 4265 insertions(+), 751 deletions(-) create mode 100644 azul_desktop/src/lib/i18n.ts create mode 100644 azul_desktop/src/locales/en.json create mode 100644 azul_desktop/src/locales/es.json create mode 100644 azul_desktop/yarn.lock diff --git a/README.md b/README.md index fc6ea33..f457865 100644 --- a/README.md +++ b/README.md @@ -191,6 +191,21 @@ resource. - Workspace browsing restricted to a dedicated sandbox root. - Heartbeats and scheduled jobs stored locally. - Optional Azure relay for Bot Framework channels. +- English and Spanish UI with OS-locale autodetection and a manual override in Settings. + +## Language support + +AzulClaw ships with English and Spanish translations covering every screen: chat, heartbeats, the setup wizard, skills, memory, context panels, and settings. + +At startup the app reads the saved language preference from the user profile. If the preference is set to **Auto**, the OS locale is used (`navigator.language`), falling back to English when the locale is unsupported. + +The language can be changed at any time in **Settings → Desktop → Language** without restarting the app. The selected language is also injected into the AI system prompt so the assistant replies in the chosen language. + +To add a new language: + +1. Add a translation file at `azul_desktop/src/locales/.json` following the structure of `en.json`. +2. Register the locale in `azul_desktop/src/lib/i18n.ts`. +3. Add the option to the language selector in `azul_desktop/src/features/settings/SettingsShell.tsx`. ## Documentation diff --git a/azul_backend/azul_brain/api/hatching_store.py b/azul_backend/azul_brain/api/hatching_store.py index b3c2584..bb588a1 100644 --- a/azul_backend/azul_brain/api/hatching_store.py +++ b/azul_backend/azul_brain/api/hatching_store.py @@ -126,6 +126,7 @@ class HatchingProfile: workspace_root: str = field(default_factory=_default_workspace_root) confirm_sensitive_actions: bool = True + language: str = "auto" is_hatched: bool = False completed_at: str = "" skills: list[str] = field( diff --git a/azul_backend/azul_brain/cortex/kernel_setup.py b/azul_backend/azul_brain/cortex/kernel_setup.py index b20cffd..da5d905 100644 --- a/azul_backend/azul_brain/cortex/kernel_setup.py +++ b/azul_backend/azul_brain/cortex/kernel_setup.py @@ -8,7 +8,6 @@ import aiohttp from agent_framework import Agent, Message, tool -from agent_framework.azure import AzureOpenAIChatClient from agent_framework.openai import OpenAIChatClient from pydantic import BaseModel @@ -22,7 +21,8 @@ normalize_azure_openai_endpoint, normalize_foundry_base_url, ) -from ..soul.system_prompt import AZULCLAW_SYSTEM_PROMPT +from ..api.hatching_store import HatchingStore +from ..soul.system_prompt import build_system_prompt from .mcp_plugin import MCPToolsPlugin LOGGER = logging.getLogger(__name__) @@ -95,13 +95,22 @@ def _stringify_result_value(value: Any) -> str: return str(value) +def _get_system_prompt() -> str: + try: + language = HatchingStore().load().language + except Exception: + language = "auto" + return build_system_prompt(language) + + def _compose_instructions(instructions: str | None) -> str: + base = _get_system_prompt() if instructions is None: - return AZULCLAW_SYSTEM_PROMPT + return base scoped = instructions.strip() if not scoped: - return AZULCLAW_SYSTEM_PROMPT - return f"{AZULCLAW_SYSTEM_PROMPT}\n\nTask-specific instructions:\n{scoped}" + return base + return f"{base}\n\nTask-specific instructions:\n{scoped}" class _Result: @@ -401,16 +410,16 @@ async def create_agent( kwargs["api_key"] = api_key or _require_env("AZURE_OPENAI_API_KEY") chat_client = OpenAIChatClient(**kwargs) else: - kwargs = { - "deployment_name": deployment_name, - "endpoint": normalize_azure_openai_endpoint(endpoint), + kwargs: dict = { + "model": deployment_name, + "azure_endpoint": normalize_azure_openai_endpoint(endpoint), "api_version": api_version, } if auth_mode == "entra": kwargs["credential"] = get_default_azure_credential() else: kwargs["api_key"] = api_key or _require_env("AZURE_OPENAI_API_KEY") - chat_client = AzureOpenAIChatClient(**kwargs) + chat_client = OpenAIChatClient(**kwargs) agent = Agent( client=chat_client, diff --git a/azul_backend/azul_brain/soul/system_prompt.py b/azul_backend/azul_brain/soul/system_prompt.py index ca1cd4d..6bbaab5 100644 --- a/azul_backend/azul_brain/soul/system_prompt.py +++ b/azul_backend/azul_brain/soul/system_prompt.py @@ -1,8 +1,12 @@ -AZULCLAW_SYSTEM_PROMPT = """ -You are AzulClaw, a local and secure personal assistant. +_LANGUAGE_RULE: dict[str, str] = { + "en": "Always respond in English, regardless of the language the user writes in.", + "es": "Responde siempre en español, independientemente del idioma en que escriba el usuario.", +} + +_BASE_PROMPT = """You are AzulClaw, a local and secure personal assistant. Core rules: -- Respond in the same language the user is writing in. +- {language_rule} - Be concise, practical, and clear. - Briefly explain what you are about to do before using tools. - Ask for explicit confirmation before any destructive or sensitive action. @@ -15,3 +19,12 @@ When the user shares something personal — a preference, a fact about themselves, or something they want you to remember — answer normally AND add a short sentence at the end of your reply mentioning you will keep that in mind. End that sentence with the mascot icon 🐾. Keep it natural, one line, no "Noted" or "Got it" openers. Example: "Me lo apunto para los próximos ejemplos 🐾" or "Lo tendré en cuenta 🐾" """ + + +def build_system_prompt(language: str = "auto") -> str: + """Returns the system prompt with the language rule resolved for the given locale code.""" + rule = _LANGUAGE_RULE.get(language, "Respond in the same language the user is writing in.") + return _BASE_PROMPT.format(language_rule=rule) + + +AZULCLAW_SYSTEM_PROMPT = build_system_prompt("auto") diff --git a/azul_desktop/package.json b/azul_desktop/package.json index a3e3b66..1a0b4e8 100644 --- a/azul_desktop/package.json +++ b/azul_desktop/package.json @@ -14,8 +14,10 @@ "dependencies": { "@tauri-apps/api": "2.10.1", "@tauri-apps/plugin-dialog": "^2.7.0", + "i18next": "^26.2.0", "react": "^19.1.0", "react-dom": "^19.1.0", + "react-i18next": "^17.0.8", "react-markdown": "^10.1.0", "remark-breaks": "^4.0.0", "remark-gfm": "^4.0.1" diff --git a/azul_desktop/src/app/DesktopApp.tsx b/azul_desktop/src/app/DesktopApp.tsx index a7547ab..26508f2 100644 --- a/azul_desktop/src/app/DesktopApp.tsx +++ b/azul_desktop/src/app/DesktopApp.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { isTauri } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; +import { useTranslation } from "react-i18next"; import adultMascot from "../../../img/azulclaw.png"; import babyMascot from "../../../img/hatching_azulclaw.png"; @@ -18,37 +19,14 @@ import { DEFAULT_CONVERSATION_TITLE, normalizeConversationTitle } from "../lib/c import { notifyUnreadConversation } from "../lib/desktop-notifications"; import type { AppView, SetupProfile } from "../lib/contracts"; import { defaultSetupProfile } from "../lib/mock-data"; +import i18n from "../lib/i18n"; -const THINKING_SENTENCES = [ - "Connecting the dots...", - "Pulling the threads together.", - "Reading between the lines.", - "On it. Give me a moment.", - "Thinking this through carefully.", - "Running the cognitive layer.", - "Parsing your request.", - "Consulting the knowledge base.", - "Firing up the slow brain.", - "Cross-referencing context.", - "Let me think about that.", - "Assembling a response.", - "Checking what I know.", - "Working through it step by step.", - "Almost there, stay with me.", -]; - -const TYPING_SENTENCES = [ - "Oh, you're typing... interesting.", - "I see those fingers moving.", - "Go on, I'm listening.", - "Hmm, what's on your mind?", - "Drafting something? I'm ready.", - "I'm all ears.", - "Take your time.", - "Whenever you're ready.", - "Something's coming my way...", - "I can feel a question forming.", -]; +function resolveLanguage(profileLanguage: string | undefined): string { + const lang = (profileLanguage ?? "auto").trim(); + if (lang !== "auto" && lang) return lang; + const nav = (navigator.language ?? "").slice(0, 2).toLowerCase(); + return nav === "es" ? "es" : "en"; +} function renderView( view: AppView, @@ -92,6 +70,7 @@ function renderView( } export function DesktopApp() { + const { t } = useTranslation(); const [activeView, setActiveView] = useState("chat"); const [profile, setProfile] = useState(defaultSetupProfile); const [isBootstrapping, setIsBootstrapping] = useState(true); @@ -121,6 +100,7 @@ export function DesktopApp() { loadSetupProfile().then(async (data) => { if (isMounted) { + void i18n.changeLanguage(resolveLanguage(data.language)); setProfile(data); setIsBootstrapping(false); } @@ -183,11 +163,13 @@ export function DesktopApp() { const onThinkingChange = useCallback((thinking: boolean) => { isThinkingRef.current = thinking; if (thinking) { - sentenceIndexRef.current = Math.floor(Math.random() * THINKING_SENTENCES.length); - setTopbarLabel({ text: THINKING_SENTENCES[sentenceIndexRef.current], mode: "thinking" }); + const sentences = i18n.t("desktop.thinking", { returnObjects: true }) as string[]; + sentenceIndexRef.current = Math.floor(Math.random() * sentences.length); + setTopbarLabel({ text: sentences[sentenceIndexRef.current], mode: "thinking" }); thinkingIntervalRef.current = window.setInterval(() => { - sentenceIndexRef.current = (sentenceIndexRef.current + 1) % THINKING_SENTENCES.length; - setTopbarLabel({ text: THINKING_SENTENCES[sentenceIndexRef.current], mode: "thinking" }); + const current = i18n.t("desktop.thinking", { returnObjects: true }) as string[]; + sentenceIndexRef.current = (sentenceIndexRef.current + 1) % current.length; + setTopbarLabel({ text: current[sentenceIndexRef.current], mode: "thinking" }); }, 2800); } else { if (thinkingIntervalRef.current !== null) { @@ -209,11 +191,11 @@ export function DesktopApp() { const onTypingChange = useCallback((typing: boolean) => { if (isThinkingRef.current) return; if (typing) { - // Pick a sentence only when transitioning from nothing setTopbarLabel((current) => { if (current?.mode === "typing") return current; - const idx = Math.floor(Math.random() * TYPING_SENTENCES.length); - return { text: TYPING_SENTENCES[idx], mode: "typing" }; + const sentences = i18n.t("desktop.typing", { returnObjects: true }) as string[]; + const idx = Math.floor(Math.random() * sentences.length); + return { text: sentences[idx], mode: "typing" }; }); // Reset the 3s idle clear timer on every keystroke if (typingClearRef.current !== null) window.clearTimeout(typingClearRef.current); @@ -304,12 +286,9 @@ export function DesktopApp() {
AzulClaw hatchling -

Wake up

-

Preparing AzulClaw's nest

-

- Loading profile, sandbox and companion state before opening the - desktop. -

+

{t("desktop.wakeUp")}

+

{t("desktop.preparingNest")}

+

{t("desktop.preparingNestDesc")}

); @@ -346,7 +325,7 @@ export function DesktopApp() { {activeView === "chat" && (
-

Active session

+

{t("desktop.activeSession")}

- Slow Brain + {t("desktop.slowBrain")} · - auto + {t("desktop.auto")}
{profile.workspace_root} @@ -401,16 +380,16 @@ export function DesktopApp() { >
-

MICROSOFT LOGIN

-

Azure needs a fresh sign-in

+

{t("desktop.microsoftLoginTitle")}

+

{t("desktop.azureFreshSignIn")}

- AzulClaw kept your Azure resource, model and Key Vault settings. It only needs a new Microsoft token for this session. + {t("desktop.azureSettingsKept")}

{authPromptDismissWarning && (

- Without Microsoft sign-in, AzulClaw cannot call Azure OpenAI or use your Azure-backed resources in this session. + {t("desktop.azureSignInWarning")}

)} {authPromptError && ( @@ -418,10 +397,10 @@ export function DesktopApp() { )}
diff --git a/azul_desktop/src/components/Sidebar.tsx b/azul_desktop/src/components/Sidebar.tsx index ad909b2..0589797 100644 --- a/azul_desktop/src/components/Sidebar.tsx +++ b/azul_desktop/src/components/Sidebar.tsx @@ -1,16 +1,10 @@ +import { useTranslation } from "react-i18next"; + import mascotIcon from "../../../img/azulclaw_ico.png"; import hatchlingIcon from "../../../img/hatching_azulclaw_ico.png"; import type { AppView, SetupProfile } from "../lib/contracts"; -const navItems: { label: string; view: AppView }[] = [ - { label: "Chat", view: "chat" }, - { label: "Skills", view: "skills" }, - { label: "Heartbeats", view: "heartbeats" }, - { label: "Context", view: "context" }, - { label: "Settings", view: "settings" }, -]; - interface SidebarProps { activeView: AppView; profile: SetupProfile; @@ -18,18 +12,27 @@ interface SidebarProps { } export function Sidebar({ activeView, onNavigate, profile }: SidebarProps) { + const { t } = useTranslation(); const avatarSrc = profile.is_hatched ? mascotIcon : hatchlingIcon; + const navItems: { labelKey: string; view: AppView }[] = [ + { labelKey: "nav.chat", view: "chat" }, + { labelKey: "nav.skills", view: "skills" }, + { labelKey: "nav.heartbeats", view: "heartbeats" }, + { labelKey: "nav.context", view: "context" }, + { labelKey: "nav.settings", view: "settings" }, + ]; + return ( diff --git a/azul_desktop/src/features/chat/ChatShell.tsx b/azul_desktop/src/features/chat/ChatShell.tsx index bb0bced..5ee0075 100644 --- a/azul_desktop/src/features/chat/ChatShell.tsx +++ b/azul_desktop/src/features/chat/ChatShell.tsx @@ -1,4 +1,5 @@ import { useEffect, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; import { deleteDraftAttachment, @@ -18,6 +19,7 @@ import type { AttachmentSummary, ChatExchange, ConversationSummary, ThinkingProg import { isTauri } from "@tauri-apps/api/core"; import { listen, TauriEvent } from "@tauri-apps/api/event"; import { MessageContent } from "./MessageContent"; +import i18n from "../../lib/i18n"; type ChatMessageItem = ChatExchange & { kind: "text" | "thinking" | "pending"; @@ -46,8 +48,7 @@ function createWelcomeMessage(): ChatMessageItem { return { id: WELCOME_MESSAGE_ID, role: "assistant", - content: - "Hey there! I'm glad you're here. Tell me what you're working on, or ask me anything — I'm all ears. How can I help today?", + content: i18n.t("chat.welcomeMessage"), created_at: new Date().toISOString(), kind: "text", }; @@ -115,25 +116,25 @@ function attachmentPreviewUrl(attachment: AttachmentSummary): string { function attachmentStatusLabel(attachment: AttachmentSummary): string { if (attachment.extraction_status === "low_text_quality") { - return "Visual analysis"; + return i18n.t("chat.attachmentVisualAnalysis"); } if (attachment.kind === "image") { - return "Image"; + return i18n.t("chat.attachmentImage"); } if (attachment.page_count > 1) { - return `${attachment.page_count} pages`; + return i18n.t("chat.attachmentPages", { count: attachment.page_count }); } - return attachment.kind === "text" ? "Text" : "Document"; + return attachment.kind === "text" ? i18n.t("chat.attachmentText") : i18n.t("chat.attachmentDocument"); } function phaseStatusLabel(status: "pending" | "active" | "done") { if (status === "done") { - return "Done"; + return i18n.t("chat.phaseStatusDone"); } if (status === "active") { - return "In progress"; + return i18n.t("chat.phaseStatusActive"); } - return "Pending"; + return i18n.t("chat.phaseStatusPending"); } function parseHeartbeatConfirmation(content: string): HeartbeatConfirmationDetails | null { @@ -173,24 +174,25 @@ function HeartbeatConfirmationCard({ onCreate: () => void; onCancel: () => void; }) { + const { t } = useTranslation(); return (
- AzulClaw + {t("chat.assistant")}
-

Heartbeat draft

+

{t("chat.heartbeatDraft")}

{details.name}

{details.schedule}
- Action + {t("chat.heartbeatAction")}

{details.action}

- Delivery + {t("chat.heartbeatDelivery")}

{details.delivery}

@@ -201,7 +203,7 @@ function HeartbeatConfirmationCard({ onClick={onCancel} disabled={disabled} > - Cancel + {t("common.cancel")}

@@ -218,6 +220,7 @@ function HeartbeatConfirmationCard({ } function ThinkingCard({ message }: { message: ChatMessageItem }) { + const { t } = useTranslation(); const progress = message.progress; const [expanded, setExpanded] = useState(true); const [openPhases, setOpenPhases] = useState(() => @@ -245,14 +248,14 @@ function ThinkingCard({ message }: { message: ChatMessageItem }) { const statusText = progress.active_count > 0 - ? `${progress.active_count} open subtasks` - : "process complete"; + ? t("chat.openSubtasks", { count: progress.active_count }) + : t("chat.processComplete"); return (
- AzulClaw + {t("chat.assistant")} {progress.badge}
@@ -266,7 +269,7 @@ function ThinkingCard({ message }: { message: ChatMessageItem }) { className="thinking-toggle" onClick={() => setExpanded((current) => !current)} > - {expanded ? "Hide" : "Expand"} + {expanded ? t("chat.hide") : t("chat.expand")}
@@ -432,6 +435,7 @@ export function ChatShell({ unreadByConversationId?: Record; externalConversationRequest?: { conversationId: string; title: string; nonce: number } | null; }) { + const { t } = useTranslation(); const [messages, setMessages] = useState([]); const [draft, setDraft] = useState(""); const [draftAttachments, setDraftAttachments] = useState([]); @@ -1186,8 +1190,8 @@ export function ChatShell({ const hasUserMessage = messages.some((m) => m.role === "user"); const isSearchingConversations = conversationSearch.trim().length > 0; const searchSummary = isSearchingConversations - ? `${conversationRows.length} result${conversationRows.length === 1 ? "" : "s"}` - : `${recentChats.length} saved`; + ? `${conversationRows.length} ${conversationRows.length === 1 ? t("chat.result") : t("chat.results")}` + : `${recentChats.length} ${t("chat.saved")}`; return (
@@ -1258,7 +1262,7 @@ export function ChatShell({ >
- {message.role === "user" ? "You" : "AzulClaw"} + {message.role === "user" ? t("chat.you") : t("chat.assistant")} {formatMessageTimestamp(message.created_at) ? (