Skip to content
Merged
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
43 changes: 43 additions & 0 deletions e2e_tests/tests_omni_light/src/i18n.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { expect, test } from "@playwright/test";
import { ChatPage, General } from "./pages";

test.describe("i18n", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/chat");
await page.evaluate(() => localStorage.clear());
});

test("shows English translations by default", async ({ page }) => {
const chatPage = new ChatPage(page);
await chatPage.goto();

await expect(
page.getByRole("heading", {
name: "How can I help you today?",
exact: true,
}),
).toBeVisible({ timeout: 10000 });
await expect(
page.getByRole("textbox", { name: "Type a message..." }),
).toBeVisible({ timeout: 5000 });
});

test("shows German translations when language is set to de", async ({
page,
}) => {
const chatPage = new ChatPage(page);
const general = new General(page);
await general.setLanguage("de");
await chatPage.goto();

await expect(
page.getByRole("heading", {
name: "Wie kann ich Ihnen heute helfen?",
exact: true,
}),
).toBeVisible({ timeout: 10000 });
await expect(
page.getByRole("textbox", { name: "Nachricht eingeben..." }),
).toBeVisible({ timeout: 5000 });
});
});
12 changes: 12 additions & 0 deletions e2e_tests/tests_omni_light/src/pages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,18 @@ export class General {
const viewport = this.page.viewportSize();
return viewport !== null && viewport.width < 768;
}

async setLanguage(lang: string): Promise<void> {
await this.page.evaluate((l) => {
localStorage.setItem("i18nextLng", l);
}, lang);
}

async clearLanguage(): Promise<void> {
await this.page.evaluate(() => {
localStorage.removeItem("i18nextLng");
});
}
}

export class ChatPage {
Expand Down
1 change: 1 addition & 0 deletions frontend/omni/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- i18n support via `i18next` with browser language detection (supports English fallback and German). Translations are module-scoped: each module with user-facing strings has its own `locales/de.json`. New `src/modules/i18n/` module provides the `getT(namespace)` helper used by all components.
- Tools can now be toggled directly from the chat input panel via a wrench-icon popover — no separate Tools page needed.

### Changed
Expand Down
2 changes: 2 additions & 0 deletions frontend/omni/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
"@ai-sdk/svelte": "^4.0.146",
"ai": "^6.0.146",
"clsx": "^2.1.1",
"i18next": "^26.0.3",
"i18next-browser-languagedetector": "^8.2.1",
"lucide-svelte": "^1.0.1",
"marked": "^17.0.6",
"sv-router": "^0.15.0",
Expand Down
33 changes: 33 additions & 0 deletions frontend/omni/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions frontend/omni/src/main.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import "./app.css";
import "@/modules/i18n/i18n.js";
import { mount } from "svelte";
import App from "./App.svelte";

Expand Down
15 changes: 9 additions & 6 deletions frontend/omni/src/modules/chat/ChatConversationArea.svelte
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
<script lang="ts">
import type { UIMessage } from "ai";
import { BotIcon, LoaderCircle } from "lucide-svelte";
import { getT } from "@/modules/i18n/index.svelte.js";
import * as Avatar from "$lib/shadcnui/components/ui/avatar/index.js";
import { ScrollArea } from "$lib/shadcnui/components/ui/scroll-area/index.js";
import ChatMessageItem from "./ChatMessageItem.svelte";

const t = getT("chat");

let {
messages,
status,
Expand Down Expand Up @@ -40,7 +43,7 @@ $effect(() => {
{#if modelsLoading}
<div class="flex items-center justify-center gap-2 py-20">
<LoaderCircle class="text-muted-foreground size-6 animate-spin" />
<span class="text-muted-foreground text-sm">Loading models...</span>
<span class="text-muted-foreground text-sm">{t("loadingModels", { defaultValue: "Loading models..." })}</span>
</div>
{:else}
<!-- Normal empty state -->
Expand All @@ -54,13 +57,13 @@ $effect(() => {
</div>
<div class="text-center">
<h2 class="text-2xl font-semibold tracking-tight">
How can I help you today?
{t("greeting", { defaultValue: "How can I help you today?" })}
</h2>
<p class="text-muted-foreground mt-2 text-sm">
{#if !hasModels}
Add a provider in Settings to start chatting.
{:else}
Ask me anything or pick a suggestion below.
{t("noProvidersHint", { defaultValue: "Add a provider in Settings to start chatting." })}
{:else}
{t("suggestionHint", { defaultValue: "Ask me anything or pick a suggestion below." })}
{/if}
</p>
</div>
Expand All @@ -83,7 +86,7 @@ $effect(() => {
<div class="flex items-center gap-2 pt-1">
<LoaderCircle class="text-muted-foreground size-4 animate-spin" />
<span class="text-muted-foreground text-xs">
{status === "submitted" ? "Thinking..." : "Generating..."}
{status === "submitted" ? t("thinking", { defaultValue: "Thinking..." }) : t("generating", { defaultValue: "Generating..." })}
</span>
</div>
</div>
Expand Down
9 changes: 6 additions & 3 deletions frontend/omni/src/modules/chat/ChatInputPanel.svelte
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
<script lang="ts">
import { LoaderCircle, SendIcon } from "lucide-svelte";
import type { Snippet } from "svelte";
import { getT } from "@/modules/i18n/index.svelte.js";
import { Button } from "$lib/shadcnui/components/ui/button/index.js";
import { Textarea } from "$lib/shadcnui/components/ui/textarea/index.js";

const t = getT("chat");

let {
canChat,
isIdle,
Expand Down Expand Up @@ -46,8 +49,8 @@ function sendMessage() {
<Textarea
bind:value={input}
onkeydown={handleKeydown}
placeholder="Type a message..."
class="border-0 shadow-none focus-visible:ring-0 min-h-[44px] resize-none p-0 max-h-48 bg-transparent dark:bg-transparent"
placeholder={t("typeMessage", { defaultValue: "Type a message..." })}
class="border-0 shadow-none focus-visible:ring-0 min-h-[44px] resize-none p-0 max-h-48 !bg-transparent dark:!bg-transparent disabled:!bg-transparent dark:disabled:!bg-transparent"
rows={1}
disabled={!canChat}
/>
Expand All @@ -68,7 +71,7 @@ function sendMessage() {
{:else}
<SendIcon class="size-3.5" />
{/if}
Send
{t("send", { defaultValue: "Send" })}
</Button>
</div>
</div>
Expand Down
9 changes: 6 additions & 3 deletions frontend/omni/src/modules/chat/ChatModelSelector.svelte
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
<script lang="ts">
import { Check, ChevronDown } from "lucide-svelte";
import { getT } from "@/modules/i18n/index.svelte.js";
import type { ProviderModel } from "@/modules/llm-provider-service/index.svelte.js";
import { Button } from "$lib/shadcnui/components/ui/button/index.js";
import * as Command from "$lib/shadcnui/components/ui/command/index.js";
import * as Popover from "$lib/shadcnui/components/ui/popover/index.js";
import { modelSelectId } from "./utils.js";

const t = getT("chat");

let {
providerGroups,
selectedModel = $bindable(),
Expand Down Expand Up @@ -33,16 +36,16 @@ function handleSelect(selectId: string) {
class="text-muted-foreground h-auto gap-1.5 px-2 py-1 text-xs"
{...props}
>
{selectedModelData?.modelName ?? "Select model"}
{selectedModelData?.modelName ?? t("selectModel", { defaultValue: "Select model" })}
<ChevronDown class="size-3" />
</Button>
{/snippet}
</Popover.Trigger>
<Popover.Content class="w-[250px] p-0" align="start" side="top">
<Command.Root>
<Command.Input placeholder="Search models..." />
<Command.Input placeholder={t("searchModels", { defaultValue: "Search models..." })} />
<Command.List>
<Command.Empty>No models found.</Command.Empty>
<Command.Empty>{t("noModelsFound", { defaultValue: "No models found." })}</Command.Empty>
{#each providerGroups as group}
<Command.Group heading={group.name}>
{#each group.models as m}
Expand Down
20 changes: 19 additions & 1 deletion frontend/omni/src/modules/chat/ChatSuggestions.svelte
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
<script lang="ts">
import { getT } from "@/modules/i18n/index.svelte.js";
import { Button } from "$lib/shadcnui/components/ui/button/index.js";

const suggestions = [
const t = getT("chat");

const defaultSuggestions = [
"What are the latest trends in AI?",
"How does machine learning work?",
"Explain quantum computing",
Expand All @@ -12,6 +15,21 @@ const suggestions = [
"Explain cloud computing basics",
];

const suggestions = $derived.by(() => {
const translatedSuggestions = t("suggestions", {
returnObjects: true,
defaultValue: defaultSuggestions,
});

return Array.isArray(translatedSuggestions) &&
translatedSuggestions.every(
(suggestion): suggestion is string =>
typeof suggestion === "string",
)
? translatedSuggestions
: defaultSuggestions;
});

let {
onselect,
}: {
Expand Down
9 changes: 6 additions & 3 deletions frontend/omni/src/modules/chat/ChatToolsSelector.svelte
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
<script lang="ts">
import { Check, Wrench } from "lucide-svelte";
import { getT } from "@/modules/i18n/index.svelte.js";
import type { OpenAIFunctionTool } from "@/modules/tools-service/index.svelte.js";
import { Button } from "$lib/shadcnui/components/ui/button/index.js";
import * as Popover from "$lib/shadcnui/components/ui/popover/index.js";

const t = getT("chat");

let {
availableTools,
selectedToolNames,
Expand All @@ -28,7 +31,7 @@ function isSelected(name: string): boolean {
variant="ghost"
size="sm"
class="text-muted-foreground h-auto gap-1.5 px-2 py-1 text-xs"
aria-label="Select tools"
aria-label={t("selectTools", { defaultValue: "Select tools" })}
{...props}
>
<Wrench class="size-3.5" />
Expand All @@ -39,12 +42,12 @@ function isSelected(name: string): boolean {
{/snippet}
</Popover.Trigger>
<Popover.Content class="w-[280px] p-1" align="start" side="top">
<div class="px-2 py-1.5 text-xs font-medium text-muted-foreground">Tools</div>
<div class="px-2 py-1.5 text-xs font-medium text-muted-foreground">{t("tools", { defaultValue: "Tools" })}</div>
{#each availableTools as tool}
<button
type="button"
class="flex w-full cursor-pointer items-start gap-2 rounded px-2 py-1.5 text-left hover:bg-accent"
aria-label="Toggle tool {tool.function.name}"
aria-label={t("toggleTool", { defaultValue: "Toggle tool {{name}}", name: tool.function.name })}
aria-pressed={isSelected(tool.function.name)}
onclick={() => ontoggle(tool.function.name)}
>
Expand Down
5 changes: 4 additions & 1 deletion frontend/omni/src/modules/chat/chatNavigationItem.svelte
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
<script lang="ts">
import { MessageSquare } from "lucide-svelte";
import { getT } from "@/modules/i18n/index.svelte.js";
import SidebarMenuNavigationItem from "@/modules/main-app-sidebar-based/lib/SidebarMenuNavigationItem.svelte";
import { CHAT_PATH } from "./chatRouteDefinition.svelte";

const t = getT("chat");
</script>

{#snippet icon()}
<MessageSquare />
{/snippet}

<SidebarMenuNavigationItem
label="Chat"
label={t("navLabel", { defaultValue: "Chat" })}
path={CHAT_PATH}
{icon}
/>
27 changes: 27 additions & 0 deletions frontend/omni/src/modules/chat/locales/de.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
{
"selectModel": "Modell auswählen",
"searchModels": "Modelle suchen...",
"noModelsFound": "Keine Modelle gefunden.",
"selectTools": "Werkzeuge auswählen",
"tools": "Werkzeuge",
"toggleTool": "Werkzeug {{name}} umschalten",
"typeMessage": "Nachricht eingeben...",
"send": "Senden",
"loadingModels": "Modelle werden geladen...",
"greeting": "Wie kann ich Ihnen heute helfen?",
"noProvidersHint": "Fügen Sie in den Einstellungen einen Anbieter hinzu, um mit dem Chatten zu beginnen.",
"suggestionHint": "Fragen Sie mich etwas oder wählen Sie einen Vorschlag.",
"thinking": "Denke nach...",
"generating": "Generiere...",
"navLabel": "Chat",
"suggestions": [
"Was sind die neuesten KI-Trends?",
"Wie funktioniert maschinelles Lernen?",
"Erkläre Quantencomputing",
"Best Practices für Svelte-Entwicklung",
"Vorteile von TypeScript",
"Wie optimiert man Datenbankabfragen?",
"Was ist der Unterschied zwischen SQL und NoSQL?",
"Grundlagen des Cloud Computing"
]
}
Loading
Loading