feat: implement i18n

This commit is contained in:
Henry Li
2026-01-20 14:06:47 +08:00
parent 33e6197f65
commit ac9ef30780
21 changed files with 455 additions and 69 deletions

View File

@@ -27,6 +27,7 @@ import { Textarea } from "@/components/ui/textarea";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { useArtifactContent } from "@/core/artifacts/hooks";
import { urlOfArtifact } from "@/core/artifacts/utils";
import { useI18n } from "@/core/i18n/hooks";
import { checkCodeFile, getFileName } from "@/core/utils/files";
import { cn } from "@/lib/utils";
@@ -41,6 +42,7 @@ export function ArtifactFileDetail({
filepath: string;
threadId: string;
}) {
const { t } = useI18n();
const { artifacts, setOpen, select } = useArtifacts();
const isWriteFile = useMemo(() => {
return filepathFromProps.startsWith("write-file:");
@@ -124,26 +126,26 @@ export function ArtifactFileDetail({
<a href={urlOfArtifact({ filepath, threadId })} target="_blank">
<ArtifactAction
icon={SquareArrowOutUpRightIcon}
label="Open in new window"
tooltip="Open in new window"
label={t.common.openInNewWindow}
tooltip={t.common.openInNewWindow}
/>
</a>
)}
{isCodeFile && (
<ArtifactAction
icon={CopyIcon}
label="Copy"
label={t.clipboard.copyToClipboard}
disabled={!content}
onClick={async () => {
try {
await navigator.clipboard.writeText(content ?? "");
toast.success("Copied to clipboard");
toast.success(t.clipboard.copiedToClipboard);
} catch (error) {
toast.error("Failed to copy to clipboard");
console.error(error);
}
}}
tooltip="Copy content to clipboard"
tooltip={t.clipboard.copyToClipboard}
/>
)}
{!isWriteFile && (
@@ -153,17 +155,16 @@ export function ArtifactFileDetail({
>
<ArtifactAction
icon={DownloadIcon}
label="Download"
onClick={() => console.log("Download")}
tooltip="Download file"
label={t.common.download}
tooltip={t.common.download}
/>
</a>
)}
<ArtifactAction
icon={XIcon}
label="Close"
label={t.common.close}
onClick={() => setOpen(false)}
tooltip="Close"
tooltip={t.common.close}
/>
</ArtifactActions>
</div>

View File

@@ -10,6 +10,7 @@ import {
CardTitle,
} from "@/components/ui/card";
import { urlOfArtifact } from "@/core/artifacts/utils";
import { useI18n } from "@/core/i18n/hooks";
import { getFileExtensionDisplayName, getFileName } from "@/core/utils/files";
import { cn } from "@/lib/utils";
@@ -24,6 +25,7 @@ export function ArtifactFileList({
files: string[];
threadId: string;
}) {
const { t } = useI18n();
const { select: selectArtifact, setOpen } = useArtifacts();
const handleClick = useCallback(
(filepath: string) => {
@@ -57,7 +59,7 @@ export function ArtifactFileList({
>
<Button variant="ghost">
<DownloadIcon className="size-4" />
Download
{t.common.download}
</Button>
</a>
</CardAction>

View File

@@ -2,6 +2,7 @@ import { CheckIcon, CopyIcon } from "lucide-react";
import { useCallback, useState, type ComponentProps } from "react";
import { Button } from "@/components/ui/button";
import { useI18n } from "@/core/i18n/hooks";
import { Tooltip } from "./tooltip";
@@ -11,6 +12,7 @@ export function CopyButton({
}: ComponentProps<typeof Button> & {
clipboardData: string;
}) {
const { t } = useI18n();
const [copied, setCopied] = useState(false);
const handleCopy = useCallback(() => {
void navigator.clipboard.writeText(clipboardData);
@@ -18,7 +20,7 @@ export function CopyButton({
setTimeout(() => setCopied(false), 2000);
}, [clipboardData]);
return (
<Tooltip content="Copy to clipboard">
<Tooltip content={t.clipboard.copyToClipboard}>
<Button
size="icon-sm"
type="button"

View File

@@ -1,3 +1,5 @@
"use client";
import type { ChatStatus } from "ai";
import { CheckIcon, LightbulbIcon, LightbulbOffIcon } from "lucide-react";
import { useCallback, useMemo, useState, type ComponentProps } from "react";
@@ -11,6 +13,7 @@ import {
PromptInputTextarea,
type PromptInputMessage,
} from "@/components/ai-elements/prompt-input";
import { useI18n } from "@/core/i18n/hooks";
import { useModels } from "@/core/models/hooks";
import type { AgentThreadContext } from "@/core/threads";
import { cn } from "@/lib/utils";
@@ -40,11 +43,11 @@ export function InputBox({
assistantId?: string | null;
status?: ChatStatus;
context: Omit<AgentThreadContext, "thread_id">;
showWelcome?: boolean;
onContextChange?: (context: Omit<AgentThreadContext, "thread_id">) => void;
onSubmit?: (message: PromptInputMessage) => void;
onStop?: () => void;
}) {
const { t } = useI18n();
const [modelDialogOpen, setModelDialogOpen] = useState(false);
const { models } = useModels();
const selectedModel = useMemo(
@@ -97,7 +100,7 @@ export function InputBox({
<PromptInputBody>
<PromptInputTextarea
className={cn("size-full")}
placeholder="How can I assist you today?"
placeholder={t.inputBox.placeholder}
autoFocus={autoFocus}
/>
</PromptInputBody>
@@ -107,13 +110,17 @@ export function InputBox({
content={
context.thinking_enabled ? (
<div className="tex-sm flex flex-col gap-1">
<div>Thinking is enabled</div>
<div className="opacity-50">Click to disable thinking</div>
<div>{t.inputBox.thinkingEnabled}</div>
<div className="opacity-50">
{t.inputBox.clickToDisableThinking}
</div>
</div>
) : (
<div className="tex-sm flex flex-col gap-1">
<div>Thinking is disabled</div>
<div className="opacity-50">Click to enable thinking</div>
<div>{t.inputBox.thinkingDisabled}</div>
<div className="opacity-50">
{t.inputBox.clickToEnableThinking}
</div>
</div>
)
}
@@ -142,7 +149,7 @@ export function InputBox({
</PromptInputButton>
</ModelSelectorTrigger>
<ModelSelectorContent>
<ModelSelectorInput placeholder="Search models..." />
<ModelSelectorInput placeholder={t.inputBox.searchModels} />
<ModelSelectorList>
{models.map((m) => (
<ModelSelectorItem

View File

@@ -21,8 +21,10 @@ import {
ChainOfThoughtSearchResults,
ChainOfThoughtStep,
} from "@/components/ai-elements/chain-of-thought";
import { CodeBlock } from "@/components/ai-elements/code-block";
import { MessageResponse } from "@/components/ai-elements/message";
import { Button } from "@/components/ui/button";
import { useI18n } from "@/core/i18n/hooks";
import {
extractReasoningContentFromMessage,
findToolCallResult,
@@ -33,7 +35,6 @@ import { cn } from "@/lib/utils";
import { useArtifacts } from "../artifacts";
import { FlipDisplay } from "../flip-display";
import { CodeBlock } from "@/components/ai-elements/code-block";
export function MessageGroup({
className,
@@ -44,6 +45,7 @@ export function MessageGroup({
messages: Message[];
isLoading?: boolean;
}) {
const { t } = useI18n();
const [showAbove, setShowAbove] = useState(false);
const [showLastThinking, setShowLastThinking] = useState(false);
const steps = useMemo(() => convertToSteps(messages), [messages]);
@@ -84,8 +86,8 @@ export function MessageGroup({
label={
<span className="opacity-60">
{showAbove
? "Less steps"
: `${aboveLastToolCallSteps.length} more step${aboveLastToolCallSteps.length === 1 ? "" : "s"}`}
? t.toolCalls.lessSteps
: t.toolCalls.moreSteps(aboveLastToolCallSteps.length)}
</span>
}
icon={
@@ -134,7 +136,7 @@ export function MessageGroup({
<div className="flex w-full items-center justify-between">
<ChainOfThoughtStep
className="font-normal"
label="Thinking"
label={t.common.thinking}
icon={LightbulbIcon}
></ChainOfThoughtStep>
<div>
@@ -178,16 +180,12 @@ function ToolCall({
args: Record<string, unknown>;
result?: string | Record<string, unknown>;
}) {
const { t } = useI18n();
const { select, setOpen } = useArtifacts();
if (name === "web_search") {
let label: React.ReactNode = "Search for related information";
let label: React.ReactNode = t.toolCalls.searchForRelatedInfo;
if (typeof args.query === "string") {
label = (
<div>
Search on the web for{" "}
<span className="font-bold">&quot;{args.query}&quot;</span>
</div>
);
label = t.toolCalls.searchOnWebFor(args.query);
}
return (
<ChainOfThoughtStep key={id} label={label} icon={SearchIcon}>
@@ -217,7 +215,7 @@ function ToolCall({
<ChainOfThoughtStep
key={id}
className="cursor-pointer"
label="View web page"
label={t.toolCalls.viewWebPage}
icon={GlobeIcon}
onClick={() => {
window.open(url, "_blank");
@@ -236,7 +234,7 @@ function ToolCall({
let description: string | undefined = (args as { description: string })
?.description;
if (!description) {
description = "List folder";
description = t.toolCalls.listFolder;
}
const path: string | undefined = (args as { path: string })?.path;
return (
@@ -250,7 +248,7 @@ function ToolCall({
let description: string | undefined = (args as { description: string })
?.description;
if (!description) {
description = "Read file";
description = t.toolCalls.readFile;
}
const path: string | undefined = (args as { path: string })?.path;
return (
@@ -264,7 +262,7 @@ function ToolCall({
let description: string | undefined = (args as { description: string })
?.description;
if (!description) {
description = "Write file";
description = t.toolCalls.writeFile;
}
const path: string | undefined = (args as { path: string })?.path;
return (
@@ -291,7 +289,7 @@ function ToolCall({
const description: string | undefined = (args as { description: string })
?.description;
if (!description) {
return "Execute command";
return t.toolCalls.executeCommand;
}
const command: string | undefined = (args as { command: string })?.command;
return (
@@ -312,7 +310,11 @@ function ToolCall({
);
} else if (name === "present_files") {
return (
<ChainOfThoughtStep key={id} label="Present files" icon={FileTextIcon}>
<ChainOfThoughtStep
key={id}
label={t.toolCalls.presentFiles}
icon={FileTextIcon}
>
<ChainOfThoughtSearchResult>
{Array.isArray((args as { filepaths: string[] }).filepaths) &&
(args as { filepaths: string[] }).filepaths.map(
@@ -330,7 +332,7 @@ function ToolCall({
return (
<ChainOfThoughtStep
key={id}
label="Need your help"
label={t.toolCalls.needYourHelp}
icon={MessageCircleQuestionMarkIcon}
></ChainOfThoughtStep>
);
@@ -340,13 +342,7 @@ function ToolCall({
return (
<ChainOfThoughtStep
key={id}
label={
description ?? (
<div>
Use &quot;<span className="font-bold">{name}</span>&quot; tool
</div>
)
}
label={description ?? t.toolCalls.useTool(name)}
icon={WrenchIcon}
></ChainOfThoughtStep>
);

View File

@@ -20,10 +20,12 @@ import {
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar";
import { useI18n } from "@/core/i18n/hooks";
import { useDeleteThread, useThreads } from "@/core/threads/hooks";
import { pathOfThread, titleOfThread } from "@/core/threads/utils";
export function RecentChatList() {
const { t } = useI18n();
const router = useRouter();
const pathname = usePathname();
const { thread_id: threadIdFromPath } = useParams<{ thread_id: string }>();
@@ -52,7 +54,7 @@ export function RecentChatList() {
}
return (
<SidebarGroup>
<SidebarGroupLabel>Recent chats</SidebarGroupLabel>
<SidebarGroupLabel>{t.sidebar.recentChats}</SidebarGroupLabel>
<SidebarGroupContent className="group-data-[collapsible=icon]:pointer-events-none group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0">
<SidebarMenu>
<div className="flex w-full flex-col gap-1">
@@ -78,7 +80,7 @@ export function RecentChatList() {
className="bg-background/50 hover:bg-background"
>
<MoreHorizontal />
<span className="sr-only">More</span>
<span className="sr-only">{t.common.more}</span>
</SidebarMenuAction>
</DropdownMenuTrigger>
<DropdownMenuContent
@@ -90,7 +92,7 @@ export function RecentChatList() {
onSelect={() => handleDelete(thread.thread_id)}
>
<Trash2 className="text-muted-foreground" />
<span>Delete</span>
<span>{t.common.delete}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>

View File

@@ -1,6 +1,10 @@
"use client";
import { useI18n } from "@/core/i18n/hooks";
import { cn } from "@/lib/utils";
export function Welcome({ className }: { className?: string }) {
const { t } = useI18n();
return (
<div
className={cn(
@@ -8,17 +12,13 @@ export function Welcome({ className }: { className?: string }) {
className,
)}
>
<div className="text-2xl font-bold">👋 Hello, again!</div>
<div className="text-2xl font-bold">{t.welcome.greeting}</div>
<div className="text-muted-foreground text-sm">
<p>
Welcome to 🦌 DeerFlow, an open source super agent. With built-in and
custom
</p>
<p>
skills, DeerFlow helps you search on the web, analyze data, and
generate
</p>{" "}
<p>artifacts like slides, web pages and do almost anything.</p>
{t.welcome.description.includes("\n") ? (
<pre className="whitespace-pre">{t.welcome.description}</pre>
) : (
<p>{t.welcome.description}</p>
)}
</div>
</div>
);

View File

@@ -12,6 +12,7 @@ import {
BreadcrumbPage,
BreadcrumbSeparator,
} from "@/components/ui/breadcrumb";
import { useI18n } from "@/core/i18n/hooks";
import { cn } from "@/lib/utils";
import { GithubIcon } from "./github-icon";
@@ -34,6 +35,7 @@ export function WorkspaceHeader({
children,
...props
}: React.ComponentProps<"header">) {
const { t } = useI18n();
const pathname = usePathname();
const segments = useMemo(() => {
const parts = pathname?.split("/") || [];
@@ -56,7 +58,7 @@ export function WorkspaceHeader({
<BreadcrumbItem className="hidden md:block">
<BreadcrumbLink asChild>
<Link href={`/${segments[0]}`}>
{nameOfSegment(segments[0])}
{nameOfSegment(segments[0], t)}
</Link>
</BreadcrumbLink>
</BreadcrumbItem>
@@ -68,12 +70,12 @@ export function WorkspaceHeader({
{segments.length >= 2 ? (
<BreadcrumbLink asChild>
<Link href={`/${segments[0]}/${segments[1]}`}>
{nameOfSegment(segments[1])}
{nameOfSegment(segments[1], t)}
</Link>
</BreadcrumbLink>
) : (
<BreadcrumbPage>
{nameOfSegment(segments[1])}
{nameOfSegment(segments[1], t)}
</BreadcrumbPage>
)}
</BreadcrumbItem>
@@ -89,7 +91,7 @@ export function WorkspaceHeader({
</Breadcrumb>
</div>
<div className="pr-4">
<Tooltip content="DeerFlow on Github">
<Tooltip content={t.workspace.githubTooltip}>
<a
href="https://github.com/bytedance/deer-flow"
target="_blank"
@@ -122,7 +124,12 @@ export function WorkspaceBody({
);
}
function nameOfSegment(segment: string | undefined) {
if (!segment) return "Home";
function nameOfSegment(
segment: string | undefined,
t: ReturnType<typeof useI18n>["t"],
) {
if (!segment) return t.common.home;
if (segment === "workspace") return t.breadcrumb.workspace;
if (segment === "chats") return t.breadcrumb.chats;
return segment[0]?.toUpperCase() + segment.slice(1);
}

View File

@@ -11,9 +11,11 @@ import {
SidebarTrigger,
useSidebar,
} from "@/components/ui/sidebar";
import { useI18n } from "@/core/i18n/hooks";
import { cn } from "@/lib/utils";
export function WorkspaceHeader({ className }: { className?: string }) {
const { t } = useI18n();
const { state } = useSidebar();
const pathname = usePathname();
return (
@@ -48,7 +50,7 @@ export function WorkspaceHeader({ className }: { className?: string }) {
>
<Link className="text-muted-foreground" href="/workspace/chats/new">
<MessageSquarePlus size={16} />
<span>New chat</span>
<span>{t.sidebar.newChat}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>

View File

@@ -10,8 +10,10 @@ import {
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar";
import { useI18n } from "@/core/i18n/hooks";
export function WorkspaceNavMenu() {
const { t } = useI18n();
const pathname = usePathname();
return (
<SidebarGroup className="pt-1">
@@ -20,7 +22,7 @@ export function WorkspaceNavMenu() {
<SidebarMenuButton isActive={pathname === "/workspace/chats"} asChild>
<Link className="text-muted-foreground" href="/workspace/chats">
<MessagesSquare />
<span>Chats</span>
<span>{t.sidebar.chats}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>

View File

@@ -1,10 +1,18 @@
import { SettingsIcon } from "lucide-react";
import {
Sidebar,
SidebarHeader,
SidebarContent,
SidebarFooter,
SidebarRail,
SidebarMenu,
SidebarMenuItem,
SidebarMenuButton,
SidebarGroup,
SidebarGroupContent,
} from "@/components/ui/sidebar";
import { useI18n } from "@/core/i18n/hooks";
import { RecentChatList } from "./recent-chat-list";
import { WorkspaceHeader } from "./workspace-header";
@@ -13,6 +21,7 @@ import { WorkspaceNavMenu } from "./workspace-nav-menu";
export function WorkspaceSidebar({
...props
}: React.ComponentProps<typeof Sidebar>) {
const { t } = useI18n();
return (
<Sidebar variant="sidebar" collapsible="icon" {...props}>
<SidebarHeader className="py-0">
@@ -22,7 +31,22 @@ export function WorkspaceSidebar({
<WorkspaceNavMenu />
<RecentChatList />
</SidebarContent>
<SidebarFooter></SidebarFooter>
<SidebarFooter>
<SidebarGroup className="px-0">
<SidebarGroupContent>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton asChild>
<div className="text-muted-foreground cursor-pointer">
<SettingsIcon size={16} />
<span>{t.common.settings}</span>
</div>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarFooter>
<SidebarRail />
</Sidebar>
);