Compare commits
22 Commits
feat/B4-ac
...
copy/missi
| Author | SHA1 | Date | |
|---|---|---|---|
|
7e1ab0212d
|
|||
|
7e11a7688c
|
|||
|
5600575ba2
|
|||
|
bc7476bf1b
|
|||
|
5a8f6bc7b3
|
|||
|
452d7d9b3d
|
|||
|
21eb211d6a
|
|||
|
508b326bf7
|
|||
|
0de99a8cc7
|
|||
|
f4117224fc
|
|||
|
ce29e0c171
|
|||
|
1bf3348c8c
|
|||
|
7c12b9ea98
|
|||
|
c596519dbd
|
|||
|
a6b1fdc33d
|
|||
|
8dd82776f1
|
|||
|
8600d4fbf2
|
|||
|
7a6f252fe0
|
|||
|
bb0d1e51b8
|
|||
|
2348cc2234
|
|||
|
f2ba12bbc5
|
|||
|
a9d7382be8
|
@@ -66,6 +66,7 @@ jobs:
|
||||
build_cortex: ${{ steps.changes.outputs.build_cortex }}
|
||||
build_neuron: ${{ steps.changes.outputs.build_neuron }}
|
||||
build_bench: ${{ steps.changes.outputs.build_bench }}
|
||||
build_upstream: ${{ steps.changes.outputs.build_upstream }}
|
||||
check_rust: ${{ steps.changes.outputs.check_rust }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -104,6 +105,7 @@ jobs:
|
||||
BUILD_CORTEX=true
|
||||
BUILD_NEURON=true
|
||||
BUILD_BENCH=true
|
||||
BUILD_UPSTREAM=true
|
||||
CHECK_RUST=true
|
||||
|
||||
if [ "${GITHUB_EVENT_NAME}" = "push" ]; then
|
||||
@@ -149,6 +151,7 @@ jobs:
|
||||
NEURON_RE='^crates/neuron/|^crates/cortex-core/|^Cargo\.toml$|^Cargo\.lock$|^rpm/helexa-neuron-prerelease\.spec$|^data/neuron|^neuron\.example\.toml$|^\.gitea/workflows/build-prerelease\.yml$'
|
||||
CORTEX_RE='^crates/cortex-gateway/|^crates/cortex-cli/|^crates/cortex-core/|^Cargo\.toml$|^Cargo\.lock$|^rpm/cortex-prerelease\.spec$|^data/cortex|^cortex\.example\.toml$|^models\.example\.toml$|^\.gitea/workflows/build-prerelease\.yml$'
|
||||
BENCH_RE='^crates/helexa-bench/|^crates/cortex-core/|^Cargo\.toml$|^Cargo\.lock$|^rpm/helexa-bench-prerelease\.spec$|^data/helexa-bench|^helexa-bench\.example\.toml$|^\.gitea/workflows/build-prerelease\.yml$'
|
||||
UPSTREAM_RE='^crates/helexa-upstream/|^crates/cortex-core/|^Cargo\.toml$|^Cargo\.lock$|^rpm/helexa-upstream-prerelease\.spec$|^data/helexa-upstream|^helexa-upstream\.example\.toml$|^\.gitea/workflows/build-prerelease\.yml$'
|
||||
# Any Rust change (incl. crates not packaged here, e.g.
|
||||
# helexa-acp) still needs lint+test on main.
|
||||
RUST_RE='\.rs$|^crates/|Cargo\.toml$|^Cargo\.lock$'
|
||||
@@ -156,10 +159,12 @@ jobs:
|
||||
CORTEX_BASE=$(base_for cortex)
|
||||
NEURON_BASE=$(base_for helexa-neuron-blackwell)
|
||||
BENCH_BASE=$(base_for helexa-bench)
|
||||
UPSTREAM_BASE=$(base_for helexa-upstream)
|
||||
BUILD_CORTEX=$(decide "$CORTEX_BASE" "$CORTEX_RE")
|
||||
BUILD_NEURON=$(decide "$NEURON_BASE" "$NEURON_RE")
|
||||
BUILD_BENCH=$(decide "$BENCH_BASE" "$BENCH_RE")
|
||||
if [ "$BUILD_CORTEX" = "true" ] || [ "$BUILD_NEURON" = "true" ] || [ "$BUILD_BENCH" = "true" ]; then
|
||||
BUILD_UPSTREAM=$(decide "$UPSTREAM_BASE" "$UPSTREAM_RE")
|
||||
if [ "$BUILD_CORTEX" = "true" ] || [ "$BUILD_NEURON" = "true" ] || [ "$BUILD_BENCH" = "true" ] || [ "$BUILD_UPSTREAM" = "true" ]; then
|
||||
CHECK_RUST=true
|
||||
else
|
||||
CHECK_RUST=$(decide "$CORTEX_BASE" "$RUST_RE")
|
||||
@@ -170,8 +175,9 @@ jobs:
|
||||
echo "build_cortex=${BUILD_CORTEX}" >> "$GITHUB_OUTPUT"
|
||||
echo "build_neuron=${BUILD_NEURON}" >> "$GITHUB_OUTPUT"
|
||||
echo "build_bench=${BUILD_BENCH}" >> "$GITHUB_OUTPUT"
|
||||
echo "build_upstream=${BUILD_UPSTREAM}" >> "$GITHUB_OUTPUT"
|
||||
echo "check_rust=${CHECK_RUST}" >> "$GITHUB_OUTPUT"
|
||||
echo "### change detection: build_cortex=${BUILD_CORTEX} build_neuron=${BUILD_NEURON} build_bench=${BUILD_BENCH} check_rust=${CHECK_RUST}"
|
||||
echo "### change detection: build_cortex=${BUILD_CORTEX} build_neuron=${BUILD_NEURON} build_bench=${BUILD_BENCH} build_upstream=${BUILD_UPSTREAM} check_rust=${CHECK_RUST}"
|
||||
|
||||
# fmt + clippy + test moved here from ci.yml for main pushes so the
|
||||
# two workflows stop queueing against each other (ci.yml's checks
|
||||
@@ -303,6 +309,45 @@ jobs:
|
||||
path: artifacts/helexa-bench
|
||||
retention-days: 1
|
||||
|
||||
build-upstream:
|
||||
name: Build helexa-upstream binary
|
||||
timeout-minutes: 25
|
||||
needs: prepare
|
||||
if: needs.prepare.outputs.build_upstream == 'true'
|
||||
# Pure-Rust, non-CUDA binary — same runner as cortex/bench.
|
||||
runs-on: rust
|
||||
env:
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_BUCKET: sccache
|
||||
SCCACHE_ENDPOINT: http://caveman.kosherinata.internal:9000
|
||||
SCCACHE_REGION: auto
|
||||
SCCACHE_S3_USE_SSL: "false"
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_S3_ACCESS_KEY }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_S3_SECRET_KEY }}
|
||||
# helexa-upstream uses the sqlx runtime query API (no compile-time
|
||||
# query macros), so it builds without a database or a .sqlx cache.
|
||||
# Set OFFLINE defensively so a stray macro can never reach for a DB.
|
||||
SQLX_OFFLINE: "true"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.ref }}
|
||||
|
||||
- name: Build helexa-upstream (release, sccache escalation)
|
||||
run: script/ci-cargo-escalate.sh cargo build --release -p helexa-upstream
|
||||
|
||||
- name: Stage binary
|
||||
run: |
|
||||
mkdir --parents artifacts
|
||||
cp target/release/helexa-upstream artifacts/helexa-upstream
|
||||
./artifacts/helexa-upstream --version || true
|
||||
|
||||
- uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: upstream-fc43
|
||||
path: artifacts/helexa-upstream
|
||||
retention-days: 1
|
||||
|
||||
build-neuron:
|
||||
name: Build neuron-${{ matrix.flavour }}
|
||||
timeout-minutes: 35
|
||||
@@ -459,6 +504,44 @@ jobs:
|
||||
path: ~/rpmbuild/RPMS/x86_64/*.rpm
|
||||
retention-days: 7
|
||||
|
||||
package-upstream:
|
||||
name: Package helexa-upstream RPM
|
||||
timeout-minutes: 20
|
||||
needs: [prepare, build-upstream]
|
||||
runs-on: rpm
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.ref }}
|
||||
|
||||
- uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: upstream-fc43
|
||||
path: artifacts/
|
||||
|
||||
- name: Build RPM
|
||||
run: |
|
||||
set -eux
|
||||
rm -f ~/.rpmmacros
|
||||
rpmdev-setuptree
|
||||
cp artifacts/helexa-upstream ~/rpmbuild/SOURCES/
|
||||
cp data/helexa-upstream.service ~/rpmbuild/SOURCES/
|
||||
cp data/helexa-upstream-sysusers.conf ~/rpmbuild/SOURCES/
|
||||
cp data/helexa-upstream-firewalld.xml ~/rpmbuild/SOURCES/
|
||||
cp helexa-upstream.example.toml ~/rpmbuild/SOURCES/
|
||||
cp LICENSE ~/rpmbuild/SOURCES/
|
||||
rpmbuild -bb rpm/helexa-upstream-prerelease.spec \
|
||||
--define "upstream_version ${{ needs.prepare.outputs.version }}" \
|
||||
--define "upstream_prerelease ${{ needs.prepare.outputs.release }}" \
|
||||
--undefine dist \
|
||||
--define "dist .fc43"
|
||||
|
||||
- uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: rpm-upstream-fc43
|
||||
path: ~/rpmbuild/RPMS/x86_64/*.rpm
|
||||
retention-days: 7
|
||||
|
||||
package-neuron:
|
||||
name: Package helexa-neuron-${{ matrix.flavour }} RPM
|
||||
timeout-minutes: 20
|
||||
@@ -508,7 +591,7 @@ jobs:
|
||||
publish:
|
||||
name: Publish to rpm.lair.cafe (unstable)
|
||||
timeout-minutes: 25
|
||||
needs: [lint, test, package-cortex, package-neuron, package-bench]
|
||||
needs: [lint, test, package-cortex, package-neuron, package-bench, package-upstream]
|
||||
# Runs when at least one package was built and nothing failed.
|
||||
# lint/test may be skipped (docs-only refs never get here because
|
||||
# no packages build), but a real failure in any blocks the
|
||||
@@ -518,10 +601,11 @@ jobs:
|
||||
!cancelled()
|
||||
&& (needs.lint.result == 'success' || needs.lint.result == 'skipped')
|
||||
&& (needs.test.result == 'success' || needs.test.result == 'skipped')
|
||||
&& (needs.package-cortex.result == 'success' || needs.package-neuron.result == 'success' || needs.package-bench.result == 'success')
|
||||
&& (needs.package-cortex.result == 'success' || needs.package-neuron.result == 'success' || needs.package-bench.result == 'success' || needs.package-upstream.result == 'success')
|
||||
&& needs.package-cortex.result != 'failure'
|
||||
&& needs.package-neuron.result != 'failure'
|
||||
&& needs.package-bench.result != 'failure'
|
||||
&& needs.package-upstream.result != 'failure'
|
||||
}}
|
||||
runs-on: rpm
|
||||
concurrency:
|
||||
|
||||
@@ -90,3 +90,20 @@ account_id = "operator"
|
||||
key_id = "infra"
|
||||
# No hard_cap → uncapped operator infra key (own fleet, own use). Still
|
||||
# metered for visibility.
|
||||
|
||||
# -- Upstream (helexa mesh) entitlements client (#57) --------------------
|
||||
# When enabled, a bearer key NOT found in [[entitlements.keys]] above is
|
||||
# validated against the helexa-upstream authority (mesh accounts), and its
|
||||
# budget is reserved/settled there. Operator-local keys (incl. the infra
|
||||
# key) never leave this process. Fail-closed: if upstream is unreachable a
|
||||
# request is refused (503 + Retry-After), never served un-authorized.
|
||||
# Disabled by default — a standalone operator runs purely local.
|
||||
[upstream]
|
||||
enabled = false
|
||||
# url = "https://upstream.helexa.ai"
|
||||
# Shared client bearer this cortex presents (maps to an operator_id
|
||||
# upstream). Override via CORTEX_UPSTREAM__BEARER in prod.
|
||||
# bearer = "replace-with-operator-client-secret"
|
||||
# timeout_secs = 5
|
||||
# How often to flush served-usage counters to upstream for reconciliation (#58).
|
||||
# served_usage_report_interval_secs = 60
|
||||
|
||||
@@ -22,6 +22,43 @@ pub struct GatewayConfig {
|
||||
/// setups keep working until keys are configured.
|
||||
#[serde(default)]
|
||||
pub entitlements: EntitlementsConfig,
|
||||
/// helexa-upstream client (#57). When enabled, keys not found in the
|
||||
/// local `[entitlements]` config are validated against the mesh
|
||||
/// authority, and budget is reserved/settled there. Disabled by default
|
||||
/// — a single operator runs purely local.
|
||||
#[serde(default)]
|
||||
pub upstream: UpstreamClientConfig,
|
||||
}
|
||||
|
||||
/// `[upstream]` — the helexa-upstream authority client (#57). Locally
|
||||
/// unrecognised bearer keys are resolved against `url`'s `/authz/v1` surface
|
||||
/// (mesh accounts); local keys (operator + infra) never leave the process.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct UpstreamClientConfig {
|
||||
/// Enable the upstream fallthrough. Off → purely local entitlements.
|
||||
#[serde(default)]
|
||||
pub enabled: bool,
|
||||
/// Base URL of helexa-upstream (e.g. "https://upstream.helexa.ai").
|
||||
#[serde(default)]
|
||||
pub url: String,
|
||||
/// Shared client bearer this cortex presents to `/authz/v1` (maps to an
|
||||
/// operator_id upstream). Sent as `Authorization: Bearer <bearer>`.
|
||||
#[serde(default)]
|
||||
pub bearer: String,
|
||||
/// Per-call timeout (seconds) to upstream.
|
||||
#[serde(default = "default_upstream_timeout")]
|
||||
pub timeout_secs: u64,
|
||||
/// How often (seconds) to flush served-usage counters to upstream for
|
||||
/// reconciliation (#58).
|
||||
#[serde(default = "default_served_usage_interval")]
|
||||
pub served_usage_report_interval_secs: u64,
|
||||
}
|
||||
|
||||
fn default_upstream_timeout() -> u64 {
|
||||
5
|
||||
}
|
||||
fn default_served_usage_interval() -> u64 {
|
||||
60
|
||||
}
|
||||
|
||||
/// `[entitlements]` — the local/static [`crate::entitlements::EntitlementProvider`]
|
||||
@@ -129,6 +166,7 @@ impl Default for GatewayConfig {
|
||||
neurons: vec![],
|
||||
models_config: default_models_path(),
|
||||
entitlements: EntitlementsConfig::default(),
|
||||
upstream: UpstreamClientConfig::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,12 +81,19 @@ pub struct BudgetSnapshot {
|
||||
pub reserved: u64,
|
||||
}
|
||||
|
||||
/// Authentication failure — the bearer key could not be resolved. Maps to
|
||||
/// `401 invalid_api_key` (#49/#63).
|
||||
/// Authentication failure — the bearer key could not be resolved.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum AuthError {
|
||||
/// The key is genuinely unknown → `401 invalid_api_key` (#49/#63).
|
||||
#[error("invalid or unknown API key")]
|
||||
InvalidKey,
|
||||
/// The authority that could resolve the key is unreachable (e.g. the
|
||||
/// helexa-upstream client failed, #57). Fail **closed** but distinctly:
|
||||
/// a transient outage must surface as `503 service_unavailable` +
|
||||
/// `Retry-After`, never `401` — a real key must not be rejected as
|
||||
/// invalid during an upstream blip.
|
||||
#[error("entitlement authority unavailable; retry in {retry_after_secs}s")]
|
||||
Unavailable { retry_after_secs: u64 },
|
||||
}
|
||||
|
||||
/// Why a reservation was refused. Carries enough for the caller to build the
|
||||
|
||||
@@ -22,7 +22,7 @@ use axum::http::header::AUTHORIZATION;
|
||||
use axum::http::{HeaderMap, HeaderValue};
|
||||
use axum::middleware::Next;
|
||||
use axum::response::Response;
|
||||
use cortex_core::entitlements::{HEADER_ACCOUNT_ID, HEADER_KEY_ID};
|
||||
use cortex_core::entitlements::{AuthError, HEADER_ACCOUNT_ID, HEADER_KEY_ID};
|
||||
use cortex_core::error_envelope::OpenAiError;
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -83,14 +83,25 @@ pub async fn require_principal(
|
||||
req.extensions_mut().insert(principal);
|
||||
next.run(req).await
|
||||
}
|
||||
// An unrecognized key only hard-fails when auth is *required*.
|
||||
// In allow-anonymous mode (the default) we must IGNORE it and
|
||||
// serve the request unauthenticated — otherwise the placeholder
|
||||
// keys that OpenAI-compatible clients send by default (opencode,
|
||||
// Open WebUI, Agent Zero, litellm) would all break, even though
|
||||
// the operator never opted into auth. Pre-#49 the bearer was
|
||||
// never inspected at all; this preserves that for require_auth=false.
|
||||
Err(_) => {
|
||||
// The entitlement authority is unreachable (upstream client
|
||||
// blip, #57). Fail **closed but distinct**: a transient outage
|
||||
// must not reject a real key as `401 invalid_api_key` — it's a
|
||||
// retryable `503`. This holds regardless of require_auth: we
|
||||
// can't safely serve a key we couldn't authorize.
|
||||
Err(AuthError::Unavailable { retry_after_secs }) => {
|
||||
envelope_response(OpenAiError::service_unavailable(
|
||||
"entitlement authority temporarily unavailable",
|
||||
Some(retry_after_secs),
|
||||
))
|
||||
}
|
||||
// A genuinely unrecognized key only hard-fails when auth is
|
||||
// *required*. In allow-anonymous mode (the default) we IGNORE it
|
||||
// and serve unauthenticated — otherwise the placeholder keys that
|
||||
// OpenAI-compatible clients send by default (opencode, Open WebUI,
|
||||
// Agent Zero, litellm) would all break though the operator never
|
||||
// opted into auth. Pre-#49 the bearer was never inspected; this
|
||||
// preserves that for require_auth=false.
|
||||
Err(AuthError::InvalidKey) => {
|
||||
if fleet.require_auth {
|
||||
unauthorized("invalid API key")
|
||||
} else {
|
||||
|
||||
112
crates/cortex-gateway/src/entitlements_chain.rs
Normal file
@@ -0,0 +1,112 @@
|
||||
//! Chained entitlement provider (#57): operator-local keys first, mesh
|
||||
//! upstream for everything else.
|
||||
//!
|
||||
//! `resolve` tries the [`LocalEntitlementProvider`] (operator + infra keys —
|
||||
//! never a network hop); only a locally-unknown key falls through to
|
||||
//! [`UpstreamEntitlementProvider`]. Because the local provider treats an
|
||||
//! unconfigured principal as uncapped, reserve/settle/release/snapshot must
|
||||
//! **not** blindly hit local — they dispatch to whichever backend resolved
|
||||
//! that account, remembered in a map keyed by `account_id` (populated at
|
||||
//! resolve time).
|
||||
|
||||
use crate::entitlements_local::LocalEntitlementProvider;
|
||||
use crate::entitlements_upstream::UpstreamEntitlementProvider;
|
||||
use async_trait::async_trait;
|
||||
use cortex_core::entitlements::{
|
||||
AuthError, BudgetError, BudgetSnapshot, EntitlementProvider, Principal, Reservation,
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Backend {
|
||||
Local,
|
||||
Upstream,
|
||||
}
|
||||
|
||||
pub struct ChainedEntitlementProvider {
|
||||
local: LocalEntitlementProvider,
|
||||
upstream: UpstreamEntitlementProvider,
|
||||
/// account_id → which backend owns it, learned at resolve time.
|
||||
backends: RwLock<HashMap<String, Backend>>,
|
||||
}
|
||||
|
||||
impl ChainedEntitlementProvider {
|
||||
pub fn new(local: LocalEntitlementProvider, upstream: UpstreamEntitlementProvider) -> Self {
|
||||
Self {
|
||||
local,
|
||||
upstream,
|
||||
backends: RwLock::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn record(&self, account_id: &str, backend: Backend) {
|
||||
self.backends
|
||||
.write()
|
||||
.await
|
||||
.insert(account_id.to_string(), backend);
|
||||
}
|
||||
|
||||
/// The backend that owns `account_id`. Defaults to `Upstream` for an
|
||||
/// account never resolved this process-lifetime (a resolve always
|
||||
/// precedes reserve in a request, so this is just a safe fallback —
|
||||
/// upstream fails closed if the account is bogus).
|
||||
async fn backend_for(&self, account_id: &str) -> Backend {
|
||||
self.backends
|
||||
.read()
|
||||
.await
|
||||
.get(account_id)
|
||||
.copied()
|
||||
.unwrap_or(Backend::Upstream)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl EntitlementProvider for ChainedEntitlementProvider {
|
||||
async fn resolve(&self, api_key: &str) -> Result<Principal, AuthError> {
|
||||
match self.local.resolve(api_key).await {
|
||||
Ok(p) => {
|
||||
self.record(&p.account_id, Backend::Local).await;
|
||||
Ok(p)
|
||||
}
|
||||
Err(AuthError::InvalidKey) => {
|
||||
let p = self.upstream.resolve(api_key).await?;
|
||||
self.record(&p.account_id, Backend::Upstream).await;
|
||||
Ok(p)
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
async fn reserve(
|
||||
&self,
|
||||
principal: &Principal,
|
||||
max_tokens: u64,
|
||||
) -> Result<Reservation, BudgetError> {
|
||||
match self.backend_for(&principal.account_id).await {
|
||||
Backend::Local => self.local.reserve(principal, max_tokens).await,
|
||||
Backend::Upstream => self.upstream.reserve(principal, max_tokens).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn settle(&self, reservation: Reservation, actual_tokens: u64) {
|
||||
match self.backend_for(&reservation.principal.account_id).await {
|
||||
Backend::Local => self.local.settle(reservation, actual_tokens).await,
|
||||
Backend::Upstream => self.upstream.settle(reservation, actual_tokens).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn release(&self, reservation: Reservation) {
|
||||
match self.backend_for(&reservation.principal.account_id).await {
|
||||
Backend::Local => self.local.release(reservation).await,
|
||||
Backend::Upstream => self.upstream.release(reservation).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn snapshot(&self, principal: &Principal) -> Option<BudgetSnapshot> {
|
||||
match self.backend_for(&principal.account_id).await {
|
||||
Backend::Local => self.local.snapshot(principal).await,
|
||||
Backend::Upstream => self.upstream.snapshot(principal).await,
|
||||
}
|
||||
}
|
||||
}
|
||||
246
crates/cortex-gateway/src/entitlements_upstream.rs
Normal file
@@ -0,0 +1,246 @@
|
||||
//! helexa-upstream client (#57): an [`EntitlementProvider`] that resolves
|
||||
//! keys and reserves/settles budget against the mesh authority's
|
||||
//! `/authz/v1` surface (B2). It is "just another impl of the trait" — cortex
|
||||
//! enforcement (`auth.rs`, `metering.rs`) is unchanged.
|
||||
//!
|
||||
//! **Fail closed.** When upstream is unreachable, `resolve` returns
|
||||
//! [`AuthError::Unavailable`] (→ `503`, never `401`) and `reserve` refuses
|
||||
//! with a retryable [`BudgetError::RateLimited`] — a request is never served
|
||||
//! on an un-authorized key, and a real key is never rejected as invalid
|
||||
//! during a blip.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use cortex_core::config::UpstreamClientConfig;
|
||||
use cortex_core::entitlements::{
|
||||
AuthError, BudgetError, BudgetSnapshot, EntitlementProvider, Principal, Reservation,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Retry-After (seconds) advertised when we fail closed on an upstream
|
||||
/// outage.
|
||||
const FAIL_CLOSED_RETRY_SECS: u64 = 5;
|
||||
|
||||
pub struct UpstreamEntitlementProvider {
|
||||
client: reqwest::Client,
|
||||
base_url: String,
|
||||
bearer: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct PrincipalDto {
|
||||
account_id: String,
|
||||
key_id: String,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
struct SnapshotDto {
|
||||
hard_cap: Option<u64>,
|
||||
spent: u64,
|
||||
reserved: u64,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
struct ResolveResp {
|
||||
principal: PrincipalDto,
|
||||
#[allow(dead_code)]
|
||||
snapshot: Option<SnapshotDto>,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
struct ReserveResp {
|
||||
reservation_id: Option<i64>,
|
||||
rejected: Option<Rejection>,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
enum Rejection {
|
||||
InsufficientQuota {
|
||||
requested: u64,
|
||||
available: u64,
|
||||
},
|
||||
RateLimited {
|
||||
requested: u64,
|
||||
available: u64,
|
||||
retry_after_secs: u64,
|
||||
},
|
||||
}
|
||||
|
||||
impl UpstreamEntitlementProvider {
|
||||
pub fn new(cfg: &UpstreamClientConfig) -> Self {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(cfg.timeout_secs))
|
||||
.build()
|
||||
.expect("failed to build upstream HTTP client");
|
||||
Self {
|
||||
client,
|
||||
base_url: cfg.url.trim_end_matches('/').to_string(),
|
||||
bearer: cfg.bearer.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn url(&self, path: &str) -> String {
|
||||
format!("{}{}", self.base_url, path)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl EntitlementProvider for UpstreamEntitlementProvider {
|
||||
async fn resolve(&self, api_key: &str) -> Result<Principal, AuthError> {
|
||||
let resp = self
|
||||
.client
|
||||
.post(self.url("/authz/v1/resolve"))
|
||||
.bearer_auth(&self.bearer)
|
||||
.json(&serde_json::json!({ "api_key": api_key }))
|
||||
.send()
|
||||
.await;
|
||||
let resp = match resp {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "upstream resolve unreachable; failing closed");
|
||||
return Err(AuthError::Unavailable {
|
||||
retry_after_secs: FAIL_CLOSED_RETRY_SECS,
|
||||
});
|
||||
}
|
||||
};
|
||||
if resp.status().as_u16() == 401 {
|
||||
return Err(AuthError::InvalidKey);
|
||||
}
|
||||
if !resp.status().is_success() {
|
||||
return Err(AuthError::Unavailable {
|
||||
retry_after_secs: FAIL_CLOSED_RETRY_SECS,
|
||||
});
|
||||
}
|
||||
match resp.json::<ResolveResp>().await {
|
||||
Ok(r) => Ok(Principal {
|
||||
account_id: r.principal.account_id,
|
||||
key_id: r.principal.key_id,
|
||||
}),
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "upstream resolve: bad body; failing closed");
|
||||
Err(AuthError::Unavailable {
|
||||
retry_after_secs: FAIL_CLOSED_RETRY_SECS,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn reserve(
|
||||
&self,
|
||||
principal: &Principal,
|
||||
max_tokens: u64,
|
||||
) -> Result<Reservation, BudgetError> {
|
||||
let fail_closed = || BudgetError::RateLimited {
|
||||
requested: max_tokens,
|
||||
available: 0,
|
||||
retry_after_secs: FAIL_CLOSED_RETRY_SECS,
|
||||
};
|
||||
let resp = self
|
||||
.client
|
||||
.post(self.url("/authz/v1/reserve"))
|
||||
.bearer_auth(&self.bearer)
|
||||
.json(&serde_json::json!({
|
||||
"account_id": principal.account_id,
|
||||
"key_id": principal.key_id,
|
||||
"max_tokens": max_tokens,
|
||||
}))
|
||||
.send()
|
||||
.await;
|
||||
let resp = match resp {
|
||||
Ok(r) if r.status().is_success() => r,
|
||||
Ok(r) => {
|
||||
tracing::warn!(status = %r.status(), "upstream reserve non-2xx; failing closed");
|
||||
return Err(fail_closed());
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "upstream reserve unreachable; failing closed");
|
||||
return Err(fail_closed());
|
||||
}
|
||||
};
|
||||
match resp.json::<ReserveResp>().await {
|
||||
Ok(ReserveResp {
|
||||
reservation_id: Some(id),
|
||||
..
|
||||
}) => Ok(Reservation {
|
||||
id: id as u64,
|
||||
principal: principal.clone(),
|
||||
reserved: max_tokens,
|
||||
}),
|
||||
Ok(ReserveResp {
|
||||
rejected:
|
||||
Some(Rejection::InsufficientQuota {
|
||||
requested,
|
||||
available,
|
||||
}),
|
||||
..
|
||||
}) => Err(BudgetError::InsufficientQuota {
|
||||
requested,
|
||||
available,
|
||||
}),
|
||||
Ok(ReserveResp {
|
||||
rejected:
|
||||
Some(Rejection::RateLimited {
|
||||
requested,
|
||||
available,
|
||||
retry_after_secs,
|
||||
}),
|
||||
..
|
||||
}) => Err(BudgetError::RateLimited {
|
||||
requested,
|
||||
available,
|
||||
retry_after_secs,
|
||||
}),
|
||||
_ => Err(fail_closed()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn settle(&self, reservation: Reservation, actual_tokens: u64) {
|
||||
// Best-effort; a lost settle is reaped by the upstream sweeper (B2).
|
||||
let _ = self
|
||||
.client
|
||||
.post(self.url("/authz/v1/settle"))
|
||||
.bearer_auth(&self.bearer)
|
||||
.json(&serde_json::json!({
|
||||
"reservation_id": reservation.id as i64,
|
||||
"actual_tokens": actual_tokens,
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.inspect_err(
|
||||
|e| tracing::warn!(error = %e, "upstream settle failed (sweeper will reap)"),
|
||||
);
|
||||
}
|
||||
|
||||
async fn release(&self, reservation: Reservation) {
|
||||
let _ = self
|
||||
.client
|
||||
.post(self.url("/authz/v1/release"))
|
||||
.bearer_auth(&self.bearer)
|
||||
.json(&serde_json::json!({ "reservation_id": reservation.id as i64 }))
|
||||
.send()
|
||||
.await
|
||||
.inspect_err(
|
||||
|e| tracing::warn!(error = %e, "upstream release failed (sweeper will reap)"),
|
||||
);
|
||||
}
|
||||
|
||||
async fn snapshot(&self, principal: &Principal) -> Option<BudgetSnapshot> {
|
||||
let resp = self
|
||||
.client
|
||||
.post(self.url("/authz/v1/snapshot"))
|
||||
.bearer_auth(&self.bearer)
|
||||
.json(&serde_json::json!({
|
||||
"account_id": principal.account_id,
|
||||
"key_id": principal.key_id,
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.ok()?;
|
||||
if !resp.status().is_success() {
|
||||
return None;
|
||||
}
|
||||
let dto = resp.json::<SnapshotDto>().await.ok()?;
|
||||
Some(BudgetSnapshot {
|
||||
hard_cap: dto.hard_cap,
|
||||
spent: dto.spent,
|
||||
reserved: dto.reserved,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -322,7 +322,11 @@ async fn anthropic_messages(
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(guard) => Some(crate::metering::usage_sink(principal, guard)),
|
||||
Ok(guard) => Some(crate::metering::usage_sink(
|
||||
principal,
|
||||
guard,
|
||||
std::sync::Arc::clone(&fleet.served_usage),
|
||||
)),
|
||||
Err(env) => return crate::error::envelope_response(env),
|
||||
}
|
||||
}
|
||||
@@ -802,7 +806,11 @@ async fn proxy_with_metrics(
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(guard) => Some(crate::metering::usage_sink(principal, guard)),
|
||||
Ok(guard) => Some(crate::metering::usage_sink(
|
||||
principal,
|
||||
guard,
|
||||
std::sync::Arc::clone(&fleet.served_usage),
|
||||
)),
|
||||
Err(env) => return crate::error::envelope_response(env),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
pub mod anthropic_sse;
|
||||
pub mod auth;
|
||||
pub mod entitlements_chain;
|
||||
pub mod entitlements_local;
|
||||
pub mod entitlements_upstream;
|
||||
pub mod error;
|
||||
pub mod evictor;
|
||||
pub mod handlers;
|
||||
@@ -9,6 +11,7 @@ pub mod metrics;
|
||||
pub mod poller;
|
||||
pub mod proxy;
|
||||
pub mod router;
|
||||
pub mod served_usage;
|
||||
pub mod state;
|
||||
|
||||
use anyhow::Result;
|
||||
@@ -55,6 +58,28 @@ pub async fn run(config: GatewayConfig) -> Result<()> {
|
||||
evictor::eviction_loop(evictor_fleet).await;
|
||||
});
|
||||
|
||||
// Served-usage reporter (#58): when this operator is part of the mesh,
|
||||
// periodically flush absolute per-principal served-token counters to
|
||||
// upstream for reconciliation.
|
||||
if config.upstream.enabled {
|
||||
let su_fleet = Arc::clone(&fleet);
|
||||
let url = config.upstream.url.clone();
|
||||
let bearer = config.upstream.bearer.clone();
|
||||
let interval =
|
||||
std::time::Duration::from_secs(config.upstream.served_usage_report_interval_secs);
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
tokio::time::sleep(interval).await;
|
||||
let rows = su_fleet.served_usage.snapshot();
|
||||
if let Err(e) =
|
||||
served_usage::report(&su_fleet.http_client, &url, &bearer, &rows).await
|
||||
{
|
||||
tracing::warn!(error = %e, "served-usage report failed (will retry)");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let app = build_app(Arc::clone(&fleet));
|
||||
|
||||
let listen_addr = config.gateway.listen.parse::<std::net::SocketAddr>()?;
|
||||
|
||||
@@ -117,9 +117,21 @@ impl Drop for ReservationGuard {
|
||||
/// Build the completion sink for an authenticated request: record spend and
|
||||
/// settle the reservation with the observed total. Dropping it unused (no
|
||||
/// usage observed) releases the reservation via the guard.
|
||||
pub fn usage_sink(principal: Principal, guard: ReservationGuard) -> UsageSink {
|
||||
pub fn usage_sink(
|
||||
principal: Principal,
|
||||
guard: ReservationGuard,
|
||||
served_usage: std::sync::Arc<crate::served_usage::ServedUsage>,
|
||||
) -> UsageSink {
|
||||
Box::new(move |prompt, completion| {
|
||||
record_spend(&principal, prompt, completion);
|
||||
// Per-principal served-usage tally for #58 reconciliation. Recorded
|
||||
// for every metered (authenticated) request; the flush task reports
|
||||
// it to upstream when the operator is part of the mesh.
|
||||
served_usage.add(
|
||||
&principal.account_id,
|
||||
&principal.key_id,
|
||||
prompt + completion,
|
||||
);
|
||||
guard.settle(prompt + completion);
|
||||
})
|
||||
}
|
||||
|
||||
105
crates/cortex-gateway/src/served_usage.rs
Normal file
@@ -0,0 +1,105 @@
|
||||
//! Served-usage ledger (#58): cortex meters, per principal and per UTC day,
|
||||
//! the tokens it has served on behalf of mesh accounts, and periodically
|
||||
//! reports **absolute** cumulative counters to helexa-upstream for
|
||||
//! reconciliation (operators are compensated for served tokens).
|
||||
//!
|
||||
//! Counters are cumulative-since-process-start for the current period;
|
||||
//! upstream upserts them monotonically (GREATEST), so re-sending the same
|
||||
//! value is idempotent and a flush that races another is harmless. (A
|
||||
//! process restart resets the in-memory counter; the monotonic upsert keeps
|
||||
//! upstream from regressing — at most it under-counts the restarted window,
|
||||
//! acceptable for beta. One cortex per operator token is assumed.)
|
||||
|
||||
use serde::Serialize;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Mutex;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
|
||||
pub struct ServedRow {
|
||||
pub account_id: String,
|
||||
pub key_id: String,
|
||||
pub period: String, // YYYY-MM-DD (UTC)
|
||||
pub served_tokens: u64,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct ServedUsage {
|
||||
inner: Mutex<HashMap<(String, String, String), u64>>,
|
||||
}
|
||||
|
||||
impl ServedUsage {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Add served tokens for a principal in today's (UTC) period.
|
||||
pub fn add(&self, account_id: &str, key_id: &str, tokens: u64) {
|
||||
if tokens == 0 {
|
||||
return;
|
||||
}
|
||||
let period = chrono::Utc::now().format("%Y-%m-%d").to_string();
|
||||
let mut m = self.inner.lock().expect("served-usage lock");
|
||||
*m.entry((account_id.to_string(), key_id.to_string(), period))
|
||||
.or_insert(0) += tokens;
|
||||
}
|
||||
|
||||
/// Absolute cumulative counters, for a flush to upstream.
|
||||
pub fn snapshot(&self) -> Vec<ServedRow> {
|
||||
let m = self.inner.lock().expect("served-usage lock");
|
||||
m.iter()
|
||||
.map(|((account_id, key_id, period), &served_tokens)| ServedRow {
|
||||
account_id: account_id.clone(),
|
||||
key_id: key_id.clone(),
|
||||
period: period.clone(),
|
||||
served_tokens,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// POST the absolute counters to upstream's `/authz/v1/served-usage`.
|
||||
pub async fn report(
|
||||
client: &reqwest::Client,
|
||||
base_url: &str,
|
||||
bearer: &str,
|
||||
rows: &[ServedRow],
|
||||
) -> Result<(), reqwest::Error> {
|
||||
if rows.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let url = format!("{}/authz/v1/served-usage", base_url.trim_end_matches('/'));
|
||||
client
|
||||
.post(url)
|
||||
.bearer_auth(bearer)
|
||||
.json(&serde_json::json!({ "rows": rows }))
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn accumulates_per_principal_and_period() {
|
||||
let su = ServedUsage::new();
|
||||
su.add("acct", "key", 10);
|
||||
su.add("acct", "key", 5);
|
||||
su.add("acct", "other", 7);
|
||||
su.add("acct", "key", 0); // no-op
|
||||
let mut rows = su.snapshot();
|
||||
rows.sort_by(|a, b| a.key_id.cmp(&b.key_id));
|
||||
assert_eq!(rows.len(), 2);
|
||||
let key_row = rows.iter().find(|r| r.key_id == "key").unwrap();
|
||||
assert_eq!(key_row.served_tokens, 15);
|
||||
assert_eq!(
|
||||
rows.iter()
|
||||
.find(|r| r.key_id == "other")
|
||||
.unwrap()
|
||||
.served_tokens,
|
||||
7
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
use crate::entitlements_chain::ChainedEntitlementProvider;
|
||||
use crate::entitlements_local::LocalEntitlementProvider;
|
||||
use crate::entitlements_upstream::UpstreamEntitlementProvider;
|
||||
use cortex_core::catalogue::ModelCatalogue;
|
||||
use cortex_core::config::{EvictionSettings, GatewayConfig, NeuronEndpoint};
|
||||
use cortex_core::entitlements::EntitlementProvider;
|
||||
@@ -20,6 +22,9 @@ pub struct CortexState {
|
||||
/// Whether to reject unauthenticated requests (#49). Read by the auth
|
||||
/// middleware once it lands.
|
||||
pub require_auth: bool,
|
||||
/// Per-principal served-token tally (#58), reported to upstream for
|
||||
/// operator reconciliation by the flush task when upstream is enabled.
|
||||
pub served_usage: Arc<crate::served_usage::ServedUsage>,
|
||||
}
|
||||
|
||||
impl CortexState {
|
||||
@@ -45,8 +50,20 @@ impl CortexState {
|
||||
|
||||
let catalogue = ModelCatalogue::load(&config.models_config);
|
||||
|
||||
let entitlements: Arc<dyn EntitlementProvider> =
|
||||
Arc::new(LocalEntitlementProvider::from_config(&config.entitlements));
|
||||
// Local provider always handles operator + infra keys. When the
|
||||
// upstream client is enabled (#57), wrap it in the chain so locally
|
||||
// unknown keys fall through to the mesh authority; otherwise stay
|
||||
// purely local.
|
||||
let local = LocalEntitlementProvider::from_config(&config.entitlements);
|
||||
let entitlements: Arc<dyn EntitlementProvider> = if config.upstream.enabled {
|
||||
tracing::info!(url = %config.upstream.url, "upstream entitlement client enabled");
|
||||
Arc::new(ChainedEntitlementProvider::new(
|
||||
local,
|
||||
UpstreamEntitlementProvider::new(&config.upstream),
|
||||
))
|
||||
} else {
|
||||
Arc::new(local)
|
||||
};
|
||||
|
||||
Self {
|
||||
nodes: RwLock::new(nodes),
|
||||
@@ -59,6 +76,7 @@ impl CortexState {
|
||||
.expect("failed to build HTTP client"),
|
||||
entitlements,
|
||||
require_auth: config.entitlements.require_auth,
|
||||
served_usage: Arc::new(crate::served_usage::ServedUsage::new()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,6 +57,7 @@ async fn test_alias_resolves_in_chat_completions() {
|
||||
}],
|
||||
models_config: models_path.to_string_lossy().to_string(),
|
||||
entitlements: Default::default(),
|
||||
upstream: Default::default(),
|
||||
};
|
||||
|
||||
let fleet = Arc::new(CortexState::from_config(&config));
|
||||
@@ -143,6 +144,7 @@ async fn test_aliases_surface_in_v1_models() {
|
||||
}],
|
||||
models_config: models_path.to_string_lossy().to_string(),
|
||||
entitlements: Default::default(),
|
||||
upstream: Default::default(),
|
||||
};
|
||||
|
||||
let fleet = Arc::new(CortexState::from_config(&config));
|
||||
@@ -232,6 +234,7 @@ async fn test_alias_falls_through_for_unmapped_model() {
|
||||
}],
|
||||
models_config: models_path.to_string_lossy().to_string(),
|
||||
entitlements: Default::default(),
|
||||
upstream: Default::default(),
|
||||
};
|
||||
|
||||
let fleet = Arc::new(CortexState::from_config(&config));
|
||||
|
||||
@@ -105,6 +105,7 @@ async fn spawn_gateway(neuron_url: &str, entitlements: EntitlementsConfig) -> St
|
||||
}],
|
||||
models_config: "/dev/null".into(),
|
||||
entitlements,
|
||||
upstream: Default::default(),
|
||||
};
|
||||
|
||||
let fleet = Arc::new(CortexState::from_config(&config));
|
||||
|
||||
@@ -81,6 +81,7 @@ async fn spawn_gateway(neuron_url: &str, key: ApiKeyConfig) -> (Arc<CortexState>
|
||||
require_auth: true,
|
||||
keys: vec![key],
|
||||
},
|
||||
upstream: Default::default(),
|
||||
};
|
||||
let fleet = Arc::new(CortexState::from_config(&config));
|
||||
{
|
||||
|
||||
@@ -430,6 +430,7 @@ pub async fn spawn_gateway_with_state(mock_url: &str) -> (Arc<CortexState>, Stri
|
||||
}],
|
||||
models_config: "/dev/null".into(),
|
||||
entitlements: Default::default(),
|
||||
upstream: Default::default(),
|
||||
};
|
||||
|
||||
let fleet = Arc::new(CortexState::from_config(&config));
|
||||
|
||||
@@ -89,6 +89,7 @@ async fn error_response_no_healthy_nodes() {
|
||||
}],
|
||||
models_config: "/dev/null".into(),
|
||||
entitlements: Default::default(),
|
||||
upstream: Default::default(),
|
||||
};
|
||||
|
||||
let fleet = Arc::new(cortex_gateway::state::CortexState::from_config(&config));
|
||||
|
||||
@@ -72,6 +72,7 @@ fn make_fleet(endpoint: &str, defrag_after: u32) -> Arc<CortexState> {
|
||||
}],
|
||||
models_config: "/dev/null".into(),
|
||||
entitlements: Default::default(),
|
||||
upstream: Default::default(),
|
||||
};
|
||||
Arc::new(CortexState::from_config(&config))
|
||||
}
|
||||
|
||||
@@ -74,6 +74,7 @@ async fn fleet_with(big_healthy: bool, big_devices: usize) -> Arc<CortexState> {
|
||||
],
|
||||
models_config: cat.to_string_lossy().into_owned(),
|
||||
entitlements: Default::default(),
|
||||
upstream: Default::default(),
|
||||
};
|
||||
let fleet = Arc::new(CortexState::from_config(&config));
|
||||
{
|
||||
|
||||
@@ -72,6 +72,7 @@ async fn two_neuron_fleet(endpoint_a: &str, endpoint_b: &str) -> Arc<CortexState
|
||||
],
|
||||
models_config: "/dev/null".into(),
|
||||
entitlements: Default::default(),
|
||||
upstream: Default::default(),
|
||||
};
|
||||
Arc::new(CortexState::from_config(&config))
|
||||
}
|
||||
|
||||
@@ -53,6 +53,7 @@ async fn spawn_metered_gateway(neuron_url: &str) -> (Arc<CortexState>, String) {
|
||||
window: CapWindow::Balance,
|
||||
}],
|
||||
},
|
||||
upstream: Default::default(),
|
||||
};
|
||||
|
||||
let fleet = Arc::new(CortexState::from_config(&config));
|
||||
@@ -158,6 +159,7 @@ async fn anonymous_request_records_no_spend() {
|
||||
}],
|
||||
models_config: "/dev/null".into(),
|
||||
entitlements: EntitlementsConfig::default(),
|
||||
upstream: Default::default(),
|
||||
};
|
||||
let fleet = Arc::new(CortexState::from_config(&config));
|
||||
{
|
||||
|
||||
@@ -66,6 +66,7 @@ harness = "candle"
|
||||
}],
|
||||
models_config: cat_path.to_string_lossy().into_owned(),
|
||||
entitlements: Default::default(),
|
||||
upstream: Default::default(),
|
||||
};
|
||||
|
||||
let fleet = Arc::new(CortexState::from_config(&config));
|
||||
|
||||
@@ -55,6 +55,7 @@ capabilities = ["text"]
|
||||
}],
|
||||
models_config: cat_path.to_string_lossy().into_owned(),
|
||||
entitlements: Default::default(),
|
||||
upstream: Default::default(),
|
||||
};
|
||||
|
||||
let fleet = Arc::new(CortexState::from_config(&config));
|
||||
|
||||
@@ -32,6 +32,7 @@ async fn test_poller_discovers_models() {
|
||||
}],
|
||||
models_config: "/dev/null".into(),
|
||||
entitlements: Default::default(),
|
||||
upstream: Default::default(),
|
||||
};
|
||||
|
||||
let fleet = Arc::new(CortexState::from_config(&config));
|
||||
@@ -84,6 +85,7 @@ async fn test_poller_updates_gateway_models_endpoint() {
|
||||
}],
|
||||
models_config: "/dev/null".into(),
|
||||
entitlements: Default::default(),
|
||||
upstream: Default::default(),
|
||||
};
|
||||
|
||||
let fleet = Arc::new(CortexState::from_config(&config));
|
||||
@@ -156,6 +158,7 @@ async fn test_models_endpoint_unions_capabilities_across_nodes() {
|
||||
],
|
||||
models_config: "/dev/null".into(),
|
||||
entitlements: Default::default(),
|
||||
upstream: Default::default(),
|
||||
};
|
||||
|
||||
let fleet = Arc::new(CortexState::from_config(&config));
|
||||
@@ -219,6 +222,7 @@ async fn test_poller_marks_unreachable_node_unhealthy() {
|
||||
}],
|
||||
models_config: "/dev/null".into(),
|
||||
entitlements: Default::default(),
|
||||
upstream: Default::default(),
|
||||
};
|
||||
|
||||
let fleet = Arc::new(CortexState::from_config(&config));
|
||||
@@ -273,6 +277,7 @@ async fn test_poller_removes_stale_models() {
|
||||
}],
|
||||
models_config: "/dev/null".into(),
|
||||
entitlements: Default::default(),
|
||||
upstream: Default::default(),
|
||||
};
|
||||
|
||||
let fleet = Arc::new(CortexState::from_config(&config));
|
||||
@@ -304,6 +309,7 @@ async fn test_poller_removes_stale_models() {
|
||||
}],
|
||||
models_config: "/dev/null".into(),
|
||||
entitlements: Default::default(),
|
||||
upstream: Default::default(),
|
||||
};
|
||||
|
||||
let fleet2 = Arc::new(CortexState::from_config(&config2));
|
||||
@@ -386,6 +392,7 @@ async fn test_poller_captures_activation_from_health() {
|
||||
}],
|
||||
models_config: "/dev/null".into(),
|
||||
entitlements: Default::default(),
|
||||
upstream: Default::default(),
|
||||
};
|
||||
|
||||
let fleet = Arc::new(CortexState::from_config(&config));
|
||||
@@ -431,6 +438,7 @@ async fn test_poller_parses_recovering_status() {
|
||||
}],
|
||||
models_config: "/dev/null".into(),
|
||||
entitlements: Default::default(),
|
||||
upstream: Default::default(),
|
||||
};
|
||||
|
||||
let fleet = Arc::new(CortexState::from_config(&config));
|
||||
|
||||
@@ -75,6 +75,7 @@ async fn spawn_gateway(neuron: &str, context: usize) -> String {
|
||||
}],
|
||||
models_config: "/dev/null".into(),
|
||||
entitlements: Default::default(),
|
||||
upstream: Default::default(),
|
||||
};
|
||||
let fleet = Arc::new(CortexState::from_config(&config));
|
||||
{
|
||||
|
||||
@@ -118,6 +118,7 @@ async fn test_no_healthy_nodes() {
|
||||
}],
|
||||
models_config: "/dev/null".into(),
|
||||
entitlements: Default::default(),
|
||||
upstream: Default::default(),
|
||||
};
|
||||
let fleet = std::sync::Arc::new(cortex_gateway::state::CortexState::from_config(&config));
|
||||
|
||||
|
||||
106
crates/cortex-gateway/tests/upstream_chain.rs
Normal file
@@ -0,0 +1,106 @@
|
||||
//! B3: the chained entitlement provider (local → upstream) and fail-closed
|
||||
//! semantics, exercised against a mock helexa-upstream `/authz/v1`.
|
||||
|
||||
use axum::{Json, Router, routing::post};
|
||||
use cortex_core::config::{ApiKeyConfig, EntitlementsConfig, UpstreamClientConfig};
|
||||
use cortex_core::entitlements::{AuthError, EntitlementProvider};
|
||||
use cortex_gateway::entitlements_chain::ChainedEntitlementProvider;
|
||||
use cortex_gateway::entitlements_local::LocalEntitlementProvider;
|
||||
use cortex_gateway::entitlements_upstream::UpstreamEntitlementProvider;
|
||||
use serde_json::{Value, json};
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
/// Mock upstream: `mesh-key` resolves to a mesh account; anything else 401.
|
||||
/// reserve always grants reservation 1.
|
||||
async fn spawn_mock_upstream() -> String {
|
||||
async fn resolve(Json(body): Json<Value>) -> axum::response::Response {
|
||||
use axum::response::IntoResponse;
|
||||
if body["api_key"] == "mesh-key" {
|
||||
Json(json!({"principal": {"account_id": "mesh-acct", "key_id": "mesh-key-1"}}))
|
||||
.into_response()
|
||||
} else {
|
||||
(
|
||||
axum::http::StatusCode::UNAUTHORIZED,
|
||||
Json(json!({"error": {"code": "invalid_api_key"}})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
async fn reserve() -> Json<Value> {
|
||||
Json(json!({ "reservation_id": 1 }))
|
||||
}
|
||||
let app = Router::new()
|
||||
.route("/authz/v1/resolve", post(resolve))
|
||||
.route("/authz/v1/reserve", post(reserve));
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
tokio::spawn(async move {
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
});
|
||||
format!("http://{addr}")
|
||||
}
|
||||
|
||||
fn local_with_key() -> LocalEntitlementProvider {
|
||||
let cfg = EntitlementsConfig {
|
||||
require_auth: false,
|
||||
keys: vec![ApiKeyConfig {
|
||||
key: "local-key".into(),
|
||||
account_id: "op".into(),
|
||||
key_id: None,
|
||||
hard_cap: None,
|
||||
window: Default::default(),
|
||||
}],
|
||||
};
|
||||
LocalEntitlementProvider::from_config(&cfg)
|
||||
}
|
||||
|
||||
fn chain(local: LocalEntitlementProvider, url: &str) -> ChainedEntitlementProvider {
|
||||
let upstream = UpstreamEntitlementProvider::new(&UpstreamClientConfig {
|
||||
enabled: true,
|
||||
url: url.to_string(),
|
||||
bearer: "client-secret".into(),
|
||||
timeout_secs: 5,
|
||||
served_usage_report_interval_secs: 60,
|
||||
});
|
||||
ChainedEntitlementProvider::new(local, upstream)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn local_key_resolves_locally() {
|
||||
let url = spawn_mock_upstream().await;
|
||||
let c = chain(local_with_key(), &url);
|
||||
let p = c.resolve("local-key").await.expect("local resolves");
|
||||
assert_eq!(p.account_id, "op");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unknown_key_falls_through_to_upstream() {
|
||||
let url = spawn_mock_upstream().await;
|
||||
let c = chain(local_with_key(), &url);
|
||||
let p = c.resolve("mesh-key").await.expect("upstream resolves");
|
||||
assert_eq!(p.account_id, "mesh-acct");
|
||||
assert_eq!(p.key_id, "mesh-key-1");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unknown_everywhere_is_invalid_key() {
|
||||
let url = spawn_mock_upstream().await;
|
||||
let c = chain(local_with_key(), &url);
|
||||
match c.resolve("nope").await {
|
||||
Err(AuthError::InvalidKey) => {}
|
||||
other => panic!("expected InvalidKey, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn upstream_unreachable_fails_closed_as_unavailable() {
|
||||
// No mock — point at a dead port. A locally-unknown key must surface
|
||||
// Unavailable (→ 503), never InvalidKey (→ 401).
|
||||
let c = chain(local_with_key(), "http://127.0.0.1:1");
|
||||
match c.resolve("some-mesh-key").await {
|
||||
Err(AuthError::Unavailable { retry_after_secs }) => assert!(retry_after_secs > 0),
|
||||
other => panic!("expected Unavailable, got {other:?}"),
|
||||
}
|
||||
// A local key still resolves without touching upstream.
|
||||
assert_eq!(c.resolve("local-key").await.unwrap().account_id, "op");
|
||||
}
|
||||
@@ -22,7 +22,7 @@ use axum::http::{StatusCode, header};
|
||||
use axum::middleware::Next;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::routing::post;
|
||||
use axum::{Json, Router};
|
||||
use axum::{Extension, Json, Router};
|
||||
use cortex_core::error_envelope::OpenAiError;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use subtle::ConstantTimeEq;
|
||||
@@ -41,6 +41,7 @@ pub fn router(state: &AppState) -> Router<AppState> {
|
||||
.route("/authz/v1/settle", post(settle))
|
||||
.route("/authz/v1/release", post(release))
|
||||
.route("/authz/v1/snapshot", post(snapshot))
|
||||
.route("/authz/v1/served-usage", post(served_usage))
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
client_auth,
|
||||
@@ -274,6 +275,61 @@ async fn snapshot(State(state): State<AppState>, Json(req): Json<SnapshotReq>) -
|
||||
}
|
||||
}
|
||||
|
||||
// ── served-usage report (#58) ───────────────────────────────────────
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ServedUsageReport {
|
||||
rows: Vec<ServedUsageRow>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ServedUsageRow {
|
||||
account_id: String,
|
||||
key_id: String,
|
||||
period: String, // YYYY-MM-DD
|
||||
served_tokens: i64,
|
||||
}
|
||||
|
||||
/// `POST /authz/v1/served-usage` — a cortex reports the absolute served-token
|
||||
/// counters it has accrued for the current period. Upsert is monotonic
|
||||
/// (`GREATEST`) so re-sends and races are idempotent and never regress.
|
||||
/// `operator_id` comes from the validated client bearer (request extension).
|
||||
async fn served_usage(
|
||||
State(state): State<AppState>,
|
||||
Extension(operator): Extension<OperatorId>,
|
||||
Json(req): Json<ServedUsageReport>,
|
||||
) -> Response {
|
||||
for row in &req.rows {
|
||||
let (Ok(account_id), Ok(key_id)) = (
|
||||
Uuid::parse_str(&row.account_id),
|
||||
Uuid::parse_str(&row.key_id),
|
||||
) else {
|
||||
continue; // skip malformed ids rather than fail the whole batch
|
||||
};
|
||||
let Ok(period) = chrono::NaiveDate::parse_from_str(&row.period, "%Y-%m-%d") else {
|
||||
continue;
|
||||
};
|
||||
let res = sqlx::query(
|
||||
"INSERT INTO served_usage (operator_id, account_id, key_id, period, served_tokens) \
|
||||
VALUES ($1, $2, $3, $4, $5) \
|
||||
ON CONFLICT (operator_id, account_id, key_id, period) \
|
||||
DO UPDATE SET served_tokens = GREATEST(served_usage.served_tokens, EXCLUDED.served_tokens)",
|
||||
)
|
||||
.bind(&operator.0)
|
||||
.bind(account_id)
|
||||
.bind(key_id)
|
||||
.bind(period)
|
||||
.bind(row.served_tokens.max(0))
|
||||
.execute(&state.pool)
|
||||
.await;
|
||||
if let Err(e) = res {
|
||||
tracing::error!(error = %e, "served-usage upsert failed");
|
||||
return envelope_response(OpenAiError::service_unavailable("authority error", Some(5)));
|
||||
}
|
||||
}
|
||||
StatusCode::NO_CONTENT.into_response()
|
||||
}
|
||||
|
||||
fn bad_request(msg: &str) -> Response {
|
||||
envelope_response(OpenAiError::new(
|
||||
400,
|
||||
|
||||
@@ -20,7 +20,9 @@ pub mod email;
|
||||
pub mod error;
|
||||
pub mod handlers;
|
||||
pub mod ledger;
|
||||
pub mod reconcile;
|
||||
pub mod state;
|
||||
pub mod topup;
|
||||
pub mod web;
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
@@ -20,6 +20,28 @@ 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>,
|
||||
},
|
||||
/// Roll up not-yet-reconciled served usage per operator/period (#58),
|
||||
/// stamp it reconciled, and print the totals. Payout is out of scope.
|
||||
Reconcile {
|
||||
#[arg(short, long, default_value = "helexa-upstream.toml")]
|
||||
config: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
@@ -40,6 +62,37 @@ 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}");
|
||||
}
|
||||
}
|
||||
Commands::Reconcile { config } => {
|
||||
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 rollup = helexa_upstream::reconcile::reconcile(&pool).await?;
|
||||
for r in &rollup {
|
||||
println!("{}\t{}\t{}", r.operator_id, r.period, r.total_served_tokens);
|
||||
}
|
||||
tracing::info!(operators_periods = rollup.len(), "reconciliation complete");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
||||
43
crates/helexa-upstream/src/reconcile.rs
Normal file
@@ -0,0 +1,43 @@
|
||||
//! Reconciliation rollup (#58): aggregate the served-usage ledger per
|
||||
//! operator and period for operator compensation, stamping rows
|
||||
//! `reconciled_at` so each window is settled once. The payout mechanism
|
||||
//! itself is out of scope — this produces the authoritative per-operator
|
||||
//! totals a settlement process consumes.
|
||||
|
||||
use sqlx::Row;
|
||||
use sqlx::postgres::PgPool;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RollupRow {
|
||||
pub operator_id: String,
|
||||
pub period: chrono::NaiveDate,
|
||||
pub total_served_tokens: i64,
|
||||
}
|
||||
|
||||
/// Roll up all not-yet-reconciled served-usage into per-(operator, period)
|
||||
/// totals, then stamp those rows `reconciled_at`. Returns the rollup.
|
||||
/// Idempotent: a second run finds nothing unreconciled and returns empty.
|
||||
pub async fn reconcile(pool: &PgPool) -> Result<Vec<RollupRow>, sqlx::Error> {
|
||||
let mut tx = pool.begin().await?;
|
||||
let rows = sqlx::query(
|
||||
// SUM(bigint) is numeric in Postgres — cast back to bigint for i64.
|
||||
"SELECT operator_id, period, SUM(served_tokens)::bigint AS total \
|
||||
FROM served_usage WHERE reconciled_at IS NULL \
|
||||
GROUP BY operator_id, period ORDER BY operator_id, period",
|
||||
)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?;
|
||||
let rollup: Vec<RollupRow> = rows
|
||||
.iter()
|
||||
.map(|r| RollupRow {
|
||||
operator_id: r.get("operator_id"),
|
||||
period: r.get("period"),
|
||||
total_served_tokens: r.get::<i64, _>("total"),
|
||||
})
|
||||
.collect();
|
||||
sqlx::query("UPDATE served_usage SET reconciled_at = now() WHERE reconciled_at IS NULL")
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
Ok(rollup)
|
||||
}
|
||||
82
crates/helexa-upstream/src/topup.rs
Normal 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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
116
crates/helexa-upstream/tests/served_usage_pg.rs
Normal file
@@ -0,0 +1,116 @@
|
||||
//! Integration test for the served-usage report (#58): the idempotent,
|
||||
//! monotonic upsert and the reconcile rollup. Gated on
|
||||
//! UPSTREAM_TEST_DATABASE_URL (skips cleanly when unset).
|
||||
|
||||
use helexa_upstream::config::{ClientToken, UpstreamConfig};
|
||||
use helexa_upstream::db::connect_and_migrate;
|
||||
use helexa_upstream::email::EmailSender;
|
||||
use helexa_upstream::reconcile::reconcile;
|
||||
use helexa_upstream::state::AppState;
|
||||
use serde_json::{Value, json};
|
||||
use sqlx::Row;
|
||||
use sqlx::postgres::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
const CLIENT_TOKEN: &str = "su-test-token";
|
||||
const OPERATOR: &str = "op-su-test";
|
||||
|
||||
async fn spawn_or_skip(test: &str) -> Option<(String, PgPool)> {
|
||||
let Ok(url) = std::env::var("UPSTREAM_TEST_DATABASE_URL") else {
|
||||
eprintln!("skipping {test}: UPSTREAM_TEST_DATABASE_URL not set");
|
||||
return None;
|
||||
};
|
||||
let pool = connect_and_migrate(&url, 16).await.expect("migrate");
|
||||
let mut config = UpstreamConfig {
|
||||
server: Default::default(),
|
||||
db: helexa_upstream::config::DbSettings {
|
||||
url,
|
||||
max_connections: 16,
|
||||
},
|
||||
grant: Default::default(),
|
||||
abuse: Default::default(),
|
||||
client_auth: Default::default(),
|
||||
authz: Default::default(),
|
||||
auth: Default::default(),
|
||||
email: Default::default(),
|
||||
};
|
||||
config.client_auth.tokens.push(ClientToken {
|
||||
token: CLIENT_TOKEN.into(),
|
||||
operator_id: OPERATOR.into(),
|
||||
});
|
||||
let email = EmailSender::from_config(&config.email).unwrap();
|
||||
let state = AppState::new(pool.clone(), config, email);
|
||||
let app = helexa_upstream::build_app(state);
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
tokio::spawn(async move {
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
});
|
||||
Some((format!("http://{addr}"), pool))
|
||||
}
|
||||
|
||||
async fn report(base: &str, rows: Value) -> u16 {
|
||||
reqwest::Client::new()
|
||||
.post(format!("{base}/authz/v1/served-usage"))
|
||||
.bearer_auth(CLIENT_TOKEN)
|
||||
.json(&json!({ "rows": rows }))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.status()
|
||||
.as_u16()
|
||||
}
|
||||
|
||||
async fn stored(pool: &PgPool, account: Uuid, key: Uuid) -> i64 {
|
||||
sqlx::query(
|
||||
"SELECT served_tokens FROM served_usage WHERE operator_id = $1 AND account_id = $2 AND key_id = $3",
|
||||
)
|
||||
.bind(OPERATOR)
|
||||
.bind(account)
|
||||
.bind(key)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.unwrap()
|
||||
.get("served_tokens")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn served_usage_upsert_is_monotonic_and_reconciles() {
|
||||
let Some((base, pool)) = spawn_or_skip("served_usage_upsert_is_monotonic_and_reconciles").await
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let account = Uuid::new_v4();
|
||||
let key = Uuid::new_v4();
|
||||
let period = "2026-06-23";
|
||||
let row = |n: i64| json!([{"account_id": account, "key_id": key, "period": period, "served_tokens": n}]);
|
||||
|
||||
// First report.
|
||||
assert_eq!(report(&base, row(100)).await, 204);
|
||||
assert_eq!(stored(&pool, account, key).await, 100);
|
||||
|
||||
// Re-send a higher absolute value → advances.
|
||||
assert_eq!(report(&base, row(250)).await, 204);
|
||||
assert_eq!(stored(&pool, account, key).await, 250);
|
||||
|
||||
// A lower value (e.g. a restarted cortex) must NOT regress (GREATEST).
|
||||
assert_eq!(report(&base, row(50)).await, 204);
|
||||
assert_eq!(stored(&pool, account, key).await, 250);
|
||||
|
||||
// Re-sending the same value is idempotent.
|
||||
assert_eq!(report(&base, row(250)).await, 204);
|
||||
assert_eq!(stored(&pool, account, key).await, 250);
|
||||
|
||||
// Reconcile rolls it up and stamps reconciled_at; a second run is empty.
|
||||
let rollup = reconcile(&pool).await.unwrap();
|
||||
let mine = rollup
|
||||
.iter()
|
||||
.find(|r| r.operator_id == OPERATOR)
|
||||
.expect("operator in rollup");
|
||||
assert!(mine.total_served_tokens >= 250);
|
||||
let again = reconcile(&pool).await.unwrap();
|
||||
assert!(
|
||||
again.iter().all(|r| r.operator_id != OPERATOR),
|
||||
"already reconciled"
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
6
data/helexa-upstream-firewalld.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<service>
|
||||
<short>helexa-upstream</short>
|
||||
<description>helexa-upstream — mesh account + budget authority API (/authz/v1 for cortex, /web/v1 for the frontend)</description>
|
||||
<port protocol="tcp" port="8090"/>
|
||||
</service>
|
||||
3
data/helexa-upstream-sysusers.conf
Normal file
@@ -0,0 +1,3 @@
|
||||
g helexa-upstream - -
|
||||
u helexa-upstream - "helexa-upstream authority" /var/lib/helexa-upstream /sbin/nologin
|
||||
m helexa-upstream helexa-upstream
|
||||
22
data/helexa-upstream.service
Normal file
@@ -0,0 +1,22 @@
|
||||
[Unit]
|
||||
Description=helexa-upstream — mesh account + budget authority (accounts, API keys, allocation ledger, top-up codes)
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/usr/bin/helexa-upstream serve --config /etc/helexa-upstream/helexa-upstream.toml
|
||||
# HTTP authority for cortex (/authz/v1) and the frontend (/web/v1); restart
|
||||
# unconditionally if it ever exits. It connects out to PostgreSQL (the
|
||||
# system of record) and runs schema migrations on startup.
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
User=helexa-upstream
|
||||
Group=helexa-upstream
|
||||
# Service user home; no local state (PostgreSQL holds everything), but
|
||||
# StateDirectory gives the user a writable, correctly-owned home.
|
||||
StateDirectory=helexa-upstream
|
||||
StateDirectoryMode=0755
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -32,3 +32,22 @@ F0 scaffold. Theming + i18n (33 languages, usage-ordered selector), the
|
||||
`/mission` page, the chat workspace (Dexie + streaming), and the account
|
||||
dashboard land in subsequent phases — see
|
||||
`~/.claude/plans/we-need-to-plan-modular-graham.md`.
|
||||
|
||||
## Deploy (public beta)
|
||||
|
||||
Build the SPA and serve it from edge nginx on the **same origin** as the
|
||||
two backends — so the browser makes no cross-origin request (no CORS) and
|
||||
the user's API key rides as a first-party bearer.
|
||||
|
||||
```sh
|
||||
npm ci && npm run build # → dist/
|
||||
sudo cp -r dist/* /var/www/helexa.ai/
|
||||
sudo cp deploy/nginx.conf /etc/nginx/conf.d/helexa.ai.conf # adjust upstreams + TLS
|
||||
sudo nginx -t && sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
`deploy/nginx.conf` routes `/` → SPA (history fallback), `/v1` + `/health`
|
||||
→ helexa-router, and `/api/` → helexa-upstream `/web/v1/`. Set
|
||||
`VITE_PUBLIC_BETA=true` at build time for the beta banner. There is **no
|
||||
server-side chat history**: conversations live only in the browser
|
||||
(IndexedDB).
|
||||
|
||||
60
helexa.ai/deploy/nginx.conf
Normal file
@@ -0,0 +1,60 @@
|
||||
# helexa.ai — edge nginx for the public beta.
|
||||
#
|
||||
# Serves the built SPA (helexa.ai/dist) and reverse-proxies the two
|
||||
# backends on the SAME ORIGIN, so the browser never makes a cross-origin
|
||||
# request: no CORS, and the user's API key rides as a first-party bearer.
|
||||
#
|
||||
# / → static SPA (history fallback to index.html)
|
||||
# /v1, /health → helexa-router (OpenAI-compatible inference data-plane)
|
||||
# /api/ → helexa-upstream /web/v1/ (account control-plane)
|
||||
#
|
||||
# TLS is terminated here (certs omitted — wire up certbot / your CA). The
|
||||
# upstream hosts below are examples; point them at your router/upstream.
|
||||
|
||||
upstream helexa_router { server 127.0.0.1:8088; }
|
||||
upstream helexa_upstream { server 127.0.0.1:8090; }
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
listen [::]:443 ssl http2;
|
||||
server_name helexa.ai;
|
||||
|
||||
# ssl_certificate /etc/letsencrypt/live/helexa.ai/fullchain.pem;
|
||||
# ssl_certificate_key /etc/letsencrypt/live/helexa.ai/privkey.pem;
|
||||
|
||||
root /var/www/helexa.ai;
|
||||
index index.html;
|
||||
|
||||
# Long-cache fingerprinted assets; never cache the HTML shell.
|
||||
location /assets/ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# Inference data-plane → router. Streaming (SSE): disable buffering so
|
||||
# tokens reach the browser as they arrive.
|
||||
location /v1/ {
|
||||
proxy_pass http://helexa_router;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header Connection "";
|
||||
proxy_buffering off;
|
||||
proxy_read_timeout 300s;
|
||||
}
|
||||
location = /health {
|
||||
proxy_pass http://helexa_router;
|
||||
}
|
||||
|
||||
# Account control-plane → upstream /web/v1/ (strip the /api prefix).
|
||||
location /api/ {
|
||||
proxy_pass http://helexa_upstream/web/v1/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
|
||||
# SPA history fallback: anything else serves index.html.
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,10 @@
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint .",
|
||||
"typecheck": "tsc -b"
|
||||
"typecheck": "tsc -b",
|
||||
"i18n:check": "node ./scripts/check-i18n-keys.mjs",
|
||||
"i18n:meta": "node ./scripts/check-i18n-metadata.mjs",
|
||||
"i18n:lang-labels": "node ./scripts/check-i18n-lang-labels.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fingerprintjs/fingerprintjs": "^4.6.2",
|
||||
|
||||
BIN
helexa.ai/public/banner.png
Normal file
|
After Width: | Height: | Size: 304 KiB |
BIN
helexa.ai/public/bg-logo-right.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
helexa.ai/public/logo.png
Normal file
|
After Width: | Height: | Size: 111 KiB |
BIN
helexa.ai/public/people-mesh-1200x630.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
helexa.ai/public/people-mesh-1536x1024.png
Normal file
|
After Width: | Height: | Size: 2.7 MiB |
BIN
helexa.ai/public/person-helix-1200x630.png
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
BIN
helexa.ai/public/person-helix-1536x1024.png
Normal file
|
After Width: | Height: | Size: 2.2 MiB |
332
helexa.ai/scripts/check-i18n-keys.mjs
Normal file
@@ -0,0 +1,332 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Simple i18n key consistency checker.
|
||||
*
|
||||
* Compares translation JSONs for all configured languages against the English
|
||||
* baseline, for each namespace (common, home, chat).
|
||||
*
|
||||
* Exit codes:
|
||||
* - 0: all good
|
||||
* - 1: inconsistencies found or unexpected error
|
||||
*/
|
||||
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import url from "url";
|
||||
|
||||
// Adjust these if your i18n structure changes.
|
||||
const ROOT = path.resolve(
|
||||
path.dirname(url.fileURLToPath(import.meta.url)),
|
||||
"..",
|
||||
);
|
||||
const RESOURCES_DIR = path.join(ROOT, "src", "i18n", "resources");
|
||||
|
||||
// Namespaces to validate.
|
||||
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
|
||||
// enforce that every supported language has wired resources. That enforcement is
|
||||
// implemented further below by checking the i18n index.
|
||||
const LANGUAGES = [
|
||||
"bg",
|
||||
"de",
|
||||
"el",
|
||||
"en",
|
||||
"es",
|
||||
"et",
|
||||
"fr",
|
||||
"it",
|
||||
"pt",
|
||||
"ro",
|
||||
"ru",
|
||||
];
|
||||
|
||||
function readJson(filePath) {
|
||||
try {
|
||||
const raw = fs.readFileSync(filePath, "utf8");
|
||||
return JSON.parse(raw);
|
||||
} catch (err) {
|
||||
throw new Error(`Failed to read/parse JSON at ${filePath}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively walk an object and collect all key paths using dot-notation.
|
||||
* Arrays are traversed structurally but their indexes are not part of the key path
|
||||
* (we only care that the shape exists, not array lengths).
|
||||
*/
|
||||
function collectKeyPaths(obj, prefix = "") {
|
||||
const keys = new Set();
|
||||
|
||||
if (obj === null || obj === undefined) {
|
||||
return keys;
|
||||
}
|
||||
|
||||
if (typeof obj !== "object") {
|
||||
if (prefix) keys.add(prefix);
|
||||
return keys;
|
||||
}
|
||||
|
||||
// If this is an array, walk its elements but don't add indices to the path.
|
||||
if (Array.isArray(obj)) {
|
||||
obj.forEach((item) => {
|
||||
for (const childKey of collectKeyPaths(item, prefix)) {
|
||||
keys.add(childKey);
|
||||
}
|
||||
});
|
||||
return keys;
|
||||
}
|
||||
|
||||
// Plain object
|
||||
for (const [k, v] of Object.entries(obj)) {
|
||||
const nextPrefix = prefix ? `${prefix}.${k}` : k;
|
||||
if (v !== null && typeof v === "object") {
|
||||
for (const childKey of collectKeyPaths(v, nextPrefix)) {
|
||||
keys.add(childKey);
|
||||
}
|
||||
} else {
|
||||
keys.add(nextPrefix);
|
||||
}
|
||||
}
|
||||
|
||||
return keys;
|
||||
}
|
||||
|
||||
function diffKeys(baseSet, targetSet) {
|
||||
const missing = [];
|
||||
const extra = [];
|
||||
|
||||
for (const k of baseSet) {
|
||||
if (!targetSet.has(k)) missing.push(k);
|
||||
}
|
||||
|
||||
for (const k of targetSet) {
|
||||
if (!baseSet.has(k)) extra.push(k);
|
||||
}
|
||||
|
||||
missing.sort();
|
||||
extra.sort();
|
||||
|
||||
return { missing, extra };
|
||||
}
|
||||
|
||||
function logHeader(title) {
|
||||
// Simple console formatting without external deps.
|
||||
console.log("\n" + "=".repeat(title.length));
|
||||
console.log(title);
|
||||
console.log("=".repeat(title.length));
|
||||
}
|
||||
|
||||
function main() {
|
||||
let hadIssues = false;
|
||||
|
||||
console.log("helexa.ai i18n key consistency check");
|
||||
console.log(`Root: ${ROOT}`);
|
||||
console.log(`Resources dir: ${RESOURCES_DIR}`);
|
||||
console.log(`Languages: ${LANGUAGES.join(", ")}`);
|
||||
console.log(`Namespaces: ${NAMESPACES.join(", ")}`);
|
||||
|
||||
// --- Wiring check: ensure each SUPPORTED_LANGUAGES entry has resources registered ---
|
||||
//
|
||||
// This prevents cases where a language is:
|
||||
// - present in LanguageCode and SUPPORTED_LANGUAGES
|
||||
// - has translation JSONs under src/i18n/resources/<code>/
|
||||
// but is *not* wired into the i18n `resources` object (and thus silently
|
||||
// falls back to English at runtime).
|
||||
try {
|
||||
const languagesTs = fs.readFileSync(
|
||||
path.join(ROOT, "src", "i18n", "languages.ts"),
|
||||
"utf8",
|
||||
);
|
||||
const i18nIndexTs = fs.readFileSync(
|
||||
path.join(ROOT, "src", "i18n", "index.ts"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
// Parse SUPPORTED_LANGUAGES from languages.ts
|
||||
const marker = "export const SUPPORTED_LANGUAGES";
|
||||
const start = languagesTs.indexOf(marker);
|
||||
if (start === -1) {
|
||||
throw new Error("Could not find `SUPPORTED_LANGUAGES` in languages.ts");
|
||||
}
|
||||
const after = languagesTs.slice(start);
|
||||
const bracketIndex = after.indexOf("[");
|
||||
const closingIndex = after.indexOf("];");
|
||||
if (bracketIndex === -1 || closingIndex === -1) {
|
||||
throw new Error("Malformed SUPPORTED_LANGUAGES array");
|
||||
}
|
||||
const arraySlice = after.slice(bracketIndex + 1, closingIndex);
|
||||
const supportedFromTs = new Set();
|
||||
const codeRegex = /"([^"]+)"/g;
|
||||
let m;
|
||||
while ((m = codeRegex.exec(arraySlice)) !== null) {
|
||||
supportedFromTs.add(m[1]);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`SUPPORTED_LANGUAGES from TS for wiring check: ${Array.from(
|
||||
supportedFromTs,
|
||||
)
|
||||
.sort()
|
||||
.join(", ")}`,
|
||||
);
|
||||
|
||||
// Languages that are intentionally "future" and may not yet have
|
||||
// resources wired in. Keep them out of the hard failure path so
|
||||
// CI does not break while their translations are still pending.
|
||||
const FUTURE_LANGUAGES = new Set(["ig", "om", "so", "ti", "wo"]);
|
||||
|
||||
// Parse i18n resources object keys from index.ts.
|
||||
// We look for a block like:
|
||||
// const resources: Resource = {
|
||||
// en: { ... },
|
||||
// fr: { ... },
|
||||
// };
|
||||
const resourcesMarker = "const resources: Resource = {";
|
||||
const resStart = i18nIndexTs.indexOf(resourcesMarker);
|
||||
if (resStart === -1) {
|
||||
throw new Error("Could not find `const resources: Resource` in index.ts");
|
||||
}
|
||||
const resAfter = i18nIndexTs.slice(resStart + resourcesMarker.length);
|
||||
const resEndIndex = resAfter.indexOf("};");
|
||||
if (resEndIndex === -1) {
|
||||
throw new Error("Malformed resources object in index.ts");
|
||||
}
|
||||
const resourcesBlock = resAfter.slice(0, resEndIndex);
|
||||
|
||||
// Extract top-level language keys: lines starting with two spaces then <code>:
|
||||
// Example: " en: {" or " fr: {"
|
||||
const wiredLangs = new Set();
|
||||
const lineRegex = /^\s*([a-z]{2}):\s*{\s*$/gm;
|
||||
let lm;
|
||||
while ((lm = lineRegex.exec(resourcesBlock)) !== null) {
|
||||
wiredLangs.add(lm[1]);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Languages wired in i18n resources: ${Array.from(wiredLangs)
|
||||
.sort()
|
||||
.join(", ")}`,
|
||||
);
|
||||
|
||||
// Now compare SUPPORTED_LANGUAGES vs wired resources.
|
||||
const missingWiring = [];
|
||||
for (const code of supportedFromTs) {
|
||||
if (FUTURE_LANGUAGES.has(code)) {
|
||||
// These are explicitly allowed to be missing until their
|
||||
// translations and wiring land.
|
||||
continue;
|
||||
}
|
||||
if (!wiredLangs.has(code)) {
|
||||
missingWiring.push(code);
|
||||
}
|
||||
}
|
||||
|
||||
if (missingWiring.length > 0) {
|
||||
hadIssues = true;
|
||||
console.error(
|
||||
"\nERROR: Some SUPPORTED_LANGUAGES codes are not wired into the i18n `resources` object in src/i18n/index.ts:",
|
||||
);
|
||||
for (const code of missingWiring.sort()) {
|
||||
console.error(` - ${code}`);
|
||||
}
|
||||
console.error(
|
||||
"These languages will silently fall back to English at runtime. Ensure that:",
|
||||
);
|
||||
console.error(
|
||||
" 1) ./resources/<code>/{common,home,chat}.json exist, and",
|
||||
);
|
||||
console.error(
|
||||
" 2) They are imported and registered in the `resources` object.",
|
||||
);
|
||||
console.error("");
|
||||
} else {
|
||||
console.log(
|
||||
"OK: Every SUPPORTED_LANGUAGES entry (excluding future languages) is wired into the i18n resources object.",
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
hadIssues = true;
|
||||
console.error(
|
||||
"ERROR: Failed while checking SUPPORTED_LANGUAGES wiring in i18n index:",
|
||||
err.message,
|
||||
);
|
||||
}
|
||||
|
||||
for (const ns of NAMESPACES) {
|
||||
logHeader(`Namespace: ${ns}`);
|
||||
|
||||
const basePath = path.join(RESOURCES_DIR, "en", `${ns}.json`);
|
||||
let baseJson;
|
||||
try {
|
||||
baseJson = readJson(basePath);
|
||||
} catch (err) {
|
||||
console.error(err.message);
|
||||
hadIssues = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
const baseKeys = collectKeyPaths(baseJson);
|
||||
console.log(`Baseline (en) keys: ${baseKeys.size}`);
|
||||
|
||||
for (const lang of LANGUAGES) {
|
||||
if (lang === "en") continue;
|
||||
|
||||
const langPath = path.join(RESOURCES_DIR, lang, `${ns}.json`);
|
||||
if (!fs.existsSync(langPath)) {
|
||||
console.error(` [${lang}] MISSING file: ${langPath}`);
|
||||
hadIssues = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
let langJson;
|
||||
try {
|
||||
langJson = readJson(langPath);
|
||||
} catch (err) {
|
||||
console.error(` [${lang}] ${err.message}`);
|
||||
hadIssues = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
const langKeys = collectKeyPaths(langJson);
|
||||
const { missing, extra } = diffKeys(baseKeys, langKeys);
|
||||
|
||||
if (missing.length === 0 && extra.length === 0) {
|
||||
console.log(` [${lang}] OK (keys: ${langKeys.size})`);
|
||||
} else {
|
||||
hadIssues = true;
|
||||
console.log(` [${lang}] Issues found:`);
|
||||
if (missing.length > 0) {
|
||||
console.log(" Missing keys (present in en, absent here):");
|
||||
for (const k of missing) {
|
||||
console.log(` - ${k}`);
|
||||
}
|
||||
}
|
||||
if (extra.length > 0) {
|
||||
console.log(" Extra keys (present here, absent in en):");
|
||||
for (const k of extra) {
|
||||
console.log(` + ${k}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log("");
|
||||
if (hadIssues) {
|
||||
console.error("i18n check completed with inconsistencies.");
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
console.log("i18n check completed successfully. All keys are consistent.");
|
||||
process.exitCode = 0;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
main();
|
||||
} catch (err) {
|
||||
console.error("Unexpected error while running i18n check:", err);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
249
helexa.ai/scripts/check-i18n-lang-labels.mjs
Normal file
@@ -0,0 +1,249 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* helexa.ai i18n language label consistency check
|
||||
*
|
||||
* This script validates that for every supported language code in
|
||||
* `SUPPORTED_LANGUAGES`:
|
||||
*
|
||||
* 1. The English `common.lang` map (`src/i18n/resources/en/common.json`)
|
||||
* contains a human-readable label at `lang.<code>`.
|
||||
* 2. Optionally (and more leniently), other languages that define a
|
||||
* `lang` block do not omit supported language codes.
|
||||
*
|
||||
* At minimum, it enforces that the English UI always has labels for all
|
||||
* supported languages, since the header language selector renders
|
||||
* `t("lang.<code>")` using the active language.
|
||||
*
|
||||
* Exit codes:
|
||||
* - 0: all checks pass
|
||||
* - 1: one or more inconsistencies found
|
||||
*/
|
||||
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import url from "url";
|
||||
|
||||
const ROOT = path.resolve(path.dirname(url.fileURLToPath(import.meta.url)), "..");
|
||||
const I18N_DIR = path.join(ROOT, "src", "i18n");
|
||||
const LANGUAGES_TS = path.join(I18N_DIR, "languages.ts");
|
||||
const EN_COMMON_JSON = path.join(I18N_DIR, "resources", "en", "common.json");
|
||||
|
||||
/**
|
||||
* Utility: read a file as UTF-8 or throw with a helpful message.
|
||||
*/
|
||||
function readFileOrDie(filePath) {
|
||||
try {
|
||||
return fs.readFileSync(filePath, "utf8");
|
||||
} catch (err) {
|
||||
throw new Error(`Failed to read ${filePath}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse SUPPORTED_LANGUAGES from languages.ts.
|
||||
*
|
||||
* Expects a definition like:
|
||||
* export const SUPPORTED_LANGUAGES: LanguageCode[] = [
|
||||
* "en",
|
||||
* "bg",
|
||||
* ];
|
||||
*/
|
||||
function parseSupportedLanguages(source) {
|
||||
const marker = "export const SUPPORTED_LANGUAGES";
|
||||
const start = source.indexOf(marker);
|
||||
if (start === -1) {
|
||||
throw new Error("Could not find `SUPPORTED_LANGUAGES` in languages.ts");
|
||||
}
|
||||
|
||||
const after = source.slice(start);
|
||||
const bracketIndex = after.indexOf("[");
|
||||
const closingIndex = after.indexOf("];");
|
||||
if (bracketIndex === -1 || closingIndex === -1) {
|
||||
throw new Error("Malformed SUPPORTED_LANGUAGES array");
|
||||
}
|
||||
|
||||
const arraySlice = after.slice(bracketIndex + 1, closingIndex);
|
||||
const codes = new Set();
|
||||
const regex = /"([^"]+)"/g;
|
||||
let m;
|
||||
while ((m = regex.exec(arraySlice)) !== null) {
|
||||
codes.add(m[1]);
|
||||
}
|
||||
|
||||
if (codes.size === 0) {
|
||||
throw new Error("No entries found in SUPPORTED_LANGUAGES");
|
||||
}
|
||||
|
||||
return codes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely read and parse a JSON file.
|
||||
*/
|
||||
function readJsonOrDie(filePath) {
|
||||
const raw = readFileOrDie(filePath);
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch (err) {
|
||||
throw new Error(`Failed to parse JSON at ${filePath}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute set difference: a \ b
|
||||
*/
|
||||
function difference(a, b) {
|
||||
const result = new Set();
|
||||
for (const x of a) {
|
||||
if (!b.has(x)) result.add(x);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience: sorted array from a Set.
|
||||
*/
|
||||
function toSortedArray(set) {
|
||||
return [...set].sort();
|
||||
}
|
||||
|
||||
function main() {
|
||||
console.log("helexa.ai i18n language label check");
|
||||
console.log(`Root: ${ROOT}`);
|
||||
console.log(`Languages file: ${LANGUAGES_TS}`);
|
||||
console.log(`English common.json: ${EN_COMMON_JSON}`);
|
||||
console.log("");
|
||||
|
||||
let hadIssues = false;
|
||||
|
||||
// 1) Load SUPPORTED_LANGUAGES
|
||||
let languagesSource;
|
||||
try {
|
||||
languagesSource = readFileOrDie(LANGUAGES_TS);
|
||||
} catch (err) {
|
||||
console.error(err.message);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
let supportedLanguages;
|
||||
try {
|
||||
supportedLanguages = parseSupportedLanguages(languagesSource);
|
||||
console.log(
|
||||
`SUPPORTED_LANGUAGES (${supportedLanguages.size}): ${toSortedArray(
|
||||
supportedLanguages,
|
||||
).join(", ")}`,
|
||||
);
|
||||
} catch (err) {
|
||||
console.error(`ERROR: ${err.message}`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("");
|
||||
|
||||
// 2) Load English common.json and extract lang map
|
||||
let enCommon;
|
||||
try {
|
||||
enCommon = readJsonOrDie(EN_COMMON_JSON);
|
||||
} catch (err) {
|
||||
console.error(`ERROR: ${err.message}`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const enLangMap = enCommon?.lang ?? {};
|
||||
if (!enLangMap || typeof enLangMap !== "object") {
|
||||
console.error("ERROR: `en/common.json` does not contain a `lang` object.");
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const enLangKeys = new Set(Object.keys(enLangMap));
|
||||
console.log(
|
||||
`English lang map keys (${enLangKeys.size}): ${toSortedArray(
|
||||
enLangKeys,
|
||||
).join(", ")}`,
|
||||
);
|
||||
console.log("");
|
||||
|
||||
// 3) Ensure every supported language has a corresponding key in en.lang
|
||||
const missingInEnglish = difference(supportedLanguages, enLangKeys);
|
||||
if (missingInEnglish.size > 0) {
|
||||
hadIssues = true;
|
||||
console.error(
|
||||
"ERROR: The following supported languages are missing labels in `en/common.json` under `lang`:",
|
||||
);
|
||||
for (const code of toSortedArray(missingInEnglish)) {
|
||||
console.error(` - lang.${code}`);
|
||||
}
|
||||
} else {
|
||||
console.log(
|
||||
"OK: Every supported language has a `lang.<code>` entry in `en/common.json`.",
|
||||
);
|
||||
}
|
||||
|
||||
console.log("");
|
||||
|
||||
// 4) Optional: scan other locales for informational consistency
|
||||
// (non-fatal, just warnings).
|
||||
const resourcesDir = path.join(I18N_DIR, "resources");
|
||||
let otherLocales = [];
|
||||
try {
|
||||
otherLocales = fs
|
||||
.readdirSync(resourcesDir, { withFileTypes: true })
|
||||
.filter((d) => d.isDirectory() && d.name !== "en")
|
||||
.map((d) => d.name);
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
`WARN: Could not list locales in ${resourcesDir}: ${err.message}`,
|
||||
);
|
||||
}
|
||||
|
||||
for (const locale of otherLocales) {
|
||||
const filePath = path.join(resourcesDir, locale, "common.json");
|
||||
if (!fs.existsSync(filePath)) continue;
|
||||
|
||||
let localeJson;
|
||||
try {
|
||||
localeJson = readJsonOrDie(filePath);
|
||||
} catch (err) {
|
||||
console.warn(`WARN: Failed to read ${filePath}: ${err.message}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const langObj = localeJson?.lang;
|
||||
if (!langObj || typeof langObj !== "object") {
|
||||
// Not all locales need to maintain a full lang map; skip silently.
|
||||
continue;
|
||||
}
|
||||
|
||||
const localeKeys = new Set(Object.keys(langObj));
|
||||
const missingHere = difference(supportedLanguages, localeKeys);
|
||||
if (missingHere.size > 0) {
|
||||
console.warn(
|
||||
`WARN: Locale '${locale}' is missing some supported language labels under "lang":`,
|
||||
);
|
||||
for (const code of toSortedArray(missingHere)) {
|
||||
console.warn(` - lang.${code}`);
|
||||
}
|
||||
console.warn("");
|
||||
}
|
||||
}
|
||||
|
||||
if (hadIssues) {
|
||||
console.error("Language label check completed with inconsistencies.");
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
console.log("Language label check completed successfully.");
|
||||
process.exitCode = 0;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
main();
|
||||
} catch (err) {
|
||||
console.error("Unexpected error while running language label check:", err);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
327
helexa.ai/scripts/check-i18n-metadata.mjs
Normal file
@@ -0,0 +1,327 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* helexa.ai i18n metadata consistency check
|
||||
*
|
||||
* This script validates that:
|
||||
* - Every `LanguageCode` used in the project appears in `TRANSLATION_PRIORITY`
|
||||
* - Every `TRANSLATION_PRIORITY` entry refers to a valid `LanguageCode`
|
||||
* - `REMAINING_LANGUAGES` is a subset of `LanguageCode`
|
||||
* - `REMAINING_LANGUAGES` is disjoint from `SUPPORTED_LANGUAGES`
|
||||
*
|
||||
* It is intentionally implemented as a standalone Node script (no TypeScript
|
||||
* build step required) and does a very lightweight parse of the TypeScript
|
||||
* source files to avoid pulling in a full TS compiler.
|
||||
*
|
||||
* Exit codes:
|
||||
* - 0: all checks pass
|
||||
* - 1: one or more metadata inconsistencies found
|
||||
*/
|
||||
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import url from "url";
|
||||
|
||||
/**
|
||||
* Resolve project root as the directory containing this script.
|
||||
*/
|
||||
const ROOT = path.resolve(path.dirname(url.fileURLToPath(import.meta.url)), "..");
|
||||
const I18N_DIR = path.join(ROOT, "src", "i18n");
|
||||
const LANGUAGES_TS = path.join(I18N_DIR, "languages.ts");
|
||||
const PRIORITY_TS = path.join(I18N_DIR, "translation-priority.ts");
|
||||
|
||||
function readFileOrDie(filePath) {
|
||||
try {
|
||||
return fs.readFileSync(filePath, "utf8");
|
||||
} catch (err) {
|
||||
console.error(`Failed to read ${filePath}: ${err.message}`);
|
||||
process.exitCode = 1;
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract union members from a definition like:
|
||||
*
|
||||
* export type LanguageCode =
|
||||
* | "en"
|
||||
* | "bg"
|
||||
* | "cs";
|
||||
*/
|
||||
function parseLanguageCodeUnion(source) {
|
||||
const start = source.indexOf("export type LanguageCode");
|
||||
if (start === -1) {
|
||||
throw new Error("Could not find `export type LanguageCode` in languages.ts");
|
||||
}
|
||||
|
||||
const after = source.slice(start);
|
||||
const eqIndex = after.indexOf("=");
|
||||
if (eqIndex === -1) {
|
||||
throw new Error("Malformed LanguageCode definition (no '=')");
|
||||
}
|
||||
|
||||
const unionBlock = after.slice(eqIndex + 1);
|
||||
const semicolonIndex = unionBlock.indexOf(";");
|
||||
const unionSlice = semicolonIndex === -1 ? unionBlock : unionBlock.slice(0, semicolonIndex);
|
||||
|
||||
const codes = new Set();
|
||||
const regex = /\|\s*"([^"]+)"/g;
|
||||
let m;
|
||||
while ((m = regex.exec(unionSlice)) !== null) {
|
||||
codes.add(m[1]);
|
||||
}
|
||||
|
||||
if (codes.size === 0) {
|
||||
throw new Error("No language codes found in LanguageCode union");
|
||||
}
|
||||
|
||||
return codes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse SUPPORTED_LANGUAGES from languages.ts
|
||||
*
|
||||
* export const SUPPORTED_LANGUAGES: LanguageCode[] = [
|
||||
* "en",
|
||||
* "bg",
|
||||
* ];
|
||||
*/
|
||||
function parseSupportedLanguages(source) {
|
||||
const marker = "export const SUPPORTED_LANGUAGES";
|
||||
const start = source.indexOf(marker);
|
||||
if (start === -1) {
|
||||
throw new Error("Could not find `SUPPORTED_LANGUAGES` in languages.ts");
|
||||
}
|
||||
|
||||
const after = source.slice(start);
|
||||
const bracketIndex = after.indexOf("[");
|
||||
const closingIndex = after.indexOf("];");
|
||||
if (bracketIndex === -1 || closingIndex === -1) {
|
||||
throw new Error("Malformed SUPPORTED_LANGUAGES array");
|
||||
}
|
||||
|
||||
const arraySlice = after.slice(bracketIndex + 1, closingIndex);
|
||||
const codes = new Set();
|
||||
const regex = /"([^"]+)"/g;
|
||||
let m;
|
||||
while ((m = regex.exec(arraySlice)) !== null) {
|
||||
codes.add(m[1]);
|
||||
}
|
||||
|
||||
if (codes.size === 0) {
|
||||
throw new Error("No entries found in SUPPORTED_LANGUAGES");
|
||||
}
|
||||
|
||||
return codes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse REMAINING_LANGUAGES from translation-priority.ts
|
||||
*
|
||||
* export const REMAINING_LANGUAGES: LanguageCode[] = [
|
||||
* "tr",
|
||||
* "pl",
|
||||
* ];
|
||||
*/
|
||||
function parseRemainingLanguages(source) {
|
||||
const marker = "export const REMAINING_LANGUAGES";
|
||||
const start = source.indexOf(marker);
|
||||
if (start === -1) {
|
||||
// It's valid for this list to not exist; treat as empty if missing
|
||||
return new Set();
|
||||
}
|
||||
|
||||
const after = source.slice(start);
|
||||
const bracketIndex = after.indexOf("[");
|
||||
const closingIndex = after.indexOf("];");
|
||||
if (bracketIndex === -1 || closingIndex === -1) {
|
||||
throw new Error("Malformed REMAINING_LANGUAGES array");
|
||||
}
|
||||
|
||||
const arraySlice = after.slice(bracketIndex + 1, closingIndex);
|
||||
const codes = new Set();
|
||||
const regex = /"([^"]+)"/g;
|
||||
let m;
|
||||
while ((m = regex.exec(arraySlice)) !== null) {
|
||||
codes.add(m[1]);
|
||||
}
|
||||
|
||||
return codes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse TRANSLATION_PRIORITY entries from translation-priority.ts
|
||||
*
|
||||
* export const TRANSLATION_PRIORITY: TranslationPriorityEntry[] = [
|
||||
* {
|
||||
* code: "tr",
|
||||
* bucket: "high",
|
||||
* nativeSpeakers: "70–90M",
|
||||
* },
|
||||
* ...
|
||||
* ];
|
||||
*/
|
||||
function parseTranslationPriority(source) {
|
||||
const marker = "export const TRANSLATION_PRIORITY";
|
||||
const start = source.indexOf(marker);
|
||||
if (start === -1) {
|
||||
throw new Error("Could not find `TRANSLATION_PRIORITY` in translation-priority.ts");
|
||||
}
|
||||
|
||||
const after = source.slice(start);
|
||||
const bracketIndex = after.indexOf("[");
|
||||
const closingIndex = after.indexOf("];");
|
||||
if (bracketIndex === -1 || closingIndex === -1) {
|
||||
throw new Error("Malformed TRANSLATION_PRIORITY array");
|
||||
}
|
||||
|
||||
const arraySlice = after.slice(bracketIndex + 1, closingIndex);
|
||||
|
||||
// Simple heuristic: find code: "<value>" inside objects
|
||||
const codes = new Set();
|
||||
const regex = /code:\s*"([^"]+)"/g;
|
||||
let m;
|
||||
while ((m = regex.exec(arraySlice)) !== null) {
|
||||
codes.add(m[1]);
|
||||
}
|
||||
|
||||
if (codes.size === 0) {
|
||||
throw new Error("No `code` entries found in TRANSLATION_PRIORITY");
|
||||
}
|
||||
|
||||
return codes;
|
||||
}
|
||||
|
||||
function difference(a, b) {
|
||||
const result = new Set();
|
||||
for (const x of a) {
|
||||
if (!b.has(x)) result.add(x);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function toSortedArray(set) {
|
||||
return [...set].sort();
|
||||
}
|
||||
|
||||
function main() {
|
||||
console.log("helexa.ai i18n metadata consistency check");
|
||||
console.log(`Root: ${ROOT}`);
|
||||
console.log(`Languages file: ${LANGUAGES_TS}`);
|
||||
console.log(`Priority file: ${PRIORITY_TS}`);
|
||||
console.log("");
|
||||
|
||||
const languagesSource = readFileOrDie(LANGUAGES_TS);
|
||||
const prioritySource = readFileOrDie(PRIORITY_TS);
|
||||
|
||||
let hadIssues = false;
|
||||
|
||||
let languageCodes;
|
||||
let supportedLanguages;
|
||||
let remainingLanguages;
|
||||
let priorityCodes;
|
||||
|
||||
try {
|
||||
languageCodes = parseLanguageCodeUnion(languagesSource);
|
||||
console.log(`LanguageCode entries: ${languageCodes.size}`);
|
||||
} catch (err) {
|
||||
console.error(`ERROR: ${err.message}`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
supportedLanguages = parseSupportedLanguages(languagesSource);
|
||||
console.log(`SUPPORTED_LANGUAGES entries: ${supportedLanguages.size}`);
|
||||
} catch (err) {
|
||||
console.error(`ERROR: ${err.message}`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
remainingLanguages = parseRemainingLanguages(prioritySource);
|
||||
console.log(`REMAINING_LANGUAGES entries: ${remainingLanguages.size}`);
|
||||
} catch (err) {
|
||||
console.error(`ERROR: ${err.message}`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
priorityCodes = parseTranslationPriority(prioritySource);
|
||||
console.log(`TRANSLATION_PRIORITY entries: ${priorityCodes.size}`);
|
||||
} catch (err) {
|
||||
console.error(`ERROR: ${err.message}`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("");
|
||||
|
||||
// 1) Every LanguageCode should have a TRANSLATION_PRIORITY entry
|
||||
const missingInPriority = difference(languageCodes, priorityCodes);
|
||||
if (missingInPriority.size > 0) {
|
||||
hadIssues = true;
|
||||
console.error("ERROR: The following LanguageCode values are missing from TRANSLATION_PRIORITY:");
|
||||
for (const code of toSortedArray(missingInPriority)) {
|
||||
console.error(` - ${code}`);
|
||||
}
|
||||
} else {
|
||||
console.log("OK: All LanguageCode values are present in TRANSLATION_PRIORITY.");
|
||||
}
|
||||
|
||||
// 2) Every TRANSLATION_PRIORITY code must be a valid LanguageCode
|
||||
const unknownPriorityCodes = difference(priorityCodes, languageCodes);
|
||||
if (unknownPriorityCodes.size > 0) {
|
||||
hadIssues = true;
|
||||
console.error("ERROR: The following TRANSLATION_PRIORITY codes are not present in LanguageCode:");
|
||||
for (const code of toSortedArray(unknownPriorityCodes)) {
|
||||
console.error(` - ${code}`);
|
||||
}
|
||||
} else {
|
||||
console.log("OK: All TRANSLATION_PRIORITY codes are valid LanguageCode values.");
|
||||
}
|
||||
|
||||
// 3) REMAINING_LANGUAGES must be subset of LanguageCode
|
||||
const remainingNotInLanguageCode = difference(remainingLanguages, languageCodes);
|
||||
if (remainingNotInLanguageCode.size > 0) {
|
||||
hadIssues = true;
|
||||
console.error("ERROR: The following REMAINING_LANGUAGES entries are not in LanguageCode:");
|
||||
for (const code of toSortedArray(remainingNotInLanguageCode)) {
|
||||
console.error(` - ${code}`);
|
||||
}
|
||||
} else {
|
||||
console.log("OK: All REMAINING_LANGUAGES are valid LanguageCode values.");
|
||||
}
|
||||
|
||||
// 4) REMAINING_LANGUAGES must be disjoint from SUPPORTED_LANGUAGES
|
||||
const remainingThatAreSupported = difference(remainingLanguages, difference(remainingLanguages, supportedLanguages));
|
||||
// Equivalent to intersection(remainingLanguages, supportedLanguages)
|
||||
if (remainingThatAreSupported.size > 0) {
|
||||
hadIssues = true;
|
||||
console.error("ERROR: The following REMAINING_LANGUAGES are already in SUPPORTED_LANGUAGES:");
|
||||
for (const code of toSortedArray(remainingThatAreSupported)) {
|
||||
console.error(` - ${code}`);
|
||||
}
|
||||
} else {
|
||||
console.log("OK: REMAINING_LANGUAGES does not include any SUPPORTED_LANGUAGES.");
|
||||
}
|
||||
|
||||
console.log("");
|
||||
|
||||
if (hadIssues) {
|
||||
console.error("i18n metadata check completed with inconsistencies.");
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
console.log("i18n metadata check completed successfully. All metadata is consistent.");
|
||||
process.exitCode = 0;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
main();
|
||||
} catch (err) {
|
||||
console.error("Unexpected error while running i18n metadata check:", err);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
423
helexa.ai/src/App.css
Normal file
@@ -0,0 +1,423 @@
|
||||
:root {
|
||||
--color-bg: #f5f7fb;
|
||||
--color-bg-elevated: #ffffff;
|
||||
--color-bg-subtle: #eef1f7;
|
||||
--color-border-subtle: rgba(15, 23, 42, 0.12);
|
||||
--color-text: #020617;
|
||||
--color-text-muted: #64748b;
|
||||
--color-accent: #22d3ee;
|
||||
--color-accent-hot: #ec4899;
|
||||
--color-accent-soft: rgba(34, 211, 238, 0.12);
|
||||
--color-navbar-bg: rgba(255, 255, 255, 0.85);
|
||||
--color-footer-bg: rgba(255, 255, 255, 0.9);
|
||||
--shadow-soft: 0 18px 45px rgba(15, 23, 42, 0.08);
|
||||
--transition-fast: 150ms ease-out;
|
||||
}
|
||||
|
||||
/* Dark theme overrides, driven by ThemeProvider via [data-theme] */
|
||||
:root[data-theme="dark"] {
|
||||
--color-bg: #000618;
|
||||
--color-bg-elevated: #020617;
|
||||
--color-bg-subtle: #020617;
|
||||
--color-border-subtle: rgba(148, 163, 184, 0.24);
|
||||
--color-text: #e2e8f0;
|
||||
--color-text-muted: #cbd5f5;
|
||||
--color-accent: #22d3ee;
|
||||
--color-accent-soft: rgba(34, 211, 238, 0.18);
|
||||
--color-navbar-bg: rgba(2, 6, 23, 0.92);
|
||||
--color-footer-bg: rgba(2, 6, 23, 0.96);
|
||||
--shadow-soft: 0 22px 55px rgba(15, 23, 42, 0.8);
|
||||
}
|
||||
|
||||
/* Global base styles */
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family:
|
||||
system-ui,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
"SF Pro Text",
|
||||
"Segoe UI",
|
||||
sans-serif;
|
||||
background-color: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
/* Basic RTL reset: when the root document is RTL, ensure logical direction */
|
||||
html[dir="rtl"] body {
|
||||
direction: rtl;
|
||||
}
|
||||
|
||||
.app-root {
|
||||
min-height: 100vh;
|
||||
background-color: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
transition:
|
||||
background-color var(--transition-fast),
|
||||
color var(--transition-fast);
|
||||
}
|
||||
|
||||
/* Typography helpers */
|
||||
|
||||
.app-main h1,
|
||||
.app-main h2,
|
||||
.app-main h3,
|
||||
.app-main h4,
|
||||
.app-main h5 {
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.app-main p.lead {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* Hot pink accent utility for occasional emphasis */
|
||||
.text-hot-pink {
|
||||
color: var(--color-accent-hot) !important;
|
||||
}
|
||||
|
||||
/* Header / Navbar */
|
||||
|
||||
.app-header {
|
||||
background-color: var(--color-navbar-bg);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
box-shadow: 0 1px 0 var(--color-border-subtle);
|
||||
transition:
|
||||
background-color var(--transition-fast),
|
||||
box-shadow var(--transition-fast),
|
||||
border-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.app-header .navbar-brand {
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.app-header .navbar-brand:hover {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.app-header .nav-link {
|
||||
color: var(--color-text-muted);
|
||||
font-weight: 500;
|
||||
font-size: 0.9rem;
|
||||
transition:
|
||||
color var(--transition-fast),
|
||||
background-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.app-header .nav-link:hover,
|
||||
.app-header .nav-link:focus {
|
||||
color: var(--color-accent);
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.app-header .nav-link.active {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.app-header .btn.btn-outline-secondary {
|
||||
--bs-btn-color: var(--color-text-muted);
|
||||
--bs-btn-border-color: var(--color-border-subtle);
|
||||
--bs-btn-hover-bg: var(--color-accent-soft);
|
||||
--bs-btn-hover-border-color: var(--color-accent);
|
||||
--bs-btn-hover-color: var(--color-accent);
|
||||
--bs-btn-focus-shadow-rgb: 34, 211, 238;
|
||||
padding-inline: 0.55rem;
|
||||
padding-block: 0.3rem;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
/* Language dropdown: theme-aware styling */
|
||||
.app-header .dropdown-menu-dark {
|
||||
--bs-dropdown-bg: var(--color-bg-elevated);
|
||||
--bs-dropdown-link-color: var(--color-text);
|
||||
--bs-dropdown-link-hover-bg: var(--color-accent-soft);
|
||||
--bs-dropdown-link-hover-color: var(--color-accent);
|
||||
--bs-dropdown-link-active-bg: var(--color-accent-soft);
|
||||
--bs-dropdown-link-active-color: var(--color-accent);
|
||||
--bs-dropdown-border-color: var(--color-border-subtle);
|
||||
background-color: var(--color-bg-elevated);
|
||||
border-color: var(--color-border-subtle);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
/* Prevent language dropdown from overflowing off-screen in RTL */
|
||||
html[dir="rtl"] .app-header .dropdown-menu-end {
|
||||
left: auto;
|
||||
right: 0;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.app-header .dropdown-menu-dark .dropdown-item.active,
|
||||
.app-header .dropdown-menu-dark .dropdown-item:active {
|
||||
background-color: var(--color-accent-soft);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.app-header .dropdown-menu-dark-context .dropdown-toggle.btn-secondary {
|
||||
--bs-btn-bg: transparent;
|
||||
--bs-btn-border-color: var(--color-border-subtle);
|
||||
--bs-btn-color: var(--color-text-muted);
|
||||
--bs-btn-hover-bg: var(--color-accent-soft);
|
||||
--bs-btn-hover-border-color: var(--color-accent);
|
||||
--bs-btn-hover-color: var(--color-accent);
|
||||
--bs-btn-focus-shadow-rgb: 34, 211, 238;
|
||||
}
|
||||
|
||||
.app-header .btn.btn-outline-secondary svg {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Generic helper class for mirroring directional icons in RTL.
|
||||
Used by DirectionalIcon when `mirrorInRtl` is enabled. */
|
||||
html[dir="rtl"] .diricon-mirror-rtl {
|
||||
transform: scaleX(-1);
|
||||
}
|
||||
|
||||
/* Provide a visible toggler icon when using default Bootstrap styles */
|
||||
.navbar-toggler {
|
||||
border-color: var(--color-border-subtle);
|
||||
}
|
||||
|
||||
.navbar-toggler-icon {
|
||||
background-image: none;
|
||||
position: relative;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
}
|
||||
|
||||
.navbar-toggler-icon::before,
|
||||
.navbar-toggler-icon::after,
|
||||
.navbar-toggler-icon span {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
border-radius: 999px;
|
||||
background-color: var(--color-text);
|
||||
transition:
|
||||
transform 150ms ease-out,
|
||||
opacity 150ms ease-out;
|
||||
}
|
||||
|
||||
.navbar-toggler-icon::before {
|
||||
top: 0.25rem;
|
||||
}
|
||||
|
||||
.navbar-toggler-icon::after {
|
||||
bottom: 0.25rem;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
|
||||
.app-footer {
|
||||
background-color: var(--color-footer-bg);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
box-shadow: 0 -1px 0 var(--color-border-subtle);
|
||||
transition:
|
||||
background-color var(--transition-fast),
|
||||
box-shadow var(--transition-fast),
|
||||
border-color var(--transition-fast);
|
||||
}
|
||||
|
||||
/* Main content layout */
|
||||
|
||||
.app-main {
|
||||
max-width: 1120px;
|
||||
}
|
||||
|
||||
/* When in RTL mode, flip common flex directions and alignment where needed */
|
||||
html[dir="rtl"] .app-root {
|
||||
direction: rtl;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
html[dir="rtl"] .app-header .navbar-brand {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
html[dir="rtl"] .app-header .nav-link {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
html[dir="rtl"] .app-header .d-flex.align-items-center.gap-2 {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
html[dir="rtl"] .app-main .card-body {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* Ensure footer content also respects RTL direction */
|
||||
html[dir="rtl"] .app-footer {
|
||||
direction: rtl;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.app-main .card,
|
||||
.app-main .card-body {
|
||||
background-color: var(--color-bg-elevated);
|
||||
border-color: var(--color-border-subtle);
|
||||
}
|
||||
|
||||
/* Generic utility styles */
|
||||
|
||||
.surface-elevated {
|
||||
background-color: var(--color-bg-elevated);
|
||||
border-radius: 1rem;
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
box-shadow: var(--shadow-soft);
|
||||
}
|
||||
|
||||
/* Mesh / principle icon containers */
|
||||
.mesh-icon,
|
||||
.principle-icon {
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
border-radius: 999px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
background: radial-gradient(
|
||||
circle,
|
||||
var(--color-accent-soft),
|
||||
transparent 60%
|
||||
);
|
||||
color: var(--color-accent);
|
||||
margin-bottom: 0.35rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.principle-icon {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
}
|
||||
|
||||
/* CTA background texture */
|
||||
.join-mesh-cta {
|
||||
background:
|
||||
radial-gradient(
|
||||
circle at top,
|
||||
rgba(34, 211, 238, 0.18),
|
||||
transparent 60%
|
||||
),
|
||||
url("/bg-logo-right.png") center/cover no-repeat,
|
||||
var(--color-bg-elevated);
|
||||
background-blend-mode: screen, normal;
|
||||
}
|
||||
|
||||
.badge-accent {
|
||||
background-color: var(--color-accent-soft);
|
||||
color: var(--color-accent);
|
||||
border-radius: 999px;
|
||||
padding: 0.1rem 0.55rem;
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
/* Ensure muted text uses our theme variable instead of Bootstrap defaults */
|
||||
.text-muted,
|
||||
small {
|
||||
color: var(--color-text-muted) !important;
|
||||
}
|
||||
|
||||
/* Small cyan dot used inside hero and CTA badges */
|
||||
.bg-cyan-500-dot {
|
||||
display: inline-block;
|
||||
width: 0.45rem;
|
||||
height: 0.45rem;
|
||||
border-radius: 999px;
|
||||
background: radial-gradient(
|
||||
circle,
|
||||
var(--color-accent) 0%,
|
||||
transparent 65%
|
||||
);
|
||||
}
|
||||
|
||||
/* Links */
|
||||
|
||||
a {
|
||||
color: var(--color-accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: var(--color-accent);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Scrollbar theming (WebKit) */
|
||||
|
||||
*::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(148, 163, 184, 0.6);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] *::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(148, 163, 184, 0.8);
|
||||
}
|
||||
|
||||
/* Code blocks / preformatted text placeholders */
|
||||
|
||||
pre,
|
||||
code {
|
||||
font-family:
|
||||
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
|
||||
"Liberation Mono", "Courier New", monospace;
|
||||
}
|
||||
|
||||
pre {
|
||||
background-color: var(--color-bg-subtle);
|
||||
border-radius: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
/* Small-screen adjustments */
|
||||
|
||||
@media (max-width: 576px) {
|
||||
.app-main {
|
||||
padding-left: 1rem !important;
|
||||
padding-right: 1rem !important;
|
||||
}
|
||||
|
||||
.app-header .navbar-brand {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Public-beta banner (F6) — slim accent strip above the header. */
|
||||
.beta-banner {
|
||||
background: var(--bs-warning-bg-subtle, #fff3cd);
|
||||
color: var(--bs-warning-text-emphasis, #664d03);
|
||||
border-bottom: 1px solid var(--bs-warning-border-subtle, #ffe69c);
|
||||
}
|
||||
[data-bs-theme="dark"] .beta-banner {
|
||||
background: rgba(255, 193, 7, 0.12);
|
||||
color: #ffda6a;
|
||||
border-bottom-color: rgba(255, 193, 7, 0.25);
|
||||
}
|
||||
@@ -1,12 +1,61 @@
|
||||
import { Container } from "react-bootstrap";
|
||||
import { BrowserRouter, Routes, Route } from "react-router-dom";
|
||||
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 BetaBanner from "./components/BetaBanner";
|
||||
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";
|
||||
|
||||
// F0 scaffold shell. Theming, i18n, routing, the chat workspace, mission
|
||||
// page and account dashboard land in the F1+ phases.
|
||||
// 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 (
|
||||
<Container className="py-5">
|
||||
<h1 className="mb-2">helexa.ai</h1>
|
||||
<p className="text-muted">Public beta — coming online.</p>
|
||||
</Container>
|
||||
<ThemeProvider>
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<div className="d-flex flex-column min-vh-100">
|
||||
<BetaBanner />
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
213
helexa.ai/src/api/account.ts
Normal 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;
|
||||
}
|
||||
45
helexa.ai/src/api/types.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
76
helexa.ai/src/auth/AuthProvider.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
14
helexa.ai/src/auth/RequireAuth.tsx
Normal 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}</>;
|
||||
}
|
||||
26
helexa.ai/src/auth/context.ts
Normal 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);
|
||||
}
|
||||
35
helexa.ai/src/components/BetaBanner.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
/**
|
||||
* Slim public-beta notice shown above the header when VITE_PUBLIC_BETA is
|
||||
* set. Dismissible for the session (sessionStorage) so it doesn't nag, but
|
||||
* returns on the next visit while the beta lasts.
|
||||
*/
|
||||
const SHOWN = import.meta.env.VITE_PUBLIC_BETA === "true";
|
||||
const DISMISS_KEY = "helexa.betaDismissed";
|
||||
|
||||
export default function BetaBanner() {
|
||||
const { t } = useTranslation("common");
|
||||
const [hidden, setHidden] = useState(
|
||||
() => sessionStorage.getItem(DISMISS_KEY) === "1",
|
||||
);
|
||||
if (!SHOWN || hidden) return null;
|
||||
return (
|
||||
<div className="beta-banner d-flex align-items-center justify-content-center gap-2 px-3 py-1 small">
|
||||
<span>
|
||||
<strong>{t("beta.tag")}</strong> {t("beta.message")}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-close btn-close-white ms-2"
|
||||
style={{ fontSize: "0.6rem" }}
|
||||
aria-label={t("beta.dismiss")}
|
||||
onClick={() => {
|
||||
sessionStorage.setItem(DISMISS_KEY, "1");
|
||||
setHidden(true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
127
helexa.ai/src/components/DirectionalIcon.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { isRtlLanguage, type LanguageCode } from "../i18n/languages";
|
||||
|
||||
export type Direction = "forward" | "back";
|
||||
|
||||
/**
|
||||
* DirectionalIcon
|
||||
*
|
||||
* Small helper component to render direction-aware icons that respect
|
||||
* the current UI writing direction (LTR vs RTL).
|
||||
*
|
||||
* Usage example:
|
||||
*
|
||||
* <DirectionalIcon
|
||||
* direction="forward"
|
||||
* ltrIcon={FaArrowRight}
|
||||
* rtlIcon={FaArrowLeft}
|
||||
* />
|
||||
*
|
||||
* - `direction="forward"` means “toward the natural reading direction”
|
||||
* (right in LTR, left in RTL).
|
||||
* - `direction="back"` means the opposite (left in LTR, right in RTL).
|
||||
*
|
||||
* You can either:
|
||||
* - pass explicit `ltrIcon` and `rtlIcon` React components, or
|
||||
* - pass a single `icon` component and set `mirrorInRtl` to flip it
|
||||
* horizontally when in RTL (via CSS transform).
|
||||
*
|
||||
* In most cases, using explicit LTR / RTL icons is clearer and avoids
|
||||
* surprises with asymmetric icon shapes.
|
||||
*/
|
||||
export interface DirectionalIconProps {
|
||||
/**
|
||||
* Logical direction relative to reading order.
|
||||
* - "forward": in the direction of the text flow
|
||||
* - "back": opposite the direction of the text flow
|
||||
*/
|
||||
direction: Direction;
|
||||
|
||||
/**
|
||||
* Icon component to use for LTR contexts (e.g. FaArrowRight).
|
||||
*/
|
||||
ltrIcon?: React.ComponentType<{ size?: number | string; className?: string }>;
|
||||
|
||||
/**
|
||||
* Icon component to use for RTL contexts (e.g. FaArrowLeft).
|
||||
*/
|
||||
rtlIcon?: React.ComponentType<{ size?: number | string; className?: string }>;
|
||||
|
||||
/**
|
||||
* Single base icon component. When provided together with
|
||||
* `mirrorInRtl={true}`, it will be mirrored horizontally in RTL.
|
||||
*/
|
||||
icon?: React.ComponentType<{ size?: number | string; className?: string }>;
|
||||
|
||||
/**
|
||||
* Whether to flip the `icon` horizontally in RTL.
|
||||
* Ignored if both `ltrIcon` and `rtlIcon` are supplied.
|
||||
*/
|
||||
mirrorInRtl?: boolean;
|
||||
|
||||
/**
|
||||
* Optional size forwarded to the rendered icon.
|
||||
*/
|
||||
size?: number | string;
|
||||
|
||||
/**
|
||||
* Additional className to apply to the rendered icon.
|
||||
*/
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if current language is RTL based on i18next language code.
|
||||
*
|
||||
* Delegates to the shared `isRtlLanguage` helper from i18n/languages.ts
|
||||
* so that all RTL logic lives in one place.
|
||||
*/
|
||||
const isRtlLanguageCode = (code: string | undefined | null): boolean => {
|
||||
if (!code) return false;
|
||||
const lang = code.split("-")[0].toLowerCase() as LanguageCode;
|
||||
return isRtlLanguage(lang);
|
||||
};
|
||||
|
||||
const DirectionalIcon: React.FC<DirectionalIconProps> = ({
|
||||
direction,
|
||||
ltrIcon: LtrIcon,
|
||||
rtlIcon: RtlIcon,
|
||||
icon: BaseIcon,
|
||||
mirrorInRtl = false,
|
||||
size,
|
||||
className,
|
||||
}) => {
|
||||
const { i18n } = useTranslation();
|
||||
const isRtl = isRtlLanguageCode(i18n.language);
|
||||
|
||||
// If explicit LTR/RTL icons are provided, prefer those.
|
||||
if (LtrIcon && RtlIcon) {
|
||||
const IconComponent =
|
||||
(direction === "forward" && !isRtl) || (direction === "back" && isRtl)
|
||||
? LtrIcon
|
||||
: RtlIcon;
|
||||
|
||||
return <IconComponent size={size} className={className} />;
|
||||
}
|
||||
|
||||
// Fallback: single base icon, optionally mirrored in RTL.
|
||||
if (!BaseIcon) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const shouldMirror =
|
||||
mirrorInRtl &&
|
||||
((direction === "forward" && isRtl) || (direction === "back" && !isRtl));
|
||||
|
||||
const combinedClassName = [
|
||||
className,
|
||||
shouldMirror ? "diricon-mirror-rtl" : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
return <BaseIcon size={size} className={combinedClassName} />;
|
||||
};
|
||||
|
||||
export default DirectionalIcon;
|
||||
23
helexa.ai/src/components/Footer.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
/**
|
||||
* Footer
|
||||
*
|
||||
* Simple application footer used in the main layout.
|
||||
* Renders a subtle, theme-aware bar with copyright text.
|
||||
*/
|
||||
const Footer: React.FC = () => {
|
||||
const year = new Date().getFullYear();
|
||||
const { t } = useTranslation("common");
|
||||
|
||||
return (
|
||||
<footer className="app-footer border-top py-3 mt-auto">
|
||||
<div className="container-fluid text-center text-muted small">
|
||||
<span>{t("footer.copyright", { year })}</span>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
};
|
||||
|
||||
export default Footer;
|
||||
160
helexa.ai/src/components/Header.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
import React from "react";
|
||||
import { Link, NavLink } from "react-router-dom";
|
||||
import { Navbar, Container, Nav, Button, Dropdown } from "react-bootstrap";
|
||||
import { FaRegMoon, FaRegSun } from "react-icons/fa6";
|
||||
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 (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
|
||||
* foregrounds helexa's international grounding. Each item shows the autonym
|
||||
* (language in its own script) plus a secondary label in the current
|
||||
* language; RTL-aware alignment.
|
||||
*/
|
||||
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;
|
||||
const isRtl = isRtlLanguage(currentLanguage);
|
||||
const languageOptions = getLanguageOptionsByUsage();
|
||||
|
||||
return (
|
||||
<Navbar
|
||||
expand="lg"
|
||||
className="app-header border-bottom"
|
||||
variant={theme === "dark" ? "dark" : "light"}
|
||||
>
|
||||
<Container fluid>
|
||||
<Navbar.Brand
|
||||
as={Link}
|
||||
to="/"
|
||||
className="d-flex align-items-center gap-2"
|
||||
>
|
||||
<img
|
||||
src="/logo.png"
|
||||
alt="helexa logo"
|
||||
width={28}
|
||||
height={28}
|
||||
style={{ borderRadius: "999px" }}
|
||||
/>
|
||||
<span className="fw-semibold text-uppercase small tracking-wide">
|
||||
{t("app.name")}
|
||||
</span>
|
||||
</Navbar.Brand>
|
||||
|
||||
<Navbar.Toggle aria-controls="main-navbar" />
|
||||
|
||||
<Navbar.Collapse id="main-navbar">
|
||||
<Nav className="me-auto">
|
||||
<NavLink
|
||||
to="/"
|
||||
end
|
||||
className={({ isActive }): string =>
|
||||
isActive ? "nav-link active" : "nav-link"
|
||||
}
|
||||
>
|
||||
{t("nav.chat")}
|
||||
</NavLink>
|
||||
<NavLink
|
||||
to="/mission"
|
||||
className={({ isActive }): string =>
|
||||
isActive ? "nav-link active" : "nav-link"
|
||||
}
|
||||
>
|
||||
{t("nav.mission")}
|
||||
</NavLink>
|
||||
</Nav>
|
||||
|
||||
<div className="d-flex align-items-center gap-2">
|
||||
{/* 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"
|
||||
variant="outline-secondary"
|
||||
type="button"
|
||||
onClick={toggleTheme}
|
||||
aria-label={
|
||||
theme === "dark"
|
||||
? t("theme.toggle.toLight")
|
||||
: t("theme.toggle.toDark")
|
||||
}
|
||||
className="d-inline-flex align-items-center justify-content-center"
|
||||
>
|
||||
{theme === "dark" ? <FaRegSun size={16} /> : <FaRegMoon size={16} />}
|
||||
</Button>
|
||||
|
||||
<Dropdown
|
||||
align={isRtl ? "start" : "end"}
|
||||
className={theme === "dark" ? "dropdown-menu-dark-context" : ""}
|
||||
>
|
||||
<Dropdown.Toggle
|
||||
size="sm"
|
||||
variant={theme === "dark" ? "secondary" : "outline-secondary"}
|
||||
id="language-switcher"
|
||||
>
|
||||
<span className="me-1" aria-hidden="true">
|
||||
文A
|
||||
</span>
|
||||
<span>{AUTONYM_MAP[currentLanguage]}</span>
|
||||
</Dropdown.Toggle>
|
||||
<Dropdown.Menu
|
||||
className={theme === "dark" ? "dropdown-menu-dark" : ""}
|
||||
>
|
||||
{languageOptions.map(({ code, autonym }) => (
|
||||
<Dropdown.Item
|
||||
key={code}
|
||||
active={code === currentLanguage}
|
||||
onClick={() => void i18n.changeLanguage(code)}
|
||||
className="d-flex align-items-center gap-2"
|
||||
>
|
||||
<span>{autonym}</span>
|
||||
<span className="text-muted small fw-light">
|
||||
· {t(`lang.${code}`)}
|
||||
</span>
|
||||
</Dropdown.Item>
|
||||
))}
|
||||
</Dropdown.Menu>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</Navbar.Collapse>
|
||||
</Container>
|
||||
</Navbar>
|
||||
);
|
||||
};
|
||||
|
||||
export default Header;
|
||||
72
helexa.ai/src/data/db.ts
Normal 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();
|
||||
154
helexa.ai/src/data/repositories.ts
Normal 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 })),
|
||||
);
|
||||
});
|
||||
}
|
||||
424
helexa.ai/src/i18n/index.ts
Normal file
@@ -0,0 +1,424 @@
|
||||
import i18n, { type Resource } from "i18next";
|
||||
import { initReactI18next } from "react-i18next";
|
||||
import {
|
||||
SUPPORTED_LANGUAGES,
|
||||
normalizeLocaleToLanguage,
|
||||
isRtlLanguage,
|
||||
} from "./languages";
|
||||
import type { LanguageCode } from "./languages";
|
||||
|
||||
// Core languages
|
||||
import enCommon from "./resources/en/common.json";
|
||||
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.
|
||||
*
|
||||
* - `common`: shared UI elements (navigation, theme toggle, etc.)
|
||||
* - `home`: marketing / narrative copy on the landing page
|
||||
* - `chat`: copy for the chat workspace
|
||||
*/
|
||||
const resources: Resource = {
|
||||
en: {
|
||||
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)
|
||||
sw: {
|
||||
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
|
||||
es: {
|
||||
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,
|
||||
},
|
||||
};
|
||||
|
||||
// Determine initial language from browser, normalised to language-only.
|
||||
const browserLang: LanguageCode =
|
||||
typeof navigator !== "undefined"
|
||||
? normalizeLocaleToLanguage(navigator.language)
|
||||
: "en";
|
||||
|
||||
// Keep document direction (ltr/rtl) in sync with the active language.
|
||||
if (typeof document !== "undefined") {
|
||||
document.documentElement.dir = isRtlLanguage(browserLang) ? "rtl" : "ltr";
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize i18next with React bindings.
|
||||
*
|
||||
* This module is imported once in src/main.tsx before any React
|
||||
* rendering so that `useTranslation` is ready everywhere.
|
||||
*/
|
||||
i18n.use(initReactI18next).init({
|
||||
resources,
|
||||
lng: browserLang,
|
||||
fallbackLng: "en",
|
||||
supportedLngs: SUPPORTED_LANGUAGES,
|
||||
ns: ["common", "mission", "chat", "account"],
|
||||
defaultNS: "common",
|
||||
// Because we control the keys and interpolate only simple values.
|
||||
interpolation: {
|
||||
escapeValue: false,
|
||||
},
|
||||
// For now we stay language-only; we already normalise the browser locale.
|
||||
load: "languageOnly",
|
||||
// Be explicit about react options for clarity.
|
||||
react: {
|
||||
useSuspense: false,
|
||||
},
|
||||
});
|
||||
|
||||
// Ensure that when the language changes at runtime, document direction
|
||||
// tracks the new language's natural writing direction.
|
||||
i18n.on("languageChanged", (lng) => {
|
||||
if (typeof document === "undefined") return;
|
||||
const lang = normalizeLocaleToLanguage(lng);
|
||||
document.documentElement.dir = isRtlLanguage(lang) ? "rtl" : "ltr";
|
||||
});
|
||||
|
||||
export default i18n;
|
||||
232
helexa.ai/src/i18n/languages.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
import type { Resource } from "i18next";
|
||||
|
||||
/**
|
||||
* Supported language codes for the application.
|
||||
*
|
||||
* For the foreseeable future we deliberately stay at the language level
|
||||
* (e.g. "en", "ru") rather than full locales (e.g. "en-GB") to keep
|
||||
* translation overhead manageable.
|
||||
*
|
||||
* When you add a new language:
|
||||
* - Add its code to `SUPPORTED_LANGUAGES`
|
||||
* - Add its autonym to `AUTONYM_MAP`
|
||||
* - Add its resources to the i18n configuration
|
||||
*/
|
||||
export type LanguageCode =
|
||||
| "en"
|
||||
| "bg"
|
||||
| "cs"
|
||||
| "da"
|
||||
| "de"
|
||||
| "el"
|
||||
| "es"
|
||||
| "he"
|
||||
| "et"
|
||||
| "ar"
|
||||
| "fa"
|
||||
| "fi"
|
||||
| "sw"
|
||||
| "ha"
|
||||
| "am"
|
||||
| "yo"
|
||||
| "zu"
|
||||
| "fr"
|
||||
| "ma"
|
||||
| "ga"
|
||||
| "hr"
|
||||
| "hu"
|
||||
| "is"
|
||||
| "it"
|
||||
| "ka"
|
||||
| "lt"
|
||||
| "lv"
|
||||
| "mt"
|
||||
| "nl"
|
||||
| "no"
|
||||
| "pl"
|
||||
| "pt"
|
||||
| "ro"
|
||||
| "ru"
|
||||
| "sk"
|
||||
| "sl"
|
||||
| "sr"
|
||||
| "sv"
|
||||
| "tr"
|
||||
| "uk"
|
||||
| "bs"
|
||||
| "mk"
|
||||
| "kk"
|
||||
| "uz"
|
||||
| "ig"
|
||||
| "om"
|
||||
| "so"
|
||||
| "ti"
|
||||
| "wo";
|
||||
|
||||
/**
|
||||
* Ordered list of languages enabled in the UI.
|
||||
*
|
||||
* For now you can keep `SUPPORTED_LANGUAGES` in sync with the
|
||||
* actually configured i18n resources (e.g. ["en", "ru"]) and grow
|
||||
* it as translations land.
|
||||
*/
|
||||
export const SUPPORTED_LANGUAGES: LanguageCode[] = [
|
||||
"bg",
|
||||
"da",
|
||||
"de",
|
||||
"el",
|
||||
"en",
|
||||
"es",
|
||||
"et",
|
||||
"fi",
|
||||
"fr",
|
||||
"he",
|
||||
"it",
|
||||
"ka",
|
||||
"kk",
|
||||
"nl",
|
||||
"no",
|
||||
"sv",
|
||||
"uz",
|
||||
"ar",
|
||||
"fa",
|
||||
"sw",
|
||||
"ha",
|
||||
"am",
|
||||
"yo",
|
||||
"zu",
|
||||
"ma",
|
||||
"pl",
|
||||
"pt",
|
||||
"ro",
|
||||
"ru",
|
||||
"sr",
|
||||
"tr",
|
||||
"uk",
|
||||
// Future Afro‑European / Eurasian candidates; keep out of SUPPORTED_LANGUAGES until translated:
|
||||
// "ig", // Igbo
|
||||
// "om", // Oromo
|
||||
// "so", // Somali
|
||||
// "ti", // Tigrinya
|
||||
// "wo", // Wolof
|
||||
];
|
||||
|
||||
/**
|
||||
* Autonym map.
|
||||
*
|
||||
* Each language is named in its own language so that a user only
|
||||
* needs to know their own language to find it in the selector.
|
||||
*/
|
||||
export const AUTONYM_MAP: Record<LanguageCode, string> = {
|
||||
en: "English",
|
||||
bg: "български",
|
||||
cs: "čeština",
|
||||
da: "dansk",
|
||||
de: "Deutsch",
|
||||
el: "Ελληνικά",
|
||||
es: "español",
|
||||
et: "eesti",
|
||||
he: "עברית",
|
||||
ar: "العربية",
|
||||
fa: "فارسی",
|
||||
sw: "Kiswahili",
|
||||
ha: "Hausa",
|
||||
am: "አማርኛ",
|
||||
yo: "Yorùbá",
|
||||
zu: "isiZulu",
|
||||
ma: "Darija",
|
||||
fi: "suomi",
|
||||
fr: "français",
|
||||
ga: "Gaeilge",
|
||||
hr: "hrvatski",
|
||||
hu: "magyar",
|
||||
is: "íslenska",
|
||||
it: "italiano",
|
||||
lt: "lietuvių",
|
||||
lv: "latviešu",
|
||||
mt: "Malti",
|
||||
nl: "Nederlands",
|
||||
no: "norsk",
|
||||
pl: "polski",
|
||||
pt: "português",
|
||||
ro: "română",
|
||||
ru: "русский",
|
||||
sk: "slovenčina",
|
||||
sl: "slovenščina",
|
||||
sr: "српски",
|
||||
sv: "svenska",
|
||||
tr: "Türkçe",
|
||||
uk: "українська",
|
||||
bs: "bosanski",
|
||||
mk: "македонски",
|
||||
ka: "ქართული", // Georgian
|
||||
kk: "қазақ тілі", // Kazakh
|
||||
uz: "oʻzbekcha", // Uzbek
|
||||
ig: "Igbo", // Igbo
|
||||
om: "Afaan Oromoo", // Oromo
|
||||
so: "Af-Soomaali", // Somali
|
||||
ti: "ትግርኛ", // Tigrinya
|
||||
wo: "Wolof", // Wolof
|
||||
};
|
||||
|
||||
/**
|
||||
* Normalize a full locale (e.g. "en-GB") down to a `LanguageCode`.
|
||||
*
|
||||
* - Uses the first segment of the locale (before "-")
|
||||
* - Falls back to "en" if the language is unsupported or invalid
|
||||
*/
|
||||
export const normalizeLocaleToLanguage = (
|
||||
locale: string | null | undefined,
|
||||
): LanguageCode => {
|
||||
if (!locale) return "en";
|
||||
const lang = locale.split("-")[0]?.toLowerCase() ?? "en";
|
||||
|
||||
if (SUPPORTED_LANGUAGES.includes(lang as LanguageCode)) {
|
||||
return lang as LanguageCode;
|
||||
}
|
||||
|
||||
return "en";
|
||||
};
|
||||
|
||||
/**
|
||||
* Build a stable list of language options for UI components
|
||||
* such as dropdowns.
|
||||
*/
|
||||
export type LanguageOption = {
|
||||
code: LanguageCode;
|
||||
autonym: string;
|
||||
};
|
||||
|
||||
export const getLanguageOptions = (): LanguageOption[] =>
|
||||
[...SUPPORTED_LANGUAGES]
|
||||
.map((code) => ({
|
||||
code,
|
||||
autonym: AUTONYM_MAP[code],
|
||||
}))
|
||||
.sort((a, b) => a.autonym.localeCompare(b.autonym));
|
||||
|
||||
/**
|
||||
* Utility to derive i18next `supportedLngs` from our language codes,
|
||||
* so configuration can import from this module instead of hardcoding
|
||||
* the list in multiple places.
|
||||
*/
|
||||
export const getSupportedLngsForI18Next = (): Resource["en"] extends never
|
||||
? string[]
|
||||
: string[] => {
|
||||
// i18next accepts string[], while we keep a stricter LanguageCode[]
|
||||
return [...SUPPORTED_LANGUAGES];
|
||||
};
|
||||
|
||||
/**
|
||||
* Languages whose natural writing direction is right-to-left.
|
||||
*
|
||||
* This is used by layout code (outside this module) to switch
|
||||
* document direction and RTL-aware styling when needed.
|
||||
*/
|
||||
export const RTL_LANGUAGES: LanguageCode[] = ["he", "ar", "fa", "ma"];
|
||||
|
||||
/**
|
||||
* Utility to check whether a given language code is RTL.
|
||||
*/
|
||||
export const isRtlLanguage = (code: LanguageCode): boolean =>
|
||||
RTL_LANGUAGES.includes(code);
|
||||
71
helexa.ai/src/i18n/resources/am/account.json
Normal 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."
|
||||
}
|
||||
}
|
||||
21
helexa.ai/src/i18n/resources/am/chat.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"title": "የውይይት ቦታ",
|
||||
"badge": "ውይይት",
|
||||
"lead": "ይህ የውይይት እይታ ነው። የውይይት ሎጂክዎን እና የተጠቃሚ በስተጀርባ ክፍሎችን ወደዚህ ገፅ ያገናኙ።",
|
||||
"transcriptPlaceholder": "የውይይቱ ሪኮርድ እዚህ ይታያል። የሞዴሉን እና የተጠቃሚውን መልዕክቶች በሚንቀሳቀስ ኮንቴይነር ውስጥ ያቀርቡ፣ приወይም በዙር ዙር በመከፈል ማቅረብ ይችላሉ።",
|
||||
"inputPlaceholder": "ውይይትን ለመጀምር መልዕክት ይፃፉ…",
|
||||
"send": "መላክ",
|
||||
"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"
|
||||
}
|
||||
68
helexa.ai/src/i18n/resources/am/common.json
Normal file
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"app": {
|
||||
"name": "helexa.ai"
|
||||
},
|
||||
"nav": {
|
||||
"home": "መነሻ ገፅ",
|
||||
"docs": "ሰነዶች",
|
||||
"chat": "ውይይት",
|
||||
"mission": "Mission",
|
||||
"login": "Sign in",
|
||||
"register": "Sign up",
|
||||
"account": "Account",
|
||||
"logout": "Sign out"
|
||||
},
|
||||
"theme": {
|
||||
"toggle": {
|
||||
"toLight": "ወደ ብርሃን ሁኔታ መቀየር",
|
||||
"toDark": "ወደ ጨለማ ሁኔታ መቀየር"
|
||||
}
|
||||
},
|
||||
"lang": {
|
||||
"bg": "ቡልጋሪኛ",
|
||||
"de": "ጀርመንኛ",
|
||||
"el": "ግሪከኛ",
|
||||
"en": "እንግሊዝኛ",
|
||||
"es": "ስፓኒሽኛ",
|
||||
"et": "ኤስቶኒያን",
|
||||
"fr": "ፈረንሳይኛ",
|
||||
"he": "እብራስጥ",
|
||||
"it": "ጣሊያንኛ",
|
||||
"nl": "ደችኛ",
|
||||
"da": "ዴኒሽኛ",
|
||||
"fi": "ፊኒሽኛ",
|
||||
"no": "ኖርዌጂያንኛ",
|
||||
"sv": "ስዊድንኛ",
|
||||
"ar": "ዐርቢኛ",
|
||||
"fa": "ፐርሺያኛ",
|
||||
"sw": "ስዋሂሊኛ",
|
||||
"ha": "ሃውሳኛ",
|
||||
"am": "አማርኛ",
|
||||
"yo": "ዮሩባ",
|
||||
"zu": "ዙሉ",
|
||||
"ma": "ዳሪጃ",
|
||||
"ig": "ኢግቦኛ",
|
||||
"ka": "ጊዮርጂያንኛ",
|
||||
"kk": "ካዛክኛ",
|
||||
"om": "ኦሮሞኛ",
|
||||
"so": "ሶማሊኛ",
|
||||
"ti": "ትግርኛ",
|
||||
"uz": "ኡዝቤክኛ",
|
||||
"wo": "ዎሎፍኛ",
|
||||
"pl": "ፖሊሽኛ",
|
||||
"pt": "ፖርቱጋልኛ",
|
||||
"ro": "ሮማኒያን",
|
||||
"ru": "ራሽኛ",
|
||||
"sr": "ሰርቢኛ",
|
||||
"tr": "ቱርክኛ",
|
||||
"uk": "ዩክሬንኛ"
|
||||
},
|
||||
"footer": {
|
||||
"copyright": "© {{year}} helexa.ai"
|
||||
},
|
||||
"beta": {
|
||||
"tag": "Public beta",
|
||||
"message": "helexa is in open beta — expect rough edges. Your chats stay in your browser.",
|
||||
"dismiss": "Dismiss"
|
||||
}
|
||||
}
|
||||
103
helexa.ai/src/i18n/resources/am/mission.json
Normal file
@@ -0,0 +1,103 @@
|
||||
{
|
||||
"hero": {
|
||||
"badge": "አዲስ የአእምሮ ቅርጽ",
|
||||
"title": "አዲስ የአእምሮ ቅርጽ",
|
||||
"lead": "Helexa በተገናኙ ነጻ ኦፕሬተሮች የሚነሳ ራሷን የሚያደርግ የኤ.አይ. መረብ ነው። ክፍት ነው። ተበታተነ። በመደጋገም ይቀየራል።",
|
||||
"ctaJoinMesh": "ወደ መረቡ ይቀላቀሉ",
|
||||
"ctaFollowProject": "ፕሮጀክቱን ይከተሉ",
|
||||
"subcopy": "AI ክፍት፣ ጠንካራ እና የሚጋራ መሆን አለበት በሚሉ ለኦፕሬተሮች፣ ለአበልጣጫዎች እና ለማህበረሰቦች ተሠርቷል።",
|
||||
"imageAlt": "የHelexa ሄሊክስ ምስል"
|
||||
},
|
||||
|
||||
"intent": {
|
||||
"title": "Helexa ለምን አለች",
|
||||
"p1": "AI በምድር ላይ በጣም ኃይለኛው መሠረታዊ መዋቅር እየሆነ ነው። ነገር ግን ዛሬ፣ ያ ኃይል በግል ቅድሚያዎች፣ በጂኦግራፊያዊ ገደቦች እና በደካማ ኢኮኖሚዎች የተሳሰበ በጥቂት ኩባንያዎች ውስጥ ተሰብስቧል።",
|
||||
"p2Intro": "Helexa የተለየ ነገር ታስባለች፦",
|
||||
"bullet1": "ከአንድ ቦታ ሳይሆን ከሁሉም ቦታ የሚያድግ አእምሮ።",
|
||||
"bullet2": "ማንኛውም ሰው ሊያስተዋውቅበትና ሊጠቀምበት የሚችለው መረብ።",
|
||||
"bullet3": "በትእዛዝ ሳይሆን በፍላጎት ላይ የሚማር ስርዓት።",
|
||||
"bullet4": "ማህበረሰቦችን ለመተካት ሳይሆን የሚጠናከል ቴክኖሎጂ።",
|
||||
"closing": "Helexa መድረክ አይደለችም። ደመና አይደለችም።\nመረብ ነች — የተወለደ እና የሚሻሻል የነጻ ኦፕሬተሮች መረብ ሆና አዲስ ዓይነት አእምሮ የምታቀና."
|
||||
},
|
||||
|
||||
"whyNow": {
|
||||
"title": "ለAI የሚለው የመቀየር ጊዜ",
|
||||
"problemTitle": "ችግኙ",
|
||||
"problemBullet1": "AI ከቀድሞው ማንኛውም ቴክኖሎጂ የማይተካ ፍጥነት በመሰብሰብ ላይ ነው።",
|
||||
"problemBullet2": "የኮምፒውተር ኃይል መዳረሻ ችሎታን ያወራል፣ ይህም መዳረሻ ግን በቀስታ እየተጠበቀ ነው።",
|
||||
"problemBullet3": "የወጪ መከልከያዎች ምርምር እና የኢንተርፕራይዝ ጀማሪዎችን እና ማህበረሰቦችን እያወጡ ነው።",
|
||||
"problemBullet4": "የጄኦፖለቲካ እና የህግ ግፊት የዓለም አቀፍ መዳረሻን እያቀረበ ነው።",
|
||||
"problemBullet5": "የሞዴሎች ፈጣሪዎችና የሀርድዌር ኦፕሬተሮች ብዙ ጊዜ በራሳቸው የሚፈጥሩትን ዋጋ አያጋሩም።",
|
||||
"opportunityTitle": "እድሉ",
|
||||
"opportunityIntro": "ግን የተሰራጨ ዓለም የሚቻል ነው።",
|
||||
"opportunityBullet1": "ሺዎች የሚቆጠሩ የGPU ካርዶች በዓለም ዙሪያ አሁንም በትንሽ እየተጠቀሙባቸው ነው።",
|
||||
"opportunityBullet2": "ኦፕሬተሮች ለየሚሰጡት ኮምፒውተር ኃይል እውነተኛ እና ፍትሃዊ ክፍያ ይፈልጋሉ።",
|
||||
"opportunityBullet3": "ዲቨሎፐሮች ክፍት እና ከመቆጣጠር የተጠበቀ መሠረታዊ መዋቅር ይፈልጋሉ።",
|
||||
"opportunityBullet4": "ማህበረሰቦች በዲጂታል ስርዓቶቻቸው ውስጥ ስብስብ መንግስታዊነትን እና ጽኑ መሆን ይፈልጋሉ።",
|
||||
"opportunityBullet5": "የAI እድገት ባለመለኪያ ሁኔታ ከባለሙያ ደመናዎች በላይ ቀድሞ ሄዷል — አዳዲስ ቅርጾች ያስፈልጋሉ።",
|
||||
"opportunityClosing": "Helexa እነዚህ ኃይሎች በአንድ ጊዜ የሚገናኙበት ጊዜ ነው።"
|
||||
},
|
||||
|
||||
"howItWorks": {
|
||||
"title": "መረቡ እንዴት እንደሚሰራ",
|
||||
"operators": {
|
||||
"eyebrow": "ኦፕሬተሮች ኖዶችን ይያዙ",
|
||||
"title": "ማንኛውም ሰው የኮምፒውተር ኃይል ሊያቀርብ ይችላል።",
|
||||
"body": "ኦፕሬተሮች የHelexa ኖዶችን ያስኬዳሉ። የማንኛውንም ሞዴል እንዲሰሩ ያስችላሉ። በሀርድዌሩና በኢኮኖሚያዊ ውሳኔዎቻቸው ላይ ቁጥጥር ይጠብቃሉ። ምንም የፈቃድ ሂደት የለም፣ ጠባቂ መከላከያ የለም።"
|
||||
},
|
||||
"routing": {
|
||||
"eyebrow": "መረቡ አእምሮን ይመራል",
|
||||
"title": "ፍላጎት በመረቡ ውስጥ ይፈሳሳል።",
|
||||
"body": "Helexa እየማረከች የቆየበት አቅም ወዴት እንደሚገኝ፣ ፍላጎት የሚጨምርበት ቦታ የት እንደሆነ እና ምን ኖድ ለማቅረብ ተስማሚ እንደሆነ ትማራለች። መረቡ በተፈጥሯዊ መንገድ ይለመዳል — እያደገ ያለ ሄሊክስ እንደሆነ።"
|
||||
},
|
||||
"value": {
|
||||
"eyebrow": "ዋጋው ወደ ኋላ ይመለሳል",
|
||||
"title": "ሥራ ታረጋገጠ። ክፍያ ፍትሃዊ ነው።",
|
||||
"body": "እያንዳንዱ ስራ ክሪፕቶግራፊ የተፈረመ ደረሰኝ ይይዛል። ኦፕሬተሮች በሚሰጡት አእምሮ ላይ ገንዘብ ያገኛሉ። የመድረክ ታክስ የለም። ግልጽ ካልሆነ የክፍያ ሂደት የለም።"
|
||||
}
|
||||
},
|
||||
|
||||
"principles": {
|
||||
"title": "በመድረኮች ላይ ሳይሆን በመርሆዎች ላይ የተገነባ",
|
||||
"distributed": {
|
||||
"title": "በንድፍ የተበታተነ",
|
||||
"body": "አንድ የብቻ የእርምጃ መደበቂያ የለም። ማዕከላዊ ሥልጣን የለም። በእያንዳንዱ አዲስ ኦፕሬተር ጋር የሚጠናከር መረብ ነው።"
|
||||
},
|
||||
"participation": {
|
||||
"title": "ተሳትፎ ለሁሉም ክፍት",
|
||||
"body": "ኮምፒውተር ኃይል ካለህ ልዩ ልዩ ድርሻ ሊያስተዋውቅ ትችላለህ። መረቡ ሁሉንም ይቀበላል — ከኤጀጅ መሣሪያዎች፣ እስከ የቤት አገልጋዮች እና የዳታ ማእከላት ድረስ።"
|
||||
},
|
||||
"fairness": {
|
||||
"title": "ፍትህና ተመልካችነት",
|
||||
"body": "ገቢዎች በእውነተኛ ስራ ላይ ተመስርተዋል፣ በክሪፕቶግራፊ መሠረት ተረጋግጠዋል። ጥቁር ሳጥን የለም። የተደበቀ ክፍያ የለም።"
|
||||
},
|
||||
"evolving": {
|
||||
"title": "የሚያድግ አእምሮ",
|
||||
"body": "መረቡ ከፍላጎት ይማራል። ሞዴሎች የሚፈለጉበት ቦታ ላይ ይጫናሉ። አእምሮ በተባባሪነት ይበተናል።"
|
||||
}
|
||||
},
|
||||
|
||||
"roadAhead": {
|
||||
"title": "Helexa ምን ለመሆን እየተሻሻለች ነው",
|
||||
"p1": "ለሁሉም የሚያገለግል የዓለም አቀፍ የአእምሮ ንብርብር፣ በሄሊክስ ያለ የኖዶችና የማህበረሰቦች መረብ የሚደገፍ።",
|
||||
"p2": "ማቋረጥ፣ ፖለቲካ፣ ነጠላ ባለቤቶች እና ወደፊት ሊከሰት በሚችል እንኳን የሚቋቋም መረብ።",
|
||||
"p3": "ኦፕሬተሮች፣ አበልጣጫዎች እና ተጠቃሚዎች ሁሉም ተዋጊ የሚሆኑበት አዲስ የኢኮኖሚ ሞዴል።",
|
||||
"p4": "ፈጠራ ከጠርዝ የሚያድግ፣ ከማዕከል ሳይሆን፣ የሥራ አካባቢ ኢኮሲስተም።",
|
||||
"card": {
|
||||
"eyebrow": "የራዕይ አጭር እትም",
|
||||
"title": "ወደ የተጋራ የአእምሮ መረብ",
|
||||
"body": "Helexa ገና በቀዳሚ ደረጃ ላይ ይ beታል። ሀሳቦቹ ከአሁን እና ከተፈጠረው ተቀባይነት የበለጠ ትልቅ ናቸው — ይህም በተወሰነ ዓላማ ነው። መረቡ በእያንዳንዱ ደረጃ ይበቃል፣ ኦፕሬተሮችና አበልጣጫዎችም እድገቷን በጋራ ይፀናሉ።"
|
||||
}
|
||||
},
|
||||
|
||||
"joinMesh": {
|
||||
"badge": "የመጀመሪያ ደረጃ",
|
||||
"title": "መረቡ እየተፈጠረ ነው።",
|
||||
"titleHighlight": "እርስዎም ክፍል ሆነው ሊሳተፉበት ይችላሉ።",
|
||||
"lead": "ሀርድዌር ብትከናወኑ፣ ሞዴሎችን ብታበርኩ፣ ወይም በአጠቃላይ AI እንዴት እንደሚመራ ብታስቡ ሆነው፣ በዚህ መረብ ውስጥ ለእርስዎ የተዘጋበ ቦታ አለ።",
|
||||
"ctaRunNode": "ኖድ ያስኬዱ (በቅርቡ)",
|
||||
"ctaJoinAnnouncements": "የቀድሞ ማስታወቂያዎችን ይቀበሉ",
|
||||
"ctaExploreCode": "ኮድን ያስሱ",
|
||||
"footer": "የተጣሉ ፍራንቻዎች የሉም። አንድ ብቻ ባለቤት የለም። የሚመጣውን የአእምሮ ወደፊት በሚያቀና መልኩ በሰዎች፣ በሀርድዌር እና በሀሳቦች የተሠራ መረብ ብቻ ነው።"
|
||||
}
|
||||
}
|
||||
71
helexa.ai/src/i18n/resources/ar/account.json
Normal 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."
|
||||
}
|
||||
}
|
||||
21
helexa.ai/src/i18n/resources/ar/chat.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"title": "مساحة محادثة",
|
||||
"badge": "محادثة",
|
||||
"lead": "هذه هي واجهة المحادثة. قم بتوصيل منطق الحوار الخاص بك ومكوّنات واجهة المستخدم بهذه الصفحة.",
|
||||
"transcriptPlaceholder": "سجل المحادثة سيظهر هنا. اعرض رسائل النموذج والمستخدم في حاوية قابلة للتمرير، ويمكنك تجميعها حسب أدوار الحوار إذا رغبت.",
|
||||
"inputPlaceholder": "اكتب رسالة لبدء المحادثة…",
|
||||
"send": "إرسال",
|
||||
"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"
|
||||
}
|
||||
68
helexa.ai/src/i18n/resources/ar/common.json
Normal file
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"app": {
|
||||
"name": "helexa.ai"
|
||||
},
|
||||
"nav": {
|
||||
"home": "الصفحة الرئيسية",
|
||||
"docs": "التوثيق",
|
||||
"chat": "المحادثة",
|
||||
"mission": "Mission",
|
||||
"login": "Sign in",
|
||||
"register": "Sign up",
|
||||
"account": "Account",
|
||||
"logout": "Sign out"
|
||||
},
|
||||
"theme": {
|
||||
"toggle": {
|
||||
"toLight": "التبديل إلى الوضع الفاتح",
|
||||
"toDark": "التبديل إلى الوضع الداكن"
|
||||
}
|
||||
},
|
||||
"lang": {
|
||||
"bg": "البلغارية",
|
||||
"de": "الألمانية",
|
||||
"el": "اليونانية",
|
||||
"en": "الإنجليزية",
|
||||
"es": "الإسبانية",
|
||||
"et": "الإستونية",
|
||||
"fr": "الفرنسية",
|
||||
"he": "العبرية",
|
||||
"it": "الإيطالية",
|
||||
"nl": "الهولندية",
|
||||
"da": "الدنماركية",
|
||||
"fi": "الفنلندية",
|
||||
"ar": "العربية",
|
||||
"fa": "الفارسية",
|
||||
"sw": "السواحيلية",
|
||||
"ha": "الهوسا",
|
||||
"am": "الأمهرية",
|
||||
"yo": "اليوربا",
|
||||
"zu": "الزولو",
|
||||
"ma": "الدارجة المغربية",
|
||||
"ig": "الإيجبو",
|
||||
"ka": "الجورجية",
|
||||
"kk": "الكازاخية",
|
||||
"no": "النرويجية",
|
||||
"om": "الأورومو",
|
||||
"so": "الصومالية",
|
||||
"sv": "السويدية",
|
||||
"ti": "التيغرينية",
|
||||
"uz": "الأوزبكية",
|
||||
"wo": "الولوف",
|
||||
"pl": "البولندية",
|
||||
"pt": "البرتغالية",
|
||||
"ro": "الرومانية",
|
||||
"ru": "الروسية",
|
||||
"sr": "الصربية",
|
||||
"tr": "التركية",
|
||||
"uk": "الأوكرانية"
|
||||
},
|
||||
"footer": {
|
||||
"copyright": "© {{year}} helexa.ai"
|
||||
},
|
||||
"beta": {
|
||||
"tag": "Public beta",
|
||||
"message": "helexa is in open beta — expect rough edges. Your chats stay in your browser.",
|
||||
"dismiss": "Dismiss"
|
||||
}
|
||||
}
|
||||
103
helexa.ai/src/i18n/resources/ar/mission.json
Normal file
@@ -0,0 +1,103 @@
|
||||
{
|
||||
"hero": {
|
||||
"badge": "شكل جديد من الذكاء",
|
||||
"title": "شكل جديد من الذكاء",
|
||||
"lead": "هيليكسـا هي شبكة ذكاء اصطناعي ذاتية التنظيم، يشغّلها مشغّلون مستقلون. مفتوحة. موزعة. تتطور باستمرار.",
|
||||
"ctaJoinMesh": "انضم إلى الشبكة",
|
||||
"ctaFollowProject": "تابع المشروع",
|
||||
"subcopy": "صُممت للمشغّلين والبنّائين والمجتمعات التي تؤمن بأن الذكاء الاصطناعي يجب أن يكون مفتوحًا، مرنًا، ومشتركًا.",
|
||||
"imageAlt": "تصور حلزوني لشكل Helexa"
|
||||
},
|
||||
|
||||
"intent": {
|
||||
"title": "لماذا وُجدت Helexa",
|
||||
"p1": "الذكاء الاصطناعي يتحول إلى أقوى بنية تحتية على كوكب الأرض. لكن اليوم، هذه القوة مركّزة في أيدي عدد قليل من الشركات، تشكلها أولويات خاصة، وحدود جغرافية، واقتصادات هشة.",
|
||||
"p2Intro": "هيليكسـا تتخيل شيئًا مختلفًا:",
|
||||
"bullet1": "ذكاء ينمو من كل مكان، لا من نقطة واحدة.",
|
||||
"bullet2": "شبكة يمكن للجميع فيها أن يساهموا ويستفيدوا.",
|
||||
"bullet3": "نظام يتكيّف مع الطلب، لا مع الأوامر الفوقية.",
|
||||
"bullet4": "تكنولوجيا تقوّي المجتمعات بدلًا من أن تستبدلها.",
|
||||
"closing": "هيليكسـا ليست منصة. وليست سحابة.\nإنها شبكة — نسيج حيّ ومتطوّر من مشغّلين مستقلين يشكّلون نوعًا جديدًا من الذكاء."
|
||||
},
|
||||
|
||||
"whyNow": {
|
||||
"title": "نقطة تحوّل للذكاء الاصطناعي",
|
||||
"problemTitle": "المشكلة",
|
||||
"problemBullet1": "الذكاء الاصطناعي يتمركز أسرع من أي تكنولوجيا سبقته.",
|
||||
"problemBullet2": "الوصول إلى القدرة الحاسوبية يحدد الإمكانات، وهذا الوصول يضيق باستمرار.",
|
||||
"problemBullet3": "حواجز التكلفة تستبعد الباحثين والشركات الناشئة والمجتمعات.",
|
||||
"problemBullet4": "الضغوط الجيوسياسية والتنظيمية تهدّد التوافر العالمي.",
|
||||
"problemBullet5": "مُنشئو النماذج ومشغّلو العتاد نادرًا ما يشاركون في القيمة التي ينتجونها.",
|
||||
"opportunityTitle": "الفرصة",
|
||||
"opportunityIntro": "لكن عالمًا موزعًا ممكن.",
|
||||
"opportunityBullet1": "آلاف وحدات الـGPU حول العالم تعمل بأقل من طاقتها.",
|
||||
"opportunityBullet2": "المشغّلون يريدون تعويضًا عادلًا عن الحوسبة التي يقدّمونها.",
|
||||
"opportunityBullet3": "المطورون يريدون بنية تحتية مفتوحة ومقاوِمة للرقابة.",
|
||||
"opportunityBullet4": "المجتمعات تريد سيادة ومرونة في أنظمتها الرقمية.",
|
||||
"opportunityBullet5": "نمو الذكاء الاصطناعي تجاوز البنى السحابية التقليدية — نحن بحاجة إلى أشكال جديدة.",
|
||||
"opportunityClosing": "هيليكسـا هي لحظة تلاقي هذه القوى."
|
||||
},
|
||||
|
||||
"howItWorks": {
|
||||
"title": "كيف تتشكّل الشبكة",
|
||||
"operators": {
|
||||
"eyebrow": "المشغّلون يديرون العقد",
|
||||
"title": "أي شخص يمكنه المساهمة بقوة حاسوبية.",
|
||||
"body": "المشغّلون يشغّلون عُقد Helexa. يقررون أي النماذج يستضيفون. يبقون متحكمين في عتادهم واقتصادهم. لا موافقات، ولا حراس بوابة."
|
||||
},
|
||||
"routing": {
|
||||
"eyebrow": "الشبكة توجّه الذكاء",
|
||||
"title": "الطلب يتدفق عبر الشبكة.",
|
||||
"body": "هيليكسـا تتعلم أين توجد السعة، وأين يرتفع الطلب، وأي العقد أنسب لخدمة الطلبات. الشبكة تتكيّف بشكل عضوي — مثل حلزون ينمو."
|
||||
},
|
||||
"value": {
|
||||
"eyebrow": "القيمة تعود إلى المصدر",
|
||||
"title": "العمل مُثبت. والدفع عادل.",
|
||||
"body": "كل مهمة تحمل إيصالًا تشفيريًا. يكسب المشغّلون مقابل الذكاء الذي يساعدون في توفيره. لا ضرائب منصة. لا فواتير غامضة."
|
||||
}
|
||||
},
|
||||
|
||||
"principles": {
|
||||
"title": "مبنية على مبادئ، لا على منصات",
|
||||
"distributed": {
|
||||
"title": "موزعة بالتصميم",
|
||||
"body": "لا توجد نقطة فشل واحدة. لا توجد سلطة مركزية. شبكة تقوى مع كل مشغّل جديد."
|
||||
},
|
||||
"participation": {
|
||||
"title": "مشاركة مفتوحة",
|
||||
"body": "إذا كان لديك قدرة حاسوبية، يمكنك المساهمة. الشبكة ترحّب بالجميع — من الحافة، إلى الخوادم المنزلية، إلى مراكز البيانات."
|
||||
},
|
||||
"fairness": {
|
||||
"title": "العدالة والشفافية",
|
||||
"body": "الأرباح مبنية على عمل حقيقي، مُثبت تشفيريًا. لا صناديق سوداء. لا رسوم خفية."
|
||||
},
|
||||
"evolving": {
|
||||
"title": "ذكاء يتطور باستمرار",
|
||||
"body": "الشبكة تتعلم من الطلب. تُحمَّل النماذج حيث تكون مطلوبة. الذكاء ينتشر من خلال التعاون."
|
||||
}
|
||||
},
|
||||
|
||||
"roadAhead": {
|
||||
"title": "إلى ماذا تطمح Helexa أن تصبح",
|
||||
"p1": "طبقة ذكاء عالمية تنتمي للجميع، مدفوعة بحلزونة من العقد والمجتمعات.",
|
||||
"p2": "شبكة مقاومة للانقطاعات، والسياسة، والاحتكارات، والإخفاق.",
|
||||
"p3": "نموذج اقتصادي جديد يستفيد منه المشغّلون والبنّاؤون والمستخدمون جميعًا.",
|
||||
"p4": "نظام بيئي تنمو فيه الابتكارات من الأطراف — لا من المركز.",
|
||||
"card": {
|
||||
"eyebrow": "لمحة عن الرؤية",
|
||||
"title": "نحو شبكة ذكاء مشتركة",
|
||||
"body": "هيليكسـا في مرحلة مبكرة. الأفكار أكبر من التنفيذ الحالي — وهذا مقصود. الشبكة ستنمو تدريجيًا، بينما يشكّل المشغّلون والبنّاؤون مسار تطورها."
|
||||
}
|
||||
},
|
||||
|
||||
"joinMesh": {
|
||||
"badge": "مرحلة مبكرة",
|
||||
"title": "الشبكة قيد التشكل.",
|
||||
"titleHighlight": "ويمكنك أن تكون جزءًا منها.",
|
||||
"lead": "سواءً كنت تدير عتادًا، تبني نماذج، أو يهمّك ببساطة كيف يُدار الذكاء الاصطناعي — هناك مكان لك داخل الشبكة.",
|
||||
"ctaRunNode": "تشغيل عقدة (قريبًا)",
|
||||
"ctaJoinAnnouncements": "انضم إلى الإعلانات المبكرة",
|
||||
"ctaExploreCode": "استكشف الشفرة",
|
||||
"footer": "لا حدائق مسوّرة. لا مالك واحد. مجرد شبكة من أشخاص وعتاد وأفكار — تصوغ مستقبلًا مختلفًا للذكاء."
|
||||
}
|
||||
}
|
||||
71
helexa.ai/src/i18n/resources/bg/account.json
Normal 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."
|
||||
}
|
||||
}
|
||||
21
helexa.ai/src/i18n/resources/bg/chat.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"title": "Работно пространство за разговори",
|
||||
"badge": "Чат",
|
||||
"lead": "Това е изгледът за чат. Тук можеш да свържеш своята логика за разговори и потребителски интерфейс.",
|
||||
"transcriptPlaceholder": "Тук ще се показва историята на чата. Визуализирай съобщенията от модела и потребителя в превъртащ се контейнер, по желание групирани по ход.",
|
||||
"inputPlaceholder": "Напиши съобщение, за да започнеш разговора…",
|
||||
"send": "Изпрати",
|
||||
"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"
|
||||
}
|
||||
68
helexa.ai/src/i18n/resources/bg/common.json
Normal file
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"app": {
|
||||
"name": "helexa.ai"
|
||||
},
|
||||
"nav": {
|
||||
"home": "Начало",
|
||||
"docs": "Документация",
|
||||
"chat": "Чат",
|
||||
"mission": "Mission",
|
||||
"login": "Sign in",
|
||||
"register": "Sign up",
|
||||
"account": "Account",
|
||||
"logout": "Sign out"
|
||||
},
|
||||
"theme": {
|
||||
"toggle": {
|
||||
"toLight": "Превключване към светла тема",
|
||||
"toDark": "Превключване към тъмна тема"
|
||||
}
|
||||
},
|
||||
"lang": {
|
||||
"bg": "български",
|
||||
"de": "немски",
|
||||
"el": "гръцки",
|
||||
"en": "английски",
|
||||
"es": "испански",
|
||||
"et": "естонски",
|
||||
"fr": "френски",
|
||||
"he": "иврит",
|
||||
"it": "италиански",
|
||||
"nl": "Нидерландски",
|
||||
"da": "Датски",
|
||||
"fi": "Фински",
|
||||
"no": "Норвежки",
|
||||
"sv": "Шведски",
|
||||
"ar": "Арабски",
|
||||
"fa": "персийски",
|
||||
"sw": "суахили",
|
||||
"ha": "хауза",
|
||||
"am": "амхарски",
|
||||
"yo": "йоруба",
|
||||
"zu": "зулу",
|
||||
"ma": "дариджа",
|
||||
"ig": "игбо",
|
||||
"ka": "грузински",
|
||||
"kk": "казахски",
|
||||
"om": "оромо",
|
||||
"so": "сомалийски",
|
||||
"ti": "тигриња",
|
||||
"uz": "узбекски",
|
||||
"wo": "волоф",
|
||||
"pl": "полски",
|
||||
"pt": "португалски",
|
||||
"ro": "румънски",
|
||||
"ru": "руски",
|
||||
"sr": "сръбски",
|
||||
"tr": "турски",
|
||||
"uk": "украински"
|
||||
},
|
||||
"footer": {
|
||||
"copyright": "© {{year}} helexa.ai"
|
||||
},
|
||||
"beta": {
|
||||
"tag": "Public beta",
|
||||
"message": "helexa is in open beta — expect rough edges. Your chats stay in your browser.",
|
||||
"dismiss": "Dismiss"
|
||||
}
|
||||
}
|
||||
103
helexa.ai/src/i18n/resources/bg/mission.json
Normal file
@@ -0,0 +1,103 @@
|
||||
{
|
||||
"hero": {
|
||||
"badge": "Нова форма на интелигентност",
|
||||
"title": "Нова форма на интелигентност",
|
||||
"lead": "Helexa е самоорганизираща се AI мрежа, задвижвана от независими оператори. Отворена. Разпределена. Еволюираща.",
|
||||
"ctaJoinMesh": "Присъедини се към мрежата",
|
||||
"ctaFollowProject": "Следвай проекта",
|
||||
"subcopy": "Създадена за оператори, разработчици и общности, които вярват, че AI трябва да бъде отворен, устойчив и споделен.",
|
||||
"imageAlt": "Визуализация на хеликса на Helexa"
|
||||
},
|
||||
|
||||
"intent": {
|
||||
"title": "Защо съществува Helexa",
|
||||
"p1": "AI се превръща в най-мощната инфраструктура на Земята. Но днес тази сила е концентрирана в шепа корпорации, оформяна от частни приоритети, географски ограничения и крехка икономика.",
|
||||
"p2Intro": "Helexa си представя нещо различно:",
|
||||
"bullet1": "Интелигентност, която расте от всякъде, не от едно място.",
|
||||
"bullet2": "Мрежа, в която всеки може да допринася и да се възползва.",
|
||||
"bullet3": "Система, която се адаптира към търсенето, не към заповеди.",
|
||||
"bullet4": "Технология, която подсилва общностите вместо да ги заменя.",
|
||||
"closing": "Helexa не е платформа. Не е и облак.\nТя е мрежа — жива, еволюираща решетка от независими оператори, която оформя нов вид интелигентност."
|
||||
},
|
||||
|
||||
"whyNow": {
|
||||
"title": "Преломен момент за AI",
|
||||
"problemTitle": "Проблемът",
|
||||
"problemBullet1": "AI се централизира по-бързо от всяка предишна технология.",
|
||||
"problemBullet2": "Достъпът до изчислителни ресурси определя възможностите, а достъпът се стеснява.",
|
||||
"problemBullet3": "Ценовите бариери изключват изследователи, стартъпи и общности.",
|
||||
"problemBullet4": "Геополитически и регулаторни натиск застрашават глобалната достъпност.",
|
||||
"problemBullet5": "Създателите на модели и операторите на хардуер рядко споделят стойността, която създават.",
|
||||
"opportunityTitle": "Възможността",
|
||||
"opportunityIntro": "Но един разпределен свят е възможен.",
|
||||
"opportunityBullet1": "Хиляди GPU машини вече са недоизползвани по целия свят.",
|
||||
"opportunityBullet2": "Операторите искат честно заплащане за изчислителната си мощ.",
|
||||
"opportunityBullet3": "Разработчиците искат отворена, устойчива на цензура инфраструктура.",
|
||||
"opportunityBullet4": "Общностите искат суверенитет и устойчивост в дигиталните системи.",
|
||||
"opportunityBullet5": "Ръстът на AI изпревари традиционните облаци — нужни са нови форми.",
|
||||
"opportunityClosing": "Helexa е моментът, в който тези сили се подравняват."
|
||||
},
|
||||
|
||||
"howItWorks": {
|
||||
"title": "Как се формира мрежата",
|
||||
"operators": {
|
||||
"eyebrow": "Операторите стартират възли",
|
||||
"title": "Всеки може да допринесе с изчислителна мощ.",
|
||||
"body": "Операторите стартират възли на Helexa. Те решават кои модели да хостват. Остават в контрол над своя хардуер и своята икономика. Без одобрения, без посредници."
|
||||
},
|
||||
"routing": {
|
||||
"eyebrow": "Мрежата маршрутизира интелигентността",
|
||||
"title": "Търсенето тече през мрежата.",
|
||||
"body": "Helexa научава къде има капацитет, къде търсенето нараства и кои възли са най-подходящи да обслужват заявките. Мрежата се адаптира органично — като растяща спирала."
|
||||
},
|
||||
"value": {
|
||||
"eyebrow": "Стойността се връща обратно",
|
||||
"title": "Работата е доказуема. Заплащането е честно.",
|
||||
"body": "Всяка задача носи криптографско потвърждение. Операторите печелят за интелигентността, която помагат да се предостави. Без платформи данъкоплатци. Без непрозрачни сметки."
|
||||
}
|
||||
},
|
||||
|
||||
"principles": {
|
||||
"title": "Изградена върху принципи, не върху платформи",
|
||||
"distributed": {
|
||||
"title": "Разпределена по замисъл",
|
||||
"body": "Без една-единствена точка на отказ. Без централен орган. Мрежа, която става по-силна с всеки нов оператор."
|
||||
},
|
||||
"participation": {
|
||||
"title": "Отворено участие",
|
||||
"body": "Ако имаш изчислителни ресурси, можеш да допринесеш. Мрежата посреща всички — edge устройства, домашни сървъри, центрове за данни."
|
||||
},
|
||||
"fairness": {
|
||||
"title": "Справедливост и прозрачност",
|
||||
"body": "Приходите се базират на реална свършена работа, криптографски потвърдена. Без черни кутии. Без скрити такси."
|
||||
},
|
||||
"evolving": {
|
||||
"title": "Еволюираща интелигентност",
|
||||
"body": "Мрежата учи от търсенето. Моделите се зареждат там, където са нужни. Интелигентността се разпространява чрез сътрудничество."
|
||||
}
|
||||
},
|
||||
|
||||
"roadAhead": {
|
||||
"title": "Какво цели да стане Helexa",
|
||||
"p1": "Глобален слой от интелигентност, който принадлежи на всички, задвижван от спирала от възли и общности.",
|
||||
"p2": "Мрежа, устойчива на сривове, политика, монополи и провали.",
|
||||
"p3": "Нов икономически модел, в който оператори, създатели и потребители печелят заедно.",
|
||||
"p4": "Екосистема, в която иновациите растат от периферията — не от центъра.",
|
||||
"card": {
|
||||
"eyebrow": "Миг от визията",
|
||||
"title": "Към споделена мрежа от интелигентност",
|
||||
"body": "Helexa е в ранен етап. Идеите са по-големи от реализацията — и това е умишлено. Мрежата ще расте итеративно, оформяна от операторите и разработчиците."
|
||||
}
|
||||
},
|
||||
|
||||
"joinMesh": {
|
||||
"badge": "Ранен етап",
|
||||
"title": "Мрежата се формира.",
|
||||
"titleHighlight": "Ти можеш да бъдеш част от нея.",
|
||||
"lead": "Независимо дали управляваш хардуер, изграждаш модели или просто ти пука как се управлява AI, има място за теб в мрежата.",
|
||||
"ctaRunNode": "Пусни възел (скоро)",
|
||||
"ctaJoinAnnouncements": "Включи се в ранните известия",
|
||||
"ctaExploreCode": "Разгледай кода",
|
||||
"footer": "Без оградени градини. Без един собственик. Само мрежа от хора, хардуер и идеи — които заедно създават различно бъдеще за интелигентността."
|
||||
}
|
||||
}
|
||||
71
helexa.ai/src/i18n/resources/da/account.json
Normal 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."
|
||||
}
|
||||
}
|
||||
21
helexa.ai/src/i18n/resources/da/chat.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"title": "Samtalearbejdsområde",
|
||||
"badge": "Chat",
|
||||
"lead": "Dette er chatvisningen. Tilføj din samtalelogik og dine UI‑komponenter til denne side.",
|
||||
"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",
|
||||
"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"
|
||||
}
|
||||
68
helexa.ai/src/i18n/resources/da/common.json
Normal file
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"app": {
|
||||
"name": "helexa.ai"
|
||||
},
|
||||
"nav": {
|
||||
"home": "Hjem",
|
||||
"docs": "Dokumentation",
|
||||
"chat": "Chat",
|
||||
"mission": "Mission",
|
||||
"login": "Sign in",
|
||||
"register": "Sign up",
|
||||
"account": "Account",
|
||||
"logout": "Sign out"
|
||||
},
|
||||
"theme": {
|
||||
"toggle": {
|
||||
"toLight": "Skift til lyst tema",
|
||||
"toDark": "Skift til mørkt tema"
|
||||
}
|
||||
},
|
||||
"lang": {
|
||||
"am": "Amharisk",
|
||||
"ar": "Arabisk",
|
||||
"bg": "Bulgarsk",
|
||||
"da": "Dansk",
|
||||
"de": "Tysk",
|
||||
"el": "Græsk",
|
||||
"en": "Engelsk",
|
||||
"es": "Spansk",
|
||||
"et": "Estisk",
|
||||
"fa": "Persisk",
|
||||
"fi": "Finsk",
|
||||
"fr": "Fransk",
|
||||
"ha": "Hausa",
|
||||
"he": "Hebraisk",
|
||||
"ig": "Igbo",
|
||||
"it": "Italiensk",
|
||||
"ka": "Georgisk",
|
||||
"kk": "Kasakhisk",
|
||||
"ma": "Darija",
|
||||
"nl": "Hollandsk",
|
||||
"no": "Norsk",
|
||||
"om": "Oromo",
|
||||
"pl": "Polsk",
|
||||
"pt": "Portugisisk",
|
||||
"ro": "Rumænsk",
|
||||
"ru": "Russisk",
|
||||
"so": "Somalisk",
|
||||
"sr": "Serbisk",
|
||||
"sv": "Svensk",
|
||||
"sw": "Swahili",
|
||||
"ti": "Tigrinya",
|
||||
"tr": "Tyrkisk",
|
||||
"uk": "Ukrainsk",
|
||||
"uz": "Usbekisk",
|
||||
"wo": "Wolof",
|
||||
"yo": "Yoruba",
|
||||
"zu": "Zulu"
|
||||
},
|
||||
"footer": {
|
||||
"copyright": "© {{year}} helexa.ai"
|
||||
},
|
||||
"beta": {
|
||||
"tag": "Public beta",
|
||||
"message": "helexa is in open beta — expect rough edges. Your chats stay in your browser.",
|
||||
"dismiss": "Dismiss"
|
||||
}
|
||||
}
|
||||
103
helexa.ai/src/i18n/resources/da/mission.json
Normal file
@@ -0,0 +1,103 @@
|
||||
{
|
||||
"hero": {
|
||||
"badge": "En ny form for intelligens",
|
||||
"title": "En ny form for intelligens",
|
||||
"lead": "Helexa er et selvorganiserende AI‑mesh drevet af uafhængige operatører. Åbent. Distribueret. Under udvikling.",
|
||||
"ctaJoinMesh": "Deltag i mesh'en",
|
||||
"ctaFollowProject": "Følg projektet",
|
||||
"subcopy": "Bygget til operatører, udviklere og fællesskaber, der mener, at AI skal være åbent, robust og delt.",
|
||||
"imageAlt": "Helexa helix‑visualisering"
|
||||
},
|
||||
|
||||
"intent": {
|
||||
"title": "Hvorfor Helexa findes",
|
||||
"p1": "AI er ved at blive den mest magtfulde infrastruktur på Jorden. Men i dag er den magt koncentreret hos en håndfuld virksomheder, formet af private prioriteter, geografiske begrænsninger og skrøbelig økonomi.",
|
||||
"p2Intro": "Helexa forestiller sig noget andet:",
|
||||
"bullet1": "Intelligens, der vokser frem overalt – ikke ét sted.",
|
||||
"bullet2": "Et netværk, hvor alle kan bidrage og få gavn.",
|
||||
"bullet3": "Et system, der tilpasser sig efter efterspørgsel, ikke direktiver.",
|
||||
"bullet4": "Teknologi, der styrker fællesskaber i stedet for at erstatte dem.",
|
||||
"closing": "Helexa er ikke en platform. Det er ikke en cloud.\nDet er et mesh – et levende, udviklende gitter af uafhængige operatører, der danner en ny form for intelligens."
|
||||
},
|
||||
|
||||
"whyNow": {
|
||||
"title": "Et vendepunkt for AI",
|
||||
"problemTitle": "Problemet",
|
||||
"problemBullet1": "AI centraliseres hurtigere end nogen tidligere teknologi.",
|
||||
"problemBullet2": "Adgang til compute definerer kapacitet, og adgangen bliver snævrere.",
|
||||
"problemBullet3": "Omkostninger udelukker forskere, startups og fællesskaber.",
|
||||
"problemBullet4": "Geopolitiske og regulatoriske pres truer global tilgængelighed.",
|
||||
"problemBullet5": "Skaberne af modeller og operatørerne af hardware deler sjældent i den værdi, de producerer.",
|
||||
"opportunityTitle": "Muligheden",
|
||||
"opportunityIntro": "Men en distribueret verden er mulig.",
|
||||
"opportunityBullet1": "Tusindvis af GPU'er står allerede uudnyttede rundt om i verden.",
|
||||
"opportunityBullet2": "Operatører ønsker retfærdig kompensation for compute.",
|
||||
"opportunityBullet3": "Udviklere ønsker åben, censurresistent infrastruktur.",
|
||||
"opportunityBullet4": "Fællesskaber ønsker suverænitet og robusthed i digitale systemer.",
|
||||
"opportunityBullet5": "AI's vækst har overhalet traditionelle clouds – der er brug for nye former.",
|
||||
"opportunityClosing": "Helexa er øjeblikket, hvor disse kræfter mødes."
|
||||
},
|
||||
|
||||
"howItWorks": {
|
||||
"title": "Sådan dannes mesh'en",
|
||||
"operators": {
|
||||
"eyebrow": "Operatører kører noder",
|
||||
"title": "Alle kan bidrage med compute.",
|
||||
"body": "Operatører kører Helexa‑noder. De beslutter, hvilke modeller der skal hostes. De bevarer kontrollen over deres hardware og økonomi. Ingen godkendelser, ingen gatekeepere."
|
||||
},
|
||||
"routing": {
|
||||
"eyebrow": "Mesh'en dirigerer intelligens",
|
||||
"title": "Efterspørgslen flyder gennem netværket.",
|
||||
"body": "Helexa lærer, hvor kapacitet findes, hvor efterspørgslen stiger, og hvilke noder der er bedst egnet til at håndtere forespørgsler. Mesh'en tilpasser sig organisk – som en voksende helix."
|
||||
},
|
||||
"value": {
|
||||
"eyebrow": "Værdien flyder tilbage",
|
||||
"title": "Arbejdet bevises. Betalingen er retfærdig.",
|
||||
"body": "Hver opgave bærer en kryptografisk kvittering. Operatører tjener på den intelligens, de hjælper med at levere. Ingen platformsskat. Ingen uigennemsigtig fakturering."
|
||||
}
|
||||
},
|
||||
|
||||
"principles": {
|
||||
"title": "Bygget på principper, ikke platforme",
|
||||
"distributed": {
|
||||
"title": "Distribueret fra starten",
|
||||
"body": "Ingen enkelt fejlkilde. Ingen central autoritet. Et netværk, der bliver stærkere for hver ny operatør."
|
||||
},
|
||||
"participation": {
|
||||
"title": "Åben deltagelse",
|
||||
"body": "Hvis du har compute, kan du bidrage. Mesh'en byder alle velkommen – edge, hjemmeserver, datacenter."
|
||||
},
|
||||
"fairness": {
|
||||
"title": "Retfærdighed og gennemsigtighed",
|
||||
"body": "Indtjening er baseret på reelt arbejde, kryptografisk verificeret. Ingen sorte bokse. Ingen skjulte gebyrer."
|
||||
},
|
||||
"evolving": {
|
||||
"title": "Udviklende intelligens",
|
||||
"body": "Mesh'en lærer af efterspørgslen. Modeller loades dér, hvor de behøves. Intelligens spredes gennem samarbejde."
|
||||
}
|
||||
},
|
||||
|
||||
"roadAhead": {
|
||||
"title": "Hvad Helexa sigter mod at blive",
|
||||
"p1": "Et globalt intelligenslag, der tilhører alle, drevet af en helix af noder og fællesskaber.",
|
||||
"p2": "Et netværk, der er robust over for nedbrud, politik, monopoler og fejl.",
|
||||
"p3": "En ny økonomisk model, hvor operatører, udviklere og brugere alle får udbytte.",
|
||||
"p4": "Et økosystem, hvor innovation vokser ud fra kanterne – ikke centrum.",
|
||||
"card": {
|
||||
"eyebrow": "Visionsoversigt",
|
||||
"title": "Mod et delt intelligens‑mesh",
|
||||
"body": "Helexa er i en tidlig fase. Idéerne er større end implementationen – og det er med vilje. Netværket vil vokse gradvist, hvor operatører og udviklere sammen former dets udvikling."
|
||||
}
|
||||
},
|
||||
|
||||
"joinMesh": {
|
||||
"badge": "Tidlig fase",
|
||||
"title": "Mesh'en tager form.",
|
||||
"titleHighlight": "Du kan være en del af den.",
|
||||
"lead": "Uanset om du kører hardware, bygger modeller eller blot går op i, hvordan AI styres, er der en plads til dig i mesh'en.",
|
||||
"ctaRunNode": "Kør en node (snart)",
|
||||
"ctaJoinAnnouncements": "Deltag i tidlige annonceringer",
|
||||
"ctaExploreCode": "Udforsk koden",
|
||||
"footer": "Ingen lukkede haver. Ingen enkelt ejer. Bare et mesh af mennesker, hardware og idéer – der sammen former en anderledes fremtid for intelligens."
|
||||
}
|
||||
}
|
||||
71
helexa.ai/src/i18n/resources/de/account.json
Normal 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."
|
||||
}
|
||||
}
|
||||
21
helexa.ai/src/i18n/resources/de/chat.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"title": "Konversationsbereich",
|
||||
"badge": "Chat",
|
||||
"lead": "Dies ist die Chat-Ansicht. Binde hier deine Konversationslogik und UI-Komponenten ein.",
|
||||
"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",
|
||||
"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"
|
||||
}
|
||||
68
helexa.ai/src/i18n/resources/de/common.json
Normal file
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"app": {
|
||||
"name": "helexa.ai"
|
||||
},
|
||||
"nav": {
|
||||
"home": "Startseite",
|
||||
"docs": "Dokumentation",
|
||||
"chat": "Chat",
|
||||
"mission": "Mission",
|
||||
"login": "Sign in",
|
||||
"register": "Sign up",
|
||||
"account": "Account",
|
||||
"logout": "Sign out"
|
||||
},
|
||||
"theme": {
|
||||
"toggle": {
|
||||
"toLight": "In den hellen Modus wechseln",
|
||||
"toDark": "In den dunklen Modus wechseln"
|
||||
}
|
||||
},
|
||||
"lang": {
|
||||
"bg": "Bulgarisch",
|
||||
"de": "Deutsch",
|
||||
"el": "Griechisch",
|
||||
"en": "Englisch",
|
||||
"es": "Spanisch",
|
||||
"et": "Estnisch",
|
||||
"fr": "Französisch",
|
||||
"he": "Hebräisch",
|
||||
"it": "Italienisch",
|
||||
"nl": "Niederländisch",
|
||||
"da": "Dänisch",
|
||||
"fi": "Finnisch",
|
||||
"no": "Norwegisch",
|
||||
"sv": "Schwedisch",
|
||||
"ar": "Arabisch",
|
||||
"fa": "Persisch",
|
||||
"sw": "Suaheli",
|
||||
"ha": "Hausa",
|
||||
"am": "Amharisch",
|
||||
"yo": "Yoruba",
|
||||
"zu": "Zulu",
|
||||
"ma": "Darija",
|
||||
"ig": "Igbo",
|
||||
"ka": "Georgisch",
|
||||
"kk": "Kasachisch",
|
||||
"om": "Oromo",
|
||||
"so": "Somali",
|
||||
"ti": "Tigrinya",
|
||||
"uz": "Usbekisch",
|
||||
"wo": "Wolof",
|
||||
"pl": "Polnisch",
|
||||
"pt": "Portugiesisch",
|
||||
"ro": "Rumänisch",
|
||||
"ru": "Russisch",
|
||||
"sr": "Serbisch",
|
||||
"tr": "Türkisch",
|
||||
"uk": "Ukrainisch"
|
||||
},
|
||||
"footer": {
|
||||
"copyright": "© {{year}} helexa.ai"
|
||||
},
|
||||
"beta": {
|
||||
"tag": "Public beta",
|
||||
"message": "helexa is in open beta — expect rough edges. Your chats stay in your browser.",
|
||||
"dismiss": "Dismiss"
|
||||
}
|
||||
}
|
||||
103
helexa.ai/src/i18n/resources/de/mission.json
Normal file
@@ -0,0 +1,103 @@
|
||||
{
|
||||
"hero": {
|
||||
"badge": "Eine neue Form von Intelligenz",
|
||||
"title": "Eine neue Form von Intelligenz",
|
||||
"lead": "Helexa ist ein selbstorganisiertes KI‑Mesh, betrieben von unabhängigen Operatoren. Offen. Dezentral. Im Wandel.",
|
||||
"ctaJoinMesh": "Dem Mesh beitreten",
|
||||
"ctaFollowProject": "Dem Projekt folgen",
|
||||
"subcopy": "Geschaffen für Operatoren, Builder und Communities, die glauben, dass KI offen, widerstandsfähig und geteilt sein sollte.",
|
||||
"imageAlt": "Helexa‑Helix‑Visualisierung"
|
||||
},
|
||||
|
||||
"intent": {
|
||||
"title": "Warum es Helexa gibt",
|
||||
"p1": "KI wird zur mächtigsten Infrastruktur auf der Erde. Doch heute ist diese Macht in den Händen weniger Konzerne konzentriert – geprägt von privaten Prioritäten, geografischen Grenzen und fragilen Geschäftsmodellen.",
|
||||
"p2Intro": "Helexa stellt sich etwas anderes vor:",
|
||||
"bullet1": "Intelligenz, die überall wächst – nicht nur an einem Ort.",
|
||||
"bullet2": "Ein Netzwerk, in dem jeder beitragen und profitieren kann.",
|
||||
"bullet3": "Ein System, das sich an Nachfrage anpasst, nicht an Vorgaben.",
|
||||
"bullet4": "Technologie, die Gemeinschaften stärkt, statt sie zu ersetzen.",
|
||||
"closing": "Helexa ist keine Plattform. Es ist keine Cloud.\nEs ist ein Mesh – ein lebendiges, sich entwickelndes Geflecht unabhängiger Operatoren, das eine neue Form von Intelligenz bildet."
|
||||
},
|
||||
|
||||
"whyNow": {
|
||||
"title": "Ein Wendepunkt für KI",
|
||||
"problemTitle": "Das Problem",
|
||||
"problemBullet1": "KI zentralisiert sich schneller als jede Technologie zuvor.",
|
||||
"problemBullet2": "Zugang zu Rechenressourcen bestimmt die Fähigkeiten – und dieser Zugang verengt sich.",
|
||||
"problemBullet3": "Kostenbarrieren schließen Forschende, Startups und Communities aus.",
|
||||
"problemBullet4": "Geopolitische und regulatorische Spannungen bedrohen die globale Verfügbarkeit.",
|
||||
"problemBullet5": "Die Schöpfer von Modellen und die Betreiber von Hardware teilen selten den Wert, den sie erzeugen.",
|
||||
"opportunityTitle": "Die Chance",
|
||||
"opportunityIntro": "Aber eine verteilte Welt ist möglich.",
|
||||
"opportunityBullet1": "Tausende GPUs weltweit sind heute bereits unterausgelastet.",
|
||||
"opportunityBullet2": "Operatoren wollen eine faire Vergütung für ihre Rechenleistung.",
|
||||
"opportunityBullet3": "Entwicklerinnen und Entwickler wünschen sich offene, zensurresistente Infrastruktur.",
|
||||
"opportunityBullet4": "Communities wollen Souveränität und Resilienz in digitalen Systemen.",
|
||||
"opportunityBullet5": "Das Wachstum der KI hat klassische Clouds überholt – es braucht neue Formen.",
|
||||
"opportunityClosing": "Helexa ist der Moment, in dem sich diese Kräfte ausrichten."
|
||||
},
|
||||
|
||||
"howItWorks": {
|
||||
"title": "Wie sich das Mesh bildet",
|
||||
"operators": {
|
||||
"eyebrow": "Operatoren betreiben Nodes",
|
||||
"title": "Jede und jeder kann Rechenleistung beisteuern.",
|
||||
"body": "Operatoren betreiben Helexa‑Nodes. Sie entscheiden, welche Modelle sie hosten. Sie behalten die Kontrolle über ihre Hardware und ihre Wirtschaftlichkeit. Keine Genehmigungen, keine Gatekeeper."
|
||||
},
|
||||
"routing": {
|
||||
"eyebrow": "Das Mesh leitet Intelligenz",
|
||||
"title": "Nachfrage fließt durch das Netzwerk.",
|
||||
"body": "Helexa lernt, wo Kapazität vorhanden ist, wo die Nachfrage steigt und welche Nodes sich am besten für Anfragen eignen. Das Mesh passt sich organisch an – wie eine wachsende Helix."
|
||||
},
|
||||
"value": {
|
||||
"eyebrow": "Wert fließt zurück",
|
||||
"title": "Arbeit wird bewiesen. Bezahlung ist fair.",
|
||||
"body": "Jeder Job trägt einen kryptografischen Beleg. Operatoren verdienen an der Intelligenz mit, die sie bereitstellen. Keine Plattformsteuer. Keine intransparente Abrechnung."
|
||||
}
|
||||
},
|
||||
|
||||
"principles": {
|
||||
"title": "Auf Prinzipien gebaut, nicht auf Plattformen",
|
||||
"distributed": {
|
||||
"title": "Von Grund auf verteilt",
|
||||
"body": "Kein Single Point of Failure. Keine zentrale Instanz. Ein Netzwerk, das mit jedem neuen Operator stärker wird."
|
||||
},
|
||||
"participation": {
|
||||
"title": "Offene Teilhabe",
|
||||
"body": "Wenn du Rechenleistung hast, kannst du beitragen. Das Mesh heißt alle willkommen – Edge, Heimserver oder Rechenzentrum."
|
||||
},
|
||||
"fairness": {
|
||||
"title": "Fairness & Transparenz",
|
||||
"body": "Einnahmen basieren auf echter, kryptografisch verifizierter Arbeit. Keine Blackboxen. Keine versteckten Gebühren."
|
||||
},
|
||||
"evolving": {
|
||||
"title": "Sich entwickelnde Intelligenz",
|
||||
"body": "Das Mesh lernt aus der Nachfrage. Modelle landen dort, wo sie gebraucht werden. Intelligenz breitet sich durch Kooperation aus."
|
||||
}
|
||||
},
|
||||
|
||||
"roadAhead": {
|
||||
"title": "Was Helexa werden will",
|
||||
"p1": "Eine globale Intelligenzschicht, die allen gehört – angetrieben von einer Helix aus Nodes und Communities.",
|
||||
"p2": "Ein Netzwerk, das Ausfällen, Politik, Monopolen und Störungen standhält.",
|
||||
"p3": "Ein neues Wirtschaftsmodell, in dem Operatoren, Builder und Nutzerinnen gleichermaßen profitieren.",
|
||||
"p4": "Ein Ökosystem, in dem Innovation von den Rändern ausgeht – nicht aus der Mitte.",
|
||||
"card": {
|
||||
"eyebrow": "Visions‑Snapshot",
|
||||
"title": "Auf dem Weg zu einem geteilten Intelligenz‑Mesh",
|
||||
"body": "Helexa steht am Anfang. Die Ideen sind größer als die aktuelle Implementierung – und das ist beabsichtigt. Das Netzwerk wird schrittweise wachsen, während Operatoren und Builder seine Entwicklung mitgestalten."
|
||||
}
|
||||
},
|
||||
|
||||
"joinMesh": {
|
||||
"badge": "Frühe Phase",
|
||||
"title": "Das Mesh formt sich.",
|
||||
"titleHighlight": "Du kannst Teil davon sein.",
|
||||
"lead": "Ob du Hardware betreibst, Modelle baust oder dir einfach wichtig ist, wie KI gesteuert wird – im Mesh gibt es einen Platz für dich.",
|
||||
"ctaRunNode": "Node betreiben (bald)",
|
||||
"ctaJoinAnnouncements": "Frühe Ankündigungen abonnieren",
|
||||
"ctaExploreCode": "Code erkunden",
|
||||
"footer": "Keine geschlossenen Gärten. Kein einzelner Besitzer. Nur ein Mesh aus Menschen, Hardware und Ideen – das eine andere Zukunft für Intelligenz komponiert."
|
||||
}
|
||||
}
|
||||
71
helexa.ai/src/i18n/resources/el/account.json
Normal 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."
|
||||
}
|
||||
}
|
||||
21
helexa.ai/src/i18n/resources/el/chat.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"title": "Χώρος συνομιλίας",
|
||||
"badge": "Συνομιλία",
|
||||
"lead": "Αυτή είναι η προβολή συνομιλίας. Σύνδεσε εδώ τη λογική της συζήτησης και τα components του περιβάλλοντος χρήστη σου.",
|
||||
"transcriptPlaceholder": "Το ιστορικό της συνομιλίας θα εμφανίζεται εδώ. Απόδωσε τα μηνύματα του μοντέλου και του χρήστη σε ένα κυλιόμενο container, προαιρετικά ομαδοποιημένα ανά γύρο.",
|
||||
"inputPlaceholder": "Πληκτρολόγησε ένα μήνυμα για να ξεκινήσεις τη συνομιλία…",
|
||||
"send": "Αποστολή",
|
||||
"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"
|
||||
}
|
||||
68
helexa.ai/src/i18n/resources/el/common.json
Normal file
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"app": {
|
||||
"name": "helexa.ai"
|
||||
},
|
||||
"nav": {
|
||||
"home": "Αρχική",
|
||||
"docs": "Τεκμηρίωση",
|
||||
"chat": "Συνομιλία",
|
||||
"mission": "Mission",
|
||||
"login": "Sign in",
|
||||
"register": "Sign up",
|
||||
"account": "Account",
|
||||
"logout": "Sign out"
|
||||
},
|
||||
"theme": {
|
||||
"toggle": {
|
||||
"toLight": "Μετάβαση σε φωτεινή λειτουργία",
|
||||
"toDark": "Μετάβαση σε σκοτεινή λειτουργία"
|
||||
}
|
||||
},
|
||||
"lang": {
|
||||
"bg": "Βουλγαρικά",
|
||||
"de": "Γερμανικά",
|
||||
"el": "Ελληνικά",
|
||||
"en": "Αγγλικά",
|
||||
"es": "Ισπανικά",
|
||||
"et": "Εσθονικά",
|
||||
"fr": "Γαλλικά",
|
||||
"he": "Εβραϊκά",
|
||||
"it": "Ιταλικά",
|
||||
"nl": "Ολλανδικά",
|
||||
"da": "Δανικά",
|
||||
"fi": "Φινλανδικά",
|
||||
"no": "Νορβηγικά",
|
||||
"sv": "Σουηδικά",
|
||||
"ar": "Αραβικά",
|
||||
"fa": "Περσικά",
|
||||
"sw": "Σουαχίλι",
|
||||
"ha": "Χάουσα",
|
||||
"am": "Αμχαρικά",
|
||||
"yo": "Γιορούμπα",
|
||||
"zu": "Ζουλού",
|
||||
"ma": "Νταρίτζα",
|
||||
"ig": "Ίγκμπο",
|
||||
"ka": "Γεωργιανά",
|
||||
"kk": "Καζακικά",
|
||||
"om": "Ορόμο",
|
||||
"so": "Σομαλικά",
|
||||
"ti": "Τιγκρινια",
|
||||
"uz": "Ουζμπεκικά",
|
||||
"wo": "Γουόλοφ",
|
||||
"pl": "Πολωνικά",
|
||||
"pt": "Πορτογαλικά",
|
||||
"ro": "Ρουμανικά",
|
||||
"ru": "Ρωσικά",
|
||||
"sr": "Σερβικά",
|
||||
"tr": "Τουρκικά",
|
||||
"uk": "Ουκρανικά"
|
||||
},
|
||||
"footer": {
|
||||
"copyright": "© {{year}} helexa.ai"
|
||||
},
|
||||
"beta": {
|
||||
"tag": "Public beta",
|
||||
"message": "helexa is in open beta — expect rough edges. Your chats stay in your browser.",
|
||||
"dismiss": "Dismiss"
|
||||
}
|
||||
}
|
||||
103
helexa.ai/src/i18n/resources/el/mission.json
Normal file
@@ -0,0 +1,103 @@
|
||||
{
|
||||
"hero": {
|
||||
"badge": "Μια νέα μορφή νοημοσύνης",
|
||||
"title": "Μια νέα μορφή νοημοσύνης",
|
||||
"lead": "Η Helexa είναι ένα αυτο-οργανωμένο πλέγμα ΤΝ που τροφοδοτείται από ανεξάρτητους operators. Ανοιχτό. Κατανεμημένο. Εξελισσόμενο.",
|
||||
"ctaJoinMesh": "Γίνε μέρος του πλέγματος",
|
||||
"ctaFollowProject": "Παρακολούθησε το έργο",
|
||||
"subcopy": "Φτιαγμένο για operators, builders και κοινότητες που πιστεύουν ότι η ΤΝ πρέπει να είναι ανοιχτή, ανθεκτική και κοινόχρηστη.",
|
||||
"imageAlt": "Οπτικοποίηση της έλικας Helexa"
|
||||
},
|
||||
|
||||
"intent": {
|
||||
"title": "Γιατί υπάρχει η Helexa",
|
||||
"p1": "Η ΤΝ γίνεται η πιο ισχυρή υποδομή στον κόσμο. Σήμερα όμως, αυτή η ισχύς είναι συγκεντρωμένη σε λίγες εταιρείες, διαμορφωμένη από ιδιωτικές προτεραιότητες, γεωγραφικούς περιορισμούς και εύθραυστες οικονομίες.",
|
||||
"p2Intro": "Η Helexa φαντάζεται κάτι διαφορετικό:",
|
||||
"bullet1": "Μια νοημοσύνη που αναπτύσσεται από παντού, όχι από ένα μόνο σημείο.",
|
||||
"bullet2": "Ένα δίκτυο στο οποίο ο καθένας μπορεί να συνεισφέρει και να ωφεληθεί.",
|
||||
"bullet3": "Ένα σύστημα που προσαρμόζεται στη ζήτηση, όχι στις εντολές.",
|
||||
"bullet4": "Τεχνολογία που ενδυναμώνει τις κοινότητες αντί να τις αντικαθιστά.",
|
||||
"closing": "Η Helexa δεν είναι πλατφόρμα. Δεν είναι cloud.\nΕίναι ένα πλέγμα — ένα ζωντανό, εξελισσόμενο δίκτυο ανεξάρτητων operators που σχηματίζουν ένα νέο είδος νοημοσύνης."
|
||||
},
|
||||
|
||||
"whyNow": {
|
||||
"title": "Ένα σημείο καμπής για την ΤΝ",
|
||||
"problemTitle": "Το πρόβλημα",
|
||||
"problemBullet1": "Η ΤΝ συγκεντρώνεται πιο γρήγορα από κάθε προηγούμενη τεχνολογία.",
|
||||
"problemBullet2": "Η πρόσβαση σε υπολογιστική ισχύ καθορίζει τις δυνατότητες και αυτή η πρόσβαση περιορίζεται.",
|
||||
"problemBullet3": "Τα κόστη αποκλείουν ερευνητές, startups και κοινότητες.",
|
||||
"problemBullet4": "Γεωπολιτικές και ρυθμιστικές πιέσεις απειλούν τη συνολική διαθεσιμότητα.",
|
||||
"problemBullet5": "Οι δημιουργοί μοντέλων και οι operators υλικού σπάνια μοιράζονται την αξία που παράγουν.",
|
||||
"opportunityTitle": "Η ευκαιρία",
|
||||
"opportunityIntro": "Όμως ένας κατανεμημένος κόσμος είναι εφικτός.",
|
||||
"opportunityBullet1": "Χιλιάδες GPUs σε όλο τον κόσμο παραμένουν υποαπασχολημένες.",
|
||||
"opportunityBullet2": "Οι operators θέλουν δίκαιη αποζημίωση για την υπολογιστική ισχύ.",
|
||||
"opportunityBullet3": "Οι developers θέλουν ανοιχτή, ανθεκτική στη λογοκρισία υποδομή.",
|
||||
"opportunityBullet4": "Οι κοινότητες θέλουν κυριαρχία και ανθεκτικότητα στα ψηφιακά τους συστήματα.",
|
||||
"opportunityBullet5": "Η ανάπτυξη της ΤΝ έχει ξεπεράσει τα παραδοσιακά clouds — χρειάζονται νέες μορφές.",
|
||||
"opportunityClosing": "Η Helexa είναι η στιγμή όπου αυτές οι δυνάμεις ευθυγραμμίζονται."
|
||||
},
|
||||
|
||||
"howItWorks": {
|
||||
"title": "Πώς σχηματίζεται το πλέγμα",
|
||||
"operators": {
|
||||
"eyebrow": "Οι operators τρέχουν nodes",
|
||||
"title": "Ο καθένας μπορεί να συνεισφέρει υπολογιστική ισχύ.",
|
||||
"body": "Οι operators τρέχουν nodes της Helexa. Αποφασίζουν ποια μοντέλα θα φιλοξενήσουν. Παραμένουν σε έλεγχο του υλικού και της οικονομίας τους. Χωρίς εγκρίσεις, χωρίς μεσάζοντες gatekeepers."
|
||||
},
|
||||
"routing": {
|
||||
"eyebrow": "Το πλέγμα δρομολογεί νοημοσύνη",
|
||||
"title": "Η ζήτηση ρέει μέσα από το δίκτυο.",
|
||||
"body": "Η Helexa μαθαίνει πού υπάρχει διαθέσιμη ισχύς, πού αυξάνεται η ζήτηση και ποια nodes είναι πιο κατάλληλα για να εξυπηρετήσουν τα αιτήματα. Το πλέγμα προσαρμόζεται οργανικά — σαν μια αναπτυσσόμενη έλικα."
|
||||
},
|
||||
"value": {
|
||||
"eyebrow": "Η αξία επιστρέφει πίσω",
|
||||
"title": "Η εργασία αποδεικνύεται. Η πληρωμή είναι δίκαιη.",
|
||||
"body": "Κάθε εργασία φέρει μια κρυπτογραφική απόδειξη. Οι operators αμείβονται για τη νοημοσύνη που βοηθούν να παραχθεί. Χωρίς φόρο πλατφόρμας. Χωρίς αδιαφανή τιμολόγηση."
|
||||
}
|
||||
},
|
||||
|
||||
"principles": {
|
||||
"title": "Χτισμένη σε αρχές, όχι σε πλατφόρμες",
|
||||
"distributed": {
|
||||
"title": "Κατανεμημένη από σχεδιασμό",
|
||||
"body": "Χωρίς μοναδικό σημείο αποτυχίας. Χωρίς κεντρική αρχή. Ένα δίκτυο που γίνεται ισχυρότερο με κάθε νέο operator."
|
||||
},
|
||||
"participation": {
|
||||
"title": "Ανοιχτή συμμετοχή",
|
||||
"body": "Αν έχεις υπολογιστική ισχύ, μπορείς να συνεισφέρεις. Το πλέγμα καλωσορίζει τους πάντες — edge, οικιακό server, datacenter."
|
||||
},
|
||||
"fairness": {
|
||||
"title": "Δικαιοσύνη & διαφάνεια",
|
||||
"body": "Τα έσοδα βασίζονται σε πραγματική, κρυπτογραφικά επαληθευμένη εργασία. Χωρίς «μαύρα κουτιά». Χωρίς κρυφές χρεώσεις."
|
||||
},
|
||||
"evolving": {
|
||||
"title": "Εξελισσόμενη νοημοσύνη",
|
||||
"body": "Το πλέγμα μαθαίνει από τη ζήτηση. Τα μοντέλα φορτώνονται εκεί όπου χρειάζονται. Η νοημοσύνη εξαπλώνεται μέσω συνεργασίας."
|
||||
}
|
||||
},
|
||||
|
||||
"roadAhead": {
|
||||
"title": "Τι στοχεύει να γίνει η Helexa",
|
||||
"p1": "Ένα παγκόσμιο επίπεδο νοημοσύνης που ανήκει σε όλους, τροφοδοτούμενο από μια έλικα από nodes και κοινότητες.",
|
||||
"p2": "Ένα δίκτυο ανθεκτικό σε διακοπές, πολιτική, μονοπώλια και αστοχίες.",
|
||||
"p3": "Ένα νέο οικονομικό μοντέλο όπου operators, builders και χρήστες ωφελούνται όλοι.",
|
||||
"p4": "Ένα οικοσύστημα όπου η καινοτομία αναπτύσσεται από τις άκρες — όχι από το κέντρο.",
|
||||
"card": {
|
||||
"eyebrow": "Στιγμιότυπο του οράματος",
|
||||
"title": "Προς ένα κοινό πλέγμα νοημοσύνης",
|
||||
"body": "Η Helexa βρίσκεται σε πρώιμο στάδιο. Οι ιδέες είναι μεγαλύτερες από την υλοποίηση — και αυτό είναι σκόπιμο. Το δίκτυο θα μεγαλώνει βήμα‑βήμα, με τους operators και τους builders να διαμορφώνουν την εξέλιξή του."
|
||||
}
|
||||
},
|
||||
|
||||
"joinMesh": {
|
||||
"badge": "Πρώιμη φάση",
|
||||
"title": "Το πλέγμα σχηματίζεται.",
|
||||
"titleHighlight": "Μπορείς να είσαι μέρος του.",
|
||||
"lead": "Είτε τρέχεις hardware, είτε χτίζεις μοντέλα, είτε απλώς σε νοιάζει πώς κυβερνάται η ΤΝ, υπάρχει μια θέση για σένα μέσα στο πλέγμα.",
|
||||
"ctaRunNode": "Τρέξε ένα node (σύντομα)",
|
||||
"ctaJoinAnnouncements": "Μπες στις πρώιμες ανακοινώσεις",
|
||||
"ctaExploreCode": "Εξερεύνησε τον κώδικα",
|
||||
"footer": "Χωρίς περιφραγμένους κήπους. Χωρίς έναν μόνο ιδιοκτήτη. Μόνο ένα πλέγμα από ανθρώπους, hardware και ιδέες — που συνθέτουν ένα διαφορετικό μέλλον για τη νοημοσύνη."
|
||||
}
|
||||
}
|
||||
71
helexa.ai/src/i18n/resources/en/account.json
Normal 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."
|
||||
}
|
||||
}
|
||||
21
helexa.ai/src/i18n/resources/en/chat.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"title": "Conversation workspace",
|
||||
"badge": "Chat",
|
||||
"lead": "This is the chat view. Plug your conversational logic and UI components into this page.",
|
||||
"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",
|
||||
"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"
|
||||
}
|
||||
68
helexa.ai/src/i18n/resources/en/common.json
Normal file
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"app": {
|
||||
"name": "helexa.ai"
|
||||
},
|
||||
"nav": {
|
||||
"home": "Home",
|
||||
"docs": "Docs",
|
||||
"chat": "Chat",
|
||||
"mission": "Mission",
|
||||
"login": "Sign in",
|
||||
"register": "Sign up",
|
||||
"account": "Account",
|
||||
"logout": "Sign out"
|
||||
},
|
||||
"theme": {
|
||||
"toggle": {
|
||||
"toLight": "Switch to light mode",
|
||||
"toDark": "Switch to dark mode"
|
||||
}
|
||||
},
|
||||
"lang": {
|
||||
"bg": "Bulgarian",
|
||||
"de": "German",
|
||||
"el": "Greek",
|
||||
"en": "English",
|
||||
"es": "Spanish",
|
||||
"et": "Estonian",
|
||||
"fr": "French",
|
||||
"he": "Hebrew",
|
||||
"it": "Italian",
|
||||
"nl": "Dutch",
|
||||
"da": "Danish",
|
||||
"fi": "Finnish",
|
||||
"no": "Norwegian",
|
||||
"sv": "Swedish",
|
||||
"ar": "Arabic",
|
||||
"fa": "Persian",
|
||||
"sw": "Swahili",
|
||||
"ha": "Hausa",
|
||||
"am": "Amharic",
|
||||
"yo": "Yoruba",
|
||||
"zu": "Zulu",
|
||||
"ma": "Darija",
|
||||
"ig": "Igbo",
|
||||
"ka": "Georgian",
|
||||
"kk": "Kazakh",
|
||||
"om": "Oromo",
|
||||
"so": "Somali",
|
||||
"ti": "Tigrinya",
|
||||
"uz": "Uzbek",
|
||||
"wo": "Wolof",
|
||||
"pl": "Polish",
|
||||
"pt": "Portuguese",
|
||||
"ro": "Romanian",
|
||||
"ru": "Russian",
|
||||
"sr": "Serbian",
|
||||
"tr": "Turkish",
|
||||
"uk": "Ukrainian"
|
||||
},
|
||||
"footer": {
|
||||
"copyright": "© {{year}} helexa.ai"
|
||||
},
|
||||
"beta": {
|
||||
"tag": "Public beta",
|
||||
"message": "helexa is in open beta — expect rough edges. Your chats stay in your browser.",
|
||||
"dismiss": "Dismiss"
|
||||
}
|
||||
}
|
||||
103
helexa.ai/src/i18n/resources/en/mission.json
Normal file
@@ -0,0 +1,103 @@
|
||||
{
|
||||
"hero": {
|
||||
"badge": "Sovereign European AI infrastructure",
|
||||
"title": "Sovereign AI Inference, Run in Europe",
|
||||
"lead": "Helexa is an AI inference platform designed for sovereign European AI deployment — running advanced models efficiently on commodity and workstation-class hardware, without hyperscale datacenters or massive GPU clusters.",
|
||||
"ctaJoinMesh": "Try it now",
|
||||
"ctaFollowProject": "Follow the project",
|
||||
"subcopy": "For European businesses that need to run AI locally — for regulatory, privacy, and cost reasons.",
|
||||
"imageAlt": "helexa helix visual"
|
||||
},
|
||||
|
||||
"intent": {
|
||||
"title": "Why Helexa Exists",
|
||||
"p1": "Today's AI infrastructure assumes hyperscale datacenters and massive GPU clusters. European businesses increasingly need to run AI locally — for regulatory, privacy, and cost reasons — but existing inference stacks are optimised for very different environments.",
|
||||
"p2Intro": "Helexa is built for the infrastructure Europe actually has:",
|
||||
"bullet1": "Advanced models running efficiently on commodity and workstation-class hardware.",
|
||||
"bullet2": "Data residency and privacy by default — inference stays on infrastructure you control.",
|
||||
"bullet3": "Lower infrastructure requirements, without sacrificing performance.",
|
||||
"bullet4": "Independence from a small group of foreign hyperscalers.",
|
||||
"closing": "Not a US cloud with an EU region — a software layer for sovereign European AI deployment."
|
||||
},
|
||||
|
||||
"whyNow": {
|
||||
"title": "Why Now",
|
||||
"problemTitle": "The Mismatch",
|
||||
"problemBullet1": "Today's inference stacks assume hyperscale datacenters and large GPU clusters.",
|
||||
"problemBullet2": "European regulation — GDPR, the AI Act, sectoral rules — pushes workloads on-premise and in-region.",
|
||||
"problemBullet3": "Sending data to foreign clouds is a regulatory, privacy, and cost liability.",
|
||||
"problemBullet4": "Cloud GPU access is expensive, supply-constrained, and governed elsewhere.",
|
||||
"problemBullet5": "Capable commodity and workstation hardware already sits underused across Europe.",
|
||||
"opportunityTitle": "The Opening",
|
||||
"opportunityIntro": "Near-frontier open-weight models now run well on hardware Europe already owns.",
|
||||
"opportunityBullet1": "Advanced models run on single workstations and commodity GPUs — no cluster required.",
|
||||
"opportunityBullet2": "European businesses want AI they can run locally, under their own control.",
|
||||
"opportunityBullet3": "Operators with capable hardware can serve inference close to users.",
|
||||
"opportunityBullet4": "Regulation is turning data sovereignty from a preference into a requirement.",
|
||||
"opportunityBullet5": "Efficient inference software is the missing layer — not more datacenters.",
|
||||
"opportunityClosing": "Helexa is that software layer."
|
||||
},
|
||||
|
||||
"howItWorks": {
|
||||
"title": "How It Works",
|
||||
"operators": {
|
||||
"eyebrow": "Deploy Locally",
|
||||
"title": "Run advanced models on your own hardware.",
|
||||
"body": "Helexa serves near-frontier open-weight models on commodity and workstation-class GPUs — on-premise, in your own jurisdiction. No cluster, no hyperscaler, no offshore dependency. Data stays where you control it."
|
||||
},
|
||||
"routing": {
|
||||
"eyebrow": "Route Intelligently",
|
||||
"title": "Requests stay close to home.",
|
||||
"body": "Helexa routes each request to infrastructure with capacity, preferring region affinity — so traffic and data stay where you expect. Deployments compose into a wider network as capacity grows."
|
||||
},
|
||||
"value": {
|
||||
"eyebrow": "Operate Independently",
|
||||
"title": "Work is proven. Economics are transparent.",
|
||||
"body": "Usage is metered transparently and independent operators are compensated for the intelligence they serve. No platform taxation, no opaque billing, no lock-in."
|
||||
}
|
||||
},
|
||||
|
||||
"principles": {
|
||||
"title": "Built on Sovereignty, Not Lock-In",
|
||||
"distributed": {
|
||||
"title": "Sovereign by Design",
|
||||
"body": "European hardware, European operators, European law. No single point of failure and no foreign control plane your access depends on."
|
||||
},
|
||||
"participation": {
|
||||
"title": "Runs on Hardware You Have",
|
||||
"body": "From a single workstation to a regional datacenter, capable compute can run helexa — reducing infrastructure requirements rather than demanding new clusters."
|
||||
},
|
||||
"fairness": {
|
||||
"title": "Privacy & Transparency",
|
||||
"body": "Privacy-native: chat history lives only in the browser, never on a server. Usage metering is transparent; there are no black boxes and no hidden fees."
|
||||
},
|
||||
"evolving": {
|
||||
"title": "Resilient by Distribution",
|
||||
"body": "Capacity spread across many independent deployments is harder to censor, throttle, or switch off — and resilient to foreign policy shifts and single-vendor failure."
|
||||
}
|
||||
},
|
||||
|
||||
"roadAhead": {
|
||||
"title": "Where Helexa Is Going",
|
||||
"p1": "Our long-term goal is to become the software layer powering a distributed network of independently operated European AI infrastructure.",
|
||||
"p2": "From enterprise deployments running advanced models on their own hardware, to renewable-powered regional datacenters serving their communities.",
|
||||
"p3": "A European inference layer owned and operated in Europe — resilient to outages, foreign policy shifts, monopolies, and single-vendor failure.",
|
||||
"p4": "Infrastructure that strengthens European autonomy instead of deepening dependency on a handful of foreign providers.",
|
||||
"card": {
|
||||
"eyebrow": "Vision",
|
||||
"title": "The software layer for European AI infrastructure",
|
||||
"body": "Helexa is early, and deliberately so — a working inference stack today, growing toward a distributed network of independently operated European deployments."
|
||||
}
|
||||
},
|
||||
|
||||
"joinMesh": {
|
||||
"badge": "Early access",
|
||||
"title": "Sovereign AI Infrastructure.",
|
||||
"titleHighlight": "Built in Europe.",
|
||||
"lead": "Whether you run AI on your own hardware, operate capacity, or are building toward European AI sovereignty, there is a place for you here.",
|
||||
"ctaRunNode": "Deploy a node (soon)",
|
||||
"ctaJoinAnnouncements": "Follow early announcements",
|
||||
"ctaExploreCode": "Explore the code",
|
||||
"footer": "No walled gardens. No foreign owner. A software layer for sovereign European AI — from a single workstation to renewable-powered regional datacenters."
|
||||
}
|
||||
}
|
||||
71
helexa.ai/src/i18n/resources/es/account.json
Normal 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."
|
||||
}
|
||||
}
|
||||
21
helexa.ai/src/i18n/resources/es/chat.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"title": "Espacio de conversación",
|
||||
"badge": "Chat",
|
||||
"lead": "Esta es la vista de chat. Conecta aquí tu lógica conversacional y los componentes de interfaz que desees.",
|
||||
"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",
|
||||
"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"
|
||||
}
|
||||
68
helexa.ai/src/i18n/resources/es/common.json
Normal file
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"app": {
|
||||
"name": "helexa.ai"
|
||||
},
|
||||
"nav": {
|
||||
"home": "Inicio",
|
||||
"docs": "Documentación",
|
||||
"chat": "Chat",
|
||||
"mission": "Mission",
|
||||
"login": "Sign in",
|
||||
"register": "Sign up",
|
||||
"account": "Account",
|
||||
"logout": "Sign out"
|
||||
},
|
||||
"theme": {
|
||||
"toggle": {
|
||||
"toLight": "Cambiar a modo claro",
|
||||
"toDark": "Cambiar a modo oscuro"
|
||||
}
|
||||
},
|
||||
"lang": {
|
||||
"bg": "Búlgaro",
|
||||
"de": "Alemán",
|
||||
"el": "Griego",
|
||||
"en": "Inglés",
|
||||
"es": "Español",
|
||||
"et": "Estonio",
|
||||
"fr": "Francés",
|
||||
"he": "Hebreo",
|
||||
"it": "Italiano",
|
||||
"nl": "Neerlandés",
|
||||
"da": "Danés",
|
||||
"fi": "Finés",
|
||||
"no": "Noruego",
|
||||
"sv": "Sueco",
|
||||
"ar": "Árabe",
|
||||
"fa": "Persa",
|
||||
"sw": "Suajili",
|
||||
"ha": "Hausa",
|
||||
"am": "Amhárico",
|
||||
"yo": "Yoruba",
|
||||
"zu": "Zulu",
|
||||
"ma": "Darija",
|
||||
"ig": "Igbo",
|
||||
"ka": "Georgiano",
|
||||
"kk": "Kazajo",
|
||||
"om": "Oromo",
|
||||
"so": "Somalí",
|
||||
"ti": "Tigriña",
|
||||
"uz": "Uzbeko",
|
||||
"wo": "Wólof",
|
||||
"pl": "Polaco",
|
||||
"pt": "Portugués",
|
||||
"ro": "Rumano",
|
||||
"ru": "Ruso",
|
||||
"sr": "Serbio",
|
||||
"tr": "Turco",
|
||||
"uk": "Ucraniano"
|
||||
},
|
||||
"footer": {
|
||||
"copyright": "© {{year}} helexa.ai"
|
||||
},
|
||||
"beta": {
|
||||
"tag": "Public beta",
|
||||
"message": "helexa is in open beta — expect rough edges. Your chats stay in your browser.",
|
||||
"dismiss": "Dismiss"
|
||||
}
|
||||
}
|
||||
103
helexa.ai/src/i18n/resources/es/mission.json
Normal file
@@ -0,0 +1,103 @@
|
||||
{
|
||||
"hero": {
|
||||
"badge": "Una nueva forma de inteligencia",
|
||||
"title": "Una nueva forma de inteligencia",
|
||||
"lead": "Helexa es una malla de IA autoorganizada impulsada por operadores independientes. Abierta. Distribuida. En evolución.",
|
||||
"ctaJoinMesh": "Únete a la malla",
|
||||
"ctaFollowProject": "Sigue el proyecto",
|
||||
"subcopy": "Construida para operadores, desarrolladores y comunidades que creen que la IA debe ser abierta, resiliente y compartida.",
|
||||
"imageAlt": "Visual de la hélice de Helexa"
|
||||
},
|
||||
|
||||
"intent": {
|
||||
"title": "Por qué existe Helexa",
|
||||
"p1": "La IA se está convirtiendo en la infraestructura más poderosa de la Tierra. Pero hoy, ese poder está concentrado en un puñado de corporaciones, moldeado por prioridades privadas, limitaciones geográficas y economías frágiles.",
|
||||
"p2Intro": "Helexa imagina algo diferente:",
|
||||
"bullet1": "Una inteligencia que crece desde todas partes, no desde un solo lugar.",
|
||||
"bullet2": "Una red donde cualquiera puede contribuir y beneficiarse.",
|
||||
"bullet3": "Un sistema que se adapta a la demanda, no a directivas.",
|
||||
"bullet4": "Tecnología que fortalece a las comunidades en lugar de reemplazarlas.",
|
||||
"closing": "Helexa no es una plataforma. No es una nube.\nEs una malla: una red viva y evolutiva de operadores independientes que forman un nuevo tipo de inteligencia."
|
||||
},
|
||||
|
||||
"whyNow": {
|
||||
"title": "Un punto de inflexión para la IA",
|
||||
"problemTitle": "El problema",
|
||||
"problemBullet1": "La IA se está centralizando más rápido que cualquier tecnología anterior.",
|
||||
"problemBullet2": "El acceso al cómputo define la capacidad, y ese acceso se está reduciendo.",
|
||||
"problemBullet3": "Las barreras de coste excluyen a investigadores, startups y comunidades.",
|
||||
"problemBullet4": "Las presiones geopolíticas y regulatorias amenazan la disponibilidad global.",
|
||||
"problemBullet5": "Los creadores de modelos y los operadores de hardware rara vez comparten el valor que producen.",
|
||||
"opportunityTitle": "La oportunidad",
|
||||
"opportunityIntro": "Pero un mundo distribuido es posible.",
|
||||
"opportunityBullet1": "Miles de GPU ya están subutilizadas en todo el mundo.",
|
||||
"opportunityBullet2": "Los operadores quieren una compensación justa por el cómputo.",
|
||||
"opportunityBullet3": "Los desarrolladores quieren infraestructura abierta y resistente a la censura.",
|
||||
"opportunityBullet4": "Las comunidades quieren soberanía y resiliencia en los sistemas digitales.",
|
||||
"opportunityBullet5": "El crecimiento de la IA ha superado a las nubes tradicionales: se necesitan nuevas formas.",
|
||||
"opportunityClosing": "Helexa es el momento en que estas fuerzas se alinean."
|
||||
},
|
||||
|
||||
"howItWorks": {
|
||||
"title": "Cómo se forma la malla",
|
||||
"operators": {
|
||||
"eyebrow": "Los operadores ejecutan nodos",
|
||||
"title": "Cualquiera puede aportar cómputo.",
|
||||
"body": "Los operadores ejecutan nodos de Helexa. Deciden qué modelos alojar. Mantienen el control de su hardware y su economía. Sin aprobaciones, sin guardianes."
|
||||
},
|
||||
"routing": {
|
||||
"eyebrow": "La malla enruta la inteligencia",
|
||||
"title": "La demanda fluye a través de la red.",
|
||||
"body": "Helexa aprende dónde existe capacidad, dónde está creciendo la demanda y qué nodos están mejor preparados para atender las solicitudes. La malla se adapta de forma orgánica, como una hélice en crecimiento."
|
||||
},
|
||||
"value": {
|
||||
"eyebrow": "El valor fluye de vuelta",
|
||||
"title": "El trabajo se prueba. El pago es justo.",
|
||||
"body": "Cada tarea lleva un recibo criptográfico. Los operadores ganan por la inteligencia que ayudan a proporcionar. Sin impuestos de plataforma. Sin facturación opaca."
|
||||
}
|
||||
},
|
||||
|
||||
"principles": {
|
||||
"title": "Basada en principios, no en plataformas",
|
||||
"distributed": {
|
||||
"title": "Distribuida por diseño",
|
||||
"body": "Sin un único punto de fallo. Sin autoridad central. Una red que se fortalece con cada nuevo operador."
|
||||
},
|
||||
"participation": {
|
||||
"title": "Participación abierta",
|
||||
"body": "Si tienes cómputo, puedes contribuir. La malla da la bienvenida a todos: edge, servidor doméstico o centro de datos."
|
||||
},
|
||||
"fairness": {
|
||||
"title": "Equidad y transparencia",
|
||||
"body": "Las ganancias se basan en trabajo real, verificado criptográficamente. Sin cajas negras. Sin comisiones ocultas."
|
||||
},
|
||||
"evolving": {
|
||||
"title": "Inteligencia en evolución",
|
||||
"body": "La malla aprende de la demanda. Los modelos se cargan donde se necesitan. La inteligencia se extiende mediante la cooperación."
|
||||
}
|
||||
},
|
||||
|
||||
"roadAhead": {
|
||||
"title": "Lo que Helexa aspira a ser",
|
||||
"p1": "Una capa de inteligencia global que pertenece a todos, impulsada por una hélice de nodos y comunidades.",
|
||||
"p2": "Una red resiliente frente a cortes, política, monopolios y fallos.",
|
||||
"p3": "Un nuevo modelo económico donde operadores, constructores y usuarios se benefician por igual.",
|
||||
"p4": "Un ecosistema donde la innovación crece desde los bordes, no desde el centro.",
|
||||
"card": {
|
||||
"eyebrow": "Instantánea de la visión",
|
||||
"title": "Hacia una malla de inteligencia compartida",
|
||||
"body": "Helexa está en una fase temprana. Las ideas son más grandes que la implementación, y eso es intencional. La red crecerá de forma iterativa, con operadores y desarrolladores dando forma a su evolución."
|
||||
}
|
||||
},
|
||||
|
||||
"joinMesh": {
|
||||
"badge": "Fase inicial",
|
||||
"title": "La malla se está formando.",
|
||||
"titleHighlight": "Tú puedes ser parte de ella.",
|
||||
"lead": "Tanto si gestionas hardware, construyes modelos o simplemente te importa cómo se gobierna la IA, hay un lugar para ti en la malla.",
|
||||
"ctaRunNode": "Ejecutar un nodo (pronto)",
|
||||
"ctaJoinAnnouncements": "Únete a los anuncios iniciales",
|
||||
"ctaExploreCode": "Explora el código",
|
||||
"footer": "Sin jardines amurallados. Sin un único propietario. Solo una malla de personas, hardware e ideas que componen un futuro distinto para la inteligencia."
|
||||
}
|
||||
}
|
||||
71
helexa.ai/src/i18n/resources/et/account.json
Normal 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."
|
||||
}
|
||||
}
|
||||
21
helexa.ai/src/i18n/resources/et/chat.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"title": "Vestluse tööruum",
|
||||
"badge": "Vestlus",
|
||||
"lead": "See on vestlusvaade. Siia saad ühendada oma vestlusloogika ja kasutajaliidese komponendid.",
|
||||
"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",
|
||||
"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"
|
||||
}
|
||||