Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<code>.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

Expand Down
1 change: 1 addition & 0 deletions azul_backend/azul_brain/api/hatching_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
27 changes: 18 additions & 9 deletions azul_backend/azul_brain/cortex/kernel_setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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__)
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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,
Expand Down
19 changes: 16 additions & 3 deletions azul_backend/azul_brain/soul/system_prompt.py
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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")
2 changes: 2 additions & 0 deletions azul_desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
83 changes: 31 additions & 52 deletions azul_desktop/src/app/DesktopApp.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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,
Expand Down Expand Up @@ -92,6 +70,7 @@ function renderView(
}

export function DesktopApp() {
const { t } = useTranslation();
const [activeView, setActiveView] = useState<AppView>("chat");
const [profile, setProfile] = useState<SetupProfile>(defaultSetupProfile);
const [isBootstrapping, setIsBootstrapping] = useState(true);
Expand Down Expand Up @@ -121,6 +100,7 @@ export function DesktopApp() {

loadSetupProfile().then(async (data) => {
if (isMounted) {
void i18n.changeLanguage(resolveLanguage(data.language));
setProfile(data);
setIsBootstrapping(false);
}
Expand Down Expand Up @@ -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) {
Expand All @@ -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);
Expand Down Expand Up @@ -304,12 +286,9 @@ export function DesktopApp() {
<div className="onboarding-stage">
<section className="onboarding-card">
<img className="onboarding-mascot" src={babyMascot} alt="AzulClaw hatchling" />
<p className="eyebrow">Wake up</p>
<h1>Preparing AzulClaw's nest</h1>
<p>
Loading profile, sandbox and companion state before opening the
desktop.
</p>
<p className="eyebrow">{t("desktop.wakeUp")}</p>
<h1>{t("desktop.preparingNest")}</h1>
<p>{t("desktop.preparingNestDesc")}</p>
</section>
</div>
);
Expand Down Expand Up @@ -346,7 +325,7 @@ export function DesktopApp() {
{activeView === "chat" && (
<div className="topbar-chat-area">
<div className="topbar-session-block">
<p className="topbar-context-eyebrow">Active session</p>
<p className="topbar-context-eyebrow">{t("desktop.activeSession")}</p>
<h2
className="topbar-context-title"
title={normalizeConversationTitle(conversationTitle)}
Expand All @@ -360,9 +339,9 @@ export function DesktopApp() {
<div className="topbar-right">
<div className="topbar-status-row">
<span className="topbar-live-dot" />
<span className="topbar-status-label">Slow Brain</span>
<span className="topbar-status-label">{t("desktop.slowBrain")}</span>
<span className="topbar-status-divider">·</span>
<span className="topbar-status-label">auto</span>
<span className="topbar-status-label">{t("desktop.auto")}</span>
</div>
<span className="topbar-workspace-chip" title={profile.workspace_root}>
{profile.workspace_root}
Expand Down Expand Up @@ -401,27 +380,27 @@ export function DesktopApp() {
>
<div className="hw-modal-head">
<div>
<p className="hw-label">MICROSOFT LOGIN</p>
<h3 className="hw-modal-title">Azure needs a fresh sign-in</h3>
<p className="hw-label">{t("desktop.microsoftLoginTitle")}</p>
<h3 className="hw-modal-title">{t("desktop.azureFreshSignIn")}</h3>
</div>
</div>
<p className="hw-inline-note">
AzulClaw kept your Azure resource, model and Key Vault settings. It only needs a new Microsoft token for this session.
{t("desktop.azureSettingsKept")}
</p>
{authPromptDismissWarning && (
<p className="hw-inline-note hw-inline-note-warning" style={{ marginTop: "10px" }}>
Without Microsoft sign-in, AzulClaw cannot call Azure OpenAI or use your Azure-backed resources in this session.
{t("desktop.azureSignInWarning")}
</p>
)}
{authPromptError && (
<p className="hw-inline-note hw-inline-note-warning" style={{ marginTop: "10px" }}>{authPromptError}</p>
)}
<div className="hw-modal-actions" style={{ marginTop: "16px" }}>
<button type="button" className="hw-btn-ghost" onClick={handleDismissAzureLogin} disabled={authPromptBusy}>
{authPromptDismissWarning ? "Continue without sign-in" : "Not now"}
{authPromptDismissWarning ? t("desktop.continueWithoutSignIn") : t("desktop.notNow")}
</button>
<button type="button" className="hw-btn-primary" onClick={() => void handleRenewAzureLogin()} disabled={authPromptBusy}>
{authPromptBusy ? "Signing in..." : "Sign in with Microsoft"}
{authPromptBusy ? t("desktop.signingIn") : t("desktop.signInMicrosoft")}
</button>
</div>
</section>
Expand Down
27 changes: 15 additions & 12 deletions azul_desktop/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
@@ -1,50 +1,53 @@
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;
onNavigate: (view: AppView) => void;
}

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 (
<aside className="sidebar">
<div className="brand-card">
<div>
<p className="eyebrow">{profile.is_hatched ? profile.archetype : "Setup"}</p>
<p className="eyebrow">{profile.is_hatched ? profile.archetype : t("nav.setup")}</p>
<h2 style={{ fontSize: "1.2rem" }}>{profile.name}</h2>
</div>
</div>

<nav className="nav-list" aria-label="Primary navigation">
<nav className="nav-list" aria-label={t("nav.primaryNavigation")}>
{navItems.map((item) => (
<button
key={item.view}
className={`nav-item${activeView === item.view ? " nav-item-active" : ""}`}
type="button"
onClick={() => onNavigate(item.view)}
>
{item.label}
{t(item.labelKey)}
</button>
))}
</nav>

<section className="sidebar-account">
<button type="button" className="disconnect-btn">
Disconnect
{t("nav.disconnect")}
</button>
</section>
</aside>
Expand Down
Loading