Compare commits

..

9 Commits

Author SHA1 Message Date
508b326bf7 feat(F5): authenticated chat + key usage integration
All checks were successful
CI / Format (push) Successful in 32s
CI / CUDA type-check (push) Successful in 1m37s
CI / Clippy (push) Successful in 3m22s
CI / Test (push) Successful in 7m4s
CI / Build cortex SRPM (push) Has been skipped
CI / Publish cortex to COPR (push) Has been skipped
CI / Build neuron SRPM (push) Has been skipped
CI / Publish neuron to COPR (push) Has been skipped
CI / Bump version in source (push) Has been skipped
Signing in upgrades the chat workspace from anonymous to account-scoped.

- Auth context exposes accountId (resolved on login and on reload for an
  existing token) so the chat can scope its Dexie owner and the dashboard
  can query.
- Chat: when authed, owner switches to the account id (anon history was
  already re-owned via claimAnonymousData on login), the anon message cap
  is lifted (budget is enforced upstream by the account allocation), the
  full default model (VITE_DEFAULT_MODEL) is used, and the user's API key
  is sent as the bearer.
- The bearer is the raw key the user stored locally via "use for chat on
  this device" in the key-creation modal (client-side only — consistent
  with no server-side secrets). Signed in without a stored key → a banner
  prompts creating/enabling one (sending is disabled until then).
- Error mapping: insufficient_quota → top-up link (/account) when authed,
  sign-up (/register) when anon; rate_limit_exceeded → a wait-and-retry
  hint; both distinct from generic errors.
- New chat (topUp/rateLimited/needsKey/manageKeysLink) and account
  (keys.useForChat/usedForChat) i18n keys across all languages.

Validated: lint, typecheck, build, i18n:check all green. Explicit-path
commit; no node_modules/dist.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F6o3ddqmYNh9kzdwq6eowh
2026-06-23 11:55:48 +03:00
0de99a8cc7 Merge feat/F4-account-dashboard: auth + account dashboard (F4)
Some checks failed
build-prerelease / Test (push) Blocked by required conditions
build-prerelease / Resolve version stamps + change detection (push) Successful in 47s
build-prerelease / Build neuron-blackwell (push) Has been skipped
build-prerelease / Build neuron-ampere (push) Has been skipped
build-prerelease / Build neuron-ada (push) Has been skipped
build-prerelease / Package helexa-neuron-ada RPM (push) Has been skipped
build-prerelease / Package helexa-neuron-ampere RPM (push) Has been skipped
build-prerelease / Package helexa-neuron-blackwell RPM (push) Has been skipped
build-prerelease / Lint (fmt + clippy) (push) Successful in 3m16s
build-prerelease / Build cortex binary (push) Has been skipped
build-prerelease / Build helexa-bench binary (push) Has been skipped
build-prerelease / Package cortex RPM (push) Has been skipped
build-prerelease / Package helexa-bench RPM (push) Has been skipped
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Has been cancelled
2026-06-23 11:46:14 +03:00
ce29e0c171 Merge feat/F3-anon-chat: anonymous chat landing + IndexedDB + SSE (F3)
All checks were successful
build-prerelease / Resolve version stamps + change detection (push) Successful in 52s
build-prerelease / Build neuron-blackwell (push) Has been skipped
build-prerelease / Lint (fmt + clippy) (push) Successful in 3m14s
build-prerelease / Build neuron-ampere (push) Has been skipped
build-prerelease / Build neuron-ada (push) Has been skipped
build-prerelease / Package helexa-neuron-ada RPM (push) Has been skipped
build-prerelease / Package helexa-neuron-ampere RPM (push) Has been skipped
build-prerelease / Package helexa-neuron-blackwell RPM (push) Has been skipped
build-prerelease / Build cortex binary (push) Has been skipped
build-prerelease / Build helexa-bench binary (push) Has been skipped
build-prerelease / Package cortex RPM (push) Has been skipped
build-prerelease / Package helexa-bench RPM (push) Has been skipped
build-prerelease / Test (push) Successful in 6m13s
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Has been skipped
2026-06-23 11:32:00 +03:00
1bf3348c8c feat(F4): auth + account dashboard (mockable client)
All checks were successful
CI / Format (push) Successful in 42s
CI / CUDA type-check (push) Successful in 1m41s
CI / Clippy (push) Successful in 3m9s
CI / Test (push) Successful in 6m30s
CI / Build cortex SRPM (push) Has been skipped
CI / Build neuron SRPM (push) Has been skipped
CI / Publish cortex to COPR (push) Has been skipped
CI / Publish neuron to COPR (push) Has been skipped
CI / Bump version in source (push) Has been skipped
The self-service account surface consuming helexa-upstream's /web/v1 (B4/B5),
with a mock so it works before/independent of the live backend.

- api/types.ts + api/account.ts: typed AccountApi over a same-origin `/api`
  prefix (vite-proxied in dev, nginx in prod) covering register/verify/
  login/password-reset/keys(list,create,archive,limit)/account/redeem;
  ApiError carries the backend code. MockAccountApi behind
  VITE_USE_MOCK_ACCOUNT_API (in-memory account, raw-key-once, redeem).
- auth/: context + useAuth, AuthProvider (JWT in localStorage, login fetches
  the account and runs claimAnonymousData → anon IndexedDB history is
  re-owned to the account, still client-side), RequireAuth guard
  (→ /login?next=).
- pages/auth/: Login, Register (sends the FingerprintJS visitor id →
  triggers the silent abuse detection), VerifyEmail (?token), RequestReset,
  ResetPassword (?token, matches the backend /reset?token= link).
- pages/account/: Dashboard (allocation balance + usage bar, redeem top-up,
  logout) and ApiKeys (list, create-modal showing the raw key ONCE with
  copy, per-key limit editor percent↔hardcap, archive). 401 → logout.
- App wraps AuthProvider + routes (account guarded); Header auth cluster
  reflects useAuth (Account/Sign out vs Sign in/up).
- `account` i18n namespace (53 keys) added + wired across all 32 langs.

Validated: lint, typecheck, build, i18n:check, lang-labels all green.
Explicit-path commit; no node_modules/dist.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F6o3ddqmYNh9kzdwq6eowh
2026-06-23 11:31:43 +03:00
7c12b9ea98 Merge feat/F2-mission: /mission route — EU digital sovereignty (F2)
All checks were successful
build-prerelease / Resolve version stamps + change detection (push) Successful in 49s
build-prerelease / Build neuron-blackwell (push) Has been skipped
build-prerelease / Lint (fmt + clippy) (push) Successful in 2m48s
build-prerelease / Build neuron-ampere (push) Has been skipped
build-prerelease / Build neuron-ada (push) Has been skipped
build-prerelease / Package helexa-neuron-ada RPM (push) Has been skipped
build-prerelease / Package helexa-neuron-ampere RPM (push) Has been skipped
build-prerelease / Package helexa-neuron-blackwell RPM (push) Has been skipped
build-prerelease / Build cortex binary (push) Has been skipped
build-prerelease / Build helexa-bench binary (push) Has been skipped
build-prerelease / Package cortex RPM (push) Has been skipped
build-prerelease / Package helexa-bench RPM (push) Has been skipped
build-prerelease / Test (push) Successful in 6m59s
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Has been skipped
2026-06-23 11:19:38 +03:00
c596519dbd feat(F3): anonymous chat landing — IndexedDB history + SSE + fingerprint
All checks were successful
CI / Format (push) Successful in 46s
CI / CUDA type-check (push) Successful in 1m40s
CI / Clippy (push) Successful in 3m20s
CI / Test (push) Successful in 8m3s
CI / Build cortex SRPM (push) Has been skipped
CI / Publish cortex to COPR (push) Has been skipped
CI / Build neuron SRPM (push) Has been skipped
CI / Publish neuron to COPR (push) Has been skipped
CI / Bump version in source (push) Has been skipped
The beta centerpiece: a chat workspace at `/` with zero server-side
history. Everything personal lives in the browser (Dexie/IndexedDB);
inference streams from the mesh router.

- data/db.ts: Dexie schema (projects, conversations[owner:'anon'|accountId],
  messages, meta) — `owner` namespaces anonymous vs account data;
  claimAnonymousData() (repositories) re-owns anon data on login (F4),
  still client-side.
- data/repositories.ts: typed CRUD + ordered queries (projects,
  conversations, messages) used reactively via useLiveQuery.
- lib/fingerprint.ts: FingerprintJS OSS, cached in meta (best-effort, never
  auth) — namespaces anon data + a soft throttle id.
- lib/chatClient.ts: streamChatCompletion → POST /v1/chat/completions
  stream:true; parses the SSE byte stream incrementally (data:/[DONE]/
  partial frames), surfaces the OpenAI envelope error.code, AbortController
  for Stop.
- lib/useChat.ts: persists the user turn, opens a streaming assistant
  message, appends deltas to Dexie live, titles the conversation, finalizes
  on done/error.
- pages/Chat.tsx: sidebar (new chat / new project, conversations grouped by
  project + Unsorted), live-updating thread with streaming + error
  rendering, composer with send/stop. Anonymous mode: no bearer +
  VITE_ANON_MODEL + a client message cap with a sign-up nudge. Routed at `/`.
- chat i18n namespace extended (newChat/newProject/unsorted/emptyState/
  anonBanner/signUp/stop) across all 33 languages (parity holds).

Validated: npm run lint, typecheck, build all green; i18n:check consistent.
(Full browser flow exercised in F6 verify.) Explicit-path commit; no
node_modules/dist.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F6o3ddqmYNh9kzdwq6eowh
2026-06-23 11:19:19 +03:00
a6b1fdc33d Merge feat/F1-theming-i18n: theming + 33-lang i18n + usage-ordered selector (F1)
All checks were successful
build-prerelease / Resolve version stamps + change detection (push) Successful in 41s
build-prerelease / Build neuron-blackwell (push) Has been skipped
build-prerelease / Build neuron-ampere (push) Has been skipped
build-prerelease / Build neuron-ada (push) Has been skipped
build-prerelease / Package helexa-neuron-ada RPM (push) Has been skipped
build-prerelease / Package helexa-neuron-ampere RPM (push) Has been skipped
build-prerelease / Package helexa-neuron-blackwell RPM (push) Has been skipped
build-prerelease / Lint (fmt + clippy) (push) Successful in 3m14s
build-prerelease / Test (push) Successful in 5m37s
build-prerelease / Build cortex binary (push) Has been skipped
build-prerelease / Package cortex RPM (push) Has been skipped
build-prerelease / Build helexa-bench binary (push) Has been skipped
build-prerelease / Package helexa-bench RPM (push) Has been skipped
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Has been skipped
2026-06-23 11:10:33 +03:00
8600d4fbf2 Merge feat/B5-topup-codes: single-use top-up codes + mint CLI (B5, #59)
All checks were successful
build-prerelease / Resolve version stamps + change detection (push) Successful in 37s
build-prerelease / Build neuron-blackwell (push) Has been skipped
build-prerelease / Build neuron-ampere (push) Has been skipped
build-prerelease / Build neuron-ada (push) Has been skipped
build-prerelease / Package helexa-neuron-ada RPM (push) Has been skipped
build-prerelease / Package helexa-neuron-ampere RPM (push) Has been skipped
build-prerelease / Package helexa-neuron-blackwell RPM (push) Has been skipped
build-prerelease / Build cortex binary (push) Has been skipped
build-prerelease / Package cortex RPM (push) Has been skipped
build-prerelease / Build helexa-bench binary (push) Has been skipped
build-prerelease / Package helexa-bench RPM (push) Has been skipped
build-prerelease / Lint (fmt + clippy) (push) Successful in 3m22s
build-prerelease / Test (push) Successful in 5m44s
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Has been skipped
2026-06-23 11:01:26 +03:00
2348cc2234 feat(B5): single-use top-up codes (redeem + mint CLI)
All checks were successful
CI / Format (push) Successful in 33s
CI / CUDA type-check (push) Successful in 1m33s
CI / Clippy (push) Successful in 3m5s
CI / Test (push) Successful in 6m29s
CI / Build cortex SRPM (push) Has been skipped
CI / Publish cortex to COPR (push) Has been skipped
CI / Build neuron SRPM (push) Has been skipped
CI / Publish neuron to COPR (push) Has been skipped
CI / Bump version in source (push) Has been skipped
Completes the hybrid allocation model — the flat free grant (B4) plus
top-up codes that extend an account's allocation.

- topup.rs: redeem(account, raw_code) — timing-safe, single-use. A
  conditional `UPDATE … WHERE redeemed_by IS NULL RETURNING value` claims
  the code atomically (concurrent double-redeem → exactly one winner), then
  raises accounts.allocation_total in the same tx. Only sha256(code) is
  stored; a not-found code and an already-redeemed code return the SAME
  generic error via the same path (no "valid but spent" oracle). Raising
  the total automatically lifts every percent-limited key's effective cap;
  hardcap keys stay pinned (by design).
- mint(value, count, denomination) — inserts codes (hash-only), returns the
  raw codes once. Exposed as `helexa-upstream mint --value --count
  [--denomination]` (raw codes to stdout, one per line) — the seam the
  future faucet bot calls. Bot itself out of scope.
- POST /web/v1/redeem (session-protected) → {allocation_total} | generic 400.

Validated against Postgres: redeem raises the 1_000_000 free grant to
1_500_000, second redemption + unknown code both generic-400, and a
concurrent race for one code yields exactly one HTTP 200.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F6o3ddqmYNh9kzdwq6eowh
2026-06-23 10:50:43 +03:00
91 changed files with 4778 additions and 64 deletions

View File

@@ -21,6 +21,7 @@ pub mod error;
pub mod handlers;
pub mod ledger;
pub mod state;
pub mod topup;
pub mod web;
use anyhow::Result;

View File

@@ -20,6 +20,22 @@ enum Commands {
#[arg(short, long, default_value = "helexa-upstream.toml")]
config: String,
},
/// Mint single-use top-up codes and print them (one per line). The raw
/// codes are shown only here — only their hash is stored. (The future
/// faucet bot calls the same path.)
Mint {
#[arg(short, long, default_value = "helexa-upstream.toml")]
config: String,
/// Tokens each code grants.
#[arg(long)]
value: i64,
/// How many codes to mint.
#[arg(long, default_value_t = 1)]
count: u32,
/// Optional human label (e.g. "small", "beta-launch").
#[arg(long)]
denomination: Option<String>,
},
}
#[tokio::main]
@@ -40,6 +56,25 @@ async fn main() -> Result<()> {
tracing::info!(listen = %cfg.server.listen, "starting helexa-upstream");
helexa_upstream::run(cfg).await?;
}
Commands::Mint {
config,
value,
count,
denomination,
} => {
let cfg = UpstreamConfig::load(&config)
.map_err(|e| anyhow::anyhow!("failed to load config from '{config}': {e}"))?;
let pool =
helexa_upstream::db::connect_and_migrate(&cfg.db.url, cfg.db.max_connections)
.await?;
let codes =
helexa_upstream::topup::mint(&pool, value, count, denomination.as_deref()).await?;
// Raw codes to stdout (one per line) for the operator to distribute;
// logs/diagnostics go to stderr via tracing.
for code in codes {
println!("{code}");
}
}
}
Ok(())

View File

@@ -0,0 +1,82 @@
//! Single-use top-up codes (#B5) — the second half of the hybrid allocation
//! model. Each code grants `value` tokens to the account that redeems it,
//! raising `accounts.allocation_total`. Minting codes is operator/CLI side
//! (the future faucet bot calls the same `mint` path); redemption is a
//! `/web/v1` action.
//!
//! Security: only `sha256(code)` is stored. Redemption is **timing-safe and
//! single-use** — a conditional `UPDATE … WHERE redeemed_by IS NULL` does
//! the claim atomically (concurrent double-redeem → exactly one winner), and
//! a not-found code and an already-redeemed code return the **same** generic
//! failure with the same code path (no oracle for "valid but spent").
use crate::crypto::{random_token, sha256};
use sqlx::Row;
use sqlx::postgres::PgPool;
use uuid::Uuid;
#[derive(Debug, thiserror::Error)]
pub enum TopUpError {
/// Code unknown OR already redeemed — deliberately indistinguishable.
#[error("invalid or already-redeemed code")]
Invalid,
#[error(transparent)]
Db(#[from] sqlx::Error),
}
/// Redeem `raw_code` for `account_id`, raising the account's
/// `allocation_total` by the code's value. Returns the new total.
pub async fn redeem(pool: &PgPool, account_id: Uuid, raw_code: &str) -> Result<i64, TopUpError> {
let mut tx = pool.begin().await?;
// Atomic single-use claim. `redeemed_by IS NULL` is the guarantee: under
// concurrent redemption exactly one UPDATE touches the row.
let claimed = sqlx::query(
"UPDATE top_up_codes SET redeemed_by = $1, redeemed_at = now() \
WHERE code_hash = $2 AND redeemed_by IS NULL RETURNING value",
)
.bind(account_id)
.bind(sha256(raw_code))
.fetch_optional(&mut *tx)
.await?;
let Some(row) = claimed else {
// Not found or already redeemed — same path, same error.
return Err(TopUpError::Invalid);
};
let value: i64 = row.get("value");
let new_total: i64 = sqlx::query(
"UPDATE accounts SET allocation_total = allocation_total + $1 WHERE id = $2 \
RETURNING allocation_total",
)
.bind(value)
.bind(account_id)
.fetch_one(&mut *tx)
.await?
.get("allocation_total");
tx.commit().await?;
Ok(new_total)
}
/// Mint `count` codes each worth `value` tokens, optionally tagged with a
/// `denomination` label. Returns the raw codes (shown once — only their
/// hash is stored). The CLI prints these; the future faucet bot calls this.
pub async fn mint(
pool: &PgPool,
value: i64,
count: u32,
denomination: Option<&str>,
) -> Result<Vec<String>, sqlx::Error> {
let mut codes = Vec::with_capacity(count as usize);
for _ in 0..count {
let raw = format!("helexa-topup-{}", random_token());
sqlx::query(
"INSERT INTO top_up_codes (code_hash, value, denomination) VALUES ($1, $2, $3)",
)
.bind(sha256(&raw))
.bind(value)
.bind(denomination)
.execute(pool)
.await?;
codes.push(raw);
}
Ok(codes)
}

View File

@@ -35,6 +35,7 @@ pub fn router(state: &AppState) -> Router<AppState> {
"/web/v1/keys/{id}/limit",
axum::routing::patch(update_key_limit),
)
.route("/web/v1/redeem", post(redeem))
.layer(axum::middleware::from_fn_with_state(
state.clone(),
require_session,
@@ -565,3 +566,29 @@ async fn update_key_limit(
}
Ok(StatusCode::NO_CONTENT.into_response())
}
#[derive(Deserialize)]
struct RedeemReq {
code: String,
}
/// `POST /web/v1/redeem` — redeem a single-use top-up code, raising the
/// account's allocation. Returns the new total. Generic 400 for an invalid
/// or already-redeemed code (no oracle).
async fn redeem(
State(state): State<AppState>,
Extension(user): Extension<AuthUser>,
Json(req): Json<RedeemReq>,
) -> WebResult<Response> {
let acct = account_id_for(&state, user.0).await?;
match crate::topup::redeem(&state.pool, acct, &req.code).await {
Ok(new_total) => Ok(Json(json!({ "allocation_total": new_total })).into_response()),
Err(crate::topup::TopUpError::Invalid) => {
Err(WebError::BadRequest("invalid or already-redeemed code"))
}
Err(crate::topup::TopUpError::Db(e)) => {
tracing::error!(error = %e, "redeem db error");
Err(WebError::Internal)
}
}
}

View File

@@ -294,3 +294,132 @@ async fn fingerprint_abuse_silently_deactivates_all_no_clue() {
"deactivated account's key looks like any invalid key"
);
}
#[tokio::test]
async fn topup_redeem_raises_allocation_single_use() {
let Some((base, pool)) = spawn_or_skip("topup_redeem_raises_allocation_single_use").await
else {
return;
};
let email = unique_email();
post(
format!("{base}/web/v1/register"),
json!({"email": email, "password": "password123"}),
None,
)
.await;
pool.execute(
sqlx::query("UPDATE users SET email_verified = true WHERE email = $1").bind(&email),
)
.await
.unwrap();
let token = post(
format!("{base}/web/v1/login"),
json!({"email": email, "password": "password123"}),
None,
)
.await
.json::<Value>()
.await
.unwrap()["token"]
.as_str()
.unwrap()
.to_string();
// Mint a code worth 500_000 (mint path used by the CLI/faucet).
let codes = helexa_upstream::topup::mint(&pool, 500_000, 1, Some("test"))
.await
.unwrap();
let code = &codes[0];
// Redeem → allocation_total rises from the 1_000_000 free grant.
let r = post(
format!("{base}/web/v1/redeem"),
json!({"code": code}),
Some(&token),
)
.await;
assert_eq!(r.status(), 200);
assert_eq!(
r.json::<Value>().await.unwrap()["allocation_total"],
1_500_000
);
// Single-use: a second redemption fails generically (no oracle).
let r = post(
format!("{base}/web/v1/redeem"),
json!({"code": code}),
Some(&token),
)
.await;
assert_eq!(r.status(), 400);
// Unknown code: same generic 400.
let r = post(
format!("{base}/web/v1/redeem"),
json!({"code": "helexa-topup-does-not-exist"}),
Some(&token),
)
.await;
assert_eq!(r.status(), 400);
}
#[tokio::test]
async fn topup_concurrent_double_redeem_one_winner() {
let Some((base, pool)) = spawn_or_skip("topup_concurrent_double_redeem_one_winner").await
else {
return;
};
// Two verified accounts.
let mut tokens = Vec::new();
for _ in 0..2 {
let email = unique_email();
post(
format!("{base}/web/v1/register"),
json!({"email": email, "password": "password123"}),
None,
)
.await;
pool.execute(
sqlx::query("UPDATE users SET email_verified = true WHERE email = $1").bind(&email),
)
.await
.unwrap();
let t = post(
format!("{base}/web/v1/login"),
json!({"email": email, "password": "password123"}),
None,
)
.await
.json::<Value>()
.await
.unwrap()["token"]
.as_str()
.unwrap()
.to_string();
tokens.push(t);
}
let code = helexa_upstream::topup::mint(&pool, 100, 1, None)
.await
.unwrap()
.remove(0);
// Both accounts race to redeem the same code; exactly one wins.
let (a, b) = tokio::join!(
post(
format!("{base}/web/v1/redeem"),
json!({"code": code}),
Some(&tokens[0])
),
post(
format!("{base}/web/v1/redeem"),
json!({"code": code}),
Some(&tokens[1])
),
);
let wins = [a.status(), b.status()]
.iter()
.filter(|s| s.as_u16() == 200)
.count();
assert_eq!(wins, 1, "exactly one redemption wins the single-use code");
}

View File

@@ -23,7 +23,7 @@ const ROOT = path.resolve(
const RESOURCES_DIR = path.join(ROOT, "src", "i18n", "resources");
// Namespaces to validate.
const NAMESPACES = ["common", "mission", "chat"];
const NAMESPACES = ["common", "mission", "chat", "account"];
// Languages to validate should track SUPPORTED_LANGUAGES in src/i18n/languages.ts.
// NOTE: This list is intentionally narrower than SUPPORTED_LANGUAGES and does not

View File

@@ -1,35 +1,58 @@
import { BrowserRouter, Routes, Route } from "react-router-dom";
import { Container } from "react-bootstrap";
import ThemeProvider from "./layout/ThemeProvider";
import AuthProvider from "./auth/AuthProvider";
import RequireAuth from "./auth/RequireAuth";
import Header from "./components/Header";
import Footer from "./components/Footer";
import Mission from "./pages/Mission";
import Chat from "./pages/Chat";
import Login from "./pages/auth/Login";
import Register from "./pages/auth/Register";
import VerifyEmail from "./pages/auth/VerifyEmail";
import RequestReset from "./pages/auth/RequestReset";
import ResetPassword from "./pages/auth/ResetPassword";
import Dashboard from "./pages/account/Dashboard";
import ApiKeys from "./pages/account/ApiKeys";
import "./App.css";
// Composition root: theme + router + layout shell. `/mission` (F2) is the
// EU-sovereignty narrative; the chat workspace at `/` (F3) and the
// auth/account routes (F4) replace the placeholder in later phases.
function Placeholder({ title }: { title: string }) {
return (
<Container className="py-5 flex-grow-1">
<h1 className="mb-2">{title}</h1>
<p className="text-muted">helexa public beta coming online.</p>
</Container>
);
}
// Composition root: theme router → auth → layout shell. `/` is the chat
// workspace (F3); `/mission` the EU-sovereignty narrative (F2); the auth +
// account routes (F4) follow, with /account guarded.
export default function App() {
return (
<ThemeProvider>
<BrowserRouter>
<div className="d-flex flex-column min-vh-100">
<Header />
<Routes>
<Route path="/" element={<Placeholder title="Chat" />} />
<Route path="/mission" element={<Mission />} />
</Routes>
<Footer />
</div>
<AuthProvider>
<div className="d-flex flex-column min-vh-100">
<Header />
<Routes>
<Route path="/" element={<Chat />} />
<Route path="/mission" element={<Mission />} />
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
<Route path="/verify" element={<VerifyEmail />} />
<Route path="/forgot" element={<RequestReset />} />
<Route path="/reset" element={<ResetPassword />} />
<Route
path="/account"
element={
<RequireAuth>
<Dashboard />
</RequireAuth>
}
/>
<Route
path="/account/keys"
element={
<RequireAuth>
<ApiKeys />
</RequireAuth>
}
/>
</Routes>
<Footer />
</div>
</AuthProvider>
</BrowserRouter>
</ThemeProvider>
);

View File

@@ -0,0 +1,213 @@
// Account API client over helexa-upstream's /web/v1 (B4/B5). The browser
// calls a same-origin `/api` prefix (vite-proxied in dev, nginx-routed in
// prod). A MockAccountApi behind VITE_USE_MOCK_ACCOUNT_API lets the
// dashboard be built/demoed before the upstream service is reachable.
import {
ApiError,
type AccountBalance,
type ApiKeySummary,
type CreatedKey,
type Session,
} from "./types";
export interface AccountApi {
register(email: string, password: string, fingerprint?: string): Promise<void>;
verify(token: string): Promise<void>;
login(email: string, password: string): Promise<Session>;
requestReset(email: string): Promise<void>;
confirmReset(token: string, newPassword: string): Promise<void>;
account(token: string): Promise<AccountBalance>;
listKeys(token: string): Promise<ApiKeySummary[]>;
createKey(
token: string,
label: string,
limitKind: "percent" | "hardcap",
limitValue: number,
): Promise<CreatedKey>;
archiveKey(token: string, id: string): Promise<void>;
updateKeyLimit(
token: string,
id: string,
limitKind: "percent" | "hardcap",
limitValue: number,
): Promise<void>;
redeem(token: string, code: string): Promise<AccountBalance>;
}
const BASE = (import.meta.env.VITE_ACCOUNT_BASE_URL || "/api").replace(/\/$/, "");
async function call<T>(
path: string,
init: RequestInit & { token?: string } = {},
): Promise<T> {
const headers: Record<string, string> = { "content-type": "application/json" };
if (init.token) headers.authorization = `Bearer ${init.token}`;
let resp: Response;
try {
resp = await fetch(`${BASE}${path}`, { ...init, headers });
} catch {
throw new ApiError(0, "network_error", "Could not reach the account service.");
}
if (resp.status === 204) return undefined as T;
let body: unknown = null;
try {
body = await resp.json();
} catch {
/* empty body */
}
if (!resp.ok) {
const err = (body as { error?: { code?: string; message?: string } })?.error;
throw new ApiError(resp.status, err?.code ?? "error", err?.message ?? "Request failed.");
}
return body as T;
}
class RealAccountApi implements AccountApi {
async register(email: string, password: string, fingerprint?: string) {
await call("/register", {
method: "POST",
body: JSON.stringify({ email, password, fingerprint }),
});
}
async verify(token: string) {
await call("/verify", { method: "POST", body: JSON.stringify({ token }) });
}
login(email: string, password: string) {
return call<Session>("/login", {
method: "POST",
body: JSON.stringify({ email, password }),
});
}
async requestReset(email: string) {
await call("/password-reset/request", {
method: "POST",
body: JSON.stringify({ email }),
});
}
async confirmReset(token: string, newPassword: string) {
await call("/password-reset/confirm", {
method: "POST",
body: JSON.stringify({ token, new_password: newPassword }),
});
}
account(token: string) {
return call<AccountBalance>("/account", { token });
}
listKeys(token: string) {
return call<{ keys: ApiKeySummary[] }>("/keys", { token }).then((r) => r.keys);
}
createKey(token: string, label: string, limit_kind: "percent" | "hardcap", limit_value: number) {
return call<CreatedKey>("/keys", {
method: "POST",
token,
body: JSON.stringify({ label, limit_kind, limit_value }),
});
}
async archiveKey(token: string, id: string) {
await call(`/keys/${id}/archive`, { method: "POST", token, body: "{}" });
}
async updateKeyLimit(
token: string,
id: string,
limit_kind: "percent" | "hardcap",
limit_value: number,
) {
await call(`/keys/${id}/limit`, {
method: "PATCH",
token,
body: JSON.stringify({ limit_kind, limit_value }),
});
}
redeem(token: string, code: string) {
return call<AccountBalance>("/redeem", {
method: "POST",
token,
body: JSON.stringify({ code }),
});
}
}
// ── Mock (VITE_USE_MOCK_ACCOUNT_API) ────────────────────────────────
// Minimal in-memory account so the dashboard is fully developable offline.
class MockAccountApi implements AccountApi {
private total = 1_000_000;
private spent = 0;
private reserved = 0;
private keys: ApiKeySummary[] = [];
private seq = 1;
async register() {}
async verify() {}
async login(): Promise<Session> {
return { token: "mock-token", expires_in: 604800 };
}
async requestReset() {}
async confirmReset() {}
async account(): Promise<AccountBalance> {
return {
account_id: "mock-account",
allocation_total: this.total,
allocation_spent: this.spent,
allocation_reserved: this.reserved,
};
}
async listKeys(): Promise<ApiKeySummary[]> {
return [...this.keys];
}
async createKey(
_t: string,
label: string,
limit_kind: "percent" | "hardcap",
limit_value: number,
): Promise<CreatedKey> {
const id = `mock-${this.seq++}`;
const prefix = `sk-helexa-mock${this.seq}`;
this.keys.push({
id,
prefix,
label,
status: "active",
limit_kind,
limit_value,
spent: 0,
reserved: 0,
created_at: new Date().toISOString(),
});
return { id, key: `${prefix}-RAWSECRETSHOWNONCE`, prefix, limit_kind, limit_value };
}
async archiveKey(_t: string, id: string) {
const k = this.keys.find((x) => x.id === id);
if (k) k.status = "archived";
}
async updateKeyLimit(
_t: string,
id: string,
limit_kind: "percent" | "hardcap",
limit_value: number,
) {
const k = this.keys.find((x) => x.id === id);
if (k) {
k.limit_kind = limit_kind;
k.limit_value = limit_value;
}
}
async redeem(_t: string, code: string): Promise<AccountBalance> {
if (!code.startsWith("helexa-topup-")) {
throw new ApiError(400, "bad_request", "invalid or already-redeemed code");
}
this.total += 500_000;
return this.account();
}
}
let instance: AccountApi | null = null;
export function accountApi(): AccountApi {
if (!instance) {
instance = import.meta.env.VITE_USE_MOCK_ACCOUNT_API
? new MockAccountApi()
: new RealAccountApi();
}
return instance;
}

View File

@@ -0,0 +1,45 @@
// Wire types for the helexa-upstream /web/v1 account API (B4/B5).
export interface ApiKeySummary {
id: string;
prefix: string;
label: string;
status: "active" | "archived";
limit_kind: "percent" | "hardcap";
limit_value: number;
spent: number;
reserved: number;
created_at: string;
}
export interface CreatedKey {
id: string;
/** Raw secret — shown exactly once at creation. */
key: string;
prefix: string;
limit_kind: "percent" | "hardcap";
limit_value: number;
}
export interface AccountBalance {
account_id: string;
allocation_total: number;
allocation_spent: number;
allocation_reserved: number;
}
export interface Session {
token: string;
expires_in: number;
}
/** Typed error carrying the backend's machine-readable code. */
export class ApiError extends Error {
code: string;
status: number;
constructor(status: number, code: string, message: string) {
super(message);
this.code = code;
this.status = status;
}
}

View File

@@ -0,0 +1,76 @@
import { useEffect, useState, type ReactNode } from "react";
import { accountApi } from "../api/account";
import { claimAnonymousData } from "../data/repositories";
import { getFingerprint } from "../lib/fingerprint";
import { AuthContext } from "./context";
const TOKEN_KEY = "helexa.token";
const EMAIL_KEY = "helexa.email";
export default function AuthProvider({ children }: { children: ReactNode }) {
const [token, setToken] = useState<string | null>(() =>
localStorage.getItem(TOKEN_KEY),
);
const [email, setEmail] = useState<string | null>(() =>
localStorage.getItem(EMAIL_KEY),
);
const [accountId, setAccountId] = useState<string | null>(null);
// Resolve the account id for an existing session (page reload) so the chat
// workspace can scope its IndexedDB owner without a fresh login.
useEffect(() => {
if (!token || accountId) return;
accountApi()
.account(token)
.then((a) => setAccountId(a.account_id))
.catch(() => {
/* token may be stale; chat falls back to anon until re-login */
});
}, [token, accountId]);
async function login(em: string, password: string): Promise<void> {
const api = accountApi();
const session = await api.login(em, password);
localStorage.setItem(TOKEN_KEY, session.token);
localStorage.setItem(EMAIL_KEY, em);
setToken(session.token);
setEmail(em);
// Claim anonymous local history into the account (stays client-side).
try {
const acct = await api.account(session.token);
setAccountId(acct.account_id);
await claimAnonymousData(acct.account_id);
} catch {
/* non-fatal */
}
}
async function register(em: string, password: string): Promise<void> {
const fingerprint = await getFingerprint();
await accountApi().register(em, password, fingerprint);
}
function logout(): void {
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(EMAIL_KEY);
setToken(null);
setEmail(null);
setAccountId(null);
}
return (
<AuthContext.Provider
value={{
token,
email,
accountId,
status: token ? "authed" : "anon",
login,
register,
logout,
}}
>
{children}
</AuthContext.Provider>
);
}

View File

@@ -0,0 +1,14 @@
import { type ReactNode } from "react";
import { Navigate, useLocation } from "react-router-dom";
import { useAuth } from "./context";
/** Route guard: redirect unauthenticated users to /login?next=…. */
export default function RequireAuth({ children }: { children: ReactNode }) {
const { status } = useAuth();
const location = useLocation();
if (status !== "authed") {
const next = encodeURIComponent(location.pathname + location.search);
return <Navigate to={`/login?next=${next}`} replace />;
}
return <>{children}</>;
}

View File

@@ -0,0 +1,26 @@
import { createContext, useContext } from "react";
export interface AuthContextValue {
token: string | null;
email: string | null;
/** The signed-in account id (for the Dexie owner + usage queries). */
accountId: string | null;
status: "anon" | "authed";
login: (email: string, password: string) => Promise<void>;
register: (email: string, password: string) => Promise<void>;
logout: () => void;
}
export const AuthContext = createContext<AuthContextValue>({
token: null,
email: null,
accountId: null,
status: "anon",
login: async () => {},
register: async () => {},
logout: () => {},
});
export function useAuth(): AuthContextValue {
return useContext(AuthContext);
}

View File

@@ -6,11 +6,12 @@ import { useTheme } from "../layout/theme";
import { useTranslation } from "react-i18next";
import { AUTONYM_MAP, type LanguageCode, isRtlLanguage } from "../i18n/languages";
import { getLanguageOptionsByUsage } from "../i18n/translation-priority";
import { useAuth } from "../auth/context";
/**
* Top navigation: brand, primary routes (chat at `/`, `/mission`), an
* auth-aware cluster (stubbed until F4 wires sessions), the theme toggle,
* and the language selector.
* auth-aware cluster (Account/Sign out when signed in, else Sign in/up),
* the theme toggle, and the language selector.
*
* The language picker is ordered by **estimated usage**
* (getLanguageOptionsByUsage), not alphabetically — a deliberate choice that
@@ -21,6 +22,7 @@ import { getLanguageOptionsByUsage } from "../i18n/translation-priority";
const Header: React.FC = () => {
const { theme, toggleTheme } = useTheme();
const { t, i18n } = useTranslation("common");
const { status, logout } = useAuth();
const currentLanguage: LanguageCode = (i18n.language.split("-")[0] ||
"en") as LanguageCode;
@@ -75,13 +77,31 @@ const Header: React.FC = () => {
</Nav>
<div className="d-flex align-items-center gap-2">
{/* Auth cluster — plain links until F4 wires session state. */}
<NavLink to="/login" className="nav-link">
{t("nav.login")}
</NavLink>
<NavLink to="/register" className="nav-link">
{t("nav.register")}
</NavLink>
{/* Auth-aware cluster. */}
{status === "authed" ? (
<>
<NavLink to="/account" className="nav-link">
{t("nav.account")}
</NavLink>
<Button
size="sm"
variant="outline-secondary"
onClick={logout}
className="me-1"
>
{t("nav.logout")}
</Button>
</>
) : (
<>
<NavLink to="/login" className="nav-link">
{t("nav.login")}
</NavLink>
<NavLink to="/register" className="nav-link">
{t("nav.register")}
</NavLink>
</>
)}
<Button
size="sm"

72
helexa.ai/src/data/db.ts Normal file
View File

@@ -0,0 +1,72 @@
// IndexedDB (Dexie) — the ONLY home for chat history and project
// organisation. Nothing here is ever sent to a server (#69/#F3): the mesh
// serves inference, but conversations live exclusively in the browser.
//
// `owner` namespaces data: `"anon"` for the fingerprinted anonymous visitor,
// or an account id once signed in. On login, anonymous data can be claimed
// into the account (F4) — still purely client-side.
import Dexie, { type Table } from "dexie";
export interface Project {
id: string;
owner: string;
name: string;
createdAt: number;
updatedAt: number;
archived: boolean;
sortOrder: number;
}
export interface Conversation {
id: string;
owner: string;
projectId: string | null; // null → "Unsorted"
title: string;
model: string;
createdAt: number;
updatedAt: number;
pinned: boolean;
}
export type MessageRole = "system" | "user" | "assistant";
export type MessageStatus = "complete" | "streaming" | "error";
export interface Message {
id: string;
conversationId: string;
role: MessageRole;
content: string;
createdAt: number;
status: MessageStatus;
errorCode?: string;
promptTokens?: number;
completionTokens?: number;
}
/** Small key/value store: fingerprint, active conversation, anon usage. */
export interface Meta {
key: string;
value: unknown;
}
class HelexaDB extends Dexie {
projects!: Table<Project, string>;
conversations!: Table<Conversation, string>;
messages!: Table<Message, string>;
meta!: Table<Meta, string>;
constructor() {
super("helexa");
this.version(1).stores({
// Indexes only — Dexie stores the whole object. Compound indexes
// drive the common queries (by owner, by conversation in time order).
projects: "id, owner, [owner+archived], updatedAt",
conversations: "id, owner, projectId, [owner+projectId], updatedAt",
messages: "id, conversationId, [conversationId+createdAt]",
meta: "key",
});
}
}
export const db = new HelexaDB();

View File

@@ -0,0 +1,154 @@
// Typed CRUD + queries over the Dexie store. UI components use the
// `useLiveQuery` hook (dexie-react-hooks) with the list helpers here so the
// sidebar/thread react to writes automatically.
import Dexie from "dexie";
import {
db,
type Conversation,
type Message,
type MessageRole,
type Project,
} from "./db";
function uuid(): string {
return crypto.randomUUID();
}
function now(): number {
return Date.now();
}
// ── projects ────────────────────────────────────────────────────────
export async function listProjects(owner: string): Promise<Project[]> {
const rows = await db.projects.where({ owner }).toArray();
return rows
.filter((p) => !p.archived)
.sort((a, b) => a.sortOrder - b.sortOrder || a.createdAt - b.createdAt);
}
export async function createProject(owner: string, name: string): Promise<string> {
const id = uuid();
const ts = now();
await db.projects.add({
id,
owner,
name,
createdAt: ts,
updatedAt: ts,
archived: false,
sortOrder: ts,
});
return id;
}
export async function renameProject(id: string, name: string): Promise<void> {
await db.projects.update(id, { name, updatedAt: now() });
}
export async function archiveProject(id: string): Promise<void> {
// Detach its conversations to "Unsorted" so nothing is orphaned.
await db.transaction("rw", db.projects, db.conversations, async () => {
await db.projects.update(id, { archived: true, updatedAt: now() });
const convs = await db.conversations.where({ projectId: id }).toArray();
await Promise.all(
convs.map((c) => db.conversations.update(c.id, { projectId: null })),
);
});
}
// ── conversations ───────────────────────────────────────────────────
export async function listConversations(owner: string): Promise<Conversation[]> {
const rows = await db.conversations.where({ owner }).toArray();
return rows.sort(
(a, b) => Number(b.pinned) - Number(a.pinned) || b.updatedAt - a.updatedAt,
);
}
export async function createConversation(
owner: string,
model: string,
projectId: string | null = null,
title = "New chat",
): Promise<string> {
const id = uuid();
const ts = now();
await db.conversations.add({
id,
owner,
projectId,
title,
model,
createdAt: ts,
updatedAt: ts,
pinned: false,
});
return id;
}
export async function renameConversation(id: string, title: string): Promise<void> {
await db.conversations.update(id, { title, updatedAt: now() });
}
export async function moveConversation(
id: string,
projectId: string | null,
): Promise<void> {
await db.conversations.update(id, { projectId, updatedAt: now() });
}
export async function deleteConversation(id: string): Promise<void> {
await db.transaction("rw", db.conversations, db.messages, async () => {
await db.messages.where({ conversationId: id }).delete();
await db.conversations.delete(id);
});
}
// ── messages ────────────────────────────────────────────────────────
export async function listMessages(conversationId: string): Promise<Message[]> {
return db.messages
.where("[conversationId+createdAt]")
.between([conversationId, Dexie.minKey], [conversationId, Dexie.maxKey])
.toArray();
}
export async function addMessage(
conversationId: string,
role: MessageRole,
content: string,
status: Message["status"] = "complete",
): Promise<string> {
const id = uuid();
await db.messages.add({ id, conversationId, role, content, createdAt: now(), status });
await db.conversations.update(conversationId, { updatedAt: now() });
return id;
}
export async function appendToMessage(id: string, delta: string): Promise<void> {
const msg = await db.messages.get(id);
if (!msg) return;
await db.messages.update(id, { content: msg.content + delta });
}
export async function finalizeMessage(
id: string,
patch: Partial<Pick<Message, "status" | "errorCode" | "promptTokens" | "completionTokens">>,
): Promise<void> {
await db.messages.update(id, patch);
}
/** Rewrite all `anon` data to `accountId` on first login (stays local). */
export async function claimAnonymousData(accountId: string): Promise<void> {
await db.transaction("rw", db.projects, db.conversations, async () => {
const projects = await db.projects.where({ owner: "anon" }).toArray();
await Promise.all(
projects.map((p) => db.projects.update(p.id, { owner: accountId })),
);
const convs = await db.conversations.where({ owner: "anon" }).toArray();
await Promise.all(
convs.map((c) => db.conversations.update(c.id, { owner: accountId })),
);
});
}

View File

@@ -13,131 +13,163 @@ import ruCommon from "./resources/ru/common.json";
import enMission from "./resources/en/mission.json";
import ruMission from "./resources/ru/mission.json";
import enChat from "./resources/en/chat.json";
import enAccount from "./resources/en/account.json";
import ruChat from "./resources/ru/chat.json";
import ruAccount from "./resources/ru/account.json";
// Scandinavian & Nordic languages
import daCommon from "./resources/da/common.json";
import daMission from "./resources/da/mission.json";
import daChat from "./resources/da/chat.json";
import daAccount from "./resources/da/account.json";
import fiCommon from "./resources/fi/common.json";
import fiMission from "./resources/fi/mission.json";
import fiChat from "./resources/fi/chat.json";
import fiAccount from "./resources/fi/account.json";
import noCommon from "./resources/no/common.json";
import noMission from "./resources/no/mission.json";
import noChat from "./resources/no/chat.json";
import noAccount from "./resources/no/account.json";
import svCommon from "./resources/sv/common.json";
import svMission from "./resources/sv/mission.json";
import svChat from "./resources/sv/chat.json";
import svAccount from "./resources/sv/account.json";
import bgCommon from "./resources/bg/common.json";
import bgMission from "./resources/bg/mission.json";
import bgChat from "./resources/bg/chat.json";
import bgAccount from "./resources/bg/account.json";
import etCommon from "./resources/et/common.json";
import etMission from "./resources/et/mission.json";
import etChat from "./resources/et/chat.json";
import etAccount from "./resources/et/account.json";
// African & MENA languages
import swCommon from "./resources/sw/common.json";
import swMission from "./resources/sw/mission.json";
import swChat from "./resources/sw/chat.json";
import swAccount from "./resources/sw/account.json";
import arCommon from "./resources/ar/common.json";
import arMission from "./resources/ar/mission.json";
import arChat from "./resources/ar/chat.json";
import arAccount from "./resources/ar/account.json";
import faCommon from "./resources/fa/common.json";
import faMission from "./resources/fa/mission.json";
import faChat from "./resources/fa/chat.json";
import faAccount from "./resources/fa/account.json";
import haCommon from "./resources/ha/common.json";
import haMission from "./resources/ha/mission.json";
import haChat from "./resources/ha/chat.json";
import haAccount from "./resources/ha/account.json";
import amCommon from "./resources/am/common.json";
import amMission from "./resources/am/mission.json";
import amChat from "./resources/am/chat.json";
import amAccount from "./resources/am/account.json";
import yoCommon from "./resources/yo/common.json";
import yoMission from "./resources/yo/mission.json";
import yoChat from "./resources/yo/chat.json";
import yoAccount from "./resources/yo/account.json";
import zuCommon from "./resources/zu/common.json";
import zuMission from "./resources/zu/mission.json";
import zuChat from "./resources/zu/chat.json";
import zuAccount from "./resources/zu/account.json";
// Darija (Moroccan Arabic)
import maCommon from "./resources/ma/common.json";
import maMission from "./resources/ma/mission.json";
import maChat from "./resources/ma/chat.json";
import maAccount from "./resources/ma/account.json";
// European / other languages
import esCommon from "./resources/es/common.json";
import esMission from "./resources/es/mission.json";
import esChat from "./resources/es/chat.json";
import esAccount from "./resources/es/account.json";
import frCommon from "./resources/fr/common.json";
import frMission from "./resources/fr/mission.json";
import frChat from "./resources/fr/chat.json";
import frAccount from "./resources/fr/account.json";
import deCommon from "./resources/de/common.json";
import deMission from "./resources/de/mission.json";
import deChat from "./resources/de/chat.json";
import deAccount from "./resources/de/account.json";
import elCommon from "./resources/el/common.json";
import elMission from "./resources/el/mission.json";
import elChat from "./resources/el/chat.json";
import elAccount from "./resources/el/account.json";
import itCommon from "./resources/it/common.json";
import itMission from "./resources/it/mission.json";
import itChat from "./resources/it/chat.json";
import itAccount from "./resources/it/account.json";
import heCommon from "./resources/he/common.json";
import heMission from "./resources/he/mission.json";
import heChat from "./resources/he/chat.json";
import heAccount from "./resources/he/account.json";
import ptCommon from "./resources/pt/common.json";
import ptMission from "./resources/pt/mission.json";
import ptChat from "./resources/pt/chat.json";
import ptAccount from "./resources/pt/account.json";
import roCommon from "./resources/ro/common.json";
import roMission from "./resources/ro/mission.json";
import roChat from "./resources/ro/chat.json";
import roAccount from "./resources/ro/account.json";
import kaCommon from "./resources/ka/common.json";
import kaMission from "./resources/ka/mission.json";
import kaChat from "./resources/ka/chat.json";
import kaAccount from "./resources/ka/account.json";
import trCommon from "./resources/tr/common.json";
import trMission from "./resources/tr/mission.json";
import trChat from "./resources/tr/chat.json";
import trAccount from "./resources/tr/account.json";
import plCommon from "./resources/pl/common.json";
import plMission from "./resources/pl/mission.json";
import plChat from "./resources/pl/chat.json";
import plAccount from "./resources/pl/account.json";
import ukCommon from "./resources/uk/common.json";
import ukMission from "./resources/uk/mission.json";
import ukChat from "./resources/uk/chat.json";
import ukAccount from "./resources/uk/account.json";
import nlCommon from "./resources/nl/common.json";
import nlMission from "./resources/nl/mission.json";
import nlChat from "./resources/nl/chat.json";
import nlAccount from "./resources/nl/account.json";
import srCommon from "./resources/sr/common.json";
import srMission from "./resources/sr/mission.json";
import srChat from "./resources/sr/chat.json";
import srAccount from "./resources/sr/account.json";
import kkCommon from "./resources/kk/common.json";
import kkMission from "./resources/kk/mission.json";
import kkChat from "./resources/kk/chat.json";
import kkAccount from "./resources/kk/account.json";
import uzCommon from "./resources/uz/common.json";
import uzMission from "./resources/uz/mission.json";
import uzChat from "./resources/uz/chat.json";
import uzAccount from "./resources/uz/account.json";
/**
* Application translation resources, split by language and namespace.
@@ -151,41 +183,49 @@ const resources: Resource = {
common: enCommon,
mission: enMission,
chat: enChat,
account: enAccount,
},
ru: {
common: ruCommon,
mission: ruMission,
chat: ruChat,
account: ruAccount,
},
bg: {
common: bgCommon,
mission: bgMission,
chat: bgChat,
account: bgAccount,
},
da: {
common: daCommon,
mission: daMission,
chat: daChat,
account: daAccount,
},
et: {
common: etCommon,
mission: etMission,
chat: etChat,
account: etAccount,
},
fi: {
common: fiCommon,
mission: fiMission,
chat: fiChat,
account: fiAccount,
},
kk: {
common: kkCommon,
mission: kkMission,
chat: kkChat,
account: kkAccount,
},
uz: {
common: uzCommon,
mission: uzMission,
chat: uzChat,
account: uzAccount,
},
// African & MENA languages (LTR unless marked RTL via isRtlLanguage)
@@ -193,41 +233,49 @@ const resources: Resource = {
common: swCommon,
mission: swMission,
chat: swChat,
account: swAccount,
},
ar: {
common: arCommon,
mission: arMission,
chat: arChat,
account: arAccount,
},
fa: {
common: faCommon,
mission: faMission,
chat: faChat,
account: faAccount,
},
ha: {
common: haCommon,
mission: haMission,
chat: haChat,
account: haAccount,
},
am: {
common: amCommon,
mission: amMission,
chat: amChat,
account: amAccount,
},
yo: {
common: yoCommon,
mission: yoMission,
chat: yoChat,
account: yoAccount,
},
zu: {
common: zuCommon,
mission: zuMission,
chat: zuChat,
account: zuAccount,
},
ma: {
common: maCommon,
mission: maMission,
chat: maChat,
account: maAccount,
},
// European & other languages
@@ -235,81 +283,97 @@ const resources: Resource = {
common: esCommon,
mission: esMission,
chat: esChat,
account: esAccount,
},
fr: {
common: frCommon,
mission: frMission,
chat: frChat,
account: frAccount,
},
de: {
common: deCommon,
mission: deMission,
chat: deChat,
account: deAccount,
},
el: {
common: elCommon,
mission: elMission,
chat: elChat,
account: elAccount,
},
it: {
common: itCommon,
mission: itMission,
chat: itChat,
account: itAccount,
},
he: {
common: heCommon,
mission: heMission,
chat: heChat,
account: heAccount,
},
pt: {
common: ptCommon,
mission: ptMission,
chat: ptChat,
account: ptAccount,
},
ro: {
common: roCommon,
mission: roMission,
chat: roChat,
account: roAccount,
},
ka: {
common: kaCommon,
mission: kaMission,
chat: kaChat,
account: kaAccount,
},
tr: {
common: trCommon,
mission: trMission,
chat: trChat,
account: trAccount,
},
pl: {
common: plCommon,
mission: plMission,
chat: plChat,
account: plAccount,
},
uk: {
common: ukCommon,
mission: ukMission,
chat: ukChat,
account: ukAccount,
},
nl: {
common: nlCommon,
mission: nlMission,
chat: nlChat,
account: nlAccount,
},
sr: {
common: srCommon,
mission: srMission,
chat: srChat,
account: srAccount,
},
no: {
common: noCommon,
mission: noMission,
chat: noChat,
account: noAccount,
},
sv: {
common: svCommon,
mission: svMission,
chat: svChat,
account: svAccount,
},
};
@@ -335,7 +399,7 @@ i18n.use(initReactI18next).init({
lng: browserLang,
fallbackLng: "en",
supportedLngs: SUPPORTED_LANGUAGES,
ns: ["common", "mission", "chat"],
ns: ["common", "mission", "chat", "account"],
defaultNS: "common",
// Because we control the keys and interpolate only simple values.
interpolation: {

View File

@@ -0,0 +1,71 @@
{
"login": {
"title": "Sign in",
"email": "Email",
"password": "Password",
"submit": "Sign in",
"noAccount": "No account? Sign up"
},
"register": {
"title": "Create your account",
"email": "Email",
"password": "Password",
"submit": "Sign up",
"haveAccount": "Already have an account? Sign in",
"checkEmail": "Almost there — check your email to verify your account."
},
"verify": {
"verifying": "Verifying…",
"ok": "Email verified. You can now sign in.",
"failed": "This verification link is invalid or has expired.",
"toLogin": "Go to sign in"
},
"reset": {
"requestTitle": "Reset your password",
"email": "Email",
"requestSubmit": "Send reset link",
"requestDone": "If that email has an account, a reset link is on its way.",
"confirmTitle": "Choose a new password",
"newPassword": "New password",
"confirmSubmit": "Set password",
"ok": "Password updated. You can now sign in."
},
"dashboard": {
"title": "Account",
"balance": "Allocation",
"total": "Total",
"spent": "Spent",
"reserved": "Reserved",
"remaining": "Remaining",
"manageKeys": "Manage API keys",
"redeemTitle": "Redeem a top-up code",
"redeemPlaceholder": "helexa-topup-…",
"redeem": "Redeem",
"redeemed": "Code redeemed.",
"logout": "Sign out"
},
"keys": {
"title": "API keys",
"create": "Create key",
"label": "Label",
"limitKind": "Limit",
"percent": "% of allocation",
"hardcap": "Hard cap (tokens)",
"value": "Value",
"none": "No keys yet.",
"createdTitle": "Your new API key",
"createdWarn": "Copy it now — you won't see it again.",
"copy": "Copy",
"copied": "Copied",
"archive": "Archive",
"save": "Save",
"status": "Status",
"usage": "Used",
"useForChat": "Use for chat on this device",
"usedForChat": "Enabled for chat ✓"
},
"error": {
"generic": "Something went wrong.",
"unauthorized": "Please sign in again."
}
}

View File

@@ -5,5 +5,17 @@
"transcriptPlaceholder": "የውይይቱ ሪኮርድ እዚህ ይታያል። የሞዴሉን እና የተጠቃሚውን መልዕክቶች በሚንቀሳቀስ ኮንቴይነር ውስጥ ያቀርቡ፣ приወይም በዙር ዙር በመከፈል ማቅረብ ይችላሉ።",
"inputPlaceholder": "ውይይትን ለመጀምር መልዕክት ይፃፉ…",
"send": "መላክ",
"clear": "ማጽዳት"
"clear": "ማጽዳት",
"newChat": "New chat",
"newProject": "New project",
"newProjectName": "New project",
"unsorted": "Unsorted",
"emptyState": "Start a conversation. Your history stays in this browser.",
"anonBanner": "You have reached the anonymous limit. Sign up for a free allocation.",
"signUp": "Sign up",
"stop": "Stop",
"topUp": "Top up",
"rateLimited": "Rate limited — wait a moment and retry.",
"needsKey": "Create an API key and enable it for chat to send as yourself.",
"manageKeysLink": "Manage keys"
}

View File

@@ -0,0 +1,71 @@
{
"login": {
"title": "Sign in",
"email": "Email",
"password": "Password",
"submit": "Sign in",
"noAccount": "No account? Sign up"
},
"register": {
"title": "Create your account",
"email": "Email",
"password": "Password",
"submit": "Sign up",
"haveAccount": "Already have an account? Sign in",
"checkEmail": "Almost there — check your email to verify your account."
},
"verify": {
"verifying": "Verifying…",
"ok": "Email verified. You can now sign in.",
"failed": "This verification link is invalid or has expired.",
"toLogin": "Go to sign in"
},
"reset": {
"requestTitle": "Reset your password",
"email": "Email",
"requestSubmit": "Send reset link",
"requestDone": "If that email has an account, a reset link is on its way.",
"confirmTitle": "Choose a new password",
"newPassword": "New password",
"confirmSubmit": "Set password",
"ok": "Password updated. You can now sign in."
},
"dashboard": {
"title": "Account",
"balance": "Allocation",
"total": "Total",
"spent": "Spent",
"reserved": "Reserved",
"remaining": "Remaining",
"manageKeys": "Manage API keys",
"redeemTitle": "Redeem a top-up code",
"redeemPlaceholder": "helexa-topup-…",
"redeem": "Redeem",
"redeemed": "Code redeemed.",
"logout": "Sign out"
},
"keys": {
"title": "API keys",
"create": "Create key",
"label": "Label",
"limitKind": "Limit",
"percent": "% of allocation",
"hardcap": "Hard cap (tokens)",
"value": "Value",
"none": "No keys yet.",
"createdTitle": "Your new API key",
"createdWarn": "Copy it now — you won't see it again.",
"copy": "Copy",
"copied": "Copied",
"archive": "Archive",
"save": "Save",
"status": "Status",
"usage": "Used",
"useForChat": "Use for chat on this device",
"usedForChat": "Enabled for chat ✓"
},
"error": {
"generic": "Something went wrong.",
"unauthorized": "Please sign in again."
}
}

View File

@@ -5,5 +5,17 @@
"transcriptPlaceholder": "سجل المحادثة سيظهر هنا. اعرض رسائل النموذج والمستخدم في حاوية قابلة للتمرير، ويمكنك تجميعها حسب أدوار الحوار إذا رغبت.",
"inputPlaceholder": "اكتب رسالة لبدء المحادثة…",
"send": "إرسال",
"clear": "مسح"
"clear": "مسح",
"newChat": "New chat",
"newProject": "New project",
"newProjectName": "New project",
"unsorted": "Unsorted",
"emptyState": "Start a conversation. Your history stays in this browser.",
"anonBanner": "You have reached the anonymous limit. Sign up for a free allocation.",
"signUp": "Sign up",
"stop": "Stop",
"topUp": "Top up",
"rateLimited": "Rate limited — wait a moment and retry.",
"needsKey": "Create an API key and enable it for chat to send as yourself.",
"manageKeysLink": "Manage keys"
}

View File

@@ -0,0 +1,71 @@
{
"login": {
"title": "Sign in",
"email": "Email",
"password": "Password",
"submit": "Sign in",
"noAccount": "No account? Sign up"
},
"register": {
"title": "Create your account",
"email": "Email",
"password": "Password",
"submit": "Sign up",
"haveAccount": "Already have an account? Sign in",
"checkEmail": "Almost there — check your email to verify your account."
},
"verify": {
"verifying": "Verifying…",
"ok": "Email verified. You can now sign in.",
"failed": "This verification link is invalid or has expired.",
"toLogin": "Go to sign in"
},
"reset": {
"requestTitle": "Reset your password",
"email": "Email",
"requestSubmit": "Send reset link",
"requestDone": "If that email has an account, a reset link is on its way.",
"confirmTitle": "Choose a new password",
"newPassword": "New password",
"confirmSubmit": "Set password",
"ok": "Password updated. You can now sign in."
},
"dashboard": {
"title": "Account",
"balance": "Allocation",
"total": "Total",
"spent": "Spent",
"reserved": "Reserved",
"remaining": "Remaining",
"manageKeys": "Manage API keys",
"redeemTitle": "Redeem a top-up code",
"redeemPlaceholder": "helexa-topup-…",
"redeem": "Redeem",
"redeemed": "Code redeemed.",
"logout": "Sign out"
},
"keys": {
"title": "API keys",
"create": "Create key",
"label": "Label",
"limitKind": "Limit",
"percent": "% of allocation",
"hardcap": "Hard cap (tokens)",
"value": "Value",
"none": "No keys yet.",
"createdTitle": "Your new API key",
"createdWarn": "Copy it now — you won't see it again.",
"copy": "Copy",
"copied": "Copied",
"archive": "Archive",
"save": "Save",
"status": "Status",
"usage": "Used",
"useForChat": "Use for chat on this device",
"usedForChat": "Enabled for chat ✓"
},
"error": {
"generic": "Something went wrong.",
"unauthorized": "Please sign in again."
}
}

View File

@@ -5,5 +5,17 @@
"transcriptPlaceholder": "Тук ще се показва историята на чата. Визуализирай съобщенията от модела и потребителя в превъртащ се контейнер, по желание групирани по ход.",
"inputPlaceholder": "Напиши съобщение, за да започнеш разговора…",
"send": "Изпрати",
"clear": "Изчисти"
"clear": "Изчисти",
"newChat": "New chat",
"newProject": "New project",
"newProjectName": "New project",
"unsorted": "Unsorted",
"emptyState": "Start a conversation. Your history stays in this browser.",
"anonBanner": "You have reached the anonymous limit. Sign up for a free allocation.",
"signUp": "Sign up",
"stop": "Stop",
"topUp": "Top up",
"rateLimited": "Rate limited — wait a moment and retry.",
"needsKey": "Create an API key and enable it for chat to send as yourself.",
"manageKeysLink": "Manage keys"
}

View File

@@ -0,0 +1,71 @@
{
"login": {
"title": "Sign in",
"email": "Email",
"password": "Password",
"submit": "Sign in",
"noAccount": "No account? Sign up"
},
"register": {
"title": "Create your account",
"email": "Email",
"password": "Password",
"submit": "Sign up",
"haveAccount": "Already have an account? Sign in",
"checkEmail": "Almost there — check your email to verify your account."
},
"verify": {
"verifying": "Verifying…",
"ok": "Email verified. You can now sign in.",
"failed": "This verification link is invalid or has expired.",
"toLogin": "Go to sign in"
},
"reset": {
"requestTitle": "Reset your password",
"email": "Email",
"requestSubmit": "Send reset link",
"requestDone": "If that email has an account, a reset link is on its way.",
"confirmTitle": "Choose a new password",
"newPassword": "New password",
"confirmSubmit": "Set password",
"ok": "Password updated. You can now sign in."
},
"dashboard": {
"title": "Account",
"balance": "Allocation",
"total": "Total",
"spent": "Spent",
"reserved": "Reserved",
"remaining": "Remaining",
"manageKeys": "Manage API keys",
"redeemTitle": "Redeem a top-up code",
"redeemPlaceholder": "helexa-topup-…",
"redeem": "Redeem",
"redeemed": "Code redeemed.",
"logout": "Sign out"
},
"keys": {
"title": "API keys",
"create": "Create key",
"label": "Label",
"limitKind": "Limit",
"percent": "% of allocation",
"hardcap": "Hard cap (tokens)",
"value": "Value",
"none": "No keys yet.",
"createdTitle": "Your new API key",
"createdWarn": "Copy it now — you won't see it again.",
"copy": "Copy",
"copied": "Copied",
"archive": "Archive",
"save": "Save",
"status": "Status",
"usage": "Used",
"useForChat": "Use for chat on this device",
"usedForChat": "Enabled for chat ✓"
},
"error": {
"generic": "Something went wrong.",
"unauthorized": "Please sign in again."
}
}

View File

@@ -5,5 +5,17 @@
"transcriptPlaceholder": "Chattransskriptionen vises her. Gengiv beskeder fra modellen og brugeren i en rullende container, eventuelt grupperet efter tur.",
"inputPlaceholder": "Skriv en besked for at starte en chat…",
"send": "Send",
"clear": "Ryd"
"clear": "Ryd",
"newChat": "New chat",
"newProject": "New project",
"newProjectName": "New project",
"unsorted": "Unsorted",
"emptyState": "Start a conversation. Your history stays in this browser.",
"anonBanner": "You have reached the anonymous limit. Sign up for a free allocation.",
"signUp": "Sign up",
"stop": "Stop",
"topUp": "Top up",
"rateLimited": "Rate limited — wait a moment and retry.",
"needsKey": "Create an API key and enable it for chat to send as yourself.",
"manageKeysLink": "Manage keys"
}

View File

@@ -0,0 +1,71 @@
{
"login": {
"title": "Sign in",
"email": "Email",
"password": "Password",
"submit": "Sign in",
"noAccount": "No account? Sign up"
},
"register": {
"title": "Create your account",
"email": "Email",
"password": "Password",
"submit": "Sign up",
"haveAccount": "Already have an account? Sign in",
"checkEmail": "Almost there — check your email to verify your account."
},
"verify": {
"verifying": "Verifying…",
"ok": "Email verified. You can now sign in.",
"failed": "This verification link is invalid or has expired.",
"toLogin": "Go to sign in"
},
"reset": {
"requestTitle": "Reset your password",
"email": "Email",
"requestSubmit": "Send reset link",
"requestDone": "If that email has an account, a reset link is on its way.",
"confirmTitle": "Choose a new password",
"newPassword": "New password",
"confirmSubmit": "Set password",
"ok": "Password updated. You can now sign in."
},
"dashboard": {
"title": "Account",
"balance": "Allocation",
"total": "Total",
"spent": "Spent",
"reserved": "Reserved",
"remaining": "Remaining",
"manageKeys": "Manage API keys",
"redeemTitle": "Redeem a top-up code",
"redeemPlaceholder": "helexa-topup-…",
"redeem": "Redeem",
"redeemed": "Code redeemed.",
"logout": "Sign out"
},
"keys": {
"title": "API keys",
"create": "Create key",
"label": "Label",
"limitKind": "Limit",
"percent": "% of allocation",
"hardcap": "Hard cap (tokens)",
"value": "Value",
"none": "No keys yet.",
"createdTitle": "Your new API key",
"createdWarn": "Copy it now — you won't see it again.",
"copy": "Copy",
"copied": "Copied",
"archive": "Archive",
"save": "Save",
"status": "Status",
"usage": "Used",
"useForChat": "Use for chat on this device",
"usedForChat": "Enabled for chat ✓"
},
"error": {
"generic": "Something went wrong.",
"unauthorized": "Please sign in again."
}
}

View File

@@ -5,5 +5,17 @@
"transcriptPlaceholder": "Das Chat-Protokoll erscheint hier. Rendern die Nachrichten des Modells und der Nutzerin bzw. des Nutzers in einem scrollbaren Container, optional nach Gesprächsrunden gruppiert.",
"inputPlaceholder": "Schreibe eine Nachricht, um mit dem Chat zu beginnen…",
"send": "Senden",
"clear": "Leeren"
"clear": "Leeren",
"newChat": "New chat",
"newProject": "New project",
"newProjectName": "New project",
"unsorted": "Unsorted",
"emptyState": "Start a conversation. Your history stays in this browser.",
"anonBanner": "You have reached the anonymous limit. Sign up for a free allocation.",
"signUp": "Sign up",
"stop": "Stop",
"topUp": "Top up",
"rateLimited": "Rate limited — wait a moment and retry.",
"needsKey": "Create an API key and enable it for chat to send as yourself.",
"manageKeysLink": "Manage keys"
}

View File

@@ -0,0 +1,71 @@
{
"login": {
"title": "Sign in",
"email": "Email",
"password": "Password",
"submit": "Sign in",
"noAccount": "No account? Sign up"
},
"register": {
"title": "Create your account",
"email": "Email",
"password": "Password",
"submit": "Sign up",
"haveAccount": "Already have an account? Sign in",
"checkEmail": "Almost there — check your email to verify your account."
},
"verify": {
"verifying": "Verifying…",
"ok": "Email verified. You can now sign in.",
"failed": "This verification link is invalid or has expired.",
"toLogin": "Go to sign in"
},
"reset": {
"requestTitle": "Reset your password",
"email": "Email",
"requestSubmit": "Send reset link",
"requestDone": "If that email has an account, a reset link is on its way.",
"confirmTitle": "Choose a new password",
"newPassword": "New password",
"confirmSubmit": "Set password",
"ok": "Password updated. You can now sign in."
},
"dashboard": {
"title": "Account",
"balance": "Allocation",
"total": "Total",
"spent": "Spent",
"reserved": "Reserved",
"remaining": "Remaining",
"manageKeys": "Manage API keys",
"redeemTitle": "Redeem a top-up code",
"redeemPlaceholder": "helexa-topup-…",
"redeem": "Redeem",
"redeemed": "Code redeemed.",
"logout": "Sign out"
},
"keys": {
"title": "API keys",
"create": "Create key",
"label": "Label",
"limitKind": "Limit",
"percent": "% of allocation",
"hardcap": "Hard cap (tokens)",
"value": "Value",
"none": "No keys yet.",
"createdTitle": "Your new API key",
"createdWarn": "Copy it now — you won't see it again.",
"copy": "Copy",
"copied": "Copied",
"archive": "Archive",
"save": "Save",
"status": "Status",
"usage": "Used",
"useForChat": "Use for chat on this device",
"usedForChat": "Enabled for chat ✓"
},
"error": {
"generic": "Something went wrong.",
"unauthorized": "Please sign in again."
}
}

View File

@@ -5,5 +5,17 @@
"transcriptPlaceholder": "Το ιστορικό της συνομιλίας θα εμφανίζεται εδώ. Απόδωσε τα μηνύματα του μοντέλου και του χρήστη σε ένα κυλιόμενο container, προαιρετικά ομαδοποιημένα ανά γύρο.",
"inputPlaceholder": "Πληκτρολόγησε ένα μήνυμα για να ξεκινήσεις τη συνομιλία…",
"send": "Αποστολή",
"clear": "Καθαρισμός"
"clear": "Καθαρισμός",
"newChat": "New chat",
"newProject": "New project",
"newProjectName": "New project",
"unsorted": "Unsorted",
"emptyState": "Start a conversation. Your history stays in this browser.",
"anonBanner": "You have reached the anonymous limit. Sign up for a free allocation.",
"signUp": "Sign up",
"stop": "Stop",
"topUp": "Top up",
"rateLimited": "Rate limited — wait a moment and retry.",
"needsKey": "Create an API key and enable it for chat to send as yourself.",
"manageKeysLink": "Manage keys"
}

View File

@@ -0,0 +1,71 @@
{
"login": {
"title": "Sign in",
"email": "Email",
"password": "Password",
"submit": "Sign in",
"noAccount": "No account? Sign up"
},
"register": {
"title": "Create your account",
"email": "Email",
"password": "Password",
"submit": "Sign up",
"haveAccount": "Already have an account? Sign in",
"checkEmail": "Almost there — check your email to verify your account."
},
"verify": {
"verifying": "Verifying…",
"ok": "Email verified. You can now sign in.",
"failed": "This verification link is invalid or has expired.",
"toLogin": "Go to sign in"
},
"reset": {
"requestTitle": "Reset your password",
"email": "Email",
"requestSubmit": "Send reset link",
"requestDone": "If that email has an account, a reset link is on its way.",
"confirmTitle": "Choose a new password",
"newPassword": "New password",
"confirmSubmit": "Set password",
"ok": "Password updated. You can now sign in."
},
"dashboard": {
"title": "Account",
"balance": "Allocation",
"total": "Total",
"spent": "Spent",
"reserved": "Reserved",
"remaining": "Remaining",
"manageKeys": "Manage API keys",
"redeemTitle": "Redeem a top-up code",
"redeemPlaceholder": "helexa-topup-…",
"redeem": "Redeem",
"redeemed": "Code redeemed.",
"logout": "Sign out"
},
"keys": {
"title": "API keys",
"create": "Create key",
"label": "Label",
"limitKind": "Limit",
"percent": "% of allocation",
"hardcap": "Hard cap (tokens)",
"value": "Value",
"none": "No keys yet.",
"createdTitle": "Your new API key",
"createdWarn": "Copy it now — you won't see it again.",
"copy": "Copy",
"copied": "Copied",
"archive": "Archive",
"save": "Save",
"status": "Status",
"usage": "Used",
"useForChat": "Use for chat on this device",
"usedForChat": "Enabled for chat ✓"
},
"error": {
"generic": "Something went wrong.",
"unauthorized": "Please sign in again."
}
}

View File

@@ -5,5 +5,17 @@
"transcriptPlaceholder": "Chat transcript will appear here. Render messages from the model and user in a scrolling container, optionally grouped by turn.",
"inputPlaceholder": "Type a message to start chatting…",
"send": "Send",
"clear": "Clear"
"clear": "Clear",
"newChat": "New chat",
"newProject": "New project",
"newProjectName": "New project",
"unsorted": "Unsorted",
"emptyState": "Start a conversation. Your history stays in this browser.",
"anonBanner": "You have reached the anonymous limit. Sign up for a free allocation.",
"signUp": "Sign up",
"stop": "Stop",
"topUp": "Top up",
"rateLimited": "Rate limited — wait a moment and retry.",
"needsKey": "Create an API key and enable it for chat to send as yourself.",
"manageKeysLink": "Manage keys"
}

View File

@@ -0,0 +1,71 @@
{
"login": {
"title": "Sign in",
"email": "Email",
"password": "Password",
"submit": "Sign in",
"noAccount": "No account? Sign up"
},
"register": {
"title": "Create your account",
"email": "Email",
"password": "Password",
"submit": "Sign up",
"haveAccount": "Already have an account? Sign in",
"checkEmail": "Almost there — check your email to verify your account."
},
"verify": {
"verifying": "Verifying…",
"ok": "Email verified. You can now sign in.",
"failed": "This verification link is invalid or has expired.",
"toLogin": "Go to sign in"
},
"reset": {
"requestTitle": "Reset your password",
"email": "Email",
"requestSubmit": "Send reset link",
"requestDone": "If that email has an account, a reset link is on its way.",
"confirmTitle": "Choose a new password",
"newPassword": "New password",
"confirmSubmit": "Set password",
"ok": "Password updated. You can now sign in."
},
"dashboard": {
"title": "Account",
"balance": "Allocation",
"total": "Total",
"spent": "Spent",
"reserved": "Reserved",
"remaining": "Remaining",
"manageKeys": "Manage API keys",
"redeemTitle": "Redeem a top-up code",
"redeemPlaceholder": "helexa-topup-…",
"redeem": "Redeem",
"redeemed": "Code redeemed.",
"logout": "Sign out"
},
"keys": {
"title": "API keys",
"create": "Create key",
"label": "Label",
"limitKind": "Limit",
"percent": "% of allocation",
"hardcap": "Hard cap (tokens)",
"value": "Value",
"none": "No keys yet.",
"createdTitle": "Your new API key",
"createdWarn": "Copy it now — you won't see it again.",
"copy": "Copy",
"copied": "Copied",
"archive": "Archive",
"save": "Save",
"status": "Status",
"usage": "Used",
"useForChat": "Use for chat on this device",
"usedForChat": "Enabled for chat ✓"
},
"error": {
"generic": "Something went wrong.",
"unauthorized": "Please sign in again."
}
}

View File

@@ -5,5 +5,17 @@
"transcriptPlaceholder": "La transcripción del chat aparecerá aquí. Renderiza los mensajes del modelo y de la persona usuaria en un contenedor desplazable, opcionalmente agrupados por turno.",
"inputPlaceholder": "Escribe un mensaje para comenzar a chatear…",
"send": "Enviar",
"clear": "Limpiar"
"clear": "Limpiar",
"newChat": "New chat",
"newProject": "New project",
"newProjectName": "New project",
"unsorted": "Unsorted",
"emptyState": "Start a conversation. Your history stays in this browser.",
"anonBanner": "You have reached the anonymous limit. Sign up for a free allocation.",
"signUp": "Sign up",
"stop": "Stop",
"topUp": "Top up",
"rateLimited": "Rate limited — wait a moment and retry.",
"needsKey": "Create an API key and enable it for chat to send as yourself.",
"manageKeysLink": "Manage keys"
}

View File

@@ -0,0 +1,71 @@
{
"login": {
"title": "Sign in",
"email": "Email",
"password": "Password",
"submit": "Sign in",
"noAccount": "No account? Sign up"
},
"register": {
"title": "Create your account",
"email": "Email",
"password": "Password",
"submit": "Sign up",
"haveAccount": "Already have an account? Sign in",
"checkEmail": "Almost there — check your email to verify your account."
},
"verify": {
"verifying": "Verifying…",
"ok": "Email verified. You can now sign in.",
"failed": "This verification link is invalid or has expired.",
"toLogin": "Go to sign in"
},
"reset": {
"requestTitle": "Reset your password",
"email": "Email",
"requestSubmit": "Send reset link",
"requestDone": "If that email has an account, a reset link is on its way.",
"confirmTitle": "Choose a new password",
"newPassword": "New password",
"confirmSubmit": "Set password",
"ok": "Password updated. You can now sign in."
},
"dashboard": {
"title": "Account",
"balance": "Allocation",
"total": "Total",
"spent": "Spent",
"reserved": "Reserved",
"remaining": "Remaining",
"manageKeys": "Manage API keys",
"redeemTitle": "Redeem a top-up code",
"redeemPlaceholder": "helexa-topup-…",
"redeem": "Redeem",
"redeemed": "Code redeemed.",
"logout": "Sign out"
},
"keys": {
"title": "API keys",
"create": "Create key",
"label": "Label",
"limitKind": "Limit",
"percent": "% of allocation",
"hardcap": "Hard cap (tokens)",
"value": "Value",
"none": "No keys yet.",
"createdTitle": "Your new API key",
"createdWarn": "Copy it now — you won't see it again.",
"copy": "Copy",
"copied": "Copied",
"archive": "Archive",
"save": "Save",
"status": "Status",
"usage": "Used",
"useForChat": "Use for chat on this device",
"usedForChat": "Enabled for chat ✓"
},
"error": {
"generic": "Something went wrong.",
"unauthorized": "Please sign in again."
}
}

View File

@@ -5,5 +5,17 @@
"transcriptPlaceholder": "Vestluse logi kuvatakse siin. Esita mudeli ja kasutaja sõnumeid keritavas konteineris, soovi korral sammude kaupa rühmitatuna.",
"inputPlaceholder": "Alusta vestlust, kirjutades siia sõnumi…",
"send": "Saada",
"clear": "Tühjenda"
"clear": "Tühjenda",
"newChat": "New chat",
"newProject": "New project",
"newProjectName": "New project",
"unsorted": "Unsorted",
"emptyState": "Start a conversation. Your history stays in this browser.",
"anonBanner": "You have reached the anonymous limit. Sign up for a free allocation.",
"signUp": "Sign up",
"stop": "Stop",
"topUp": "Top up",
"rateLimited": "Rate limited — wait a moment and retry.",
"needsKey": "Create an API key and enable it for chat to send as yourself.",
"manageKeysLink": "Manage keys"
}

View File

@@ -0,0 +1,71 @@
{
"login": {
"title": "Sign in",
"email": "Email",
"password": "Password",
"submit": "Sign in",
"noAccount": "No account? Sign up"
},
"register": {
"title": "Create your account",
"email": "Email",
"password": "Password",
"submit": "Sign up",
"haveAccount": "Already have an account? Sign in",
"checkEmail": "Almost there — check your email to verify your account."
},
"verify": {
"verifying": "Verifying…",
"ok": "Email verified. You can now sign in.",
"failed": "This verification link is invalid or has expired.",
"toLogin": "Go to sign in"
},
"reset": {
"requestTitle": "Reset your password",
"email": "Email",
"requestSubmit": "Send reset link",
"requestDone": "If that email has an account, a reset link is on its way.",
"confirmTitle": "Choose a new password",
"newPassword": "New password",
"confirmSubmit": "Set password",
"ok": "Password updated. You can now sign in."
},
"dashboard": {
"title": "Account",
"balance": "Allocation",
"total": "Total",
"spent": "Spent",
"reserved": "Reserved",
"remaining": "Remaining",
"manageKeys": "Manage API keys",
"redeemTitle": "Redeem a top-up code",
"redeemPlaceholder": "helexa-topup-…",
"redeem": "Redeem",
"redeemed": "Code redeemed.",
"logout": "Sign out"
},
"keys": {
"title": "API keys",
"create": "Create key",
"label": "Label",
"limitKind": "Limit",
"percent": "% of allocation",
"hardcap": "Hard cap (tokens)",
"value": "Value",
"none": "No keys yet.",
"createdTitle": "Your new API key",
"createdWarn": "Copy it now — you won't see it again.",
"copy": "Copy",
"copied": "Copied",
"archive": "Archive",
"save": "Save",
"status": "Status",
"usage": "Used",
"useForChat": "Use for chat on this device",
"usedForChat": "Enabled for chat ✓"
},
"error": {
"generic": "Something went wrong.",
"unauthorized": "Please sign in again."
}
}

View File

@@ -5,5 +5,17 @@
"transcriptPlaceholder": "رونوشت گفتگو در اینجا نمایش داده می‌شود. پیام‌های مدل و کاربر را در یک محفظه قابل اسکرول رندر کنید؛ در صورت تمایل آن‌ها را بر اساس نوبت گروه‌بندی کنید.",
"inputPlaceholder": "برای شروع گفتگو یک پیام بنویسید…",
"send": "ارسال",
"clear": "پاک کردن"
"clear": "پاک کردن",
"newChat": "New chat",
"newProject": "New project",
"newProjectName": "New project",
"unsorted": "Unsorted",
"emptyState": "Start a conversation. Your history stays in this browser.",
"anonBanner": "You have reached the anonymous limit. Sign up for a free allocation.",
"signUp": "Sign up",
"stop": "Stop",
"topUp": "Top up",
"rateLimited": "Rate limited — wait a moment and retry.",
"needsKey": "Create an API key and enable it for chat to send as yourself.",
"manageKeysLink": "Manage keys"
}

View File

@@ -0,0 +1,71 @@
{
"login": {
"title": "Sign in",
"email": "Email",
"password": "Password",
"submit": "Sign in",
"noAccount": "No account? Sign up"
},
"register": {
"title": "Create your account",
"email": "Email",
"password": "Password",
"submit": "Sign up",
"haveAccount": "Already have an account? Sign in",
"checkEmail": "Almost there — check your email to verify your account."
},
"verify": {
"verifying": "Verifying…",
"ok": "Email verified. You can now sign in.",
"failed": "This verification link is invalid or has expired.",
"toLogin": "Go to sign in"
},
"reset": {
"requestTitle": "Reset your password",
"email": "Email",
"requestSubmit": "Send reset link",
"requestDone": "If that email has an account, a reset link is on its way.",
"confirmTitle": "Choose a new password",
"newPassword": "New password",
"confirmSubmit": "Set password",
"ok": "Password updated. You can now sign in."
},
"dashboard": {
"title": "Account",
"balance": "Allocation",
"total": "Total",
"spent": "Spent",
"reserved": "Reserved",
"remaining": "Remaining",
"manageKeys": "Manage API keys",
"redeemTitle": "Redeem a top-up code",
"redeemPlaceholder": "helexa-topup-…",
"redeem": "Redeem",
"redeemed": "Code redeemed.",
"logout": "Sign out"
},
"keys": {
"title": "API keys",
"create": "Create key",
"label": "Label",
"limitKind": "Limit",
"percent": "% of allocation",
"hardcap": "Hard cap (tokens)",
"value": "Value",
"none": "No keys yet.",
"createdTitle": "Your new API key",
"createdWarn": "Copy it now — you won't see it again.",
"copy": "Copy",
"copied": "Copied",
"archive": "Archive",
"save": "Save",
"status": "Status",
"usage": "Used",
"useForChat": "Use for chat on this device",
"usedForChat": "Enabled for chat ✓"
},
"error": {
"generic": "Something went wrong.",
"unauthorized": "Please sign in again."
}
}

View File

@@ -5,5 +5,17 @@
"transcriptPlaceholder": "Keskusteluloki näkyy tässä. Näytä mallin ja käyttäjän viestit vieritettävässä säiliössä, halutessasi vuoroittain ryhmiteltynä.",
"inputPlaceholder": "Kirjoita viesti aloittaaksesi keskustelun…",
"send": "Lähetä",
"clear": "Tyhjennä"
"clear": "Tyhjennä",
"newChat": "New chat",
"newProject": "New project",
"newProjectName": "New project",
"unsorted": "Unsorted",
"emptyState": "Start a conversation. Your history stays in this browser.",
"anonBanner": "You have reached the anonymous limit. Sign up for a free allocation.",
"signUp": "Sign up",
"stop": "Stop",
"topUp": "Top up",
"rateLimited": "Rate limited — wait a moment and retry.",
"needsKey": "Create an API key and enable it for chat to send as yourself.",
"manageKeysLink": "Manage keys"
}

View File

@@ -0,0 +1,71 @@
{
"login": {
"title": "Sign in",
"email": "Email",
"password": "Password",
"submit": "Sign in",
"noAccount": "No account? Sign up"
},
"register": {
"title": "Create your account",
"email": "Email",
"password": "Password",
"submit": "Sign up",
"haveAccount": "Already have an account? Sign in",
"checkEmail": "Almost there — check your email to verify your account."
},
"verify": {
"verifying": "Verifying…",
"ok": "Email verified. You can now sign in.",
"failed": "This verification link is invalid or has expired.",
"toLogin": "Go to sign in"
},
"reset": {
"requestTitle": "Reset your password",
"email": "Email",
"requestSubmit": "Send reset link",
"requestDone": "If that email has an account, a reset link is on its way.",
"confirmTitle": "Choose a new password",
"newPassword": "New password",
"confirmSubmit": "Set password",
"ok": "Password updated. You can now sign in."
},
"dashboard": {
"title": "Account",
"balance": "Allocation",
"total": "Total",
"spent": "Spent",
"reserved": "Reserved",
"remaining": "Remaining",
"manageKeys": "Manage API keys",
"redeemTitle": "Redeem a top-up code",
"redeemPlaceholder": "helexa-topup-…",
"redeem": "Redeem",
"redeemed": "Code redeemed.",
"logout": "Sign out"
},
"keys": {
"title": "API keys",
"create": "Create key",
"label": "Label",
"limitKind": "Limit",
"percent": "% of allocation",
"hardcap": "Hard cap (tokens)",
"value": "Value",
"none": "No keys yet.",
"createdTitle": "Your new API key",
"createdWarn": "Copy it now — you won't see it again.",
"copy": "Copy",
"copied": "Copied",
"archive": "Archive",
"save": "Save",
"status": "Status",
"usage": "Used",
"useForChat": "Use for chat on this device",
"usedForChat": "Enabled for chat ✓"
},
"error": {
"generic": "Something went wrong.",
"unauthorized": "Please sign in again."
}
}

View File

@@ -5,5 +5,17 @@
"transcriptPlaceholder": "La transcription du chat apparaîtra ici. Affichez les messages du modèle et de lutilisateur dans un conteneur défilant, éventuellement groupés par tour.",
"inputPlaceholder": "Tapez un message pour commencer à discuter…",
"send": "Envoyer",
"clear": "Effacer"
"clear": "Effacer",
"newChat": "New chat",
"newProject": "New project",
"newProjectName": "New project",
"unsorted": "Unsorted",
"emptyState": "Start a conversation. Your history stays in this browser.",
"anonBanner": "You have reached the anonymous limit. Sign up for a free allocation.",
"signUp": "Sign up",
"stop": "Stop",
"topUp": "Top up",
"rateLimited": "Rate limited — wait a moment and retry.",
"needsKey": "Create an API key and enable it for chat to send as yourself.",
"manageKeysLink": "Manage keys"
}

View File

@@ -0,0 +1,71 @@
{
"login": {
"title": "Sign in",
"email": "Email",
"password": "Password",
"submit": "Sign in",
"noAccount": "No account? Sign up"
},
"register": {
"title": "Create your account",
"email": "Email",
"password": "Password",
"submit": "Sign up",
"haveAccount": "Already have an account? Sign in",
"checkEmail": "Almost there — check your email to verify your account."
},
"verify": {
"verifying": "Verifying…",
"ok": "Email verified. You can now sign in.",
"failed": "This verification link is invalid or has expired.",
"toLogin": "Go to sign in"
},
"reset": {
"requestTitle": "Reset your password",
"email": "Email",
"requestSubmit": "Send reset link",
"requestDone": "If that email has an account, a reset link is on its way.",
"confirmTitle": "Choose a new password",
"newPassword": "New password",
"confirmSubmit": "Set password",
"ok": "Password updated. You can now sign in."
},
"dashboard": {
"title": "Account",
"balance": "Allocation",
"total": "Total",
"spent": "Spent",
"reserved": "Reserved",
"remaining": "Remaining",
"manageKeys": "Manage API keys",
"redeemTitle": "Redeem a top-up code",
"redeemPlaceholder": "helexa-topup-…",
"redeem": "Redeem",
"redeemed": "Code redeemed.",
"logout": "Sign out"
},
"keys": {
"title": "API keys",
"create": "Create key",
"label": "Label",
"limitKind": "Limit",
"percent": "% of allocation",
"hardcap": "Hard cap (tokens)",
"value": "Value",
"none": "No keys yet.",
"createdTitle": "Your new API key",
"createdWarn": "Copy it now — you won't see it again.",
"copy": "Copy",
"copied": "Copied",
"archive": "Archive",
"save": "Save",
"status": "Status",
"usage": "Used",
"useForChat": "Use for chat on this device",
"usedForChat": "Enabled for chat ✓"
},
"error": {
"generic": "Something went wrong.",
"unauthorized": "Please sign in again."
}
}

View File

@@ -5,5 +5,17 @@
"transcriptPlaceholder": "Rubutaccen tarihin tattaunawa zai bayyana a nan. Nuna saƙonnin samfurin AI da na mai amfani a cikin akwatin da ake iya gungurawa, kana iya rarrabe su bisa jujjuyawar magana.",
"inputPlaceholder": "Rubuta saƙo don fara tattaunawa…",
"send": "Aika",
"clear": "Share"
"clear": "Share",
"newChat": "New chat",
"newProject": "New project",
"newProjectName": "New project",
"unsorted": "Unsorted",
"emptyState": "Start a conversation. Your history stays in this browser.",
"anonBanner": "You have reached the anonymous limit. Sign up for a free allocation.",
"signUp": "Sign up",
"stop": "Stop",
"topUp": "Top up",
"rateLimited": "Rate limited — wait a moment and retry.",
"needsKey": "Create an API key and enable it for chat to send as yourself.",
"manageKeysLink": "Manage keys"
}

View File

@@ -0,0 +1,71 @@
{
"login": {
"title": "Sign in",
"email": "Email",
"password": "Password",
"submit": "Sign in",
"noAccount": "No account? Sign up"
},
"register": {
"title": "Create your account",
"email": "Email",
"password": "Password",
"submit": "Sign up",
"haveAccount": "Already have an account? Sign in",
"checkEmail": "Almost there — check your email to verify your account."
},
"verify": {
"verifying": "Verifying…",
"ok": "Email verified. You can now sign in.",
"failed": "This verification link is invalid or has expired.",
"toLogin": "Go to sign in"
},
"reset": {
"requestTitle": "Reset your password",
"email": "Email",
"requestSubmit": "Send reset link",
"requestDone": "If that email has an account, a reset link is on its way.",
"confirmTitle": "Choose a new password",
"newPassword": "New password",
"confirmSubmit": "Set password",
"ok": "Password updated. You can now sign in."
},
"dashboard": {
"title": "Account",
"balance": "Allocation",
"total": "Total",
"spent": "Spent",
"reserved": "Reserved",
"remaining": "Remaining",
"manageKeys": "Manage API keys",
"redeemTitle": "Redeem a top-up code",
"redeemPlaceholder": "helexa-topup-…",
"redeem": "Redeem",
"redeemed": "Code redeemed.",
"logout": "Sign out"
},
"keys": {
"title": "API keys",
"create": "Create key",
"label": "Label",
"limitKind": "Limit",
"percent": "% of allocation",
"hardcap": "Hard cap (tokens)",
"value": "Value",
"none": "No keys yet.",
"createdTitle": "Your new API key",
"createdWarn": "Copy it now — you won't see it again.",
"copy": "Copy",
"copied": "Copied",
"archive": "Archive",
"save": "Save",
"status": "Status",
"usage": "Used",
"useForChat": "Use for chat on this device",
"usedForChat": "Enabled for chat ✓"
},
"error": {
"generic": "Something went wrong.",
"unauthorized": "Please sign in again."
}
}

View File

@@ -5,5 +5,17 @@
"transcriptPlaceholder": "תמליל הצ'אט יופיע כאן. הציגו את ההודעות של המודל ושל המשתמש/ת במכל גלילה, אפשר גם לקבץ לפי סבב שיחה.",
"inputPlaceholder": "כתבו הודעה כדי להתחיל שיחה…",
"send": "שליחה",
"clear": "ניקוי"
"clear": "ניקוי",
"newChat": "New chat",
"newProject": "New project",
"newProjectName": "New project",
"unsorted": "Unsorted",
"emptyState": "Start a conversation. Your history stays in this browser.",
"anonBanner": "You have reached the anonymous limit. Sign up for a free allocation.",
"signUp": "Sign up",
"stop": "Stop",
"topUp": "Top up",
"rateLimited": "Rate limited — wait a moment and retry.",
"needsKey": "Create an API key and enable it for chat to send as yourself.",
"manageKeysLink": "Manage keys"
}

View File

@@ -0,0 +1,71 @@
{
"login": {
"title": "Sign in",
"email": "Email",
"password": "Password",
"submit": "Sign in",
"noAccount": "No account? Sign up"
},
"register": {
"title": "Create your account",
"email": "Email",
"password": "Password",
"submit": "Sign up",
"haveAccount": "Already have an account? Sign in",
"checkEmail": "Almost there — check your email to verify your account."
},
"verify": {
"verifying": "Verifying…",
"ok": "Email verified. You can now sign in.",
"failed": "This verification link is invalid or has expired.",
"toLogin": "Go to sign in"
},
"reset": {
"requestTitle": "Reset your password",
"email": "Email",
"requestSubmit": "Send reset link",
"requestDone": "If that email has an account, a reset link is on its way.",
"confirmTitle": "Choose a new password",
"newPassword": "New password",
"confirmSubmit": "Set password",
"ok": "Password updated. You can now sign in."
},
"dashboard": {
"title": "Account",
"balance": "Allocation",
"total": "Total",
"spent": "Spent",
"reserved": "Reserved",
"remaining": "Remaining",
"manageKeys": "Manage API keys",
"redeemTitle": "Redeem a top-up code",
"redeemPlaceholder": "helexa-topup-…",
"redeem": "Redeem",
"redeemed": "Code redeemed.",
"logout": "Sign out"
},
"keys": {
"title": "API keys",
"create": "Create key",
"label": "Label",
"limitKind": "Limit",
"percent": "% of allocation",
"hardcap": "Hard cap (tokens)",
"value": "Value",
"none": "No keys yet.",
"createdTitle": "Your new API key",
"createdWarn": "Copy it now — you won't see it again.",
"copy": "Copy",
"copied": "Copied",
"archive": "Archive",
"save": "Save",
"status": "Status",
"usage": "Used",
"useForChat": "Use for chat on this device",
"usedForChat": "Enabled for chat ✓"
},
"error": {
"generic": "Something went wrong.",
"unauthorized": "Please sign in again."
}
}

View File

@@ -5,5 +5,17 @@
"transcriptPlaceholder": "La trascrizione della chat apparirà qui. Visualizza i messaggi del modello e dellutente in un contenitore scorrevole, eventualmente raggruppati per turno.",
"inputPlaceholder": "Scrivi un messaggio per iniziare a chattare…",
"send": "Invia",
"clear": "Pulisci"
"clear": "Pulisci",
"newChat": "New chat",
"newProject": "New project",
"newProjectName": "New project",
"unsorted": "Unsorted",
"emptyState": "Start a conversation. Your history stays in this browser.",
"anonBanner": "You have reached the anonymous limit. Sign up for a free allocation.",
"signUp": "Sign up",
"stop": "Stop",
"topUp": "Top up",
"rateLimited": "Rate limited — wait a moment and retry.",
"needsKey": "Create an API key and enable it for chat to send as yourself.",
"manageKeysLink": "Manage keys"
}

View File

@@ -0,0 +1,71 @@
{
"login": {
"title": "Sign in",
"email": "Email",
"password": "Password",
"submit": "Sign in",
"noAccount": "No account? Sign up"
},
"register": {
"title": "Create your account",
"email": "Email",
"password": "Password",
"submit": "Sign up",
"haveAccount": "Already have an account? Sign in",
"checkEmail": "Almost there — check your email to verify your account."
},
"verify": {
"verifying": "Verifying…",
"ok": "Email verified. You can now sign in.",
"failed": "This verification link is invalid or has expired.",
"toLogin": "Go to sign in"
},
"reset": {
"requestTitle": "Reset your password",
"email": "Email",
"requestSubmit": "Send reset link",
"requestDone": "If that email has an account, a reset link is on its way.",
"confirmTitle": "Choose a new password",
"newPassword": "New password",
"confirmSubmit": "Set password",
"ok": "Password updated. You can now sign in."
},
"dashboard": {
"title": "Account",
"balance": "Allocation",
"total": "Total",
"spent": "Spent",
"reserved": "Reserved",
"remaining": "Remaining",
"manageKeys": "Manage API keys",
"redeemTitle": "Redeem a top-up code",
"redeemPlaceholder": "helexa-topup-…",
"redeem": "Redeem",
"redeemed": "Code redeemed.",
"logout": "Sign out"
},
"keys": {
"title": "API keys",
"create": "Create key",
"label": "Label",
"limitKind": "Limit",
"percent": "% of allocation",
"hardcap": "Hard cap (tokens)",
"value": "Value",
"none": "No keys yet.",
"createdTitle": "Your new API key",
"createdWarn": "Copy it now — you won't see it again.",
"copy": "Copy",
"copied": "Copied",
"archive": "Archive",
"save": "Save",
"status": "Status",
"usage": "Used",
"useForChat": "Use for chat on this device",
"usedForChat": "Enabled for chat ✓"
},
"error": {
"generic": "Something went wrong.",
"unauthorized": "Please sign in again."
}
}

View File

@@ -5,5 +5,17 @@
"transcriptPlaceholder": "ჩატის ისტორია გამოჩნდება აქ. ნაჩვენებია მოდელისა და მომხმარებლის შეტყობინებები გადახვევად კონტეინერში, სურვილის შემთხვევაში ტურნების მიხედვით დაჯგუფებით.",
"inputPlaceholder": "დაიწყე საუბარი — მიწერე შეტყობინება…",
"send": "გაგზავნა",
"clear": "გასუფთავება"
"clear": "გასუფთავება",
"newChat": "New chat",
"newProject": "New project",
"newProjectName": "New project",
"unsorted": "Unsorted",
"emptyState": "Start a conversation. Your history stays in this browser.",
"anonBanner": "You have reached the anonymous limit. Sign up for a free allocation.",
"signUp": "Sign up",
"stop": "Stop",
"topUp": "Top up",
"rateLimited": "Rate limited — wait a moment and retry.",
"needsKey": "Create an API key and enable it for chat to send as yourself.",
"manageKeysLink": "Manage keys"
}

View File

@@ -0,0 +1,71 @@
{
"login": {
"title": "Sign in",
"email": "Email",
"password": "Password",
"submit": "Sign in",
"noAccount": "No account? Sign up"
},
"register": {
"title": "Create your account",
"email": "Email",
"password": "Password",
"submit": "Sign up",
"haveAccount": "Already have an account? Sign in",
"checkEmail": "Almost there — check your email to verify your account."
},
"verify": {
"verifying": "Verifying…",
"ok": "Email verified. You can now sign in.",
"failed": "This verification link is invalid or has expired.",
"toLogin": "Go to sign in"
},
"reset": {
"requestTitle": "Reset your password",
"email": "Email",
"requestSubmit": "Send reset link",
"requestDone": "If that email has an account, a reset link is on its way.",
"confirmTitle": "Choose a new password",
"newPassword": "New password",
"confirmSubmit": "Set password",
"ok": "Password updated. You can now sign in."
},
"dashboard": {
"title": "Account",
"balance": "Allocation",
"total": "Total",
"spent": "Spent",
"reserved": "Reserved",
"remaining": "Remaining",
"manageKeys": "Manage API keys",
"redeemTitle": "Redeem a top-up code",
"redeemPlaceholder": "helexa-topup-…",
"redeem": "Redeem",
"redeemed": "Code redeemed.",
"logout": "Sign out"
},
"keys": {
"title": "API keys",
"create": "Create key",
"label": "Label",
"limitKind": "Limit",
"percent": "% of allocation",
"hardcap": "Hard cap (tokens)",
"value": "Value",
"none": "No keys yet.",
"createdTitle": "Your new API key",
"createdWarn": "Copy it now — you won't see it again.",
"copy": "Copy",
"copied": "Copied",
"archive": "Archive",
"save": "Save",
"status": "Status",
"usage": "Used",
"useForChat": "Use for chat on this device",
"usedForChat": "Enabled for chat ✓"
},
"error": {
"generic": "Something went wrong.",
"unauthorized": "Please sign in again."
}
}

View File

@@ -5,5 +5,17 @@
"transcriptPlaceholder": "Мұнда чат журналын көрсетіңіз. Модель мен пайдаланушы хабарламаларын жылжымалы контейнерде, қажет болса кезек бойынша топтастырып шығарыңыз.",
"inputPlaceholder": "Чатты бастау үшін хабарлама жазыңыз…",
"send": "Жіберу",
"clear": "Тазалау"
"clear": "Тазалау",
"newChat": "New chat",
"newProject": "New project",
"newProjectName": "New project",
"unsorted": "Unsorted",
"emptyState": "Start a conversation. Your history stays in this browser.",
"anonBanner": "You have reached the anonymous limit. Sign up for a free allocation.",
"signUp": "Sign up",
"stop": "Stop",
"topUp": "Top up",
"rateLimited": "Rate limited — wait a moment and retry.",
"needsKey": "Create an API key and enable it for chat to send as yourself.",
"manageKeysLink": "Manage keys"
}

View File

@@ -0,0 +1,71 @@
{
"login": {
"title": "Sign in",
"email": "Email",
"password": "Password",
"submit": "Sign in",
"noAccount": "No account? Sign up"
},
"register": {
"title": "Create your account",
"email": "Email",
"password": "Password",
"submit": "Sign up",
"haveAccount": "Already have an account? Sign in",
"checkEmail": "Almost there — check your email to verify your account."
},
"verify": {
"verifying": "Verifying…",
"ok": "Email verified. You can now sign in.",
"failed": "This verification link is invalid or has expired.",
"toLogin": "Go to sign in"
},
"reset": {
"requestTitle": "Reset your password",
"email": "Email",
"requestSubmit": "Send reset link",
"requestDone": "If that email has an account, a reset link is on its way.",
"confirmTitle": "Choose a new password",
"newPassword": "New password",
"confirmSubmit": "Set password",
"ok": "Password updated. You can now sign in."
},
"dashboard": {
"title": "Account",
"balance": "Allocation",
"total": "Total",
"spent": "Spent",
"reserved": "Reserved",
"remaining": "Remaining",
"manageKeys": "Manage API keys",
"redeemTitle": "Redeem a top-up code",
"redeemPlaceholder": "helexa-topup-…",
"redeem": "Redeem",
"redeemed": "Code redeemed.",
"logout": "Sign out"
},
"keys": {
"title": "API keys",
"create": "Create key",
"label": "Label",
"limitKind": "Limit",
"percent": "% of allocation",
"hardcap": "Hard cap (tokens)",
"value": "Value",
"none": "No keys yet.",
"createdTitle": "Your new API key",
"createdWarn": "Copy it now — you won't see it again.",
"copy": "Copy",
"copied": "Copied",
"archive": "Archive",
"save": "Save",
"status": "Status",
"usage": "Used",
"useForChat": "Use for chat on this device",
"usedForChat": "Enabled for chat ✓"
},
"error": {
"generic": "Something went wrong.",
"unauthorized": "Please sign in again."
}
}

View File

@@ -5,5 +5,17 @@
"transcriptPlaceholder": "سجل المحادثة سيظهر هنا. اعرض رسائل النموذج والمستخدم في حاوية قابلة للتمرير، ويمكنك تجميعها حسب أدوار الحوار إذا رغبت.",
"inputPlaceholder": "اكتب رسالة لبدء المحادثة…",
"send": "إرسال",
"clear": "مسح"
"clear": "مسح",
"newChat": "New chat",
"newProject": "New project",
"newProjectName": "New project",
"unsorted": "Unsorted",
"emptyState": "Start a conversation. Your history stays in this browser.",
"anonBanner": "You have reached the anonymous limit. Sign up for a free allocation.",
"signUp": "Sign up",
"stop": "Stop",
"topUp": "Top up",
"rateLimited": "Rate limited — wait a moment and retry.",
"needsKey": "Create an API key and enable it for chat to send as yourself.",
"manageKeysLink": "Manage keys"
}

View File

@@ -0,0 +1,71 @@
{
"login": {
"title": "Sign in",
"email": "Email",
"password": "Password",
"submit": "Sign in",
"noAccount": "No account? Sign up"
},
"register": {
"title": "Create your account",
"email": "Email",
"password": "Password",
"submit": "Sign up",
"haveAccount": "Already have an account? Sign in",
"checkEmail": "Almost there — check your email to verify your account."
},
"verify": {
"verifying": "Verifying…",
"ok": "Email verified. You can now sign in.",
"failed": "This verification link is invalid or has expired.",
"toLogin": "Go to sign in"
},
"reset": {
"requestTitle": "Reset your password",
"email": "Email",
"requestSubmit": "Send reset link",
"requestDone": "If that email has an account, a reset link is on its way.",
"confirmTitle": "Choose a new password",
"newPassword": "New password",
"confirmSubmit": "Set password",
"ok": "Password updated. You can now sign in."
},
"dashboard": {
"title": "Account",
"balance": "Allocation",
"total": "Total",
"spent": "Spent",
"reserved": "Reserved",
"remaining": "Remaining",
"manageKeys": "Manage API keys",
"redeemTitle": "Redeem a top-up code",
"redeemPlaceholder": "helexa-topup-…",
"redeem": "Redeem",
"redeemed": "Code redeemed.",
"logout": "Sign out"
},
"keys": {
"title": "API keys",
"create": "Create key",
"label": "Label",
"limitKind": "Limit",
"percent": "% of allocation",
"hardcap": "Hard cap (tokens)",
"value": "Value",
"none": "No keys yet.",
"createdTitle": "Your new API key",
"createdWarn": "Copy it now — you won't see it again.",
"copy": "Copy",
"copied": "Copied",
"archive": "Archive",
"save": "Save",
"status": "Status",
"usage": "Used",
"useForChat": "Use for chat on this device",
"usedForChat": "Enabled for chat ✓"
},
"error": {
"generic": "Something went wrong.",
"unauthorized": "Please sign in again."
}
}

View File

@@ -5,5 +5,17 @@
"transcriptPlaceholder": "De chattranscriptie verschijnt hier. Toon berichten van model en gebruiker in een scrollbare container, eventueel gegroepeerd per beurt.",
"inputPlaceholder": "Schrijf een bericht om een gesprek te starten…",
"send": "Verzenden",
"clear": "Leegmaken"
"clear": "Leegmaken",
"newChat": "New chat",
"newProject": "New project",
"newProjectName": "New project",
"unsorted": "Unsorted",
"emptyState": "Start a conversation. Your history stays in this browser.",
"anonBanner": "You have reached the anonymous limit. Sign up for a free allocation.",
"signUp": "Sign up",
"stop": "Stop",
"topUp": "Top up",
"rateLimited": "Rate limited — wait a moment and retry.",
"needsKey": "Create an API key and enable it for chat to send as yourself.",
"manageKeysLink": "Manage keys"
}

View File

@@ -0,0 +1,71 @@
{
"login": {
"title": "Sign in",
"email": "Email",
"password": "Password",
"submit": "Sign in",
"noAccount": "No account? Sign up"
},
"register": {
"title": "Create your account",
"email": "Email",
"password": "Password",
"submit": "Sign up",
"haveAccount": "Already have an account? Sign in",
"checkEmail": "Almost there — check your email to verify your account."
},
"verify": {
"verifying": "Verifying…",
"ok": "Email verified. You can now sign in.",
"failed": "This verification link is invalid or has expired.",
"toLogin": "Go to sign in"
},
"reset": {
"requestTitle": "Reset your password",
"email": "Email",
"requestSubmit": "Send reset link",
"requestDone": "If that email has an account, a reset link is on its way.",
"confirmTitle": "Choose a new password",
"newPassword": "New password",
"confirmSubmit": "Set password",
"ok": "Password updated. You can now sign in."
},
"dashboard": {
"title": "Account",
"balance": "Allocation",
"total": "Total",
"spent": "Spent",
"reserved": "Reserved",
"remaining": "Remaining",
"manageKeys": "Manage API keys",
"redeemTitle": "Redeem a top-up code",
"redeemPlaceholder": "helexa-topup-…",
"redeem": "Redeem",
"redeemed": "Code redeemed.",
"logout": "Sign out"
},
"keys": {
"title": "API keys",
"create": "Create key",
"label": "Label",
"limitKind": "Limit",
"percent": "% of allocation",
"hardcap": "Hard cap (tokens)",
"value": "Value",
"none": "No keys yet.",
"createdTitle": "Your new API key",
"createdWarn": "Copy it now — you won't see it again.",
"copy": "Copy",
"copied": "Copied",
"archive": "Archive",
"save": "Save",
"status": "Status",
"usage": "Used",
"useForChat": "Use for chat on this device",
"usedForChat": "Enabled for chat ✓"
},
"error": {
"generic": "Something went wrong.",
"unauthorized": "Please sign in again."
}
}

View File

@@ -5,5 +5,17 @@
"transcriptPlaceholder": "Chatloggen vises her. Vis meldinger fra modell og bruker i en rullende beholder, eventuelt gruppert per tur.",
"inputPlaceholder": "Skriv en melding for å starte en samtale…",
"send": "Send",
"clear": "Tøm"
"clear": "Tøm",
"newChat": "New chat",
"newProject": "New project",
"newProjectName": "New project",
"unsorted": "Unsorted",
"emptyState": "Start a conversation. Your history stays in this browser.",
"anonBanner": "You have reached the anonymous limit. Sign up for a free allocation.",
"signUp": "Sign up",
"stop": "Stop",
"topUp": "Top up",
"rateLimited": "Rate limited — wait a moment and retry.",
"needsKey": "Create an API key and enable it for chat to send as yourself.",
"manageKeysLink": "Manage keys"
}

View File

@@ -0,0 +1,71 @@
{
"login": {
"title": "Sign in",
"email": "Email",
"password": "Password",
"submit": "Sign in",
"noAccount": "No account? Sign up"
},
"register": {
"title": "Create your account",
"email": "Email",
"password": "Password",
"submit": "Sign up",
"haveAccount": "Already have an account? Sign in",
"checkEmail": "Almost there — check your email to verify your account."
},
"verify": {
"verifying": "Verifying…",
"ok": "Email verified. You can now sign in.",
"failed": "This verification link is invalid or has expired.",
"toLogin": "Go to sign in"
},
"reset": {
"requestTitle": "Reset your password",
"email": "Email",
"requestSubmit": "Send reset link",
"requestDone": "If that email has an account, a reset link is on its way.",
"confirmTitle": "Choose a new password",
"newPassword": "New password",
"confirmSubmit": "Set password",
"ok": "Password updated. You can now sign in."
},
"dashboard": {
"title": "Account",
"balance": "Allocation",
"total": "Total",
"spent": "Spent",
"reserved": "Reserved",
"remaining": "Remaining",
"manageKeys": "Manage API keys",
"redeemTitle": "Redeem a top-up code",
"redeemPlaceholder": "helexa-topup-…",
"redeem": "Redeem",
"redeemed": "Code redeemed.",
"logout": "Sign out"
},
"keys": {
"title": "API keys",
"create": "Create key",
"label": "Label",
"limitKind": "Limit",
"percent": "% of allocation",
"hardcap": "Hard cap (tokens)",
"value": "Value",
"none": "No keys yet.",
"createdTitle": "Your new API key",
"createdWarn": "Copy it now — you won't see it again.",
"copy": "Copy",
"copied": "Copied",
"archive": "Archive",
"save": "Save",
"status": "Status",
"usage": "Used",
"useForChat": "Use for chat on this device",
"usedForChat": "Enabled for chat ✓"
},
"error": {
"generic": "Something went wrong.",
"unauthorized": "Please sign in again."
}
}

View File

@@ -5,5 +5,17 @@
"transcriptPlaceholder": "Transkrypcja czatu pojawi się tutaj. Renderuj wiadomości modelu i użytkownika w przewijanym kontenerze, opcjonalnie pogrupowane według tur.",
"inputPlaceholder": "Napisz wiadomość, aby rozpocząć rozmowę…",
"send": "Wyślij",
"clear": "Wyczyść"
"clear": "Wyczyść",
"newChat": "New chat",
"newProject": "New project",
"newProjectName": "New project",
"unsorted": "Unsorted",
"emptyState": "Start a conversation. Your history stays in this browser.",
"anonBanner": "You have reached the anonymous limit. Sign up for a free allocation.",
"signUp": "Sign up",
"stop": "Stop",
"topUp": "Top up",
"rateLimited": "Rate limited — wait a moment and retry.",
"needsKey": "Create an API key and enable it for chat to send as yourself.",
"manageKeysLink": "Manage keys"
}

View File

@@ -0,0 +1,71 @@
{
"login": {
"title": "Sign in",
"email": "Email",
"password": "Password",
"submit": "Sign in",
"noAccount": "No account? Sign up"
},
"register": {
"title": "Create your account",
"email": "Email",
"password": "Password",
"submit": "Sign up",
"haveAccount": "Already have an account? Sign in",
"checkEmail": "Almost there — check your email to verify your account."
},
"verify": {
"verifying": "Verifying…",
"ok": "Email verified. You can now sign in.",
"failed": "This verification link is invalid or has expired.",
"toLogin": "Go to sign in"
},
"reset": {
"requestTitle": "Reset your password",
"email": "Email",
"requestSubmit": "Send reset link",
"requestDone": "If that email has an account, a reset link is on its way.",
"confirmTitle": "Choose a new password",
"newPassword": "New password",
"confirmSubmit": "Set password",
"ok": "Password updated. You can now sign in."
},
"dashboard": {
"title": "Account",
"balance": "Allocation",
"total": "Total",
"spent": "Spent",
"reserved": "Reserved",
"remaining": "Remaining",
"manageKeys": "Manage API keys",
"redeemTitle": "Redeem a top-up code",
"redeemPlaceholder": "helexa-topup-…",
"redeem": "Redeem",
"redeemed": "Code redeemed.",
"logout": "Sign out"
},
"keys": {
"title": "API keys",
"create": "Create key",
"label": "Label",
"limitKind": "Limit",
"percent": "% of allocation",
"hardcap": "Hard cap (tokens)",
"value": "Value",
"none": "No keys yet.",
"createdTitle": "Your new API key",
"createdWarn": "Copy it now — you won't see it again.",
"copy": "Copy",
"copied": "Copied",
"archive": "Archive",
"save": "Save",
"status": "Status",
"usage": "Used",
"useForChat": "Use for chat on this device",
"usedForChat": "Enabled for chat ✓"
},
"error": {
"generic": "Something went wrong.",
"unauthorized": "Please sign in again."
}
}

View File

@@ -5,5 +5,17 @@
"transcriptPlaceholder": "A transcrição do chat aparecerá aqui. Renderiza as mensagens do modelo e da pessoa utilizadora num contentor rolável, opcionalmente agrupadas por turno.",
"inputPlaceholder": "Escreve uma mensagem para começar a conversar…",
"send": "Enviar",
"clear": "Limpar"
"clear": "Limpar",
"newChat": "New chat",
"newProject": "New project",
"newProjectName": "New project",
"unsorted": "Unsorted",
"emptyState": "Start a conversation. Your history stays in this browser.",
"anonBanner": "You have reached the anonymous limit. Sign up for a free allocation.",
"signUp": "Sign up",
"stop": "Stop",
"topUp": "Top up",
"rateLimited": "Rate limited — wait a moment and retry.",
"needsKey": "Create an API key and enable it for chat to send as yourself.",
"manageKeysLink": "Manage keys"
}

View File

@@ -0,0 +1,71 @@
{
"login": {
"title": "Sign in",
"email": "Email",
"password": "Password",
"submit": "Sign in",
"noAccount": "No account? Sign up"
},
"register": {
"title": "Create your account",
"email": "Email",
"password": "Password",
"submit": "Sign up",
"haveAccount": "Already have an account? Sign in",
"checkEmail": "Almost there — check your email to verify your account."
},
"verify": {
"verifying": "Verifying…",
"ok": "Email verified. You can now sign in.",
"failed": "This verification link is invalid or has expired.",
"toLogin": "Go to sign in"
},
"reset": {
"requestTitle": "Reset your password",
"email": "Email",
"requestSubmit": "Send reset link",
"requestDone": "If that email has an account, a reset link is on its way.",
"confirmTitle": "Choose a new password",
"newPassword": "New password",
"confirmSubmit": "Set password",
"ok": "Password updated. You can now sign in."
},
"dashboard": {
"title": "Account",
"balance": "Allocation",
"total": "Total",
"spent": "Spent",
"reserved": "Reserved",
"remaining": "Remaining",
"manageKeys": "Manage API keys",
"redeemTitle": "Redeem a top-up code",
"redeemPlaceholder": "helexa-topup-…",
"redeem": "Redeem",
"redeemed": "Code redeemed.",
"logout": "Sign out"
},
"keys": {
"title": "API keys",
"create": "Create key",
"label": "Label",
"limitKind": "Limit",
"percent": "% of allocation",
"hardcap": "Hard cap (tokens)",
"value": "Value",
"none": "No keys yet.",
"createdTitle": "Your new API key",
"createdWarn": "Copy it now — you won't see it again.",
"copy": "Copy",
"copied": "Copied",
"archive": "Archive",
"save": "Save",
"status": "Status",
"usage": "Used",
"useForChat": "Use for chat on this device",
"usedForChat": "Enabled for chat ✓"
},
"error": {
"generic": "Something went wrong.",
"unauthorized": "Please sign in again."
}
}

View File

@@ -5,5 +5,17 @@
"transcriptPlaceholder": "Transcrierea conversației va apărea aici. Afișează mesajele modelului și ale utilizatorului într-un container derulant, opțional grupate pe rânduri de dialog.",
"inputPlaceholder": "Scrie un mesaj pentru a începe conversația…",
"send": "Trimite",
"clear": "Curăță"
"clear": "Curăță",
"newChat": "New chat",
"newProject": "New project",
"newProjectName": "New project",
"unsorted": "Unsorted",
"emptyState": "Start a conversation. Your history stays in this browser.",
"anonBanner": "You have reached the anonymous limit. Sign up for a free allocation.",
"signUp": "Sign up",
"stop": "Stop",
"topUp": "Top up",
"rateLimited": "Rate limited — wait a moment and retry.",
"needsKey": "Create an API key and enable it for chat to send as yourself.",
"manageKeysLink": "Manage keys"
}

View File

@@ -0,0 +1,71 @@
{
"login": {
"title": "Sign in",
"email": "Email",
"password": "Password",
"submit": "Sign in",
"noAccount": "No account? Sign up"
},
"register": {
"title": "Create your account",
"email": "Email",
"password": "Password",
"submit": "Sign up",
"haveAccount": "Already have an account? Sign in",
"checkEmail": "Almost there — check your email to verify your account."
},
"verify": {
"verifying": "Verifying…",
"ok": "Email verified. You can now sign in.",
"failed": "This verification link is invalid or has expired.",
"toLogin": "Go to sign in"
},
"reset": {
"requestTitle": "Reset your password",
"email": "Email",
"requestSubmit": "Send reset link",
"requestDone": "If that email has an account, a reset link is on its way.",
"confirmTitle": "Choose a new password",
"newPassword": "New password",
"confirmSubmit": "Set password",
"ok": "Password updated. You can now sign in."
},
"dashboard": {
"title": "Account",
"balance": "Allocation",
"total": "Total",
"spent": "Spent",
"reserved": "Reserved",
"remaining": "Remaining",
"manageKeys": "Manage API keys",
"redeemTitle": "Redeem a top-up code",
"redeemPlaceholder": "helexa-topup-…",
"redeem": "Redeem",
"redeemed": "Code redeemed.",
"logout": "Sign out"
},
"keys": {
"title": "API keys",
"create": "Create key",
"label": "Label",
"limitKind": "Limit",
"percent": "% of allocation",
"hardcap": "Hard cap (tokens)",
"value": "Value",
"none": "No keys yet.",
"createdTitle": "Your new API key",
"createdWarn": "Copy it now — you won't see it again.",
"copy": "Copy",
"copied": "Copied",
"archive": "Archive",
"save": "Save",
"status": "Status",
"usage": "Used",
"useForChat": "Use for chat on this device",
"usedForChat": "Enabled for chat ✓"
},
"error": {
"generic": "Something went wrong.",
"unauthorized": "Please sign in again."
}
}

View File

@@ -5,5 +5,17 @@
"transcriptPlaceholder": "",
"inputPlaceholder": "",
"send": "",
"clear": ""
"clear": "",
"newChat": "New chat",
"newProject": "New project",
"newProjectName": "New project",
"unsorted": "Unsorted",
"emptyState": "Start a conversation. Your history stays in this browser.",
"anonBanner": "You have reached the anonymous limit. Sign up for a free allocation.",
"signUp": "Sign up",
"stop": "Stop",
"topUp": "Top up",
"rateLimited": "Rate limited — wait a moment and retry.",
"needsKey": "Create an API key and enable it for chat to send as yourself.",
"manageKeysLink": "Manage keys"
}

View File

@@ -0,0 +1,71 @@
{
"login": {
"title": "Sign in",
"email": "Email",
"password": "Password",
"submit": "Sign in",
"noAccount": "No account? Sign up"
},
"register": {
"title": "Create your account",
"email": "Email",
"password": "Password",
"submit": "Sign up",
"haveAccount": "Already have an account? Sign in",
"checkEmail": "Almost there — check your email to verify your account."
},
"verify": {
"verifying": "Verifying…",
"ok": "Email verified. You can now sign in.",
"failed": "This verification link is invalid or has expired.",
"toLogin": "Go to sign in"
},
"reset": {
"requestTitle": "Reset your password",
"email": "Email",
"requestSubmit": "Send reset link",
"requestDone": "If that email has an account, a reset link is on its way.",
"confirmTitle": "Choose a new password",
"newPassword": "New password",
"confirmSubmit": "Set password",
"ok": "Password updated. You can now sign in."
},
"dashboard": {
"title": "Account",
"balance": "Allocation",
"total": "Total",
"spent": "Spent",
"reserved": "Reserved",
"remaining": "Remaining",
"manageKeys": "Manage API keys",
"redeemTitle": "Redeem a top-up code",
"redeemPlaceholder": "helexa-topup-…",
"redeem": "Redeem",
"redeemed": "Code redeemed.",
"logout": "Sign out"
},
"keys": {
"title": "API keys",
"create": "Create key",
"label": "Label",
"limitKind": "Limit",
"percent": "% of allocation",
"hardcap": "Hard cap (tokens)",
"value": "Value",
"none": "No keys yet.",
"createdTitle": "Your new API key",
"createdWarn": "Copy it now — you won't see it again.",
"copy": "Copy",
"copied": "Copied",
"archive": "Archive",
"save": "Save",
"status": "Status",
"usage": "Used",
"useForChat": "Use for chat on this device",
"usedForChat": "Enabled for chat ✓"
},
"error": {
"generic": "Something went wrong.",
"unauthorized": "Please sign in again."
}
}

View File

@@ -5,5 +5,17 @@
"transcriptPlaceholder": "Transkript ćaskanja će se pojaviti ovde. Prikaži poruke modela i korisnika u kliznom kontejneru, po želji grupisane po toku razgovora.",
"inputPlaceholder": "Unesi poruku da započneš razgovor…",
"send": "Pošalji",
"clear": "Obriši"
"clear": "Obriši",
"newChat": "New chat",
"newProject": "New project",
"newProjectName": "New project",
"unsorted": "Unsorted",
"emptyState": "Start a conversation. Your history stays in this browser.",
"anonBanner": "You have reached the anonymous limit. Sign up for a free allocation.",
"signUp": "Sign up",
"stop": "Stop",
"topUp": "Top up",
"rateLimited": "Rate limited — wait a moment and retry.",
"needsKey": "Create an API key and enable it for chat to send as yourself.",
"manageKeysLink": "Manage keys"
}

View File

@@ -0,0 +1,71 @@
{
"login": {
"title": "Sign in",
"email": "Email",
"password": "Password",
"submit": "Sign in",
"noAccount": "No account? Sign up"
},
"register": {
"title": "Create your account",
"email": "Email",
"password": "Password",
"submit": "Sign up",
"haveAccount": "Already have an account? Sign in",
"checkEmail": "Almost there — check your email to verify your account."
},
"verify": {
"verifying": "Verifying…",
"ok": "Email verified. You can now sign in.",
"failed": "This verification link is invalid or has expired.",
"toLogin": "Go to sign in"
},
"reset": {
"requestTitle": "Reset your password",
"email": "Email",
"requestSubmit": "Send reset link",
"requestDone": "If that email has an account, a reset link is on its way.",
"confirmTitle": "Choose a new password",
"newPassword": "New password",
"confirmSubmit": "Set password",
"ok": "Password updated. You can now sign in."
},
"dashboard": {
"title": "Account",
"balance": "Allocation",
"total": "Total",
"spent": "Spent",
"reserved": "Reserved",
"remaining": "Remaining",
"manageKeys": "Manage API keys",
"redeemTitle": "Redeem a top-up code",
"redeemPlaceholder": "helexa-topup-…",
"redeem": "Redeem",
"redeemed": "Code redeemed.",
"logout": "Sign out"
},
"keys": {
"title": "API keys",
"create": "Create key",
"label": "Label",
"limitKind": "Limit",
"percent": "% of allocation",
"hardcap": "Hard cap (tokens)",
"value": "Value",
"none": "No keys yet.",
"createdTitle": "Your new API key",
"createdWarn": "Copy it now — you won't see it again.",
"copy": "Copy",
"copied": "Copied",
"archive": "Archive",
"save": "Save",
"status": "Status",
"usage": "Used",
"useForChat": "Use for chat on this device",
"usedForChat": "Enabled for chat ✓"
},
"error": {
"generic": "Something went wrong.",
"unauthorized": "Please sign in again."
}
}

View File

@@ -5,5 +5,17 @@
"transcriptPlaceholder": "Chattloggen visas här. Visa meddelanden från modellen och användaren i en rullande behållare, eventuellt grupperade per tur.",
"inputPlaceholder": "Skriv ett meddelande för att börja chatta…",
"send": "Skicka",
"clear": "Rensa"
"clear": "Rensa",
"newChat": "New chat",
"newProject": "New project",
"newProjectName": "New project",
"unsorted": "Unsorted",
"emptyState": "Start a conversation. Your history stays in this browser.",
"anonBanner": "You have reached the anonymous limit. Sign up for a free allocation.",
"signUp": "Sign up",
"stop": "Stop",
"topUp": "Top up",
"rateLimited": "Rate limited — wait a moment and retry.",
"needsKey": "Create an API key and enable it for chat to send as yourself.",
"manageKeysLink": "Manage keys"
}

View File

@@ -0,0 +1,71 @@
{
"login": {
"title": "Sign in",
"email": "Email",
"password": "Password",
"submit": "Sign in",
"noAccount": "No account? Sign up"
},
"register": {
"title": "Create your account",
"email": "Email",
"password": "Password",
"submit": "Sign up",
"haveAccount": "Already have an account? Sign in",
"checkEmail": "Almost there — check your email to verify your account."
},
"verify": {
"verifying": "Verifying…",
"ok": "Email verified. You can now sign in.",
"failed": "This verification link is invalid or has expired.",
"toLogin": "Go to sign in"
},
"reset": {
"requestTitle": "Reset your password",
"email": "Email",
"requestSubmit": "Send reset link",
"requestDone": "If that email has an account, a reset link is on its way.",
"confirmTitle": "Choose a new password",
"newPassword": "New password",
"confirmSubmit": "Set password",
"ok": "Password updated. You can now sign in."
},
"dashboard": {
"title": "Account",
"balance": "Allocation",
"total": "Total",
"spent": "Spent",
"reserved": "Reserved",
"remaining": "Remaining",
"manageKeys": "Manage API keys",
"redeemTitle": "Redeem a top-up code",
"redeemPlaceholder": "helexa-topup-…",
"redeem": "Redeem",
"redeemed": "Code redeemed.",
"logout": "Sign out"
},
"keys": {
"title": "API keys",
"create": "Create key",
"label": "Label",
"limitKind": "Limit",
"percent": "% of allocation",
"hardcap": "Hard cap (tokens)",
"value": "Value",
"none": "No keys yet.",
"createdTitle": "Your new API key",
"createdWarn": "Copy it now — you won't see it again.",
"copy": "Copy",
"copied": "Copied",
"archive": "Archive",
"save": "Save",
"status": "Status",
"usage": "Used",
"useForChat": "Use for chat on this device",
"usedForChat": "Enabled for chat ✓"
},
"error": {
"generic": "Something went wrong.",
"unauthorized": "Please sign in again."
}
}

View File

@@ -5,5 +5,17 @@
"transcriptPlaceholder": "Maandishi ya mazungumzo yataonekana hapa. Onyesha ujumbe wa mtumiaji na ule wa modeli ndani ya sehemu inayosogezwa, ukipanga kwa mizunguko ya mazungumzo ikiwa utapendelea.",
"inputPlaceholder": "Andika ujumbe kuanza kuzungumza…",
"send": "Tuma",
"clear": "Futa"
"clear": "Futa",
"newChat": "New chat",
"newProject": "New project",
"newProjectName": "New project",
"unsorted": "Unsorted",
"emptyState": "Start a conversation. Your history stays in this browser.",
"anonBanner": "You have reached the anonymous limit. Sign up for a free allocation.",
"signUp": "Sign up",
"stop": "Stop",
"topUp": "Top up",
"rateLimited": "Rate limited — wait a moment and retry.",
"needsKey": "Create an API key and enable it for chat to send as yourself.",
"manageKeysLink": "Manage keys"
}

View File

@@ -0,0 +1,71 @@
{
"login": {
"title": "Sign in",
"email": "Email",
"password": "Password",
"submit": "Sign in",
"noAccount": "No account? Sign up"
},
"register": {
"title": "Create your account",
"email": "Email",
"password": "Password",
"submit": "Sign up",
"haveAccount": "Already have an account? Sign in",
"checkEmail": "Almost there — check your email to verify your account."
},
"verify": {
"verifying": "Verifying…",
"ok": "Email verified. You can now sign in.",
"failed": "This verification link is invalid or has expired.",
"toLogin": "Go to sign in"
},
"reset": {
"requestTitle": "Reset your password",
"email": "Email",
"requestSubmit": "Send reset link",
"requestDone": "If that email has an account, a reset link is on its way.",
"confirmTitle": "Choose a new password",
"newPassword": "New password",
"confirmSubmit": "Set password",
"ok": "Password updated. You can now sign in."
},
"dashboard": {
"title": "Account",
"balance": "Allocation",
"total": "Total",
"spent": "Spent",
"reserved": "Reserved",
"remaining": "Remaining",
"manageKeys": "Manage API keys",
"redeemTitle": "Redeem a top-up code",
"redeemPlaceholder": "helexa-topup-…",
"redeem": "Redeem",
"redeemed": "Code redeemed.",
"logout": "Sign out"
},
"keys": {
"title": "API keys",
"create": "Create key",
"label": "Label",
"limitKind": "Limit",
"percent": "% of allocation",
"hardcap": "Hard cap (tokens)",
"value": "Value",
"none": "No keys yet.",
"createdTitle": "Your new API key",
"createdWarn": "Copy it now — you won't see it again.",
"copy": "Copy",
"copied": "Copied",
"archive": "Archive",
"save": "Save",
"status": "Status",
"usage": "Used",
"useForChat": "Use for chat on this device",
"usedForChat": "Enabled for chat ✓"
},
"error": {
"generic": "Something went wrong.",
"unauthorized": "Please sign in again."
}
}

View File

@@ -5,5 +5,17 @@
"transcriptPlaceholder": "Sohbet geçmişi burada görünecek. Modelden ve kullanıcıdan gelen mesajları, isterseniz turlara göre gruplayarak, kaydırılabilir bir alanda gösterin.",
"inputPlaceholder": "Sohbete başlamak için bir mesaj yazın…",
"send": "Gönder",
"clear": "Temizle"
"clear": "Temizle",
"newChat": "New chat",
"newProject": "New project",
"newProjectName": "New project",
"unsorted": "Unsorted",
"emptyState": "Start a conversation. Your history stays in this browser.",
"anonBanner": "You have reached the anonymous limit. Sign up for a free allocation.",
"signUp": "Sign up",
"stop": "Stop",
"topUp": "Top up",
"rateLimited": "Rate limited — wait a moment and retry.",
"needsKey": "Create an API key and enable it for chat to send as yourself.",
"manageKeysLink": "Manage keys"
}

View File

@@ -0,0 +1,71 @@
{
"login": {
"title": "Sign in",
"email": "Email",
"password": "Password",
"submit": "Sign in",
"noAccount": "No account? Sign up"
},
"register": {
"title": "Create your account",
"email": "Email",
"password": "Password",
"submit": "Sign up",
"haveAccount": "Already have an account? Sign in",
"checkEmail": "Almost there — check your email to verify your account."
},
"verify": {
"verifying": "Verifying…",
"ok": "Email verified. You can now sign in.",
"failed": "This verification link is invalid or has expired.",
"toLogin": "Go to sign in"
},
"reset": {
"requestTitle": "Reset your password",
"email": "Email",
"requestSubmit": "Send reset link",
"requestDone": "If that email has an account, a reset link is on its way.",
"confirmTitle": "Choose a new password",
"newPassword": "New password",
"confirmSubmit": "Set password",
"ok": "Password updated. You can now sign in."
},
"dashboard": {
"title": "Account",
"balance": "Allocation",
"total": "Total",
"spent": "Spent",
"reserved": "Reserved",
"remaining": "Remaining",
"manageKeys": "Manage API keys",
"redeemTitle": "Redeem a top-up code",
"redeemPlaceholder": "helexa-topup-…",
"redeem": "Redeem",
"redeemed": "Code redeemed.",
"logout": "Sign out"
},
"keys": {
"title": "API keys",
"create": "Create key",
"label": "Label",
"limitKind": "Limit",
"percent": "% of allocation",
"hardcap": "Hard cap (tokens)",
"value": "Value",
"none": "No keys yet.",
"createdTitle": "Your new API key",
"createdWarn": "Copy it now — you won't see it again.",
"copy": "Copy",
"copied": "Copied",
"archive": "Archive",
"save": "Save",
"status": "Status",
"usage": "Used",
"useForChat": "Use for chat on this device",
"usedForChat": "Enabled for chat ✓"
},
"error": {
"generic": "Something went wrong.",
"unauthorized": "Please sign in again."
}
}

View File

@@ -5,5 +5,17 @@
"transcriptPlaceholder": "Тут з’явиться стенограма чату. Відображайте повідомлення моделі та користувача в контейнері з прокруткою, за бажанням групуючи їх за ходами діалогу.",
"inputPlaceholder": "Напишіть повідомлення, щоб розпочати розмову…",
"send": "Надіслати",
"clear": "Очистити"
"clear": "Очистити",
"newChat": "New chat",
"newProject": "New project",
"newProjectName": "New project",
"unsorted": "Unsorted",
"emptyState": "Start a conversation. Your history stays in this browser.",
"anonBanner": "You have reached the anonymous limit. Sign up for a free allocation.",
"signUp": "Sign up",
"stop": "Stop",
"topUp": "Top up",
"rateLimited": "Rate limited — wait a moment and retry.",
"needsKey": "Create an API key and enable it for chat to send as yourself.",
"manageKeysLink": "Manage keys"
}

View File

@@ -0,0 +1,71 @@
{
"login": {
"title": "Sign in",
"email": "Email",
"password": "Password",
"submit": "Sign in",
"noAccount": "No account? Sign up"
},
"register": {
"title": "Create your account",
"email": "Email",
"password": "Password",
"submit": "Sign up",
"haveAccount": "Already have an account? Sign in",
"checkEmail": "Almost there — check your email to verify your account."
},
"verify": {
"verifying": "Verifying…",
"ok": "Email verified. You can now sign in.",
"failed": "This verification link is invalid or has expired.",
"toLogin": "Go to sign in"
},
"reset": {
"requestTitle": "Reset your password",
"email": "Email",
"requestSubmit": "Send reset link",
"requestDone": "If that email has an account, a reset link is on its way.",
"confirmTitle": "Choose a new password",
"newPassword": "New password",
"confirmSubmit": "Set password",
"ok": "Password updated. You can now sign in."
},
"dashboard": {
"title": "Account",
"balance": "Allocation",
"total": "Total",
"spent": "Spent",
"reserved": "Reserved",
"remaining": "Remaining",
"manageKeys": "Manage API keys",
"redeemTitle": "Redeem a top-up code",
"redeemPlaceholder": "helexa-topup-…",
"redeem": "Redeem",
"redeemed": "Code redeemed.",
"logout": "Sign out"
},
"keys": {
"title": "API keys",
"create": "Create key",
"label": "Label",
"limitKind": "Limit",
"percent": "% of allocation",
"hardcap": "Hard cap (tokens)",
"value": "Value",
"none": "No keys yet.",
"createdTitle": "Your new API key",
"createdWarn": "Copy it now — you won't see it again.",
"copy": "Copy",
"copied": "Copied",
"archive": "Archive",
"save": "Save",
"status": "Status",
"usage": "Used",
"useForChat": "Use for chat on this device",
"usedForChat": "Enabled for chat ✓"
},
"error": {
"generic": "Something went wrong.",
"unauthorized": "Please sign in again."
}
}

View File

@@ -5,5 +5,17 @@
"transcriptPlaceholder": "Chat tarixlari shu yerda koʻrinadi. Model va foydalanuvchi xabarlarini aylanuvchi konteynerda, zarur boʻlsa navbatlar boʻyicha guruhlab tasvirlang.",
"inputPlaceholder": "Chatni boshlash uchun xabar yozing…",
"send": "Yuborish",
"clear": "Tozalash"
"clear": "Tozalash",
"newChat": "New chat",
"newProject": "New project",
"newProjectName": "New project",
"unsorted": "Unsorted",
"emptyState": "Start a conversation. Your history stays in this browser.",
"anonBanner": "You have reached the anonymous limit. Sign up for a free allocation.",
"signUp": "Sign up",
"stop": "Stop",
"topUp": "Top up",
"rateLimited": "Rate limited — wait a moment and retry.",
"needsKey": "Create an API key and enable it for chat to send as yourself.",
"manageKeysLink": "Manage keys"
}

View File

@@ -0,0 +1,71 @@
{
"login": {
"title": "Sign in",
"email": "Email",
"password": "Password",
"submit": "Sign in",
"noAccount": "No account? Sign up"
},
"register": {
"title": "Create your account",
"email": "Email",
"password": "Password",
"submit": "Sign up",
"haveAccount": "Already have an account? Sign in",
"checkEmail": "Almost there — check your email to verify your account."
},
"verify": {
"verifying": "Verifying…",
"ok": "Email verified. You can now sign in.",
"failed": "This verification link is invalid or has expired.",
"toLogin": "Go to sign in"
},
"reset": {
"requestTitle": "Reset your password",
"email": "Email",
"requestSubmit": "Send reset link",
"requestDone": "If that email has an account, a reset link is on its way.",
"confirmTitle": "Choose a new password",
"newPassword": "New password",
"confirmSubmit": "Set password",
"ok": "Password updated. You can now sign in."
},
"dashboard": {
"title": "Account",
"balance": "Allocation",
"total": "Total",
"spent": "Spent",
"reserved": "Reserved",
"remaining": "Remaining",
"manageKeys": "Manage API keys",
"redeemTitle": "Redeem a top-up code",
"redeemPlaceholder": "helexa-topup-…",
"redeem": "Redeem",
"redeemed": "Code redeemed.",
"logout": "Sign out"
},
"keys": {
"title": "API keys",
"create": "Create key",
"label": "Label",
"limitKind": "Limit",
"percent": "% of allocation",
"hardcap": "Hard cap (tokens)",
"value": "Value",
"none": "No keys yet.",
"createdTitle": "Your new API key",
"createdWarn": "Copy it now — you won't see it again.",
"copy": "Copy",
"copied": "Copied",
"archive": "Archive",
"save": "Save",
"status": "Status",
"usage": "Used",
"useForChat": "Use for chat on this device",
"usedForChat": "Enabled for chat ✓"
},
"error": {
"generic": "Something went wrong.",
"unauthorized": "Please sign in again."
}
}

View File

@@ -5,5 +5,17 @@
"transcriptPlaceholder": "Àkọsílẹ̀ ìjíròrò yóò hàn níbí. Ṣàfihàn àwọn ìfiránṣẹ́ láti ọ̀dọ̀ àwòrán àfọwọ́kọ àti láti ọdọ oníṣè̩-ló̩rùn nínú àpótí tí a lè sáré sókè-sísàlẹ̀, tí o bá fẹ́ o tún lè pín wọn sí ìpínpín nípasẹ̀ àtúnṣe (turn).",
"inputPlaceholder": "Kọ ìfiránṣẹ́ láti bẹ̀rẹ̀ ìjíròrò…",
"send": "Ránṣẹ́",
"clear": "Pa mọ́"
"clear": "Pa mọ́",
"newChat": "New chat",
"newProject": "New project",
"newProjectName": "New project",
"unsorted": "Unsorted",
"emptyState": "Start a conversation. Your history stays in this browser.",
"anonBanner": "You have reached the anonymous limit. Sign up for a free allocation.",
"signUp": "Sign up",
"stop": "Stop",
"topUp": "Top up",
"rateLimited": "Rate limited — wait a moment and retry.",
"needsKey": "Create an API key and enable it for chat to send as yourself.",
"manageKeysLink": "Manage keys"
}

View File

@@ -0,0 +1,71 @@
{
"login": {
"title": "Sign in",
"email": "Email",
"password": "Password",
"submit": "Sign in",
"noAccount": "No account? Sign up"
},
"register": {
"title": "Create your account",
"email": "Email",
"password": "Password",
"submit": "Sign up",
"haveAccount": "Already have an account? Sign in",
"checkEmail": "Almost there — check your email to verify your account."
},
"verify": {
"verifying": "Verifying…",
"ok": "Email verified. You can now sign in.",
"failed": "This verification link is invalid or has expired.",
"toLogin": "Go to sign in"
},
"reset": {
"requestTitle": "Reset your password",
"email": "Email",
"requestSubmit": "Send reset link",
"requestDone": "If that email has an account, a reset link is on its way.",
"confirmTitle": "Choose a new password",
"newPassword": "New password",
"confirmSubmit": "Set password",
"ok": "Password updated. You can now sign in."
},
"dashboard": {
"title": "Account",
"balance": "Allocation",
"total": "Total",
"spent": "Spent",
"reserved": "Reserved",
"remaining": "Remaining",
"manageKeys": "Manage API keys",
"redeemTitle": "Redeem a top-up code",
"redeemPlaceholder": "helexa-topup-…",
"redeem": "Redeem",
"redeemed": "Code redeemed.",
"logout": "Sign out"
},
"keys": {
"title": "API keys",
"create": "Create key",
"label": "Label",
"limitKind": "Limit",
"percent": "% of allocation",
"hardcap": "Hard cap (tokens)",
"value": "Value",
"none": "No keys yet.",
"createdTitle": "Your new API key",
"createdWarn": "Copy it now — you won't see it again.",
"copy": "Copy",
"copied": "Copied",
"archive": "Archive",
"save": "Save",
"status": "Status",
"usage": "Used",
"useForChat": "Use for chat on this device",
"usedForChat": "Enabled for chat ✓"
},
"error": {
"generic": "Something went wrong.",
"unauthorized": "Please sign in again."
}
}

View File

@@ -5,5 +5,17 @@
"transcriptPlaceholder": "Irekhodi lengxoxo lizovela lapha. Bonisa imiyalezo evela kumodeli nasemsebenzini womsebenzisi ngaphakathi kwesiqukathi esinokuskroleka, ungayihlukanisa nangemijikelezo yengxoxo uma uthanda.",
"inputPlaceholder": "Bhala umlayezo ukuqala ingxoxo…",
"send": "Thumela",
"clear": "Sula"
"clear": "Sula",
"newChat": "New chat",
"newProject": "New project",
"newProjectName": "New project",
"unsorted": "Unsorted",
"emptyState": "Start a conversation. Your history stays in this browser.",
"anonBanner": "You have reached the anonymous limit. Sign up for a free allocation.",
"signUp": "Sign up",
"stop": "Stop",
"topUp": "Top up",
"rateLimited": "Rate limited — wait a moment and retry.",
"needsKey": "Create an API key and enable it for chat to send as yourself.",
"manageKeysLink": "Manage keys"
}

View File

@@ -0,0 +1,109 @@
// Streaming chat client → the mesh router's OpenAI-compatible
// /v1/chat/completions (SSE). Parses the byte stream incrementally so tokens
// render as they arrive; surfaces the OpenAI error envelope's `code` so the
// UI can react (rate_limit_exceeded, insufficient_quota, invalid_api_key,
// context_length_exceeded). An AbortController powers the Stop button.
export interface ChatMessage {
role: "system" | "user" | "assistant";
content: string;
}
export interface StreamHandlers {
onDelta: (text: string) => void;
onUsage?: (prompt: number, completion: number) => void;
onDone: () => void;
onError: (code: string, message: string) => void;
}
export interface StreamOptions {
baseUrl?: string;
apiKey?: string; // bearer for authenticated requests; omitted = anonymous
model: string;
messages: ChatMessage[];
signal: AbortSignal;
}
const DEFAULT_BASE = import.meta.env.VITE_ROUTER_BASE_URL || "";
export async function streamChatCompletion(
opts: StreamOptions,
h: StreamHandlers,
): Promise<void> {
const base = (opts.baseUrl ?? DEFAULT_BASE).replace(/\/$/, "");
let resp: Response;
try {
const headers: Record<string, string> = {
"content-type": "application/json",
accept: "text/event-stream",
};
if (opts.apiKey) headers.authorization = `Bearer ${opts.apiKey}`;
resp = await fetch(`${base}/v1/chat/completions`, {
method: "POST",
headers,
body: JSON.stringify({
model: opts.model,
messages: opts.messages,
stream: true,
}),
signal: opts.signal,
});
} catch (e) {
if ((e as Error).name === "AbortError") return h.onDone();
return h.onError("network_error", "Could not reach the mesh.");
}
if (!resp.ok || !resp.body) {
// Parse the OpenAI error envelope for the machine-readable code.
let code = "api_error";
let message = `Request failed (${resp.status}).`;
try {
const body = await resp.json();
code = body?.error?.code ?? body?.error?.type ?? code;
message = body?.error?.message ?? message;
} catch {
/* non-JSON body */
}
return h.onError(code, message);
}
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
try {
for (;;) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
// SSE frames are separated by a blank line.
let sep: number;
while ((sep = buffer.indexOf("\n\n")) !== -1) {
const frame = buffer.slice(0, sep);
buffer = buffer.slice(sep + 2);
for (const line of frame.split("\n")) {
const trimmed = line.trimStart();
if (!trimmed.startsWith("data:")) continue;
const data = trimmed.slice(5).trim();
if (data === "[DONE]") {
return h.onDone();
}
try {
const json = JSON.parse(data);
const delta = json?.choices?.[0]?.delta?.content;
if (typeof delta === "string" && delta) h.onDelta(delta);
const usage = json?.usage;
if (usage && h.onUsage) {
h.onUsage(usage.prompt_tokens ?? 0, usage.completion_tokens ?? 0);
}
} catch {
/* keep streaming past a non-JSON keepalive */
}
}
}
}
h.onDone();
} catch (e) {
if ((e as Error).name === "AbortError") return h.onDone();
h.onError("stream_error", "The response stream was interrupted.");
}
}

View File

@@ -0,0 +1,31 @@
// Browser fingerprint (FingerprintJS OSS) — best-effort, never auth.
// Two uses: namespacing anonymous local data, and a soft client identifier
// the router can use to throttle the anonymous public path. Cached in the
// Dexie `meta` store so it's computed once.
import FingerprintJS from "@fingerprintjs/fingerprintjs";
import { db } from "../data/db";
const META_KEY = "fingerprint";
let inFlight: Promise<string> | null = null;
export async function getFingerprint(): Promise<string> {
const cached = await db.meta.get(META_KEY);
if (cached && typeof cached.value === "string") return cached.value;
if (inFlight) return inFlight;
inFlight = (async () => {
let id = "unknown";
try {
const fp = await FingerprintJS.load();
const result = await fp.get();
id = result.visitorId;
} catch {
// Fingerprinting is best-effort; fall back to a random local id so
// anonymous data still has a stable namespace this session.
id = `local-${crypto.randomUUID()}`;
}
await db.meta.put({ key: META_KEY, value: id });
return id;
})();
return inFlight;
}

View File

@@ -0,0 +1,82 @@
// Orchestrates a single conversation: persists the user turn, opens a
// streaming assistant message, appends deltas to Dexie live, and finalizes
// on done/error. The UI re-renders via useLiveQuery on the messages table.
import { useRef, useState } from "react";
import {
addMessage,
appendToMessage,
finalizeMessage,
listMessages,
renameConversation,
} from "../data/repositories";
import { streamChatCompletion, type ChatMessage } from "./chatClient";
export interface UseChat {
streaming: boolean;
error: { code: string; message: string } | null;
send: (text: string) => Promise<void>;
stop: () => void;
}
export function useChat(
conversationId: string | null,
opts: { model: string; apiKey?: string },
): UseChat {
const [streaming, setStreaming] = useState(false);
const [error, setError] = useState<{ code: string; message: string } | null>(null);
const abortRef = useRef<AbortController | null>(null);
async function send(text: string): Promise<void> {
if (!conversationId || streaming || !text.trim()) return;
setError(null);
// History → request messages (before adding the new turn's assistant slot).
const history = await listMessages(conversationId);
const reqMessages: ChatMessage[] = history
.filter((m) => m.status !== "error")
.map((m) => ({ role: m.role, content: m.content }));
reqMessages.push({ role: "user", content: text });
await addMessage(conversationId, "user", text, "complete");
// Title the conversation from its first user message.
if (history.length === 0) {
await renameConversation(conversationId, text.slice(0, 60));
}
const assistantId = await addMessage(conversationId, "assistant", "", "streaming");
const controller = new AbortController();
abortRef.current = controller;
setStreaming(true);
await streamChatCompletion(
{
apiKey: opts.apiKey,
model: opts.model,
messages: reqMessages,
signal: controller.signal,
},
{
onDelta: (t) => void appendToMessage(assistantId, t),
onUsage: (p, c) =>
void finalizeMessage(assistantId, { promptTokens: p, completionTokens: c }),
onDone: () => {
void finalizeMessage(assistantId, { status: "complete" });
setStreaming(false);
},
onError: (code, message) => {
void finalizeMessage(assistantId, { status: "error", errorCode: code });
setError({ code, message });
setStreaming(false);
},
},
);
}
function stop(): void {
abortRef.current?.abort();
setStreaming(false);
}
return { streaming, error, send, stop };
}

View File

@@ -0,0 +1,290 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useLiveQuery } from "dexie-react-hooks";
import { Alert, Badge, Button, Form } from "react-bootstrap";
import { db } from "../data/db";
import {
createConversation,
createProject,
listConversations,
listProjects,
} from "../data/repositories";
import { getFingerprint } from "../lib/fingerprint";
import { useChat } from "../lib/useChat";
import { useAuth } from "../auth/context";
const ANON_MODEL = import.meta.env.VITE_ANON_MODEL || "helexa/small";
const AUTH_MODEL = import.meta.env.VITE_DEFAULT_MODEL || "helexa/balanced";
const ANON_MESSAGE_CAP = 20;
const ANON_COUNT_KEY = "anonMessageCount";
/**
* The chat workspace landing (`/`). Anonymous visitors are fingerprinted and
* capped, streaming from the constrained public model with no bearer. Signed
* in (F5), the workspace switches its IndexedDB owner to the account, lifts
* the cap, uses the full default model, and sends the user's API key (stored
* locally, never server-side) as the bearer. History always stays in the
* browser.
*/
export default function Chat() {
const { t } = useTranslation("chat");
const { status, accountId } = useAuth();
const authed = status === "authed" && !!accountId;
const owner = authed ? accountId! : "anon";
const model = authed ? AUTH_MODEL : ANON_MODEL;
// Namespace anonymous data to the fingerprint (best-effort) at mount.
useEffect(() => {
void getFingerprint();
}, []);
// The user's API key for authenticated chat — stored client-side only,
// captured from the create-key modal ("use for chat on this device").
const chatApiKey = useLiveQuery(
async () => {
const m = await db.meta.get("chatApiKey");
return typeof m?.value === "string" ? m.value : undefined;
},
[],
undefined,
);
const projects = useLiveQuery(() => listProjects(owner), [owner], []);
const conversations = useLiveQuery(() => listConversations(owner), [owner], []);
const [activeId, setActiveId] = useState<string | null>(null);
// Reset the active conversation when the owner changes (login/logout).
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
setActiveId(null);
}, [owner]);
const anonCount =
useLiveQuery(async () => {
const m = await db.meta.get(ANON_COUNT_KEY);
return typeof m?.value === "number" ? m.value : 0;
}, [], 0) ?? 0;
// The cap only applies to anonymous visitors; signed-in users are gated by
// their account allocation (enforced upstream), not a client counter.
const capped = !authed && anonCount >= ANON_MESSAGE_CAP;
// Signed in but no local key enabled for chat → can't send as yourself yet.
const needsKey = authed && !chatApiKey;
const messages = useLiveQuery(
async () => {
if (!activeId) return [];
const { listMessages } = await import("../data/repositories");
return listMessages(activeId);
},
[activeId],
[],
);
const { streaming, error, send, stop } = useChat(activeId, {
model,
apiKey: authed ? chatApiKey : undefined,
});
const [draft, setDraft] = useState("");
const threadRef = useRef<HTMLDivElement>(null);
useEffect(() => {
threadRef.current?.scrollTo({ top: threadRef.current.scrollHeight });
}, [messages]);
async function newChat(projectId: string | null = null) {
const id = await createConversation(owner, model, projectId);
setActiveId(id);
}
async function onSend() {
const text = draft.trim();
if (!text || streaming || capped || needsKey) return;
let convId = activeId;
if (!convId) {
convId = await createConversation(owner, model);
setActiveId(convId);
}
setDraft("");
if (!authed) {
await db.meta.put({ key: ANON_COUNT_KEY, value: anonCount + 1 });
}
await send(text);
}
// Group conversations by project for the sidebar.
const grouped = useMemo(() => {
const byProject = new Map<string | null, typeof conversations>();
for (const c of conversations ?? []) {
const arr = byProject.get(c.projectId) ?? [];
arr.push(c);
byProject.set(c.projectId, arr);
}
return byProject;
}, [conversations]);
return (
<div className="d-flex flex-grow-1" style={{ minHeight: 0 }}>
{/* Sidebar */}
<aside
className="border-end p-3 d-flex flex-column gap-2"
style={{ width: 280, overflowY: "auto" }}
>
<div className="d-flex gap-2">
<Button size="sm" variant="primary" onClick={() => void newChat()}>
{t("newChat")}
</Button>
<Button
size="sm"
variant="outline-secondary"
onClick={() => void createProject(owner, t("newProjectName"))}
>
{t("newProject")}
</Button>
</div>
<ConversationGroup
label={t("unsorted")}
items={grouped.get(null) ?? []}
activeId={activeId}
onSelect={setActiveId}
/>
{(projects ?? []).map((p) => (
<ConversationGroup
key={p.id}
label={p.name}
items={grouped.get(p.id) ?? []}
activeId={activeId}
onSelect={setActiveId}
/>
))}
</aside>
{/* Main */}
<section className="d-flex flex-column flex-grow-1" style={{ minWidth: 0 }}>
<div ref={threadRef} className="flex-grow-1 p-3 overflow-auto">
{(messages ?? []).length === 0 ? (
<div className="text-muted text-center mt-5">
<Badge bg="secondary" className="mb-2">
{t("badge")}
</Badge>
<p>{t("emptyState")}</p>
</div>
) : (
(messages ?? []).map((m) => (
<div
key={m.id}
className={`mb-3 d-flex ${m.role === "user" ? "justify-content-end" : "justify-content-start"}`}
>
<div
className={`surface-elevated p-2 px-3 rounded-3 ${m.role === "user" ? "bg-body-tertiary" : ""}`}
style={{ maxWidth: "80%", whiteSpace: "pre-wrap" }}
>
{m.content}
{m.status === "streaming" && <span className="opacity-50"> </span>}
{m.status === "error" && (
<span className="text-danger small"> {m.errorCode}</span>
)}
</div>
</div>
))
)}
</div>
{error && (
<Alert variant="warning" className="m-2 py-2">
{error.message}{" "}
{error.code === "insufficient_quota" ? (
// Hard balance exhausted → top up (authed) or sign up (anon).
<a href={authed ? "/account" : "/register"}>
{authed ? t("topUp") : t("signUp")}
</a>
) : error.code === "rate_limit_exceeded" ? (
<span className="text-muted">{t("rateLimited")}</span>
) : (
!authed && <a href="/register">{t("signUp")}</a>
)}
</Alert>
)}
{capped && !error && (
<Alert variant="info" className="m-2 py-2">
{t("anonBanner")} <a href="/register">{t("signUp")}</a>
</Alert>
)}
{needsKey && !error && (
<Alert variant="info" className="m-2 py-2">
{t("needsKey")} <a href="/account/keys">{t("manageKeysLink")}</a>
</Alert>
)}
<Form
className="d-flex gap-2 p-2 border-top"
onSubmit={(e) => {
e.preventDefault();
void onSend();
}}
>
<Form.Control
as="textarea"
rows={1}
value={draft}
disabled={capped || needsKey}
placeholder={
capped ? t("anonBanner") : needsKey ? t("needsKey") : t("inputPlaceholder")
}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
void onSend();
}
}}
/>
{streaming ? (
<Button variant="outline-danger" onClick={stop}>
{t("stop")}
</Button>
) : (
<Button type="submit" variant="primary" disabled={capped || needsKey || !draft.trim()}>
{t("send")}
</Button>
)}
</Form>
</section>
</div>
);
}
function ConversationGroup({
label,
items,
activeId,
onSelect,
}: {
label: string;
items: { id: string; title: string }[];
activeId: string | null;
onSelect: (id: string) => void;
}) {
if (items.length === 0) return null;
return (
<div>
<div className="text-uppercase text-muted small fw-semibold mt-2 mb-1">
{label}
</div>
{items.map((c) => (
<button
key={c.id}
type="button"
onClick={() => onSelect(c.id)}
className={`btn btn-sm w-100 text-start text-truncate ${
c.id === activeId ? "btn-secondary" : "btn-link text-body"
}`}
>
{c.title}
</button>
))}
</div>
);
}

View File

@@ -0,0 +1,175 @@
import { useCallback, useEffect, useState } from "react";
import { Alert, Badge, Button, Container, Form, Modal, Table } from "react-bootstrap";
import { useTranslation } from "react-i18next";
import { useAuth } from "../../auth/context";
import { accountApi } from "../../api/account";
import { db } from "../../data/db";
import { ApiError, type ApiKeySummary, type CreatedKey } from "../../api/types";
type LimitKind = "percent" | "hardcap";
export default function ApiKeys() {
const { t } = useTranslation("account");
const { token, logout } = useAuth();
const [keys, setKeys] = useState<ApiKeySummary[]>([]);
const [error, setError] = useState<string | null>(null);
// Create-key form state.
const [label, setLabel] = useState("");
const [limitKind, setLimitKind] = useState<LimitKind>("percent");
const [limitValue, setLimitValue] = useState(100);
const [created, setCreated] = useState<CreatedKey | null>(null);
const [copied, setCopied] = useState(false);
const [usedForChat, setUsedForChat] = useState(false);
const load = useCallback(async () => {
if (!token) return;
try {
setKeys(await accountApi().listKeys(token));
} catch (err) {
if (err instanceof ApiError && err.status === 401) logout();
else setError(t("error.generic"));
}
}, [token, logout, t]);
useEffect(() => {
// load() is async; setState happens after await, not synchronously.
// eslint-disable-next-line react-hooks/set-state-in-effect
void load();
}, [load]);
async function create(e: React.FormEvent) {
e.preventDefault();
if (!token) return;
setError(null);
try {
const key = await accountApi().createKey(token, label, limitKind, limitValue);
setCreated(key);
setLabel("");
await load();
} catch (err) {
setError(err instanceof ApiError ? err.message : t("error.generic"));
}
}
async function archive(id: string) {
if (!token) return;
await accountApi().archiveKey(token, id);
await load();
}
return (
<Container className="py-5 flex-grow-1" style={{ maxWidth: 860 }}>
<h1 className="h3 mb-4">{t("keys.title")}</h1>
{error && <Alert variant="warning">{error}</Alert>}
<Form onSubmit={create} className="surface-elevated p-3 rounded-3 mb-4">
<div className="row g-2 align-items-end">
<div className="col">
<Form.Label className="small">{t("keys.label")}</Form.Label>
<Form.Control value={label} onChange={(e) => setLabel(e.target.value)} />
</div>
<div className="col">
<Form.Label className="small">{t("keys.limitKind")}</Form.Label>
<Form.Select
value={limitKind}
onChange={(e) => setLimitKind(e.target.value as LimitKind)}
>
<option value="percent">{t("keys.percent")}</option>
<option value="hardcap">{t("keys.hardcap")}</option>
</Form.Select>
</div>
<div className="col">
<Form.Label className="small">{t("keys.value")}</Form.Label>
<Form.Control
type="number"
min={0}
value={limitValue}
onChange={(e) => setLimitValue(Number(e.target.value))}
/>
</div>
<div className="col-auto">
<Button type="submit">{t("keys.create")}</Button>
</div>
</div>
</Form>
{keys.length === 0 ? (
<p className="text-muted">{t("keys.none")}</p>
) : (
<Table responsive hover>
<thead>
<tr>
<th>{t("keys.label")}</th>
<th>Prefix</th>
<th>{t("keys.limitKind")}</th>
<th>{t("keys.usage")}</th>
<th>{t("keys.status")}</th>
<th />
</tr>
</thead>
<tbody>
{keys.map((k) => (
<tr key={k.id}>
<td>{k.label || "—"}</td>
<td>
<code>{k.prefix}</code>
</td>
<td>
{k.limit_kind === "percent" ? `${k.limit_value}%` : k.limit_value.toLocaleString()}
</td>
<td>{k.spent.toLocaleString()}</td>
<td>
<Badge bg={k.status === "active" ? "success" : "secondary"}>{k.status}</Badge>
</td>
<td className="text-end">
{k.status === "active" && (
<Button size="sm" variant="outline-danger" onClick={() => void archive(k.id)}>
{t("keys.archive")}
</Button>
)}
</td>
</tr>
))}
</tbody>
</Table>
)}
{/* The raw key is shown exactly once. */}
<Modal show={!!created} onHide={() => setCreated(null)} centered>
<Modal.Header closeButton>
<Modal.Title className="h6">{t("keys.createdTitle")}</Modal.Title>
</Modal.Header>
<Modal.Body>
<Alert variant="warning" className="py-2">{t("keys.createdWarn")}</Alert>
<div className="d-flex gap-2">
<Form.Control readOnly value={created?.key ?? ""} />
<Button
variant="outline-secondary"
onClick={() => {
if (created) void navigator.clipboard.writeText(created.key);
setCopied(true);
}}
>
{copied ? t("keys.copied") : t("keys.copy")}
</Button>
</div>
{/* Store the raw key locally (this browser only) so the chat can
use it as your bearer — consistent with no server-side secrets. */}
<Button
variant="link"
className="px-0 mt-2"
onClick={async () => {
if (!created) return;
await db.meta.put({ key: "chatApiKey", value: created.key });
await db.meta.put({ key: "chatApiKeyId", value: created.id });
setUsedForChat(true);
}}
>
{usedForChat ? t("keys.usedForChat") : t("keys.useForChat")}
</Button>
</Modal.Body>
</Modal>
</Container>
);
}

View File

@@ -0,0 +1,104 @@
import { useCallback, useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { Alert, Button, Card, Container, Form, ProgressBar } from "react-bootstrap";
import { useTranslation } from "react-i18next";
import { useAuth } from "../../auth/context";
import { accountApi } from "../../api/account";
import { ApiError, type AccountBalance } from "../../api/types";
export default function Dashboard() {
const { t } = useTranslation("account");
const { token, logout } = useAuth();
const [balance, setBalance] = useState<AccountBalance | null>(null);
const [code, setCode] = useState("");
const [msg, setMsg] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const load = useCallback(async () => {
if (!token) return;
try {
setBalance(await accountApi().account(token));
} catch (err) {
if (err instanceof ApiError && err.status === 401) logout();
else setError(t("error.generic"));
}
}, [token, logout, t]);
useEffect(() => {
// load() is async; setState happens after await, not synchronously.
// eslint-disable-next-line react-hooks/set-state-in-effect
void load();
}, [load]);
async function redeem(e: React.FormEvent) {
e.preventDefault();
if (!token) return;
setError(null);
setMsg(null);
try {
setBalance(await accountApi().redeem(token, code.trim()));
setCode("");
setMsg(t("dashboard.redeemed"));
} catch (err) {
setError(err instanceof ApiError ? err.message : t("error.generic"));
}
}
const remaining = balance
? balance.allocation_total - balance.allocation_spent - balance.allocation_reserved
: 0;
const pct = balance && balance.allocation_total > 0
? Math.round(((balance.allocation_spent + balance.allocation_reserved) / balance.allocation_total) * 100)
: 0;
return (
<Container className="py-5 flex-grow-1" style={{ maxWidth: 720 }}>
<div className="d-flex justify-content-between align-items-center mb-4">
<h1 className="h3 mb-0">{t("dashboard.title")}</h1>
<Button variant="outline-secondary" size="sm" onClick={logout}>
{t("dashboard.logout")}
</Button>
</div>
<Card className="surface-elevated mb-4">
<Card.Body>
<Card.Title className="h6 text-uppercase text-muted">
{t("dashboard.balance")}
</Card.Title>
{balance && (
<>
<ProgressBar now={pct} className="my-3" />
<div className="d-flex justify-content-between small">
<span>{t("dashboard.total")}: {balance.allocation_total.toLocaleString()}</span>
<span>{t("dashboard.spent")}: {balance.allocation_spent.toLocaleString()}</span>
<span>{t("dashboard.reserved")}: {balance.allocation_reserved.toLocaleString()}</span>
<span>{t("dashboard.remaining")}: {remaining.toLocaleString()}</span>
</div>
</>
)}
<Link to="/account/keys" className="btn btn-primary btn-sm mt-3">
{t("dashboard.manageKeys")}
</Link>
</Card.Body>
</Card>
<Card className="surface-elevated">
<Card.Body>
<Card.Title className="h6">{t("dashboard.redeemTitle")}</Card.Title>
{msg && <Alert variant="success" className="py-2">{msg}</Alert>}
{error && <Alert variant="warning" className="py-2">{error}</Alert>}
<Form onSubmit={redeem} className="d-flex gap-2">
<Form.Control
value={code}
placeholder={t("dashboard.redeemPlaceholder")}
onChange={(e) => setCode(e.target.value)}
/>
<Button type="submit" disabled={!code.trim()}>
{t("dashboard.redeem")}
</Button>
</Form>
</Card.Body>
</Card>
</Container>
);
}

View File

@@ -0,0 +1,64 @@
import { useState } from "react";
import { Link, useNavigate, useSearchParams } from "react-router-dom";
import { Alert, Button, Container, Form } from "react-bootstrap";
import { useTranslation } from "react-i18next";
import { useAuth } from "../../auth/context";
import { ApiError } from "../../api/types";
export default function Login() {
const { t } = useTranslation("account");
const { login } = useAuth();
const nav = useNavigate();
const [params] = useSearchParams();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
async function submit(e: React.FormEvent) {
e.preventDefault();
setBusy(true);
setError(null);
try {
await login(email, password);
nav(params.get("next") || "/account", { replace: true });
} catch (err) {
setError(err instanceof ApiError ? err.message : t("error.generic"));
} finally {
setBusy(false);
}
}
return (
<Container className="py-5 flex-grow-1" style={{ maxWidth: 420 }}>
<h1 className="h3 mb-4">{t("login.title")}</h1>
{error && <Alert variant="warning">{error}</Alert>}
<Form onSubmit={submit}>
<Form.Group className="mb-3">
<Form.Label>{t("login.email")}</Form.Label>
<Form.Control
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>{t("login.password")}</Form.Label>
<Form.Control
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</Form.Group>
<Button type="submit" disabled={busy} className="w-100">
{t("login.submit")}
</Button>
</Form>
<p className="mt-3 small">
<Link to="/register">{t("login.noAccount")}</Link>
</p>
</Container>
);
}

View File

@@ -0,0 +1,70 @@
import { useState } from "react";
import { Link } from "react-router-dom";
import { Alert, Button, Container, Form } from "react-bootstrap";
import { useTranslation } from "react-i18next";
import { useAuth } from "../../auth/context";
import { ApiError } from "../../api/types";
export default function Register() {
const { t } = useTranslation("account");
const { register } = useAuth();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [done, setDone] = useState(false);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
async function submit(e: React.FormEvent) {
e.preventDefault();
setBusy(true);
setError(null);
try {
await register(email, password);
setDone(true);
} catch (err) {
setError(err instanceof ApiError ? err.message : t("error.generic"));
} finally {
setBusy(false);
}
}
return (
<Container className="py-5 flex-grow-1" style={{ maxWidth: 420 }}>
<h1 className="h3 mb-4">{t("register.title")}</h1>
{done ? (
<Alert variant="success">{t("register.checkEmail")}</Alert>
) : (
<>
{error && <Alert variant="warning">{error}</Alert>}
<Form onSubmit={submit}>
<Form.Group className="mb-3">
<Form.Label>{t("register.email")}</Form.Label>
<Form.Control
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>{t("register.password")}</Form.Label>
<Form.Control
type="password"
minLength={8}
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</Form.Group>
<Button type="submit" disabled={busy} className="w-100">
{t("register.submit")}
</Button>
</Form>
<p className="mt-3 small">
<Link to="/login">{t("register.haveAccount")}</Link>
</p>
</>
)}
</Container>
);
}

View File

@@ -0,0 +1,48 @@
import { useState } from "react";
import { Alert, Button, Container, Form } from "react-bootstrap";
import { useTranslation } from "react-i18next";
import { accountApi } from "../../api/account";
export default function RequestReset() {
const { t } = useTranslation("account");
const [email, setEmail] = useState("");
const [done, setDone] = useState(false);
const [busy, setBusy] = useState(false);
async function submit(e: React.FormEvent) {
e.preventDefault();
setBusy(true);
// Always succeeds from the UI's view (no account enumeration).
try {
await accountApi().requestReset(email);
} catch {
/* swallow */
}
setDone(true);
setBusy(false);
}
return (
<Container className="py-5 flex-grow-1" style={{ maxWidth: 420 }}>
<h1 className="h3 mb-4">{t("reset.requestTitle")}</h1>
{done ? (
<Alert variant="info">{t("reset.requestDone")}</Alert>
) : (
<Form onSubmit={submit}>
<Form.Group className="mb-3">
<Form.Label>{t("reset.email")}</Form.Label>
<Form.Control
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</Form.Group>
<Button type="submit" disabled={busy} className="w-100">
{t("reset.requestSubmit")}
</Button>
</Form>
)}
</Container>
);
}

View File

@@ -0,0 +1,64 @@
import { useState } from "react";
import { Link, useSearchParams } from "react-router-dom";
import { Alert, Button, Container, Form } from "react-bootstrap";
import { useTranslation } from "react-i18next";
import { accountApi } from "../../api/account";
import { ApiError } from "../../api/types";
export default function ResetPassword() {
const { t } = useTranslation("account");
const [params] = useSearchParams();
const [password, setPassword] = useState("");
const [done, setDone] = useState(false);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
async function submit(e: React.FormEvent) {
e.preventDefault();
const token = params.get("token");
if (!token) {
setError(t("verify.failed"));
return;
}
setBusy(true);
setError(null);
try {
await accountApi().confirmReset(token, password);
setDone(true);
} catch (err) {
setError(err instanceof ApiError ? err.message : t("error.generic"));
} finally {
setBusy(false);
}
}
return (
<Container className="py-5 flex-grow-1" style={{ maxWidth: 420 }}>
<h1 className="h3 mb-4">{t("reset.confirmTitle")}</h1>
{done ? (
<Alert variant="success">
{t("reset.ok")} <Link to="/login">{t("verify.toLogin")}</Link>
</Alert>
) : (
<>
{error && <Alert variant="warning">{error}</Alert>}
<Form onSubmit={submit}>
<Form.Group className="mb-3">
<Form.Label>{t("reset.newPassword")}</Form.Label>
<Form.Control
type="password"
minLength={8}
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</Form.Group>
<Button type="submit" disabled={busy} className="w-100">
{t("reset.confirmSubmit")}
</Button>
</Form>
</>
)}
</Container>
);
}

View File

@@ -0,0 +1,40 @@
import { useEffect, useState } from "react";
import { Link, useSearchParams } from "react-router-dom";
import { Alert, Container, Spinner } from "react-bootstrap";
import { useTranslation } from "react-i18next";
import { accountApi } from "../../api/account";
export default function VerifyEmail() {
const { t } = useTranslation("account");
const [params] = useSearchParams();
const [state, setState] = useState<"verifying" | "ok" | "failed">("verifying");
useEffect(() => {
const token = params.get("token");
// Keep all setState in async callbacks (no synchronous setState in the
// effect body): a missing token resolves to a rejected promise.
const run = token ? accountApi().verify(token) : Promise.reject(new Error("no token"));
run.then(() => setState("ok")).catch(() => setState("failed"));
}, [params]);
return (
<Container className="py-5 flex-grow-1" style={{ maxWidth: 480 }}>
{state === "verifying" && (
<p>
<Spinner size="sm" className="me-2" />
{t("verify.verifying")}
</p>
)}
{state === "ok" && (
<Alert variant="success">
{t("verify.ok")} <Link to="/login">{t("verify.toLogin")}</Link>
</Alert>
)}
{state === "failed" && (
<Alert variant="warning">
{t("verify.failed")} <Link to="/login">{t("verify.toLogin")}</Link>
</Alert>
)}
</Container>
);
}