Skip to content
Closed
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
696 changes: 445 additions & 251 deletions Cargo.lock

Large diffs are not rendered by default.

429 changes: 429 additions & 0 deletions README_CN.md

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions crates/openfang-api/src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,8 @@ pub async fn build_router(
.route("/", axum::routing::get(webchat::webchat_page))
.route("/logo.png", axum::routing::get(webchat::logo_png))
.route("/favicon.ico", axum::routing::get(webchat::favicon_ico))
.route("/locales/en.json", axum::routing::get(webchat::locale_en))
.route("/locales/zh-CN.json", axum::routing::get(webchat::locale_zh_cn))
.route(
"/api/metrics",
axum::routing::get(routes::prometheus_metrics),
Expand Down
30 changes: 30 additions & 0 deletions crates/openfang-api/src/webchat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ const LOGO_PNG: &[u8] = include_bytes!("../static/logo.png");
/// Embedded favicon ICO for browser tabs.
const FAVICON_ICO: &[u8] = include_bytes!("../static/favicon.ico");

/// Embedded locale files for i18n.
const LOCALE_EN: &str = include_str!("../static/locales/en.json");
const LOCALE_ZH_CN: &str = include_str!("../static/locales/zh-CN.json");

/// GET /logo.png — Serve the OpenFang logo.
pub async fn logo_png() -> impl IntoResponse {
(
Expand All @@ -46,6 +50,28 @@ pub async fn favicon_ico() -> impl IntoResponse {
)
}

/// GET /locales/en.json — Serve English locale file.
pub async fn locale_en() -> impl IntoResponse {
(
[
(header::CONTENT_TYPE, "application/json; charset=utf-8"),
(header::CACHE_CONTROL, "public, max-age=3600"),
],
LOCALE_EN,
)
}

/// GET /locales/zh-CN.json — Serve Chinese locale file.
pub async fn locale_zh_cn() -> impl IntoResponse {
(
[
(header::CONTENT_TYPE, "application/json; charset=utf-8"),
(header::CACHE_CONTROL, "public, max-age=3600"),
],
LOCALE_ZH_CN,
)
}

/// GET / — Serve the OpenFang Dashboard single-page application.
///
/// Returns the full SPA with ETag header based on package version for caching.
Expand Down Expand Up @@ -88,6 +114,10 @@ const WEBCHAT_HTML: &str = concat!(
"<script>\n",
include_str!("../static/vendor/highlight.min.js"),
"\n</script>\n",
// i18n module (must load before app.js)
"<script>\n",
include_str!("../static/js/i18n.js"),
"\n</script>\n",
// App code
"<script>\n",
include_str!("../static/js/api.js"),
Expand Down
22 changes: 22 additions & 0 deletions crates/openfang-api/static/css/components.css
Original file line number Diff line number Diff line change
Expand Up @@ -1271,6 +1271,28 @@ mark.search-highlight {
.theme-opt:hover { color: var(--text-primary); background: var(--bg-hover); }
.theme-opt.active { color: var(--accent); background: var(--accent-glow); }

/* Language Switcher */
.language-switcher {
display: inline-flex;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
overflow: hidden;
margin-left: 8px;
}
.lang-opt {
cursor: pointer;
padding: 4px 8px;
font-size: 12px;
font-weight: 500;
background: none;
border: none;
color: var(--text-muted);
transition: all 0.2s;
line-height: 1;
}
.lang-opt:hover { color: var(--text-primary); background: var(--bg-hover); }
.lang-opt.active { color: #fff; background: var(--accent); }

/* Utility */
.flex { display: flex; }
.flex-col { flex-direction: column; }
Expand Down
138 changes: 72 additions & 66 deletions crates/openfang-api/static/index_body.html

Large diffs are not rendered by default.

7 changes: 7 additions & 0 deletions crates/openfang-api/static/js/app.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
// OpenFang App — Alpine.js init, hash router, global store
'use strict';

// Initialize i18n (will auto-detect language from browser/localStorage)
if (typeof i18n !== 'undefined') {
i18n.init().then(function(lang) {
console.log('OpenFang i18n initialized:', lang);
});
}

// Marked.js configuration
if (typeof marked !== 'undefined') {
marked.setOptions({
Expand Down
223 changes: 223 additions & 0 deletions crates/openfang-api/static/js/i18n.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
/**
* OpenFang Web UI - Internationalization (i18n) Module
*
* A lightweight i18n system for the OpenFang dashboard.
* Loads translations from JSON files and provides simple API for localization.
*/

(function(global) {
'use strict';

// Supported languages
const SUPPORTED_LANGUAGES = ['en', 'zh-CN'];
const DEFAULT_LANGUAGE = 'en';

// Current language and translations cache
let currentLanguage = DEFAULT_LANGUAGE;
let translations = {};
let isLoaded = false;

/**
* Detect user's preferred language from browser settings
*/
function detectLanguage() {
// Check localStorage first
const stored = localStorage.getItem('openfang-language');
if (stored && SUPPORTED_LANGUAGES.includes(stored)) {
return stored;
}

// Check browser language
const browserLang = navigator.language || navigator.userLanguage;

// Exact match
if (SUPPORTED_LANGUAGES.includes(browserLang)) {
return browserLang;
}

// Check language prefix (e.g., 'zh' for 'zh-CN', 'zh-TW')
const langPrefix = browserLang.split('-')[0];
const match = SUPPORTED_LANGUAGES.find(lang => lang.startsWith(langPrefix));
if (match) {
return match;
}

return DEFAULT_LANGUAGE;
}

/**
* Load translations for a specific language
*/
async function loadTranslations(lang) {
try {
const response = await fetch(`/locales/${lang}.json`);
if (!response.ok) {
console.warn(`Failed to load translations for ${lang}, falling back to ${DEFAULT_LANGUAGE}`);
if (lang !== DEFAULT_LANGUAGE) {
return loadTranslations(DEFAULT_LANGUAGE);
}
return {};
}
return await response.json();
} catch (error) {
console.error(`Error loading translations for ${lang}:`, error);
if (lang !== DEFAULT_LANGUAGE) {
return loadTranslations(DEFAULT_LANGUAGE);
}
return {};
}
}

/**
* Get nested value from object using dot notation
*/
function getNestedValue(obj, path) {
return path.split('.').reduce((current, key) => {
return current && current[key] !== undefined ? current[key] : null;
}, obj);
}

/**
* Replace placeholders in a string with values
* Supports {key} syntax
*/
function replacePlaceholders(str, params) {
if (!params || typeof str !== 'string') return str;
return str.replace(/\{(\w+)\}/g, (match, key) => {
return params[key] !== undefined ? params[key] : match;
});
}

/**
* Translate a key to the current language
* @param {string} key - Translation key (e.g., 'nav.chat')
* @param {object} params - Optional parameters for placeholders
* @returns {string} Translated string or key if not found
*/
function t(key, params) {
const value = getNestedValue(translations, key);
if (value === null) {
console.warn(`Missing translation for key: ${key}`);
return `[${key}]`;
}
return replacePlaceholders(value, params);
}

/**
* Initialize i18n with optional language override
*/
async function init(lang) {
currentLanguage = lang || detectLanguage();
translations = await loadTranslations(currentLanguage);
isLoaded = true;

// Store the language preference
localStorage.setItem('openfang-language', currentLanguage);

// Update DOM elements with data-i18n attribute
updateDOM();

// Dispatch event for components that need to react
window.dispatchEvent(new CustomEvent('i18n-loaded', {
detail: { language: currentLanguage }
}));

return currentLanguage;
}

/**
* Change the current language
*/
async function setLanguage(lang) {
if (!SUPPORTED_LANGUAGES.includes(lang)) {
console.warn(`Unsupported language: ${lang}`);
return false;
}

currentLanguage = lang;
translations = await loadTranslations(lang);
localStorage.setItem('openfang-language', lang);

updateDOM();

window.dispatchEvent(new CustomEvent('i18n-changed', {
detail: { language: lang }
}));

return true;
}

/**
* Get the current language
*/
function getLanguage() {
return currentLanguage;
}

/**
* Get list of supported languages
*/
function getSupportedLanguages() {
return [...SUPPORTED_LANGUAGES];
}

/**
* Update all DOM elements with data-i18n attribute
*/
function updateDOM() {
document.querySelectorAll('[data-i18n]').forEach(element => {
const key = element.getAttribute('data-i18n');
const translated = t(key);
if (!translated.startsWith('[')) {
element.textContent = translated;
}
});

// Update elements with data-i18n-placeholder attribute
document.querySelectorAll('[data-i18n-placeholder]').forEach(element => {
const key = element.getAttribute('data-i18n-placeholder');
const translated = t(key);
if (!translated.startsWith('[')) {
element.placeholder = translated;
}
});

// Update elements with data-i18n-title attribute
document.querySelectorAll('[data-i18n-title]').forEach(element => {
const key = element.getAttribute('data-i18n-title');
const translated = t(key);
if (!translated.startsWith('[')) {
element.title = translated;
}
});
}

/**
* Check if translations are loaded
*/
function isReady() {
return isLoaded;
}

// Export the i18n API
const i18n = {
init,
t,
setLanguage,
getLanguage,
getSupportedLanguages,
updateDOM,
isReady,
DEFAULT_LANGUAGE,
SUPPORTED_LANGUAGES
};

// Make it globally available
global.i18n = i18n;

// Also support ES module-like access
if (typeof module !== 'undefined' && module.exports) {
module.exports = i18n;
}

})(typeof window !== 'undefined' ? window : this);
27 changes: 27 additions & 0 deletions crates/openfang-api/static/js/pages/agents.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ function agentsPage() {
return {
tab: 'agents',
activeChatAgent: null,
// -- i18n state for reactivity --
_currentLang: typeof i18n !== 'undefined' ? i18n.getLanguage() : 'en',
// -- Agents state --
showSpawnModal: false,
showDetailModal: false,
Expand Down Expand Up @@ -168,6 +170,31 @@ function agentsPage() {
}
],

// ── Localized Templates (i18n support) ──
get localizedTemplates() {
var self = this;
// Access _currentLang to trigger reactivity on language change
var lang = this._currentLang;
if (typeof i18n === 'undefined' || lang === 'en') {
return this.builtinTemplates;
}
return this.builtinTemplates.map(function(t) {
var key = t.name.replace(/\s+/g, '');
var translatedName = i18n.t('template.' + key + '.name');
var translatedDesc = i18n.t('template.' + key + '.desc');
var translatedCategory = i18n.t('category.' + t.category.toLowerCase());
return {
name: translatedName && !translatedName.startsWith('[') ? translatedName : t.name,
description: translatedDesc && !translatedDesc.startsWith('[') ? translatedDesc : t.description,
category: translatedCategory && !translatedCategory.startsWith('[') ? translatedCategory : t.category,
provider: t.provider,
model: t.model,
profile: t.profile,
system_prompt: t.system_prompt
};
});
},

// ── Profile Descriptions ──
profileDescriptions: {
minimal: { label: 'Minimal', desc: 'Read-only file access' },
Expand Down
Loading