Compare commits
8 Commits
feat/F1-th
...
feat/B6-se
| Author | SHA1 | Date | |
|---|---|---|---|
|
f4117224fc
|
|||
|
ce29e0c171
|
|||
|
7c12b9ea98
|
|||
|
c596519dbd
|
|||
|
a6b1fdc33d
|
|||
|
8dd82776f1
|
|||
|
8600d4fbf2
|
|||
|
2348cc2234
|
@@ -105,3 +105,5 @@ enabled = false
|
||||
# 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
|
||||
|
||||
@@ -48,11 +48,18 @@ pub struct UpstreamClientConfig {
|
||||
/// 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`]
|
||||
/// source of truth (#50). Accounts, keys, and hard caps live here; the
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,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;
|
||||
@@ -57,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
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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -22,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 {
|
||||
@@ -73,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()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,6 +60,7 @@ fn chain(local: LocalEntitlementProvider, url: &str) -> ChainedEntitlementProvid
|
||||
url: url.to_string(),
|
||||
bearer: "client-secret".into(),
|
||||
timeout_secs: 5,
|
||||
served_usage_report_interval_secs: 60,
|
||||
});
|
||||
ChainedEntitlementProvider::new(local, upstream)
|
||||
}
|
||||
|
||||
@@ -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
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
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
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");
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ const ROOT = path.resolve(
|
||||
const RESOURCES_DIR = path.join(ROOT, "src", "i18n", "resources");
|
||||
|
||||
// Namespaces to validate.
|
||||
const NAMESPACES = ["common", "home", "chat"];
|
||||
const NAMESPACES = ["common", "mission", "chat"];
|
||||
|
||||
// Languages to validate should track SUPPORTED_LANGUAGES in src/i18n/languages.ts.
|
||||
// NOTE: This list is intentionally narrower than SUPPORTED_LANGUAGES and does not
|
||||
|
||||
@@ -1,22 +1,14 @@
|
||||
import { BrowserRouter, Routes, Route } from "react-router-dom";
|
||||
import { Container } from "react-bootstrap";
|
||||
import ThemeProvider from "./layout/ThemeProvider";
|
||||
import Header from "./components/Header";
|
||||
import Footer from "./components/Footer";
|
||||
import Mission from "./pages/Mission";
|
||||
import Chat from "./pages/Chat";
|
||||
import "./App.css";
|
||||
|
||||
// F1 composition root: theme + router + layout shell. The chat workspace
|
||||
// (`/`, F3), `/mission` (F2), and the auth/account routes (F4) replace these
|
||||
// placeholders in later phases.
|
||||
function Placeholder({ title }: { title: string }) {
|
||||
return (
|
||||
<Container className="py-5 flex-grow-1">
|
||||
<h1 className="mb-2">{title}</h1>
|
||||
<p className="text-muted">helexa public beta — coming online.</p>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
// Composition root: theme + router + layout shell. `/` is the chat
|
||||
// workspace (F3, anonymous for now); `/mission` (F2) is the EU-sovereignty
|
||||
// narrative; the auth/account routes (F4) land next.
|
||||
export default function App() {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
@@ -24,8 +16,8 @@ export default function App() {
|
||||
<div className="d-flex flex-column min-vh-100">
|
||||
<Header />
|
||||
<Routes>
|
||||
<Route path="/" element={<Placeholder title="Chat" />} />
|
||||
<Route path="/mission" element={<Placeholder title="Mission" />} />
|
||||
<Route path="/" element={<Chat />} />
|
||||
<Route path="/mission" element={<Mission />} />
|
||||
</Routes>
|
||||
<Footer />
|
||||
</div>
|
||||
|
||||
72
helexa.ai/src/data/db.ts
Normal file
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
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 })),
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -10,133 +10,133 @@ import type { LanguageCode } from "./languages";
|
||||
// Core languages
|
||||
import enCommon from "./resources/en/common.json";
|
||||
import ruCommon from "./resources/ru/common.json";
|
||||
import enHome from "./resources/en/home.json";
|
||||
import ruHome from "./resources/ru/home.json";
|
||||
import enMission from "./resources/en/mission.json";
|
||||
import ruMission from "./resources/ru/mission.json";
|
||||
import enChat from "./resources/en/chat.json";
|
||||
import ruChat from "./resources/ru/chat.json";
|
||||
|
||||
// Scandinavian & Nordic languages
|
||||
import daCommon from "./resources/da/common.json";
|
||||
import daHome from "./resources/da/home.json";
|
||||
import daMission from "./resources/da/mission.json";
|
||||
import daChat from "./resources/da/chat.json";
|
||||
|
||||
import fiCommon from "./resources/fi/common.json";
|
||||
import fiHome from "./resources/fi/home.json";
|
||||
import fiMission from "./resources/fi/mission.json";
|
||||
import fiChat from "./resources/fi/chat.json";
|
||||
|
||||
import noCommon from "./resources/no/common.json";
|
||||
import noHome from "./resources/no/home.json";
|
||||
import noMission from "./resources/no/mission.json";
|
||||
import noChat from "./resources/no/chat.json";
|
||||
|
||||
import svCommon from "./resources/sv/common.json";
|
||||
import svHome from "./resources/sv/home.json";
|
||||
import svMission from "./resources/sv/mission.json";
|
||||
import svChat from "./resources/sv/chat.json";
|
||||
|
||||
import bgCommon from "./resources/bg/common.json";
|
||||
import bgHome from "./resources/bg/home.json";
|
||||
import bgMission from "./resources/bg/mission.json";
|
||||
import bgChat from "./resources/bg/chat.json";
|
||||
|
||||
import etCommon from "./resources/et/common.json";
|
||||
import etHome from "./resources/et/home.json";
|
||||
import etMission from "./resources/et/mission.json";
|
||||
import etChat from "./resources/et/chat.json";
|
||||
|
||||
// African & MENA languages
|
||||
import swCommon from "./resources/sw/common.json";
|
||||
import swHome from "./resources/sw/home.json";
|
||||
import swMission from "./resources/sw/mission.json";
|
||||
import swChat from "./resources/sw/chat.json";
|
||||
|
||||
import arCommon from "./resources/ar/common.json";
|
||||
import arHome from "./resources/ar/home.json";
|
||||
import arMission from "./resources/ar/mission.json";
|
||||
import arChat from "./resources/ar/chat.json";
|
||||
|
||||
import faCommon from "./resources/fa/common.json";
|
||||
import faHome from "./resources/fa/home.json";
|
||||
import faMission from "./resources/fa/mission.json";
|
||||
import faChat from "./resources/fa/chat.json";
|
||||
|
||||
import haCommon from "./resources/ha/common.json";
|
||||
import haHome from "./resources/ha/home.json";
|
||||
import haMission from "./resources/ha/mission.json";
|
||||
import haChat from "./resources/ha/chat.json";
|
||||
|
||||
import amCommon from "./resources/am/common.json";
|
||||
import amHome from "./resources/am/home.json";
|
||||
import amMission from "./resources/am/mission.json";
|
||||
import amChat from "./resources/am/chat.json";
|
||||
|
||||
import yoCommon from "./resources/yo/common.json";
|
||||
import yoHome from "./resources/yo/home.json";
|
||||
import yoMission from "./resources/yo/mission.json";
|
||||
import yoChat from "./resources/yo/chat.json";
|
||||
|
||||
import zuCommon from "./resources/zu/common.json";
|
||||
import zuHome from "./resources/zu/home.json";
|
||||
import zuMission from "./resources/zu/mission.json";
|
||||
import zuChat from "./resources/zu/chat.json";
|
||||
|
||||
// Darija (Moroccan Arabic)
|
||||
import maCommon from "./resources/ma/common.json";
|
||||
import maHome from "./resources/ma/home.json";
|
||||
import maMission from "./resources/ma/mission.json";
|
||||
import maChat from "./resources/ma/chat.json";
|
||||
|
||||
// European / other languages
|
||||
import esCommon from "./resources/es/common.json";
|
||||
import esHome from "./resources/es/home.json";
|
||||
import esMission from "./resources/es/mission.json";
|
||||
import esChat from "./resources/es/chat.json";
|
||||
|
||||
import frCommon from "./resources/fr/common.json";
|
||||
import frHome from "./resources/fr/home.json";
|
||||
import frMission from "./resources/fr/mission.json";
|
||||
import frChat from "./resources/fr/chat.json";
|
||||
|
||||
import deCommon from "./resources/de/common.json";
|
||||
import deHome from "./resources/de/home.json";
|
||||
import deMission from "./resources/de/mission.json";
|
||||
import deChat from "./resources/de/chat.json";
|
||||
|
||||
import elCommon from "./resources/el/common.json";
|
||||
import elHome from "./resources/el/home.json";
|
||||
import elMission from "./resources/el/mission.json";
|
||||
import elChat from "./resources/el/chat.json";
|
||||
|
||||
import itCommon from "./resources/it/common.json";
|
||||
import itHome from "./resources/it/home.json";
|
||||
import itMission from "./resources/it/mission.json";
|
||||
import itChat from "./resources/it/chat.json";
|
||||
|
||||
import heCommon from "./resources/he/common.json";
|
||||
import heHome from "./resources/he/home.json";
|
||||
import heMission from "./resources/he/mission.json";
|
||||
import heChat from "./resources/he/chat.json";
|
||||
|
||||
import ptCommon from "./resources/pt/common.json";
|
||||
import ptHome from "./resources/pt/home.json";
|
||||
import ptMission from "./resources/pt/mission.json";
|
||||
import ptChat from "./resources/pt/chat.json";
|
||||
|
||||
import roCommon from "./resources/ro/common.json";
|
||||
import roHome from "./resources/ro/home.json";
|
||||
import roMission from "./resources/ro/mission.json";
|
||||
import roChat from "./resources/ro/chat.json";
|
||||
|
||||
import kaCommon from "./resources/ka/common.json";
|
||||
import kaHome from "./resources/ka/home.json";
|
||||
import kaMission from "./resources/ka/mission.json";
|
||||
import kaChat from "./resources/ka/chat.json";
|
||||
|
||||
import trCommon from "./resources/tr/common.json";
|
||||
import trHome from "./resources/tr/home.json";
|
||||
import trMission from "./resources/tr/mission.json";
|
||||
import trChat from "./resources/tr/chat.json";
|
||||
|
||||
import plCommon from "./resources/pl/common.json";
|
||||
import plHome from "./resources/pl/home.json";
|
||||
import plMission from "./resources/pl/mission.json";
|
||||
import plChat from "./resources/pl/chat.json";
|
||||
|
||||
import ukCommon from "./resources/uk/common.json";
|
||||
import ukHome from "./resources/uk/home.json";
|
||||
import ukMission from "./resources/uk/mission.json";
|
||||
import ukChat from "./resources/uk/chat.json";
|
||||
|
||||
import nlCommon from "./resources/nl/common.json";
|
||||
import nlHome from "./resources/nl/home.json";
|
||||
import nlMission from "./resources/nl/mission.json";
|
||||
import nlChat from "./resources/nl/chat.json";
|
||||
|
||||
import srCommon from "./resources/sr/common.json";
|
||||
import srHome from "./resources/sr/home.json";
|
||||
import srMission from "./resources/sr/mission.json";
|
||||
import srChat from "./resources/sr/chat.json";
|
||||
|
||||
import kkCommon from "./resources/kk/common.json";
|
||||
import kkHome from "./resources/kk/home.json";
|
||||
import kkMission from "./resources/kk/mission.json";
|
||||
import kkChat from "./resources/kk/chat.json";
|
||||
|
||||
import uzCommon from "./resources/uz/common.json";
|
||||
import uzHome from "./resources/uz/home.json";
|
||||
import uzMission from "./resources/uz/mission.json";
|
||||
import uzChat from "./resources/uz/chat.json";
|
||||
|
||||
/**
|
||||
@@ -149,166 +149,166 @@ import uzChat from "./resources/uz/chat.json";
|
||||
const resources: Resource = {
|
||||
en: {
|
||||
common: enCommon,
|
||||
home: enHome,
|
||||
mission: enMission,
|
||||
chat: enChat,
|
||||
},
|
||||
ru: {
|
||||
common: ruCommon,
|
||||
home: ruHome,
|
||||
mission: ruMission,
|
||||
chat: ruChat,
|
||||
},
|
||||
bg: {
|
||||
common: bgCommon,
|
||||
home: bgHome,
|
||||
mission: bgMission,
|
||||
chat: bgChat,
|
||||
},
|
||||
da: {
|
||||
common: daCommon,
|
||||
home: daHome,
|
||||
mission: daMission,
|
||||
chat: daChat,
|
||||
},
|
||||
et: {
|
||||
common: etCommon,
|
||||
home: etHome,
|
||||
mission: etMission,
|
||||
chat: etChat,
|
||||
},
|
||||
fi: {
|
||||
common: fiCommon,
|
||||
home: fiHome,
|
||||
mission: fiMission,
|
||||
chat: fiChat,
|
||||
},
|
||||
kk: {
|
||||
common: kkCommon,
|
||||
home: kkHome,
|
||||
mission: kkMission,
|
||||
chat: kkChat,
|
||||
},
|
||||
uz: {
|
||||
common: uzCommon,
|
||||
home: uzHome,
|
||||
mission: uzMission,
|
||||
chat: uzChat,
|
||||
},
|
||||
|
||||
// African & MENA languages (LTR unless marked RTL via isRtlLanguage)
|
||||
sw: {
|
||||
common: swCommon,
|
||||
home: swHome,
|
||||
mission: swMission,
|
||||
chat: swChat,
|
||||
},
|
||||
ar: {
|
||||
common: arCommon,
|
||||
home: arHome,
|
||||
mission: arMission,
|
||||
chat: arChat,
|
||||
},
|
||||
fa: {
|
||||
common: faCommon,
|
||||
home: faHome,
|
||||
mission: faMission,
|
||||
chat: faChat,
|
||||
},
|
||||
ha: {
|
||||
common: haCommon,
|
||||
home: haHome,
|
||||
mission: haMission,
|
||||
chat: haChat,
|
||||
},
|
||||
am: {
|
||||
common: amCommon,
|
||||
home: amHome,
|
||||
mission: amMission,
|
||||
chat: amChat,
|
||||
},
|
||||
yo: {
|
||||
common: yoCommon,
|
||||
home: yoHome,
|
||||
mission: yoMission,
|
||||
chat: yoChat,
|
||||
},
|
||||
zu: {
|
||||
common: zuCommon,
|
||||
home: zuHome,
|
||||
mission: zuMission,
|
||||
chat: zuChat,
|
||||
},
|
||||
ma: {
|
||||
common: maCommon,
|
||||
home: maHome,
|
||||
mission: maMission,
|
||||
chat: maChat,
|
||||
},
|
||||
|
||||
// European & other languages
|
||||
es: {
|
||||
common: esCommon,
|
||||
home: esHome,
|
||||
mission: esMission,
|
||||
chat: esChat,
|
||||
},
|
||||
fr: {
|
||||
common: frCommon,
|
||||
home: frHome,
|
||||
mission: frMission,
|
||||
chat: frChat,
|
||||
},
|
||||
de: {
|
||||
common: deCommon,
|
||||
home: deHome,
|
||||
mission: deMission,
|
||||
chat: deChat,
|
||||
},
|
||||
el: {
|
||||
common: elCommon,
|
||||
home: elHome,
|
||||
mission: elMission,
|
||||
chat: elChat,
|
||||
},
|
||||
it: {
|
||||
common: itCommon,
|
||||
home: itHome,
|
||||
mission: itMission,
|
||||
chat: itChat,
|
||||
},
|
||||
he: {
|
||||
common: heCommon,
|
||||
home: heHome,
|
||||
mission: heMission,
|
||||
chat: heChat,
|
||||
},
|
||||
pt: {
|
||||
common: ptCommon,
|
||||
home: ptHome,
|
||||
mission: ptMission,
|
||||
chat: ptChat,
|
||||
},
|
||||
ro: {
|
||||
common: roCommon,
|
||||
home: roHome,
|
||||
mission: roMission,
|
||||
chat: roChat,
|
||||
},
|
||||
ka: {
|
||||
common: kaCommon,
|
||||
home: kaHome,
|
||||
mission: kaMission,
|
||||
chat: kaChat,
|
||||
},
|
||||
tr: {
|
||||
common: trCommon,
|
||||
home: trHome,
|
||||
mission: trMission,
|
||||
chat: trChat,
|
||||
},
|
||||
pl: {
|
||||
common: plCommon,
|
||||
home: plHome,
|
||||
mission: plMission,
|
||||
chat: plChat,
|
||||
},
|
||||
uk: {
|
||||
common: ukCommon,
|
||||
home: ukHome,
|
||||
mission: ukMission,
|
||||
chat: ukChat,
|
||||
},
|
||||
nl: {
|
||||
common: nlCommon,
|
||||
home: nlHome,
|
||||
mission: nlMission,
|
||||
chat: nlChat,
|
||||
},
|
||||
sr: {
|
||||
common: srCommon,
|
||||
home: srHome,
|
||||
mission: srMission,
|
||||
chat: srChat,
|
||||
},
|
||||
no: {
|
||||
common: noCommon,
|
||||
home: noHome,
|
||||
mission: noMission,
|
||||
chat: noChat,
|
||||
},
|
||||
sv: {
|
||||
common: svCommon,
|
||||
home: svHome,
|
||||
mission: svMission,
|
||||
chat: svChat,
|
||||
},
|
||||
};
|
||||
@@ -335,7 +335,7 @@ i18n.use(initReactI18next).init({
|
||||
lng: browserLang,
|
||||
fallbackLng: "en",
|
||||
supportedLngs: SUPPORTED_LANGUAGES,
|
||||
ns: ["common", "home", "chat"],
|
||||
ns: ["common", "mission", "chat"],
|
||||
defaultNS: "common",
|
||||
// Because we control the keys and interpolate only simple values.
|
||||
interpolation: {
|
||||
|
||||
@@ -5,5 +5,13 @@
|
||||
"transcriptPlaceholder": "የውይይቱ ሪኮርድ እዚህ ይታያል። የሞዴሉን እና የተጠቃሚውን መልዕክቶች በሚንቀሳቀስ ኮንቴይነር ውስጥ ያቀርቡ፣ приወይም በዙር ዙር በመከፈል ማቅረብ ይችላሉ።",
|
||||
"inputPlaceholder": "ውይይትን ለመጀምር መልዕክት ይፃፉ…",
|
||||
"send": "መላክ",
|
||||
"clear": "ማጽዳት"
|
||||
"clear": "ማጽዳት",
|
||||
"newChat": "New chat",
|
||||
"newProject": "New project",
|
||||
"newProjectName": "New project",
|
||||
"unsorted": "Unsorted",
|
||||
"emptyState": "Start a conversation. Your history stays in this browser.",
|
||||
"anonBanner": "You have reached the anonymous limit. Sign up for a free allocation.",
|
||||
"signUp": "Sign up",
|
||||
"stop": "Stop"
|
||||
}
|
||||
|
||||
@@ -5,5 +5,13 @@
|
||||
"transcriptPlaceholder": "سجل المحادثة سيظهر هنا. اعرض رسائل النموذج والمستخدم في حاوية قابلة للتمرير، ويمكنك تجميعها حسب أدوار الحوار إذا رغبت.",
|
||||
"inputPlaceholder": "اكتب رسالة لبدء المحادثة…",
|
||||
"send": "إرسال",
|
||||
"clear": "مسح"
|
||||
"clear": "مسح",
|
||||
"newChat": "New chat",
|
||||
"newProject": "New project",
|
||||
"newProjectName": "New project",
|
||||
"unsorted": "Unsorted",
|
||||
"emptyState": "Start a conversation. Your history stays in this browser.",
|
||||
"anonBanner": "You have reached the anonymous limit. Sign up for a free allocation.",
|
||||
"signUp": "Sign up",
|
||||
"stop": "Stop"
|
||||
}
|
||||
|
||||
@@ -5,5 +5,13 @@
|
||||
"transcriptPlaceholder": "Тук ще се показва историята на чата. Визуализирай съобщенията от модела и потребителя в превъртащ се контейнер, по желание групирани по ход.",
|
||||
"inputPlaceholder": "Напиши съобщение, за да започнеш разговора…",
|
||||
"send": "Изпрати",
|
||||
"clear": "Изчисти"
|
||||
"clear": "Изчисти",
|
||||
"newChat": "New chat",
|
||||
"newProject": "New project",
|
||||
"newProjectName": "New project",
|
||||
"unsorted": "Unsorted",
|
||||
"emptyState": "Start a conversation. Your history stays in this browser.",
|
||||
"anonBanner": "You have reached the anonymous limit. Sign up for a free allocation.",
|
||||
"signUp": "Sign up",
|
||||
"stop": "Stop"
|
||||
}
|
||||
|
||||
@@ -5,5 +5,13 @@
|
||||
"transcriptPlaceholder": "Chattransskriptionen vises her. Gengiv beskeder fra modellen og brugeren i en rullende container, eventuelt grupperet efter tur.",
|
||||
"inputPlaceholder": "Skriv en besked for at starte en chat…",
|
||||
"send": "Send",
|
||||
"clear": "Ryd"
|
||||
"clear": "Ryd",
|
||||
"newChat": "New chat",
|
||||
"newProject": "New project",
|
||||
"newProjectName": "New project",
|
||||
"unsorted": "Unsorted",
|
||||
"emptyState": "Start a conversation. Your history stays in this browser.",
|
||||
"anonBanner": "You have reached the anonymous limit. Sign up for a free allocation.",
|
||||
"signUp": "Sign up",
|
||||
"stop": "Stop"
|
||||
}
|
||||
|
||||
@@ -5,5 +5,13 @@
|
||||
"transcriptPlaceholder": "Das Chat-Protokoll erscheint hier. Rendern die Nachrichten des Modells und der Nutzerin bzw. des Nutzers in einem scrollbaren Container, optional nach Gesprächsrunden gruppiert.",
|
||||
"inputPlaceholder": "Schreibe eine Nachricht, um mit dem Chat zu beginnen…",
|
||||
"send": "Senden",
|
||||
"clear": "Leeren"
|
||||
"clear": "Leeren",
|
||||
"newChat": "New chat",
|
||||
"newProject": "New project",
|
||||
"newProjectName": "New project",
|
||||
"unsorted": "Unsorted",
|
||||
"emptyState": "Start a conversation. Your history stays in this browser.",
|
||||
"anonBanner": "You have reached the anonymous limit. Sign up for a free allocation.",
|
||||
"signUp": "Sign up",
|
||||
"stop": "Stop"
|
||||
}
|
||||
|
||||
@@ -5,5 +5,13 @@
|
||||
"transcriptPlaceholder": "Το ιστορικό της συνομιλίας θα εμφανίζεται εδώ. Απόδωσε τα μηνύματα του μοντέλου και του χρήστη σε ένα κυλιόμενο container, προαιρετικά ομαδοποιημένα ανά γύρο.",
|
||||
"inputPlaceholder": "Πληκτρολόγησε ένα μήνυμα για να ξεκινήσεις τη συνομιλία…",
|
||||
"send": "Αποστολή",
|
||||
"clear": "Καθαρισμός"
|
||||
"clear": "Καθαρισμός",
|
||||
"newChat": "New chat",
|
||||
"newProject": "New project",
|
||||
"newProjectName": "New project",
|
||||
"unsorted": "Unsorted",
|
||||
"emptyState": "Start a conversation. Your history stays in this browser.",
|
||||
"anonBanner": "You have reached the anonymous limit. Sign up for a free allocation.",
|
||||
"signUp": "Sign up",
|
||||
"stop": "Stop"
|
||||
}
|
||||
|
||||
@@ -5,5 +5,13 @@
|
||||
"transcriptPlaceholder": "Chat transcript will appear here. Render messages from the model and user in a scrolling container, optionally grouped by turn.",
|
||||
"inputPlaceholder": "Type a message to start chatting…",
|
||||
"send": "Send",
|
||||
"clear": "Clear"
|
||||
"clear": "Clear",
|
||||
"newChat": "New chat",
|
||||
"newProject": "New project",
|
||||
"newProjectName": "New project",
|
||||
"unsorted": "Unsorted",
|
||||
"emptyState": "Start a conversation. Your history stays in this browser.",
|
||||
"anonBanner": "You have reached the anonymous limit. Sign up for a free allocation.",
|
||||
"signUp": "Sign up",
|
||||
"stop": "Stop"
|
||||
}
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
{
|
||||
"hero": {
|
||||
"badge": "A new shape of intelligence",
|
||||
"title": "A New Shape of Intelligence",
|
||||
"lead": "Helexa is a self-organising AI mesh powered by independent operators. Open. Distributed. Evolving.",
|
||||
"ctaJoinMesh": "Join the mesh",
|
||||
"ctaFollowProject": "Follow the project",
|
||||
"subcopy": "Built for operators, builders, and communities who believe AI should be open, resilient, and shared.",
|
||||
"imageAlt": "Helexa helix visual"
|
||||
},
|
||||
|
||||
"intent": {
|
||||
"title": "Why Helexa Exists",
|
||||
"p1": "AI is becoming the most powerful infrastructure on Earth. But today, that power is concentrated in a handful of corporations, shaped by private priorities, geographic limitations, and fragile economics.",
|
||||
"p2Intro": "Helexa imagines something different:",
|
||||
"bullet1": "Intelligence that grows from everywhere, not one place.",
|
||||
"bullet2": "A network where anyone can contribute and benefit.",
|
||||
"bullet3": "A system that adapts to demand, not directives.",
|
||||
"bullet4": "Technology that strengthens communities instead of replacing them.",
|
||||
"closing": "Helexa is not a platform. It is not a cloud.\nIt is a mesh — a living, evolving lattice of independent operators forming a new kind of intelligence."
|
||||
},
|
||||
|
||||
"whyNow": {
|
||||
"title": "A Turning Point for AI",
|
||||
"problemTitle": "The Problem",
|
||||
"problemBullet1": "AI is centralising faster than any technology before it.",
|
||||
"problemBullet2": "Compute access defines capability, and access is narrowing.",
|
||||
"problemBullet3": "Cost barriers exclude researchers, startups, and communities.",
|
||||
"problemBullet4": "Geopolitical and regulatory pressures threaten global availability.",
|
||||
"problemBullet5": "Creators of models and operators of hardware rarely share in the value they produce.",
|
||||
"opportunityTitle": "The Opportunity",
|
||||
"opportunityIntro": "But a distributed world is possible.",
|
||||
"opportunityBullet1": "Thousands of GPUs already sit underutilised, worldwide.",
|
||||
"opportunityBullet2": "Operators want fair compensation for compute.",
|
||||
"opportunityBullet3": "Developers want open, censorship-resistant infrastructure.",
|
||||
"opportunityBullet4": "Communities want sovereignty and resilience in digital systems.",
|
||||
"opportunityBullet5": "AI's growth has outpaced traditional clouds — new shapes are needed.",
|
||||
"opportunityClosing": "Helexa is the moment these forces align."
|
||||
},
|
||||
|
||||
"howItWorks": {
|
||||
"title": "How the Mesh Forms",
|
||||
"operators": {
|
||||
"eyebrow": "Operators Run Nodes",
|
||||
"title": "Anyone can contribute compute.",
|
||||
"body": "Operators run Helexa nodes. They decide what models to host. They stay in control of their hardware and their economics. No approvals, no gatekeepers."
|
||||
},
|
||||
"routing": {
|
||||
"eyebrow": "The Mesh Routes Intelligence",
|
||||
"title": "Demand flows through the network.",
|
||||
"body": "Helexa learns where capacity exists, where demand is rising, and which nodes are best suited to serve requests. The mesh adapts organically — like a growing helix."
|
||||
},
|
||||
"value": {
|
||||
"eyebrow": "Value Flows Back",
|
||||
"title": "Work is proven. Payment is fair.",
|
||||
"body": "Every job carries a cryptographic receipt. Operators earn for the intelligence they help provide. No platform taxation. No opaque billing."
|
||||
}
|
||||
},
|
||||
|
||||
"principles": {
|
||||
"title": "Built on Principles, Not Platforms",
|
||||
"distributed": {
|
||||
"title": "Distributed by Design",
|
||||
"body": "No single point of failure. No central authority. A network that grows stronger with every new operator."
|
||||
},
|
||||
"participation": {
|
||||
"title": "Open Participation",
|
||||
"body": "If you have compute, you can contribute. The mesh welcomes everyone — edge, home server, datacenter."
|
||||
},
|
||||
"fairness": {
|
||||
"title": "Fairness & Transparency",
|
||||
"body": "Earnings are based on real work, cryptographically verified. No black boxes. No hidden fees."
|
||||
},
|
||||
"evolving": {
|
||||
"title": "Evolving Intelligence",
|
||||
"body": "The mesh learns from demand. Models load where they're needed. Intelligence spreads through cooperation."
|
||||
}
|
||||
},
|
||||
|
||||
"roadAhead": {
|
||||
"title": "What Helexa Aims to Become",
|
||||
"p1": "A global intelligence layer that belongs to everyone, powered by a helix of nodes and communities.",
|
||||
"p2": "A network resilient to outages, politics, monopolies, and failure.",
|
||||
"p3": "A new economic model where operators, builders, and users all benefit.",
|
||||
"p4": "An ecosystem where innovation grows from the edges — not the center.",
|
||||
"card": {
|
||||
"eyebrow": "Vision snapshot",
|
||||
"title": "Toward a shared intelligence mesh",
|
||||
"body": "Helexa is early. The ideas are bigger than the implementation — and that's intentional. The network will grow iteratively, with operators and builders shaping its evolution."
|
||||
}
|
||||
},
|
||||
|
||||
"joinMesh": {
|
||||
"badge": "Early phase",
|
||||
"title": "The Mesh Is Forming.",
|
||||
"titleHighlight": "You Can Be Part of It.",
|
||||
"lead": "Whether you run hardware, build models, or just care about how AI is governed, there is a place for you in the mesh.",
|
||||
"ctaRunNode": "Run a node (soon)",
|
||||
"ctaJoinAnnouncements": "Join early announcements",
|
||||
"ctaExploreCode": "Explore the code",
|
||||
"footer": "No walled gardens. No single owner. Just a mesh of people, hardware, and ideas — composing a different future for intelligence."
|
||||
}
|
||||
}
|
||||
103
helexa.ai/src/i18n/resources/en/mission.json
Normal file
103
helexa.ai/src/i18n/resources/en/mission.json
Normal file
@@ -0,0 +1,103 @@
|
||||
{
|
||||
"hero": {
|
||||
"badge": "European digital sovereignty",
|
||||
"title": "Sovereign AI, Run by Europe",
|
||||
"lead": "helexa is a sovereign AI mesh: near-frontier models served by independent European operators, on European hardware, under European law. Open. Distributed. Yours.",
|
||||
"ctaJoinMesh": "Start chatting",
|
||||
"ctaFollowProject": "Follow the project",
|
||||
"subcopy": "For people and organisations who refuse to make their thinking a dependency of a foreign hyperscaler.",
|
||||
"imageAlt": "helexa helix visual"
|
||||
},
|
||||
|
||||
"intent": {
|
||||
"title": "Why helexa Exists",
|
||||
"p1": "AI is becoming the most consequential infrastructure on Earth — and Europe runs almost none of it. The models, the GPUs, the clouds, and the terms of service belong to a handful of US corporations, leaving European users, researchers, and businesses renting their own intelligence back from someone else's jurisdiction.",
|
||||
"p2Intro": "helexa is built for a different settlement:",
|
||||
"bullet1": "Data residency by default — your prompts stay on operators you can locate on a map.",
|
||||
"bullet2": "GDPR-native, not GDPR-retrofitted: no server-side chat history, ever.",
|
||||
"bullet3": "Capacity owned by independent operators, not a single hyperscaler.",
|
||||
"bullet4": "Infrastructure that strengthens European autonomy instead of deepening dependency.",
|
||||
"closing": "helexa is not a platform. It is not a US cloud with an EU region.\nIt is a mesh — a lattice of independent European operators serving frontier-class intelligence under European law."
|
||||
},
|
||||
|
||||
"whyNow": {
|
||||
"title": "A Turning Point for European AI",
|
||||
"problemTitle": "The Dependency",
|
||||
"problemBullet1": "Frontier AI is centralising faster than any technology before it — almost entirely outside Europe.",
|
||||
"problemBullet2": "Compute access defines capability, and that access is gated by a few foreign providers.",
|
||||
"problemBullet3": "Cross-border data flows and shifting US policy put European data and availability at risk.",
|
||||
"problemBullet4": "Terms, prices, and model availability can change overnight, decided elsewhere.",
|
||||
"problemBullet5": "European operators who own capable hardware rarely share in the value it could produce.",
|
||||
"opportunityTitle": "The Sovereign Opportunity",
|
||||
"opportunityIntro": "A European alternative is already within reach.",
|
||||
"opportunityBullet1": "Capable consumer GPUs sit underutilised across European homes, labs, and datacentres.",
|
||||
"opportunityBullet2": "Operators want fair compensation for serving compute close to users.",
|
||||
"opportunityBullet3": "Developers want open, censorship-resistant infrastructure under known law.",
|
||||
"opportunityBullet4": "Communities and institutions want sovereignty and resilience in their digital systems.",
|
||||
"opportunityBullet5": "Near-frontier open-weight models now run well on consumer hardware — no hyperscaler required.",
|
||||
"opportunityClosing": "helexa is the moment these forces align — in Europe's favour."
|
||||
},
|
||||
|
||||
"howItWorks": {
|
||||
"title": "How the Mesh Forms",
|
||||
"operators": {
|
||||
"eyebrow": "Operators Run Nodes",
|
||||
"title": "European compute, locally owned.",
|
||||
"body": "Independent operators run helexa nodes on their own hardware, in their own jurisdiction. They choose what to host and keep control of their economics. No approvals, no gatekeepers, no offshore dependency."
|
||||
},
|
||||
"routing": {
|
||||
"eyebrow": "The Mesh Routes Intelligence",
|
||||
"title": "Requests stay close to home.",
|
||||
"body": "helexa routes each request to an operator with capacity, preferring region affinity — so traffic and data stay where you expect. The mesh adapts organically, like a growing helix."
|
||||
},
|
||||
"value": {
|
||||
"eyebrow": "Value Flows Back",
|
||||
"title": "Work is proven. Payment is fair.",
|
||||
"body": "Usage is metered transparently and operators are compensated for the intelligence they serve. No platform taxation, no opaque billing, no lock-in."
|
||||
}
|
||||
},
|
||||
|
||||
"principles": {
|
||||
"title": "Built on Sovereignty, Not Platforms",
|
||||
"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": "Open Participation",
|
||||
"body": "If you have capable compute, you can contribute — edge, home server, or datacentre. The mesh welcomes every European operator."
|
||||
},
|
||||
"fairness": {
|
||||
"title": "Privacy & Transparency",
|
||||
"body": "GDPR-native: chat history lives only in your browser, never on a server. Usage metering is transparent; there are no black boxes and no hidden fees."
|
||||
},
|
||||
"evolving": {
|
||||
"title": "Resilient Intelligence",
|
||||
"body": "The mesh learns from demand and loads models where they're needed. Capacity spread across many operators is harder to censor, throttle, or switch off."
|
||||
}
|
||||
},
|
||||
|
||||
"roadAhead": {
|
||||
"title": "What helexa Aims to Become",
|
||||
"p1": "A European intelligence layer that belongs to its users and operators, powered by a helix of independent nodes.",
|
||||
"p2": "A network resilient to outages, foreign policy shifts, monopolies, and single-vendor failure.",
|
||||
"p3": "A fair economic model where European operators, builders, and users all benefit.",
|
||||
"p4": "An ecosystem where innovation grows from the edges of Europe — not the centre of someone else's market.",
|
||||
"card": {
|
||||
"eyebrow": "Vision snapshot",
|
||||
"title": "Toward a sovereign intelligence mesh",
|
||||
"body": "helexa is early, and deliberately so. The network grows iteratively, with European operators and builders shaping its evolution — not a roadmap dictated from abroad."
|
||||
}
|
||||
},
|
||||
|
||||
"joinMesh": {
|
||||
"badge": "Early phase",
|
||||
"title": "The Mesh Is Forming.",
|
||||
"titleHighlight": "You Can Be Part of It.",
|
||||
"lead": "Whether you run hardware, build models, or simply care about who governs your AI, there is a place for you in the mesh.",
|
||||
"ctaRunNode": "Run a node (soon)",
|
||||
"ctaJoinAnnouncements": "Join early announcements",
|
||||
"ctaExploreCode": "Explore the code",
|
||||
"footer": "No walled gardens. No foreign owner. A mesh of European people, hardware, and ideas — composing a sovereign future for intelligence."
|
||||
}
|
||||
}
|
||||
@@ -5,5 +5,13 @@
|
||||
"transcriptPlaceholder": "La transcripción del chat aparecerá aquí. Renderiza los mensajes del modelo y de la persona usuaria en un contenedor desplazable, opcionalmente agrupados por turno.",
|
||||
"inputPlaceholder": "Escribe un mensaje para comenzar a chatear…",
|
||||
"send": "Enviar",
|
||||
"clear": "Limpiar"
|
||||
"clear": "Limpiar",
|
||||
"newChat": "New chat",
|
||||
"newProject": "New project",
|
||||
"newProjectName": "New project",
|
||||
"unsorted": "Unsorted",
|
||||
"emptyState": "Start a conversation. Your history stays in this browser.",
|
||||
"anonBanner": "You have reached the anonymous limit. Sign up for a free allocation.",
|
||||
"signUp": "Sign up",
|
||||
"stop": "Stop"
|
||||
}
|
||||
|
||||
@@ -5,5 +5,13 @@
|
||||
"transcriptPlaceholder": "Vestluse logi kuvatakse siin. Esita mudeli ja kasutaja sõnumeid keritavas konteineris, soovi korral sammude kaupa rühmitatuna.",
|
||||
"inputPlaceholder": "Alusta vestlust, kirjutades siia sõnumi…",
|
||||
"send": "Saada",
|
||||
"clear": "Tühjenda"
|
||||
"clear": "Tühjenda",
|
||||
"newChat": "New chat",
|
||||
"newProject": "New project",
|
||||
"newProjectName": "New project",
|
||||
"unsorted": "Unsorted",
|
||||
"emptyState": "Start a conversation. Your history stays in this browser.",
|
||||
"anonBanner": "You have reached the anonymous limit. Sign up for a free allocation.",
|
||||
"signUp": "Sign up",
|
||||
"stop": "Stop"
|
||||
}
|
||||
|
||||
@@ -5,5 +5,13 @@
|
||||
"transcriptPlaceholder": "رونوشت گفتگو در اینجا نمایش داده میشود. پیامهای مدل و کاربر را در یک محفظه قابل اسکرول رندر کنید؛ در صورت تمایل آنها را بر اساس نوبت گروهبندی کنید.",
|
||||
"inputPlaceholder": "برای شروع گفتگو یک پیام بنویسید…",
|
||||
"send": "ارسال",
|
||||
"clear": "پاک کردن"
|
||||
"clear": "پاک کردن",
|
||||
"newChat": "New chat",
|
||||
"newProject": "New project",
|
||||
"newProjectName": "New project",
|
||||
"unsorted": "Unsorted",
|
||||
"emptyState": "Start a conversation. Your history stays in this browser.",
|
||||
"anonBanner": "You have reached the anonymous limit. Sign up for a free allocation.",
|
||||
"signUp": "Sign up",
|
||||
"stop": "Stop"
|
||||
}
|
||||
|
||||
@@ -5,5 +5,13 @@
|
||||
"transcriptPlaceholder": "Keskusteluloki näkyy tässä. Näytä mallin ja käyttäjän viestit vieritettävässä säiliössä, halutessasi vuoroittain ryhmiteltynä.",
|
||||
"inputPlaceholder": "Kirjoita viesti aloittaaksesi keskustelun…",
|
||||
"send": "Lähetä",
|
||||
"clear": "Tyhjennä"
|
||||
"clear": "Tyhjennä",
|
||||
"newChat": "New chat",
|
||||
"newProject": "New project",
|
||||
"newProjectName": "New project",
|
||||
"unsorted": "Unsorted",
|
||||
"emptyState": "Start a conversation. Your history stays in this browser.",
|
||||
"anonBanner": "You have reached the anonymous limit. Sign up for a free allocation.",
|
||||
"signUp": "Sign up",
|
||||
"stop": "Stop"
|
||||
}
|
||||
|
||||
@@ -5,5 +5,13 @@
|
||||
"transcriptPlaceholder": "La transcription du chat apparaîtra ici. Affichez les messages du modèle et de l’utilisateur dans un conteneur défilant, éventuellement groupés par tour.",
|
||||
"inputPlaceholder": "Tapez un message pour commencer à discuter…",
|
||||
"send": "Envoyer",
|
||||
"clear": "Effacer"
|
||||
"clear": "Effacer",
|
||||
"newChat": "New chat",
|
||||
"newProject": "New project",
|
||||
"newProjectName": "New project",
|
||||
"unsorted": "Unsorted",
|
||||
"emptyState": "Start a conversation. Your history stays in this browser.",
|
||||
"anonBanner": "You have reached the anonymous limit. Sign up for a free allocation.",
|
||||
"signUp": "Sign up",
|
||||
"stop": "Stop"
|
||||
}
|
||||
|
||||
@@ -5,5 +5,13 @@
|
||||
"transcriptPlaceholder": "Rubutaccen tarihin tattaunawa zai bayyana a nan. Nuna saƙonnin samfurin AI da na mai amfani a cikin akwatin da ake iya gungurawa, kana iya rarrabe su bisa jujjuyawar magana.",
|
||||
"inputPlaceholder": "Rubuta saƙo don fara tattaunawa…",
|
||||
"send": "Aika",
|
||||
"clear": "Share"
|
||||
"clear": "Share",
|
||||
"newChat": "New chat",
|
||||
"newProject": "New project",
|
||||
"newProjectName": "New project",
|
||||
"unsorted": "Unsorted",
|
||||
"emptyState": "Start a conversation. Your history stays in this browser.",
|
||||
"anonBanner": "You have reached the anonymous limit. Sign up for a free allocation.",
|
||||
"signUp": "Sign up",
|
||||
"stop": "Stop"
|
||||
}
|
||||
|
||||
@@ -5,5 +5,13 @@
|
||||
"transcriptPlaceholder": "תמליל הצ'אט יופיע כאן. הציגו את ההודעות של המודל ושל המשתמש/ת במכל גלילה, אפשר גם לקבץ לפי סבב שיחה.",
|
||||
"inputPlaceholder": "כתבו הודעה כדי להתחיל שיחה…",
|
||||
"send": "שליחה",
|
||||
"clear": "ניקוי"
|
||||
"clear": "ניקוי",
|
||||
"newChat": "New chat",
|
||||
"newProject": "New project",
|
||||
"newProjectName": "New project",
|
||||
"unsorted": "Unsorted",
|
||||
"emptyState": "Start a conversation. Your history stays in this browser.",
|
||||
"anonBanner": "You have reached the anonymous limit. Sign up for a free allocation.",
|
||||
"signUp": "Sign up",
|
||||
"stop": "Stop"
|
||||
}
|
||||
|
||||
@@ -5,5 +5,13 @@
|
||||
"transcriptPlaceholder": "La trascrizione della chat apparirà qui. Visualizza i messaggi del modello e dell’utente in un contenitore scorrevole, eventualmente raggruppati per turno.",
|
||||
"inputPlaceholder": "Scrivi un messaggio per iniziare a chattare…",
|
||||
"send": "Invia",
|
||||
"clear": "Pulisci"
|
||||
"clear": "Pulisci",
|
||||
"newChat": "New chat",
|
||||
"newProject": "New project",
|
||||
"newProjectName": "New project",
|
||||
"unsorted": "Unsorted",
|
||||
"emptyState": "Start a conversation. Your history stays in this browser.",
|
||||
"anonBanner": "You have reached the anonymous limit. Sign up for a free allocation.",
|
||||
"signUp": "Sign up",
|
||||
"stop": "Stop"
|
||||
}
|
||||
|
||||
@@ -5,5 +5,13 @@
|
||||
"transcriptPlaceholder": "ჩატის ისტორია გამოჩნდება აქ. ნაჩვენებია მოდელისა და მომხმარებლის შეტყობინებები გადახვევად კონტეინერში, სურვილის შემთხვევაში ტურნების მიხედვით დაჯგუფებით.",
|
||||
"inputPlaceholder": "დაიწყე საუბარი — მიწერე შეტყობინება…",
|
||||
"send": "გაგზავნა",
|
||||
"clear": "გასუფთავება"
|
||||
"clear": "გასუფთავება",
|
||||
"newChat": "New chat",
|
||||
"newProject": "New project",
|
||||
"newProjectName": "New project",
|
||||
"unsorted": "Unsorted",
|
||||
"emptyState": "Start a conversation. Your history stays in this browser.",
|
||||
"anonBanner": "You have reached the anonymous limit. Sign up for a free allocation.",
|
||||
"signUp": "Sign up",
|
||||
"stop": "Stop"
|
||||
}
|
||||
|
||||
@@ -5,5 +5,13 @@
|
||||
"transcriptPlaceholder": "Мұнда чат журналын көрсетіңіз. Модель мен пайдаланушы хабарламаларын жылжымалы контейнерде, қажет болса кезек бойынша топтастырып шығарыңыз.",
|
||||
"inputPlaceholder": "Чатты бастау үшін хабарлама жазыңыз…",
|
||||
"send": "Жіберу",
|
||||
"clear": "Тазалау"
|
||||
"clear": "Тазалау",
|
||||
"newChat": "New chat",
|
||||
"newProject": "New project",
|
||||
"newProjectName": "New project",
|
||||
"unsorted": "Unsorted",
|
||||
"emptyState": "Start a conversation. Your history stays in this browser.",
|
||||
"anonBanner": "You have reached the anonymous limit. Sign up for a free allocation.",
|
||||
"signUp": "Sign up",
|
||||
"stop": "Stop"
|
||||
}
|
||||
|
||||
@@ -5,5 +5,13 @@
|
||||
"transcriptPlaceholder": "سجل المحادثة سيظهر هنا. اعرض رسائل النموذج والمستخدم في حاوية قابلة للتمرير، ويمكنك تجميعها حسب أدوار الحوار إذا رغبت.",
|
||||
"inputPlaceholder": "اكتب رسالة لبدء المحادثة…",
|
||||
"send": "إرسال",
|
||||
"clear": "مسح"
|
||||
"clear": "مسح",
|
||||
"newChat": "New chat",
|
||||
"newProject": "New project",
|
||||
"newProjectName": "New project",
|
||||
"unsorted": "Unsorted",
|
||||
"emptyState": "Start a conversation. Your history stays in this browser.",
|
||||
"anonBanner": "You have reached the anonymous limit. Sign up for a free allocation.",
|
||||
"signUp": "Sign up",
|
||||
"stop": "Stop"
|
||||
}
|
||||
|
||||
@@ -5,5 +5,13 @@
|
||||
"transcriptPlaceholder": "De chattranscriptie verschijnt hier. Toon berichten van model en gebruiker in een scrollbare container, eventueel gegroepeerd per beurt.",
|
||||
"inputPlaceholder": "Schrijf een bericht om een gesprek te starten…",
|
||||
"send": "Verzenden",
|
||||
"clear": "Leegmaken"
|
||||
"clear": "Leegmaken",
|
||||
"newChat": "New chat",
|
||||
"newProject": "New project",
|
||||
"newProjectName": "New project",
|
||||
"unsorted": "Unsorted",
|
||||
"emptyState": "Start a conversation. Your history stays in this browser.",
|
||||
"anonBanner": "You have reached the anonymous limit. Sign up for a free allocation.",
|
||||
"signUp": "Sign up",
|
||||
"stop": "Stop"
|
||||
}
|
||||
|
||||
@@ -5,5 +5,13 @@
|
||||
"transcriptPlaceholder": "Chat‑loggen vises her. Vis meldinger fra modell og bruker i en rullende beholder, eventuelt gruppert per tur.",
|
||||
"inputPlaceholder": "Skriv en melding for å starte en samtale…",
|
||||
"send": "Send",
|
||||
"clear": "Tøm"
|
||||
"clear": "Tøm",
|
||||
"newChat": "New chat",
|
||||
"newProject": "New project",
|
||||
"newProjectName": "New project",
|
||||
"unsorted": "Unsorted",
|
||||
"emptyState": "Start a conversation. Your history stays in this browser.",
|
||||
"anonBanner": "You have reached the anonymous limit. Sign up for a free allocation.",
|
||||
"signUp": "Sign up",
|
||||
"stop": "Stop"
|
||||
}
|
||||
|
||||
@@ -5,5 +5,13 @@
|
||||
"transcriptPlaceholder": "Transkrypcja czatu pojawi się tutaj. Renderuj wiadomości modelu i użytkownika w przewijanym kontenerze, opcjonalnie pogrupowane według tur.",
|
||||
"inputPlaceholder": "Napisz wiadomość, aby rozpocząć rozmowę…",
|
||||
"send": "Wyślij",
|
||||
"clear": "Wyczyść"
|
||||
"clear": "Wyczyść",
|
||||
"newChat": "New chat",
|
||||
"newProject": "New project",
|
||||
"newProjectName": "New project",
|
||||
"unsorted": "Unsorted",
|
||||
"emptyState": "Start a conversation. Your history stays in this browser.",
|
||||
"anonBanner": "You have reached the anonymous limit. Sign up for a free allocation.",
|
||||
"signUp": "Sign up",
|
||||
"stop": "Stop"
|
||||
}
|
||||
|
||||
@@ -5,5 +5,13 @@
|
||||
"transcriptPlaceholder": "A transcrição do chat aparecerá aqui. Renderiza as mensagens do modelo e da pessoa utilizadora num contentor rolável, opcionalmente agrupadas por turno.",
|
||||
"inputPlaceholder": "Escreve uma mensagem para começar a conversar…",
|
||||
"send": "Enviar",
|
||||
"clear": "Limpar"
|
||||
"clear": "Limpar",
|
||||
"newChat": "New chat",
|
||||
"newProject": "New project",
|
||||
"newProjectName": "New project",
|
||||
"unsorted": "Unsorted",
|
||||
"emptyState": "Start a conversation. Your history stays in this browser.",
|
||||
"anonBanner": "You have reached the anonymous limit. Sign up for a free allocation.",
|
||||
"signUp": "Sign up",
|
||||
"stop": "Stop"
|
||||
}
|
||||
|
||||
@@ -5,5 +5,13 @@
|
||||
"transcriptPlaceholder": "Transcrierea conversației va apărea aici. Afișează mesajele modelului și ale utilizatorului într-un container derulant, opțional grupate pe rânduri de dialog.",
|
||||
"inputPlaceholder": "Scrie un mesaj pentru a începe conversația…",
|
||||
"send": "Trimite",
|
||||
"clear": "Curăță"
|
||||
"clear": "Curăță",
|
||||
"newChat": "New chat",
|
||||
"newProject": "New project",
|
||||
"newProjectName": "New project",
|
||||
"unsorted": "Unsorted",
|
||||
"emptyState": "Start a conversation. Your history stays in this browser.",
|
||||
"anonBanner": "You have reached the anonymous limit. Sign up for a free allocation.",
|
||||
"signUp": "Sign up",
|
||||
"stop": "Stop"
|
||||
}
|
||||
|
||||
@@ -5,5 +5,13 @@
|
||||
"transcriptPlaceholder": "",
|
||||
"inputPlaceholder": "",
|
||||
"send": "",
|
||||
"clear": ""
|
||||
"clear": "",
|
||||
"newChat": "New chat",
|
||||
"newProject": "New project",
|
||||
"newProjectName": "New project",
|
||||
"unsorted": "Unsorted",
|
||||
"emptyState": "Start a conversation. Your history stays in this browser.",
|
||||
"anonBanner": "You have reached the anonymous limit. Sign up for a free allocation.",
|
||||
"signUp": "Sign up",
|
||||
"stop": "Stop"
|
||||
}
|
||||
|
||||
@@ -5,5 +5,13 @@
|
||||
"transcriptPlaceholder": "Transkript ćaskanja će se pojaviti ovde. Prikaži poruke modela i korisnika u kliznom kontejneru, po želji grupisane po toku razgovora.",
|
||||
"inputPlaceholder": "Unesi poruku da započneš razgovor…",
|
||||
"send": "Pošalji",
|
||||
"clear": "Obriši"
|
||||
"clear": "Obriši",
|
||||
"newChat": "New chat",
|
||||
"newProject": "New project",
|
||||
"newProjectName": "New project",
|
||||
"unsorted": "Unsorted",
|
||||
"emptyState": "Start a conversation. Your history stays in this browser.",
|
||||
"anonBanner": "You have reached the anonymous limit. Sign up for a free allocation.",
|
||||
"signUp": "Sign up",
|
||||
"stop": "Stop"
|
||||
}
|
||||
|
||||
@@ -5,5 +5,13 @@
|
||||
"transcriptPlaceholder": "Chattloggen visas här. Visa meddelanden från modellen och användaren i en rullande behållare, eventuellt grupperade per tur.",
|
||||
"inputPlaceholder": "Skriv ett meddelande för att börja chatta…",
|
||||
"send": "Skicka",
|
||||
"clear": "Rensa"
|
||||
"clear": "Rensa",
|
||||
"newChat": "New chat",
|
||||
"newProject": "New project",
|
||||
"newProjectName": "New project",
|
||||
"unsorted": "Unsorted",
|
||||
"emptyState": "Start a conversation. Your history stays in this browser.",
|
||||
"anonBanner": "You have reached the anonymous limit. Sign up for a free allocation.",
|
||||
"signUp": "Sign up",
|
||||
"stop": "Stop"
|
||||
}
|
||||
|
||||
@@ -5,5 +5,13 @@
|
||||
"transcriptPlaceholder": "Maandishi ya mazungumzo yataonekana hapa. Onyesha ujumbe wa mtumiaji na ule wa modeli ndani ya sehemu inayosogezwa, ukipanga kwa mizunguko ya mazungumzo ikiwa utapendelea.",
|
||||
"inputPlaceholder": "Andika ujumbe kuanza kuzungumza…",
|
||||
"send": "Tuma",
|
||||
"clear": "Futa"
|
||||
"clear": "Futa",
|
||||
"newChat": "New chat",
|
||||
"newProject": "New project",
|
||||
"newProjectName": "New project",
|
||||
"unsorted": "Unsorted",
|
||||
"emptyState": "Start a conversation. Your history stays in this browser.",
|
||||
"anonBanner": "You have reached the anonymous limit. Sign up for a free allocation.",
|
||||
"signUp": "Sign up",
|
||||
"stop": "Stop"
|
||||
}
|
||||
|
||||
@@ -5,5 +5,13 @@
|
||||
"transcriptPlaceholder": "Sohbet geçmişi burada görünecek. Modelden ve kullanıcıdan gelen mesajları, isterseniz turlara göre gruplayarak, kaydırılabilir bir alanda gösterin.",
|
||||
"inputPlaceholder": "Sohbete başlamak için bir mesaj yazın…",
|
||||
"send": "Gönder",
|
||||
"clear": "Temizle"
|
||||
"clear": "Temizle",
|
||||
"newChat": "New chat",
|
||||
"newProject": "New project",
|
||||
"newProjectName": "New project",
|
||||
"unsorted": "Unsorted",
|
||||
"emptyState": "Start a conversation. Your history stays in this browser.",
|
||||
"anonBanner": "You have reached the anonymous limit. Sign up for a free allocation.",
|
||||
"signUp": "Sign up",
|
||||
"stop": "Stop"
|
||||
}
|
||||
|
||||
@@ -5,5 +5,13 @@
|
||||
"transcriptPlaceholder": "Тут з’явиться стенограма чату. Відображайте повідомлення моделі та користувача в контейнері з прокруткою, за бажанням групуючи їх за ходами діалогу.",
|
||||
"inputPlaceholder": "Напишіть повідомлення, щоб розпочати розмову…",
|
||||
"send": "Надіслати",
|
||||
"clear": "Очистити"
|
||||
"clear": "Очистити",
|
||||
"newChat": "New chat",
|
||||
"newProject": "New project",
|
||||
"newProjectName": "New project",
|
||||
"unsorted": "Unsorted",
|
||||
"emptyState": "Start a conversation. Your history stays in this browser.",
|
||||
"anonBanner": "You have reached the anonymous limit. Sign up for a free allocation.",
|
||||
"signUp": "Sign up",
|
||||
"stop": "Stop"
|
||||
}
|
||||
|
||||
@@ -5,5 +5,13 @@
|
||||
"transcriptPlaceholder": "Chat tarixlari shu yerda koʻrinadi. Model va foydalanuvchi xabarlarini aylanuvchi konteynerda, zarur boʻlsa navbatlar boʻyicha guruhlab tasvirlang.",
|
||||
"inputPlaceholder": "Chatni boshlash uchun xabar yozing…",
|
||||
"send": "Yuborish",
|
||||
"clear": "Tozalash"
|
||||
"clear": "Tozalash",
|
||||
"newChat": "New chat",
|
||||
"newProject": "New project",
|
||||
"newProjectName": "New project",
|
||||
"unsorted": "Unsorted",
|
||||
"emptyState": "Start a conversation. Your history stays in this browser.",
|
||||
"anonBanner": "You have reached the anonymous limit. Sign up for a free allocation.",
|
||||
"signUp": "Sign up",
|
||||
"stop": "Stop"
|
||||
}
|
||||
|
||||
@@ -5,5 +5,13 @@
|
||||
"transcriptPlaceholder": "Àkọsílẹ̀ ìjíròrò yóò hàn níbí. Ṣàfihàn àwọn ìfiránṣẹ́ láti ọ̀dọ̀ àwòrán àfọwọ́kọ àti láti ọdọ oníṣè̩-ló̩rùn nínú àpótí tí a lè sáré sókè-sísàlẹ̀, tí o bá fẹ́ o tún lè pín wọn sí ìpínpín nípasẹ̀ àtúnṣe (turn).",
|
||||
"inputPlaceholder": "Kọ ìfiránṣẹ́ láti bẹ̀rẹ̀ ìjíròrò…",
|
||||
"send": "Ránṣẹ́",
|
||||
"clear": "Pa mọ́"
|
||||
"clear": "Pa mọ́",
|
||||
"newChat": "New chat",
|
||||
"newProject": "New project",
|
||||
"newProjectName": "New project",
|
||||
"unsorted": "Unsorted",
|
||||
"emptyState": "Start a conversation. Your history stays in this browser.",
|
||||
"anonBanner": "You have reached the anonymous limit. Sign up for a free allocation.",
|
||||
"signUp": "Sign up",
|
||||
"stop": "Stop"
|
||||
}
|
||||
|
||||
@@ -5,5 +5,13 @@
|
||||
"transcriptPlaceholder": "Irekhodi lengxoxo lizovela lapha. Bonisa imiyalezo evela kumodeli nasemsebenzini womsebenzisi ngaphakathi kwesiqukathi esinokuskroleka, ungayihlukanisa nangemijikelezo yengxoxo uma uthanda.",
|
||||
"inputPlaceholder": "Bhala umlayezo ukuqala ingxoxo…",
|
||||
"send": "Thumela",
|
||||
"clear": "Sula"
|
||||
"clear": "Sula",
|
||||
"newChat": "New chat",
|
||||
"newProject": "New project",
|
||||
"newProjectName": "New project",
|
||||
"unsorted": "Unsorted",
|
||||
"emptyState": "Start a conversation. Your history stays in this browser.",
|
||||
"anonBanner": "You have reached the anonymous limit. Sign up for a free allocation.",
|
||||
"signUp": "Sign up",
|
||||
"stop": "Stop"
|
||||
}
|
||||
|
||||
109
helexa.ai/src/lib/chatClient.ts
Normal file
109
helexa.ai/src/lib/chatClient.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
// Streaming chat client → the mesh router's OpenAI-compatible
|
||||
// /v1/chat/completions (SSE). Parses the byte stream incrementally so tokens
|
||||
// render as they arrive; surfaces the OpenAI error envelope's `code` so the
|
||||
// UI can react (rate_limit_exceeded, insufficient_quota, invalid_api_key,
|
||||
// context_length_exceeded). An AbortController powers the Stop button.
|
||||
|
||||
export interface ChatMessage {
|
||||
role: "system" | "user" | "assistant";
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface StreamHandlers {
|
||||
onDelta: (text: string) => void;
|
||||
onUsage?: (prompt: number, completion: number) => void;
|
||||
onDone: () => void;
|
||||
onError: (code: string, message: string) => void;
|
||||
}
|
||||
|
||||
export interface StreamOptions {
|
||||
baseUrl?: string;
|
||||
apiKey?: string; // bearer for authenticated requests; omitted = anonymous
|
||||
model: string;
|
||||
messages: ChatMessage[];
|
||||
signal: AbortSignal;
|
||||
}
|
||||
|
||||
const DEFAULT_BASE = import.meta.env.VITE_ROUTER_BASE_URL || "";
|
||||
|
||||
export async function streamChatCompletion(
|
||||
opts: StreamOptions,
|
||||
h: StreamHandlers,
|
||||
): Promise<void> {
|
||||
const base = (opts.baseUrl ?? DEFAULT_BASE).replace(/\/$/, "");
|
||||
let resp: Response;
|
||||
try {
|
||||
const headers: Record<string, string> = {
|
||||
"content-type": "application/json",
|
||||
accept: "text/event-stream",
|
||||
};
|
||||
if (opts.apiKey) headers.authorization = `Bearer ${opts.apiKey}`;
|
||||
resp = await fetch(`${base}/v1/chat/completions`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
model: opts.model,
|
||||
messages: opts.messages,
|
||||
stream: true,
|
||||
}),
|
||||
signal: opts.signal,
|
||||
});
|
||||
} catch (e) {
|
||||
if ((e as Error).name === "AbortError") return h.onDone();
|
||||
return h.onError("network_error", "Could not reach the mesh.");
|
||||
}
|
||||
|
||||
if (!resp.ok || !resp.body) {
|
||||
// Parse the OpenAI error envelope for the machine-readable code.
|
||||
let code = "api_error";
|
||||
let message = `Request failed (${resp.status}).`;
|
||||
try {
|
||||
const body = await resp.json();
|
||||
code = body?.error?.code ?? body?.error?.type ?? code;
|
||||
message = body?.error?.message ?? message;
|
||||
} catch {
|
||||
/* non-JSON body */
|
||||
}
|
||||
return h.onError(code, message);
|
||||
}
|
||||
|
||||
const reader = resp.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
try {
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
// SSE frames are separated by a blank line.
|
||||
let sep: number;
|
||||
while ((sep = buffer.indexOf("\n\n")) !== -1) {
|
||||
const frame = buffer.slice(0, sep);
|
||||
buffer = buffer.slice(sep + 2);
|
||||
for (const line of frame.split("\n")) {
|
||||
const trimmed = line.trimStart();
|
||||
if (!trimmed.startsWith("data:")) continue;
|
||||
const data = trimmed.slice(5).trim();
|
||||
if (data === "[DONE]") {
|
||||
return h.onDone();
|
||||
}
|
||||
try {
|
||||
const json = JSON.parse(data);
|
||||
const delta = json?.choices?.[0]?.delta?.content;
|
||||
if (typeof delta === "string" && delta) h.onDelta(delta);
|
||||
const usage = json?.usage;
|
||||
if (usage && h.onUsage) {
|
||||
h.onUsage(usage.prompt_tokens ?? 0, usage.completion_tokens ?? 0);
|
||||
}
|
||||
} catch {
|
||||
/* keep streaming past a non-JSON keepalive */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
h.onDone();
|
||||
} catch (e) {
|
||||
if ((e as Error).name === "AbortError") return h.onDone();
|
||||
h.onError("stream_error", "The response stream was interrupted.");
|
||||
}
|
||||
}
|
||||
31
helexa.ai/src/lib/fingerprint.ts
Normal file
31
helexa.ai/src/lib/fingerprint.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
// Browser fingerprint (FingerprintJS OSS) — best-effort, never auth.
|
||||
// Two uses: namespacing anonymous local data, and a soft client identifier
|
||||
// the router can use to throttle the anonymous public path. Cached in the
|
||||
// Dexie `meta` store so it's computed once.
|
||||
|
||||
import FingerprintJS from "@fingerprintjs/fingerprintjs";
|
||||
import { db } from "../data/db";
|
||||
|
||||
const META_KEY = "fingerprint";
|
||||
let inFlight: Promise<string> | null = null;
|
||||
|
||||
export async function getFingerprint(): Promise<string> {
|
||||
const cached = await db.meta.get(META_KEY);
|
||||
if (cached && typeof cached.value === "string") return cached.value;
|
||||
if (inFlight) return inFlight;
|
||||
inFlight = (async () => {
|
||||
let id = "unknown";
|
||||
try {
|
||||
const fp = await FingerprintJS.load();
|
||||
const result = await fp.get();
|
||||
id = result.visitorId;
|
||||
} catch {
|
||||
// Fingerprinting is best-effort; fall back to a random local id so
|
||||
// anonymous data still has a stable namespace this session.
|
||||
id = `local-${crypto.randomUUID()}`;
|
||||
}
|
||||
await db.meta.put({ key: META_KEY, value: id });
|
||||
return id;
|
||||
})();
|
||||
return inFlight;
|
||||
}
|
||||
82
helexa.ai/src/lib/useChat.ts
Normal file
82
helexa.ai/src/lib/useChat.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
// Orchestrates a single conversation: persists the user turn, opens a
|
||||
// streaming assistant message, appends deltas to Dexie live, and finalizes
|
||||
// on done/error. The UI re-renders via useLiveQuery on the messages table.
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import {
|
||||
addMessage,
|
||||
appendToMessage,
|
||||
finalizeMessage,
|
||||
listMessages,
|
||||
renameConversation,
|
||||
} from "../data/repositories";
|
||||
import { streamChatCompletion, type ChatMessage } from "./chatClient";
|
||||
|
||||
export interface UseChat {
|
||||
streaming: boolean;
|
||||
error: { code: string; message: string } | null;
|
||||
send: (text: string) => Promise<void>;
|
||||
stop: () => void;
|
||||
}
|
||||
|
||||
export function useChat(
|
||||
conversationId: string | null,
|
||||
opts: { model: string; apiKey?: string },
|
||||
): UseChat {
|
||||
const [streaming, setStreaming] = useState(false);
|
||||
const [error, setError] = useState<{ code: string; message: string } | null>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
async function send(text: string): Promise<void> {
|
||||
if (!conversationId || streaming || !text.trim()) return;
|
||||
setError(null);
|
||||
|
||||
// History → request messages (before adding the new turn's assistant slot).
|
||||
const history = await listMessages(conversationId);
|
||||
const reqMessages: ChatMessage[] = history
|
||||
.filter((m) => m.status !== "error")
|
||||
.map((m) => ({ role: m.role, content: m.content }));
|
||||
reqMessages.push({ role: "user", content: text });
|
||||
|
||||
await addMessage(conversationId, "user", text, "complete");
|
||||
// Title the conversation from its first user message.
|
||||
if (history.length === 0) {
|
||||
await renameConversation(conversationId, text.slice(0, 60));
|
||||
}
|
||||
const assistantId = await addMessage(conversationId, "assistant", "", "streaming");
|
||||
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
setStreaming(true);
|
||||
|
||||
await streamChatCompletion(
|
||||
{
|
||||
apiKey: opts.apiKey,
|
||||
model: opts.model,
|
||||
messages: reqMessages,
|
||||
signal: controller.signal,
|
||||
},
|
||||
{
|
||||
onDelta: (t) => void appendToMessage(assistantId, t),
|
||||
onUsage: (p, c) =>
|
||||
void finalizeMessage(assistantId, { promptTokens: p, completionTokens: c }),
|
||||
onDone: () => {
|
||||
void finalizeMessage(assistantId, { status: "complete" });
|
||||
setStreaming(false);
|
||||
},
|
||||
onError: (code, message) => {
|
||||
void finalizeMessage(assistantId, { status: "error", errorCode: code });
|
||||
setError({ code, message });
|
||||
setStreaming(false);
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function stop(): void {
|
||||
abortRef.current?.abort();
|
||||
setStreaming(false);
|
||||
}
|
||||
|
||||
return { streaming, error, send, stop };
|
||||
}
|
||||
244
helexa.ai/src/pages/Chat.tsx
Normal file
244
helexa.ai/src/pages/Chat.tsx
Normal file
@@ -0,0 +1,244 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLiveQuery } from "dexie-react-hooks";
|
||||
import { Alert, Badge, Button, Form } from "react-bootstrap";
|
||||
import { db } from "../data/db";
|
||||
import {
|
||||
createConversation,
|
||||
createProject,
|
||||
listConversations,
|
||||
listProjects,
|
||||
} from "../data/repositories";
|
||||
import { getFingerprint } from "../lib/fingerprint";
|
||||
import { useChat } from "../lib/useChat";
|
||||
|
||||
const ANON_OWNER = "anon";
|
||||
const ANON_MODEL = import.meta.env.VITE_ANON_MODEL || "helexa/small";
|
||||
const ANON_MESSAGE_CAP = 20;
|
||||
const ANON_COUNT_KEY = "anonMessageCount";
|
||||
|
||||
/**
|
||||
* The chat workspace landing (`/`). Anonymous + fingerprinted: history and
|
||||
* project organisation live entirely in IndexedDB; inference streams from
|
||||
* the mesh router with no bearer (the constrained anonymous model), capped
|
||||
* client-side with a sign-up nudge. Authenticated mode (bearer + full
|
||||
* models, owner = account) lands in F5.
|
||||
*/
|
||||
export default function Chat() {
|
||||
const { t } = useTranslation("chat");
|
||||
const owner = ANON_OWNER;
|
||||
|
||||
// Namespace anonymous data to the fingerprint (best-effort) at mount.
|
||||
useEffect(() => {
|
||||
void getFingerprint();
|
||||
}, []);
|
||||
|
||||
const projects = useLiveQuery(() => listProjects(owner), [owner], []);
|
||||
const conversations = useLiveQuery(() => listConversations(owner), [owner], []);
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
|
||||
const anonCount =
|
||||
useLiveQuery(async () => {
|
||||
const m = await db.meta.get(ANON_COUNT_KEY);
|
||||
return typeof m?.value === "number" ? m.value : 0;
|
||||
}, [], 0) ?? 0;
|
||||
const capped = anonCount >= ANON_MESSAGE_CAP;
|
||||
|
||||
const messages = useLiveQuery(
|
||||
async () => {
|
||||
if (!activeId) return [];
|
||||
const { listMessages } = await import("../data/repositories");
|
||||
return listMessages(activeId);
|
||||
},
|
||||
[activeId],
|
||||
[],
|
||||
);
|
||||
|
||||
const { streaming, error, send, stop } = useChat(activeId, { model: ANON_MODEL });
|
||||
const [draft, setDraft] = useState("");
|
||||
const threadRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
threadRef.current?.scrollTo({ top: threadRef.current.scrollHeight });
|
||||
}, [messages]);
|
||||
|
||||
async function newChat(projectId: string | null = null) {
|
||||
const id = await createConversation(owner, ANON_MODEL, projectId);
|
||||
setActiveId(id);
|
||||
}
|
||||
|
||||
async function onSend() {
|
||||
const text = draft.trim();
|
||||
if (!text || streaming || capped) return;
|
||||
let convId = activeId;
|
||||
if (!convId) {
|
||||
convId = await createConversation(owner, ANON_MODEL);
|
||||
setActiveId(convId);
|
||||
}
|
||||
setDraft("");
|
||||
await db.meta.put({ key: ANON_COUNT_KEY, value: anonCount + 1 });
|
||||
await send(text);
|
||||
}
|
||||
|
||||
// Group conversations by project for the sidebar.
|
||||
const grouped = useMemo(() => {
|
||||
const byProject = new Map<string | null, typeof conversations>();
|
||||
for (const c of conversations ?? []) {
|
||||
const arr = byProject.get(c.projectId) ?? [];
|
||||
arr.push(c);
|
||||
byProject.set(c.projectId, arr);
|
||||
}
|
||||
return byProject;
|
||||
}, [conversations]);
|
||||
|
||||
return (
|
||||
<div className="d-flex flex-grow-1" style={{ minHeight: 0 }}>
|
||||
{/* Sidebar */}
|
||||
<aside
|
||||
className="border-end p-3 d-flex flex-column gap-2"
|
||||
style={{ width: 280, overflowY: "auto" }}
|
||||
>
|
||||
<div className="d-flex gap-2">
|
||||
<Button size="sm" variant="primary" onClick={() => void newChat()}>
|
||||
{t("newChat")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline-secondary"
|
||||
onClick={() => void createProject(owner, t("newProjectName"))}
|
||||
>
|
||||
{t("newProject")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ConversationGroup
|
||||
label={t("unsorted")}
|
||||
items={grouped.get(null) ?? []}
|
||||
activeId={activeId}
|
||||
onSelect={setActiveId}
|
||||
/>
|
||||
{(projects ?? []).map((p) => (
|
||||
<ConversationGroup
|
||||
key={p.id}
|
||||
label={p.name}
|
||||
items={grouped.get(p.id) ?? []}
|
||||
activeId={activeId}
|
||||
onSelect={setActiveId}
|
||||
/>
|
||||
))}
|
||||
</aside>
|
||||
|
||||
{/* Main */}
|
||||
<section className="d-flex flex-column flex-grow-1" style={{ minWidth: 0 }}>
|
||||
<div ref={threadRef} className="flex-grow-1 p-3 overflow-auto">
|
||||
{(messages ?? []).length === 0 ? (
|
||||
<div className="text-muted text-center mt-5">
|
||||
<Badge bg="secondary" className="mb-2">
|
||||
{t("badge")}
|
||||
</Badge>
|
||||
<p>{t("emptyState")}</p>
|
||||
</div>
|
||||
) : (
|
||||
(messages ?? []).map((m) => (
|
||||
<div
|
||||
key={m.id}
|
||||
className={`mb-3 d-flex ${m.role === "user" ? "justify-content-end" : "justify-content-start"}`}
|
||||
>
|
||||
<div
|
||||
className={`surface-elevated p-2 px-3 rounded-3 ${m.role === "user" ? "bg-body-tertiary" : ""}`}
|
||||
style={{ maxWidth: "80%", whiteSpace: "pre-wrap" }}
|
||||
>
|
||||
{m.content}
|
||||
{m.status === "streaming" && <span className="opacity-50"> ▋</span>}
|
||||
{m.status === "error" && (
|
||||
<span className="text-danger small"> ⚠ {m.errorCode}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Alert variant="warning" className="m-2 py-2">
|
||||
{error.message}{" "}
|
||||
{(error.code === "insufficient_quota" ||
|
||||
error.code === "rate_limit_exceeded" ||
|
||||
capped) && <a href="/register">{t("signUp")}</a>}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{capped && !error && (
|
||||
<Alert variant="info" className="m-2 py-2">
|
||||
{t("anonBanner")} <a href="/register">{t("signUp")}</a>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Form
|
||||
className="d-flex gap-2 p-2 border-top"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void onSend();
|
||||
}}
|
||||
>
|
||||
<Form.Control
|
||||
as="textarea"
|
||||
rows={1}
|
||||
value={draft}
|
||||
disabled={capped}
|
||||
placeholder={capped ? t("anonBanner") : t("inputPlaceholder")}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
void onSend();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{streaming ? (
|
||||
<Button variant="outline-danger" onClick={stop}>
|
||||
{t("stop")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button type="submit" variant="primary" disabled={capped || !draft.trim()}>
|
||||
{t("send")}
|
||||
</Button>
|
||||
)}
|
||||
</Form>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ConversationGroup({
|
||||
label,
|
||||
items,
|
||||
activeId,
|
||||
onSelect,
|
||||
}: {
|
||||
label: string;
|
||||
items: { id: string; title: string }[];
|
||||
activeId: string | null;
|
||||
onSelect: (id: string) => void;
|
||||
}) {
|
||||
if (items.length === 0) return null;
|
||||
return (
|
||||
<div>
|
||||
<div className="text-uppercase text-muted small fw-semibold mt-2 mb-1">
|
||||
{label}
|
||||
</div>
|
||||
{items.map((c) => (
|
||||
<button
|
||||
key={c.id}
|
||||
type="button"
|
||||
onClick={() => onSelect(c.id)}
|
||||
className={`btn btn-sm w-100 text-start text-truncate ${
|
||||
c.id === activeId ? "btn-secondary" : "btn-link text-body"
|
||||
}`}
|
||||
>
|
||||
{c.title}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
387
helexa.ai/src/pages/Mission.tsx
Normal file
387
helexa.ai/src/pages/Mission.tsx
Normal file
@@ -0,0 +1,387 @@
|
||||
import React from "react";
|
||||
import { Row, Col, Button } from "react-bootstrap";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
FaArrowRight,
|
||||
FaArrowLeft,
|
||||
FaGithub,
|
||||
FaArrowUpRightFromSquare,
|
||||
FaServer,
|
||||
FaNetworkWired,
|
||||
FaCoins,
|
||||
FaCircleNodes,
|
||||
FaPeopleGroup,
|
||||
FaScaleBalanced,
|
||||
FaInfinity,
|
||||
} from "react-icons/fa6";
|
||||
import DirectionalIcon from "../components/DirectionalIcon";
|
||||
|
||||
import "../App.css";
|
||||
import "../index.css";
|
||||
|
||||
/**
|
||||
* Mission
|
||||
*
|
||||
* Narrative homepage implementing the conceptual structure from ideas.md:
|
||||
* hero → intent → why now → how it works → principles → road ahead → CTA
|
||||
*/
|
||||
const Mission: React.FC = () => {
|
||||
return (
|
||||
<main className="app-main container py-4">
|
||||
<HeroSection />
|
||||
<IntentSection />
|
||||
<WhyNowSection />
|
||||
<HowItWorksSection />
|
||||
<PrinciplesSection />
|
||||
<RoadAheadSection />
|
||||
<JoinMeshSection />
|
||||
</main>
|
||||
);
|
||||
};
|
||||
|
||||
const HeroSection: React.FC = () => {
|
||||
const { t } = useTranslation("mission");
|
||||
|
||||
return (
|
||||
<section className="mb-5">
|
||||
<Row className="align-items-center g-4">
|
||||
<Col lg={6}>
|
||||
<div className="mb-3">
|
||||
<span className="badge-accent d-inline-flex align-items-center gap-2">
|
||||
<span className="rounded-circle bg-cyan-500-dot" />
|
||||
{t("hero.badge")}
|
||||
</span>
|
||||
</div>
|
||||
<h1 className="mb-3" style={{ letterSpacing: "0.04em" }}>
|
||||
{t("hero.title")}
|
||||
</h1>
|
||||
<p className="lead mb-4">{t("hero.lead")}</p>
|
||||
|
||||
<div className="d-flex flex-wrap gap-3 mb-4">
|
||||
<Button
|
||||
size="lg"
|
||||
variant="primary"
|
||||
className="d-inline-flex align-items-center gap-2"
|
||||
href="https://x.com/helexaai"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{t("hero.ctaJoinMesh")}
|
||||
<DirectionalIcon
|
||||
direction="forward"
|
||||
ltrIcon={FaArrowRight}
|
||||
rtlIcon={FaArrowLeft}
|
||||
/>
|
||||
</Button>
|
||||
<Button
|
||||
size="lg"
|
||||
variant="outline-secondary"
|
||||
className="d-inline-flex align-items-center gap-2"
|
||||
href="https://github.com/helexa-ai"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<FaGithub />
|
||||
{t("hero.ctaFollowProject")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p className="text-muted small mb-0">{t("hero.subcopy")}</p>
|
||||
</Col>
|
||||
|
||||
<Col lg={6}>
|
||||
<div className="hero-visual-wrapper position-relative">
|
||||
<div className="hero-banner surface-elevated overflow-hidden">
|
||||
{/* Use banner.png (dark #000618 background) as hero visual */}
|
||||
<img
|
||||
src="/banner.png"
|
||||
alt={t("hero.imageAlt")}
|
||||
className="img-fluid w-100"
|
||||
style={{
|
||||
display: "block",
|
||||
objectFit: "cover",
|
||||
borderRadius: "1rem",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
const IntentSection: React.FC = () => {
|
||||
const { t } = useTranslation("mission");
|
||||
|
||||
return (
|
||||
<section className="mb-5">
|
||||
<Row className="g-4">
|
||||
<Col lg={5}>
|
||||
<h2 className="mb-3">{t("intent.title")}</h2>
|
||||
</Col>
|
||||
<Col lg={7}>
|
||||
<p className="mb-3">{t("intent.p1")}</p>
|
||||
<p className="mb-3">{t("intent.p2Intro")}</p>
|
||||
<ul className="mb-3">
|
||||
<li>{t("intent.bullet1")}</li>
|
||||
<li>{t("intent.bullet2")}</li>
|
||||
<li>{t("intent.bullet3")}</li>
|
||||
<li>{t("intent.bullet4")}</li>
|
||||
</ul>
|
||||
<p className="mb-0 fw-semibold">{t("intent.closing")}</p>
|
||||
</Col>
|
||||
</Row>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
const WhyNowSection: React.FC = () => {
|
||||
const { t } = useTranslation("mission");
|
||||
|
||||
return (
|
||||
<section className="mb-5">
|
||||
<Row className="g-4">
|
||||
<Col lg={12}>
|
||||
<h2 className="mb-3">{t("whyNow.title")}</h2>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row className="g-4">
|
||||
<Col lg={6}>
|
||||
<div className="surface-elevated p-4 h-100">
|
||||
<h3 className="h5 mb-3">{t("whyNow.problemTitle")}</h3>
|
||||
<ul className="mb-0">
|
||||
<li>{t("whyNow.problemBullet1")}</li>
|
||||
<li>{t("whyNow.problemBullet2")}</li>
|
||||
<li>{t("whyNow.problemBullet3")}</li>
|
||||
<li>{t("whyNow.problemBullet4")}</li>
|
||||
<li>{t("whyNow.problemBullet5")}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</Col>
|
||||
<Col lg={6}>
|
||||
<div className="surface-elevated p-4 h-100">
|
||||
<h3 className="h5 mb-3">{t("whyNow.opportunityTitle")}</h3>
|
||||
<p className="mb-3">{t("whyNow.opportunityIntro")}</p>
|
||||
<ul className="mb-3">
|
||||
<li>{t("whyNow.opportunityBullet1")}</li>
|
||||
<li>{t("whyNow.opportunityBullet2")}</li>
|
||||
<li>{t("whyNow.opportunityBullet3")}</li>
|
||||
<li>{t("whyNow.opportunityBullet4")}</li>
|
||||
<li>{t("whyNow.opportunityBullet5")}</li>
|
||||
</ul>
|
||||
<p className="fw-semibold mb-0">{t("whyNow.opportunityClosing")}</p>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
const HowItWorksSection: React.FC = () => {
|
||||
const { t } = useTranslation("mission");
|
||||
|
||||
return (
|
||||
<section className="mb-5">
|
||||
<h2 className="mb-3">{t("howItWorks.title")}</h2>
|
||||
<Row className="g-4">
|
||||
<Col md={4}>
|
||||
<div className="surface-elevated p-4 h-100">
|
||||
<div className="mesh-icon">
|
||||
<FaServer size={16} />
|
||||
</div>
|
||||
<h3 className="h6 text-uppercase text-muted mb-2">
|
||||
{t("howItWorks.operators.eyebrow")}
|
||||
</h3>
|
||||
<h4 className="h5 mb-3">{t("howItWorks.operators.title")}</h4>
|
||||
<p className="mb-0">{t("howItWorks.operators.body")}</p>
|
||||
</div>
|
||||
</Col>
|
||||
<Col md={4}>
|
||||
<div className="surface-elevated p-4 h-100">
|
||||
<div className="mesh-icon">
|
||||
<FaNetworkWired size={16} />
|
||||
</div>
|
||||
<h3 className="h6 text-uppercase text-muted mb-2">
|
||||
{t("howItWorks.routing.eyebrow")}
|
||||
</h3>
|
||||
<h4 className="h5 mb-3">{t("howItWorks.routing.title")}</h4>
|
||||
<p className="mb-0">{t("howItWorks.routing.body")}</p>
|
||||
</div>
|
||||
</Col>
|
||||
<Col md={4}>
|
||||
<div className="surface-elevated p-4 h-100">
|
||||
<div className="mesh-icon">
|
||||
<FaCoins size={16} />
|
||||
</div>
|
||||
<h3 className="h6 text-uppercase text-muted mb-2">
|
||||
{t("howItWorks.value.eyebrow")}
|
||||
</h3>
|
||||
<h4 className="h5 mb-3">{t("howItWorks.value.title")}</h4>
|
||||
<p className="mb-0">{t("howItWorks.value.body")}</p>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
const PrinciplesSection: React.FC = () => {
|
||||
const { t } = useTranslation("mission");
|
||||
|
||||
return (
|
||||
<section className="mb-5">
|
||||
<h2 className="mb-3">{t("principles.title")}</h2>
|
||||
<Row className="g-4">
|
||||
<Col md={6}>
|
||||
<div className="surface-elevated p-4 h-100 d-flex gap-3">
|
||||
<div className="principle-icon">
|
||||
<FaCircleNodes size={14} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="h5 mb-2">{t("principles.distributed.title")}</h3>
|
||||
<p className="mb-0">{t("principles.distributed.body")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Col>
|
||||
<Col md={6}>
|
||||
<div className="surface-elevated p-4 h-100 d-flex gap-3">
|
||||
<div className="principle-icon">
|
||||
<FaPeopleGroup size={14} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="h5 mb-2">{t("principles.participation.title")}</h3>
|
||||
<p className="mb-0">{t("principles.participation.body")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Col>
|
||||
<Col md={6}>
|
||||
<div className="surface-elevated p-4 h-100 d-flex gap-3">
|
||||
<div className="principle-icon">
|
||||
<FaScaleBalanced size={14} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="h5 mb-2">{t("principles.fairness.title")}</h3>
|
||||
<p className="mb-0">{t("principles.fairness.body")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Col>
|
||||
<Col md={6}>
|
||||
<div className="surface-elevated p-4 h-100 d-flex gap-3">
|
||||
<div className="principle-icon">
|
||||
<FaInfinity size={14} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="h5 mb-2">{t("principles.evolving.title")}</h3>
|
||||
<p className="mb-0">{t("principles.evolving.body")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
const RoadAheadSection: React.FC = () => {
|
||||
const { t } = useTranslation("mission");
|
||||
|
||||
return (
|
||||
<section className="mb-5">
|
||||
<Row className="g-4 align-items-center">
|
||||
<Col lg={6}>
|
||||
<h2 className="mb-3">{t("roadAhead.title")}</h2>
|
||||
<p className="mb-3">{t("roadAhead.p1")}</p>
|
||||
<p className="mb-3">{t("roadAhead.p2")}</p>
|
||||
<p className="mb-3">{t("roadAhead.p3")}</p>
|
||||
<p className="mb-0">{t("roadAhead.p4")}</p>
|
||||
</Col>
|
||||
<Col lg={6}>
|
||||
<div className="surface-elevated p-4 d-flex flex-column align-items-start gap-3">
|
||||
<div className="d-flex align-items-center gap-3">
|
||||
<img
|
||||
src="/logo.png"
|
||||
alt="Helexa logo"
|
||||
width={40}
|
||||
height={40}
|
||||
style={{ borderRadius: "999px" }}
|
||||
/>
|
||||
<div>
|
||||
<div className="small text-uppercase text-muted">
|
||||
{t("roadAhead.card.eyebrow")}
|
||||
</div>
|
||||
<div className="fw-semibold">{t("roadAhead.card.title")}</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mb-0 text-muted small">{t("roadAhead.card.body")}</p>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
const JoinMeshSection: React.FC = () => {
|
||||
const { t } = useTranslation("mission");
|
||||
|
||||
return (
|
||||
<section className="mb-5">
|
||||
<div className="surface-elevated p-4 p-md-5 text-center position-relative overflow-hidden join-mesh-cta">
|
||||
<div className="mb-3">
|
||||
<span className="badge-accent d-inline-flex align-items-center gap-2">
|
||||
<span className="rounded-circle bg-cyan-500-dot" />
|
||||
{t("joinMesh.badge")}
|
||||
</span>
|
||||
</div>
|
||||
<h2 className="mb-3">
|
||||
{t("joinMesh.title")}{" "}
|
||||
<span className="text-hot-pink">{t("joinMesh.titleHighlight")}</span>
|
||||
</h2>
|
||||
<p className="lead mb-4">{t("joinMesh.lead")}</p>
|
||||
|
||||
<div className="d-flex flex-wrap justify-content-center gap-3 mb-4">
|
||||
<Button
|
||||
size="lg"
|
||||
variant="primary"
|
||||
className="d-inline-flex align-items-center gap-2"
|
||||
href="https://github.com/helexa-ai"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{t("joinMesh.ctaRunNode")}
|
||||
<DirectionalIcon
|
||||
direction="forward"
|
||||
ltrIcon={FaArrowRight}
|
||||
rtlIcon={FaArrowLeft}
|
||||
/>
|
||||
</Button>
|
||||
<Button
|
||||
size="lg"
|
||||
variant="outline-secondary"
|
||||
className="d-inline-flex align-items-center gap-2"
|
||||
href="https://x.com/helexaai"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{t("joinMesh.ctaJoinAnnouncements")}
|
||||
<FaArrowUpRightFromSquare />
|
||||
</Button>
|
||||
<Button
|
||||
size="lg"
|
||||
variant="outline-secondary"
|
||||
className="d-inline-flex align-items-center gap-2"
|
||||
href="https://github.com/helexa-ai"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<FaGithub />
|
||||
{t("joinMesh.ctaExploreCode")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p className="text-muted small mb-0">{t("joinMesh.footer")}</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default Mission;
|
||||
Reference in New Issue
Block a user