Compare commits
9 Commits
master
...
web-electr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b890b7da74 | ||
|
|
ac63a4d16a | ||
|
|
149165fa45 | ||
|
|
47d46f90dd | ||
|
|
183069d87f | ||
|
|
c280ae5045 | ||
|
|
3f5c57634b | ||
|
|
c56c66a21d | ||
|
|
b248d40abc |
1
.gitignore
vendored
1
.gitignore
vendored
@ -1,5 +1,6 @@
|
||||
/target
|
||||
firmware/*/target
|
||||
web/dist/
|
||||
*.db
|
||||
*.db-journal
|
||||
.DS_Store
|
||||
|
||||
47
build.rs
Normal file
47
build.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
|
||||
1
web/dist/assets/index-DEhGL4Jw.css
vendored
1
web/dist/assets/index-DEhGL4Jw.css
vendored
File diff suppressed because one or more lines are too long
295
web/dist/assets/index-Dam-egf7.js
vendored
295
web/dist/assets/index-Dam-egf7.js
vendored
File diff suppressed because one or more lines are too long
14
web/dist/index.html
vendored
14
web/dist/index.html
vendored
@ -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
60
web/e2e/dashboard.spec.ts
Normal 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
118
web/e2e/locales.spec.ts
Normal 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
353
web/e2e/mock-server.mjs
Normal 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
435
web/package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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
34
web/playwright.config.ts
Normal 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,
|
||||
},
|
||||
],
|
||||
});
|
||||
111
web/src/App.tsx
111
web/src/App.tsx
@ -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 />} />
|
||||
|
||||
127
web/src/components/controls/LanguageSelector.tsx
Normal file
127
web/src/components/controls/LanguageSelector.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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),
|
||||
};
|
||||
}
|
||||
63
web/src/lib/i18n/index.test.ts
Normal file
63
web/src/lib/i18n/index.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
4
web/src/lib/i18n/index.ts
Normal file
4
web/src/lib/i18n/index.ts
Normal 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';
|
||||
58
web/src/lib/i18n/languages.ts
Normal file
58
web/src/lib/i18n/languages.ts
Normal 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;
|
||||
}
|
||||
33
web/src/lib/i18n/locales/ar.ts
Normal file
33
web/src/lib/i18n/locales/ar.ts
Normal 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;
|
||||
33
web/src/lib/i18n/locales/bn.ts
Normal file
33
web/src/lib/i18n/locales/bn.ts
Normal 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;
|
||||
33
web/src/lib/i18n/locales/cs.ts
Normal file
33
web/src/lib/i18n/locales/cs.ts
Normal 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;
|
||||
33
web/src/lib/i18n/locales/da.ts
Normal file
33
web/src/lib/i18n/locales/da.ts
Normal 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;
|
||||
33
web/src/lib/i18n/locales/de.ts
Normal file
33
web/src/lib/i18n/locales/de.ts
Normal 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;
|
||||
33
web/src/lib/i18n/locales/el.ts
Normal file
33
web/src/lib/i18n/locales/el.ts
Normal 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;
|
||||
273
web/src/lib/i18n/locales/en.ts
Normal file
273
web/src/lib/i18n/locales/en.ts
Normal 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;
|
||||
33
web/src/lib/i18n/locales/es.ts
Normal file
33
web/src/lib/i18n/locales/es.ts
Normal 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;
|
||||
33
web/src/lib/i18n/locales/fi.ts
Normal file
33
web/src/lib/i18n/locales/fi.ts
Normal 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;
|
||||
51
web/src/lib/i18n/locales/fr.ts
Normal file
51
web/src/lib/i18n/locales/fr.ts
Normal 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;
|
||||
33
web/src/lib/i18n/locales/he.ts
Normal file
33
web/src/lib/i18n/locales/he.ts
Normal 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;
|
||||
33
web/src/lib/i18n/locales/hi.ts
Normal file
33
web/src/lib/i18n/locales/hi.ts
Normal 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;
|
||||
33
web/src/lib/i18n/locales/hu.ts
Normal file
33
web/src/lib/i18n/locales/hu.ts
Normal 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;
|
||||
33
web/src/lib/i18n/locales/id.ts
Normal file
33
web/src/lib/i18n/locales/id.ts
Normal 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;
|
||||
70
web/src/lib/i18n/locales/index.ts
Normal file
70
web/src/lib/i18n/locales/index.ts
Normal 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),
|
||||
};
|
||||
33
web/src/lib/i18n/locales/it.ts
Normal file
33
web/src/lib/i18n/locales/it.ts
Normal 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;
|
||||
51
web/src/lib/i18n/locales/ja.ts
Normal file
51
web/src/lib/i18n/locales/ja.ts
Normal 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;
|
||||
33
web/src/lib/i18n/locales/ko.ts
Normal file
33
web/src/lib/i18n/locales/ko.ts
Normal 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;
|
||||
33
web/src/lib/i18n/locales/nb.ts
Normal file
33
web/src/lib/i18n/locales/nb.ts
Normal 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;
|
||||
33
web/src/lib/i18n/locales/nl.ts
Normal file
33
web/src/lib/i18n/locales/nl.ts
Normal 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;
|
||||
33
web/src/lib/i18n/locales/pl.ts
Normal file
33
web/src/lib/i18n/locales/pl.ts
Normal 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;
|
||||
33
web/src/lib/i18n/locales/pt.ts
Normal file
33
web/src/lib/i18n/locales/pt.ts
Normal 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;
|
||||
33
web/src/lib/i18n/locales/ro.ts
Normal file
33
web/src/lib/i18n/locales/ro.ts
Normal 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;
|
||||
51
web/src/lib/i18n/locales/ru.ts
Normal file
51
web/src/lib/i18n/locales/ru.ts
Normal 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;
|
||||
33
web/src/lib/i18n/locales/sv.ts
Normal file
33
web/src/lib/i18n/locales/sv.ts
Normal 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;
|
||||
33
web/src/lib/i18n/locales/th.ts
Normal file
33
web/src/lib/i18n/locales/th.ts
Normal 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;
|
||||
33
web/src/lib/i18n/locales/tl.ts
Normal file
33
web/src/lib/i18n/locales/tl.ts
Normal 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;
|
||||
157
web/src/lib/i18n/locales/tr.ts
Normal file
157
web/src/lib/i18n/locales/tr.ts
Normal 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;
|
||||
33
web/src/lib/i18n/locales/uk.ts
Normal file
33
web/src/lib/i18n/locales/uk.ts
Normal 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;
|
||||
33
web/src/lib/i18n/locales/ur.ts
Normal file
33
web/src/lib/i18n/locales/ur.ts
Normal 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;
|
||||
51
web/src/lib/i18n/locales/vi.ts
Normal file
51
web/src/lib/i18n/locales/vi.ts
Normal 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;
|
||||
51
web/src/lib/i18n/locales/zh-CN.ts
Normal file
51
web/src/lib/i18n/locales/zh-CN.ts
Normal 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;
|
||||
75
web/src/lib/i18n/translate.ts
Normal file
75
web/src/lib/i18n/translate.ts
Normal 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
46
web/src/lib/i18n/types.ts
Normal 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;
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
) : (
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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>
|
||||
) : (
|
||||
|
||||
@ -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>
|
||||
) : (
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
15
web/vitest.config.ts
Normal 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/**'],
|
||||
},
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user