diff --git a/libs/hexagent_demo/backend/hexagent_api/config.py b/libs/hexagent_demo/backend/hexagent_api/config.py index 53696e60..869114a4 100644 --- a/libs/hexagent_demo/backend/hexagent_api/config.py +++ b/libs/hexagent_demo/backend/hexagent_api/config.py @@ -92,6 +92,7 @@ class AppConfig: sandbox: SandboxConfig = field(default_factory=SandboxConfig) disabled_skills: list[str] = field(default_factory=list) mcp_servers: list[McpServerConfig] = field(default_factory=list) + language: str = "en" @property def main_model(self) -> ModelConfig | None: @@ -120,7 +121,8 @@ def load_config() -> AppConfig: sandbox = SandboxConfig(**file_data["sandbox"]) if "sandbox" in file_data else SandboxConfig() disabled_skills = file_data.get("disabled_skills", []) mcp_servers = [McpServerConfig(**m) for m in file_data.get("mcp_servers", [])] - return AppConfig(models=models, main_model_id=main_id, fast_model_id=fast_id, agents=agents, tools=tools, sandbox=sandbox, disabled_skills=disabled_skills, mcp_servers=mcp_servers) + language = file_data.get("language", "en") + return AppConfig(models=models, main_model_id=main_id, fast_model_id=fast_id, agents=agents, tools=tools, sandbox=sandbox, disabled_skills=disabled_skills, mcp_servers=mcp_servers, language=language) # No config.json — return empty config (frontend will show setup flow) config = AppConfig() diff --git a/libs/hexagent_demo/backend/hexagent_api/routes/config.py b/libs/hexagent_demo/backend/hexagent_api/routes/config.py index f7b1ef26..3e56a389 100644 --- a/libs/hexagent_demo/backend/hexagent_api/routes/config.py +++ b/libs/hexagent_demo/backend/hexagent_api/routes/config.py @@ -82,6 +82,9 @@ async def put_config(body: dict[str, Any]) -> dict[str, Any]: if "mcp_servers" in body: current.mcp_servers = [McpServerConfig(**m) for m in body["mcp_servers"]] + if "language" in body: + current.language = body["language"] + save_config(current) # Invalidate cached agents so they pick up new config on next use diff --git a/libs/hexagent_demo/frontend/package-lock.json b/libs/hexagent_demo/frontend/package-lock.json index ec9d4b22..2a40d5b2 100644 --- a/libs/hexagent_demo/frontend/package-lock.json +++ b/libs/hexagent_demo/frontend/package-lock.json @@ -10,12 +10,14 @@ "dependencies": { "docx-preview": "^0.3.7", "echarts": "^6.0.0", + "i18next": "^25.10.10", "lucide-react": "^0.577.0", "mermaid": "^11.13.0", "pdfjs-dist": "^5.4.296", "pptx-viewer": "^0.1.0", "react": "^19.0.0", "react-dom": "^19.0.0", + "react-i18next": "^17.0.0", "react-markdown": "^9.0.1", "react-pdf": "^10.4.1", "react-syntax-highlighter": "^16.1.1", @@ -290,9 +292,9 @@ } }, "node_modules/@babel/runtime": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", - "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -4116,6 +4118,15 @@ "integrity": "sha512-PDEfEF102G23vHmPhLyPboFCD+BkMGu+GuJe2d9/eH4FsCwvgBpnc9n0pGE+ffKdph38s6foEZiEjdgHdzp+IA==", "license": "CC0-1.0" }, + "node_modules/html-parse-stringify": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", + "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==", + "license": "MIT", + "dependencies": { + "void-elements": "3.1.0" + } + }, "node_modules/html-url-attributes": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", @@ -4136,6 +4147,37 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/i18next": { + "version": "25.10.10", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-25.10.10.tgz", + "integrity": "sha512-cqUW2Z3EkRx7NqSyywjkgCLK7KLCL6IFVFcONG7nVYIJ3ekZ1/N5jUsihHV6Bq37NfhgtczxJcxduELtjTwkuQ==", + "funding": [ + { + "type": "individual", + "url": "https://www.locize.com/i18next" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + }, + { + "type": "individual", + "url": "https://www.locize.com" + } + ], + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2" + }, + "peerDependencies": { + "typescript": "^5 || ^6" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", @@ -5868,6 +5910,33 @@ "react": "^19.2.4" } }, + "node_modules/react-i18next": { + "version": "17.0.0", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.0.tgz", + "integrity": "sha512-L7aqwOePCExt6nlF7000lN2YKWnR7IpSpQId9sj01798Xn3LAncBdTHKl9lA/nr+YrG78BTqWPJxq9mlrrmH7Q==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "html-parse-stringify": "^3.0.1", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "i18next": ">= 25.10.10", + "react": ">= 16.8.0", + "typescript": "^5 || ^6" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, "node_modules/react-markdown": { "version": "9.1.0", "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-9.1.0.tgz", @@ -6419,7 +6488,7 @@ "version": "5.6.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -6587,6 +6656,15 @@ "punycode": "^2.1.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/utif2": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/utif2/-/utif2-4.1.0.tgz", @@ -6732,6 +6810,15 @@ } } }, + "node_modules/void-elements": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", + "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/vscode-jsonrpc": { "version": "8.2.0", "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz", diff --git a/libs/hexagent_demo/frontend/package.json b/libs/hexagent_demo/frontend/package.json index 3ce315d2..a33e4436 100644 --- a/libs/hexagent_demo/frontend/package.json +++ b/libs/hexagent_demo/frontend/package.json @@ -12,12 +12,14 @@ "dependencies": { "docx-preview": "^0.3.7", "echarts": "^6.0.0", + "i18next": "^25.10.10", "lucide-react": "^0.577.0", "mermaid": "^11.13.0", "pdfjs-dist": "^5.4.296", "pptx-viewer": "^0.1.0", "react": "^19.0.0", "react-dom": "^19.0.0", + "react-i18next": "^17.0.0", "react-markdown": "^9.0.1", "react-pdf": "^10.4.1", "react-syntax-highlighter": "^16.1.1", diff --git a/libs/hexagent_demo/frontend/src/App.tsx b/libs/hexagent_demo/frontend/src/App.tsx index 3da8d60b..6e40130e 100644 --- a/libs/hexagent_demo/frontend/src/App.tsx +++ b/libs/hexagent_demo/frontend/src/App.tsx @@ -1,4 +1,5 @@ import { useReducer, useEffect, useCallback, useRef, useState } from "react"; +import i18n from "./i18n"; import { AppContext, initialState, reducer } from "./store"; import { listConversations, createConversation, createWarmSession, deleteWarmSession, sendMessage, subscribeToStream, getServerConfig, getVMStatus, type ServerConfig } from "./api"; import { useSettings } from "./hooks/useSettings"; @@ -86,6 +87,10 @@ function App() { loadedConfig = cfg; dispatch({ type: "SET_SERVER_CONFIG", payload: cfg }); if (cfg.models.length === 0) setSetupNeeded(true); + // Sync language from backend config if it differs from local + if (cfg.language && cfg.language !== settings.language) { + setSettings((prev) => ({ ...prev, language: cfg.language })); + } }) .catch(() => {}); Promise.all([convP, cfgP]).then(() => { @@ -202,7 +207,7 @@ function App() { warmSessionPromiseRef.current = p; p.catch(() => { dispatch({ type: "SHOW_NOTIFICATION", payload: { - message: "Session setup failed. You can still send messages.", + message: i18n.t("misc:sessionSetupFailed"), type: "info", }}); }) @@ -418,7 +423,7 @@ function App() { doSendMessage(conv.id, content, options?.attachments); } catch { dispatch({ type: "REQUEST_END" }); - dispatch({ type: "SHOW_NOTIFICATION", payload: { message: "Failed to create conversation", type: "error" } }); + dispatch({ type: "SHOW_NOTIFICATION", payload: { message: i18n.t("misc:failedToCreateConversation"), type: "error" } }); } }, [dispatch, state.activeConversationId, state.isRequestPending, state.streamingByConversation, state.selectedModelId, state.selectedMode, state.warmSessionId, doSendMessage] diff --git a/libs/hexagent_demo/frontend/src/api.ts b/libs/hexagent_demo/frontend/src/api.ts index 6bc4dc43..da8a5950 100644 --- a/libs/hexagent_demo/frontend/src/api.ts +++ b/libs/hexagent_demo/frontend/src/api.ts @@ -1,4 +1,4 @@ -import type { Conversation } from "./types"; +import type { Conversation } from "./types"; const API_BASE = (() => { if (typeof window !== 'undefined' && window.electronAPI?.backendPort) { @@ -34,7 +34,7 @@ export async function getConversation(id: string): Promise { return res.json(); } -// ── Warm session (pre-conversation) ── +// 鈹€鈹€ Warm session (pre-conversation) 鈹€鈹€ export interface WarmSessionResponse { session_id: string; @@ -264,7 +264,7 @@ export function subscribeToStream( return controller; } -// ── File upload ── +// 鈹€鈹€ File upload 鈹€鈹€ export interface UploadResult { filename: string; @@ -295,7 +295,7 @@ export async function deleteChatFile(conversationId: string, filename: string): } } -// ── Folder picker ── +// 鈹€鈹€ Folder picker 鈹€鈹€ export async function browseFolder(): Promise { const res = await fetch(`${API_BASE}/api/browse-folder`, { method: "POST" }); @@ -304,7 +304,7 @@ export async function browseFolder(): Promise { return data.path || null; } -// ── Server config ── +// 鈹€鈹€ Server config 鈹€鈹€ export interface ModelConfig { id: string; @@ -359,13 +359,14 @@ export interface ServerConfig { tools: ToolsConfig; sandbox: SandboxConfig; mcp_servers: McpServerEntry[]; + language: string; } export async function getServerConfig(): Promise { const res = await fetch(`${API_BASE}/api/config`); if (!res.ok) throw new Error(`Failed to get config: ${res.statusText}`); const data = await res.json(); - return { agents: [], tools: { search_provider: "", search_api_key: "", fetch_provider: "jina", fetch_api_key: "" }, sandbox: { e2b_api_key: "", chat_enabled: false }, mcp_servers: [], ...data }; + return { agents: [], tools: { search_provider: "", search_api_key: "", fetch_provider: "jina", fetch_api_key: "" }, sandbox: { e2b_api_key: "", chat_enabled: false }, mcp_servers: [], language: "en", ...data }; } export async function updateServerConfig(config: ServerConfig): Promise { @@ -388,7 +389,7 @@ export async function testMcpConnection(server: McpServerEntry): Promise<{ ok: b return res.json(); } -// ── Skills ── +// 鈹€鈹€ Skills 鈹€鈹€ export interface SkillsList { public: string[]; @@ -446,7 +447,7 @@ export async function toggleSkill(name: string, enabled: boolean): Promise if (!res.ok) throw new Error(`Failed to toggle skill: ${res.statusText}`); } -// ── Setup / VM backend ── +// 鈹€鈹€ Setup / VM backend 鈹€鈹€ export interface VMStatus { supported: boolean; @@ -481,7 +482,7 @@ export function installVMBackend( }); } -// ── VM Build ── +// 鈹€鈹€ VM Build 鈹€鈹€ export interface VMBuildStatus { status: "idle" | "running" | "done" | "error"; @@ -562,7 +563,7 @@ export function buildVM( }); } -// ── VM Provision ── +// 鈹€鈹€ VM Provision 鈹€鈹€ export interface ProvisionStepDef { id: string; diff --git a/libs/hexagent_demo/frontend/src/components/ChatArea.tsx b/libs/hexagent_demo/frontend/src/components/ChatArea.tsx index 81144cdf..334c1f3d 100644 --- a/libs/hexagent_demo/frontend/src/components/ChatArea.tsx +++ b/libs/hexagent_demo/frontend/src/components/ChatArea.tsx @@ -1,5 +1,6 @@ -import { useState, useCallback, useRef, useEffect, useMemo } from "react"; +import { useState, useCallback, useRef, useEffect, useMemo } from "react"; import { PanelRight } from "lucide-react"; +import { useTranslation } from "react-i18next"; import { useAppContext } from "../store"; import { getVMStatus } from "../api"; import WelcomeScreen from "./WelcomeScreen"; @@ -18,6 +19,7 @@ interface ChatAreaProps { } export default function ChatArea({ conversation, onSendMessage, onOpenSettings, rightPanel }: ChatAreaProps) { + const { t } = useTranslation("chat"); const { state, dispatch } = useAppContext(); const [editingTitle, setEditingTitle] = useState(false); const chatAreaRef = useRef(null); @@ -89,7 +91,7 @@ export default function ChatArea({ conversation, onSendMessage, onOpenSettings, [dispatch] ); - // Keyboard shortcuts: Cmd/Ctrl+Shift+1 → Chat, Cmd/Ctrl+Shift+2 → Cowork + // Keyboard shortcuts: Cmd/Ctrl+Shift+1 鈫?Chat, Cmd/Ctrl+Shift+2 鈫?Cowork useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { const mod = isMac ? e.metaKey : e.ctrlKey; @@ -155,10 +157,10 @@ export default function ChatArea({ conversation, onSendMessage, onOpenSettings, onClick={() => handleModeChange(mode)} type="button" > - {mode === "chat" ? "Chat" : "Cowork"} + {t(`mode.${mode}`)} - {mode === "chat" ? "Chat" : "Cowork"} - {isMac ? "⇧⌘" : "Ctrl+Shift+"}{idx + 1} + {t(`mode.${mode}`)} + {isMac ? "鈬р寴" : "Ctrl+Shift+"}{idx + 1} ))} @@ -170,7 +172,7 @@ export default function ChatArea({ conversation, onSendMessage, onOpenSettings, diff --git a/libs/hexagent_demo/frontend/src/components/ChatInput.tsx b/libs/hexagent_demo/frontend/src/components/ChatInput.tsx index 088c68c8..8296f754 100644 --- a/libs/hexagent_demo/frontend/src/components/ChatInput.tsx +++ b/libs/hexagent_demo/frontend/src/components/ChatInput.tsx @@ -1,5 +1,6 @@ import { useState, useCallback, useRef, useEffect } from "react"; import { Paperclip, ArrowUp, ArrowDown, X, FileText, Loader2, CircleAlert } from "lucide-react"; +import { useTranslation } from "react-i18next"; import { useAppContext } from "../store"; import { uploadChatFile, deleteChatFile } from "../api"; import { useFileDrop } from "../hooks/useFileDrop"; @@ -37,6 +38,7 @@ function shouldShowScrollButton(container: HTMLElement, inputContainer: HTMLElem } export default function ChatInput({ conversationId, onSend, scrollContainerRef, onOpenSettings }: ChatInputProps) { + const { t } = useTranslation("chat"); const { state, dispatch } = useAppContext(); const noModels = !state.serverConfig?.models?.length; const missingE2bKey = state.selectedMode === "chat" && !state.serverConfig?.sandbox?.e2b_api_key; @@ -152,7 +154,7 @@ export default function ChatInput({ conversationId, onSend, scrollContainerRef, // Check for name collision against existing pending files const collision = pendingFilesRef.current.some((f) => f.name === file.name && f.status !== "failed"); if (collision) { - const error = `"${file.name}" already attached. Rename the file and try again.`; + const error = t("alreadyAttached", { filename: file.name }); setPendingFiles((prev) => [...prev, { id, name: file.name, status: "failed" as const, error }]); dispatch({ type: "SHOW_NOTIFICATION", payload: { message: error, type: "error" } }); return; @@ -167,13 +169,13 @@ export default function ChatInput({ conversationId, onSend, scrollContainerRef, ); }) .catch((err) => { - const message = err instanceof Error ? err.message : "Upload failed"; + const message = err instanceof Error ? err.message : t("uploadFailed"); setPendingFiles((prev) => prev.map((f) => f.id === id ? { ...f, status: "failed" as const, error: message } : f) ); dispatch({ type: "SHOW_NOTIFICATION", - payload: { message: `Failed to upload ${file.name}: ${message}`, type: "error" }, + payload: { message: t("failedToUpload", { filename: file.name, message }), type: "error" }, }); }); }, [conversationId, dispatch]); @@ -231,7 +233,7 @@ export default function ChatInput({ conversationId, onSend, scrollContainerRef, return (