Compare commits

...

9 Commits

Author SHA1 Message Date
Simian Astronaut 7
b890b7da74 fix(web): add root redirect to dashboard and fix header z-index layering
Add permanent redirect from / to /_app/ so the gateway root serves
the dashboard. Fix z-index stacking in Layout so the header dropdown
menus render above main content.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 03:41:31 -04:00
Simian Astronaut 7
ac63a4d16a refactor(web): decompose i18n module into smaller focused files
Split the monolithic i18n.ts (~1700 lines) into separate modules:
- types.ts: type definitions
- languages.ts: language options and helpers
- translate.ts: locale get/set and translation logic
- locales/: one file per locale
- index.ts: re-exports for backward-compatible imports

Update LanguageSelector to use option.label directly and remove
unused getLanguageOptionLabel import and id attribute.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 03:41:23 -04:00
Simian Astronaut 7
149165fa45 build: add build.rs to compile web dashboard during cargo build
Integrates the web dashboard build into the Rust build pipeline.
Runs npm install (if needed) and npm run build, with graceful
fallback warnings if Node.js tooling is unavailable.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 03:41:16 -04:00
Simian Astronaut 7
47d46f90dd chore(web): remove committed dist/ artifacts and gitignore web/dist/
Build outputs should not be tracked in version control. Remove the
committed web/dist/ files and add web/dist/ to .gitignore.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 03:41:10 -04:00
argenis de la rosa
183069d87f fix(web): make sidebar navigation items reactive to locale changes
The sidebar was using t() which references a module-level currentLocale variable
that doesn't trigger re-renders when the locale changes. Updated to use the
LocaleContext and tLocale() so sidebar navigation items update immediately
when the user selects a different language.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 16:48:07 -04:00
argenis de la rosa
c280ae5045 fix(web): address CodeRabbit review feedback
Fixes issues identified in PR #3076:

1. mock-server.mjs: Fix HTTP line ending escape sequence
   - Changed `\\r\\n\\r\\n` to `\r\n\r\n` for proper HTTP CRLF terminators

2. App.tsx: Add accessibility attributes to pairing form
   - Added aria-label, aria-invalid, aria-describedby to input
   - Added id="pairing-error" and role="alert" to error message

3. Header.tsx: Add accessible name to logout button
   - Added aria-label for screen readers on mobile (icon-only) view

4. Layout.tsx: Guard localStorage access with try-catch
   - Prevents runtime errors when storage is blocked/unavailable

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 16:43:08 -04:00
argenis de la rosa
3f5c57634b feat(web): replace native language selects with custom dropdown and fix RTL text alignment
This commit modernizes the language selector UI across the ZeroClaw web dashboard by replacing
native <select> elements with a shared custom dropdown component featuring styled flag icons,
proper RTL/LTR text direction support, and consistent left-aligned text for all languages.

Changes:
- Add LanguageSelector component with custom dropdown, flag badges, and check mark for selected option
- Wire document direction updates on locale change for Arabic, Hebrew, Urdu, and other RTL languages
- Fix RTL text alignment in dropdown options by applying dir attribute only to text spans
- Update pairing dialog and authenticated header to use the shared LanguageSelector
- Add locale metadata helpers: getLanguageOption, getLanguageOptionLabel, getLocaleDirection, applyLocaleToDocument
- Add Vitest configuration and unit tests for i18n helpers
- Add Playwright E2E tests verifying all 31 locales with flag visibility and lang/dir attributes

Testing:
- Unit tests: 5 passed (npm run test:unit)
- Build: passed (npm run build)
- E2E tests: 34 passed (npx playwright test)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 13:50:21 -04:00
argenis de la rosa
c56c66a21d Revert "feat(web): port electric dashboard UI from source repo"
This reverts commit b248d40abc.
2026-03-07 17:56:15 -05:00
argenis de la rosa
b248d40abc feat(web): port electric dashboard UI from source repo 2026-03-07 17:37:46 -05:00
70 changed files with 4350 additions and 1248 deletions

1
.gitignore vendored
View File

@ -1,5 +1,6 @@
/target
firmware/*/target
web/dist/
*.db
*.db-journal
.DS_Store

47
build.rs Normal file
View File

@ -0,0 +1,47 @@
use std::process::Command;
fn main() {
// Re-run if web source files change
println!("cargo:rerun-if-changed=web/src/");
println!("cargo:rerun-if-changed=web/index.html");
println!("cargo:rerun-if-changed=web/package.json");
println!("cargo:rerun-if-changed=web/vite.config.ts");
println!("cargo:rerun-if-changed=web/tsconfig.json");
let web_dir = std::path::Path::new("web");
// Skip if node_modules not installed
if !web_dir.join("node_modules").exists() {
let status = Command::new("npm")
.args(["install"])
.current_dir(web_dir)
.status();
match status {
Ok(s) if s.success() => {}
Ok(s) => {
eprintln!("warning: `npm install` exited with {s}; web dashboard may be stale");
return;
}
Err(e) => {
eprintln!("warning: could not run `npm install`: {e}; web dashboard may be stale");
return;
}
}
}
let status = Command::new("npm")
.args(["run", "build"])
.current_dir(web_dir)
.status();
match status {
Ok(s) if s.success() => {}
Ok(s) => {
eprintln!("warning: `npm run build` exited with {s}; web dashboard may be stale");
}
Err(e) => {
eprintln!("warning: could not run `npm run build`: {e}; web dashboard may be stale");
}
}
}

View File

@ -689,6 +689,10 @@ pub async fn run_gateway(host: &str, port: u16, config: Config) -> Result<()> {
.route("/ws/chat", get(ws::handle_ws_chat))
// ── Static assets (web dashboard) ──
.route("/_app/{*path}", get(static_files::handle_static))
// ── Root redirect to dashboard ──
.route("/", get(|| async {
axum::response::Redirect::permanent("/_app/")
}))
// ── Config PUT with larger body limit ──
.merge(config_put_router)
.with_state(state)

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

14
web/dist/index.html vendored
View File

@ -1,14 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="dark" />
<title>ZeroClaw</title>
<script type="module" crossorigin src="/_app/assets/index-Dam-egf7.js"></script>
<link rel="stylesheet" crossorigin href="/_app/assets/index-DEhGL4Jw.css">
</head>
<body>
<div id="root"></div>
</body>
</html>

60
web/e2e/dashboard.spec.ts Normal file
View File

@ -0,0 +1,60 @@
import { expect, test } from '@playwright/test';
test.beforeEach(async ({ page }) => {
await page.addInitScript(() => {
window.localStorage.setItem('zeroclaw_token', 'test-token');
window.localStorage.setItem('zeroclaw:locale', 'en');
});
});
test('dashboard end-to-end flow stays healthy', async ({ page }) => {
await page.goto('/_app/');
await expect(page.getByText('Electric Runtime Dashboard')).toBeVisible();
await page.goto('/_app/agent');
await expect(page.getByPlaceholder('Type a message...')).toBeVisible();
await page.getByTestId('chat-input').fill('hello from e2e');
await page.keyboard.press('Enter');
await expect(page.getByText('Echo: hello from e2e')).toBeVisible();
await page.goto('/_app/tools');
await page.getByRole('button', { name: /shell/i }).first().click();
await expect(page.getByText('Parameter Schema')).toBeVisible();
await page.goto('/_app/cron');
await page.getByRole('button', { name: 'Add Job' }).click();
await page.getByPlaceholder('e.g. Daily cleanup').fill('Morning job');
await page.getByPlaceholder('e.g. 0 0 * * * (cron expression)').fill('0 8 * * *');
await page.getByPlaceholder('e.g. cleanup --older-than 7d').fill('sync --morning');
await page.getByRole('button', { name: 'Add Job' }).last().click();
await expect(page.getByText('Morning job')).toBeVisible();
await page.goto('/_app/integrations');
await expect(page.getByText('Discord')).toBeVisible();
await page.goto('/_app/memory');
await page.getByRole('button', { name: 'Add Memory' }).click();
await page.getByPlaceholder('e.g. user_preferences').fill('favorite_editor');
await page.getByPlaceholder('Memory content...').fill('neovim');
await page.getByPlaceholder('e.g. preferences, context, facts').fill('preferences');
await page.getByRole('button', { name: 'Save' }).last().click();
await expect(page.getByText('favorite_editor')).toBeVisible();
await page.goto('/_app/config');
await page.getByRole('button', { name: 'Save' }).click();
await expect(page.getByText('Configuration saved successfully.')).toBeVisible();
await page.goto('/_app/cost');
await expect(page.getByText('Token Statistics')).toBeVisible();
await expect(page.getByText('anthropic/claude-sonnet-4.6')).toBeVisible();
await page.goto('/_app/logs');
await expect(page.getByText('Scheduler heartbeat ok.', { exact: false })).toBeVisible();
await page.getByRole('button', { name: 'Pause' }).click();
await expect(page.getByRole('button', { name: 'Resume' })).toBeVisible();
await page.goto('/_app/doctor');
await page.getByRole('button', { name: 'Run Diagnostics' }).click();
await expect(page.getByText('Configuration looks healthy.')).toBeVisible();
await expect(page.getByText('Webhook endpoint is not configured.')).toBeVisible();
});

118
web/e2e/locales.spec.ts Normal file
View File

@ -0,0 +1,118 @@
import { expect, test, type Page } from '@playwright/test';
import { getLanguageOption, getLocaleDirection, LANGUAGE_OPTIONS, LANGUAGE_SWITCH_ORDER, tLocale, type Locale } from '../src/lib/i18n';
const routes: Array<{
path: string;
titleKey: string;
assertion?: (page: Page, locale: Locale) => Promise<void>;
}> = [
{
path: '/_app/',
titleKey: 'nav.dashboard',
assertion: (page, locale) => expect(page.getByText(tLocale('dashboard.hero_title', locale), { exact: false })).toBeVisible(),
},
{
path: '/_app/agent',
titleKey: 'nav.agent',
assertion: (page, locale) => expect(page.getByPlaceholder(tLocale('agent.placeholder', locale))).toBeVisible(),
},
{
path: '/_app/tools',
titleKey: 'nav.tools',
assertion: (page, locale) => expect(page.getByPlaceholder(tLocale('tools.search', locale))).toBeVisible(),
},
{
path: '/_app/cron',
titleKey: 'nav.cron',
assertion: (page, locale) => expect(page.getByRole('button', { name: tLocale('cron.add', locale) })).toBeVisible(),
},
{ path: '/_app/integrations', titleKey: 'nav.integrations' },
{
path: '/_app/memory',
titleKey: 'nav.memory',
assertion: (page, locale) => expect(page.getByRole('button', { name: tLocale('memory.add_memory', locale) })).toBeVisible(),
},
{
path: '/_app/config',
titleKey: 'nav.config',
assertion: (page, locale) => expect(page.getByRole('button', { name: tLocale('config.save', locale) })).toBeVisible(),
},
{
path: '/_app/cost',
titleKey: 'nav.cost',
assertion: (page, locale) => expect(page.getByText(tLocale('cost.token_statistics', locale), { exact: false })).toBeVisible(),
},
{
path: '/_app/logs',
titleKey: 'nav.logs',
assertion: (page, locale) => expect(page.getByText(tLocale('logs.title', locale), { exact: false })).toBeVisible(),
},
{
path: '/_app/doctor',
titleKey: 'nav.doctor',
assertion: (page, locale) => expect(page.getByRole('heading', { name: tLocale('doctor.title', locale) }).first()).toBeVisible(),
},
];
async function chooseLocale(page: Page, locale: Locale) {
if (await page.locator('html').getAttribute('lang') === locale) {
await expect(page.getByTestId('locale-flag')).toHaveText(getLanguageOption(locale).flag);
return;
}
await page.getByTestId('locale-select').click();
await expect(page.getByTestId('locale-menu')).toBeVisible();
await page.getByTestId(`locale-option-${locale}`).click();
await expect(page.getByTestId('locale-menu')).toBeHidden();
}
test.describe('localization', () => {
test('locale selector lists every configured language', async ({ page }) => {
await page.goto('/_app/');
await page.getByTestId('locale-select').click();
await expect(page.getByTestId('locale-menu')).toBeVisible();
for (const option of LANGUAGE_OPTIONS) {
const optionLocator = page.getByTestId(`locale-option-${option.value}`);
await expect(optionLocator).toContainText(option.flag);
await expect(optionLocator).toContainText(option.label);
}
});
test('pairing screen supports every locale', async ({ page }) => {
await page.goto('/_app/');
for (const locale of LANGUAGE_SWITCH_ORDER) {
await chooseLocale(page, locale);
await expect(page.getByTestId('locale-flag')).toHaveText(getLanguageOption(locale).flag);
await expect(page.locator('html')).toHaveAttribute('lang', locale);
await expect(page.locator('html')).toHaveAttribute('dir', getLocaleDirection(locale));
await expect(page.locator('body')).toHaveAttribute('dir', getLocaleDirection(locale));
await expect(page.getByTestId('pair-button')).toHaveText(tLocale('auth.pair_button', locale));
await expect(page.getByPlaceholder(tLocale('auth.code_placeholder', locale))).toBeVisible();
await expect(page.getByText(tLocale('auth.enter_code', locale))).toBeVisible();
}
});
for (const locale of LANGUAGE_SWITCH_ORDER) {
test(`authenticated dashboard shell localizes cleanly for ${locale}`, async ({ page }) => {
await page.addInitScript((selectedLocale) => {
window.localStorage.setItem('zeroclaw_token', 'test-token');
window.localStorage.setItem('zeroclaw:locale', selectedLocale);
}, locale);
for (const route of routes) {
await page.goto(route.path);
await chooseLocale(page, locale);
await expect(page.getByTestId('locale-flag')).toHaveText(getLanguageOption(locale).flag);
await expect(page.locator('html')).toHaveAttribute('lang', locale);
await expect(page.locator('html')).toHaveAttribute('dir', getLocaleDirection(locale));
await expect(page.locator('body')).toHaveAttribute('dir', getLocaleDirection(locale));
await expect(page.locator('header h1')).toHaveText(tLocale(route.titleKey, locale));
if (route.assertion) {
await route.assertion(page, locale);
}
}
});
}
});

353
web/e2e/mock-server.mjs Normal file
View File

@ -0,0 +1,353 @@
import http from 'node:http';
import { WebSocketServer } from 'ws';
const PORT = Number(process.env.PW_API_PORT ?? '4174');
const VALID_TOKEN = 'test-token';
const VALID_CODE = '123456';
let cronJobs = [
{
id: 'job-alpha',
name: 'nightly sync',
command: 'sync --nightly',
next_run: '2026-03-10T00:00:00.000Z',
last_run: '2026-03-09T00:00:00.000Z',
last_status: 'ok',
enabled: true,
},
];
let memoryEntries = [
{
id: 'memory-1',
key: 'workspace_mode',
content: 'Dashboard E2E workspace is active.',
category: 'context',
timestamp: '2026-03-09T12:00:00.000Z',
session_id: 'session-1',
score: 0.99,
},
];
const healthSnapshot = {
pid: 4242,
updated_at: '2026-03-09T12:00:00.000Z',
uptime_seconds: 12345,
components: {
gateway: {
status: 'ok',
updated_at: '2026-03-09T12:00:00.000Z',
last_ok: '2026-03-09T12:00:00.000Z',
last_error: null,
restart_count: 0,
},
scheduler: {
status: 'ok',
updated_at: '2026-03-09T12:00:00.000Z',
last_ok: '2026-03-09T12:00:00.000Z',
last_error: null,
restart_count: 1,
},
},
};
function sendJson(res, statusCode, body) {
res.writeHead(statusCode, {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
});
res.end(JSON.stringify(body));
}
function unauthorized(res) {
sendJson(res, 401, { error: 'Unauthorized' });
}
function readBody(req) {
return new Promise((resolve, reject) => {
let data = '';
req.on('data', (chunk) => {
data += chunk;
});
req.on('end', () => resolve(data));
req.on('error', reject);
});
}
function isAuthorized(req) {
const authHeader = req.headers.authorization;
if (authHeader === `Bearer ${VALID_TOKEN}`) {
return true;
}
const url = new URL(req.url, `http://${req.headers.host}`);
return url.searchParams.get('token') === VALID_TOKEN;
}
const server = http.createServer(async (req, res) => {
const url = new URL(req.url, `http://${req.headers.host}`);
if (req.method === 'GET' && url.pathname === '/health') {
return sendJson(res, 200, { require_pairing: true, paired: false });
}
if (req.method === 'POST' && url.pathname === '/pair') {
if (req.headers['x-pairing-code'] !== VALID_CODE) {
return sendJson(res, 400, { error: 'Invalid pairing code' });
}
return sendJson(res, 200, { token: VALID_TOKEN });
}
if (url.pathname.startsWith('/api') && !isAuthorized(req)) {
return unauthorized(res);
}
if (req.method === 'GET' && url.pathname === '/api/status') {
return sendJson(res, 200, {
provider: 'openrouter',
model: 'anthropic/claude-sonnet-4.6',
temperature: 0.7,
uptime_seconds: 12345,
gateway_port: PORT,
locale: 'en',
memory_backend: 'sqlite',
paired: true,
channels: {
discord: true,
telegram: false,
slack: false,
},
health: healthSnapshot,
});
}
if (req.method === 'GET' && url.pathname === '/api/health') {
return sendJson(res, 200, { health: healthSnapshot });
}
if (req.method === 'GET' && url.pathname === '/api/config') {
return sendJson(res, 200, {
format: 'toml',
content: [
'default_provider = "openrouter"',
'default_model = "anthropic/claude-sonnet-4.6"',
'',
'[gateway]',
`port = ${PORT}`,
].join('\n'),
});
}
if (req.method === 'PUT' && url.pathname === '/api/config') {
await readBody(req);
return sendJson(res, 200, { status: 'ok' });
}
if (req.method === 'GET' && url.pathname === '/api/tools') {
return sendJson(res, 200, {
tools: [
{
name: 'shell',
description: 'Execute shell commands in the active workspace.',
parameters: { type: 'object', properties: { command: { type: 'string' } } },
},
{
name: 'memory_store',
description: 'Persist a memory entry.',
parameters: { type: 'object', properties: { key: { type: 'string' } } },
},
],
});
}
if (req.method === 'GET' && url.pathname === '/api/cli-tools') {
return sendJson(res, 200, {
cli_tools: [
{ name: 'git', path: '/usr/bin/git', version: '2.39.0', category: 'vcs' },
{ name: 'cargo', path: '/usr/bin/cargo', version: '1.86.0', category: 'rust' },
],
});
}
if (req.method === 'GET' && url.pathname === '/api/cron') {
return sendJson(res, 200, { jobs: cronJobs });
}
if (req.method === 'POST' && url.pathname === '/api/cron') {
const raw = await readBody(req);
const body = JSON.parse(raw || '{}');
const job = {
id: `job-${Date.now()}`,
name: body.name ?? null,
command: body.command,
next_run: '2026-03-11T00:00:00.000Z',
last_run: null,
last_status: null,
enabled: body.enabled ?? true,
};
cronJobs = [...cronJobs, job];
return sendJson(res, 200, { status: 'ok', job });
}
if (req.method === 'DELETE' && url.pathname.startsWith('/api/cron/')) {
const id = decodeURIComponent(url.pathname.split('/').pop());
cronJobs = cronJobs.filter((job) => job.id !== id);
res.writeHead(204);
return res.end();
}
if (req.method === 'GET' && url.pathname === '/api/integrations') {
return sendJson(res, 200, {
integrations: [
{
name: 'Discord',
description: 'Send notifications and respond in channels.',
category: 'chat',
status: 'Active',
},
{
name: 'GitHub',
description: 'Track pull requests and issues.',
category: 'devops',
status: 'Available',
},
{
name: 'Linear',
description: 'Sync roadmap items and tasks.',
category: 'project',
status: 'ComingSoon',
},
],
});
}
if (req.method === 'POST' && url.pathname === '/api/doctor') {
return sendJson(res, 200, {
results: [
{ severity: 'ok', category: 'config', message: 'Configuration looks healthy.' },
{ severity: 'warn', category: 'network', message: 'Webhook endpoint is not configured.' },
],
});
}
if (req.method === 'GET' && url.pathname === '/api/memory') {
const query = url.searchParams.get('query')?.toLowerCase() ?? '';
const category = url.searchParams.get('category') ?? '';
const entries = memoryEntries.filter((entry) => {
const matchesQuery = !query
|| entry.key.toLowerCase().includes(query)
|| entry.content.toLowerCase().includes(query);
const matchesCategory = !category || entry.category === category;
return matchesQuery && matchesCategory;
});
return sendJson(res, 200, { entries });
}
if (req.method === 'POST' && url.pathname === '/api/memory') {
const raw = await readBody(req);
const body = JSON.parse(raw || '{}');
const entry = {
id: `memory-${Date.now()}`,
key: body.key,
content: body.content,
category: body.category || 'notes',
timestamp: new Date().toISOString(),
session_id: 'session-e2e',
score: 1,
};
memoryEntries = [entry, ...memoryEntries];
return sendJson(res, 200, { status: 'ok' });
}
if (req.method === 'DELETE' && url.pathname.startsWith('/api/memory/')) {
const key = decodeURIComponent(url.pathname.split('/').pop());
memoryEntries = memoryEntries.filter((entry) => entry.key !== key);
res.writeHead(204);
return res.end();
}
if (req.method === 'GET' && url.pathname === '/api/cost') {
return sendJson(res, 200, {
cost: {
session_cost_usd: 0.0132,
daily_cost_usd: 0.1024,
monthly_cost_usd: 2.3811,
total_tokens: 48231,
request_count: 128,
by_model: {
'anthropic/claude-sonnet-4.6': {
model: 'anthropic/claude-sonnet-4.6',
cost_usd: 1.8123,
total_tokens: 35123,
request_count: 84,
},
'openai/gpt-4o-mini': {
model: 'openai/gpt-4o-mini',
cost_usd: 0.5688,
total_tokens: 13108,
request_count: 44,
},
},
},
});
}
if (req.method === 'GET' && url.pathname === '/api/events') {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
});
const push = (payload) => {
res.write(`event: ${payload.type}\n`);
res.write(`data: ${JSON.stringify(payload)}\n\n`);
};
push({ type: 'status', timestamp: new Date().toISOString(), message: 'Event stream connected.' });
const interval = setInterval(() => {
push({ type: 'health', timestamp: new Date().toISOString(), message: 'Scheduler heartbeat ok.' });
}, 750);
req.on('close', () => {
clearInterval(interval);
res.end();
});
return;
}
sendJson(res, 404, { error: 'Not found' });
});
const wsServer = new WebSocketServer({ noServer: true });
wsServer.on('connection', (socket) => {
socket.send(JSON.stringify({ type: 'message', content: 'Connected to mock ZeroClaw runtime.' }));
socket.on('message', (raw) => {
const message = JSON.parse(String(raw));
if (message.type !== 'message') {
return;
}
socket.send(JSON.stringify({ type: 'chunk', content: 'Echo: ' }));
socket.send(JSON.stringify({ type: 'done', content: `Echo: ${message.content}` }));
});
});
server.on('upgrade', (req, socket, head) => {
const url = new URL(req.url, `http://${req.headers.host}`);
if (url.pathname !== '/ws/chat' || !isAuthorized(req)) {
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
socket.destroy();
return;
}
wsServer.handleUpgrade(req, socket, head, (client) => {
wsServer.emit('connection', client, req);
});
});
server.listen(PORT, '127.0.0.1', () => {
console.log(`Mock backend listening on http://127.0.0.1:${PORT}`);
});

435
web/package-lock.json generated
View File

@ -7,6 +7,7 @@
"": {
"name": "zeroclaw-web",
"version": "0.1.0",
"license": "(MIT OR Apache-2.0)",
"dependencies": {
"lucide-react": "^0.468.0",
"react": "^19.0.0",
@ -14,6 +15,7 @@
"react-router-dom": "^7.1.1"
},
"devDependencies": {
"@playwright/test": "^1.58.2",
"@tailwindcss/vite": "^4.0.0",
"@types/node": "^25.3.0",
"@types/react": "^19.0.7",
@ -21,7 +23,9 @@
"@vitejs/plugin-react": "^4.3.4",
"tailwindcss": "^4.0.0",
"typescript": "~5.7.2",
"vite": "^6.0.7"
"vite": "^6.0.7",
"vitest": "^4.0.18",
"ws": "^8.19.0"
}
},
"node_modules/@babel/code-frame": {
@ -798,6 +802,22 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@playwright/test": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
"integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.58.2"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.27",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
@ -1155,6 +1175,13 @@
"win32"
]
},
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"dev": true,
"license": "MIT"
},
"node_modules/@tailwindcss/node": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.0.tgz",
@ -1472,6 +1499,24 @@
"@babel/types": "^7.28.2"
}
},
"node_modules/@types/chai": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
"integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/deep-eql": "*",
"assertion-error": "^2.0.1"
}
},
"node_modules/@types/deep-eql": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
"integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@ -1530,6 +1575,127 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
}
},
"node_modules/@vitest/expect": {
"version": "4.0.18",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz",
"integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@types/chai": "^5.2.2",
"@vitest/spy": "4.0.18",
"@vitest/utils": "4.0.18",
"chai": "^6.2.1",
"tinyrainbow": "^3.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/mocker": {
"version": "4.0.18",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz",
"integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "4.0.18",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.21"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"msw": "^2.4.9",
"vite": "^6.0.0 || ^7.0.0-0"
},
"peerDependenciesMeta": {
"msw": {
"optional": true
},
"vite": {
"optional": true
}
}
},
"node_modules/@vitest/pretty-format": {
"version": "4.0.18",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz",
"integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==",
"dev": true,
"license": "MIT",
"dependencies": {
"tinyrainbow": "^3.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/runner": {
"version": "4.0.18",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.18.tgz",
"integrity": "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/utils": "4.0.18",
"pathe": "^2.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/snapshot": {
"version": "4.0.18",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.18.tgz",
"integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.0.18",
"magic-string": "^0.30.21",
"pathe": "^2.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/spy": {
"version": "4.0.18",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz",
"integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/utils": {
"version": "4.0.18",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz",
"integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.0.18",
"tinyrainbow": "^3.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/assertion-error": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/baseline-browser-mapping": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz",
@ -1598,6 +1764,16 @@
],
"license": "CC-BY-4.0"
},
"node_modules/chai": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
"integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/convert-source-map": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
@ -1674,6 +1850,13 @@
"node": ">=10.13.0"
}
},
"node_modules/es-module-lexer": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
"integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
"dev": true,
"license": "MIT"
},
"node_modules/esbuild": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
@ -1726,6 +1909,26 @@
"node": ">=6"
}
},
"node_modules/estree-walker": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0"
}
},
"node_modules/expect-type": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
"integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@ -2142,6 +2345,24 @@
"dev": true,
"license": "MIT"
},
"node_modules/obug": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
"integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==",
"dev": true,
"funding": [
"https://github.com/sponsors/sxzz",
"https://opencollective.com/debug"
],
"license": "MIT"
},
"node_modules/pathe": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
"dev": true,
"license": "MIT"
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@ -2162,6 +2383,53 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/playwright": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.58.2"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/postcss": {
"version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
@ -2327,6 +2595,13 @@
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
"license": "MIT"
},
"node_modules/siginfo": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
"dev": true,
"license": "ISC"
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@ -2337,6 +2612,20 @@
"node": ">=0.10.0"
}
},
"node_modules/stackback": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
"dev": true,
"license": "MIT"
},
"node_modules/std-env": {
"version": "3.10.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
"integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
"dev": true,
"license": "MIT"
},
"node_modules/tailwindcss": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.0.tgz",
@ -2358,6 +2647,23 @@
"url": "https://opencollective.com/webpack"
}
},
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
"integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
"dev": true,
"license": "MIT"
},
"node_modules/tinyexec": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz",
"integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/tinyglobby": {
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
@ -2375,6 +2681,16 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/tinyrainbow": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz",
"integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/typescript": {
"version": "5.7.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz",
@ -2502,6 +2818,123 @@
}
}
},
"node_modules/vitest": {
"version": "4.0.18",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz",
"integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/expect": "4.0.18",
"@vitest/mocker": "4.0.18",
"@vitest/pretty-format": "4.0.18",
"@vitest/runner": "4.0.18",
"@vitest/snapshot": "4.0.18",
"@vitest/spy": "4.0.18",
"@vitest/utils": "4.0.18",
"es-module-lexer": "^1.7.0",
"expect-type": "^1.2.2",
"magic-string": "^0.30.21",
"obug": "^2.1.1",
"pathe": "^2.0.3",
"picomatch": "^4.0.3",
"std-env": "^3.10.0",
"tinybench": "^2.9.0",
"tinyexec": "^1.0.2",
"tinyglobby": "^0.2.15",
"tinyrainbow": "^3.0.3",
"vite": "^6.0.0 || ^7.0.0",
"why-is-node-running": "^2.3.0"
},
"bin": {
"vitest": "vitest.mjs"
},
"engines": {
"node": "^20.0.0 || ^22.0.0 || >=24.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@edge-runtime/vm": "*",
"@opentelemetry/api": "^1.9.0",
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
"@vitest/browser-playwright": "4.0.18",
"@vitest/browser-preview": "4.0.18",
"@vitest/browser-webdriverio": "4.0.18",
"@vitest/ui": "4.0.18",
"happy-dom": "*",
"jsdom": "*"
},
"peerDependenciesMeta": {
"@edge-runtime/vm": {
"optional": true
},
"@opentelemetry/api": {
"optional": true
},
"@types/node": {
"optional": true
},
"@vitest/browser-playwright": {
"optional": true
},
"@vitest/browser-preview": {
"optional": true
},
"@vitest/browser-webdriverio": {
"optional": true
},
"@vitest/ui": {
"optional": true
},
"happy-dom": {
"optional": true
},
"jsdom": {
"optional": true
}
}
},
"node_modules/why-is-node-running": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
"integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
"dev": true,
"license": "MIT",
"dependencies": {
"siginfo": "^2.0.0",
"stackback": "0.0.2"
},
"bin": {
"why-is-node-running": "cli.js"
},
"engines": {
"node": ">=8"
}
},
"node_modules/ws": {
"version": "8.19.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",

View File

@ -7,7 +7,10 @@
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
"preview": "vite preview",
"test:e2e": "playwright test",
"test:e2e:headed": "playwright test --headed",
"test:unit": "vitest run --config vitest.config.ts"
},
"dependencies": {
"lucide-react": "^0.468.0",
@ -16,6 +19,7 @@
"react-router-dom": "^7.1.1"
},
"devDependencies": {
"@playwright/test": "^1.58.2",
"@tailwindcss/vite": "^4.0.0",
"@types/node": "^25.3.0",
"@types/react": "^19.0.7",
@ -23,6 +27,8 @@
"@vitejs/plugin-react": "^4.3.4",
"tailwindcss": "^4.0.0",
"typescript": "~5.7.2",
"vite": "^6.0.7"
"vite": "^6.0.7",
"vitest": "^4.0.18",
"ws": "^8.19.0"
}
}

34
web/playwright.config.ts Normal file
View File

@ -0,0 +1,34 @@
import { defineConfig } from '@playwright/test';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
const FRONTEND_PORT = Number(process.env.PW_WEB_PORT ?? '4173');
const BACKEND_PORT = Number(process.env.PW_API_PORT ?? '4174');
const rootDir = path.dirname(fileURLToPath(import.meta.url));
export default defineConfig({
testDir: './e2e',
timeout: 60_000,
expect: {
timeout: 10_000,
},
use: {
baseURL: `http://127.0.0.1:${FRONTEND_PORT}`,
headless: true,
trace: 'on-first-retry',
},
webServer: [
{
command: `node e2e/mock-server.mjs`,
url: `http://127.0.0.1:${BACKEND_PORT}/health`,
reuseExistingServer: true,
cwd: rootDir,
},
{
command: `VITE_API_TARGET=http://127.0.0.1:${BACKEND_PORT} VITE_WS_TARGET=ws://127.0.0.1:${BACKEND_PORT} npm run dev -- --host 127.0.0.1 --port ${FRONTEND_PORT}`,
url: `http://127.0.0.1:${FRONTEND_PORT}/_app/`,
reuseExistingServer: true,
cwd: rootDir,
},
],
});

View File

@ -12,27 +12,48 @@ import Cost from './pages/Cost';
import Logs from './pages/Logs';
import Doctor from './pages/Doctor';
import { AuthProvider, useAuth } from './hooks/useAuth';
import { setLocale, type Locale } from './lib/i18n';
import {
applyLocaleToDocument,
coerceLocale,
getLocaleDirection,
setLocale,
tLocale,
type Locale,
} from './lib/i18n';
import LanguageSelector from './components/controls/LanguageSelector';
const LOCALE_STORAGE_KEY = 'zeroclaw:locale';
// Locale context
interface LocaleContextType {
locale: string;
setAppLocale: (locale: string) => void;
locale: Locale;
setAppLocale: (locale: Locale) => void;
}
export const LocaleContext = createContext<LocaleContextType>({
locale: 'tr',
setAppLocale: () => {},
locale: 'en',
setAppLocale: (_locale: Locale) => {},
});
export const useLocaleContext = () => useContext(LocaleContext);
// Pairing dialog component
function PairingDialog({ onPair }: { onPair: (code: string) => Promise<void> }) {
function PairingDialog({
locale,
setAppLocale,
onPair,
}: {
locale: Locale;
setAppLocale: (locale: Locale) => void;
onPair: (code: string) => Promise<void>;
}) {
const [code, setCode] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const translate = (key: string) => tLocale(key, locale);
const localeDirection = getLocaleDirection(locale);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
@ -40,38 +61,52 @@ function PairingDialog({ onPair }: { onPair: (code: string) => Promise<void> })
try {
await onPair(code);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Pairing failed');
setError(err instanceof Error ? err.message : translate('auth.pairing_failed'));
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen bg-gray-950 flex items-center justify-center">
<div className="bg-gray-900 rounded-xl p-8 w-full max-w-md border border-gray-800">
<div className="pairing-shell min-h-screen flex items-center justify-center px-4" dir={localeDirection}>
<div className="pairing-card w-full max-w-md rounded-2xl p-8">
<div className="mb-4 flex justify-end">
<LanguageSelector
locale={locale}
onChange={setAppLocale}
ariaLabel={translate('common.select_language')}
title={translate('common.languages')}
align="right"
buttonClassName="inline-flex min-w-[12rem] items-center gap-2 rounded-xl border border-[#2b4f97] bg-[#091937]/75 px-3 py-2 text-sm text-[#c4d8ff] shadow-[0_0_0_1px_rgba(79,131,255,0.08)] transition hover:border-[#4f83ff] hover:text-white"
/>
</div>
<div className="text-center mb-6">
<h1 className="text-2xl font-bold text-white mb-2">ZeroClaw</h1>
<p className="text-gray-400">Enter the pairing code from your terminal</p>
<h1 className="mb-2 text-2xl font-semibold tracking-[0.16em] pairing-brand">ZEROCLAW</h1>
<p className="text-sm text-[#9bb8e8]">{translate('auth.enter_code')}</p>
</div>
<form onSubmit={handleSubmit}>
<input
type="text"
aria-label={translate('auth.enter_code')}
aria-invalid={Boolean(error)}
aria-describedby={error ? 'pairing-error' : undefined}
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder="6-digit code"
className="w-full px-4 py-3 bg-gray-800 border border-gray-700 rounded-lg text-white text-center text-2xl tracking-widest focus:outline-none focus:border-blue-500 mb-4"
placeholder={translate('auth.code_placeholder')}
className="w-full rounded-xl border border-[#29509c] bg-[#071228]/90 px-4 py-3 text-center text-2xl tracking-[0.35em] text-white focus:border-[#4f83ff] focus:outline-none mb-4"
maxLength={6}
autoFocus
/>
{error && (
<p className="text-red-400 text-sm mb-4 text-center">{error}</p>
<p id="pairing-error" role="alert" className="mb-4 text-center text-sm text-rose-300">{error}</p>
)}
<button
type="submit"
disabled={loading || code.length < 6}
className="w-full py-3 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-700 disabled:text-gray-500 text-white rounded-lg font-medium transition-colors"
data-testid="pair-button"
className="electric-button w-full rounded-xl py-3 font-medium text-white disabled:opacity-50"
>
{loading ? 'Pairing...' : 'Pair'}
{loading ? translate('auth.pairing_progress') : translate('auth.pair_button')}
</button>
</form>
</div>
@ -81,11 +116,38 @@ function PairingDialog({ onPair }: { onPair: (code: string) => Promise<void> })
function AppContent() {
const { isAuthenticated, loading, pair, logout } = useAuth();
const [locale, setLocaleState] = useState('tr');
const [locale, setLocaleState] = useState<Locale>(() => {
const initialLocale = (() => {
if (typeof window === 'undefined') {
return 'en';
}
const setAppLocale = (newLocale: string) => {
const saved = window.localStorage.getItem(LOCALE_STORAGE_KEY);
if (saved) {
return coerceLocale(saved);
}
return coerceLocale(window.navigator.language);
})();
setLocale(initialLocale);
if (typeof document !== 'undefined') {
applyLocaleToDocument(initialLocale, document);
}
return initialLocale;
});
useEffect(() => {
setLocale(locale);
if (typeof window !== 'undefined') {
window.localStorage.setItem(LOCALE_STORAGE_KEY, locale);
applyLocaleToDocument(locale, document);
}
}, [locale]);
const setAppLocale = (newLocale: Locale) => {
setLocale(newLocale);
setLocaleState(newLocale);
setLocale(newLocale as Locale);
};
// Listen for 401 events to force logout
@ -99,19 +161,22 @@ function AppContent() {
if (loading) {
return (
<div className="min-h-screen bg-gray-950 flex items-center justify-center">
<p className="text-gray-400">Connecting...</p>
<div className="pairing-shell min-h-screen flex items-center justify-center">
<div className="flex flex-col items-center gap-3">
<div className="electric-loader h-10 w-10 rounded-full" />
<p className="text-[#a7c4f3]">{tLocale('common.connecting', locale)}</p>
</div>
</div>
);
}
if (!isAuthenticated) {
return <PairingDialog onPair={pair} />;
return <PairingDialog locale={locale} setAppLocale={setAppLocale} onPair={pair} />;
}
return (
<LocaleContext.Provider value={{ locale, setAppLocale }}>
<Routes>
<Routes key={locale}>
<Route element={<Layout />}>
<Route path="/" element={<Dashboard />} />
<Route path="/agent" element={<AgentChat />} />

View File

@ -0,0 +1,127 @@
import { Check, ChevronDown } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import {
getLanguageOption,
getLocaleDirection,
LANGUAGE_OPTIONS,
type Locale,
} from '@/lib/i18n';
interface LanguageSelectorProps {
locale: Locale;
onChange: (locale: Locale) => void;
ariaLabel: string;
title?: string;
align?: 'left' | 'right';
buttonClassName?: string;
menuClassName?: string;
}
export default function LanguageSelector({
locale,
onChange,
ariaLabel,
title,
align = 'right',
buttonClassName = '',
menuClassName = '',
}: LanguageSelectorProps) {
const [open, setOpen] = useState(false);
const containerRef = useRef<HTMLDivElement | null>(null);
const activeLanguage = getLanguageOption(locale);
const localeDirection = getLocaleDirection(locale);
useEffect(() => {
const handlePointerDown = (event: MouseEvent) => {
if (!containerRef.current?.contains(event.target as Node)) {
setOpen(false);
}
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
setOpen(false);
}
};
window.addEventListener('mousedown', handlePointerDown);
window.addEventListener('keydown', handleKeyDown);
return () => {
window.removeEventListener('mousedown', handlePointerDown);
window.removeEventListener('keydown', handleKeyDown);
};
}, []);
const alignmentClass = align === 'left' ? 'left-0' : 'right-0';
return (
<div ref={containerRef} className="relative" dir={localeDirection}>
<button
type="button"
data-testid="locale-select"
aria-label={ariaLabel}
aria-haspopup="listbox"
aria-expanded={open}
title={title}
onClick={() => setOpen((current) => !current)}
className={buttonClassName}
>
<span
aria-hidden="true"
data-testid="locale-flag"
className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-[#0f2450] text-sm shadow-inner shadow-black/20"
>
{activeLanguage.flag}
</span>
<span dir={localeDirection} className="min-w-0 truncate text-start">{activeLanguage.label}</span>
<ChevronDown className={`h-4 w-4 shrink-0 transition ${open ? 'rotate-180' : ''}`} />
</button>
{open ? (
<div
className={`absolute z-50 mt-2 w-72 max-w-[min(18rem,calc(100vw-2rem))] overflow-hidden rounded-2xl border border-[#2b4f97] bg-[#071228]/96 shadow-[0_20px_60px_rgba(0,0,0,0.45)] backdrop-blur-xl ${alignmentClass} ${menuClassName}`}
>
<div
role="listbox"
aria-label={ariaLabel}
data-testid="locale-menu"
className="max-h-80 overflow-y-auto p-2"
>
{LANGUAGE_OPTIONS.map((option) => {
const selected = option.value === locale;
return (
<button
key={option.value}
type="button"
role="option"
aria-selected={selected}
data-testid={`locale-option-${option.value}`}
onClick={() => {
onChange(option.value);
setOpen(false);
}}
className={`flex w-full items-center gap-3 rounded-xl px-3 py-2 text-sm transition text-start ${
selected
? 'bg-[#13305f] text-white shadow-[0_0_0_1px_rgba(79,131,255,0.24)]'
: 'text-[#c4d8ff] hover:bg-[#0d2147] hover:text-white'
}`}
>
<span
aria-hidden="true"
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-[#0f2450] text-base shadow-inner shadow-black/20"
>
{option.flag}
</span>
<span dir={option.direction} className="min-w-0 flex-1 truncate text-start">
{option.label}
</span>
{selected ? <Check className="h-4 w-4 shrink-0 text-[#8cc2ff]" /> : null}
</button>
);
})}
</div>
</div>
) : null}
</div>
);
}

View File

@ -1,6 +1,7 @@
import { useLocation } from 'react-router-dom';
import { LogOut } from 'lucide-react';
import { t } from '@/lib/i18n';
import { LogOut, Menu } from 'lucide-react';
import { t, tLocale } from '@/lib/i18n';
import LanguageSelector from '@/components/controls/LanguageSelector';
import { useLocaleContext } from '@/App';
import { useAuth } from '@/hooks/useAuth';
@ -17,42 +18,60 @@ const routeTitles: Record<string, string> = {
'/doctor': 'nav.doctor',
};
export default function Header() {
interface HeaderProps {
onToggleSidebar: () => void;
}
export default function Header({ onToggleSidebar }: HeaderProps) {
const location = useLocation();
const { logout } = useAuth();
const { locale, setAppLocale } = useLocaleContext();
const titleKey = routeTitles[location.pathname] ?? 'nav.dashboard';
const pageTitle = t(titleKey);
const toggleLanguage = () => {
setAppLocale(locale === 'en' ? 'tr' : 'en');
};
const pageTitle = tLocale(titleKey, locale);
return (
<header className="h-14 bg-gray-800 border-b border-gray-700 flex items-center justify-between px-6">
{/* Page title */}
<h1 className="text-lg font-semibold text-white">{pageTitle}</h1>
<header className="glass-header relative flex min-h-[4.5rem] flex-wrap items-center justify-between gap-2 rounded-2xl border border-[#1a3670] px-4 py-3 sm:px-5 sm:py-3.5 md:flex-nowrap md:px-8 md:py-4">
<div className="absolute inset-0 pointer-events-none opacity-70 bg-[radial-gradient(circle_at_15%_30%,rgba(41,148,255,0.22),transparent_45%),radial-gradient(circle_at_85%_75%,rgba(0,209,255,0.14),transparent_40%)]" />
{/* Right-side controls */}
<div className="flex items-center gap-4">
{/* Language switcher */}
<div className="relative flex min-w-0 items-center gap-2.5 sm:gap-3">
<button
type="button"
onClick={toggleLanguage}
className="px-3 py-1 rounded-md text-sm font-medium border border-gray-600 text-gray-300 hover:bg-gray-700 hover:text-white transition-colors"
onClick={onToggleSidebar}
aria-label={t('navigation.open')}
className="rounded-lg border border-[#294a8f] bg-[#081637]/70 p-1.5 text-[#9ec2ff] transition hover:border-[#4f83ff] hover:text-white md:hidden"
>
{locale === 'en' ? 'EN' : 'TR'}
<Menu className="h-5 w-5" />
</button>
{/* Logout */}
<div className="min-w-0">
<h1 className="truncate text-base font-semibold tracking-wide text-white sm:text-lg">
{pageTitle}
</h1>
<p className="hidden text-[10px] uppercase tracking-[0.16em] text-[#7ea5eb] sm:block">
{tLocale('header.dashboard_tagline', locale)}
</p>
</div>
</div>
<div className="relative flex w-full items-center justify-end gap-1.5 sm:gap-2 md:w-auto md:gap-3">
<LanguageSelector
locale={locale}
onChange={setAppLocale}
ariaLabel={t('common.select_language')}
title={t('common.languages')}
align="right"
buttonClassName="flex min-w-[11rem] items-center gap-2 rounded-xl border border-[#2b4f97] bg-[#091937]/75 px-2.5 py-1 text-[#c4d8ff] shadow-[0_0_0_1px_rgba(79,131,255,0.08)] transition hover:border-[#4f83ff] hover:text-white"
/>
<button
type="button"
onClick={logout}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm text-gray-300 hover:bg-gray-700 hover:text-white transition-colors"
aria-label={t('auth.logout')}
className="flex items-center gap-1 rounded-lg border border-[#2b4f97] bg-[#091937]/75 px-2.5 py-1.5 text-xs text-[#c4d8ff] transition hover:border-[#4f83ff] hover:text-white sm:gap-1.5 sm:px-3 sm:text-sm"
>
<LogOut className="h-4 w-4" />
<span>{t('auth.logout')}</span>
<span className="hidden sm:inline">{t('auth.logout')}</span>
</button>
</div>
</header>

View File

@ -1,19 +1,57 @@
import { Outlet } from 'react-router-dom';
import { useState } from 'react';
import Sidebar from '@/components/layout/Sidebar';
import Header from '@/components/layout/Header';
const SIDEBAR_COLLAPSED_KEY = 'zeroclaw:sidebar-collapsed';
export default function Layout() {
const [sidebarOpen, setSidebarOpen] = useState(false);
const [sidebarCollapsed, setSidebarCollapsed] = useState<boolean>(() => {
if (typeof window === 'undefined') {
return false;
}
try {
return window.localStorage.getItem(SIDEBAR_COLLAPSED_KEY) === '1';
} catch {
return false;
}
});
const toggleSidebarCollapsed = () => {
setSidebarCollapsed((prev) => {
const next = !prev;
if (typeof window !== 'undefined') {
try {
window.localStorage.setItem(SIDEBAR_COLLAPSED_KEY, next ? '1' : '0');
} catch {
// Storage unavailable, ignore
}
}
return next;
});
};
return (
<div className="min-h-screen bg-gray-950 text-white">
{/* Fixed sidebar */}
<Sidebar />
<div className="app-shell min-h-screen text-white">
<Sidebar
isOpen={sidebarOpen}
isCollapsed={sidebarCollapsed}
onClose={() => setSidebarOpen(false)}
onToggleCollapse={toggleSidebarCollapsed}
/>
{/* Main area offset by sidebar width (240px / w-60) */}
<div className="ml-60 flex flex-col min-h-screen">
<Header />
<div
className={[
'flex min-h-screen flex-col transition-[margin-left] duration-300 ease-out',
sidebarCollapsed ? 'md:ml-[6.25rem]' : 'md:ml-[17.5rem]',
].join(' ')}
>
<div className="relative z-50">
<Header onToggleSidebar={() => setSidebarOpen((open) => !open)} />
</div>
{/* Page content */}
<main className="flex-1 overflow-y-auto">
<main className="relative z-10 flex-1 overflow-y-auto px-4 pb-8 pt-5 md:px-8 md:pt-8">
<Outlet />
</main>
</div>

View File

@ -1,5 +1,7 @@
import { useEffect, useState } from 'react';
import { NavLink } from 'react-router-dom';
import {
ChevronsLeftRightEllipsis,
LayoutDashboard,
MessageSquare,
Wrench,
@ -10,8 +12,12 @@ import {
DollarSign,
Activity,
Stethoscope,
X,
} from 'lucide-react';
import { t } from '@/lib/i18n';
import { tLocale } from '@/lib/i18n';
import { useLocaleContext } from '@/App';
const COLLAPSE_BUTTON_DELAY_MS = 1000;
const navItems = [
{ to: '/', icon: LayoutDashboard, labelKey: 'nav.dashboard' },
@ -26,40 +32,128 @@ const navItems = [
{ to: '/doctor', icon: Stethoscope, labelKey: 'nav.doctor' },
];
export default function Sidebar() {
return (
<aside className="fixed top-0 left-0 h-screen w-60 bg-gray-900 flex flex-col border-r border-gray-800">
{/* Logo / Title */}
<div className="flex items-center gap-2 px-5 py-5 border-b border-gray-800">
<div className="h-8 w-8 rounded-lg bg-blue-600 flex items-center justify-center text-white font-bold text-sm">
ZC
</div>
<span className="text-lg font-semibold text-white tracking-wide">
ZeroClaw
</span>
</div>
interface SidebarProps {
isOpen: boolean;
isCollapsed: boolean;
onClose: () => void;
onToggleCollapse: () => void;
}
{/* Navigation */}
<nav className="flex-1 overflow-y-auto py-4 px-3 space-y-1">
{navItems.map(({ to, icon: Icon, labelKey }) => (
<NavLink
key={to}
to={to}
end={to === '/'}
className={({ isActive }) =>
[
'flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors',
isActive
? 'bg-blue-600 text-white'
: 'text-gray-300 hover:bg-gray-800 hover:text-white',
].join(' ')
}
>
<Icon className="h-5 w-5 flex-shrink-0" />
<span>{t(labelKey)}</span>
</NavLink>
))}
</nav>
</aside>
export default function Sidebar({
isOpen,
isCollapsed,
onClose,
onToggleCollapse,
}: SidebarProps) {
const [showCollapseButton, setShowCollapseButton] = useState(false);
const { locale } = useLocaleContext();
const t = (key: string) => tLocale(key, locale);
useEffect(() => {
const id = setTimeout(() => setShowCollapseButton(true), COLLAPSE_BUTTON_DELAY_MS);
return () => clearTimeout(id);
}, []);
return (
<>
<button
type="button"
aria-label={t('navigation.close')}
onClick={onClose}
className={[
'fixed inset-0 z-30 bg-black/50 transition-opacity md:hidden',
isOpen ? 'opacity-100' : 'pointer-events-none opacity-0',
].join(' ')}
/>
<aside
className={[
'fixed left-0 top-0 z-40 flex h-screen w-[86vw] max-w-[17.5rem] flex-col border-r border-[#1e2f5d] bg-[#050b1a]/95 backdrop-blur-xl',
'shadow-[0_0_50px_-25px_rgba(8,121,255,0.7)]',
'transform transition-[width,transform] duration-300 ease-out',
isOpen ? 'translate-x-0' : '-translate-x-full',
isCollapsed ? 'md:w-[6.25rem]' : 'md:w-[17.5rem]',
'md:translate-x-0',
].join(' ')}
>
<div className="relative flex items-center justify-between border-b border-[#1a2d5e] px-4 py-4">
<div className="flex items-center gap-3 overflow-hidden">
{!isCollapsed && (
<>
<div className="electric-brand-mark h-9 w-9 shrink-0 rounded-xl text-sm font-semibold text-white">
ZC
</div>
<span className="text-lg font-semibold tracking-[0.1em] text-white">
ZeroClaw
</span>
</>
)}
</div>
<div className="flex items-center gap-2">
{showCollapseButton && (
<button
type="button"
onClick={onToggleCollapse}
aria-label={isCollapsed ? t('navigation.expand') : t('navigation.collapse')}
className="hidden rounded-lg border border-[#2c4e97] bg-[#0a1b3f]/60 p-1.5 text-[#8bb9ff] transition hover:border-[#4f83ff] hover:text-white md:block"
>
<ChevronsLeftRightEllipsis className="h-4 w-4" />
</button>
)}
<button
type="button"
onClick={onClose}
aria-label={t('navigation.close')}
className="rounded-lg p-1.5 text-gray-300 transition-colors hover:bg-gray-800 hover:text-white md:hidden"
>
<X className="h-4 w-4" />
</button>
</div>
</div>
<nav className="flex-1 space-y-1 overflow-y-auto px-3 py-4">
{navItems.map(({ to, icon: Icon, labelKey }) => (
<NavLink
key={to}
to={to}
end={to === '/'}
onClick={onClose}
title={isCollapsed ? t(labelKey) : undefined}
className={({ isActive }) =>
[
'group flex items-center gap-3 overflow-hidden rounded-xl px-3 py-2.5 text-sm font-medium transition-all duration-300',
isActive
? 'border border-[#3a6de0] bg-[#0b2f80]/55 text-white shadow-[0_0_30px_-16px_rgba(72,140,255,0.95)]'
: 'border border-transparent text-[#9bb7eb] hover:border-[#294a8d] hover:bg-[#07132f] hover:text-white',
].join(' ')
}
>
<Icon className="h-5 w-5 shrink-0 transition-transform duration-300 group-hover:scale-110" />
<span
className={[
'whitespace-nowrap transition-[opacity,transform,width] duration-300',
isCollapsed ? 'w-0 -translate-x-3 opacity-0 md:invisible' : 'w-auto opacity-100',
].join(' ')}
>
{t(labelKey)}
</span>
</NavLink>
))}
</nav>
<div
className={[
'mx-3 mb-4 rounded-xl border border-[#1b3670] bg-[#071328]/80 px-3 py-3 text-xs text-[#89a9df] transition-all duration-300',
isCollapsed ? 'md:px-1.5 md:text-center' : '',
].join(' ')}
>
<p className={isCollapsed ? 'hidden md:block' : ''}>{t('sidebar.gateway_dashboard')}</p>
<p className={isCollapsed ? 'text-[10px] uppercase tracking-widest' : 'mt-1 text-[#5f84cc]'}>
{isCollapsed ? 'UI' : t('sidebar.runtime_mode')}
</p>
</div>
</aside>
</>
);
}

View File

@ -1,89 +1,598 @@
@import "tailwindcss";
/*
* ZeroClaw Dark Theme
* Dark-mode by default with gray cards and blue/green accents.
*/
@theme {
--color-bg-primary: #0a0a0f;
--color-bg-secondary: #12121a;
--color-bg-card: #1a1a2e;
--color-bg-card-hover: #22223a;
--color-bg-input: #14141f;
--color-border-default: #2a2a3e;
--color-border-subtle: #1e1e30;
--color-accent-blue: #3b82f6;
--color-accent-blue-hover: #2563eb;
--color-accent-green: #10b981;
--color-accent-green-hover: #059669;
--color-text-primary: #e2e8f0;
--color-text-secondary: #94a3b8;
--color-text-muted: #64748b;
--color-status-success: #10b981;
--color-status-warning: #f59e0b;
--color-status-error: #ef4444;
--color-status-info: #3b82f6;
--color-electric-50: #eaf3ff;
--color-electric-100: #d8e8ff;
--color-electric-300: #87b8ff;
--color-electric-500: #2f8fff;
--color-electric-700: #0f57dd;
--color-electric-900: #031126;
}
/* Base styles */
html {
color-scheme: dark;
}
body {
background-color: var(--color-bg-primary);
color: var(--color-text-primary);
margin: 0;
min-height: 100dvh;
color: #edf4ff;
background: #020813;
font-family:
"Inter",
ui-sans-serif,
system-ui,
-apple-system,
"Sora",
"Manrope",
"Avenir Next",
"Segoe UI",
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: geometricPrecision;
overflow-x: hidden;
}
#root {
min-height: 100vh;
min-height: 100dvh;
}
.app-shell {
position: relative;
isolation: isolate;
background:
radial-gradient(circle at 8% 5%, rgba(47, 143, 255, 0.22), transparent 35%),
radial-gradient(circle at 92% 14%, rgba(0, 209, 255, 0.16), transparent 32%),
linear-gradient(175deg, #020816 0%, #03091b 46%, #040e24 100%);
}
.app-shell::before,
.app-shell::after {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
z-index: -1;
}
.app-shell::before {
background-image:
linear-gradient(rgba(76, 118, 194, 0.1) 1px, transparent 1px),
linear-gradient(90deg, rgba(76, 118, 194, 0.1) 1px, transparent 1px);
background-size: 34px 34px;
mask-image: radial-gradient(circle at 50% 36%, black 22%, transparent 80%);
opacity: 0.35;
}
.app-shell::after {
background:
radial-gradient(circle at 16% 86%, rgba(42, 128, 255, 0.34), transparent 43%),
radial-gradient(circle at 84% 22%, rgba(0, 212, 255, 0.2), transparent 38%),
radial-gradient(circle at 52% 122%, rgba(40, 118, 255, 0.3), transparent 56%);
filter: blur(4px);
animation: appGlowDrift 28s ease-in-out infinite;
}
.glass-header {
position: relative;
backdrop-filter: blur(16px);
background: linear-gradient(160deg, rgba(6, 19, 45, 0.85), rgba(5, 14, 33, 0.9));
box-shadow:
0 18px 32px -28px rgba(68, 145, 255, 0.95),
inset 0 1px 0 rgba(140, 183, 255, 0.14);
}
.glass-header::after {
content: "";
position: absolute;
inset: -1px;
border-radius: inherit;
background: linear-gradient(105deg, transparent 10%, rgba(79, 155, 255, 0.24), transparent 70%);
transform: translateX(-70%);
animation: topGlowSweep 7s ease-in-out infinite;
pointer-events: none;
}
.hero-panel {
position: relative;
overflow: hidden;
border-radius: 1.25rem;
border: 1px solid #21438c;
padding: 1.15rem 1.2rem;
background:
radial-gradient(circle at 0% 0%, rgba(56, 143, 255, 0.24), transparent 40%),
linear-gradient(146deg, rgba(8, 26, 64, 0.95), rgba(4, 13, 34, 0.92));
box-shadow:
inset 0 1px 0 rgba(130, 174, 255, 0.16),
0 22px 50px -38px rgba(64, 145, 255, 0.94);
}
.hero-panel::after {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(118deg, transparent, rgba(128, 184, 255, 0.12), transparent 70%);
transform: translateX(-62%);
animation: heroSweep 5.8s ease-in-out infinite;
pointer-events: none;
}
.hero-panel::before {
content: "";
position: absolute;
width: 19rem;
height: 19rem;
right: -6rem;
top: -10rem;
border-radius: 999px;
background: radial-gradient(circle at center, rgba(63, 167, 255, 0.42), transparent 70%);
filter: blur(12px);
animation: heroGlowPulse 4.8s ease-in-out infinite;
pointer-events: none;
}
.status-pill {
display: inline-flex;
align-items: center;
gap: 0.35rem;
border-radius: 999px;
border: 1px solid #2d58ac;
background: rgba(6, 29, 78, 0.68);
color: #c2d9ff;
padding: 0.35rem 0.65rem;
font-size: 0.68rem;
text-transform: uppercase;
letter-spacing: 0.12em;
box-shadow: 0 0 22px -16px rgba(83, 153, 255, 0.95);
}
.electric-brand-mark {
display: flex;
align-items: center;
justify-content: center;
background:
radial-gradient(circle at 22% 18%, rgba(94, 200, 255, 0.35), rgba(23, 119, 255, 0.2) 52%, rgba(10, 72, 181, 0.28) 100%);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.24),
0 10px 25px -12px rgba(41, 130, 255, 0.95);
}
.electric-card {
position: relative;
overflow: hidden;
border-radius: 1rem;
border: 1px solid #1a3670;
background:
linear-gradient(165deg, rgba(8, 24, 60, 0.95), rgba(4, 14, 34, 0.96));
box-shadow:
inset 0 1px 0 rgba(129, 174, 255, 0.12),
0 25px 45px -36px rgba(47, 140, 255, 0.95),
0 0 0 1px rgba(63, 141, 255, 0.2),
0 0 26px -17px rgba(76, 176, 255, 0.82);
}
.electric-card::after {
content: "";
position: absolute;
left: -20%;
right: -20%;
bottom: -65%;
height: 72%;
border-radius: 50%;
background: radial-gradient(circle, rgba(59, 148, 255, 0.25), transparent 72%);
filter: blur(16px);
opacity: 0.45;
pointer-events: none;
animation: cardGlowPulse 5.2s ease-in-out infinite;
}
.electric-icon {
display: flex;
align-items: center;
justify-content: center;
color: #9bc3ff;
background:
radial-gradient(circle at 35% 22%, rgba(123, 198, 255, 0.38), rgba(29, 92, 214, 0.32) 66%, rgba(12, 44, 102, 0.48) 100%);
border: 1px solid rgba(86, 143, 255, 0.45);
}
.metric-head {
display: inline-flex;
align-items: center;
gap: 0.4rem;
border-radius: 999px;
border: 1px solid #244991;
background: rgba(6, 22, 54, 0.74);
color: #91b8fb;
font-size: 0.66rem;
letter-spacing: 0.11em;
text-transform: uppercase;
padding: 0.3rem 0.55rem;
}
.metric-value {
color: #ffffff;
font-size: clamp(1.15rem, 1.8vw, 1.45rem);
font-weight: 620;
letter-spacing: 0.02em;
}
.metric-sub {
color: #89aee8;
font-size: 0.78rem;
}
.metric-pill {
border-radius: 0.85rem;
border: 1px solid #1d3c77;
background: rgba(5, 17, 44, 0.86);
padding: 0.6rem 0.72rem;
box-shadow:
0 0 0 1px rgba(62, 137, 255, 0.16),
0 16px 30px -24px rgba(47, 140, 255, 0.86),
0 0 18px -15px rgba(73, 176, 255, 0.78);
}
.metric-pill span {
display: block;
font-size: 0.68rem;
text-transform: uppercase;
letter-spacing: 0.1em;
color: #85a9e1;
}
.metric-pill strong {
display: block;
margin-top: 0.2rem;
font-size: 0.93rem;
color: #f5f9ff;
}
.electric-progress {
background:
linear-gradient(90deg, #1f76ff 0%, #2f97ff 60%, #48cdff 100%);
box-shadow: 0 0 18px -7px rgba(62, 166, 255, 0.95);
}
.pairing-shell {
position: relative;
isolation: isolate;
min-height: 100dvh;
background:
radial-gradient(circle at 20% 5%, rgba(64, 141, 255, 0.24), transparent 35%),
radial-gradient(circle at 75% 92%, rgba(0, 193, 255, 0.13), transparent 35%),
linear-gradient(155deg, #020816 0%, #030c20 58%, #030915 100%);
overflow: hidden;
}
.pairing-shell::after {
content: "";
position: absolute;
inset: -20%;
background:
radial-gradient(circle at 10% 20%, rgba(84, 173, 255, 0.25), transparent 60%),
radial-gradient(circle at 85% 80%, rgba(0, 204, 255, 0.22), transparent 60%);
filter: blur(12px);
opacity: 0.7;
animation: pairingSpotlightSweep 18s ease-in-out infinite;
pointer-events: none;
z-index: -1;
}
.pairing-card {
position: relative;
overflow: hidden;
border: 1px solid #2956a8;
background:
linear-gradient(155deg, rgba(9, 27, 68, 0.9), rgba(4, 15, 35, 0.94));
box-shadow:
inset 0 1px 0 rgba(146, 190, 255, 0.16),
0 30px 60px -44px rgba(47, 141, 255, 0.98),
0 0 0 1px rgba(67, 150, 255, 0.2),
0 0 28px -18px rgba(76, 184, 255, 0.82);
}
.pairing-card::before {
content: "";
position: absolute;
inset: -1px;
border-radius: inherit;
background: linear-gradient(135deg, transparent 10%, rgba(102, 186, 255, 0.5), transparent 80%);
mix-blend-mode: screen;
opacity: 0.0;
transform: translateX(-65%);
animation: pairingCardSweep 7.5s ease-in-out infinite;
pointer-events: none;
}
.pairing-brand {
background-image: linear-gradient(120deg, #5bc0ff 0%, #f9e775 28%, #5bc0ff 56%, #f9e775 100%);
background-size: 260% 260%;
background-clip: text;
-webkit-background-clip: text;
color: transparent;
text-shadow:
0 0 14px rgba(96, 189, 255, 0.8),
0 0 32px rgba(46, 138, 255, 0.9),
0 0 42px rgba(252, 238, 147, 0.85);
letter-spacing: 0.18em;
animation: pairingElectricCharge 5.4s ease-in-out infinite;
}
:is(div, section, article)[class*="bg-gray-900"][class*="rounded-xl"][class*="border"],
:is(div, section, article)[class*="bg-gray-900"][class*="rounded-lg"][class*="border"],
:is(div, section, article)[class*="bg-gray-950"][class*="rounded-lg"][class*="border"] {
box-shadow:
0 0 0 1px rgba(67, 144, 255, 0.14),
0 22px 40px -32px rgba(45, 134, 255, 0.86),
0 0 22px -16px rgba(73, 180, 255, 0.75);
}
.electric-button {
border: 1px solid #4a89ff;
background: linear-gradient(126deg, #125bdf 0%, #1f88ff 55%, #17b4ff 100%);
box-shadow: 0 18px 30px -20px rgba(47, 141, 255, 0.9);
transition: transform 180ms ease, filter 180ms ease, box-shadow 180ms ease;
}
.electric-button:hover {
transform: translateY(-1px);
filter: brightness(1.05);
box-shadow: 0 20px 34px -19px rgba(56, 154, 255, 0.95);
}
.electric-loader {
border: 3px solid rgba(89, 146, 255, 0.22);
border-top-color: #51abff;
box-shadow: 0 0 20px -12px rgba(66, 157, 255, 1);
animation: spin 1s linear infinite;
}
.motion-rise {
animation: riseIn 580ms ease both;
}
.motion-delay-1 {
animation-delay: 70ms;
}
.motion-delay-2 {
animation-delay: 130ms;
}
.motion-delay-3 {
animation-delay: 190ms;
}
.motion-delay-4 {
animation-delay: 250ms;
}
* {
scrollbar-width: thin;
scrollbar-color: #244787 #081126;
}
/* Scrollbar styling */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--color-bg-secondary);
background: #081126;
}
::-webkit-scrollbar-thumb {
background: var(--color-border-default);
border-radius: 4px;
background: #244787;
border-radius: 999px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--color-text-muted);
background: #3160b6;
}
/* Card utility */
.card {
background-color: var(--color-bg-card);
border: 1px solid var(--color-border-default);
border-radius: 0.75rem;
}
.card:hover {
background-color: var(--color-bg-card-hover);
}
/* Focus ring utility */
*:focus-visible {
outline: 2px solid var(--color-accent-blue);
outline: 2px solid #4ea4ff;
outline-offset: 2px;
}
@keyframes riseIn {
from {
opacity: 0;
transform: translateY(14px) scale(0.985);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes heroSweep {
0%,
100% {
transform: translateX(-68%);
opacity: 0;
}
30% {
opacity: 0.65;
}
60% {
transform: translateX(58%);
opacity: 0;
}
}
@keyframes heroGlowPulse {
0%,
100% {
opacity: 0.38;
transform: scale(0.94);
}
50% {
opacity: 0.72;
transform: scale(1.08);
}
}
@keyframes cardGlowPulse {
0%,
100% {
opacity: 0.28;
transform: translateY(0) scale(0.96);
}
50% {
opacity: 0.55;
transform: translateY(-2%) scale(1.04);
}
}
@keyframes pairingElectricCharge {
0%,
100% {
background-position: 0% 50%;
text-shadow:
0 0 14px rgba(86, 177, 255, 0.5),
0 0 32px rgba(36, 124, 255, 0.7),
0 0 38px rgba(252, 238, 147, 0.6);
transform: translateY(0) scale(1);
}
35% {
background-position: 80% 50%;
text-shadow:
0 0 26px rgba(138, 218, 255, 1),
0 0 52px rgba(56, 176, 255, 1),
0 0 60px rgba(252, 238, 147, 1);
transform: translateY(-1px) scale(1.06);
}
60% {
background-position: 50% 50%;
text-shadow:
0 0 18px rgba(86, 177, 255, 0.7),
0 0 36px rgba(36, 124, 255, 0.8),
0 0 44px rgba(252, 238, 147, 0.7);
transform: translateY(0) scale(1.02);
}
}
@keyframes pairingSpotlightSweep {
0% {
transform: translate3d(-12%, 8%, 0) scale(1);
opacity: 0.45;
}
30% {
transform: translate3d(10%, -4%, 0) scale(1.06);
opacity: 0.7;
}
55% {
transform: translate3d(16%, 10%, 0) scale(1.1);
opacity: 0.6;
}
80% {
transform: translate3d(-8%, -6%, 0) scale(1.04);
opacity: 0.5;
}
100% {
transform: translate3d(-12%, 8%, 0) scale(1);
opacity: 0.45;
}
}
@keyframes pairingCardSweep {
0%,
100% {
transform: translateX(-70%);
opacity: 0;
}
25% {
opacity: 0.55;
}
50% {
transform: translateX(55%);
opacity: 0;
}
}
@keyframes topGlowSweep {
0%,
100% {
transform: translateX(-78%);
opacity: 0;
}
30% {
opacity: 0.55;
}
58% {
transform: translateX(58%);
opacity: 0;
}
}
@keyframes appGlowDrift {
0% {
opacity: 0.3;
transform: translate3d(-3%, 1.8%, 0) scale(1);
}
25% {
opacity: 0.5;
transform: translate3d(2.6%, -1.2%, 0) scale(1.04);
}
50% {
opacity: 0.56;
transform: translate3d(4.4%, -3.4%, 0) scale(1.09);
}
75% {
opacity: 0.44;
transform: translate3d(-1.8%, -2.1%, 0) scale(1.05);
}
100% {
opacity: 0.34;
transform: translate3d(-3.6%, 2.6%, 0) scale(1.01);
}
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (max-width: 768px) {
.hero-panel {
padding: 0.95rem 0.95rem;
}
.status-pill {
padding: 0.28rem 0.52rem;
font-size: 0.61rem;
letter-spacing: 0.1em;
}
.metric-value {
font-size: 1.08rem;
}
.metric-sub {
font-size: 0.74rem;
}
.electric-card {
border-radius: 0.9rem;
}
}
@media (prefers-reduced-motion: reduce) {
.hero-panel::after,
.hero-panel::before,
.glass-header::after,
.electric-card::after,
.app-shell::after,
.pairing-shell::after,
.pairing-card::before,
.motion-rise,
.electric-loader {
animation: none !important;
}
.electric-button {
transition: none !important;
}
}

View File

@ -72,6 +72,21 @@ function unwrapField<T>(value: T | Record<string, T>, key: string): T {
return value as T;
}
function normalizeMemoryCategory(value: unknown): string {
if (typeof value === 'string') {
return value;
}
if (value && typeof value === 'object') {
const custom = (value as { custom?: unknown }).custom;
if (typeof custom === 'string' && custom.trim().length > 0) {
return custom;
}
}
return 'unknown';
}
// ---------------------------------------------------------------------------
// Pairing
// ---------------------------------------------------------------------------
@ -208,7 +223,11 @@ export function getMemory(
if (category) params.set('category', category);
const qs = params.toString();
return apiFetch<MemoryEntry[] | { entries: MemoryEntry[] }>(`/api/memory${qs ? `?${qs}` : ''}`).then(
(data) => unwrapField(data, 'entries'),
(data) =>
unwrapField(data, 'entries').map((entry) => ({
...entry,
category: normalizeMemoryCategory((entry as { category: unknown }).category),
})),
);
}

View File

@ -1,448 +0,0 @@
import { useState, useEffect } from 'react';
import { getStatus } from './api';
// ---------------------------------------------------------------------------
// Translation dictionaries
// ---------------------------------------------------------------------------
export type Locale = 'en' | 'tr';
const translations: Record<Locale, Record<string, string>> = {
en: {
// Navigation
'nav.dashboard': 'Dashboard',
'nav.agent': 'Agent',
'nav.tools': 'Tools',
'nav.cron': 'Scheduled Jobs',
'nav.integrations': 'Integrations',
'nav.memory': 'Memory',
'nav.config': 'Configuration',
'nav.cost': 'Cost Tracker',
'nav.logs': 'Logs',
'nav.doctor': 'Doctor',
// Dashboard
'dashboard.title': 'Dashboard',
'dashboard.provider': 'Provider',
'dashboard.model': 'Model',
'dashboard.uptime': 'Uptime',
'dashboard.temperature': 'Temperature',
'dashboard.gateway_port': 'Gateway Port',
'dashboard.locale': 'Locale',
'dashboard.memory_backend': 'Memory Backend',
'dashboard.paired': 'Paired',
'dashboard.channels': 'Channels',
'dashboard.health': 'Health',
'dashboard.status': 'Status',
'dashboard.overview': 'Overview',
'dashboard.system_info': 'System Information',
'dashboard.quick_actions': 'Quick Actions',
// Agent / Chat
'agent.title': 'Agent Chat',
'agent.send': 'Send',
'agent.placeholder': 'Type a message...',
'agent.connecting': 'Connecting...',
'agent.connected': 'Connected',
'agent.disconnected': 'Disconnected',
'agent.reconnecting': 'Reconnecting...',
'agent.thinking': 'Thinking...',
'agent.tool_call': 'Tool Call',
'agent.tool_result': 'Tool Result',
// Tools
'tools.title': 'Available Tools',
'tools.name': 'Name',
'tools.description': 'Description',
'tools.parameters': 'Parameters',
'tools.search': 'Search tools...',
'tools.empty': 'No tools available.',
'tools.count': 'Total tools',
// Cron
'cron.title': 'Scheduled Jobs',
'cron.add': 'Add Job',
'cron.delete': 'Delete',
'cron.enable': 'Enable',
'cron.disable': 'Disable',
'cron.name': 'Name',
'cron.command': 'Command',
'cron.schedule': 'Schedule',
'cron.next_run': 'Next Run',
'cron.last_run': 'Last Run',
'cron.last_status': 'Last Status',
'cron.enabled': 'Enabled',
'cron.empty': 'No scheduled jobs.',
'cron.confirm_delete': 'Are you sure you want to delete this job?',
// Integrations
'integrations.title': 'Integrations',
'integrations.available': 'Available',
'integrations.active': 'Active',
'integrations.coming_soon': 'Coming Soon',
'integrations.category': 'Category',
'integrations.status': 'Status',
'integrations.search': 'Search integrations...',
'integrations.empty': 'No integrations found.',
'integrations.activate': 'Activate',
'integrations.deactivate': 'Deactivate',
// Memory
'memory.title': 'Memory Store',
'memory.search': 'Search memory...',
'memory.add': 'Store Memory',
'memory.delete': 'Delete',
'memory.key': 'Key',
'memory.content': 'Content',
'memory.category': 'Category',
'memory.timestamp': 'Timestamp',
'memory.session': 'Session',
'memory.score': 'Score',
'memory.empty': 'No memory entries found.',
'memory.confirm_delete': 'Are you sure you want to delete this memory entry?',
'memory.all_categories': 'All Categories',
// Config
'config.title': 'Configuration',
'config.save': 'Save',
'config.reset': 'Reset',
'config.saved': 'Configuration saved successfully.',
'config.error': 'Failed to save configuration.',
'config.loading': 'Loading configuration...',
'config.editor_placeholder': 'TOML configuration...',
// Cost
'cost.title': 'Cost Tracker',
'cost.session': 'Session Cost',
'cost.daily': 'Daily Cost',
'cost.monthly': 'Monthly Cost',
'cost.total_tokens': 'Total Tokens',
'cost.request_count': 'Requests',
'cost.by_model': 'Cost by Model',
'cost.model': 'Model',
'cost.tokens': 'Tokens',
'cost.requests': 'Requests',
'cost.usd': 'Cost (USD)',
// Logs
'logs.title': 'Live Logs',
'logs.clear': 'Clear',
'logs.pause': 'Pause',
'logs.resume': 'Resume',
'logs.filter': 'Filter logs...',
'logs.empty': 'No log entries.',
'logs.connected': 'Connected to event stream.',
'logs.disconnected': 'Disconnected from event stream.',
// Doctor
'doctor.title': 'System Diagnostics',
'doctor.run': 'Run Diagnostics',
'doctor.running': 'Running diagnostics...',
'doctor.ok': 'OK',
'doctor.warn': 'Warning',
'doctor.error': 'Error',
'doctor.severity': 'Severity',
'doctor.category': 'Category',
'doctor.message': 'Message',
'doctor.empty': 'No diagnostics have been run yet.',
'doctor.summary': 'Diagnostic Summary',
// Auth / Pairing
'auth.pair': 'Pair Device',
'auth.pairing_code': 'Pairing Code',
'auth.pair_button': 'Pair',
'auth.logout': 'Logout',
'auth.pairing_success': 'Pairing successful!',
'auth.pairing_failed': 'Pairing failed. Please try again.',
'auth.enter_code': 'Enter your pairing code to connect to the agent.',
// Common
'common.loading': 'Loading...',
'common.error': 'An error occurred.',
'common.retry': 'Retry',
'common.cancel': 'Cancel',
'common.confirm': 'Confirm',
'common.save': 'Save',
'common.delete': 'Delete',
'common.edit': 'Edit',
'common.close': 'Close',
'common.yes': 'Yes',
'common.no': 'No',
'common.search': 'Search...',
'common.no_data': 'No data available.',
'common.refresh': 'Refresh',
'common.back': 'Back',
'common.actions': 'Actions',
'common.name': 'Name',
'common.description': 'Description',
'common.status': 'Status',
'common.created': 'Created',
'common.updated': 'Updated',
// Health
'health.title': 'System Health',
'health.component': 'Component',
'health.status': 'Status',
'health.last_ok': 'Last OK',
'health.last_error': 'Last Error',
'health.restart_count': 'Restarts',
'health.pid': 'Process ID',
'health.uptime': 'Uptime',
'health.updated_at': 'Last Updated',
},
tr: {
// Navigation
'nav.dashboard': 'Kontrol Paneli',
'nav.agent': 'Ajan',
'nav.tools': 'Araclar',
'nav.cron': 'Zamanlanmis Gorevler',
'nav.integrations': 'Entegrasyonlar',
'nav.memory': 'Hafiza',
'nav.config': 'Yapilandirma',
'nav.cost': 'Maliyet Takibi',
'nav.logs': 'Kayitlar',
'nav.doctor': 'Doktor',
// Dashboard
'dashboard.title': 'Kontrol Paneli',
'dashboard.provider': 'Saglayici',
'dashboard.model': 'Model',
'dashboard.uptime': 'Calisma Suresi',
'dashboard.temperature': 'Sicaklik',
'dashboard.gateway_port': 'Gecit Portu',
'dashboard.locale': 'Yerel Ayar',
'dashboard.memory_backend': 'Hafiza Motoru',
'dashboard.paired': 'Eslestirilmis',
'dashboard.channels': 'Kanallar',
'dashboard.health': 'Saglik',
'dashboard.status': 'Durum',
'dashboard.overview': 'Genel Bakis',
'dashboard.system_info': 'Sistem Bilgisi',
'dashboard.quick_actions': 'Hizli Islemler',
// Agent / Chat
'agent.title': 'Ajan Sohbet',
'agent.send': 'Gonder',
'agent.placeholder': 'Bir mesaj yazin...',
'agent.connecting': 'Baglaniyor...',
'agent.connected': 'Bagli',
'agent.disconnected': 'Baglanti Kesildi',
'agent.reconnecting': 'Yeniden Baglaniyor...',
'agent.thinking': 'Dusunuyor...',
'agent.tool_call': 'Arac Cagrisi',
'agent.tool_result': 'Arac Sonucu',
// Tools
'tools.title': 'Mevcut Araclar',
'tools.name': 'Ad',
'tools.description': 'Aciklama',
'tools.parameters': 'Parametreler',
'tools.search': 'Arac ara...',
'tools.empty': 'Mevcut arac yok.',
'tools.count': 'Toplam arac',
// Cron
'cron.title': 'Zamanlanmis Gorevler',
'cron.add': 'Gorev Ekle',
'cron.delete': 'Sil',
'cron.enable': 'Etkinlestir',
'cron.disable': 'Devre Disi Birak',
'cron.name': 'Ad',
'cron.command': 'Komut',
'cron.schedule': 'Zamanlama',
'cron.next_run': 'Sonraki Calistirma',
'cron.last_run': 'Son Calistirma',
'cron.last_status': 'Son Durum',
'cron.enabled': 'Etkin',
'cron.empty': 'Zamanlanmis gorev yok.',
'cron.confirm_delete': 'Bu gorevi silmek istediginizden emin misiniz?',
// Integrations
'integrations.title': 'Entegrasyonlar',
'integrations.available': 'Mevcut',
'integrations.active': 'Aktif',
'integrations.coming_soon': 'Yakinda',
'integrations.category': 'Kategori',
'integrations.status': 'Durum',
'integrations.search': 'Entegrasyon ara...',
'integrations.empty': 'Entegrasyon bulunamadi.',
'integrations.activate': 'Etkinlestir',
'integrations.deactivate': 'Devre Disi Birak',
// Memory
'memory.title': 'Hafiza Deposu',
'memory.search': 'Hafizada ara...',
'memory.add': 'Hafiza Kaydet',
'memory.delete': 'Sil',
'memory.key': 'Anahtar',
'memory.content': 'Icerik',
'memory.category': 'Kategori',
'memory.timestamp': 'Zaman Damgasi',
'memory.session': 'Oturum',
'memory.score': 'Skor',
'memory.empty': 'Hafiza kaydi bulunamadi.',
'memory.confirm_delete': 'Bu hafiza kaydini silmek istediginizden emin misiniz?',
'memory.all_categories': 'Tum Kategoriler',
// Config
'config.title': 'Yapilandirma',
'config.save': 'Kaydet',
'config.reset': 'Sifirla',
'config.saved': 'Yapilandirma basariyla kaydedildi.',
'config.error': 'Yapilandirma kaydedilemedi.',
'config.loading': 'Yapilandirma yukleniyor...',
'config.editor_placeholder': 'TOML yapilandirmasi...',
// Cost
'cost.title': 'Maliyet Takibi',
'cost.session': 'Oturum Maliyeti',
'cost.daily': 'Gunluk Maliyet',
'cost.monthly': 'Aylik Maliyet',
'cost.total_tokens': 'Toplam Token',
'cost.request_count': 'Istekler',
'cost.by_model': 'Modele Gore Maliyet',
'cost.model': 'Model',
'cost.tokens': 'Token',
'cost.requests': 'Istekler',
'cost.usd': 'Maliyet (USD)',
// Logs
'logs.title': 'Canli Kayitlar',
'logs.clear': 'Temizle',
'logs.pause': 'Duraklat',
'logs.resume': 'Devam Et',
'logs.filter': 'Kayitlari filtrele...',
'logs.empty': 'Kayit girisi yok.',
'logs.connected': 'Olay akisina baglandi.',
'logs.disconnected': 'Olay akisi baglantisi kesildi.',
// Doctor
'doctor.title': 'Sistem Teshisleri',
'doctor.run': 'Teshis Calistir',
'doctor.running': 'Teshisler calistiriliyor...',
'doctor.ok': 'Tamam',
'doctor.warn': 'Uyari',
'doctor.error': 'Hata',
'doctor.severity': 'Ciddiyet',
'doctor.category': 'Kategori',
'doctor.message': 'Mesaj',
'doctor.empty': 'Henuz teshis calistirilmadi.',
'doctor.summary': 'Teshis Ozeti',
// Auth / Pairing
'auth.pair': 'Cihaz Esle',
'auth.pairing_code': 'Eslestirme Kodu',
'auth.pair_button': 'Esle',
'auth.logout': 'Cikis Yap',
'auth.pairing_success': 'Eslestirme basarili!',
'auth.pairing_failed': 'Eslestirme basarisiz. Lutfen tekrar deneyin.',
'auth.enter_code': 'Ajana baglanmak icin eslestirme kodunuzu girin.',
// Common
'common.loading': 'Yukleniyor...',
'common.error': 'Bir hata olustu.',
'common.retry': 'Tekrar Dene',
'common.cancel': 'Iptal',
'common.confirm': 'Onayla',
'common.save': 'Kaydet',
'common.delete': 'Sil',
'common.edit': 'Duzenle',
'common.close': 'Kapat',
'common.yes': 'Evet',
'common.no': 'Hayir',
'common.search': 'Ara...',
'common.no_data': 'Veri mevcut degil.',
'common.refresh': 'Yenile',
'common.back': 'Geri',
'common.actions': 'Islemler',
'common.name': 'Ad',
'common.description': 'Aciklama',
'common.status': 'Durum',
'common.created': 'Olusturulma',
'common.updated': 'Guncellenme',
// Health
'health.title': 'Sistem Sagligi',
'health.component': 'Bilesen',
'health.status': 'Durum',
'health.last_ok': 'Son Basarili',
'health.last_error': 'Son Hata',
'health.restart_count': 'Yeniden Baslatmalar',
'health.pid': 'Islem Kimligi',
'health.uptime': 'Calisma Suresi',
'health.updated_at': 'Son Guncelleme',
},
};
// ---------------------------------------------------------------------------
// Current locale state
// ---------------------------------------------------------------------------
let currentLocale: Locale = 'en';
export function getLocale(): Locale {
return currentLocale;
}
export function setLocale(locale: Locale): void {
currentLocale = locale;
}
// ---------------------------------------------------------------------------
// Translation function
// ---------------------------------------------------------------------------
/**
* Translate a key using the current locale. Returns the key itself if no
* translation is found.
*/
export function t(key: string): string {
return translations[currentLocale]?.[key] ?? translations.en[key] ?? key;
}
/**
* Get the translation for a specific locale. Falls back to English, then to the
* raw key.
*/
export function tLocale(key: string, locale: Locale): string {
return translations[locale]?.[key] ?? translations.en[key] ?? key;
}
// ---------------------------------------------------------------------------
// React hook
// ---------------------------------------------------------------------------
/**
* React hook that fetches the locale from /api/status on mount and keeps the
* i18n module in sync. Returns the current locale and a `t` helper bound to it.
*/
export function useLocale(): { locale: Locale; t: (key: string) => string } {
const [locale, setLocaleState] = useState<Locale>(currentLocale);
useEffect(() => {
let cancelled = false;
getStatus()
.then((status) => {
if (cancelled) return;
const detected = status.locale?.toLowerCase().startsWith('tr')
? 'tr'
: 'en';
setLocale(detected);
setLocaleState(detected);
})
.catch(() => {
// Keep default locale on error
});
return () => {
cancelled = true;
};
}, []);
return {
locale,
t: (key: string) => tLocale(key, locale),
};
}

View File

@ -0,0 +1,63 @@
import { describe, expect, it } from 'vitest';
import {
applyLocaleToDocument,
coerceLocale,
getLanguageOption,
getLocaleDirection,
LANGUAGE_OPTIONS,
LANGUAGE_SWITCH_ORDER,
} from '.';
describe('language metadata', () => {
it('keeps language options aligned with switch order', () => {
expect(LANGUAGE_OPTIONS.map((option) => option.value)).toEqual(LANGUAGE_SWITCH_ORDER);
expect(new Set(LANGUAGE_OPTIONS.map((option) => option.value)).size).toBe(LANGUAGE_OPTIONS.length);
});
it('provides a flag-backed label for every locale', () => {
for (const option of LANGUAGE_OPTIONS) {
expect(getLanguageOption(option.value)).toEqual(option);
expect(option.label.length).toBeGreaterThan(0);
expect(option.flag.length).toBeGreaterThan(0);
}
});
});
describe('coerceLocale', () => {
it('normalizes browser locale variants to supported locales', () => {
expect(coerceLocale('ar-SA')).toBe('ar');
expect(coerceLocale('he-IL')).toBe('he');
expect(coerceLocale('iw-IL')).toBe('he');
expect(coerceLocale('pt-BR')).toBe('pt');
expect(coerceLocale('no-NO')).toBe('nb');
expect(coerceLocale('zh-Hans')).toBe('zh-CN');
expect(coerceLocale(undefined)).toBe('en');
});
});
describe('locale direction', () => {
it('returns rtl only for rtl languages', () => {
expect(getLocaleDirection('ar')).toBe('rtl');
expect(getLocaleDirection('he')).toBe('rtl');
expect(getLocaleDirection('ur')).toBe('rtl');
expect(getLocaleDirection('en')).toBe('ltr');
expect(getLocaleDirection('ja')).toBe('ltr');
});
it('applies lang and dir to a document-like target', () => {
const target = {
documentElement: { lang: '', dir: '' },
body: { dir: '' },
};
applyLocaleToDocument('ar', target);
expect(target.documentElement.lang).toBe('ar');
expect(target.documentElement.dir).toBe('rtl');
expect(target.body.dir).toBe('rtl');
applyLocaleToDocument('fr', target);
expect(target.documentElement.lang).toBe('fr');
expect(target.documentElement.dir).toBe('ltr');
expect(target.body.dir).toBe('ltr');
});
});

View File

@ -0,0 +1,4 @@
export type { Locale, LocaleDirection, LanguageOption, LocaleDocumentTarget } from './types';
export { LANGUAGE_OPTIONS, LANGUAGE_SWITCH_ORDER, getLocaleDirection, getLanguageOption } from './languages';
export { coerceLocale, getLocale, setLocale, t, tLocale, applyLocaleToDocument } from './translate';
export { translations } from './locales';

View File

@ -0,0 +1,58 @@
import type { LanguageOption, Locale, LocaleDirection } from './types';
export const LANGUAGE_OPTIONS: ReadonlyArray<LanguageOption> = [
{ value: 'en', label: 'English', flag: '🇺🇸', direction: 'ltr' },
{ value: 'zh-CN', label: '简体中文', flag: '🇨🇳', direction: 'ltr' },
{ value: 'ja', label: '日本語', flag: '🇯🇵', direction: 'ltr' },
{ value: 'ko', label: '한국어', flag: '🇰🇷', direction: 'ltr' },
{ value: 'vi', label: 'Tiếng Việt', flag: '🇻🇳', direction: 'ltr' },
{ value: 'tl', label: 'Tagalog', flag: '🇵🇭', direction: 'ltr' },
{ value: 'es', label: 'Español', flag: '🇪🇸', direction: 'ltr' },
{ value: 'pt', label: 'Português', flag: '🇵🇹', direction: 'ltr' },
{ value: 'it', label: 'Italiano', flag: '🇮🇹', direction: 'ltr' },
{ value: 'de', label: 'Deutsch', flag: '🇩🇪', direction: 'ltr' },
{ value: 'fr', label: 'Français', flag: '🇫🇷', direction: 'ltr' },
{ value: 'ar', label: 'العربية', flag: '🇸🇦', direction: 'rtl' },
{ value: 'hi', label: 'हिन्दी', flag: '🇮🇳', direction: 'ltr' },
{ value: 'ru', label: 'Русский', flag: '🇷🇺', direction: 'ltr' },
{ value: 'bn', label: 'বাংলা', flag: '🇧🇩', direction: 'ltr' },
{ value: 'he', label: 'עברית', flag: '🇮🇱', direction: 'rtl' },
{ value: 'pl', label: 'Polski', flag: '🇵🇱', direction: 'ltr' },
{ value: 'cs', label: 'Čeština', flag: '🇨🇿', direction: 'ltr' },
{ value: 'nl', label: 'Nederlands', flag: '🇳🇱', direction: 'ltr' },
{ value: 'tr', label: 'Türkçe', flag: '🇹🇷', direction: 'ltr' },
{ value: 'uk', label: 'Українська', flag: '🇺🇦', direction: 'ltr' },
{ value: 'id', label: 'Bahasa Indonesia', flag: '🇮🇩', direction: 'ltr' },
{ value: 'th', label: 'ไทย', flag: '🇹🇭', direction: 'ltr' },
{ value: 'ur', label: 'اردو', flag: '🇵🇰', direction: 'rtl' },
{ value: 'ro', label: 'Română', flag: '🇷🇴', direction: 'ltr' },
{ value: 'sv', label: 'Svenska', flag: '🇸🇪', direction: 'ltr' },
{ value: 'el', label: 'Ελληνικά', flag: '🇬🇷', direction: 'ltr' },
{ value: 'hu', label: 'Magyar', flag: '🇭🇺', direction: 'ltr' },
{ value: 'fi', label: 'Suomi', flag: '🇫🇮', direction: 'ltr' },
{ value: 'da', label: 'Dansk', flag: '🇩🇰', direction: 'ltr' },
{ value: 'nb', label: 'Norsk Bokmål', flag: '🇳🇴', direction: 'ltr' },
];
export const LANGUAGE_SWITCH_ORDER: ReadonlyArray<Locale> =
LANGUAGE_OPTIONS.map((option) => option.value);
const RTL_LOCALES = new Set<Locale>(['ar', 'he', 'ur']);
export function getLocaleDirection(locale: Locale): LocaleDirection {
return RTL_LOCALES.has(locale) ? 'rtl' : 'ltr';
}
export function getLanguageOption(locale: Locale): LanguageOption {
const matched = LANGUAGE_OPTIONS.find((option) => option.value === locale);
if (matched) {
return matched;
}
const fallback = LANGUAGE_OPTIONS.find((option) => option.value === 'en');
if (!fallback) {
throw new Error('English locale metadata is missing.');
}
return fallback;
}

View File

@ -0,0 +1,33 @@
import type { TranslationKeys } from './en';
const ar: Partial<TranslationKeys> = {
'nav.dashboard': 'لوحة التحكم',
'nav.agent': 'الوكيل',
'nav.tools': 'الأدوات',
'nav.cron': 'المهام المجدولة',
'nav.integrations': 'التكاملات',
'nav.memory': 'الذاكرة',
'nav.config': 'الإعدادات',
'nav.cost': 'تتبع التكلفة',
'nav.logs': 'السجلات',
'nav.doctor': 'التشخيص',
'dashboard.hero_title': 'لوحة تشغيل كهربائية',
'agent.placeholder': 'اكتب رسالة…',
'tools.search': 'ابحث في الأدوات…',
'cron.add': 'إضافة مهمة',
'memory.add_memory': 'إضافة ذاكرة',
'config.save': 'حفظ',
'cost.token_statistics': 'إحصاءات الرموز',
'logs.title': 'السجلات المباشرة',
'doctor.title': 'تشخيص النظام',
'auth.pair_button': 'اقتران',
'auth.enter_code': 'أدخل رمز الاقتران لمرة واحدة من الطرفية',
'auth.code_placeholder': 'رمز من 6 أرقام',
'auth.pairing_progress': 'جارٍ الاقتران…',
'auth.logout': 'تسجيل الخروج',
'common.languages': 'اللغات',
'common.select_language': 'اختر اللغة',
'header.dashboard_tagline': 'لوحة ZeroClaw',
};
export default ar;

View File

@ -0,0 +1,33 @@
import type { TranslationKeys } from './en';
const bn: Partial<TranslationKeys> = {
'nav.dashboard': 'ড্যাশবোর্ড',
'nav.agent': 'এজেন্ট',
'nav.tools': 'টুলস',
'nav.cron': 'নির্ধারিত কাজ',
'nav.integrations': 'ইন্টিগ্রেশন',
'nav.memory': 'মেমরি',
'nav.config': 'কনফিগারেশন',
'nav.cost': 'খরচ ট্র্যাকার',
'nav.logs': 'লগ',
'nav.doctor': 'ডায়াগনস্টিক',
'dashboard.hero_title': 'ইলেকট্রিক রানটাইম ড্যাশবোর্ড',
'agent.placeholder': 'একটি বার্তা লিখুন…',
'tools.search': 'টুল খুঁজুন…',
'cron.add': 'কাজ যোগ করুন',
'memory.add_memory': 'মেমরি যোগ করুন',
'config.save': 'সংরক্ষণ করুন',
'cost.token_statistics': 'টোকেন পরিসংখ্যান',
'logs.title': 'লাইভ লগ',
'doctor.title': 'সিস্টেম ডায়াগনস্টিক',
'auth.pair_button': 'পেয়ার করুন',
'auth.enter_code': 'টার্মিনাল থেকে একবারের পেয়ারিং কোড লিখুন',
'auth.code_placeholder': '৬-সংখ্যার কোড',
'auth.pairing_progress': 'পেয়ার করা হচ্ছে…',
'auth.logout': 'লগ আউট',
'common.languages': 'ভাষাসমূহ',
'common.select_language': 'ভাষা বেছে নিন',
'header.dashboard_tagline': 'ZeroClaw ড্যাশবোর্ড',
};
export default bn;

View File

@ -0,0 +1,33 @@
import type { TranslationKeys } from './en';
const cs: Partial<TranslationKeys> = {
'nav.dashboard': 'Nástěnka',
'nav.agent': 'Agent',
'nav.tools': 'Nástroje',
'nav.cron': 'Plánované úlohy',
'nav.integrations': 'Integrace',
'nav.memory': 'Paměť',
'nav.config': 'Konfigurace',
'nav.cost': 'Náklady',
'nav.logs': 'Logy',
'nav.doctor': 'Diagnostika',
'dashboard.hero_title': 'Elektrický runtime panel',
'agent.placeholder': 'Napište zprávu…',
'tools.search': 'Hledat nástroje…',
'cron.add': 'Přidat úlohu',
'memory.add_memory': 'Přidat paměť',
'config.save': 'Uložit',
'cost.token_statistics': 'Statistiky tokenů',
'logs.title': 'Živé logy',
'doctor.title': 'Diagnostika systému',
'auth.pair_button': 'Spárovat',
'auth.enter_code': 'Zadejte jednorázový párovací kód z terminálu',
'auth.code_placeholder': '6místný kód',
'auth.pairing_progress': 'Párování…',
'auth.logout': 'Odhlásit se',
'common.languages': 'Jazyky',
'common.select_language': 'Vyberte jazyk',
'header.dashboard_tagline': 'Panel ZeroClaw',
};
export default cs;

View File

@ -0,0 +1,33 @@
import type { TranslationKeys } from './en';
const da: Partial<TranslationKeys> = {
'nav.dashboard': 'Kontrolpanel',
'nav.agent': 'Agent',
'nav.tools': 'Værktøjer',
'nav.cron': 'Planlagte job',
'nav.integrations': 'Integrationer',
'nav.memory': 'Hukommelse',
'nav.config': 'Konfiguration',
'nav.cost': 'Omkostninger',
'nav.logs': 'Logge',
'nav.doctor': 'Diagnostik',
'dashboard.hero_title': 'Elektrisk runtime-kontrolpanel',
'agent.placeholder': 'Skriv en besked…',
'tools.search': 'Søg værktøjer…',
'cron.add': 'Tilføj job',
'memory.add_memory': 'Tilføj hukommelse',
'config.save': 'Gem',
'cost.token_statistics': 'Tokenstatistik',
'logs.title': 'Live-logge',
'doctor.title': 'Systemdiagnostik',
'auth.pair_button': 'Par',
'auth.enter_code': 'Indtast engangskoden fra terminalen',
'auth.code_placeholder': '6-cifret kode',
'auth.pairing_progress': 'Parrer…',
'auth.logout': 'Log ud',
'common.languages': 'Sprog',
'common.select_language': 'Vælg sprog',
'header.dashboard_tagline': 'ZeroClaw-kontrolpanel',
};
export default da;

View File

@ -0,0 +1,33 @@
import type { TranslationKeys } from './en';
const de: Partial<TranslationKeys> = {
'nav.dashboard': 'Dashboard',
'nav.agent': 'Agent',
'nav.tools': 'Werkzeuge',
'nav.cron': 'Geplante Aufgaben',
'nav.integrations': 'Integrationen',
'nav.memory': 'Speicher',
'nav.config': 'Konfiguration',
'nav.cost': 'Kosten',
'nav.logs': 'Protokolle',
'nav.doctor': 'Diagnose',
'dashboard.hero_title': 'Elektrisches Runtime-Dashboard',
'agent.placeholder': 'Nachricht eingeben…',
'tools.search': 'Werkzeuge suchen…',
'cron.add': 'Aufgabe hinzufügen',
'memory.add_memory': 'Speicher hinzufügen',
'config.save': 'Speichern',
'cost.token_statistics': 'Token-Statistiken',
'logs.title': 'Live-Protokolle',
'doctor.title': 'Systemdiagnose',
'auth.pair_button': 'Koppeln',
'auth.enter_code': 'Geben Sie den einmaligen Kopplungscode aus dem Terminal ein',
'auth.code_placeholder': '6-stelliger Code',
'auth.pairing_progress': 'Kopplung…',
'auth.logout': 'Abmelden',
'common.languages': 'Sprachen',
'common.select_language': 'Sprache auswählen',
'header.dashboard_tagline': 'ZeroClaw-Dashboard',
};
export default de;

View File

@ -0,0 +1,33 @@
import type { TranslationKeys } from './en';
const el: Partial<TranslationKeys> = {
'nav.dashboard': 'Πίνακας ελέγχου',
'nav.agent': 'Πράκτορας',
'nav.tools': 'Εργαλεία',
'nav.cron': 'Προγραμματισμένες εργασίες',
'nav.integrations': 'Ενσωματώσεις',
'nav.memory': 'Μνήμη',
'nav.config': 'Ρυθμίσεις',
'nav.cost': 'Κόστος',
'nav.logs': 'Αρχεία καταγραφής',
'nav.doctor': 'Διάγνωση',
'dashboard.hero_title': 'Ηλεκτρικός πίνακας runtime',
'agent.placeholder': 'Πληκτρολογήστε μήνυμα…',
'tools.search': 'Αναζήτηση εργαλείων…',
'cron.add': 'Προσθήκη εργασίας',
'memory.add_memory': 'Προσθήκη μνήμης',
'config.save': 'Αποθήκευση',
'cost.token_statistics': 'Στατιστικά token',
'logs.title': 'Ζωντανά αρχεία καταγραφής',
'doctor.title': 'Διάγνωση συστήματος',
'auth.pair_button': 'Σύζευξη',
'auth.enter_code': 'Εισαγάγετε τον εφάπαξ κωδικό σύζευξης από το terminal',
'auth.code_placeholder': '6ψήφιος κωδικός',
'auth.pairing_progress': 'Σύζευξη…',
'auth.logout': 'Αποσύνδεση',
'common.languages': 'Γλώσσες',
'common.select_language': 'Επιλέξτε γλώσσα',
'header.dashboard_tagline': 'Πίνακας ZeroClaw',
};
export default el;

View File

@ -0,0 +1,273 @@
const en = {
'nav.dashboard': 'Dashboard',
'nav.agent': 'Agent',
'nav.tools': 'Tools',
'nav.cron': 'Scheduled Jobs',
'nav.integrations': 'Integrations',
'nav.memory': 'Memory',
'nav.config': 'Configuration',
'nav.cost': 'Cost Tracker',
'nav.logs': 'Logs',
'nav.doctor': 'Doctor',
'dashboard.title': 'Dashboard',
'dashboard.provider': 'Provider',
'dashboard.model': 'Model',
'dashboard.uptime': 'Uptime',
'dashboard.temperature': 'Temperature',
'dashboard.gateway_port': 'Gateway Port',
'dashboard.locale': 'Locale',
'dashboard.memory_backend': 'Memory Backend',
'dashboard.paired': 'Paired',
'dashboard.channels': 'Channels',
'dashboard.health': 'Health',
'dashboard.status': 'Status',
'dashboard.overview': 'Overview',
'dashboard.system_info': 'System Information',
'dashboard.quick_actions': 'Quick Actions',
'dashboard.load_failed': 'Dashboard load failed',
'dashboard.load_unknown_error': 'Unknown dashboard load error',
'dashboard.hero_eyebrow': 'ZeroClaw Command Deck',
'dashboard.hero_title': 'Electric Runtime Dashboard',
'dashboard.hero_subtitle': 'Real-time telemetry, cost pulse, and operations status in a single collapsible surface.',
'dashboard.live_gateway': 'Live Gateway',
'dashboard.unpaired': 'Unpaired',
'dashboard.provider_model': 'Provider / Model',
'dashboard.since_last_restart': 'Since last restart',
'dashboard.pairing_active': 'Pairing active',
'dashboard.no_paired_devices': 'No paired devices',
'dashboard.cost_pulse': 'Cost Pulse',
'dashboard.cost_subtitle': 'Session, daily, and monthly runtime spend',
'dashboard.session': 'Session',
'dashboard.daily': 'Daily',
'dashboard.monthly': 'Monthly',
'dashboard.channel_activity': 'Channel Activity',
'dashboard.channel_subtitle': 'Live integrations and route connectivity',
'dashboard.no_channels': 'No channels configured.',
'dashboard.active': 'Active',
'dashboard.inactive': 'Inactive',
'dashboard.component_health': 'Component Health',
'dashboard.component_subtitle': 'Runtime heartbeat and restart awareness',
'dashboard.no_component_health': 'No component health is currently available.',
'dashboard.restarts': 'Restarts',
'dashboard.unknown_provider': 'Unknown',
'agent.title': 'Agent Chat',
'agent.send': 'Send',
'agent.placeholder': 'Type a message...',
'agent.connecting': 'Connecting...',
'agent.connected': 'Connected',
'agent.disconnected': 'Disconnected',
'agent.reconnecting': 'Reconnecting...',
'agent.thinking': 'Thinking...',
'agent.tool_call': 'Tool Call',
'agent.tool_result': 'Tool Result',
'agent.connection_error': 'Connection error. Attempting to reconnect...',
'agent.failed_send': 'Failed to send message. Please try again.',
'agent.empty_title': 'ZeroClaw Agent',
'agent.empty_subtitle': 'Send a message to start the conversation',
'agent.unknown_error': 'Unknown error',
'tools.title': 'Available Tools',
'tools.name': 'Name',
'tools.description': 'Description',
'tools.parameters': 'Parameters',
'tools.search': 'Search tools...',
'tools.empty': 'No tools available.',
'tools.count': 'Total tools',
'tools.agent_tools': 'Agent Tools',
'tools.cli_tools': 'CLI Tools',
'tools.no_search_results': 'No tools match your search.',
'tools.parameter_schema': 'Parameter Schema',
'tools.path': 'Path',
'tools.version': 'Version',
'tools.load_failed': 'Failed to load tools',
'cron.title': 'Scheduled Jobs',
'cron.add': 'Add Job',
'cron.delete': 'Delete',
'cron.enable': 'Enable',
'cron.disable': 'Disable',
'cron.name': 'Name',
'cron.command': 'Command',
'cron.schedule': 'Schedule',
'cron.next_run': 'Next Run',
'cron.last_run': 'Last Run',
'cron.last_status': 'Last Status',
'cron.enabled': 'Enabled',
'cron.empty': 'No scheduled jobs.',
'cron.confirm_delete': 'Are you sure you want to delete this job?',
'cron.scheduled_tasks': 'Scheduled Tasks',
'cron.add_cron_job': 'Add Cron Job',
'cron.name_optional': 'Name (optional)',
'cron.schedule_required_command_required': 'Schedule and command are required.',
'cron.adding': 'Adding...',
'cron.no_tasks_configured': 'No scheduled tasks configured.',
'cron.load_failed': 'Failed to load cron jobs',
'cron.failed_add': 'Failed to add job',
'cron.failed_delete': 'Failed to delete job',
'cron.delete_prompt': 'Delete?',
'cron.id': 'ID',
'cron.disabled': 'Disabled',
'integrations.title': 'Integrations',
'integrations.available': 'Available',
'integrations.active': 'Active',
'integrations.coming_soon': 'Coming Soon',
'integrations.category': 'Category',
'integrations.status': 'Status',
'integrations.search': 'Search integrations...',
'integrations.empty': 'No integrations found.',
'integrations.activate': 'Activate',
'integrations.deactivate': 'Deactivate',
'integrations.load_failed': 'Failed to load integrations',
'integrations.all': 'all',
'memory.title': 'Memory Store',
'memory.search': 'Search memory...',
'memory.add': 'Store Memory',
'memory.delete': 'Delete',
'memory.key': 'Key',
'memory.content': 'Content',
'memory.category': 'Category',
'memory.timestamp': 'Timestamp',
'memory.session': 'Session',
'memory.score': 'Score',
'memory.empty': 'No memory entries found.',
'memory.confirm_delete': 'Are you sure you want to delete this memory entry?',
'memory.all_categories': 'All Categories',
'memory.add_memory': 'Add Memory',
'memory.search_entries': 'Search memory entries...',
'memory.load_failed': 'Failed to load memory',
'memory.key_content_required': 'Key and content are required.',
'memory.failed_store': 'Failed to store memory',
'memory.failed_delete': 'Failed to delete memory',
'memory.category_optional': 'Category (optional)',
'memory.key_placeholder': 'e.g. user_preferences',
'memory.content_placeholder': 'Memory content...',
'memory.category_placeholder': 'e.g. preferences, context, facts',
'memory.search_button': 'Search',
'memory.saving': 'Saving...',
'memory.delete_prompt': 'Delete?',
'config.title': 'Configuration',
'config.save': 'Save',
'config.reset': 'Reset',
'config.saved': 'Configuration saved successfully.',
'config.error': 'Failed to save configuration.',
'config.loading': 'Loading configuration...',
'config.editor_placeholder': 'TOML configuration...',
'config.saving': 'Saving...',
'config.masked_title': 'Sensitive fields are masked',
'config.masked_description': 'API keys, tokens, and passwords are hidden for security. To update a masked field, replace the entire masked value with your new value.',
'config.toml_configuration': 'TOML Configuration',
'config.lines': 'lines',
'cost.title': 'Cost Tracker',
'cost.session': 'Session Cost',
'cost.daily': 'Daily Cost',
'cost.monthly': 'Monthly Cost',
'cost.total_tokens': 'Total Tokens',
'cost.request_count': 'Requests',
'cost.by_model': 'Cost by Model',
'cost.model': 'Model',
'cost.tokens': 'Tokens',
'cost.requests': 'Requests',
'cost.usd': 'Cost (USD)',
'cost.total_requests': 'Total Requests',
'cost.token_statistics': 'Token Statistics',
'cost.avg_tokens_per_request': 'Avg Tokens / Request',
'cost.cost_per_1k_tokens': 'Cost per 1K Tokens',
'cost.model_breakdown': 'Model Breakdown',
'cost.no_model_data': 'No model data available.',
'cost.share': 'Share',
'cost.load_failed': 'Failed to load cost data',
'logs.title': 'Live Logs',
'logs.clear': 'Clear',
'logs.pause': 'Pause',
'logs.resume': 'Resume',
'logs.filter': 'Filter logs...',
'logs.empty': 'No log entries.',
'logs.connected': 'Connected to event stream.',
'logs.disconnected': 'Disconnected from event stream.',
'logs.events': 'events',
'logs.jump_to_bottom': 'Jump to bottom',
'logs.filter_label': 'Filter:',
'logs.paused_stream': 'Log streaming is paused.',
'logs.waiting_for_events': 'Waiting for events...',
'doctor.title': 'System Diagnostics',
'doctor.run': 'Run Diagnostics',
'doctor.running': 'Running diagnostics...',
'doctor.ok': 'OK',
'doctor.warn': 'Warning',
'doctor.error': 'Error',
'doctor.severity': 'Severity',
'doctor.category': 'Category',
'doctor.message': 'Message',
'doctor.empty': 'No diagnostics have been run yet.',
'doctor.summary': 'Diagnostic Summary',
'doctor.running_short': 'Running...',
'doctor.running_hint': 'This may take a few seconds.',
'doctor.issues_found': 'Issues Found',
'doctor.warnings': 'Warnings',
'doctor.all_clear': 'All Clear',
'doctor.instructions': 'Click "Run Diagnostics" to check your ZeroClaw installation.',
'auth.pair': 'Pair Device',
'auth.pairing_code': 'Pairing Code',
'auth.pair_button': 'Pair',
'auth.logout': 'Logout',
'auth.pairing_success': 'Pairing successful!',
'auth.pairing_failed': 'Pairing failed. Please try again.',
'auth.enter_code': 'Enter the one-time pairing code from your terminal',
'auth.code_placeholder': '6-digit code',
'auth.pairing_progress': 'Pairing...',
'common.loading': 'Loading...',
'common.error': 'An error occurred.',
'common.retry': 'Retry',
'common.cancel': 'Cancel',
'common.confirm': 'Confirm',
'common.save': 'Save',
'common.delete': 'Delete',
'common.edit': 'Edit',
'common.close': 'Close',
'common.yes': 'Yes',
'common.no': 'No',
'common.search': 'Search...',
'common.no_data': 'No data available.',
'common.refresh': 'Refresh',
'common.back': 'Back',
'common.actions': 'Actions',
'common.name': 'Name',
'common.description': 'Description',
'common.status': 'Status',
'common.created': 'Created',
'common.updated': 'Updated',
'common.languages': 'Languages',
'common.select_language': 'Select language',
'common.connecting': 'Connecting...',
'health.title': 'System Health',
'health.component': 'Component',
'health.status': 'Status',
'health.last_ok': 'Last OK',
'health.last_error': 'Last Error',
'health.restart_count': 'Restart Count',
'health.pid': 'Process ID',
'health.uptime': 'Uptime',
'health.updated_at': 'Updated At',
'header.dashboard_tagline': 'ZeroClaw dashboard',
'sidebar.gateway_dashboard': 'Gateway + Dashboard',
'sidebar.runtime_mode': 'Runtime Mode',
'navigation.open': 'Open navigation',
'navigation.close': 'Close navigation',
'navigation.expand': 'Expand navigation',
'navigation.collapse': 'Collapse navigation',
} satisfies Record<string, string>;
export type TranslationKeys = typeof en;
export default en;

View File

@ -0,0 +1,33 @@
import type { TranslationKeys } from './en';
const es: Partial<TranslationKeys> = {
'nav.dashboard': 'Panel',
'nav.agent': 'Agente',
'nav.tools': 'Herramientas',
'nav.cron': 'Tareas programadas',
'nav.integrations': 'Integraciones',
'nav.memory': 'Memoria',
'nav.config': 'Configuración',
'nav.cost': 'Costos',
'nav.logs': 'Registros',
'nav.doctor': 'Diagnóstico',
'dashboard.hero_title': 'Panel eléctrico del runtime',
'agent.placeholder': 'Escribe un mensaje…',
'tools.search': 'Buscar herramientas…',
'cron.add': 'Agregar tarea',
'memory.add_memory': 'Agregar memoria',
'config.save': 'Guardar',
'cost.token_statistics': 'Estadísticas de tokens',
'logs.title': 'Registros en vivo',
'doctor.title': 'Diagnóstico del sistema',
'auth.pair_button': 'Vincular',
'auth.enter_code': 'Introduce el código de vinculación de un solo uso del terminal',
'auth.code_placeholder': 'Código de 6 dígitos',
'auth.pairing_progress': 'Vinculando…',
'auth.logout': 'Cerrar sesión',
'common.languages': 'Idiomas',
'common.select_language': 'Elegir idioma',
'header.dashboard_tagline': 'Panel de ZeroClaw',
};
export default es;

View File

@ -0,0 +1,33 @@
import type { TranslationKeys } from './en';
const fi: Partial<TranslationKeys> = {
'nav.dashboard': 'Hallintapaneeli',
'nav.agent': 'Agentti',
'nav.tools': 'Työkalut',
'nav.cron': 'Ajastetut tehtävät',
'nav.integrations': 'Integraatiot',
'nav.memory': 'Muisti',
'nav.config': 'Asetukset',
'nav.cost': 'Kustannukset',
'nav.logs': 'Lokit',
'nav.doctor': 'Diagnostiikka',
'dashboard.hero_title': 'Sähköinen runtime-hallintapaneeli',
'agent.placeholder': 'Kirjoita viesti…',
'tools.search': 'Etsi työkaluja…',
'cron.add': 'Lisää tehtävä',
'memory.add_memory': 'Lisää muisti',
'config.save': 'Tallenna',
'cost.token_statistics': 'Token-tilastot',
'logs.title': 'Live-lokit',
'doctor.title': 'Järjestelmädiagnostiikka',
'auth.pair_button': 'Yhdistä',
'auth.enter_code': 'Syötä terminaalin kertakäyttöinen parituskoodi',
'auth.code_placeholder': '6-numeroinen koodi',
'auth.pairing_progress': 'Yhdistetään…',
'auth.logout': 'Kirjaudu ulos',
'common.languages': 'Kielet',
'common.select_language': 'Valitse kieli',
'header.dashboard_tagline': 'ZeroClaw-hallintapaneeli',
};
export default fi;

View File

@ -0,0 +1,51 @@
import type { TranslationKeys } from './en';
const fr: Partial<TranslationKeys> = {
'nav.dashboard': 'Tableau de bord',
'nav.agent': 'Agent',
'nav.tools': 'Outils',
'nav.cron': 'Tâches planifiées',
'nav.integrations': 'Intégrations',
'nav.memory': 'Mémoire',
'nav.config': 'Configuration',
'nav.cost': 'Coûts',
'nav.logs': 'Journaux',
'nav.doctor': 'Diagnostic',
'dashboard.hero_title': 'Tableau de bord runtime électrique',
'dashboard.live_gateway': 'Passerelle active',
'dashboard.unpaired': 'Non appairé',
'agent.title': 'Chat agent',
'agent.placeholder': 'Saisissez un message…',
'agent.connecting': 'Connexion…',
'agent.connected': 'Connecté',
'agent.disconnected': 'Déconnecté',
'tools.search': 'Rechercher des outils…',
'tools.agent_tools': 'Outils agent',
'tools.cli_tools': 'Outils CLI',
'cron.add': 'Ajouter une tâche',
'cron.scheduled_tasks': 'Tâches planifiées',
'integrations.title': 'Intégrations',
'memory.add_memory': 'Ajouter une mémoire',
'memory.search_entries': 'Rechercher dans la mémoire…',
'config.save': 'Enregistrer',
'config.saving': 'Enregistrement…',
'cost.session': 'Coût de session',
'cost.daily': 'Coût journalier',
'cost.monthly': 'Coût mensuel',
'logs.title': 'Journaux en direct',
'logs.pause': 'Pause',
'logs.resume': 'Reprendre',
'doctor.title': 'Diagnostic système',
'doctor.run': 'Lancer le diagnostic',
'doctor.running_short': 'Exécution…',
'auth.pair_button': 'Associer',
'auth.enter_code': 'Entrez le code d\u2019appairage à usage unique affiché dans le terminal',
'auth.code_placeholder': 'Code à 6 chiffres',
'auth.pairing_progress': 'Appairage…',
'auth.logout': 'Déconnexion',
'common.languages': 'Langues',
'common.select_language': 'Choisir la langue',
'header.dashboard_tagline': 'Tableau de bord ZeroClaw',
};
export default fr;

View File

@ -0,0 +1,33 @@
import type { TranslationKeys } from './en';
const he: Partial<TranslationKeys> = {
'nav.dashboard': 'לוח מחוונים',
'nav.agent': 'סוכן',
'nav.tools': 'כלים',
'nav.cron': 'משימות מתוזמנות',
'nav.integrations': 'אינטגרציות',
'nav.memory': 'זיכרון',
'nav.config': 'תצורה',
'nav.cost': 'מעקב עלויות',
'nav.logs': 'יומנים',
'nav.doctor': 'אבחון',
'dashboard.hero_title': 'לוח מחוונים חשמלי של זמן הריצה',
'agent.placeholder': 'הקלד הודעה…',
'tools.search': 'חפש כלים…',
'cron.add': 'הוסף משימה',
'memory.add_memory': 'הוסף זיכרון',
'config.save': 'שמור',
'cost.token_statistics': 'סטטיסטיקות אסימונים',
'logs.title': 'יומנים חיים',
'doctor.title': 'אבחון מערכת',
'auth.pair_button': 'התאמה',
'auth.enter_code': 'הזן את קוד ההתאמה החד-פעמי מהמסוף',
'auth.code_placeholder': 'קוד בן 6 ספרות',
'auth.pairing_progress': 'מתבצעת התאמה…',
'auth.logout': 'התנתק',
'common.languages': 'שפות',
'common.select_language': 'בחר שפה',
'header.dashboard_tagline': 'לוח המחוונים של ZeroClaw',
};
export default he;

View File

@ -0,0 +1,33 @@
import type { TranslationKeys } from './en';
const hi: Partial<TranslationKeys> = {
'nav.dashboard': 'डैशबोर्ड',
'nav.agent': 'एजेंट',
'nav.tools': 'टूल्स',
'nav.cron': 'निर्धारित कार्य',
'nav.integrations': 'इंटीग्रेशन',
'nav.memory': 'मेमोरी',
'nav.config': 'कॉन्फ़िगरेशन',
'nav.cost': 'लागत ट्रैकर',
'nav.logs': 'लॉग्स',
'nav.doctor': 'जाँच',
'dashboard.hero_title': 'इलेक्ट्रिक रनटाइम डैशबोर्ड',
'agent.placeholder': 'संदेश लिखें…',
'tools.search': 'टूल खोजें…',
'cron.add': 'कार्य जोड़ें',
'memory.add_memory': 'मेमोरी जोड़ें',
'config.save': 'सहेजें',
'cost.token_statistics': 'टोकन आँकड़े',
'logs.title': 'लाइव लॉग्स',
'doctor.title': 'सिस्टम जाँच',
'auth.pair_button': 'पेयर करें',
'auth.enter_code': 'टर्मिनल से एक-बार वाला पेयरिंग कोड दर्ज करें',
'auth.code_placeholder': '6-अंकों का कोड',
'auth.pairing_progress': 'पेयर किया जा रहा है…',
'auth.logout': 'लॉग आउट',
'common.languages': 'भाषाएँ',
'common.select_language': 'भाषा चुनें',
'header.dashboard_tagline': 'ZeroClaw डैशबोर्ड',
};
export default hi;

View File

@ -0,0 +1,33 @@
import type { TranslationKeys } from './en';
const hu: Partial<TranslationKeys> = {
'nav.dashboard': 'Irányítópult',
'nav.agent': 'Ügynök',
'nav.tools': 'Eszközök',
'nav.cron': 'Ütemezett feladatok',
'nav.integrations': 'Integrációk',
'nav.memory': 'Memória',
'nav.config': 'Konfiguráció',
'nav.cost': 'Költségek',
'nav.logs': 'Naplók',
'nav.doctor': 'Diagnosztika',
'dashboard.hero_title': 'Elektromos runtime irányítópult',
'agent.placeholder': 'Írjon üzenetet…',
'tools.search': 'Eszközök keresése…',
'cron.add': 'Feladat hozzáadása',
'memory.add_memory': 'Memória hozzáadása',
'config.save': 'Mentés',
'cost.token_statistics': 'Tokenstatisztika',
'logs.title': 'Élő naplók',
'doctor.title': 'Rendszerdiagnosztika',
'auth.pair_button': 'Párosítás',
'auth.enter_code': 'Adja meg a terminál egyszer használatos párosítási kódját',
'auth.code_placeholder': '6 számjegyű kód',
'auth.pairing_progress': 'Párosítás…',
'auth.logout': 'Kijelentkezés',
'common.languages': 'Nyelvek',
'common.select_language': 'Nyelv kiválasztása',
'header.dashboard_tagline': 'ZeroClaw irányítópult',
};
export default hu;

View File

@ -0,0 +1,33 @@
import type { TranslationKeys } from './en';
const id: Partial<TranslationKeys> = {
'nav.dashboard': 'Dasbor',
'nav.agent': 'Agen',
'nav.tools': 'Alat',
'nav.cron': 'Tugas terjadwal',
'nav.integrations': 'Integrasi',
'nav.memory': 'Memori',
'nav.config': 'Konfigurasi',
'nav.cost': 'Biaya',
'nav.logs': 'Log',
'nav.doctor': 'Diagnosis',
'dashboard.hero_title': 'Dasbor runtime elektrik',
'agent.placeholder': 'Tulis pesan…',
'tools.search': 'Cari alat…',
'cron.add': 'Tambah tugas',
'memory.add_memory': 'Tambah memori',
'config.save': 'Simpan',
'cost.token_statistics': 'Statistik token',
'logs.title': 'Log langsung',
'doctor.title': 'Diagnosis sistem',
'auth.pair_button': 'Pasangkan',
'auth.enter_code': 'Masukkan kode pairing sekali pakai dari terminal',
'auth.code_placeholder': 'Kode 6 digit',
'auth.pairing_progress': 'Sedang memasangkan…',
'auth.logout': 'Keluar',
'common.languages': 'Bahasa',
'common.select_language': 'Pilih bahasa',
'header.dashboard_tagline': 'Dasbor ZeroClaw',
};
export default id;

View File

@ -0,0 +1,70 @@
import type { Locale } from '../types';
import en from './en';
import zhCN from './zh-CN';
import ja from './ja';
import ko from './ko';
import vi from './vi';
import tl from './tl';
import es from './es';
import pt from './pt';
import it from './it';
import de from './de';
import fr from './fr';
import ar from './ar';
import hi from './hi';
import ru from './ru';
import bn from './bn';
import he from './he';
import pl from './pl';
import cs from './cs';
import nl from './nl';
import tr from './tr';
import uk from './uk';
import id from './id';
import th from './th';
import ur from './ur';
import ro from './ro';
import sv from './sv';
import el from './el';
import hu from './hu';
import fi from './fi';
import da from './da';
import nb from './nb';
function merge(overrides: Partial<typeof en>): Record<string, string> {
return { ...en, ...overrides };
}
export const translations: Record<Locale, Record<string, string>> = {
en,
'zh-CN': merge(zhCN),
ja: merge(ja),
ko: merge(ko),
vi: merge(vi),
tl: merge(tl),
es: merge(es),
pt: merge(pt),
it: merge(it),
de: merge(de),
fr: merge(fr),
ar: merge(ar),
hi: merge(hi),
ru: merge(ru),
bn: merge(bn),
he: merge(he),
pl: merge(pl),
cs: merge(cs),
nl: merge(nl),
tr: merge(tr),
uk: merge(uk),
id: merge(id),
th: merge(th),
ur: merge(ur),
ro: merge(ro),
sv: merge(sv),
el: merge(el),
hu: merge(hu),
fi: merge(fi),
da: merge(da),
nb: merge(nb),
};

View File

@ -0,0 +1,33 @@
import type { TranslationKeys } from './en';
const it: Partial<TranslationKeys> = {
'nav.dashboard': 'Dashboard',
'nav.agent': 'Agente',
'nav.tools': 'Strumenti',
'nav.cron': 'Attività pianificate',
'nav.integrations': 'Integrazioni',
'nav.memory': 'Memoria',
'nav.config': 'Configurazione',
'nav.cost': 'Costi',
'nav.logs': 'Log',
'nav.doctor': 'Diagnostica',
'dashboard.hero_title': 'Dashboard runtime elettrica',
'agent.placeholder': 'Scrivi un messaggio…',
'tools.search': 'Cerca strumenti…',
'cron.add': 'Aggiungi attività',
'memory.add_memory': 'Aggiungi memoria',
'config.save': 'Salva',
'cost.token_statistics': 'Statistiche token',
'logs.title': 'Log in tempo reale',
'doctor.title': 'Diagnostica di sistema',
'auth.pair_button': 'Associa',
'auth.enter_code': 'Inserisci il codice di associazione monouso dal terminale',
'auth.code_placeholder': 'Codice a 6 cifre',
'auth.pairing_progress': 'Associazione…',
'auth.logout': 'Disconnetti',
'common.languages': 'Lingue',
'common.select_language': 'Scegli lingua',
'header.dashboard_tagline': 'Dashboard di ZeroClaw',
};
export default it;

View File

@ -0,0 +1,51 @@
import type { TranslationKeys } from './en';
const ja: Partial<TranslationKeys> = {
'nav.dashboard': 'ダッシュボード',
'nav.agent': 'エージェント',
'nav.tools': 'ツール',
'nav.cron': 'スケジュール',
'nav.integrations': '連携',
'nav.memory': 'メモリ',
'nav.config': '設定',
'nav.cost': 'コスト',
'nav.logs': 'ログ',
'nav.doctor': '診断',
'dashboard.hero_title': 'エレクトリック・ランタイム・ダッシュボード',
'dashboard.live_gateway': 'ライブゲートウェイ',
'dashboard.unpaired': '未ペア',
'agent.title': 'エージェントチャット',
'agent.placeholder': 'メッセージを入力…',
'agent.connecting': '接続中…',
'agent.connected': '接続済み',
'agent.disconnected': '切断済み',
'tools.search': 'ツールを検索…',
'tools.agent_tools': 'エージェントツール',
'tools.cli_tools': 'CLI ツール',
'cron.add': 'ジョブを追加',
'cron.scheduled_tasks': 'スケジュールされたジョブ',
'integrations.title': '連携',
'memory.add_memory': 'メモリを追加',
'memory.search_entries': 'メモリエントリを検索…',
'config.save': '保存',
'config.saving': '保存中…',
'cost.session': 'セッションコスト',
'cost.daily': '日次コスト',
'cost.monthly': '月次コスト',
'logs.title': 'ライブログ',
'logs.pause': '一時停止',
'logs.resume': '再開',
'doctor.title': 'システム診断',
'doctor.run': '診断を実行',
'doctor.running_short': '実行中…',
'auth.pair_button': 'ペアリング',
'auth.enter_code': '端末のワンタイムペアリングコードを入力してください',
'auth.code_placeholder': '6桁のコード',
'auth.pairing_progress': 'ペアリング中…',
'auth.logout': 'ログアウト',
'common.languages': '言語',
'common.select_language': '言語を選択',
'header.dashboard_tagline': 'ZeroClaw ダッシュボード',
};
export default ja;

View File

@ -0,0 +1,33 @@
import type { TranslationKeys } from './en';
const ko: Partial<TranslationKeys> = {
'nav.dashboard': '대시보드',
'nav.agent': '에이전트',
'nav.tools': '도구',
'nav.cron': '예약 작업',
'nav.integrations': '통합',
'nav.memory': '메모리',
'nav.config': '설정',
'nav.cost': '비용 추적',
'nav.logs': '로그',
'nav.doctor': '진단',
'dashboard.hero_title': '전기 런타임 대시보드',
'agent.placeholder': '메시지를 입력하세요…',
'tools.search': '도구 검색…',
'cron.add': '작업 추가',
'memory.add_memory': '메모리 추가',
'config.save': '저장',
'cost.token_statistics': '토큰 통계',
'logs.title': '실시간 로그',
'doctor.title': '시스템 진단',
'auth.pair_button': '페어링',
'auth.enter_code': '터미널에 표시된 일회용 페어링 코드를 입력하세요',
'auth.code_placeholder': '6자리 코드',
'auth.pairing_progress': '페어링 중…',
'auth.logout': '로그아웃',
'common.languages': '언어',
'common.select_language': '언어 선택',
'header.dashboard_tagline': 'ZeroClaw 대시보드',
};
export default ko;

View File

@ -0,0 +1,33 @@
import type { TranslationKeys } from './en';
const nb: Partial<TranslationKeys> = {
'nav.dashboard': 'Kontrollpanel',
'nav.agent': 'Agent',
'nav.tools': 'Verktøy',
'nav.cron': 'Planlagte jobber',
'nav.integrations': 'Integrasjoner',
'nav.memory': 'Minne',
'nav.config': 'Konfigurasjon',
'nav.cost': 'Kostnader',
'nav.logs': 'Logger',
'nav.doctor': 'Diagnostikk',
'dashboard.hero_title': 'Elektrisk runtime-kontrollpanel',
'agent.placeholder': 'Skriv en melding…',
'tools.search': 'Søk etter verktøy…',
'cron.add': 'Legg til jobb',
'memory.add_memory': 'Legg til minne',
'config.save': 'Lagre',
'cost.token_statistics': 'Tokenstatistikk',
'logs.title': 'Live-logger',
'doctor.title': 'Systemdiagnostikk',
'auth.pair_button': 'Koble til',
'auth.enter_code': 'Skriv inn engangskoden fra terminalen',
'auth.code_placeholder': '6-sifret kode',
'auth.pairing_progress': 'Kobler til…',
'auth.logout': 'Logg ut',
'common.languages': 'Språk',
'common.select_language': 'Velg språk',
'header.dashboard_tagline': 'ZeroClaw-kontrollpanel',
};
export default nb;

View File

@ -0,0 +1,33 @@
import type { TranslationKeys } from './en';
const nl: Partial<TranslationKeys> = {
'nav.dashboard': 'Dashboard',
'nav.agent': 'Agent',
'nav.tools': 'Tools',
'nav.cron': 'Geplande taken',
'nav.integrations': 'Integraties',
'nav.memory': 'Geheugen',
'nav.config': 'Configuratie',
'nav.cost': 'Kosten',
'nav.logs': 'Logs',
'nav.doctor': 'Diagnose',
'dashboard.hero_title': 'Elektrisch runtime-dashboard',
'agent.placeholder': 'Typ een bericht…',
'tools.search': 'Tools zoeken…',
'cron.add': 'Taak toevoegen',
'memory.add_memory': 'Geheugen toevoegen',
'config.save': 'Opslaan',
'cost.token_statistics': 'Tokenstatistieken',
'logs.title': 'Live-logs',
'doctor.title': 'Systeemdiagnose',
'auth.pair_button': 'Koppelen',
'auth.enter_code': 'Voer de eenmalige koppelcode uit de terminal in',
'auth.code_placeholder': '6-cijferige code',
'auth.pairing_progress': 'Koppelen…',
'auth.logout': 'Afmelden',
'common.languages': 'Talen',
'common.select_language': 'Kies taal',
'header.dashboard_tagline': 'ZeroClaw-dashboard',
};
export default nl;

View File

@ -0,0 +1,33 @@
import type { TranslationKeys } from './en';
const pl: Partial<TranslationKeys> = {
'nav.dashboard': 'Pulpit',
'nav.agent': 'Agent',
'nav.tools': 'Narzędzia',
'nav.cron': 'Zaplanowane zadania',
'nav.integrations': 'Integracje',
'nav.memory': 'Pamięć',
'nav.config': 'Konfiguracja',
'nav.cost': 'Koszty',
'nav.logs': 'Logi',
'nav.doctor': 'Diagnostyka',
'dashboard.hero_title': 'Elektryczny pulpit runtime',
'agent.placeholder': 'Wpisz wiadomość…',
'tools.search': 'Szukaj narzędzi…',
'cron.add': 'Dodaj zadanie',
'memory.add_memory': 'Dodaj pamięć',
'config.save': 'Zapisz',
'cost.token_statistics': 'Statystyki tokenów',
'logs.title': 'Logi na żywo',
'doctor.title': 'Diagnostyka systemu',
'auth.pair_button': 'Sparuj',
'auth.enter_code': 'Wprowadź jednorazowy kod parowania z terminala',
'auth.code_placeholder': '6-cyfrowy kod',
'auth.pairing_progress': 'Parowanie…',
'auth.logout': 'Wyloguj',
'common.languages': 'Języki',
'common.select_language': 'Wybierz język',
'header.dashboard_tagline': 'Pulpit ZeroClaw',
};
export default pl;

View File

@ -0,0 +1,33 @@
import type { TranslationKeys } from './en';
const pt: Partial<TranslationKeys> = {
'nav.dashboard': 'Painel',
'nav.agent': 'Agente',
'nav.tools': 'Ferramentas',
'nav.cron': 'Tarefas agendadas',
'nav.integrations': 'Integrações',
'nav.memory': 'Memória',
'nav.config': 'Configuração',
'nav.cost': 'Custos',
'nav.logs': 'Logs',
'nav.doctor': 'Diagnóstico',
'dashboard.hero_title': 'Painel elétrico do runtime',
'agent.placeholder': 'Digite uma mensagem…',
'tools.search': 'Buscar ferramentas…',
'cron.add': 'Adicionar tarefa',
'memory.add_memory': 'Adicionar memória',
'config.save': 'Salvar',
'cost.token_statistics': 'Estatísticas de tokens',
'logs.title': 'Logs ao vivo',
'doctor.title': 'Diagnóstico do sistema',
'auth.pair_button': 'Parear',
'auth.enter_code': 'Digite o código único de pareamento mostrado no terminal',
'auth.code_placeholder': 'Código de 6 dígitos',
'auth.pairing_progress': 'Pareando…',
'auth.logout': 'Sair',
'common.languages': 'Idiomas',
'common.select_language': 'Escolher idioma',
'header.dashboard_tagline': 'Painel do ZeroClaw',
};
export default pt;

View File

@ -0,0 +1,33 @@
import type { TranslationKeys } from './en';
const ro: Partial<TranslationKeys> = {
'nav.dashboard': 'Tablou de bord',
'nav.agent': 'Agent',
'nav.tools': 'Unelte',
'nav.cron': 'Sarcini programate',
'nav.integrations': 'Integrări',
'nav.memory': 'Memorie',
'nav.config': 'Configurație',
'nav.cost': 'Costuri',
'nav.logs': 'Jurnale',
'nav.doctor': 'Diagnostic',
'dashboard.hero_title': 'Tablou de bord runtime electric',
'agent.placeholder': 'Scrie un mesaj…',
'tools.search': 'Caută unelte…',
'cron.add': 'Adaugă sarcină',
'memory.add_memory': 'Adaugă memorie',
'config.save': 'Salvează',
'cost.token_statistics': 'Statistici tokenuri',
'logs.title': 'Jurnale live',
'doctor.title': 'Diagnostic sistem',
'auth.pair_button': 'Asociază',
'auth.enter_code': 'Introdu codul unic de asociere din terminal',
'auth.code_placeholder': 'Cod din 6 cifre',
'auth.pairing_progress': 'Asociere…',
'auth.logout': 'Deconectare',
'common.languages': 'Limbi',
'common.select_language': 'Alege limba',
'header.dashboard_tagline': 'Tabloul ZeroClaw',
};
export default ro;

View File

@ -0,0 +1,51 @@
import type { TranslationKeys } from './en';
const ru: Partial<TranslationKeys> = {
'nav.dashboard': 'Панель',
'nav.agent': 'Агент',
'nav.tools': 'Инструменты',
'nav.cron': 'Задания',
'nav.integrations': 'Интеграции',
'nav.memory': 'Память',
'nav.config': 'Конфигурация',
'nav.cost': 'Расходы',
'nav.logs': 'Логи',
'nav.doctor': 'Диагностика',
'dashboard.hero_title': 'Панель электрического рантайма',
'dashboard.live_gateway': 'Живой шлюз',
'dashboard.unpaired': 'Не сопряжено',
'agent.title': 'Чат агента',
'agent.placeholder': 'Введите сообщение…',
'agent.connecting': 'Подключение…',
'agent.connected': 'Подключено',
'agent.disconnected': 'Отключено',
'tools.search': 'Поиск инструментов…',
'tools.agent_tools': 'Инструменты агента',
'tools.cli_tools': 'CLI-инструменты',
'cron.add': 'Добавить задачу',
'cron.scheduled_tasks': 'Запланированные задания',
'integrations.title': 'Интеграции',
'memory.add_memory': 'Добавить память',
'memory.search_entries': 'Искать записи памяти…',
'config.save': 'Сохранить',
'config.saving': 'Сохранение…',
'cost.session': 'Стоимость сессии',
'cost.daily': 'Стоимость за день',
'cost.monthly': 'Стоимость за месяц',
'logs.title': 'Живые логи',
'logs.pause': 'Пауза',
'logs.resume': 'Продолжить',
'doctor.title': 'Диагностика системы',
'doctor.run': 'Запустить диагностику',
'doctor.running_short': 'Выполняется…',
'auth.pair_button': 'Сопрячь',
'auth.enter_code': 'Введите одноразовый код сопряжения из терминала',
'auth.code_placeholder': '6-значный код',
'auth.pairing_progress': 'Сопряжение…',
'auth.logout': 'Выйти',
'common.languages': 'Языки',
'common.select_language': 'Выберите язык',
'header.dashboard_tagline': 'Панель ZeroClaw',
};
export default ru;

View File

@ -0,0 +1,33 @@
import type { TranslationKeys } from './en';
const sv: Partial<TranslationKeys> = {
'nav.dashboard': 'Instrumentpanel',
'nav.agent': 'Agent',
'nav.tools': 'Verktyg',
'nav.cron': 'Schemalagda jobb',
'nav.integrations': 'Integrationer',
'nav.memory': 'Minne',
'nav.config': 'Konfiguration',
'nav.cost': 'Kostnader',
'nav.logs': 'Loggar',
'nav.doctor': 'Diagnostik',
'dashboard.hero_title': 'Elektrisk runtimepanel',
'agent.placeholder': 'Skriv ett meddelande…',
'tools.search': 'Sök verktyg…',
'cron.add': 'Lägg till jobb',
'memory.add_memory': 'Lägg till minne',
'config.save': 'Spara',
'cost.token_statistics': 'Tokenstatistik',
'logs.title': 'Live-loggar',
'doctor.title': 'Systemdiagnostik',
'auth.pair_button': 'Para',
'auth.enter_code': 'Ange engångskoden från terminalen',
'auth.code_placeholder': '6-siffrig kod',
'auth.pairing_progress': 'Parar…',
'auth.logout': 'Logga ut',
'common.languages': 'Språk',
'common.select_language': 'Välj språk',
'header.dashboard_tagline': 'ZeroClaw-panel',
};
export default sv;

View File

@ -0,0 +1,33 @@
import type { TranslationKeys } from './en';
const th: Partial<TranslationKeys> = {
'nav.dashboard': 'แดชบอร์ด',
'nav.agent': 'เอเจนต์',
'nav.tools': 'เครื่องมือ',
'nav.cron': 'งานที่ตั้งเวลา',
'nav.integrations': 'การเชื่อมต่อ',
'nav.memory': 'หน่วยความจำ',
'nav.config': 'การกำหนดค่า',
'nav.cost': 'ต้นทุน',
'nav.logs': 'บันทึก',
'nav.doctor': 'วินิจฉัย',
'dashboard.hero_title': 'แดชบอร์ดรันไทม์ไฟฟ้า',
'agent.placeholder': 'พิมพ์ข้อความ…',
'tools.search': 'ค้นหาเครื่องมือ…',
'cron.add': 'เพิ่มงาน',
'memory.add_memory': 'เพิ่มหน่วยความจำ',
'config.save': 'บันทึก',
'cost.token_statistics': 'สถิติโทเค็น',
'logs.title': 'บันทึกสด',
'doctor.title': 'วินิจฉัยระบบ',
'auth.pair_button': 'จับคู่',
'auth.enter_code': 'ป้อนรหัสจับคู่แบบใช้ครั้งเดียวจากเทอร์มินัล',
'auth.code_placeholder': 'รหัส 6 หลัก',
'auth.pairing_progress': 'กำลังจับคู่…',
'auth.logout': 'ออกจากระบบ',
'common.languages': 'ภาษา',
'common.select_language': 'เลือกภาษา',
'header.dashboard_tagline': 'แดชบอร์ด ZeroClaw',
};
export default th;

View File

@ -0,0 +1,33 @@
import type { TranslationKeys } from './en';
const tl: Partial<TranslationKeys> = {
'nav.dashboard': 'Dashboard',
'nav.agent': 'Ahente',
'nav.tools': 'Mga Tool',
'nav.cron': 'Naka-iskedyul na Trabaho',
'nav.integrations': 'Mga Integrasyon',
'nav.memory': 'Alaala',
'nav.config': 'Konpigurasyon',
'nav.cost': 'Pagsubaybay sa Gastos',
'nav.logs': 'Mga Log',
'nav.doctor': 'Diyagnostiko',
'dashboard.hero_title': 'Elektrikong Dashboard ng Runtime',
'agent.placeholder': 'Mag-type ng mensahe…',
'tools.search': 'Maghanap ng tool…',
'cron.add': 'Magdagdag ng gawain',
'memory.add_memory': 'Magdagdag ng alaala',
'config.save': 'I-save',
'cost.token_statistics': 'Estadistika ng Token',
'logs.title': 'Mga Live Log',
'doctor.title': 'Diyagnostiko ng System',
'auth.pair_button': 'Ipares',
'auth.enter_code': 'Ilagay ang isang beses na pairing code mula sa terminal',
'auth.code_placeholder': '6-digit na code',
'auth.pairing_progress': 'Pinapares…',
'auth.logout': 'Mag-logout',
'common.languages': 'Mga Wika',
'common.select_language': 'Piliin ang wika',
'header.dashboard_tagline': 'Dashboard ng ZeroClaw',
};
export default tl;

View File

@ -0,0 +1,157 @@
import type { TranslationKeys } from './en';
const tr: Partial<TranslationKeys> = {
'nav.dashboard': 'Kontrol Paneli',
'nav.agent': 'Ajan',
'nav.tools': 'Araçlar',
'nav.cron': 'Zamanlanmış Görevler',
'nav.integrations': 'Entegrasyonlar',
'nav.memory': 'Hafıza',
'nav.config': 'Yapılandırma',
'nav.cost': 'Maliyet Takibi',
'nav.logs': 'Kayıtlar',
'nav.doctor': 'Doktor',
'agent.title': 'Ajan Sohbeti',
'agent.send': 'Gönder',
'agent.placeholder': 'Bir mesaj yazın...',
'agent.connecting': 'Bağlanıyor...',
'agent.connected': 'Bağlı',
'agent.disconnected': 'Bağlantı Kesildi',
'agent.reconnecting': 'Yeniden bağlanıyor...',
'agent.thinking': 'Düşünüyor...',
'agent.tool_call': 'Araç Çağrısı',
'agent.tool_result': 'Araç Sonucu',
'agent.connection_error': 'Bağlantı hatası. Yeniden bağlanmaya çalışılıyor...',
'agent.failed_send': 'Mesaj gönderilemedi. Lütfen tekrar deneyin.',
'agent.empty_title': 'ZeroClaw Ajanı',
'agent.empty_subtitle': 'Konuşmayı başlatmak için bir mesaj gönderin',
'dashboard.title': 'Kontrol Paneli',
'dashboard.provider': 'Sağlayıcı',
'dashboard.model': 'Model',
'dashboard.uptime': 'Çalışma Süresi',
'dashboard.temperature': 'Sıcaklık',
'dashboard.gateway_port': 'Ağ Geçidi Portu',
'dashboard.locale': 'Yerel Ayar',
'dashboard.memory_backend': 'Hafıza Arka Ucu',
'dashboard.hero_eyebrow': 'ZeroClaw Komuta Güvertesi',
'dashboard.hero_title': 'Elektrik Çalışma Zamanı Paneli',
'dashboard.hero_subtitle': 'Gerçek zamanlı telemetri, maliyet akışı ve operasyon durumunu tek bir daraltılabilir yüzeyde görün.',
'dashboard.live_gateway': 'Canlı Ağ Geçidi',
'dashboard.unpaired': 'Eşleşmemiş',
'dashboard.provider_model': 'Sağlayıcı / Model',
'dashboard.since_last_restart': 'Son yeniden başlatmadan beri',
'dashboard.pairing_active': 'Eşleştirme etkin',
'dashboard.no_paired_devices': 'Eşleşmiş cihaz yok',
'dashboard.cost_pulse': 'Maliyet Nabzı',
'dashboard.cost_subtitle': 'Oturum, günlük ve aylık çalışma zamanı harcaması',
'dashboard.session': 'Oturum',
'dashboard.daily': 'Günlük',
'dashboard.monthly': 'Aylık',
'dashboard.channel_activity': 'Kanal Etkinliği',
'dashboard.channel_subtitle': 'Canlı entegrasyonlar ve rota bağlantısı',
'dashboard.no_channels': 'Hiç kanal yapılandırılmamış.',
'dashboard.active': 'Aktif',
'dashboard.inactive': 'Pasif',
'dashboard.component_health': 'Bileşen Sağlığı',
'dashboard.component_subtitle': 'Çalışma zamanı nabzı ve yeniden başlatma farkındalığı',
'dashboard.no_component_health': 'Şu anda bileşen sağlığı bilgisi yok.',
'dashboard.restarts': 'Yeniden Başlatmalar',
'tools.title': 'Mevcut Araçlar',
'tools.search': 'Araç ara...',
'tools.agent_tools': 'Ajan Araçları',
'tools.cli_tools': 'CLI Araçları',
'tools.no_search_results': 'Aramanızla eşleşen araç yok.',
'tools.parameter_schema': 'Parametre Şeması',
'tools.path': 'Yol',
'tools.version': 'Sürüm',
'cron.title': 'Zamanlanmış Görevler',
'cron.add': 'Görev Ekle',
'cron.scheduled_tasks': 'Zamanlanmış Görevler',
'cron.add_cron_job': 'Cron Görevi Ekle',
'cron.name_optional': 'Ad (isteğe bağlı)',
'cron.schedule_required_command_required': 'Zamanlama ve komut gereklidir.',
'cron.adding': 'Ekleniyor...',
'cron.no_tasks_configured': 'Zamanlanmış görev yapılandırılmamış.',
'cron.load_failed': 'Cron görevleri yüklenemedi',
'cron.failed_add': 'Görev eklenemedi',
'cron.failed_delete': 'Görev silinemedi',
'cron.delete_prompt': 'Silinsin mi?',
'cron.disabled': 'Devre Dışı',
'integrations.title': 'Entegrasyonlar',
'integrations.available': 'Mevcut',
'integrations.active': 'Aktif',
'integrations.coming_soon': 'Yakında',
'integrations.empty': 'Entegrasyon bulunamadı.',
'integrations.load_failed': 'Entegrasyonlar yüklenemedi',
'memory.title': 'Hafıza Deposu',
'memory.add_memory': 'Hafıza Ekle',
'memory.search_entries': 'Hafıza girdilerinde ara...',
'memory.all_categories': 'Tüm Kategoriler',
'memory.search_button': 'Ara',
'memory.load_failed': 'Hafıza yüklenemedi',
'memory.key_content_required': 'Anahtar ve içerik gereklidir.',
'memory.failed_store': 'Hafıza kaydedilemedi',
'memory.failed_delete': 'Hafıza silinemedi',
'memory.category_optional': 'Kategori (isteğe bağlı)',
'memory.key_placeholder': 'örn. kullanici_tercihleri',
'memory.content_placeholder': 'Hafıza içeriği...',
'memory.category_placeholder': 'örn. tercihler, bağlam, gerçekler',
'memory.saving': 'Kaydediliyor...',
'memory.delete_prompt': 'Silinsin mi?',
'config.title': 'Yapılandırma',
'config.save': 'Kaydet',
'config.saved': 'Yapılandırma başarıyla kaydedildi.',
'config.saving': 'Kaydediliyor...',
'config.masked_title': 'Hassas alanlar maskelendi',
'config.masked_description': 'Güvenlik için API anahtarları, belirteçler ve parolalar gizlenir. Maskelenmiş bir alanı güncellemek için tüm maskeli değeri yeni değerinizle değiştirin.',
'config.toml_configuration': 'TOML Yapılandırması',
'config.lines': 'satır',
'cost.title': 'Maliyet Takibi',
'cost.session': 'Oturum Maliyeti',
'cost.daily': 'Günlük Maliyet',
'cost.monthly': 'Aylık Maliyet',
'cost.total_requests': 'Toplam İstek',
'cost.token_statistics': 'Belirteç İstatistikleri',
'cost.avg_tokens_per_request': 'İstek Başına Ort. Belirteç',
'cost.cost_per_1k_tokens': '1K Belirteç Başına Maliyet',
'cost.model_breakdown': 'Model Dağılımı',
'cost.no_model_data': 'Model verisi yok.',
'cost.share': 'Pay',
'cost.load_failed': 'Maliyet verisi yüklenemedi',
'logs.title': 'Canlı Kayıtlar',
'logs.pause': 'Duraklat',
'logs.resume': 'Sürdür',
'logs.events': 'olay',
'logs.jump_to_bottom': 'Alta git',
'logs.filter_label': 'Filtre:',
'logs.paused_stream': 'Kayıt akışı duraklatıldı.',
'logs.waiting_for_events': 'Olaylar bekleniyor...',
'doctor.title': 'Sistem Teşhisleri',
'doctor.run': 'Teşhisleri Çalıştır',
'doctor.running': 'Teşhisler çalıştırılıyor...',
'doctor.running_short': 'Çalışıyor...',
'doctor.running_hint': 'Bu birkaç saniye sürebilir.',
'doctor.warn': 'Uyarı',
'doctor.issues_found': 'Sorunlar Bulundu',
'doctor.warnings': 'Uyarılar',
'doctor.all_clear': 'Temiz',
'doctor.instructions': 'ZeroClaw kurulumunuzu kontrol etmek için "Teşhisleri Çalıştır" düğmesine tıklayın.',
'auth.pair': 'Cihazı Eşle',
'auth.pair_button': 'Eşle',
'auth.logout': ıkış Yap',
'auth.enter_code': 'Terminalinizdeki tek kullanımlık eşleştirme kodunu girin',
'auth.code_placeholder': '6 haneli kod',
'auth.pairing_progress': 'Eşleştiriliyor...',
'common.languages': 'Diller',
'common.select_language': 'Dil seçin',
'common.connecting': 'Bağlanıyor...',
'header.dashboard_tagline': 'ZeroClaw paneli',
'sidebar.gateway_dashboard': 'Ağ Geçidi + Panel',
'sidebar.runtime_mode': 'Çalışma Modu',
'navigation.open': 'Gezinmeyi aç',
'navigation.close': 'Gezinmeyi kapat',
'navigation.expand': 'Gezinmeyi genişlet',
'navigation.collapse': 'Gezinmeyi daralt',
};
export default tr;

View File

@ -0,0 +1,33 @@
import type { TranslationKeys } from './en';
const uk: Partial<TranslationKeys> = {
'nav.dashboard': 'Панель',
'nav.agent': 'Агент',
'nav.tools': 'Інструменти',
'nav.cron': 'Заплановані завдання',
'nav.integrations': 'Інтеграції',
'nav.memory': 'Пам\u2019ять',
'nav.config': 'Конфігурація',
'nav.cost': 'Витрати',
'nav.logs': 'Журнали',
'nav.doctor': 'Діагностика',
'dashboard.hero_title': 'Електрична панель runtime',
'agent.placeholder': 'Введіть повідомлення…',
'tools.search': 'Пошук інструментів…',
'cron.add': 'Додати завдання',
'memory.add_memory': 'Додати пам\u2019ять',
'config.save': 'Зберегти',
'cost.token_statistics': 'Статистика токенів',
'logs.title': 'Живі журнали',
'doctor.title': 'Діагностика системи',
'auth.pair_button': 'З\u2019єднати',
'auth.enter_code': 'Введіть одноразовий код з\u2019єднання з термінала',
'auth.code_placeholder': '6-значний код',
'auth.pairing_progress': 'З\u2019єднання…',
'auth.logout': 'Вийти',
'common.languages': 'Мови',
'common.select_language': 'Оберіть мову',
'header.dashboard_tagline': 'Панель ZeroClaw',
};
export default uk;

View File

@ -0,0 +1,33 @@
import type { TranslationKeys } from './en';
const ur: Partial<TranslationKeys> = {
'nav.dashboard': 'ڈیش بورڈ',
'nav.agent': 'ایجنٹ',
'nav.tools': 'ٹولز',
'nav.cron': 'شیڈول شدہ کام',
'nav.integrations': 'انضمامات',
'nav.memory': 'میموری',
'nav.config': 'ترتیبات',
'nav.cost': 'لاگت',
'nav.logs': 'لاگز',
'nav.doctor': 'تشخیص',
'dashboard.hero_title': 'الیکٹرک رن ٹائم ڈیش بورڈ',
'agent.placeholder': 'پیغام لکھیں…',
'tools.search': 'ٹولز تلاش کریں…',
'cron.add': 'کام شامل کریں',
'memory.add_memory': 'میموری شامل کریں',
'config.save': 'محفوظ کریں',
'cost.token_statistics': 'ٹوکن کے اعدادوشمار',
'logs.title': 'لائیو لاگز',
'doctor.title': 'سسٹم تشخیص',
'auth.pair_button': 'جوڑیں',
'auth.enter_code': 'ٹرمینل سے ایک بار استعمال ہونے والا پیئرنگ کوڈ درج کریں',
'auth.code_placeholder': '6 ہندسوں کا کوڈ',
'auth.pairing_progress': 'جوڑا جا رہا ہے…',
'auth.logout': 'لاگ آؤٹ',
'common.languages': 'زبانیں',
'common.select_language': 'زبان منتخب کریں',
'header.dashboard_tagline': 'ZeroClaw ڈیش بورڈ',
};
export default ur;

View File

@ -0,0 +1,51 @@
import type { TranslationKeys } from './en';
const vi: Partial<TranslationKeys> = {
'nav.dashboard': 'Bảng điều khiển',
'nav.agent': 'Tác tử',
'nav.tools': 'Công cụ',
'nav.cron': 'Lịch tác vụ',
'nav.integrations': 'Tích hợp',
'nav.memory': 'Bộ nhớ',
'nav.config': 'Cấu hình',
'nav.cost': 'Chi phí',
'nav.logs': 'Nhật ký',
'nav.doctor': 'Chẩn đoán',
'dashboard.hero_title': 'Bảng điều khiển runtime điện xanh',
'dashboard.live_gateway': 'Cổng hoạt động',
'dashboard.unpaired': 'Chưa ghép đôi',
'agent.title': 'Trò chuyện với tác tử',
'agent.placeholder': 'Nhập tin nhắn…',
'agent.connecting': 'Đang kết nối…',
'agent.connected': 'Đã kết nối',
'agent.disconnected': 'Đã ngắt kết nối',
'tools.search': 'Tìm công cụ…',
'tools.agent_tools': 'Công cụ tác tử',
'tools.cli_tools': 'Công cụ CLI',
'cron.add': 'Thêm tác vụ',
'cron.scheduled_tasks': 'Tác vụ đã lên lịch',
'integrations.title': 'Tích hợp',
'memory.add_memory': 'Thêm bộ nhớ',
'memory.search_entries': 'Tìm trong bộ nhớ…',
'config.save': 'Lưu',
'config.saving': 'Đang lưu…',
'cost.session': 'Chi phí phiên',
'cost.daily': 'Chi phí ngày',
'cost.monthly': 'Chi phí tháng',
'logs.title': 'Nhật ký trực tiếp',
'logs.pause': 'Tạm dừng',
'logs.resume': 'Tiếp tục',
'doctor.title': 'Chẩn đoán hệ thống',
'doctor.run': 'Chạy chẩn đoán',
'doctor.running_short': 'Đang chạy…',
'auth.pair_button': 'Ghép đôi',
'auth.enter_code': 'Nhập mã ghép đôi một lần từ terminal',
'auth.code_placeholder': 'Mã 6 chữ số',
'auth.pairing_progress': 'Đang ghép đôi…',
'auth.logout': 'Đăng xuất',
'common.languages': 'Ngôn ngữ',
'common.select_language': 'Chọn ngôn ngữ',
'header.dashboard_tagline': 'Bảng điều khiển ZeroClaw',
};
export default vi;

View File

@ -0,0 +1,51 @@
import type { TranslationKeys } from './en';
const zhCN: Partial<TranslationKeys> = {
'nav.dashboard': '仪表盘',
'nav.agent': '代理',
'nav.tools': '工具',
'nav.cron': '定时任务',
'nav.integrations': '集成',
'nav.memory': '记忆',
'nav.config': '配置',
'nav.cost': '成本跟踪',
'nav.logs': '日志',
'nav.doctor': '诊断',
'dashboard.hero_title': '电光运行仪表盘',
'dashboard.live_gateway': '在线网关',
'dashboard.unpaired': '未配对',
'agent.title': '代理聊天',
'agent.placeholder': '输入消息…',
'agent.connecting': '正在连接…',
'agent.connected': '已连接',
'agent.disconnected': '已断开',
'tools.search': '搜索工具…',
'tools.agent_tools': '代理工具',
'tools.cli_tools': 'CLI 工具',
'cron.add': '添加任务',
'cron.scheduled_tasks': '定时任务',
'integrations.title': '集成',
'memory.add_memory': '添加记忆',
'memory.search_entries': '搜索记忆条目…',
'config.save': '保存',
'config.saving': '正在保存…',
'cost.session': '会话成本',
'cost.daily': '每日成本',
'cost.monthly': '每月成本',
'logs.title': '实时日志',
'logs.pause': '暂停',
'logs.resume': '继续',
'doctor.title': '系统诊断',
'doctor.run': '运行诊断',
'doctor.running_short': '运行中…',
'auth.pair_button': '配对',
'auth.enter_code': '输入终端中的一次性配对码',
'auth.code_placeholder': '6 位代码',
'auth.pairing_progress': '正在配对…',
'auth.logout': '退出',
'common.languages': '语言',
'common.select_language': '选择语言',
'header.dashboard_tagline': 'ZeroClaw 仪表盘',
};
export default zhCN;

View File

@ -0,0 +1,75 @@
import type { Locale, LocaleDocumentTarget } from './types';
import { getLocaleDirection } from './languages';
import { translations } from './locales';
const LOCALE_PREFIX_MAP = new Map<string, Locale>([
['zh', 'zh-CN'],
['ja', 'ja'],
['ko', 'ko'],
['vi', 'vi'],
['tl', 'tl'],
['es', 'es'],
['pt', 'pt'],
['it', 'it'],
['de', 'de'],
['fr', 'fr'],
['ar', 'ar'],
['hi', 'hi'],
['ru', 'ru'],
['bn', 'bn'],
['iw', 'he'],
['he', 'he'],
['pl', 'pl'],
['cs', 'cs'],
['nl', 'nl'],
['tr', 'tr'],
['uk', 'uk'],
['id', 'id'],
['th', 'th'],
['ur', 'ur'],
['ro', 'ro'],
['sv', 'sv'],
['el', 'el'],
['hu', 'hu'],
['fi', 'fi'],
['da', 'da'],
['nb', 'nb'],
['no', 'nb'],
]);
export function coerceLocale(locale: string | undefined): Locale {
if (!locale) return 'en';
const prefix = locale.toLowerCase().split(/[-_]/)[0];
return LOCALE_PREFIX_MAP.get(prefix) ?? 'en';
}
let currentLocale: Locale = 'en';
export function getLocale(): Locale {
return currentLocale;
}
export function setLocale(locale: Locale): void {
currentLocale = locale;
}
export function t(key: string): string {
return translations[currentLocale]?.[key] ?? translations.en[key] ?? key;
}
export function tLocale(key: string, locale: Locale): string {
return translations[locale]?.[key] ?? translations.en[key] ?? key;
}
export function applyLocaleToDocument(locale: Locale, target: LocaleDocumentTarget): void {
const direction = getLocaleDirection(locale);
if (target.documentElement) {
target.documentElement.lang = locale;
target.documentElement.dir = direction;
}
if (target.body) {
target.body.dir = direction;
}
}

46
web/src/lib/i18n/types.ts Normal file
View File

@ -0,0 +1,46 @@
export type Locale =
| 'en'
| 'zh-CN'
| 'ja'
| 'ko'
| 'vi'
| 'tl'
| 'es'
| 'pt'
| 'it'
| 'de'
| 'fr'
| 'ar'
| 'hi'
| 'ru'
| 'bn'
| 'he'
| 'pl'
| 'cs'
| 'nl'
| 'tr'
| 'uk'
| 'id'
| 'th'
| 'ur'
| 'ro'
| 'sv'
| 'el'
| 'hu'
| 'fi'
| 'da'
| 'nb';
export type LocaleDirection = 'ltr' | 'rtl';
export interface LanguageOption {
value: Locale;
label: string;
flag: string;
direction: LocaleDirection;
}
export interface LocaleDocumentTarget {
documentElement?: { lang?: string; dir?: string };
body?: { dir?: string } | null;
}

View File

@ -6,8 +6,8 @@ import './index.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
{/* Vite base '/_app/' scopes static asset URLs only; app routes stay rooted at '/' for SPA fallback. */}
<BrowserRouter basename="/">
{/* Match React Router paths to Vite's public base so in-app links resolve under /_app/. */}
<BrowserRouter basename="/_app">
<App />
</BrowserRouter>
</React.StrictMode>

View File

@ -2,6 +2,7 @@ import { useState, useEffect, useRef } from 'react';
import { Send, Bot, User, AlertCircle } from 'lucide-react';
import type { WsMessage } from '@/types/api';
import { WebSocketClient } from '@/lib/ws';
import { t } from '@/lib/i18n';
interface ChatMessage {
id: string;
@ -35,7 +36,7 @@ export default function AgentChat() {
};
ws.onError = () => {
setError('Connection error. Attempting to reconnect...');
setError(t('agent.connection_error'));
};
ws.onMessage = (msg: WsMessage) => {
@ -67,37 +68,37 @@ export default function AgentChat() {
case 'tool_call':
setMessages((prev) => [
...prev,
{
id: crypto.randomUUID(),
role: 'agent',
content: `[Tool Call] ${msg.name ?? 'unknown'}(${JSON.stringify(msg.args ?? {})})`,
timestamp: new Date(),
},
]);
{
id: crypto.randomUUID(),
role: 'agent',
content: `[${t('agent.tool_call')}] ${msg.name ?? 'unknown'}(${JSON.stringify(msg.args ?? {})})`,
timestamp: new Date(),
},
]);
break;
case 'tool_result':
setMessages((prev) => [
...prev,
{
id: crypto.randomUUID(),
role: 'agent',
content: `[Tool Result] ${msg.output ?? ''}`,
timestamp: new Date(),
},
]);
{
id: crypto.randomUUID(),
role: 'agent',
content: `[${t('agent.tool_result')}] ${msg.output ?? ''}`,
timestamp: new Date(),
},
]);
break;
case 'error':
setMessages((prev) => [
...prev,
{
id: crypto.randomUUID(),
role: 'agent',
content: `[Error] ${msg.message ?? 'Unknown error'}`,
timestamp: new Date(),
},
]);
{
id: crypto.randomUUID(),
role: 'agent',
content: `[${t('doctor.error')}] ${msg.message ?? t('agent.unknown_error')}`,
timestamp: new Date(),
},
]);
setTyping(false);
pendingContentRef.current = '';
break;
@ -135,7 +136,7 @@ export default function AgentChat() {
setTyping(true);
pendingContentRef.current = '';
} catch {
setError('Failed to send message. Please try again.');
setError(t('agent.failed_send'));
}
setInput('');
@ -164,8 +165,8 @@ export default function AgentChat() {
{messages.length === 0 && (
<div className="flex flex-col items-center justify-center h-full text-gray-500">
<Bot className="h-12 w-12 mb-3 text-gray-600" />
<p className="text-lg font-medium">ZeroClaw Agent</p>
<p className="text-sm mt-1">Send a message to start the conversation</p>
<p className="text-lg font-medium">{t('agent.empty_title')}</p>
<p className="text-sm mt-1">{t('agent.empty_subtitle')}</p>
</div>
)}
@ -219,7 +220,7 @@ export default function AgentChat() {
<span className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '150ms' }} />
<span className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '300ms' }} />
</div>
<p className="text-xs text-gray-500 mt-1">Typing...</p>
<p className="text-xs text-gray-500 mt-1">{t('agent.thinking')}</p>
</div>
</div>
)}
@ -234,10 +235,11 @@ export default function AgentChat() {
<input
ref={inputRef}
type="text"
data-testid="chat-input"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={connected ? 'Type a message...' : 'Connecting...'}
placeholder={connected ? t('agent.placeholder') : t('agent.connecting')}
disabled={!connected}
className="w-full bg-gray-800 border border-gray-700 rounded-xl px-4 py-3 text-sm text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:opacity-50"
/>
@ -257,7 +259,7 @@ export default function AgentChat() {
}`}
/>
<span className="text-xs text-gray-500">
{connected ? 'Connected' : 'Disconnected'}
{connected ? t('agent.connected') : t('agent.disconnected')}
</span>
</div>
</div>

View File

@ -7,6 +7,7 @@ import {
ShieldAlert,
} from 'lucide-react';
import { getConfig, putConfig } from '@/lib/api';
import { t } from '@/lib/i18n';
export default function Config() {
const [config, setConfig] = useState('');
@ -31,9 +32,9 @@ export default function Config() {
setSuccess(null);
try {
await putConfig(config);
setSuccess('Configuration saved successfully.');
setSuccess(t('config.saved'));
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Failed to save configuration');
setError(err instanceof Error ? err.message : t('config.error'));
} finally {
setSaving(false);
}
@ -60,7 +61,7 @@ export default function Config() {
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Settings className="h-5 w-5 text-blue-400" />
<h2 className="text-base font-semibold text-white">Configuration</h2>
<h2 className="text-base font-semibold text-white">{t('config.title')}</h2>
</div>
<button
onClick={handleSave}
@ -68,7 +69,7 @@ export default function Config() {
className="flex items-center gap-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium px-4 py-2 rounded-lg transition-colors disabled:opacity-50"
>
<Save className="h-4 w-4" />
{saving ? 'Saving...' : 'Save'}
{saving ? t('config.saving') : t('config.save')}
</button>
</div>
@ -77,11 +78,10 @@ export default function Config() {
<ShieldAlert className="h-5 w-5 text-yellow-400 flex-shrink-0 mt-0.5" />
<div>
<p className="text-sm text-yellow-300 font-medium">
Sensitive fields are masked
{t('config.masked_title')}
</p>
<p className="text-sm text-yellow-400/70 mt-0.5">
API keys, tokens, and passwords are hidden for security. To update a
masked field, replace the entire masked value with your new value.
{t('config.masked_description')}
</p>
</div>
</div>
@ -106,10 +106,10 @@ export default function Config() {
<div className="bg-gray-900 rounded-xl border border-gray-800 overflow-hidden">
<div className="flex items-center justify-between px-4 py-2 border-b border-gray-800 bg-gray-800/50">
<span className="text-xs text-gray-400 font-medium uppercase tracking-wider">
TOML Configuration
{t('config.toml_configuration')}
</span>
<span className="text-xs text-gray-500">
{config.split('\n').length} lines
{config.split('\n').length} {t('config.lines')}
</span>
</div>
<textarea

View File

@ -7,6 +7,7 @@ import {
} from 'lucide-react';
import type { CostSummary } from '@/types/api';
import { getCost } from '@/lib/api';
import { t } from '@/lib/i18n';
function formatUSD(value: number): string {
return `$${value.toFixed(4)}`;
@ -28,7 +29,7 @@ export default function Cost() {
return (
<div className="p-6">
<div className="rounded-lg bg-red-900/30 border border-red-700 p-4 text-red-300">
Failed to load cost data: {error}
{t('cost.load_failed')}: {error}
</div>
</div>
);
@ -53,7 +54,7 @@ export default function Cost() {
<div className="p-2 bg-blue-600/20 rounded-lg">
<DollarSign className="h-5 w-5 text-blue-400" />
</div>
<span className="text-sm text-gray-400">Session Cost</span>
<span className="text-sm text-gray-400">{t('cost.session')}</span>
</div>
<p className="text-2xl font-bold text-white">
{formatUSD(cost.session_cost_usd)}
@ -65,7 +66,7 @@ export default function Cost() {
<div className="p-2 bg-green-600/20 rounded-lg">
<TrendingUp className="h-5 w-5 text-green-400" />
</div>
<span className="text-sm text-gray-400">Daily Cost</span>
<span className="text-sm text-gray-400">{t('cost.daily')}</span>
</div>
<p className="text-2xl font-bold text-white">
{formatUSD(cost.daily_cost_usd)}
@ -77,7 +78,7 @@ export default function Cost() {
<div className="p-2 bg-purple-600/20 rounded-lg">
<Layers className="h-5 w-5 text-purple-400" />
</div>
<span className="text-sm text-gray-400">Monthly Cost</span>
<span className="text-sm text-gray-400">{t('cost.monthly')}</span>
</div>
<p className="text-2xl font-bold text-white">
{formatUSD(cost.monthly_cost_usd)}
@ -89,7 +90,7 @@ export default function Cost() {
<div className="p-2 bg-orange-600/20 rounded-lg">
<Hash className="h-5 w-5 text-orange-400" />
</div>
<span className="text-sm text-gray-400">Total Requests</span>
<span className="text-sm text-gray-400">{t('cost.total_requests')}</span>
</div>
<p className="text-2xl font-bold text-white">
{cost.request_count.toLocaleString()}
@ -100,17 +101,17 @@ export default function Cost() {
{/* Token Statistics */}
<div className="bg-gray-900 rounded-xl border border-gray-800 p-5">
<h3 className="text-base font-semibold text-white mb-4">
Token Statistics
{t('cost.token_statistics')}
</h3>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="bg-gray-800/50 rounded-lg p-4">
<p className="text-sm text-gray-400">Total Tokens</p>
<p className="text-sm text-gray-400">{t('cost.total_tokens')}</p>
<p className="text-xl font-bold text-white mt-1">
{cost.total_tokens.toLocaleString()}
</p>
</div>
<div className="bg-gray-800/50 rounded-lg p-4">
<p className="text-sm text-gray-400">Avg Tokens / Request</p>
<p className="text-sm text-gray-400">{t('cost.avg_tokens_per_request')}</p>
<p className="text-xl font-bold text-white mt-1">
{cost.request_count > 0
? Math.round(cost.total_tokens / cost.request_count).toLocaleString()
@ -118,7 +119,7 @@ export default function Cost() {
</p>
</div>
<div className="bg-gray-800/50 rounded-lg p-4">
<p className="text-sm text-gray-400">Cost per 1K Tokens</p>
<p className="text-sm text-gray-400">{t('cost.cost_per_1k_tokens')}</p>
<p className="text-xl font-bold text-white mt-1">
{cost.total_tokens > 0
? formatUSD((cost.monthly_cost_usd / cost.total_tokens) * 1000)
@ -132,12 +133,12 @@ export default function Cost() {
<div className="bg-gray-900 rounded-xl border border-gray-800 overflow-hidden">
<div className="px-5 py-4 border-b border-gray-800">
<h3 className="text-base font-semibold text-white">
Model Breakdown
{t('cost.model_breakdown')}
</h3>
</div>
{models.length === 0 ? (
<div className="p-8 text-center text-gray-500">
No model data available.
{t('cost.no_model_data')}
</div>
) : (
<div className="overflow-x-auto">
@ -145,19 +146,19 @@ export default function Cost() {
<thead>
<tr className="border-b border-gray-800">
<th className="text-left px-5 py-3 text-gray-400 font-medium">
Model
{t('cost.model')}
</th>
<th className="text-right px-5 py-3 text-gray-400 font-medium">
Cost
{t('cost.usd')}
</th>
<th className="text-right px-5 py-3 text-gray-400 font-medium">
Tokens
{t('cost.tokens')}
</th>
<th className="text-right px-5 py-3 text-gray-400 font-medium">
Requests
{t('cost.requests')}
</th>
<th className="text-left px-5 py-3 text-gray-400 font-medium">
Share
{t('cost.share')}
</th>
</tr>
</thead>

View File

@ -10,6 +10,7 @@ import {
} from 'lucide-react';
import type { CronJob } from '@/types/api';
import { getCronJobs, addCronJob, deleteCronJob } from '@/lib/api';
import { t } from '@/lib/i18n';
function formatDate(iso: string | null): string {
if (!iso) return '-';
@ -45,7 +46,7 @@ export default function Cron() {
const handleAdd = async () => {
if (!formSchedule.trim() || !formCommand.trim()) {
setFormError('Schedule and command are required.');
setFormError(t('cron.schedule_required_command_required'));
return;
}
setSubmitting(true);
@ -62,7 +63,7 @@ export default function Cron() {
setFormSchedule('');
setFormCommand('');
} catch (err: unknown) {
setFormError(err instanceof Error ? err.message : 'Failed to add job');
setFormError(err instanceof Error ? err.message : t('cron.failed_add'));
} finally {
setSubmitting(false);
}
@ -73,7 +74,7 @@ export default function Cron() {
await deleteCronJob(id);
setJobs((prev) => prev.filter((j) => j.id !== id));
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Failed to delete job');
setError(err instanceof Error ? err.message : t('cron.failed_delete'));
} finally {
setConfirmDelete(null);
}
@ -97,7 +98,7 @@ export default function Cron() {
return (
<div className="p-6">
<div className="rounded-lg bg-red-900/30 border border-red-700 p-4 text-red-300">
Failed to load cron jobs: {error}
{t('cron.load_failed')}: {error}
</div>
</div>
);
@ -118,7 +119,7 @@ export default function Cron() {
<div className="flex items-center gap-2">
<Clock className="h-5 w-5 text-blue-400" />
<h2 className="text-base font-semibold text-white">
Scheduled Tasks ({jobs.length})
{t('cron.scheduled_tasks')} ({jobs.length})
</h2>
</div>
<button
@ -126,7 +127,7 @@ export default function Cron() {
className="flex items-center gap-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium px-4 py-2 rounded-lg transition-colors"
>
<Plus className="h-4 w-4" />
Add Job
{t('cron.add')}
</button>
</div>
@ -135,7 +136,7 @@ export default function Cron() {
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50">
<div className="bg-gray-900 border border-gray-700 rounded-xl p-6 w-full max-w-md mx-4">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-white">Add Cron Job</h3>
<h3 className="text-lg font-semibold text-white">{t('cron.add_cron_job')}</h3>
<button
onClick={() => {
setShowForm(false);
@ -156,7 +157,7 @@ export default function Cron() {
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-300 mb-1">
Name (optional)
{t('cron.name_optional')}
</label>
<input
type="text"
@ -168,7 +169,7 @@ export default function Cron() {
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-1">
Schedule <span className="text-red-400">*</span>
{t('cron.schedule')} <span className="text-red-400">*</span>
</label>
<input
type="text"
@ -180,7 +181,7 @@ export default function Cron() {
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-1">
Command <span className="text-red-400">*</span>
{t('cron.command')} <span className="text-red-400">*</span>
</label>
<input
type="text"
@ -200,14 +201,14 @@ export default function Cron() {
}}
className="px-4 py-2 text-sm font-medium text-gray-300 hover:text-white border border-gray-700 rounded-lg hover:bg-gray-800 transition-colors"
>
Cancel
{t('common.cancel')}
</button>
<button
onClick={handleAdd}
disabled={submitting}
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors disabled:opacity-50"
>
{submitting ? 'Adding...' : 'Add Job'}
{submitting ? t('cron.adding') : t('cron.add')}
</button>
</div>
</div>
@ -218,7 +219,7 @@ export default function Cron() {
{jobs.length === 0 ? (
<div className="bg-gray-900 rounded-xl border border-gray-800 p-8 text-center">
<Clock className="h-10 w-10 text-gray-600 mx-auto mb-3" />
<p className="text-gray-400">No scheduled tasks configured.</p>
<p className="text-gray-400">{t('cron.no_tasks_configured')}</p>
</div>
) : (
<div className="bg-gray-900 rounded-xl border border-gray-800 overflow-x-auto">
@ -229,22 +230,22 @@ export default function Cron() {
ID
</th>
<th className="text-left px-4 py-3 text-gray-400 font-medium">
Name
{t('cron.name')}
</th>
<th className="text-left px-4 py-3 text-gray-400 font-medium">
Command
{t('cron.command')}
</th>
<th className="text-left px-4 py-3 text-gray-400 font-medium">
Next Run
{t('cron.next_run')}
</th>
<th className="text-left px-4 py-3 text-gray-400 font-medium">
Last Status
{t('cron.last_status')}
</th>
<th className="text-left px-4 py-3 text-gray-400 font-medium">
Enabled
{t('cron.enabled')}
</th>
<th className="text-right px-4 py-3 text-gray-400 font-medium">
Actions
{t('common.actions')}
</th>
</tr>
</thead>
@ -282,24 +283,24 @@ export default function Cron() {
: 'bg-gray-800 text-gray-500 border border-gray-700'
}`}
>
{job.enabled ? 'Enabled' : 'Disabled'}
{job.enabled ? t('cron.enabled') : t('cron.disabled')}
</span>
</td>
<td className="px-4 py-3 text-right">
{confirmDelete === job.id ? (
<div className="flex items-center justify-end gap-2">
<span className="text-xs text-red-400">Delete?</span>
<span className="text-xs text-red-400">{t('cron.delete_prompt')}</span>
<button
onClick={() => handleDelete(job.id)}
className="text-red-400 hover:text-red-300 text-xs font-medium"
>
Yes
{t('common.yes')}
</button>
<button
onClick={() => setConfirmDelete(null)}
className="text-gray-400 hover:text-white text-xs font-medium"
>
No
{t('common.no')}
</button>
</div>
) : (

View File

@ -1,15 +1,38 @@
import { useState, useEffect } from 'react';
import { useEffect, useState } from 'react';
import type { ComponentType, ReactNode, SVGProps } from 'react';
import {
Cpu,
Clock,
Globe,
Database,
Activity,
ChevronDown,
Clock3,
Cpu,
Database,
DollarSign,
Globe2,
Radio,
ShieldCheck,
Sparkles,
} from 'lucide-react';
import type { StatusResponse, CostSummary } from '@/types/api';
import { getStatus, getCost } from '@/lib/api';
import type { CostSummary, StatusResponse } from '@/types/api';
import { getCost, getStatus } from '@/lib/api';
import { t } from '@/lib/i18n';
type DashboardSectionKey = 'cost' | 'channels' | 'health';
interface DashboardSectionState {
cost: boolean;
channels: boolean;
health: boolean;
}
interface CollapsibleSectionProps {
title: string;
subtitle: string;
icon: ComponentType<SVGProps<SVGSVGElement>>;
sectionKey: DashboardSectionKey;
openState: DashboardSectionState;
onToggle: (section: DashboardSectionKey) => void;
children: ReactNode;
}
function formatUptime(seconds: number): string {
const d = Math.floor(seconds / 86400);
@ -28,13 +51,13 @@ function healthColor(status: string): string {
switch (status.toLowerCase()) {
case 'ok':
case 'healthy':
return 'bg-green-500';
return 'bg-emerald-400';
case 'warn':
case 'warning':
case 'degraded':
return 'bg-yellow-500';
return 'bg-amber-400';
default:
return 'bg-red-500';
return 'bg-rose-500';
}
}
@ -42,44 +65,105 @@ function healthBorder(status: string): string {
switch (status.toLowerCase()) {
case 'ok':
case 'healthy':
return 'border-green-500/30';
return 'border-emerald-500/30';
case 'warn':
case 'warning':
case 'degraded':
return 'border-yellow-500/30';
return 'border-amber-400/30';
default:
return 'border-red-500/30';
return 'border-rose-500/35';
}
}
function CollapsibleSection({
title,
subtitle,
icon: Icon,
sectionKey,
openState,
onToggle,
children,
}: CollapsibleSectionProps) {
const isOpen = openState[sectionKey];
return (
<section className="electric-card motion-rise">
<button
type="button"
onClick={() => onToggle(sectionKey)}
aria-expanded={isOpen}
className="group flex w-full items-center justify-between gap-4 rounded-xl px-4 py-4 text-left md:px-5"
>
<div className="flex items-center gap-3">
<div className="electric-icon h-10 w-10 rounded-xl">
<Icon className="h-5 w-5" />
</div>
<div>
<h2 className="text-base font-semibold text-white">{title}</h2>
<p className="text-xs uppercase tracking-[0.13em] text-[#7ea5eb]">{subtitle}</p>
</div>
</div>
<ChevronDown
className={[
'h-5 w-5 text-[#7ea5eb] transition-transform duration-300',
isOpen ? 'rotate-180' : 'rotate-0',
].join(' ')}
/>
</button>
<div
className={[
'grid overflow-hidden transition-[grid-template-rows,opacity] duration-300 ease-out',
isOpen ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0',
].join(' ')}
>
<div className="min-h-0 border-t border-[#18356f] px-4 pb-4 pt-4 md:px-5">{children}</div>
</div>
</section>
);
}
export default function Dashboard() {
const [status, setStatus] = useState<StatusResponse | null>(null);
const [cost, setCost] = useState<CostSummary | null>(null);
const [error, setError] = useState<string | null>(null);
const [sectionsOpen, setSectionsOpen] = useState<DashboardSectionState>({
cost: true,
channels: true,
health: true,
});
useEffect(() => {
Promise.all([getStatus(), getCost()])
.then(([s, c]) => {
setStatus(s);
setCost(c);
.then(([statusPayload, costPayload]) => {
setStatus(statusPayload);
setCost(costPayload);
})
.catch((err) => setError(err.message));
.catch((err: unknown) => {
const message = err instanceof Error ? err.message : t('dashboard.load_unknown_error');
setError(message);
});
}, []);
const toggleSection = (section: DashboardSectionKey) => {
setSectionsOpen((prev) => ({
...prev,
[section]: !prev[section],
}));
};
if (error) {
return (
<div className="p-6">
<div className="rounded-lg bg-red-900/30 border border-red-700 p-4 text-red-300">
Failed to load dashboard: {error}
</div>
<div className="electric-card p-5 text-rose-200">
<h2 className="text-lg font-semibold text-rose-100">{t('dashboard.load_failed')}</h2>
<p className="mt-2 text-sm text-rose-200/90">{error}</p>
</div>
);
}
if (!status || !cost) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-2 border-blue-500 border-t-transparent" />
<div className="flex h-64 items-center justify-center">
<div className="electric-loader h-12 w-12 rounded-full" />
</div>
);
}
@ -87,165 +171,184 @@ export default function Dashboard() {
const maxCost = Math.max(cost.session_cost_usd, cost.daily_cost_usd, cost.monthly_cost_usd, 0.001);
return (
<div className="p-6 space-y-6">
{/* Status Cards Grid */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="bg-gray-900 rounded-xl p-5 border border-gray-800">
<div className="flex items-center gap-3 mb-3">
<div className="p-2 bg-blue-600/20 rounded-lg">
<Cpu className="h-5 w-5 text-blue-400" />
</div>
<span className="text-sm text-gray-400">Provider / Model</span>
<div className="space-y-5 md:space-y-6">
<section className="hero-panel motion-rise">
<div className="relative z-10 flex flex-wrap items-start justify-between gap-4">
<div>
<p className="text-xs uppercase tracking-[0.22em] text-[#8fb8ff]">{t('dashboard.hero_eyebrow')}</p>
<h1 className="mt-2 text-2xl font-semibold tracking-[0.03em] text-white md:text-3xl">
{t('dashboard.hero_title')}
</h1>
<p className="mt-2 max-w-2xl text-sm text-[#b3cbf8] md:text-base">
{t('dashboard.hero_subtitle')}
</p>
</div>
<p className="text-lg font-semibold text-white truncate">
{status.provider ?? 'Unknown'}
</p>
<p className="text-sm text-gray-400 truncate">{status.model}</p>
</div>
<div className="bg-gray-900 rounded-xl p-5 border border-gray-800">
<div className="flex items-center gap-3 mb-3">
<div className="p-2 bg-green-600/20 rounded-lg">
<Clock className="h-5 w-5 text-green-400" />
</div>
<span className="text-sm text-gray-400">Uptime</span>
<div className="flex flex-wrap items-center gap-2">
<span className="status-pill">
<Sparkles className="h-3.5 w-3.5" />
{t('dashboard.live_gateway')}
</span>
<span className="status-pill">
<ShieldCheck className="h-3.5 w-3.5" />
{status.paired ? t('dashboard.paired') : t('dashboard.unpaired')}
</span>
</div>
<p className="text-lg font-semibold text-white">
{formatUptime(status.uptime_seconds)}
</p>
<p className="text-sm text-gray-400">Since last restart</p>
</div>
</section>
<div className="bg-gray-900 rounded-xl p-5 border border-gray-800">
<div className="flex items-center gap-3 mb-3">
<div className="p-2 bg-purple-600/20 rounded-lg">
<Globe className="h-5 w-5 text-purple-400" />
</div>
<span className="text-sm text-gray-400">Gateway Port</span>
<section className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
<article className="electric-card motion-rise motion-delay-1 p-4">
<div className="metric-head">
<Cpu className="h-4 w-4" />
<span>{t('dashboard.provider_model')}</span>
</div>
<p className="text-lg font-semibold text-white">
:{status.gateway_port}
</p>
<p className="text-sm text-gray-400">Locale: {status.locale}</p>
</div>
<p className="metric-value mt-3">{status.provider ?? t('dashboard.unknown_provider')}</p>
<p className="metric-sub mt-1 truncate">{status.model}</p>
</article>
<div className="bg-gray-900 rounded-xl p-5 border border-gray-800">
<div className="flex items-center gap-3 mb-3">
<div className="p-2 bg-orange-600/20 rounded-lg">
<Database className="h-5 w-5 text-orange-400" />
</div>
<span className="text-sm text-gray-400">Memory Backend</span>
<article className="electric-card motion-rise motion-delay-2 p-4">
<div className="metric-head">
<Clock3 className="h-4 w-4" />
<span>{t('dashboard.uptime')}</span>
</div>
<p className="text-lg font-semibold text-white capitalize">
{status.memory_backend}
</p>
<p className="text-sm text-gray-400">
Paired: {status.paired ? 'Yes' : 'No'}
</p>
</div>
</div>
<p className="metric-value mt-3">{formatUptime(status.uptime_seconds)}</p>
<p className="metric-sub mt-1">{t('dashboard.since_last_restart')}</p>
</article>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Cost Widget */}
<div className="bg-gray-900 rounded-xl p-5 border border-gray-800">
<div className="flex items-center gap-2 mb-4">
<DollarSign className="h-5 w-5 text-blue-400" />
<h2 className="text-base font-semibold text-white">Cost Overview</h2>
<article className="electric-card motion-rise motion-delay-3 p-4">
<div className="metric-head">
<Globe2 className="h-4 w-4" />
<span>{t('dashboard.gateway_port')}</span>
</div>
<p className="metric-value mt-3">:{status.gateway_port}</p>
<p className="metric-sub mt-1">{status.locale}</p>
</article>
<article className="electric-card motion-rise motion-delay-4 p-4">
<div className="metric-head">
<Database className="h-4 w-4" />
<span>{t('dashboard.memory_backend')}</span>
</div>
<p className="metric-value mt-3 capitalize">{status.memory_backend}</p>
<p className="metric-sub mt-1">{status.paired ? t('dashboard.pairing_active') : t('dashboard.no_paired_devices')}</p>
</article>
</section>
<div className="space-y-4">
<CollapsibleSection
title={t('dashboard.cost_pulse')}
subtitle={t('dashboard.cost_subtitle')}
icon={DollarSign}
sectionKey="cost"
openState={sectionsOpen}
onToggle={toggleSection}
>
<div className="space-y-4">
{[
{ label: 'Session', value: cost.session_cost_usd, color: 'bg-blue-500' },
{ label: 'Daily', value: cost.daily_cost_usd, color: 'bg-green-500' },
{ label: 'Monthly', value: cost.monthly_cost_usd, color: 'bg-purple-500' },
].map(({ label, value, color }) => (
{ label: t('dashboard.session'), value: cost.session_cost_usd },
{ label: t('dashboard.daily'), value: cost.daily_cost_usd },
{ label: t('dashboard.monthly'), value: cost.monthly_cost_usd },
].map(({ label, value }) => (
<div key={label}>
<div className="flex justify-between text-sm mb-1">
<span className="text-gray-400">{label}</span>
<span className="text-white font-medium">{formatUSD(value)}</span>
<div className="mb-1.5 flex items-center justify-between text-sm">
<span className="text-[#9bb8ec]">{label}</span>
<span className="font-semibold text-white">{formatUSD(value)}</span>
</div>
<div className="w-full h-2 bg-gray-800 rounded-full overflow-hidden">
<div className="h-2.5 overflow-hidden rounded-full bg-[#061230]">
<div
className={`h-full rounded-full ${color}`}
style={{ width: `${Math.max((value / maxCost) * 100, 2)}%` }}
className="electric-progress h-full rounded-full"
style={{ width: `${Math.max((value / maxCost) * 100, 3)}%` }}
/>
</div>
</div>
))}
</div>
<div className="mt-4 pt-3 border-t border-gray-800 flex justify-between text-sm">
<span className="text-gray-400">Total Tokens</span>
<span className="text-white">{cost.total_tokens.toLocaleString()}</span>
</div>
<div className="flex justify-between text-sm mt-1">
<span className="text-gray-400">Requests</span>
<span className="text-white">{cost.request_count.toLocaleString()}</span>
</div>
</div>
{/* Active Channels */}
<div className="bg-gray-900 rounded-xl p-5 border border-gray-800">
<div className="flex items-center gap-2 mb-4">
<Radio className="h-5 w-5 text-blue-400" />
<h2 className="text-base font-semibold text-white">Active Channels</h2>
<div className="grid grid-cols-2 gap-3 pt-2">
<div className="metric-pill">
<span>{t('cost.total_tokens')}</span>
<strong>{cost.total_tokens.toLocaleString()}</strong>
</div>
<div className="metric-pill">
<span>{t('cost.requests')}</span>
<strong>{cost.request_count.toLocaleString()}</strong>
</div>
</div>
</div>
<div className="space-y-2">
{Object.entries(status.channels).length === 0 ? (
<p className="text-sm text-gray-500">No channels configured</p>
) : (
Object.entries(status.channels).map(([name, active]) => (
</CollapsibleSection>
<CollapsibleSection
title={t('dashboard.channel_activity')}
subtitle={t('dashboard.channel_subtitle')}
icon={Radio}
sectionKey="channels"
openState={sectionsOpen}
onToggle={toggleSection}
>
{Object.entries(status.channels).length === 0 ? (
<p className="text-sm text-[#8aa8df]">{t('dashboard.no_channels')}</p>
) : (
<div className="grid grid-cols-1 gap-2 md:grid-cols-2">
{Object.entries(status.channels).map(([name, active]) => (
<div
key={name}
className="flex items-center justify-between py-2 px-3 rounded-lg bg-gray-800/50"
className="rounded-xl border border-[#1d3770] bg-[#05112c]/90 px-3 py-2.5"
>
<span className="text-sm text-white capitalize">{name}</span>
<div className="flex items-center gap-2">
<span
className={`inline-block h-2.5 w-2.5 rounded-full ${
active ? 'bg-green-500' : 'bg-gray-500'
}`}
/>
<span className="text-xs text-gray-400">
{active ? 'Active' : 'Inactive'}
<div className="flex items-center justify-between">
<span className="text-sm capitalize text-white">{name}</span>
<span className="flex items-center gap-2 text-xs text-[#8baee7]">
<span
className={[
'inline-block h-2.5 w-2.5 rounded-full',
active ? 'bg-emerald-400 shadow-[0_0_12px_0_rgba(52,211,153,0.8)]' : 'bg-slate-500',
].join(' ')}
/>
{active ? t('dashboard.active') : t('dashboard.inactive')}
</span>
</div>
</div>
))
)}
</div>
</div>
))}
</div>
)}
</CollapsibleSection>
{/* Health Grid */}
<div className="bg-gray-900 rounded-xl p-5 border border-gray-800">
<div className="flex items-center gap-2 mb-4">
<Activity className="h-5 w-5 text-blue-400" />
<h2 className="text-base font-semibold text-white">Component Health</h2>
</div>
<div className="grid grid-cols-2 gap-3">
{Object.entries(status.health.components).length === 0 ? (
<p className="text-sm text-gray-500 col-span-2">No components reporting</p>
) : (
Object.entries(status.health.components).map(([name, comp]) => (
<CollapsibleSection
title={t('dashboard.component_health')}
subtitle={t('dashboard.component_subtitle')}
icon={Activity}
sectionKey="health"
openState={sectionsOpen}
onToggle={toggleSection}
>
{Object.entries(status.health.components).length === 0 ? (
<p className="text-sm text-[#8aa8df]">{t('dashboard.no_component_health')}</p>
) : (
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
{Object.entries(status.health.components).map(([name, component]) => (
<div
key={name}
className={`rounded-lg p-3 border ${healthBorder(comp.status)} bg-gray-800/50`}
className={[
'rounded-xl border bg-[#05112c]/80 px-3 py-3',
healthBorder(component.status),
].join(' ')}
>
<div className="flex items-center gap-2 mb-1">
<span className={`inline-block h-2 w-2 rounded-full ${healthColor(comp.status)}`} />
<span className="text-sm font-medium text-white capitalize truncate">
{name}
</span>
<div className="flex items-center justify-between">
<p className="text-sm font-semibold capitalize text-white">{name}</p>
<span className={['inline-block h-2.5 w-2.5 rounded-full', healthColor(component.status)].join(' ')} />
</div>
<p className="text-xs text-gray-400 capitalize">{comp.status}</p>
{comp.restart_count > 0 && (
<p className="text-xs text-yellow-400 mt-1">
Restarts: {comp.restart_count}
<p className="mt-1 text-xs uppercase tracking-[0.12em] text-[#87a9e5]">
{component.status}
</p>
{component.restart_count > 0 && (
<p className="mt-2 text-xs text-amber-300">
{t('dashboard.restarts')}: {component.restart_count}
</p>
)}
</div>
))
)}
</div>
</div>
))}
</div>
)}
</CollapsibleSection>
</div>
</div>
);

View File

@ -9,6 +9,7 @@ import {
} from 'lucide-react';
import type { DiagResult } from '@/types/api';
import { runDoctor } from '@/lib/api';
import { t } from '@/lib/i18n';
function severityIcon(severity: DiagResult['severity']) {
switch (severity) {
@ -56,7 +57,7 @@ export default function Doctor() {
const data = await runDoctor();
setResults(data);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Failed to run diagnostics');
setError(err instanceof Error ? err.message : t('doctor.error'));
} finally {
setLoading(false);
}
@ -82,7 +83,7 @@ export default function Doctor() {
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Stethoscope className="h-5 w-5 text-blue-400" />
<h2 className="text-base font-semibold text-white">Diagnostics</h2>
<h2 className="text-base font-semibold text-white">{t('doctor.title')}</h2>
</div>
<button
onClick={handleRun}
@ -92,12 +93,12 @@ export default function Doctor() {
{loading ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Running...
{t('doctor.running_short')}
</>
) : (
<>
<Play className="h-4 w-4" />
Run Diagnostics
{t('doctor.run')}
</>
)}
</button>
@ -114,9 +115,9 @@ export default function Doctor() {
{loading && (
<div className="flex flex-col items-center justify-center py-16">
<Loader2 className="h-10 w-10 text-blue-500 animate-spin mb-4" />
<p className="text-gray-400">Running diagnostics...</p>
<p className="text-gray-400">{t('doctor.running')}</p>
<p className="text-sm text-gray-500 mt-1">
This may take a few seconds.
{t('doctor.running_hint')}
</p>
</div>
)}
@ -128,44 +129,44 @@ export default function Doctor() {
<div className="flex items-center gap-4 bg-gray-900 rounded-xl border border-gray-800 p-4">
<div className="flex items-center gap-2">
<CheckCircle className="h-5 w-5 text-green-400" />
<span className="text-sm text-white font-medium">
{okCount} <span className="text-gray-400 font-normal">ok</span>
<span className="text-sm text-white font-medium">
{okCount} <span className="text-gray-400 font-normal">{t('doctor.ok').toLowerCase()}</span>
</span>
</div>
<div className="w-px h-5 bg-gray-700" />
<div className="flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-yellow-400" />
<span className="text-sm text-white font-medium">
{warnCount}{' '}
<span className="text-gray-400 font-normal">
warning{warnCount !== 1 ? 's' : ''}
<span className="text-sm text-white font-medium">
{warnCount}{' '}
<span className="text-gray-400 font-normal">
{t('doctor.warnings').toLowerCase()}
</span>
</span>
</span>
</div>
</div>
<div className="w-px h-5 bg-gray-700" />
<div className="flex items-center gap-2">
<XCircle className="h-5 w-5 text-red-400" />
<span className="text-sm text-white font-medium">
{errorCount}{' '}
<span className="text-gray-400 font-normal">
error{errorCount !== 1 ? 's' : ''}
<span className="text-sm text-white font-medium">
{errorCount}{' '}
<span className="text-gray-400 font-normal">
{t('doctor.error').toLowerCase()}
</span>
</span>
</span>
</div>
</div>
{/* Overall indicator */}
<div className="ml-auto">
{errorCount > 0 ? (
<span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-medium bg-red-900/40 text-red-400 border border-red-700/50">
Issues Found
{t('doctor.issues_found')}
</span>
) : warnCount > 0 ? (
<span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-medium bg-yellow-900/40 text-yellow-400 border border-yellow-700/50">
Warnings
{t('doctor.warnings')}
</span>
) : (
<span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-medium bg-green-900/40 text-green-400 border border-green-700/50">
All Clear
{t('doctor.all_clear')}
</span>
)}
</div>
@ -204,13 +205,13 @@ export default function Doctor() {
{/* Empty state */}
{!results && !loading && !error && (
<div className="flex flex-col items-center justify-center py-16 text-gray-500">
<Stethoscope className="h-12 w-12 text-gray-600 mb-4" />
<p className="text-lg font-medium">System Diagnostics</p>
<p className="text-sm mt-1">
Click "Run Diagnostics" to check your ZeroClaw installation.
</p>
</div>
<div className="flex flex-col items-center justify-center py-16 text-gray-500">
<Stethoscope className="h-12 w-12 text-gray-600 mb-4" />
<p className="text-lg font-medium">{t('doctor.title')}</p>
<p className="text-sm mt-1">
{t('doctor.instructions')}
</p>
</div>
)}
</div>
);

View File

@ -2,27 +2,28 @@ import { useState, useEffect } from 'react';
import { Puzzle, Check, Zap, Clock } from 'lucide-react';
import type { Integration } from '@/types/api';
import { getIntegrations } from '@/lib/api';
import { t } from '@/lib/i18n';
function statusBadge(status: Integration['status']) {
switch (status) {
case 'Active':
return {
icon: Check,
label: 'Active',
classes: 'bg-green-900/40 text-green-400 border-green-700/50',
};
return {
icon: Check,
label: t('integrations.active'),
classes: 'bg-green-900/40 text-green-400 border-green-700/50',
};
case 'Available':
return {
icon: Zap,
label: 'Available',
classes: 'bg-blue-900/40 text-blue-400 border-blue-700/50',
};
return {
icon: Zap,
label: t('integrations.available'),
classes: 'bg-blue-900/40 text-blue-400 border-blue-700/50',
};
case 'ComingSoon':
return {
icon: Clock,
label: 'Coming Soon',
classes: 'bg-gray-800 text-gray-400 border-gray-700',
};
return {
icon: Clock,
label: t('integrations.coming_soon'),
classes: 'bg-gray-800 text-gray-400 border-gray-700',
};
}
}
@ -61,7 +62,7 @@ export default function Integrations() {
return (
<div className="p-6">
<div className="rounded-lg bg-red-900/30 border border-red-700 p-4 text-red-300">
Failed to load integrations: {error}
{t('integrations.load_failed')}: {error}
</div>
</div>
);
@ -78,12 +79,12 @@ export default function Integrations() {
return (
<div className="p-6 space-y-6">
{/* Header */}
<div className="flex items-center gap-2">
<Puzzle className="h-5 w-5 text-blue-400" />
<h2 className="text-base font-semibold text-white">
Integrations ({integrations.length})
</h2>
</div>
<div className="flex items-center gap-2">
<Puzzle className="h-5 w-5 text-blue-400" />
<h2 className="text-base font-semibold text-white">
{t('integrations.title')} ({integrations.length})
</h2>
</div>
{/* Category Filter Tabs */}
<div className="flex flex-wrap gap-2">
@ -106,7 +107,7 @@ export default function Integrations() {
{Object.keys(grouped).length === 0 ? (
<div className="bg-gray-900 rounded-xl border border-gray-800 p-8 text-center">
<Puzzle className="h-10 w-10 text-gray-600 mx-auto mb-3" />
<p className="text-gray-400">No integrations found.</p>
<p className="text-gray-400">{t('integrations.empty')}</p>
</div>
) : (
Object.entries(grouped)

View File

@ -8,6 +8,7 @@ import {
} from 'lucide-react';
import type { SSEEvent } from '@/types/api';
import { SSEClient } from '@/lib/sse';
import { t } from '@/lib/i18n';
function formatTimestamp(ts?: string): string {
if (!ts) return new Date().toLocaleTimeString();
@ -138,7 +139,7 @@ export default function Logs() {
<div className="flex items-center justify-between px-6 py-3 border-b border-gray-800 bg-gray-900">
<div className="flex items-center gap-3">
<Activity className="h-5 w-5 text-blue-400" />
<h2 className="text-base font-semibold text-white">Live Logs</h2>
<h2 className="text-base font-semibold text-white">{t('logs.title')}</h2>
<div className="flex items-center gap-2 ml-2">
<span
className={`inline-block h-2 w-2 rounded-full ${
@ -146,11 +147,11 @@ export default function Logs() {
}`}
/>
<span className="text-xs text-gray-500">
{connected ? 'Connected' : 'Disconnected'}
{connected ? t('agent.connected') : t('agent.disconnected')}
</span>
</div>
<span className="text-xs text-gray-500 ml-2">
{filteredEntries.length} events
{filteredEntries.length} {t('logs.events')}
</span>
</div>
@ -166,11 +167,11 @@ export default function Logs() {
>
{paused ? (
<>
<Play className="h-3.5 w-3.5" /> Resume
<Play className="h-3.5 w-3.5" /> {t('logs.resume')}
</>
) : (
<>
<Pause className="h-3.5 w-3.5" /> Pause
<Pause className="h-3.5 w-3.5" /> {t('logs.pause')}
</>
)}
</button>
@ -182,7 +183,7 @@ export default function Logs() {
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium bg-blue-600 hover:bg-blue-700 text-white transition-colors"
>
<ArrowDown className="h-3.5 w-3.5" />
Jump to bottom
{t('logs.jump_to_bottom')}
</button>
)}
</div>
@ -192,7 +193,7 @@ export default function Logs() {
{allTypes.length > 0 && (
<div className="flex items-center gap-2 px-6 py-2 border-b border-gray-800 bg-gray-900/80 overflow-x-auto">
<Filter className="h-4 w-4 text-gray-500 flex-shrink-0" />
<span className="text-xs text-gray-500 flex-shrink-0">Filter:</span>
<span className="text-xs text-gray-500 flex-shrink-0">{t('logs.filter_label')}</span>
{allTypes.map((type) => (
<label
key={type}
@ -212,7 +213,7 @@ export default function Logs() {
onClick={() => setTypeFilters(new Set())}
className="text-xs text-blue-400 hover:text-blue-300 flex-shrink-0 ml-1"
>
Clear
{t('logs.clear')}
</button>
)}
</div>
@ -229,8 +230,8 @@ export default function Logs() {
<Activity className="h-10 w-10 text-gray-600 mb-3" />
<p className="text-sm">
{paused
? 'Log streaming is paused.'
: 'Waiting for events...'}
? t('logs.paused_stream')
: t('logs.waiting_for_events')}
</p>
</div>
) : (

View File

@ -9,6 +9,7 @@ import {
} from 'lucide-react';
import type { MemoryEntry } from '@/types/api';
import { getMemory, storeMemory, deleteMemory } from '@/lib/api';
import { t } from '@/lib/i18n';
function truncate(text: string, max: number): string {
if (text.length <= max) return text;
@ -60,7 +61,7 @@ export default function Memory() {
const handleAdd = async () => {
if (!formKey.trim() || !formContent.trim()) {
setFormError('Key and content are required.');
setFormError(t('memory.key_content_required'));
return;
}
setSubmitting(true);
@ -77,7 +78,7 @@ export default function Memory() {
setFormContent('');
setFormCategory('');
} catch (err: unknown) {
setFormError(err instanceof Error ? err.message : 'Failed to store memory');
setFormError(err instanceof Error ? err.message : t('memory.failed_store'));
} finally {
setSubmitting(false);
}
@ -88,7 +89,7 @@ export default function Memory() {
await deleteMemory(key);
setEntries((prev) => prev.filter((e) => e.key !== key));
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Failed to delete memory');
setError(err instanceof Error ? err.message : t('memory.failed_delete'));
} finally {
setConfirmDelete(null);
}
@ -98,7 +99,7 @@ export default function Memory() {
return (
<div className="p-6">
<div className="rounded-lg bg-red-900/30 border border-red-700 p-4 text-red-300">
Failed to load memory: {error}
{t('memory.load_failed')}: {error}
</div>
</div>
);
@ -111,7 +112,7 @@ export default function Memory() {
<div className="flex items-center gap-2">
<Brain className="h-5 w-5 text-blue-400" />
<h2 className="text-base font-semibold text-white">
Memory ({entries.length})
{t('nav.memory')} ({entries.length})
</h2>
</div>
<button
@ -119,7 +120,7 @@ export default function Memory() {
className="flex items-center gap-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium px-4 py-2 rounded-lg transition-colors"
>
<Plus className="h-4 w-4" />
Add Memory
{t('memory.add_memory')}
</button>
</div>
@ -132,7 +133,7 @@ export default function Memory() {
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Search memory entries..."
placeholder={t('memory.search_entries')}
className="w-full bg-gray-900 border border-gray-700 rounded-lg pl-10 pr-4 py-2.5 text-sm text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
@ -143,7 +144,7 @@ export default function Memory() {
onChange={(e) => setCategoryFilter(e.target.value)}
className="bg-gray-900 border border-gray-700 rounded-lg pl-10 pr-8 py-2.5 text-sm text-white appearance-none focus:outline-none focus:ring-2 focus:ring-blue-500 cursor-pointer"
>
<option value="">All Categories</option>
<option value="">{t('memory.all_categories')}</option>
{categories.map((cat) => (
<option key={cat} value={cat}>
{cat}
@ -155,7 +156,7 @@ export default function Memory() {
onClick={handleSearch}
className="px-4 py-2.5 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-lg transition-colors"
>
Search
{t('memory.search_button')}
</button>
</div>
@ -171,7 +172,7 @@ export default function Memory() {
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50">
<div className="bg-gray-900 border border-gray-700 rounded-xl p-6 w-full max-w-md mx-4">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-white">Add Memory</h3>
<h3 className="text-lg font-semibold text-white">{t('memory.add_memory')}</h3>
<button
onClick={() => {
setShowForm(false);
@ -192,37 +193,37 @@ export default function Memory() {
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-300 mb-1">
Key <span className="text-red-400">*</span>
{t('memory.key')} <span className="text-red-400">*</span>
</label>
<input
type="text"
value={formKey}
onChange={(e) => setFormKey(e.target.value)}
placeholder="e.g. user_preferences"
placeholder={t('memory.key_placeholder')}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-1">
Content <span className="text-red-400">*</span>
{t('memory.content')} <span className="text-red-400">*</span>
</label>
<textarea
value={formContent}
onChange={(e) => setFormContent(e.target.value)}
placeholder="Memory content..."
placeholder={t('memory.content_placeholder')}
rows={4}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-1">
Category (optional)
{t('memory.category_optional')}
</label>
<input
type="text"
value={formCategory}
onChange={(e) => setFormCategory(e.target.value)}
placeholder="e.g. preferences, context, facts"
placeholder={t('memory.category_placeholder')}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
@ -236,14 +237,14 @@ export default function Memory() {
}}
className="px-4 py-2 text-sm font-medium text-gray-300 hover:text-white border border-gray-700 rounded-lg hover:bg-gray-800 transition-colors"
>
Cancel
{t('common.cancel')}
</button>
<button
onClick={handleAdd}
disabled={submitting}
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors disabled:opacity-50"
>
{submitting ? 'Saving...' : 'Save'}
{submitting ? t('memory.saving') : t('common.save')}
</button>
</div>
</div>
@ -258,7 +259,7 @@ export default function Memory() {
) : entries.length === 0 ? (
<div className="bg-gray-900 rounded-xl border border-gray-800 p-8 text-center">
<Brain className="h-10 w-10 text-gray-600 mx-auto mb-3" />
<p className="text-gray-400">No memory entries found.</p>
<p className="text-gray-400">{t('memory.empty')}</p>
</div>
) : (
<div className="bg-gray-900 rounded-xl border border-gray-800 overflow-x-auto">
@ -266,19 +267,19 @@ export default function Memory() {
<thead>
<tr className="border-b border-gray-800">
<th className="text-left px-4 py-3 text-gray-400 font-medium">
Key
{t('memory.key')}
</th>
<th className="text-left px-4 py-3 text-gray-400 font-medium">
Content
{t('memory.content')}
</th>
<th className="text-left px-4 py-3 text-gray-400 font-medium">
Category
{t('memory.category')}
</th>
<th className="text-left px-4 py-3 text-gray-400 font-medium">
Timestamp
{t('memory.timestamp')}
</th>
<th className="text-right px-4 py-3 text-gray-400 font-medium">
Actions
{t('common.actions')}
</th>
</tr>
</thead>
@ -307,18 +308,18 @@ export default function Memory() {
<td className="px-4 py-3 text-right">
{confirmDelete === entry.key ? (
<div className="flex items-center justify-end gap-2">
<span className="text-xs text-red-400">Delete?</span>
<span className="text-xs text-red-400">{t('memory.delete_prompt')}</span>
<button
onClick={() => handleDelete(entry.key)}
className="text-red-400 hover:text-red-300 text-xs font-medium"
>
Yes
{t('common.yes')}
</button>
<button
onClick={() => setConfirmDelete(null)}
className="text-gray-400 hover:text-white text-xs font-medium"
>
No
{t('common.no')}
</button>
</div>
) : (

View File

@ -9,6 +9,7 @@ import {
} from 'lucide-react';
import type { ToolSpec, CliTool } from '@/types/api';
import { getTools, getCliTools } from '@/lib/api';
import { t } from '@/lib/i18n';
export default function Tools() {
const [tools, setTools] = useState<ToolSpec[]>([]);
@ -44,7 +45,7 @@ export default function Tools() {
return (
<div className="p-6">
<div className="rounded-lg bg-red-900/30 border border-red-700 p-4 text-red-300">
Failed to load tools: {error}
{t('tools.load_failed')}: {error}
</div>
</div>
);
@ -67,7 +68,7 @@ export default function Tools() {
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search tools..."
placeholder={t('tools.search')}
className="w-full bg-gray-900 border border-gray-700 rounded-lg pl-10 pr-4 py-2.5 text-sm text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
@ -77,12 +78,12 @@ export default function Tools() {
<div className="flex items-center gap-2 mb-4">
<Wrench className="h-5 w-5 text-blue-400" />
<h2 className="text-base font-semibold text-white">
Agent Tools ({filtered.length})
{t('tools.agent_tools')} ({filtered.length})
</h2>
</div>
{filtered.length === 0 ? (
<p className="text-sm text-gray-500">No tools match your search.</p>
<p className="text-sm text-gray-500">{t('tools.no_search_results')}</p>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
{filtered.map((tool) => {
@ -119,7 +120,7 @@ export default function Tools() {
{isExpanded && tool.parameters && (
<div className="border-t border-gray-800 p-4">
<p className="text-xs text-gray-500 mb-2 font-medium uppercase tracking-wider">
Parameter Schema
{t('tools.parameter_schema')}
</p>
<pre className="text-xs text-gray-300 bg-gray-950 rounded-lg p-3 overflow-x-auto max-h-64 overflow-y-auto">
{JSON.stringify(tool.parameters, null, 2)}
@ -139,7 +140,7 @@ export default function Tools() {
<div className="flex items-center gap-2 mb-4">
<Terminal className="h-5 w-5 text-green-400" />
<h2 className="text-base font-semibold text-white">
CLI Tools ({filteredCli.length})
{t('tools.cli_tools')} ({filteredCli.length})
</h2>
</div>
@ -148,16 +149,16 @@ export default function Tools() {
<thead>
<tr className="border-b border-gray-800">
<th className="text-left px-4 py-3 text-gray-400 font-medium">
Name
{t('tools.name')}
</th>
<th className="text-left px-4 py-3 text-gray-400 font-medium">
Path
{t('tools.path')}
</th>
<th className="text-left px-4 py-3 text-gray-400 font-medium">
Version
{t('tools.version')}
</th>
<th className="text-left px-4 py-3 text-gray-400 font-medium">
Category
{t('integrations.category')}
</th>
</tr>
</thead>

View File

@ -3,27 +3,40 @@ import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
import path from "path";
export default defineConfig({
base: "/_app/",
plugins: [react(), tailwindcss()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
build: {
outDir: "dist",
},
server: {
proxy: {
"/api": {
target: "http://localhost:5555",
changeOrigin: true,
},
"/ws": {
target: "ws://localhost:5555",
ws: true,
export default defineConfig(() => {
const apiTarget = process.env.VITE_API_TARGET ?? "http://127.0.0.1:42617";
const wsTarget = process.env.VITE_WS_TARGET ?? apiTarget.replace(/^http/, "ws");
return {
base: "/_app/",
plugins: [react(), tailwindcss()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
},
build: {
outDir: "dist",
},
server: {
proxy: {
"/health": {
target: apiTarget,
changeOrigin: true,
},
"/pair": {
target: apiTarget,
changeOrigin: true,
},
"/api": {
target: apiTarget,
changeOrigin: true,
},
"/ws": {
target: wsTarget,
ws: true,
},
},
},
};
});

15
web/vitest.config.ts Normal file
View File

@ -0,0 +1,15 @@
import path from 'node:path';
import { defineConfig } from 'vitest/config';
export default defineConfig({
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
test: {
environment: 'node',
include: ['src/**/*.test.ts'],
exclude: ['e2e/**'],
},
});