Compare commits
30 Commits
feat/B3-co
...
4cb52e3144
| Author | SHA1 | Date | |
|---|---|---|---|
| 4cb52e3144 | |||
|
6f956dfda3
|
|||
|
6e0f15c888
|
|||
| 66eb9f558f | |||
|
f96a2e7ed3
|
|||
| b17b555a3d | |||
|
13daf95514
|
|||
| 319b01e0b2 | |||
|
6731adca51
|
|||
|
7e11a7688c
|
|||
|
5600575ba2
|
|||
|
bc7476bf1b
|
|||
|
5a8f6bc7b3
|
|||
|
452d7d9b3d
|
|||
|
21eb211d6a
|
|||
|
508b326bf7
|
|||
|
0de99a8cc7
|
|||
|
f4117224fc
|
|||
|
ce29e0c171
|
|||
|
1bf3348c8c
|
|||
|
7c12b9ea98
|
|||
|
c596519dbd
|
|||
|
a6b1fdc33d
|
|||
|
8dd82776f1
|
|||
|
8600d4fbf2
|
|||
|
7a6f252fe0
|
|||
|
bb0d1e51b8
|
|||
|
2348cc2234
|
|||
|
f2ba12bbc5
|
|||
|
d94c62c143
|
@@ -66,6 +66,7 @@ jobs:
|
||||
build_cortex: ${{ steps.changes.outputs.build_cortex }}
|
||||
build_neuron: ${{ steps.changes.outputs.build_neuron }}
|
||||
build_bench: ${{ steps.changes.outputs.build_bench }}
|
||||
build_upstream: ${{ steps.changes.outputs.build_upstream }}
|
||||
check_rust: ${{ steps.changes.outputs.check_rust }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -104,6 +105,7 @@ jobs:
|
||||
BUILD_CORTEX=true
|
||||
BUILD_NEURON=true
|
||||
BUILD_BENCH=true
|
||||
BUILD_UPSTREAM=true
|
||||
CHECK_RUST=true
|
||||
|
||||
if [ "${GITHUB_EVENT_NAME}" = "push" ]; then
|
||||
@@ -149,6 +151,7 @@ jobs:
|
||||
NEURON_RE='^crates/neuron/|^crates/cortex-core/|^Cargo\.toml$|^Cargo\.lock$|^rpm/helexa-neuron-prerelease\.spec$|^data/neuron|^neuron\.example\.toml$|^\.gitea/workflows/build-prerelease\.yml$'
|
||||
CORTEX_RE='^crates/cortex-gateway/|^crates/cortex-cli/|^crates/cortex-core/|^Cargo\.toml$|^Cargo\.lock$|^rpm/cortex-prerelease\.spec$|^data/cortex|^cortex\.example\.toml$|^models\.example\.toml$|^\.gitea/workflows/build-prerelease\.yml$'
|
||||
BENCH_RE='^crates/helexa-bench/|^crates/cortex-core/|^Cargo\.toml$|^Cargo\.lock$|^rpm/helexa-bench-prerelease\.spec$|^data/helexa-bench|^helexa-bench\.example\.toml$|^\.gitea/workflows/build-prerelease\.yml$'
|
||||
UPSTREAM_RE='^crates/helexa-upstream/|^crates/cortex-core/|^Cargo\.toml$|^Cargo\.lock$|^rpm/helexa-upstream-prerelease\.spec$|^data/helexa-upstream|^helexa-upstream\.example\.toml$|^\.gitea/workflows/build-prerelease\.yml$'
|
||||
# Any Rust change (incl. crates not packaged here, e.g.
|
||||
# helexa-acp) still needs lint+test on main.
|
||||
RUST_RE='\.rs$|^crates/|Cargo\.toml$|^Cargo\.lock$'
|
||||
@@ -156,10 +159,12 @@ jobs:
|
||||
CORTEX_BASE=$(base_for cortex)
|
||||
NEURON_BASE=$(base_for helexa-neuron-blackwell)
|
||||
BENCH_BASE=$(base_for helexa-bench)
|
||||
UPSTREAM_BASE=$(base_for helexa-upstream)
|
||||
BUILD_CORTEX=$(decide "$CORTEX_BASE" "$CORTEX_RE")
|
||||
BUILD_NEURON=$(decide "$NEURON_BASE" "$NEURON_RE")
|
||||
BUILD_BENCH=$(decide "$BENCH_BASE" "$BENCH_RE")
|
||||
if [ "$BUILD_CORTEX" = "true" ] || [ "$BUILD_NEURON" = "true" ] || [ "$BUILD_BENCH" = "true" ]; then
|
||||
BUILD_UPSTREAM=$(decide "$UPSTREAM_BASE" "$UPSTREAM_RE")
|
||||
if [ "$BUILD_CORTEX" = "true" ] || [ "$BUILD_NEURON" = "true" ] || [ "$BUILD_BENCH" = "true" ] || [ "$BUILD_UPSTREAM" = "true" ]; then
|
||||
CHECK_RUST=true
|
||||
else
|
||||
CHECK_RUST=$(decide "$CORTEX_BASE" "$RUST_RE")
|
||||
@@ -170,8 +175,9 @@ jobs:
|
||||
echo "build_cortex=${BUILD_CORTEX}" >> "$GITHUB_OUTPUT"
|
||||
echo "build_neuron=${BUILD_NEURON}" >> "$GITHUB_OUTPUT"
|
||||
echo "build_bench=${BUILD_BENCH}" >> "$GITHUB_OUTPUT"
|
||||
echo "build_upstream=${BUILD_UPSTREAM}" >> "$GITHUB_OUTPUT"
|
||||
echo "check_rust=${CHECK_RUST}" >> "$GITHUB_OUTPUT"
|
||||
echo "### change detection: build_cortex=${BUILD_CORTEX} build_neuron=${BUILD_NEURON} build_bench=${BUILD_BENCH} check_rust=${CHECK_RUST}"
|
||||
echo "### change detection: build_cortex=${BUILD_CORTEX} build_neuron=${BUILD_NEURON} build_bench=${BUILD_BENCH} build_upstream=${BUILD_UPSTREAM} check_rust=${CHECK_RUST}"
|
||||
|
||||
# fmt + clippy + test moved here from ci.yml for main pushes so the
|
||||
# two workflows stop queueing against each other (ci.yml's checks
|
||||
@@ -303,6 +309,45 @@ jobs:
|
||||
path: artifacts/helexa-bench
|
||||
retention-days: 1
|
||||
|
||||
build-upstream:
|
||||
name: Build helexa-upstream binary
|
||||
timeout-minutes: 25
|
||||
needs: prepare
|
||||
if: needs.prepare.outputs.build_upstream == 'true'
|
||||
# Pure-Rust, non-CUDA binary — same runner as cortex/bench.
|
||||
runs-on: rust
|
||||
env:
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_BUCKET: sccache
|
||||
SCCACHE_ENDPOINT: http://caveman.kosherinata.internal:9000
|
||||
SCCACHE_REGION: auto
|
||||
SCCACHE_S3_USE_SSL: "false"
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_S3_ACCESS_KEY }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_S3_SECRET_KEY }}
|
||||
# helexa-upstream uses the sqlx runtime query API (no compile-time
|
||||
# query macros), so it builds without a database or a .sqlx cache.
|
||||
# Set OFFLINE defensively so a stray macro can never reach for a DB.
|
||||
SQLX_OFFLINE: "true"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.ref }}
|
||||
|
||||
- name: Build helexa-upstream (release, sccache escalation)
|
||||
run: script/ci-cargo-escalate.sh cargo build --release -p helexa-upstream
|
||||
|
||||
- name: Stage binary
|
||||
run: |
|
||||
mkdir --parents artifacts
|
||||
cp target/release/helexa-upstream artifacts/helexa-upstream
|
||||
./artifacts/helexa-upstream --version || true
|
||||
|
||||
- uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: upstream-fc43
|
||||
path: artifacts/helexa-upstream
|
||||
retention-days: 1
|
||||
|
||||
build-neuron:
|
||||
name: Build neuron-${{ matrix.flavour }}
|
||||
timeout-minutes: 35
|
||||
@@ -459,6 +504,44 @@ jobs:
|
||||
path: ~/rpmbuild/RPMS/x86_64/*.rpm
|
||||
retention-days: 7
|
||||
|
||||
package-upstream:
|
||||
name: Package helexa-upstream RPM
|
||||
timeout-minutes: 20
|
||||
needs: [prepare, build-upstream]
|
||||
runs-on: rpm
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.ref }}
|
||||
|
||||
- uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: upstream-fc43
|
||||
path: artifacts/
|
||||
|
||||
- name: Build RPM
|
||||
run: |
|
||||
set -eux
|
||||
rm -f ~/.rpmmacros
|
||||
rpmdev-setuptree
|
||||
cp artifacts/helexa-upstream ~/rpmbuild/SOURCES/
|
||||
cp data/helexa-upstream.service ~/rpmbuild/SOURCES/
|
||||
cp data/helexa-upstream-sysusers.conf ~/rpmbuild/SOURCES/
|
||||
cp data/helexa-upstream-firewalld.xml ~/rpmbuild/SOURCES/
|
||||
cp helexa-upstream.example.toml ~/rpmbuild/SOURCES/
|
||||
cp LICENSE ~/rpmbuild/SOURCES/
|
||||
rpmbuild -bb rpm/helexa-upstream-prerelease.spec \
|
||||
--define "upstream_version ${{ needs.prepare.outputs.version }}" \
|
||||
--define "upstream_prerelease ${{ needs.prepare.outputs.release }}" \
|
||||
--undefine dist \
|
||||
--define "dist .fc43"
|
||||
|
||||
- uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: rpm-upstream-fc43
|
||||
path: ~/rpmbuild/RPMS/x86_64/*.rpm
|
||||
retention-days: 7
|
||||
|
||||
package-neuron:
|
||||
name: Package helexa-neuron-${{ matrix.flavour }} RPM
|
||||
timeout-minutes: 20
|
||||
@@ -508,7 +591,7 @@ jobs:
|
||||
publish:
|
||||
name: Publish to rpm.lair.cafe (unstable)
|
||||
timeout-minutes: 25
|
||||
needs: [lint, test, package-cortex, package-neuron, package-bench]
|
||||
needs: [lint, test, package-cortex, package-neuron, package-bench, package-upstream]
|
||||
# Runs when at least one package was built and nothing failed.
|
||||
# lint/test may be skipped (docs-only refs never get here because
|
||||
# no packages build), but a real failure in any blocks the
|
||||
@@ -518,10 +601,11 @@ jobs:
|
||||
!cancelled()
|
||||
&& (needs.lint.result == 'success' || needs.lint.result == 'skipped')
|
||||
&& (needs.test.result == 'success' || needs.test.result == 'skipped')
|
||||
&& (needs.package-cortex.result == 'success' || needs.package-neuron.result == 'success' || needs.package-bench.result == 'success')
|
||||
&& (needs.package-cortex.result == 'success' || needs.package-neuron.result == 'success' || needs.package-bench.result == 'success' || needs.package-upstream.result == 'success')
|
||||
&& needs.package-cortex.result != 'failure'
|
||||
&& needs.package-neuron.result != 'failure'
|
||||
&& needs.package-bench.result != 'failure'
|
||||
&& needs.package-upstream.result != 'failure'
|
||||
}}
|
||||
runs-on: rpm
|
||||
concurrency:
|
||||
|
||||
125
Cargo.lock
generated
@@ -153,6 +153,18 @@ version = "1.0.102"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||
|
||||
[[package]]
|
||||
name = "argon2"
|
||||
version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"blake2",
|
||||
"cpufeatures",
|
||||
"password-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-channel"
|
||||
version = "2.5.0"
|
||||
@@ -427,6 +439,15 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "blake2"
|
||||
version = "0.10.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
|
||||
dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.10.4"
|
||||
@@ -1202,6 +1223,22 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "email-encoding"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9298e6504d9b9e780ed3f7dfd43a61be8cd0e09eb07f7706a945b0072b6670b6"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "email_address"
|
||||
version = "0.2.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449"
|
||||
|
||||
[[package]]
|
||||
name = "encode_unicode"
|
||||
version = "1.0.0"
|
||||
@@ -1296,7 +1333,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "74fef4569247a5f429d9156b9d0a2599914385dd189c539334c625d8099d90ab"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"nom",
|
||||
"nom 7.1.3",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
@@ -2079,11 +2116,15 @@ name = "helexa-upstream"
|
||||
version = "0.1.16"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"argon2",
|
||||
"axum",
|
||||
"chrono",
|
||||
"clap",
|
||||
"cortex-core",
|
||||
"figment",
|
||||
"jsonwebtoken",
|
||||
"lettre",
|
||||
"rand 0.8.6",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -2564,6 +2605,21 @@ dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jsonwebtoken"
|
||||
version = "9.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"js-sys",
|
||||
"pem",
|
||||
"ring",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"simple_asn1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
version = "1.5.0"
|
||||
@@ -2579,6 +2635,33 @@ version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
|
||||
|
||||
[[package]]
|
||||
name = "lettre"
|
||||
version = "0.11.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0da65617f6cb926332d039cb578aad56178da86e128db6a1b09f4c94fa5b3349"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.22.1",
|
||||
"email-encoding",
|
||||
"email_address",
|
||||
"fastrand",
|
||||
"futures-io",
|
||||
"futures-util",
|
||||
"httpdate",
|
||||
"idna",
|
||||
"mime",
|
||||
"nom 8.0.0",
|
||||
"percent-encoding",
|
||||
"quoted_printable",
|
||||
"rustls",
|
||||
"socket2",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"url",
|
||||
"webpki-roots 1.0.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.185"
|
||||
@@ -2927,6 +3010,15 @@ dependencies = [
|
||||
"minimal-lexical",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nom"
|
||||
version = "8.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nu-ansi-term"
|
||||
version = "0.50.3"
|
||||
@@ -3162,6 +3254,17 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "password-hash"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"rand_core 0.6.4",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "paste"
|
||||
version = "1.0.15"
|
||||
@@ -3509,6 +3612,12 @@ dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quoted_printable"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "478e0585659a122aa407eb7e3c0e1fa51b1d8a870038bd29f0cf4a8551eea972"
|
||||
|
||||
[[package]]
|
||||
name = "r-efi"
|
||||
version = "5.3.0"
|
||||
@@ -4270,6 +4379,18 @@ version = "0.3.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
|
||||
|
||||
[[package]]
|
||||
name = "simple_asn1"
|
||||
version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d"
|
||||
dependencies = [
|
||||
"num-bigint",
|
||||
"num-traits",
|
||||
"thiserror 2.0.18",
|
||||
"time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sketches-ddsketch"
|
||||
version = "0.3.1"
|
||||
@@ -4338,7 +4459,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5851699c4033c63636f7ea4cf7b7c1f1bf06d0cc03cfb42e711de5a5c46cf326"
|
||||
dependencies = [
|
||||
"base64 0.13.1",
|
||||
"nom",
|
||||
"nom 7.1.3",
|
||||
"serde",
|
||||
"unicode-segmentation",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -116,6 +116,23 @@ pub struct Usage {
|
||||
/// prompt caching lands (#11); `None` until then.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub prompt_tokens_details: Option<PromptTokensDetails>,
|
||||
/// helexa extension (non-OpenAI): server-measured prefill/decode
|
||||
/// timing, so the bench harness can compute true prefill vs decode
|
||||
/// tok/s instead of inferring both from client-side SSE arrival
|
||||
/// (#85). Additive and optional — standard OpenAI clients ignore
|
||||
/// it; cortex forwards usage verbatim so it survives proxying.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub helexa_timing: Option<HelexaTiming>,
|
||||
}
|
||||
|
||||
/// helexa extension carried on [`Usage::helexa_timing`]. Mirrors
|
||||
/// neuron's internal `FinishTiming`. All fields are server-measured;
|
||||
/// `prefill_tokens` is the prefill-rate denominator.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct HelexaTiming {
|
||||
pub prefill_ms: u64,
|
||||
pub decode_ms: u64,
|
||||
pub prefill_tokens: u64,
|
||||
}
|
||||
|
||||
/// Sub-counts of `Usage::completion_tokens`.
|
||||
|
||||
@@ -66,14 +66,48 @@ pub struct ResponsesRequest {
|
||||
pub extra: Value,
|
||||
}
|
||||
|
||||
/// `input` is either a single string or an array of typed items.
|
||||
/// `input` is either a single string or an array of items.
|
||||
/// `#[serde(untagged)]` so the wire shape `"input": "hi"` and
|
||||
/// `"input": [{...}]` both deserialize.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum ResponsesInput {
|
||||
Text(String),
|
||||
Items(Vec<ResponsesInputItem>),
|
||||
Items(Vec<ResponsesInputElement>),
|
||||
}
|
||||
|
||||
/// One element of an `input` array.
|
||||
///
|
||||
/// OpenAI's Responses API accepts three shapes here, and real clients
|
||||
/// use all of them — most notably agent-zero (via litellm), which
|
||||
/// sends the bare "easy message" form. We must tolerate every shape,
|
||||
/// because `input` is an `#[serde(untagged)]` array: a single element
|
||||
/// that matches no variant fails the *entire* request with a 422
|
||||
/// (`did not match any variant of untagged enum ResponsesInput`).
|
||||
///
|
||||
/// 1. [`Self::Typed`] — an item carrying an explicit `"type"`
|
||||
/// discriminant (`message`, `function_call`, `function_call_output`,
|
||||
/// `reasoning`).
|
||||
/// 2. [`Self::EasyMessage`] — a bare `{role, content}` with **no**
|
||||
/// `type` field. This is OpenAI's `EasyInputMessage` and what
|
||||
/// litellm emits for every turn. `content` is optional so an
|
||||
/// assistant turn carrying only tool calls (`content: null`) still
|
||||
/// parses.
|
||||
/// 3. [`Self::Other`] — anything else, captured as raw JSON and
|
||||
/// dropped during translation. This is the forward-compat escape
|
||||
/// hatch that mirrors [`ResponsesRequest::extra`] at the item
|
||||
/// level: an unmodeled item type can never again reject the whole
|
||||
/// request.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum ResponsesInputElement {
|
||||
Typed(ResponsesInputItem),
|
||||
EasyMessage {
|
||||
role: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
content: Option<ResponsesMessageContent>,
|
||||
},
|
||||
Other(Value),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -91,8 +125,11 @@ pub enum ResponsesInputItem {
|
||||
name: String,
|
||||
arguments: String,
|
||||
},
|
||||
/// User is feeding a tool result back into the model.
|
||||
FunctionCallOutput { call_id: String, output: String },
|
||||
/// User is feeding a tool result back into the model. `output`
|
||||
/// is a `Value` because OpenAI allows it to be either a plain
|
||||
/// string or an array of content parts; the translator renders
|
||||
/// either form to text rather than losing the tool result.
|
||||
FunctionCallOutput { call_id: String, output: Value },
|
||||
/// Reasoning items emitted by o-series models. Accepted but
|
||||
/// not forwarded to the model — neuron's candle path doesn't
|
||||
/// surface reasoning separately yet.
|
||||
@@ -132,6 +169,11 @@ pub enum ResponsesContentPart {
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
annotations: Vec<Value>,
|
||||
},
|
||||
/// Any content-part type we don't model (e.g. `refusal`, audio).
|
||||
/// Captured as a unit so an unknown part can't reject the whole
|
||||
/// request; dropped during translation.
|
||||
#[serde(other)]
|
||||
Unknown,
|
||||
}
|
||||
|
||||
// ── Response (non-streaming) ─────────────────────────────────────────
|
||||
@@ -277,20 +319,116 @@ mod tests {
|
||||
ResponsesInput::Items(items) => {
|
||||
assert_eq!(items.len(), 1);
|
||||
match &items[0] {
|
||||
ResponsesInputItem::Message { role, content } => {
|
||||
ResponsesInputElement::Typed(ResponsesInputItem::Message { role, content }) => {
|
||||
assert_eq!(role, "user");
|
||||
match content {
|
||||
ResponsesMessageContent::Text(t) => assert_eq!(t, "hi"),
|
||||
other => panic!("expected Text content, got {other:?}"),
|
||||
}
|
||||
}
|
||||
other => panic!("expected Message item, got {other:?}"),
|
||||
other => panic!("expected typed Message item, got {other:?}"),
|
||||
}
|
||||
}
|
||||
other => panic!("expected Items, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialises_bare_easy_message_without_type() {
|
||||
// The shape agent-zero (via litellm) actually sends: `input`
|
||||
// items are bare `{role, content}` with NO `type` field. This
|
||||
// is the exact payload that was returning 422.
|
||||
let raw = r#"{
|
||||
"model": "Qwen/Qwen3.6-27B",
|
||||
"store": true,
|
||||
"tools": [{"type": "function", "name": "x", "description": "d", "parameters": {}}],
|
||||
"input": [
|
||||
{"role": "system", "content": "you are helpful"},
|
||||
{"role": "assistant", "content": "{\"tool_name\":\"response\"}"},
|
||||
{"role": "user", "content": "hi"}
|
||||
]
|
||||
}"#;
|
||||
let req: ResponsesRequest = serde_json::from_str(raw).unwrap();
|
||||
let items = match req.input {
|
||||
ResponsesInput::Items(i) => i,
|
||||
other => panic!("expected Items, got {other:?}"),
|
||||
};
|
||||
assert_eq!(items.len(), 3);
|
||||
for el in &items {
|
||||
assert!(
|
||||
matches!(el, ResponsesInputElement::EasyMessage { .. }),
|
||||
"expected EasyMessage, got {el:?}"
|
||||
);
|
||||
}
|
||||
// `tools` / `store` ride through `extra`, not `input`.
|
||||
assert!(req.extra.get("tools").is_some());
|
||||
assert_eq!(req.extra.get("store"), Some(&Value::Bool(true)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tolerates_null_content_and_unknown_item_types() {
|
||||
// An assistant turn carrying only tool calls has `content: null`;
|
||||
// and a future/unmodeled item type must not 422 the request.
|
||||
let raw = r#"{
|
||||
"model": "m",
|
||||
"input": [
|
||||
{"role": "assistant", "content": null},
|
||||
{"type": "item_reference", "id": "abc"},
|
||||
{"type": "function_call_output", "call_id": "c1",
|
||||
"output": [{"type": "output_text", "text": "result"}]},
|
||||
{"role": "user", "content": "go"}
|
||||
]
|
||||
}"#;
|
||||
let req: ResponsesRequest = serde_json::from_str(raw).unwrap();
|
||||
let items = match req.input {
|
||||
ResponsesInput::Items(i) => i,
|
||||
other => panic!("expected Items, got {other:?}"),
|
||||
};
|
||||
assert_eq!(items.len(), 4);
|
||||
assert!(matches!(
|
||||
&items[0],
|
||||
ResponsesInputElement::EasyMessage { content: None, .. }
|
||||
));
|
||||
assert!(matches!(&items[1], ResponsesInputElement::Other(_)));
|
||||
assert!(matches!(
|
||||
&items[2],
|
||||
ResponsesInputElement::Typed(ResponsesInputItem::FunctionCallOutput { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
&items[3],
|
||||
ResponsesInputElement::EasyMessage { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tolerates_unknown_content_part_type() {
|
||||
// A `refusal` (or any unmodeled) content part must parse, not 422.
|
||||
let raw = r#"{
|
||||
"model": "m",
|
||||
"input": [
|
||||
{"role": "assistant", "content": [
|
||||
{"type": "refusal", "refusal": "no"},
|
||||
{"type": "output_text", "text": "ok"}
|
||||
]}
|
||||
]
|
||||
}"#;
|
||||
let req: ResponsesRequest = serde_json::from_str(raw).unwrap();
|
||||
let items = match req.input {
|
||||
ResponsesInput::Items(i) => i,
|
||||
other => panic!("expected Items, got {other:?}"),
|
||||
};
|
||||
let parts = match &items[0] {
|
||||
ResponsesInputElement::EasyMessage {
|
||||
content: Some(ResponsesMessageContent::Parts(p)),
|
||||
..
|
||||
} => p,
|
||||
other => panic!("expected EasyMessage with Parts, got {other:?}"),
|
||||
};
|
||||
assert_eq!(parts.len(), 2);
|
||||
assert!(matches!(&parts[0], ResponsesContentPart::Unknown));
|
||||
assert!(matches!(&parts[1], ResponsesContentPart::OutputText { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialises_input_with_image() {
|
||||
let raw = r#"{
|
||||
@@ -308,10 +446,10 @@ mod tests {
|
||||
other => panic!("expected Items, got {other:?}"),
|
||||
};
|
||||
let parts = match &items[0] {
|
||||
ResponsesInputItem::Message {
|
||||
ResponsesInputElement::Typed(ResponsesInputItem::Message {
|
||||
content: ResponsesMessageContent::Parts(p),
|
||||
..
|
||||
} => p,
|
||||
}) => p,
|
||||
other => panic!("expected Parts, got {other:?}"),
|
||||
};
|
||||
assert_eq!(parts.len(), 2);
|
||||
|
||||
@@ -400,6 +400,7 @@ pub fn openai_to_anthropic(resp: ChatCompletionResponse) -> MessagesResponse {
|
||||
total_tokens: 0,
|
||||
completion_tokens_details: None,
|
||||
prompt_tokens_details: None,
|
||||
helexa_timing: None,
|
||||
});
|
||||
|
||||
MessagesResponse {
|
||||
@@ -772,6 +773,7 @@ mod stream_tests {
|
||||
total_tokens: 267,
|
||||
completion_tokens_details: None,
|
||||
prompt_tokens_details: None,
|
||||
helexa_timing: None,
|
||||
});
|
||||
t.on_chunk(&usage_chunk);
|
||||
let fin = t.finish();
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -44,6 +44,16 @@ sqlx = { version = "0.8", default-features = false, features = [
|
||||
uuid = { version = "1", features = ["v4", "serde"] }
|
||||
sha2 = "0.10"
|
||||
subtle = "2.6"
|
||||
# Web auth (B4): argon2id password hashing, JWT sessions, CSPRNG secrets,
|
||||
# transactional email.
|
||||
argon2 = "0.5"
|
||||
jsonwebtoken = "9"
|
||||
rand = "0.8"
|
||||
lettre = { version = "0.11", default-features = false, features = [
|
||||
"tokio1-rustls-tls",
|
||||
"smtp-transport",
|
||||
"builder",
|
||||
] }
|
||||
|
||||
# cortex-core for the shared #63 OpenAiError envelope on the authz surface.
|
||||
cortex-core = { workspace = true }
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -22,6 +22,64 @@ pub struct UpstreamConfig {
|
||||
pub client_auth: ClientAuthSettings,
|
||||
#[serde(default)]
|
||||
pub authz: AuthzSettings,
|
||||
#[serde(default)]
|
||||
pub auth: AuthSettings,
|
||||
#[serde(default)]
|
||||
pub email: EmailSettings,
|
||||
}
|
||||
|
||||
/// `[auth]` — web-session signing + token lifetimes (B4). Web sessions are
|
||||
/// JWTs, distinct from inference API keys.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AuthSettings {
|
||||
/// HMAC secret for signing session JWTs. MUST be overridden in prod
|
||||
/// (env `UPSTREAM_AUTH__JWT_SECRET`); the default is dev-only.
|
||||
#[serde(default = "default_jwt_secret")]
|
||||
pub jwt_secret: String,
|
||||
/// Session token lifetime (seconds).
|
||||
#[serde(default = "default_session_ttl")]
|
||||
pub session_ttl_secs: u64,
|
||||
/// Email verification / password-reset token lifetime (seconds).
|
||||
#[serde(default = "default_email_token_ttl")]
|
||||
pub email_token_ttl_secs: u64,
|
||||
/// Public base URL of the frontend, used to build verify/reset links.
|
||||
#[serde(default = "default_app_base_url")]
|
||||
pub app_base_url: String,
|
||||
}
|
||||
|
||||
impl Default for AuthSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
jwt_secret: default_jwt_secret(),
|
||||
session_ttl_secs: default_session_ttl(),
|
||||
email_token_ttl_secs: default_email_token_ttl(),
|
||||
app_base_url: default_app_base_url(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `[email]` — transactional email transport for verify/reset.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct EmailSettings {
|
||||
/// `"log"` (dev — logs the link) or `"smtp"`.
|
||||
#[serde(default = "default_email_provider")]
|
||||
pub provider: String,
|
||||
/// SMTP relay URL (e.g. "smtp://user:pass@host:587") when provider=smtp.
|
||||
#[serde(default)]
|
||||
pub smtp_url: Option<String>,
|
||||
/// `From:` address.
|
||||
#[serde(default = "default_from_addr")]
|
||||
pub from_addr: String,
|
||||
}
|
||||
|
||||
impl Default for EmailSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
provider: default_email_provider(),
|
||||
smtp_url: None,
|
||||
from_addr: default_from_addr(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `[client_auth]` — credentials operators' cortexes present to `/authz/v1`.
|
||||
@@ -142,6 +200,24 @@ fn default_reservation_ttl() -> u64 {
|
||||
fn default_sweep_interval() -> u64 {
|
||||
60
|
||||
}
|
||||
fn default_jwt_secret() -> String {
|
||||
"dev-insecure-change-me".into()
|
||||
}
|
||||
fn default_session_ttl() -> u64 {
|
||||
7 * 24 * 3600
|
||||
}
|
||||
fn default_email_token_ttl() -> u64 {
|
||||
24 * 3600
|
||||
}
|
||||
fn default_app_base_url() -> String {
|
||||
"http://localhost:5173".into()
|
||||
}
|
||||
fn default_email_provider() -> String {
|
||||
"log".into()
|
||||
}
|
||||
fn default_from_addr() -> String {
|
||||
"helexa <no-reply@helexa.ai>".into()
|
||||
}
|
||||
|
||||
impl UpstreamConfig {
|
||||
/// Load from a TOML file with `UPSTREAM_`-prefixed env overrides
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
//! Hashing helpers. API keys and top-up codes are stored only as their
|
||||
//! sha256 (they are high-entropy secrets; sha256 is the fast, sufficient
|
||||
//! choice — argon2 is reserved for low-entropy passwords).
|
||||
//! Hashing + secret-generation helpers.
|
||||
//!
|
||||
//! - **Passwords** (low-entropy) → argon2id PHC strings.
|
||||
//! - **API keys / top-up codes / email + session tokens** (high-entropy
|
||||
//! secrets minted here) → stored only as their sha256; sha256 is the fast,
|
||||
//! sufficient choice for high-entropy material.
|
||||
|
||||
use argon2::Argon2;
|
||||
use argon2::password_hash::rand_core::OsRng as ArgonOsRng;
|
||||
use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString};
|
||||
use rand::RngCore;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
/// sha256 of `input`, as raw bytes (matches the `BYTEA` columns).
|
||||
@@ -10,3 +17,97 @@ pub fn sha256(input: &str) -> Vec<u8> {
|
||||
h.update(input.as_bytes());
|
||||
h.finalize().to_vec()
|
||||
}
|
||||
|
||||
/// Hash a password with argon2id, returning a PHC string for storage.
|
||||
pub fn hash_password(password: &str) -> Result<String, argon2::password_hash::Error> {
|
||||
let salt = SaltString::generate(&mut ArgonOsRng);
|
||||
Ok(Argon2::default()
|
||||
.hash_password(password.as_bytes(), &salt)?
|
||||
.to_string())
|
||||
}
|
||||
|
||||
/// Verify a password against a stored PHC hash. `false` on any mismatch or
|
||||
/// malformed hash (never panics).
|
||||
pub fn verify_password(password: &str, phc: &str) -> bool {
|
||||
match PasswordHash::new(phc) {
|
||||
Ok(parsed) => Argon2::default()
|
||||
.verify_password(password.as_bytes(), &parsed)
|
||||
.is_ok(),
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// A fresh URL-safe high-entropy secret (256 bits) for email/session/reset
|
||||
/// tokens. The caller stores only `sha256` of this and emails/returns the
|
||||
/// raw value.
|
||||
pub fn random_token() -> String {
|
||||
let mut bytes = [0u8; 32];
|
||||
rand::rngs::OsRng.fill_bytes(&mut bytes);
|
||||
base62(&bytes)
|
||||
}
|
||||
|
||||
/// Mint a new API key: `(raw, prefix)`. `raw` is shown to the user once;
|
||||
/// only `sha256(raw)` is stored. The prefix is a non-secret display tag.
|
||||
pub fn generate_api_key() -> (String, String) {
|
||||
let mut bytes = [0u8; 32];
|
||||
rand::rngs::OsRng.fill_bytes(&mut bytes);
|
||||
let raw = format!("sk-helexa-{}", base62(&bytes));
|
||||
// Non-secret prefix for the dashboard list (scheme + first few chars).
|
||||
let prefix: String = raw.chars().take(14).collect();
|
||||
(raw, prefix)
|
||||
}
|
||||
|
||||
/// base62 encode (0-9A-Za-z) — URL/clipboard friendly, no padding.
|
||||
fn base62(bytes: &[u8]) -> String {
|
||||
const ALPHABET: &[u8] = b"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
|
||||
// Treat the bytes as a big-endian integer and base62 it. 32 bytes → ~43
|
||||
// chars. Simple repeated-division over a big-uint built from the bytes.
|
||||
let mut digits: Vec<u8> = vec![0];
|
||||
for &byte in bytes {
|
||||
let mut carry = byte as u32;
|
||||
for d in digits.iter_mut() {
|
||||
let v = (*d as u32) * 256 + carry;
|
||||
*d = (v % 62) as u8;
|
||||
carry = v / 62;
|
||||
}
|
||||
while carry > 0 {
|
||||
digits.push((carry % 62) as u8);
|
||||
carry /= 62;
|
||||
}
|
||||
}
|
||||
digits
|
||||
.iter()
|
||||
.rev()
|
||||
.map(|&d| ALPHABET[d as usize] as char)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn password_round_trips_and_rejects_wrong() {
|
||||
let phc = hash_password("correct horse").unwrap();
|
||||
assert!(verify_password("correct horse", &phc));
|
||||
assert!(!verify_password("wrong", &phc));
|
||||
assert!(!verify_password("correct horse", "not-a-phc-string"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api_key_has_scheme_prefix_and_unique_body() {
|
||||
let (raw, prefix) = generate_api_key();
|
||||
assert!(raw.starts_with("sk-helexa-"));
|
||||
assert!(prefix.starts_with("sk-helexa-"));
|
||||
let (raw2, _) = generate_api_key();
|
||||
assert_ne!(raw, raw2, "keys are unique");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn random_tokens_are_unique_and_nonempty() {
|
||||
let a = random_token();
|
||||
let b = random_token();
|
||||
assert!(!a.is_empty());
|
||||
assert_ne!(a, b);
|
||||
}
|
||||
}
|
||||
|
||||
64
crates/helexa-upstream/src/email.rs
Normal file
@@ -0,0 +1,64 @@
|
||||
//! Transactional email for verification + password-reset links.
|
||||
//!
|
||||
//! Two transports: `Log` (dev — writes the link to the log so flows are
|
||||
//! testable without a relay) and `Smtp` (lettre over rustls). Built from
|
||||
//! `[email]` config.
|
||||
|
||||
use crate::config::EmailSettings;
|
||||
use anyhow::{Context, Result};
|
||||
use lettre::message::Mailbox;
|
||||
use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub enum EmailSender {
|
||||
/// Dev: log the message instead of sending.
|
||||
Log { from: String },
|
||||
Smtp {
|
||||
from: String,
|
||||
transport: AsyncSmtpTransport<Tokio1Executor>,
|
||||
},
|
||||
}
|
||||
|
||||
impl EmailSender {
|
||||
pub fn from_config(cfg: &EmailSettings) -> Result<Self> {
|
||||
match cfg.provider.as_str() {
|
||||
"smtp" => {
|
||||
let url = cfg
|
||||
.smtp_url
|
||||
.as_deref()
|
||||
.context("[email].smtp_url required when provider = \"smtp\"")?;
|
||||
let transport = AsyncSmtpTransport::<Tokio1Executor>::from_url(url)
|
||||
.context("parsing [email].smtp_url")?
|
||||
.build();
|
||||
Ok(EmailSender::Smtp {
|
||||
from: cfg.from_addr.clone(),
|
||||
transport,
|
||||
})
|
||||
}
|
||||
_ => Ok(EmailSender::Log {
|
||||
from: cfg.from_addr.clone(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Send a plaintext email. Errors are returned but the caller treats
|
||||
/// send failures as non-fatal to the request (the user can re-request).
|
||||
pub async fn send(&self, to: &str, subject: &str, body: &str) -> Result<()> {
|
||||
match self {
|
||||
EmailSender::Log { from } => {
|
||||
tracing::info!(%from, %to, %subject, body, "EMAIL (log transport)");
|
||||
Ok(())
|
||||
}
|
||||
EmailSender::Smtp { from, transport } => {
|
||||
let msg = Message::builder()
|
||||
.from(from.parse::<Mailbox>().context("parsing from_addr")?)
|
||||
.to(to.parse::<Mailbox>().context("parsing recipient")?)
|
||||
.subject(subject)
|
||||
.body(body.to_string())
|
||||
.context("building message")?;
|
||||
transport.send(msg).await.context("sending email")?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,15 +16,21 @@ pub mod authz;
|
||||
pub mod config;
|
||||
pub mod crypto;
|
||||
pub mod db;
|
||||
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;
|
||||
use config::UpstreamConfig;
|
||||
use email::EmailSender;
|
||||
use state::AppState;
|
||||
use std::time::Duration;
|
||||
use tower_http::cors::CorsLayer;
|
||||
use tower_http::trace::TraceLayer;
|
||||
|
||||
/// Build the axum application.
|
||||
@@ -32,6 +38,11 @@ pub fn build_app(state: AppState) -> axum::Router {
|
||||
axum::Router::new()
|
||||
.merge(handlers::routes())
|
||||
.merge(authz::router(&state))
|
||||
.merge(web::router(&state))
|
||||
// The /web/v1 surface is called cross-origin by the browser SPA in
|
||||
// dev; same-origin behind nginx in prod. Permissive is fine — these
|
||||
// endpoints authenticate via bearer/JWT, not cookies.
|
||||
.layer(CorsLayer::permissive())
|
||||
.layer(TraceLayer::new_for_http())
|
||||
.with_state(state)
|
||||
}
|
||||
@@ -40,8 +51,9 @@ pub fn build_app(state: AppState) -> axum::Router {
|
||||
/// reservation sweeper, bind the listener.
|
||||
pub async fn run(config: UpstreamConfig) -> Result<()> {
|
||||
let pool = db::connect_and_migrate(&config.db.url, config.db.max_connections).await?;
|
||||
let email = EmailSender::from_config(&config.email)?;
|
||||
let listen = config.server.listen.clone();
|
||||
let state = AppState::new(pool, config);
|
||||
let state = AppState::new(pool, config, email);
|
||||
|
||||
if state.config.client_auth.tokens.is_empty() {
|
||||
tracing::warn!(
|
||||
|
||||
@@ -20,6 +20,28 @@ enum Commands {
|
||||
#[arg(short, long, default_value = "helexa-upstream.toml")]
|
||||
config: String,
|
||||
},
|
||||
/// Mint single-use top-up codes and print them (one per line). The raw
|
||||
/// codes are shown only here — only their hash is stored. (The future
|
||||
/// faucet bot calls the same path.)
|
||||
Mint {
|
||||
#[arg(short, long, default_value = "helexa-upstream.toml")]
|
||||
config: String,
|
||||
/// Tokens each code grants.
|
||||
#[arg(long)]
|
||||
value: i64,
|
||||
/// How many codes to mint.
|
||||
#[arg(long, default_value_t = 1)]
|
||||
count: u32,
|
||||
/// Optional human label (e.g. "small", "beta-launch").
|
||||
#[arg(long)]
|
||||
denomination: Option<String>,
|
||||
},
|
||||
/// Roll up not-yet-reconciled served usage per operator/period (#58),
|
||||
/// stamp it reconciled, and print the totals. Payout is out of scope.
|
||||
Reconcile {
|
||||
#[arg(short, long, default_value = "helexa-upstream.toml")]
|
||||
config: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
@@ -40,6 +62,37 @@ async fn main() -> Result<()> {
|
||||
tracing::info!(listen = %cfg.server.listen, "starting helexa-upstream");
|
||||
helexa_upstream::run(cfg).await?;
|
||||
}
|
||||
Commands::Mint {
|
||||
config,
|
||||
value,
|
||||
count,
|
||||
denomination,
|
||||
} => {
|
||||
let cfg = UpstreamConfig::load(&config)
|
||||
.map_err(|e| anyhow::anyhow!("failed to load config from '{config}': {e}"))?;
|
||||
let pool =
|
||||
helexa_upstream::db::connect_and_migrate(&cfg.db.url, cfg.db.max_connections)
|
||||
.await?;
|
||||
let codes =
|
||||
helexa_upstream::topup::mint(&pool, value, count, denomination.as_deref()).await?;
|
||||
// Raw codes to stdout (one per line) for the operator to distribute;
|
||||
// logs/diagnostics go to stderr via tracing.
|
||||
for code in codes {
|
||||
println!("{code}");
|
||||
}
|
||||
}
|
||||
Commands::Reconcile { config } => {
|
||||
let cfg = UpstreamConfig::load(&config)
|
||||
.map_err(|e| anyhow::anyhow!("failed to load config from '{config}': {e}"))?;
|
||||
let pool =
|
||||
helexa_upstream::db::connect_and_migrate(&cfg.db.url, cfg.db.max_connections)
|
||||
.await?;
|
||||
let rollup = helexa_upstream::reconcile::reconcile(&pool).await?;
|
||||
for r in &rollup {
|
||||
println!("{}\t{}\t{}", r.operator_id, r.period, r.total_served_tokens);
|
||||
}
|
||||
tracing::info!(operators_periods = rollup.len(), "reconciliation complete");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
||||
43
crates/helexa-upstream/src/reconcile.rs
Normal file
@@ -0,0 +1,43 @@
|
||||
//! Reconciliation rollup (#58): aggregate the served-usage ledger per
|
||||
//! operator and period for operator compensation, stamping rows
|
||||
//! `reconciled_at` so each window is settled once. The payout mechanism
|
||||
//! itself is out of scope — this produces the authoritative per-operator
|
||||
//! totals a settlement process consumes.
|
||||
|
||||
use sqlx::Row;
|
||||
use sqlx::postgres::PgPool;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RollupRow {
|
||||
pub operator_id: String,
|
||||
pub period: chrono::NaiveDate,
|
||||
pub total_served_tokens: i64,
|
||||
}
|
||||
|
||||
/// Roll up all not-yet-reconciled served-usage into per-(operator, period)
|
||||
/// totals, then stamp those rows `reconciled_at`. Returns the rollup.
|
||||
/// Idempotent: a second run finds nothing unreconciled and returns empty.
|
||||
pub async fn reconcile(pool: &PgPool) -> Result<Vec<RollupRow>, sqlx::Error> {
|
||||
let mut tx = pool.begin().await?;
|
||||
let rows = sqlx::query(
|
||||
// SUM(bigint) is numeric in Postgres — cast back to bigint for i64.
|
||||
"SELECT operator_id, period, SUM(served_tokens)::bigint AS total \
|
||||
FROM served_usage WHERE reconciled_at IS NULL \
|
||||
GROUP BY operator_id, period ORDER BY operator_id, period",
|
||||
)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?;
|
||||
let rollup: Vec<RollupRow> = rows
|
||||
.iter()
|
||||
.map(|r| RollupRow {
|
||||
operator_id: r.get("operator_id"),
|
||||
period: r.get("period"),
|
||||
total_served_tokens: r.get::<i64, _>("total"),
|
||||
})
|
||||
.collect();
|
||||
sqlx::query("UPDATE served_usage SET reconciled_at = now() WHERE reconciled_at IS NULL")
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
Ok(rollup)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Shared application state.
|
||||
|
||||
use crate::config::UpstreamConfig;
|
||||
use crate::email::EmailSender;
|
||||
use sqlx::postgres::PgPool;
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -8,13 +9,15 @@ use std::sync::Arc;
|
||||
pub struct AppState {
|
||||
pub pool: PgPool,
|
||||
pub config: Arc<UpstreamConfig>,
|
||||
pub email: EmailSender,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub fn new(pool: PgPool, config: UpstreamConfig) -> Self {
|
||||
pub fn new(pool: PgPool, config: UpstreamConfig, email: EmailSender) -> Self {
|
||||
Self {
|
||||
pool,
|
||||
config: Arc::new(config),
|
||||
email,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
594
crates/helexa-upstream/src/web.rs
Normal file
@@ -0,0 +1,594 @@
|
||||
//! `/web/v1` — the human-facing account API the helexa.ai frontend (#F4)
|
||||
//! consumes: email+password auth (register / verify / login / reset),
|
||||
//! API-key CRUD with per-key limits, and the account balance. Web sessions
|
||||
//! are JWTs, **distinct** from inference API keys.
|
||||
//!
|
||||
//! Errors use a plain JSON shape `{ "error": { "message", "code" } }` (web
|
||||
//! clients, not OpenAI clients — the #63 envelope is the authz surface).
|
||||
//!
|
||||
//! Silent fingerprint abuse (no clue to the abuser): registration captures
|
||||
//! the browser fingerprint and always succeeds; when ≥ threshold accounts
|
||||
//! share one fingerprint, all are silently `deactivated` (keys then resolve
|
||||
//! as ordinary `401`s at the authz surface — never a "banned" signal).
|
||||
|
||||
use crate::crypto::{generate_api_key, hash_password, random_token, sha256, verify_password};
|
||||
use crate::state::AppState;
|
||||
use axum::extract::{Path, Request, State};
|
||||
use axum::http::{StatusCode, header};
|
||||
use axum::middleware::Next;
|
||||
use axum::response::{IntoResponse, Json, Response};
|
||||
use axum::routing::{get, post};
|
||||
use axum::{Extension, Router};
|
||||
use chrono::{DateTime, Duration, Utc};
|
||||
use jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation, decode, encode};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use sqlx::Row;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub fn router(state: &AppState) -> Router<AppState> {
|
||||
let protected = Router::new()
|
||||
.route("/web/v1/account", get(account))
|
||||
.route("/web/v1/keys", get(list_keys).post(create_key))
|
||||
.route("/web/v1/keys/{id}/archive", post(archive_key))
|
||||
.route(
|
||||
"/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,
|
||||
));
|
||||
|
||||
Router::new()
|
||||
.route("/web/v1/register", post(register))
|
||||
.route("/web/v1/verify", post(verify))
|
||||
.route("/web/v1/login", post(login))
|
||||
.route("/web/v1/password-reset/request", post(reset_request))
|
||||
.route("/web/v1/password-reset/confirm", post(reset_confirm))
|
||||
.merge(protected)
|
||||
}
|
||||
|
||||
// ── errors ──────────────────────────────────────────────────────────
|
||||
|
||||
enum WebError {
|
||||
BadRequest(&'static str),
|
||||
Unauthorized,
|
||||
Internal,
|
||||
}
|
||||
|
||||
impl IntoResponse for WebError {
|
||||
fn into_response(self) -> Response {
|
||||
let (status, code, message) = match self {
|
||||
WebError::BadRequest(m) => (StatusCode::BAD_REQUEST, "bad_request", m),
|
||||
WebError::Unauthorized => (StatusCode::UNAUTHORIZED, "unauthorized", "unauthorized"),
|
||||
WebError::Internal => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"internal_error",
|
||||
"internal error",
|
||||
),
|
||||
};
|
||||
(
|
||||
status,
|
||||
Json(json!({"error": {"message": message, "code": code}})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<sqlx::Error> for WebError {
|
||||
fn from(e: sqlx::Error) -> Self {
|
||||
tracing::error!(error = %e, "web db error");
|
||||
WebError::Internal
|
||||
}
|
||||
}
|
||||
|
||||
type WebResult<T> = Result<T, WebError>;
|
||||
|
||||
// ── sessions (JWT) ──────────────────────────────────────────────────
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct Claims {
|
||||
sub: String, // user id
|
||||
exp: usize,
|
||||
}
|
||||
|
||||
fn mint_session(state: &AppState, user_id: Uuid) -> WebResult<String> {
|
||||
let exp = (Utc::now() + Duration::seconds(state.config.auth.session_ttl_secs as i64))
|
||||
.timestamp() as usize;
|
||||
let claims = Claims {
|
||||
sub: user_id.to_string(),
|
||||
exp,
|
||||
};
|
||||
encode(
|
||||
&Header::default(),
|
||||
&claims,
|
||||
&EncodingKey::from_secret(state.config.auth.jwt_secret.as_bytes()),
|
||||
)
|
||||
.map_err(|_| WebError::Internal)
|
||||
}
|
||||
|
||||
/// Authenticated user id, injected by [`require_session`].
|
||||
#[derive(Clone)]
|
||||
struct AuthUser(Uuid);
|
||||
|
||||
async fn require_session(State(state): State<AppState>, mut req: Request, next: Next) -> Response {
|
||||
let token = req
|
||||
.headers()
|
||||
.get(header::AUTHORIZATION)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.strip_prefix("Bearer "))
|
||||
.map(str::trim);
|
||||
let Some(token) = token else {
|
||||
return WebError::Unauthorized.into_response();
|
||||
};
|
||||
let decoded = decode::<Claims>(
|
||||
token,
|
||||
&DecodingKey::from_secret(state.config.auth.jwt_secret.as_bytes()),
|
||||
&Validation::default(),
|
||||
);
|
||||
match decoded
|
||||
.ok()
|
||||
.and_then(|d| Uuid::parse_str(&d.claims.sub).ok())
|
||||
{
|
||||
Some(uid) => {
|
||||
req.extensions_mut().insert(AuthUser(uid));
|
||||
next.run(req).await
|
||||
}
|
||||
None => WebError::Unauthorized.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The caller's single account id.
|
||||
async fn account_id_for(state: &AppState, user_id: Uuid) -> WebResult<Uuid> {
|
||||
let row = sqlx::query("SELECT id FROM accounts WHERE owner_user_id = $1")
|
||||
.bind(user_id)
|
||||
.fetch_optional(&state.pool)
|
||||
.await?;
|
||||
row.map(|r| r.get::<Uuid, _>("id"))
|
||||
.ok_or(WebError::Internal)
|
||||
}
|
||||
|
||||
// ── auth lifecycle ──────────────────────────────────────────────────
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct RegisterReq {
|
||||
email: String,
|
||||
password: String,
|
||||
#[serde(default)]
|
||||
fingerprint: Option<String>,
|
||||
}
|
||||
|
||||
/// `POST /web/v1/register` — always returns `202`, regardless of whether the
|
||||
/// email was new, already taken, or fingerprint-flagged (no enumeration, no
|
||||
/// abuse clue).
|
||||
async fn register(State(state): State<AppState>, Json(req): Json<RegisterReq>) -> Response {
|
||||
match register_inner(&state, req).await {
|
||||
Ok(()) | Err(WebError::BadRequest(_)) => {}
|
||||
Err(e) => return e.into_response(),
|
||||
}
|
||||
// Generic 202 whatever happened above (except hard server errors).
|
||||
StatusCode::ACCEPTED.into_response()
|
||||
}
|
||||
|
||||
async fn register_inner(state: &AppState, req: RegisterReq) -> WebResult<()> {
|
||||
if !req.email.contains('@') {
|
||||
return Err(WebError::BadRequest("invalid email"));
|
||||
}
|
||||
if req.password.len() < 8 {
|
||||
return Err(WebError::BadRequest("password too short (min 8)"));
|
||||
}
|
||||
let phc = hash_password(&req.password).map_err(|_| WebError::Internal)?;
|
||||
|
||||
// Insert the user; a duplicate email silently no-ops (no enumeration).
|
||||
let user_id: Option<Uuid> = sqlx::query(
|
||||
"INSERT INTO users (email, password_hash, registration_fingerprint) \
|
||||
VALUES ($1, $2, $3) ON CONFLICT (email) DO NOTHING RETURNING id",
|
||||
)
|
||||
.bind(&req.email)
|
||||
.bind(&phc)
|
||||
.bind(&req.fingerprint)
|
||||
.fetch_optional(&state.pool)
|
||||
.await?
|
||||
.map(|r| r.get("id"));
|
||||
|
||||
let Some(user_id) = user_id else {
|
||||
return Ok(()); // email already registered — say nothing
|
||||
};
|
||||
|
||||
// Account with the flat free grant.
|
||||
sqlx::query("INSERT INTO accounts (owner_user_id, allocation_total) VALUES ($1, $2)")
|
||||
.bind(user_id)
|
||||
.bind(state.config.grant.free_token_grant)
|
||||
.execute(&state.pool)
|
||||
.await?;
|
||||
|
||||
// Silent fingerprint abuse handling.
|
||||
if let Some(fp) = req.fingerprint.as_deref().filter(|f| !f.is_empty()) {
|
||||
apply_fingerprint_policy(state, fp).await?;
|
||||
}
|
||||
|
||||
// Email verification link.
|
||||
let token = random_token();
|
||||
let expires: DateTime<Utc> =
|
||||
Utc::now() + Duration::seconds(state.config.auth.email_token_ttl_secs as i64);
|
||||
sqlx::query(
|
||||
"INSERT INTO email_tokens (token_hash, user_id, kind, expires_at) \
|
||||
VALUES ($1, $2, 'verify', $3)",
|
||||
)
|
||||
.bind(sha256(&token))
|
||||
.bind(user_id)
|
||||
.bind(expires)
|
||||
.execute(&state.pool)
|
||||
.await?;
|
||||
|
||||
let link = format!("{}/verify?token={token}", state.config.auth.app_base_url);
|
||||
let _ = state
|
||||
.email
|
||||
.send(
|
||||
&req.email,
|
||||
"Verify your helexa account",
|
||||
&format!("Welcome to helexa. Verify your email:\n\n{link}\n"),
|
||||
)
|
||||
.await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Count accounts sharing `fp`; flag them, and silently deactivate all once
|
||||
/// the count reaches the configured threshold. No response difference — the
|
||||
/// abuser gets no signal.
|
||||
async fn apply_fingerprint_policy(state: &AppState, fp: &str) -> WebResult<()> {
|
||||
let count: i64 =
|
||||
sqlx::query_scalar("SELECT count(*) FROM users WHERE registration_fingerprint = $1")
|
||||
.bind(fp)
|
||||
.fetch_one(&state.pool)
|
||||
.await?;
|
||||
if count > 1 {
|
||||
sqlx::query(
|
||||
"UPDATE accounts SET fingerprint_flagged = true \
|
||||
WHERE owner_user_id IN (SELECT id FROM users WHERE registration_fingerprint = $1)",
|
||||
)
|
||||
.bind(fp)
|
||||
.execute(&state.pool)
|
||||
.await?;
|
||||
}
|
||||
if count >= state.config.abuse.fingerprint_account_threshold {
|
||||
let res = sqlx::query(
|
||||
"UPDATE accounts SET status = 'deactivated' \
|
||||
WHERE owner_user_id IN (SELECT id FROM users WHERE registration_fingerprint = $1)",
|
||||
)
|
||||
.bind(fp)
|
||||
.execute(&state.pool)
|
||||
.await?;
|
||||
tracing::warn!(
|
||||
fingerprint = fp,
|
||||
accounts = res.rows_affected(),
|
||||
"silently deactivated fingerprint-abusing accounts"
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct TokenReq {
|
||||
token: String,
|
||||
}
|
||||
|
||||
/// `POST /web/v1/verify` — consume a verification token, mark verified.
|
||||
async fn verify(State(state): State<AppState>, Json(req): Json<TokenReq>) -> WebResult<Response> {
|
||||
let row = sqlx::query(
|
||||
"UPDATE email_tokens SET consumed_at = now() \
|
||||
WHERE token_hash = $1 AND kind = 'verify' AND consumed_at IS NULL AND expires_at > now() \
|
||||
RETURNING user_id",
|
||||
)
|
||||
.bind(sha256(&req.token))
|
||||
.fetch_optional(&state.pool)
|
||||
.await?;
|
||||
let Some(row) = row else {
|
||||
return Err(WebError::BadRequest("invalid or expired token"));
|
||||
};
|
||||
let user_id: Uuid = row.get("user_id");
|
||||
sqlx::query("UPDATE users SET email_verified = true WHERE id = $1")
|
||||
.bind(user_id)
|
||||
.execute(&state.pool)
|
||||
.await?;
|
||||
Ok(StatusCode::OK.into_response())
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct LoginReq {
|
||||
email: String,
|
||||
password: String,
|
||||
}
|
||||
|
||||
/// `POST /web/v1/login` — verify password + email-verified → session JWT.
|
||||
async fn login(State(state): State<AppState>, Json(req): Json<LoginReq>) -> WebResult<Response> {
|
||||
let row = sqlx::query("SELECT id, password_hash, email_verified FROM users WHERE email = $1")
|
||||
.bind(&req.email)
|
||||
.fetch_optional(&state.pool)
|
||||
.await?;
|
||||
// Generic 401 for every failure mode (no enumeration).
|
||||
let Some(row) = row else {
|
||||
return Err(WebError::Unauthorized);
|
||||
};
|
||||
let phc: String = row.get("password_hash");
|
||||
let verified: bool = row.get("email_verified");
|
||||
if !verify_password(&req.password, &phc) || !verified {
|
||||
return Err(WebError::Unauthorized);
|
||||
}
|
||||
let user_id: Uuid = row.get("id");
|
||||
let token = mint_session(&state, user_id)?;
|
||||
Ok(Json(json!({
|
||||
"token": token,
|
||||
"expires_in": state.config.auth.session_ttl_secs,
|
||||
}))
|
||||
.into_response())
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct EmailReq {
|
||||
email: String,
|
||||
}
|
||||
|
||||
/// `POST /web/v1/password-reset/request` — always `202` (no enumeration);
|
||||
/// mints + emails a reset token only if the account exists.
|
||||
async fn reset_request(State(state): State<AppState>, Json(req): Json<EmailReq>) -> Response {
|
||||
// The inner only ever yields `Internal` (DB failure); a missing email is
|
||||
// Ok(()) so there's no enumeration. Surface 500 on a real error, else 202.
|
||||
match reset_request_inner(&state, &req.email).await {
|
||||
Ok(()) => StatusCode::ACCEPTED.into_response(),
|
||||
Err(e) => e.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn reset_request_inner(state: &AppState, email: &str) -> WebResult<()> {
|
||||
let row = sqlx::query("SELECT id FROM users WHERE email = $1")
|
||||
.bind(email)
|
||||
.fetch_optional(&state.pool)
|
||||
.await?;
|
||||
let Some(row) = row else { return Ok(()) };
|
||||
let user_id: Uuid = row.get("id");
|
||||
let token = random_token();
|
||||
let expires: DateTime<Utc> =
|
||||
Utc::now() + Duration::seconds(state.config.auth.email_token_ttl_secs as i64);
|
||||
sqlx::query(
|
||||
"INSERT INTO email_tokens (token_hash, user_id, kind, expires_at) \
|
||||
VALUES ($1, $2, 'reset', $3)",
|
||||
)
|
||||
.bind(sha256(&token))
|
||||
.bind(user_id)
|
||||
.bind(expires)
|
||||
.execute(&state.pool)
|
||||
.await?;
|
||||
let link = format!("{}/reset?token={token}", state.config.auth.app_base_url);
|
||||
let _ = state
|
||||
.email
|
||||
.send(
|
||||
email,
|
||||
"Reset your helexa password",
|
||||
&format!("Reset your password:\n\n{link}\n"),
|
||||
)
|
||||
.await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ResetConfirmReq {
|
||||
token: String,
|
||||
new_password: String,
|
||||
}
|
||||
|
||||
/// `POST /web/v1/password-reset/confirm` — consume reset token, rotate hash.
|
||||
async fn reset_confirm(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<ResetConfirmReq>,
|
||||
) -> WebResult<Response> {
|
||||
if req.new_password.len() < 8 {
|
||||
return Err(WebError::BadRequest("password too short (min 8)"));
|
||||
}
|
||||
let row = sqlx::query(
|
||||
"UPDATE email_tokens SET consumed_at = now() \
|
||||
WHERE token_hash = $1 AND kind = 'reset' AND consumed_at IS NULL AND expires_at > now() \
|
||||
RETURNING user_id",
|
||||
)
|
||||
.bind(sha256(&req.token))
|
||||
.fetch_optional(&state.pool)
|
||||
.await?;
|
||||
let Some(row) = row else {
|
||||
return Err(WebError::BadRequest("invalid or expired token"));
|
||||
};
|
||||
let user_id: Uuid = row.get("user_id");
|
||||
let phc = hash_password(&req.new_password).map_err(|_| WebError::Internal)?;
|
||||
sqlx::query("UPDATE users SET password_hash = $1 WHERE id = $2")
|
||||
.bind(phc)
|
||||
.bind(user_id)
|
||||
.execute(&state.pool)
|
||||
.await?;
|
||||
Ok(StatusCode::OK.into_response())
|
||||
}
|
||||
|
||||
// ── account + keys (protected) ──────────────────────────────────────
|
||||
|
||||
async fn account(
|
||||
State(state): State<AppState>,
|
||||
Extension(user): Extension<AuthUser>,
|
||||
) -> WebResult<Response> {
|
||||
let acct = account_id_for(&state, user.0).await?;
|
||||
let row = sqlx::query(
|
||||
"SELECT allocation_total, allocation_spent, allocation_reserved FROM accounts WHERE id = $1",
|
||||
)
|
||||
.bind(acct)
|
||||
.fetch_one(&state.pool)
|
||||
.await?;
|
||||
Ok(Json(json!({
|
||||
"account_id": acct.to_string(),
|
||||
"allocation_total": row.get::<i64, _>("allocation_total"),
|
||||
"allocation_spent": row.get::<i64, _>("allocation_spent"),
|
||||
"allocation_reserved": row.get::<i64, _>("allocation_reserved"),
|
||||
}))
|
||||
.into_response())
|
||||
}
|
||||
|
||||
async fn list_keys(
|
||||
State(state): State<AppState>,
|
||||
Extension(user): Extension<AuthUser>,
|
||||
) -> WebResult<Response> {
|
||||
let acct = account_id_for(&state, user.0).await?;
|
||||
let rows = sqlx::query(
|
||||
"SELECT id, key_prefix, label, status, limit_kind, limit_value, key_spent, key_reserved, \
|
||||
created_at \
|
||||
FROM api_keys WHERE account_id = $1 ORDER BY created_at DESC",
|
||||
)
|
||||
.bind(acct)
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
let keys: Vec<_> = rows
|
||||
.iter()
|
||||
.map(|r| {
|
||||
json!({
|
||||
"id": r.get::<Uuid, _>("id").to_string(),
|
||||
"prefix": r.get::<String, _>("key_prefix"),
|
||||
"label": r.get::<String, _>("label"),
|
||||
"status": r.get::<String, _>("status"),
|
||||
"limit_kind": r.get::<String, _>("limit_kind"),
|
||||
"limit_value": r.get::<i64, _>("limit_value"),
|
||||
"spent": r.get::<i64, _>("key_spent"),
|
||||
"reserved": r.get::<i64, _>("key_reserved"),
|
||||
"created_at": r.get::<DateTime<Utc>, _>("created_at").to_rfc3339(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Ok(Json(json!({ "keys": keys })).into_response())
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct CreateKeyReq {
|
||||
#[serde(default)]
|
||||
label: String,
|
||||
/// "percent" | "hardcap" (default percent=100 → full allocation).
|
||||
#[serde(default)]
|
||||
limit_kind: Option<String>,
|
||||
#[serde(default)]
|
||||
limit_value: Option<i64>,
|
||||
}
|
||||
|
||||
async fn create_key(
|
||||
State(state): State<AppState>,
|
||||
Extension(user): Extension<AuthUser>,
|
||||
Json(req): Json<CreateKeyReq>,
|
||||
) -> WebResult<Response> {
|
||||
let acct = account_id_for(&state, user.0).await?;
|
||||
let limit_kind = match req.limit_kind.as_deref() {
|
||||
Some("hardcap") => "hardcap",
|
||||
_ => "percent",
|
||||
};
|
||||
let limit_value = req.limit_value.unwrap_or(100).max(0);
|
||||
let (raw, prefix) = generate_api_key();
|
||||
let id: Uuid = sqlx::query(
|
||||
"INSERT INTO api_keys (account_id, key_hash, key_prefix, label, limit_kind, limit_value) \
|
||||
VALUES ($1, $2, $3, $4, $5, $6) RETURNING id",
|
||||
)
|
||||
.bind(acct)
|
||||
.bind(sha256(&raw))
|
||||
.bind(&prefix)
|
||||
.bind(&req.label)
|
||||
.bind(limit_kind)
|
||||
.bind(limit_value)
|
||||
.fetch_one(&state.pool)
|
||||
.await?
|
||||
.get("id");
|
||||
// The raw key is shown exactly once.
|
||||
Ok((
|
||||
StatusCode::CREATED,
|
||||
Json(json!({
|
||||
"id": id.to_string(),
|
||||
"key": raw,
|
||||
"prefix": prefix,
|
||||
"limit_kind": limit_kind,
|
||||
"limit_value": limit_value,
|
||||
})),
|
||||
)
|
||||
.into_response())
|
||||
}
|
||||
|
||||
async fn archive_key(
|
||||
State(state): State<AppState>,
|
||||
Extension(user): Extension<AuthUser>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> WebResult<Response> {
|
||||
let acct = account_id_for(&state, user.0).await?;
|
||||
let res = sqlx::query(
|
||||
"UPDATE api_keys SET status = 'archived' WHERE id = $1 AND account_id = $2 AND status = 'active'",
|
||||
)
|
||||
.bind(id)
|
||||
.bind(acct)
|
||||
.execute(&state.pool)
|
||||
.await?;
|
||||
if res.rows_affected() == 0 {
|
||||
return Err(WebError::BadRequest("no such active key"));
|
||||
}
|
||||
Ok(StatusCode::NO_CONTENT.into_response())
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct UpdateLimitReq {
|
||||
limit_kind: String,
|
||||
limit_value: i64,
|
||||
}
|
||||
|
||||
async fn update_key_limit(
|
||||
State(state): State<AppState>,
|
||||
Extension(user): Extension<AuthUser>,
|
||||
Path(id): Path<Uuid>,
|
||||
Json(req): Json<UpdateLimitReq>,
|
||||
) -> WebResult<Response> {
|
||||
if req.limit_kind != "percent" && req.limit_kind != "hardcap" {
|
||||
return Err(WebError::BadRequest(
|
||||
"limit_kind must be percent or hardcap",
|
||||
));
|
||||
}
|
||||
if req.limit_value < 0 {
|
||||
return Err(WebError::BadRequest("limit_value must be >= 0"));
|
||||
}
|
||||
let acct = account_id_for(&state, user.0).await?;
|
||||
let res = sqlx::query(
|
||||
"UPDATE api_keys SET limit_kind = $1, limit_value = $2 WHERE id = $3 AND account_id = $4",
|
||||
)
|
||||
.bind(&req.limit_kind)
|
||||
.bind(req.limit_value)
|
||||
.bind(id)
|
||||
.bind(acct)
|
||||
.execute(&state.pool)
|
||||
.await?;
|
||||
if res.rows_affected() == 0 {
|
||||
return Err(WebError::BadRequest("no such key"));
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -34,13 +34,16 @@ async fn spawn_or_skip(test: &str) -> Option<(String, PgPool)> {
|
||||
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: "op-test".into(),
|
||||
});
|
||||
|
||||
let state = AppState::new(pool.clone(), config);
|
||||
let email = helexa_upstream::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();
|
||||
|
||||
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"
|
||||
);
|
||||
}
|
||||
425
crates/helexa-upstream/tests/web_pg.rs
Normal file
@@ -0,0 +1,425 @@
|
||||
//! Integration tests for the `/web/v1` account API + the silent fingerprint
|
||||
//! abuse policy, driving the built app over HTTP against a real Postgres.
|
||||
//! Gated on `UPSTREAM_TEST_DATABASE_URL` (skips cleanly when unset).
|
||||
|
||||
use helexa_upstream::config::{ClientToken, UpstreamConfig};
|
||||
use helexa_upstream::crypto::sha256;
|
||||
use helexa_upstream::db::connect_and_migrate;
|
||||
use helexa_upstream::email::EmailSender;
|
||||
use helexa_upstream::state::AppState;
|
||||
use serde_json::{Value, json};
|
||||
use sqlx::Executor;
|
||||
use sqlx::Row;
|
||||
use sqlx::postgres::PgPool;
|
||||
|
||||
const CLIENT_TOKEN: &str = "web-test-operator-token";
|
||||
|
||||
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(), // Log transport
|
||||
};
|
||||
config.client_auth.tokens.push(ClientToken {
|
||||
token: CLIENT_TOKEN.into(),
|
||||
operator_id: "op-web".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))
|
||||
}
|
||||
|
||||
fn unique_email() -> String {
|
||||
format!("u-{}@test.local", uuid::Uuid::new_v4())
|
||||
}
|
||||
|
||||
async fn post(url: String, body: Value, bearer: Option<&str>) -> reqwest::Response {
|
||||
let c = reqwest::Client::new();
|
||||
let mut req = c.post(url).json(&body);
|
||||
if let Some(b) = bearer {
|
||||
req = req.bearer_auth(b);
|
||||
}
|
||||
req.send().await.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn verify_endpoint_consumes_token_once() {
|
||||
let Some((base, pool)) = spawn_or_skip("verify_endpoint_consumes_token_once").await else {
|
||||
return;
|
||||
};
|
||||
let email = unique_email();
|
||||
// Register, then mint a verify token directly (the raw token is only in
|
||||
// the email; here we insert a known one to drive the endpoint).
|
||||
assert_eq!(
|
||||
post(
|
||||
format!("{base}/web/v1/register"),
|
||||
json!({"email": email, "password": "password123"}),
|
||||
None
|
||||
)
|
||||
.await
|
||||
.status(),
|
||||
202
|
||||
);
|
||||
let user_id: uuid::Uuid = pool
|
||||
.fetch_one(sqlx::query("SELECT id FROM users WHERE email = $1").bind(&email))
|
||||
.await
|
||||
.unwrap()
|
||||
.get("id");
|
||||
let raw = "verify-raw-token-xyz";
|
||||
pool.execute(
|
||||
sqlx::query(
|
||||
"INSERT INTO email_tokens (token_hash, user_id, kind, expires_at) \
|
||||
VALUES ($1, $2, 'verify', now() + interval '1 hour')",
|
||||
)
|
||||
.bind(sha256(raw))
|
||||
.bind(user_id),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
post(format!("{base}/web/v1/verify"), json!({"token": raw}), None)
|
||||
.await
|
||||
.status(),
|
||||
200
|
||||
);
|
||||
// Consumed → second attempt fails.
|
||||
assert_eq!(
|
||||
post(format!("{base}/web/v1/verify"), json!({"token": raw}), None)
|
||||
.await
|
||||
.status(),
|
||||
400
|
||||
);
|
||||
|
||||
let verified: bool = pool
|
||||
.fetch_one(sqlx::query("SELECT email_verified FROM users WHERE id = $1").bind(user_id))
|
||||
.await
|
||||
.unwrap()
|
||||
.get("email_verified");
|
||||
assert!(verified);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn account_lifecycle_and_key_resolves_then_archives() {
|
||||
let Some((base, pool)) =
|
||||
spawn_or_skip("account_lifecycle_and_key_resolves_then_archives").await
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let email = unique_email();
|
||||
post(
|
||||
format!("{base}/web/v1/register"),
|
||||
json!({"email": email, "password": "password123"}),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
// Bypass the email step for the login/key portion.
|
||||
pool.execute(
|
||||
sqlx::query("UPDATE users SET email_verified = true WHERE email = $1").bind(&email),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// login → session JWT
|
||||
let r = post(
|
||||
format!("{base}/web/v1/login"),
|
||||
json!({"email": email, "password": "password123"}),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(r.status(), 200);
|
||||
let token = r.json::<Value>().await.unwrap()["token"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
|
||||
// create key (raw shown once)
|
||||
let r = post(
|
||||
format!("{base}/web/v1/keys"),
|
||||
json!({"label": "laptop"}),
|
||||
Some(&token),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(r.status(), 201);
|
||||
let body: Value = r.json().await.unwrap();
|
||||
let raw_key = body["key"].as_str().unwrap().to_string();
|
||||
let key_id = body["id"].as_str().unwrap().to_string();
|
||||
assert!(raw_key.starts_with("sk-helexa-"));
|
||||
|
||||
// account balance reflects the free grant
|
||||
let r = reqwest::Client::new()
|
||||
.get(format!("{base}/web/v1/account"))
|
||||
.bearer_auth(&token)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
r.json::<Value>().await.unwrap()["allocation_total"],
|
||||
1_000_000
|
||||
);
|
||||
|
||||
// list keys shows the prefix, never the raw secret
|
||||
let r = reqwest::Client::new()
|
||||
.get(format!("{base}/web/v1/keys"))
|
||||
.bearer_auth(&token)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let listed = r.json::<Value>().await.unwrap();
|
||||
let k = &listed["keys"][0];
|
||||
assert_eq!(k["id"], key_id);
|
||||
assert!(k.get("key").is_none(), "raw secret never listed");
|
||||
assert!(k["prefix"].as_str().unwrap().starts_with("sk-helexa-"));
|
||||
|
||||
// the key authorizes at the authz surface
|
||||
let r = post(
|
||||
format!("{base}/authz/v1/resolve"),
|
||||
json!({"api_key": raw_key}),
|
||||
Some(CLIENT_TOKEN),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(r.status(), 200);
|
||||
|
||||
// archive → the key no longer resolves
|
||||
let r = post(
|
||||
format!("{base}/web/v1/keys/{key_id}/archive"),
|
||||
json!({}),
|
||||
Some(&token),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(r.status(), 204);
|
||||
let r = post(
|
||||
format!("{base}/authz/v1/resolve"),
|
||||
json!({"api_key": raw_key}),
|
||||
Some(CLIENT_TOKEN),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(r.status(), 401);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fingerprint_abuse_silently_deactivates_all_no_clue() {
|
||||
let Some((base, pool)) =
|
||||
spawn_or_skip("fingerprint_abuse_silently_deactivates_all_no_clue").await
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let fp = format!("fp-{}", uuid::Uuid::new_v4());
|
||||
|
||||
// 5 registrations sharing one fingerprint — every one returns a normal 202.
|
||||
let mut emails = Vec::new();
|
||||
for _ in 0..5 {
|
||||
let email = unique_email();
|
||||
let r = post(
|
||||
format!("{base}/web/v1/register"),
|
||||
json!({"email": email, "password": "password123", "fingerprint": fp}),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(r.status(), 202, "registration always looks successful");
|
||||
emails.push(email);
|
||||
}
|
||||
|
||||
// Silent effect: all 5 accounts are deactivated + flagged.
|
||||
let (deactivated, flagged): (i64, i64) = {
|
||||
let row = pool
|
||||
.fetch_one(
|
||||
sqlx::query(
|
||||
"SELECT \
|
||||
count(*) FILTER (WHERE a.status = 'deactivated') AS d, \
|
||||
count(*) FILTER (WHERE a.fingerprint_flagged) AS f \
|
||||
FROM accounts a JOIN users u ON u.id = a.owner_user_id \
|
||||
WHERE u.registration_fingerprint = $1",
|
||||
)
|
||||
.bind(&fp),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
(row.get("d"), row.get("f"))
|
||||
};
|
||||
assert_eq!(deactivated, 5, "all sharing accounts silently deactivated");
|
||||
assert_eq!(flagged, 5);
|
||||
|
||||
// No clue at the authz surface: a key on a deactivated account resolves
|
||||
// as an ordinary 401, indistinguishable from an unknown key.
|
||||
let acct: uuid::Uuid = pool
|
||||
.fetch_one(
|
||||
sqlx::query(
|
||||
"SELECT a.id FROM accounts a JOIN users u ON u.id = a.owner_user_id \
|
||||
WHERE u.registration_fingerprint = $1 LIMIT 1",
|
||||
)
|
||||
.bind(&fp),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.get("id");
|
||||
let raw = "sk-helexa-deactivated-probe";
|
||||
pool.execute(
|
||||
sqlx::query(
|
||||
"INSERT INTO api_keys (account_id, key_hash, key_prefix) VALUES ($1, $2, 'sk-helexa-')",
|
||||
)
|
||||
.bind(acct)
|
||||
.bind(sha256(raw)),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let r = post(
|
||||
format!("{base}/authz/v1/resolve"),
|
||||
json!({"api_key": raw}),
|
||||
Some(CLIENT_TOKEN),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
r.status(),
|
||||
401,
|
||||
"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");
|
||||
}
|
||||
@@ -16,7 +16,7 @@ use cortex_core::discovery::{DiscoveryResponse, HealthResponse};
|
||||
use cortex_core::entitlements::{HEADER_ACCOUNT_ID, HEADER_KEY_ID};
|
||||
use cortex_core::harness::ModelSpec;
|
||||
use cortex_core::openai::{ChatCompletionRequest, MessageContent};
|
||||
use cortex_core::responses::{ResponsesRequest, ResponsesUsage};
|
||||
use cortex_core::responses::{OutputTokensDetails, ResponsesRequest, ResponsesUsage};
|
||||
use futures::stream::{self, StreamExt};
|
||||
use serde_json::{Value, json};
|
||||
use std::convert::Infallible;
|
||||
@@ -418,8 +418,14 @@ async fn responses(
|
||||
input_tokens: u.prompt_tokens,
|
||||
output_tokens: u.completion_tokens,
|
||||
total_tokens: u.prompt_tokens + u.completion_tokens,
|
||||
// Non-streaming reasoning accounting deferred (#64).
|
||||
output_tokens_details: None,
|
||||
// Carry the reasoning sub-count through from the chat
|
||||
// usage — the non-streaming path now splits off the
|
||||
// `<think>` span and counts it (see `split_off_reasoning`).
|
||||
output_tokens_details: u.completion_tokens_details.as_ref().map(|d| {
|
||||
OutputTokensDetails {
|
||||
reasoning_tokens: d.reasoning_tokens,
|
||||
}
|
||||
}),
|
||||
input_tokens_details: None,
|
||||
});
|
||||
let meta = openai_responses::ResponseMeta {
|
||||
|
||||
@@ -23,11 +23,11 @@ use candle_transformers::models::qwen3_moe as qwen3_moe_dense;
|
||||
use cortex_core::harness::{Harness, HarnessHealth, ModelInfo, ModelSpec};
|
||||
use cortex_core::openai::{
|
||||
ChatCompletionChoice, ChatCompletionChunk, ChatCompletionRequest, ChatCompletionResponse,
|
||||
ChatMessage, MessageContent, Usage,
|
||||
ChatMessage, CompletionTokensDetails, MessageContent, Usage,
|
||||
};
|
||||
|
||||
use crate::wire::{
|
||||
FinishReason, InferenceEvent, ReasoningTokenPair, ToolCallTokenPair,
|
||||
FinishReason, FinishTiming, InferenceEvent, ReasoningTokenPair, ToolCallTokenPair,
|
||||
detect_reasoning_token_pair, detect_tool_call_token_pair, openai_chat as wire_chat,
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
@@ -784,6 +784,39 @@ impl ModelArch {
|
||||
}
|
||||
}
|
||||
|
||||
/// Split a non-streaming completion's generated tokens into the
|
||||
/// visible answer and the leading reasoning span.
|
||||
///
|
||||
/// Reasoning models (Qwen3 `<think>`, DeepSeek-R1, …) emit their
|
||||
/// chain-of-thought *before* the answer, and the chat template injects
|
||||
/// the **opening** marker into the prompt — so the generated tokens look
|
||||
/// like `…reasoning… </think> …answer…` with no opening marker present
|
||||
/// in the output. The streaming path drops reasoning as
|
||||
/// [`InferenceEvent::ReasoningDelta`]; the non-streaming path has to do
|
||||
/// the equivalent post-hoc or the chain-of-thought leaks into the
|
||||
/// assistant `content` (which broke agent-zero v2.0, whose parser
|
||||
/// expected the bare JSON answer, not a `<think>` preamble).
|
||||
///
|
||||
/// Returns `(content_ids, reasoning_token_count)`. Strategy: if the model
|
||||
/// declares a reasoning marker pair and its **close** token appears in
|
||||
/// `generated_ids`, everything up to and including the last close token is
|
||||
/// reasoning and only the tail is the answer. Otherwise (non-reasoning
|
||||
/// model, thinking disabled, or a generation truncated mid-reasoning) the
|
||||
/// tokens are returned unchanged. Splitting on the token id — not a
|
||||
/// decoded `</think>` string — keeps this robust against tokenizer
|
||||
/// byte-fallback and special-token handling.
|
||||
fn split_off_reasoning<'a>(
|
||||
generated_ids: &'a [u32],
|
||||
reasoning: Option<&ReasoningTokenPair>,
|
||||
) -> (&'a [u32], u64) {
|
||||
if let Some(pair) = reasoning
|
||||
&& let Some(idx) = generated_ids.iter().rposition(|&t| t == pair.close_id)
|
||||
{
|
||||
return (&generated_ids[idx + 1..], (idx + 1) as u64);
|
||||
}
|
||||
(generated_ids, 0)
|
||||
}
|
||||
|
||||
/// Squeeze any leading singleton dims off the logits tensor so the
|
||||
/// caller gets a rank-1 `[vocab_size]` slice ready for sampling. Bails
|
||||
/// on a non-singleton leading dim (would mean a batched forward, which
|
||||
@@ -2454,21 +2487,36 @@ impl CandleHarness {
|
||||
)));
|
||||
};
|
||||
|
||||
// Strip the leading `<think>` span so the chain-of-thought
|
||||
// doesn't leak into `content` (the streaming path drops it
|
||||
// as ReasoningDelta; this is the non-streaming equivalent).
|
||||
let (content_ids, reasoning_tokens) =
|
||||
split_off_reasoning(&generated_ids, loaded.reasoning_tokens.as_ref());
|
||||
let completion_text = loaded
|
||||
.tokenizer
|
||||
.decode(&generated_ids, true)
|
||||
.decode(content_ids, true)
|
||||
.map_err(|e| InferenceError::Other(anyhow::anyhow!("detokenize: {e}")))?;
|
||||
// The first answer token after `</think>` is usually a
|
||||
// newline pair; trim it so `content` starts at the answer.
|
||||
let completion_text = if reasoning_tokens > 0 {
|
||||
completion_text.trim_start().to_string()
|
||||
} else {
|
||||
completion_text
|
||||
};
|
||||
|
||||
let usage = Usage {
|
||||
prompt_tokens: prompt_len as u64,
|
||||
completion_tokens: generated_ids.len() as u64,
|
||||
total_tokens: (prompt_len + generated_ids.len()) as u64,
|
||||
// Reasoning accounting is streaming-only: the
|
||||
// non-streaming path doesn't track `in_reasoning`
|
||||
// (would require post-hoc <think> span parsing).
|
||||
// Deferred — see #64.
|
||||
completion_tokens_details: None,
|
||||
// `reasoning_tokens` is an additive sub-count of
|
||||
// `completion_tokens` (which still counts every
|
||||
// generated token, reasoning included).
|
||||
completion_tokens_details: (reasoning_tokens > 0)
|
||||
.then_some(CompletionTokensDetails { reasoning_tokens }),
|
||||
prompt_tokens_details: None,
|
||||
// Non-streaming path: prefill/decode split is only
|
||||
// surfaced on the streaming Finish event today (#85).
|
||||
helexa_timing: None,
|
||||
};
|
||||
|
||||
tracing::info!(
|
||||
@@ -3957,6 +4005,11 @@ impl CandleHarness {
|
||||
// call — promotes the terminal finish_reason to ToolCalls
|
||||
// so Anthropic clients see stop_reason: tool_use.
|
||||
let mut emitted_tool_call = false;
|
||||
// Prefill/decode split timers (#85). Declared outside 'work
|
||||
// so the terminal Finish — built after the block exits — can
|
||||
// read them; populated at the prefill→decode boundary inside.
|
||||
let mut prefill_ms_measured: u32 = 0;
|
||||
let mut decode_start: Option<std::time::Instant> = None;
|
||||
|
||||
'work: {
|
||||
// Prefix-cache decision (#11): vision requests
|
||||
@@ -4084,14 +4137,16 @@ impl CandleHarness {
|
||||
break 'work;
|
||||
}
|
||||
};
|
||||
let prefill_elapsed = prefill_start.elapsed();
|
||||
prefill_ms_measured = prefill_elapsed.as_millis() as u32;
|
||||
tp_for_task
|
||||
.prefill_rate
|
||||
.record(prompt_len, prefill_start.elapsed());
|
||||
.record(prompt_len, prefill_elapsed);
|
||||
let (post_prefill_vram_free_mb, _) = tp_for_task.query_vram().await;
|
||||
tracing::info!(
|
||||
model = %model_id,
|
||||
prompt_len,
|
||||
prefill_ms = prefill_start.elapsed().as_millis(),
|
||||
prefill_ms = prefill_elapsed.as_millis(),
|
||||
vram_free_mb = post_prefill_vram_free_mb,
|
||||
"TP chat_completion (stream): prefill complete"
|
||||
);
|
||||
@@ -4116,6 +4171,8 @@ impl CandleHarness {
|
||||
break 'work;
|
||||
}
|
||||
};
|
||||
// Decode-phase timer for the Finish prefill/decode split (#85).
|
||||
decode_start = Some(std::time::Instant::now());
|
||||
|
||||
if Some(next_token) == eos_id {
|
||||
finish_reason = FinishReason::Stop;
|
||||
@@ -4393,6 +4450,13 @@ impl CandleHarness {
|
||||
prompt_tokens: prompt_len as u32,
|
||||
completion_tokens: all_tokens.len() as u32,
|
||||
reasoning_tokens: reasoning_token_count,
|
||||
timing: Some(FinishTiming {
|
||||
prefill_ms: prefill_ms_measured,
|
||||
decode_ms: decode_start
|
||||
.map(|d| d.elapsed().as_millis() as u32)
|
||||
.unwrap_or(0),
|
||||
prefill_tokens: prompt_len as u32,
|
||||
}),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
@@ -4722,19 +4786,31 @@ async fn chat_completion_tp_inner(
|
||||
}
|
||||
drop(pool);
|
||||
|
||||
// Strip the leading `<think>` span (see `split_off_reasoning` and the
|
||||
// single-GPU path) so the chain-of-thought doesn't leak into `content`.
|
||||
let (content_ids, reasoning_tokens) =
|
||||
split_off_reasoning(&generated, tp.reasoning_tokens.as_ref());
|
||||
let completion_text = tp
|
||||
.tokenizer
|
||||
.decode(&generated, true)
|
||||
.decode(content_ids, true)
|
||||
.map_err(|e| InferenceError::Other(anyhow::anyhow!("detokenize: {e}")))?;
|
||||
let completion_text = if reasoning_tokens > 0 {
|
||||
completion_text.trim_start().to_string()
|
||||
} else {
|
||||
completion_text
|
||||
};
|
||||
|
||||
let usage = Usage {
|
||||
prompt_tokens: prompt_len as u64,
|
||||
completion_tokens: generated.len() as u64,
|
||||
total_tokens: (prompt_len + generated.len()) as u64,
|
||||
// Reasoning accounting is streaming-only (non-streaming TP path
|
||||
// doesn't track `in_reasoning`). Deferred — see #64.
|
||||
completion_tokens_details: None,
|
||||
// `reasoning_tokens` is an additive sub-count of `completion_tokens`.
|
||||
completion_tokens_details: (reasoning_tokens > 0)
|
||||
.then_some(CompletionTokensDetails { reasoning_tokens }),
|
||||
prompt_tokens_details: None,
|
||||
// Non-streaming path: prefill/decode split is only surfaced on
|
||||
// the streaming Finish event today (#85).
|
||||
helexa_timing: None,
|
||||
};
|
||||
|
||||
tracing::info!(
|
||||
@@ -6064,7 +6140,8 @@ async fn stream_inference_via_worker(
|
||||
}
|
||||
}
|
||||
};
|
||||
prefill_rate.record(prefill_prompt_len, prefill_start.elapsed());
|
||||
let prefill_elapsed = prefill_start.elapsed();
|
||||
prefill_rate.record(prefill_prompt_len, prefill_elapsed);
|
||||
let logits = Tensor::new(logits_vec.as_slice(), &Device::Cpu)?;
|
||||
let mut next_token = match sample_with_penalty(&logits, &all_tokens, &mut logits_processor) {
|
||||
Ok(t) => t,
|
||||
@@ -6077,6 +6154,8 @@ async fn stream_inference_via_worker(
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
// Decode-phase timer for the Finish prefill/decode split (#85).
|
||||
let decode_start = std::time::Instant::now();
|
||||
|
||||
// Per-token routing. `tokenizers::DecodeStream` carries five
|
||||
// generic parameters (`M, N, PT, PP, D`) which makes naming
|
||||
@@ -6221,6 +6300,11 @@ async fn stream_inference_via_worker(
|
||||
prompt_tokens: prompt_tokens.len() as u32,
|
||||
completion_tokens: all_tokens.len() as u32,
|
||||
reasoning_tokens: reasoning_token_count,
|
||||
timing: Some(FinishTiming {
|
||||
prefill_ms: prefill_elapsed.as_millis() as u32,
|
||||
decode_ms: decode_start.elapsed().as_millis() as u32,
|
||||
prefill_tokens: prefill_prompt_len as u32,
|
||||
}),
|
||||
})
|
||||
.await;
|
||||
|
||||
@@ -6355,6 +6439,10 @@ fn run_inference_streaming(
|
||||
// See `inference_tp_stream`: promotes finish_reason to ToolCalls.
|
||||
let mut emitted_tool_call = false;
|
||||
|
||||
// Time prefill and decode separately so the Finish event can carry
|
||||
// a server-measured prefill/decode split (#85) instead of leaving
|
||||
// the client to infer both from SSE chunk arrival.
|
||||
let prefill_start = std::time::Instant::now();
|
||||
let reused = restore_or_clear_local(arch, prefix_cache, prompt_tokens)?;
|
||||
// Two-stage prefill around the retokenization-stable snapshot
|
||||
// boundary — see `run_inference_via_worker`.
|
||||
@@ -6373,6 +6461,8 @@ fn run_inference_streaming(
|
||||
None => chunked_prefill_local(arch, device, prompt_tokens, reused)?,
|
||||
};
|
||||
let mut next_token = sample_with_penalty(&logits, &all_tokens, &mut logits_processor)?;
|
||||
let prefill_elapsed = prefill_start.elapsed();
|
||||
let decode_start = std::time::Instant::now();
|
||||
|
||||
// Per-token routing block, used at both the prefill-sample
|
||||
// tail and the decode loop. Macros are ugly but Rust's
|
||||
@@ -6481,6 +6571,11 @@ fn run_inference_streaming(
|
||||
prompt_tokens: prompt_tokens.len() as u32,
|
||||
completion_tokens: all_tokens.len() as u32,
|
||||
reasoning_tokens: reasoning_token_count,
|
||||
timing: Some(FinishTiming {
|
||||
prefill_ms: prefill_elapsed.as_millis() as u32,
|
||||
decode_ms: decode_start.elapsed().as_millis() as u32,
|
||||
prefill_tokens: prompt_tokens.len() as u32,
|
||||
}),
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
@@ -6505,6 +6600,60 @@ mod tests {
|
||||
|
||||
const IM_START: u32 = 999;
|
||||
|
||||
fn think_pair() -> ReasoningTokenPair {
|
||||
ReasoningTokenPair {
|
||||
open_id: 100,
|
||||
close_id: 200,
|
||||
open_text: "<think>".into(),
|
||||
close_text: "</think>".into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_off_reasoning_strips_up_to_close_marker() {
|
||||
// [reasoning_a, reasoning_b, </think>, answer_x, answer_y]
|
||||
let ids = [10, 11, 200, 42, 43];
|
||||
let (content, reasoning) = split_off_reasoning(&ids, Some(&think_pair()));
|
||||
assert_eq!(content, &[42, 43]);
|
||||
assert_eq!(reasoning, 3); // two reasoning tokens + the close marker
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_off_reasoning_no_close_marker_returns_all() {
|
||||
// Thinking disabled / model never closed the span: return as-is.
|
||||
let ids = [42, 43, 44];
|
||||
let (content, reasoning) = split_off_reasoning(&ids, Some(&think_pair()));
|
||||
assert_eq!(content, &ids);
|
||||
assert_eq!(reasoning, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_off_reasoning_no_marker_pair_is_noop() {
|
||||
let ids = [1, 2, 3];
|
||||
let (content, reasoning) = split_off_reasoning(&ids, None);
|
||||
assert_eq!(content, &ids);
|
||||
assert_eq!(reasoning, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_off_reasoning_close_at_end_yields_empty_content() {
|
||||
// All reasoning, answer truncated to nothing after the marker.
|
||||
let ids = [10, 11, 200];
|
||||
let (content, reasoning) = split_off_reasoning(&ids, Some(&think_pair()));
|
||||
assert!(content.is_empty());
|
||||
assert_eq!(reasoning, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_off_reasoning_splits_on_last_close_marker() {
|
||||
// Defensive: if the model emits its own <think></think> pair plus
|
||||
// the prompt-injected one, split on the LAST close marker.
|
||||
let ids = [200, 10, 200, 42];
|
||||
let (content, reasoning) = split_off_reasoning(&ids, Some(&think_pair()));
|
||||
assert_eq!(content, &[42]);
|
||||
assert_eq!(reasoning, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stable_snapshot_cut_lands_after_last_im_start() {
|
||||
// ChatML shape: [im_start, "system", ..., im_start, "user",
|
||||
|
||||
@@ -84,9 +84,38 @@ pub enum InferenceEvent {
|
||||
/// `output_tokens_details.reasoning_tokens` (responses).
|
||||
/// Zero for non-reasoning models.
|
||||
reasoning_tokens: u32,
|
||||
/// Server-measured prefill/decode timing for the request, or
|
||||
/// `None` on paths that don't measure it (CPU fallback that
|
||||
/// doesn't instrument, tests). Streaming projectors surface
|
||||
/// this as a `helexa_timing` extension on the OpenAI `usage`
|
||||
/// object so the bench harness can compute true prefill vs
|
||||
/// decode tok/s instead of inferring both from client-side
|
||||
/// SSE arrival (#85).
|
||||
timing: Option<FinishTiming>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Server-measured timing for one completed inference, attached to
|
||||
/// [`InferenceEvent::Finish`]. The whole point is to separate the two
|
||||
/// phases the client cannot tell apart from chunk-arrival timing:
|
||||
/// prefill (tokenize + prompt forward pass, ending at the first
|
||||
/// sampled token) and decode (every subsequent token through EOS /
|
||||
/// `max_tokens`).
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct FinishTiming {
|
||||
/// Wall-clock of the prefill phase in milliseconds: from the start
|
||||
/// of the prompt forward pass(es) to the first sampled token.
|
||||
pub prefill_ms: u32,
|
||||
/// Wall-clock of the decode phase in milliseconds: from the first
|
||||
/// sampled token to stream end.
|
||||
pub decode_ms: u32,
|
||||
/// Prompt tokens submitted to the prefill forward pass — the
|
||||
/// denominator for prefill tok/s. With prefix-KV-cache hits (#11)
|
||||
/// the elapsed `prefill_ms` drops while this stays the full prompt
|
||||
/// length, so a high implied rate is itself the cache-hit signal.
|
||||
pub prefill_tokens: u32,
|
||||
}
|
||||
|
||||
/// Why a stream stopped. Stays small on purpose — anything that
|
||||
/// doesn't map cleanly to one of these collapses to [`Stop`].
|
||||
///
|
||||
|
||||
@@ -22,6 +22,6 @@ pub mod openai_chat;
|
||||
pub mod openai_responses;
|
||||
|
||||
pub use event::{
|
||||
FinishReason, InferenceEvent, ReasoningTokenPair, ToolCallTokenPair,
|
||||
FinishReason, FinishTiming, InferenceEvent, ReasoningTokenPair, ToolCallTokenPair,
|
||||
detect_reasoning_token_pair, detect_tool_call_token_pair,
|
||||
};
|
||||
|
||||
@@ -26,11 +26,13 @@
|
||||
//! producer blocks on its own send. The bounded channels
|
||||
//! propagate without us writing any logic.
|
||||
|
||||
use cortex_core::openai::{ChatCompletionChunk, ChunkChoice, CompletionTokensDetails, Usage};
|
||||
use cortex_core::openai::{
|
||||
ChatCompletionChunk, ChunkChoice, CompletionTokensDetails, HelexaTiming, Usage,
|
||||
};
|
||||
use serde_json::json;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
use super::event::{FinishReason, InferenceEvent, ReasoningTokenPair};
|
||||
use super::event::{FinishReason, FinishTiming, InferenceEvent, ReasoningTokenPair};
|
||||
|
||||
/// Output channel buffer size. Mirrors the input side's bound; one
|
||||
/// event maps to at most one chunk, so equal capacity keeps the
|
||||
@@ -193,12 +195,14 @@ pub fn project_chat_stream_with(
|
||||
prompt_tokens,
|
||||
completion_tokens,
|
||||
reasoning_tokens,
|
||||
timing,
|
||||
} => {
|
||||
// The finish_reason chunk, then an OpenAI-style
|
||||
// usage-only chunk (`choices: []`, `usage` populated).
|
||||
// Clients (opencode) read this to track context size;
|
||||
// cortex's Anthropic translator also picks `usage` up
|
||||
// for its `message_delta`.
|
||||
// for its `message_delta`. `timing` rides along as the
|
||||
// `helexa_timing` usage extension for the bench harness (#85).
|
||||
vec![
|
||||
final_chunk(&id, created, &model_id, reason),
|
||||
usage_chunk(
|
||||
@@ -208,6 +212,7 @@ pub fn project_chat_stream_with(
|
||||
prompt_tokens,
|
||||
completion_tokens,
|
||||
reasoning_tokens,
|
||||
timing,
|
||||
),
|
||||
]
|
||||
}
|
||||
@@ -334,6 +339,7 @@ fn usage_chunk(
|
||||
prompt_tokens: u32,
|
||||
completion_tokens: u32,
|
||||
reasoning_tokens: u32,
|
||||
timing: Option<FinishTiming>,
|
||||
) -> ChatCompletionChunk {
|
||||
ChatCompletionChunk {
|
||||
id: id.into(),
|
||||
@@ -351,6 +357,14 @@ fn usage_chunk(
|
||||
reasoning_tokens: reasoning_tokens as u64,
|
||||
}),
|
||||
prompt_tokens_details: None,
|
||||
// helexa extension (#85): server-measured prefill/decode
|
||||
// timing for the bench harness. Omitted on paths that don't
|
||||
// measure it so standard clients see unchanged JSON.
|
||||
helexa_timing: timing.map(|t| HelexaTiming {
|
||||
prefill_ms: t.prefill_ms as u64,
|
||||
decode_ms: t.decode_ms as u64,
|
||||
prefill_tokens: t.prefill_tokens as u64,
|
||||
}),
|
||||
}),
|
||||
extra: serde_json::Value::Object(Default::default()),
|
||||
}
|
||||
@@ -391,6 +405,7 @@ mod tests {
|
||||
prompt_tokens: 0,
|
||||
completion_tokens: 0,
|
||||
reasoning_tokens: 0,
|
||||
timing: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -413,6 +428,45 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn finish_timing_surfaces_on_usage_chunk() {
|
||||
// O1 (#85) wire contract: a Finish carrying FinishTiming must
|
||||
// surface as `usage.helexa_timing` on the trailing usage chunk,
|
||||
// which is what the bench harness reads to compute true prefill
|
||||
// vs decode tok/s. Absent timing must leave it None.
|
||||
let (tx, rx) = mpsc::channel::<InferenceEvent>(4);
|
||||
let out_rx = project_chat_stream(rx, "id-1".into(), 1700, "m".into());
|
||||
|
||||
tx.send(InferenceEvent::Start).await.unwrap();
|
||||
tx.send(InferenceEvent::Finish {
|
||||
reason: FinishReason::Stop,
|
||||
prompt_tokens: 128,
|
||||
completion_tokens: 64,
|
||||
reasoning_tokens: 0,
|
||||
timing: Some(FinishTiming {
|
||||
prefill_ms: 200,
|
||||
decode_ms: 1500,
|
||||
prefill_tokens: 128,
|
||||
}),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
drop(tx);
|
||||
|
||||
let out = collect(out_rx).await;
|
||||
let usage = out
|
||||
.iter()
|
||||
.find_map(|c| c.usage.as_ref())
|
||||
.expect("usage chunk present");
|
||||
let timing = usage
|
||||
.helexa_timing
|
||||
.as_ref()
|
||||
.expect("helexa_timing populated when Finish carried timing");
|
||||
assert_eq!(timing.prefill_ms, 200);
|
||||
assert_eq!(timing.decode_ms, 1500);
|
||||
assert_eq!(timing.prefill_tokens, 128);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_text_delta_is_dropped() {
|
||||
let (tx, rx) = mpsc::channel::<InferenceEvent>(4);
|
||||
@@ -434,6 +488,7 @@ mod tests {
|
||||
prompt_tokens: 0,
|
||||
completion_tokens: 0,
|
||||
reasoning_tokens: 0,
|
||||
timing: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -496,6 +551,7 @@ mod tests {
|
||||
prompt_tokens: 0,
|
||||
completion_tokens: 0,
|
||||
reasoning_tokens: 0,
|
||||
timing: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -547,6 +603,7 @@ mod tests {
|
||||
prompt_tokens: 0,
|
||||
completion_tokens: 0,
|
||||
reasoning_tokens: 0,
|
||||
timing: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -592,6 +649,7 @@ mod tests {
|
||||
prompt_tokens: 0,
|
||||
completion_tokens: 0,
|
||||
reasoning_tokens: 0,
|
||||
timing: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -635,6 +693,7 @@ mod tests {
|
||||
prompt_tokens: 0,
|
||||
completion_tokens: 0,
|
||||
reasoning_tokens: 0,
|
||||
timing: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -662,6 +721,7 @@ mod tests {
|
||||
prompt_tokens: 42,
|
||||
completion_tokens: 5,
|
||||
reasoning_tokens: 2,
|
||||
timing: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -695,6 +755,7 @@ mod tests {
|
||||
prompt_tokens: 10,
|
||||
completion_tokens: 7,
|
||||
reasoning_tokens: 0,
|
||||
timing: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -29,9 +29,9 @@
|
||||
|
||||
use cortex_core::openai::{ChatCompletionRequest, ChatMessage, MessageContent};
|
||||
use cortex_core::responses::{
|
||||
OutputTokensDetails, ResponsesContentPart, ResponsesInput, ResponsesInputItem,
|
||||
ResponsesMessageContent, ResponsesOutputContent, ResponsesOutputItem, ResponsesRequest,
|
||||
ResponsesResponse, ResponsesUsage, events,
|
||||
OutputTokensDetails, ResponsesContentPart, ResponsesInput, ResponsesInputElement,
|
||||
ResponsesInputItem, ResponsesMessageContent, ResponsesOutputContent, ResponsesOutputItem,
|
||||
ResponsesRequest, ResponsesResponse, ResponsesUsage, events,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use tokio::sync::mpsc;
|
||||
@@ -109,8 +109,26 @@ pub fn request_to_chat(req: ResponsesRequest) -> Result<ChatCompletionRequest, T
|
||||
});
|
||||
}
|
||||
ResponsesInput::Items(items) => {
|
||||
for item in items {
|
||||
if let Some(msg) = input_item_to_chat(item) {
|
||||
for element in items {
|
||||
let msg = match element {
|
||||
ResponsesInputElement::Typed(item) => input_item_to_chat(item),
|
||||
// Bare `{role, content}` (OpenAI EasyInputMessage —
|
||||
// what litellm/agent-zero emit). `content: null`
|
||||
// (e.g. an assistant turn carrying only tool calls)
|
||||
// collapses to an empty string so the turn is kept.
|
||||
ResponsesInputElement::EasyMessage { role, content } => Some(ChatMessage {
|
||||
role,
|
||||
content: content
|
||||
.map(message_content_to_chat)
|
||||
.unwrap_or_else(|| MessageContent::Text(String::new())),
|
||||
extra: Value::Object(Default::default()),
|
||||
}),
|
||||
// Forward-compat: an item shape we don't model.
|
||||
// Dropped rather than rejected (see
|
||||
// `ResponsesInputElement::Other`).
|
||||
ResponsesInputElement::Other(_) => None,
|
||||
};
|
||||
if let Some(msg) = msg {
|
||||
messages.push(msg);
|
||||
}
|
||||
}
|
||||
@@ -159,11 +177,18 @@ fn input_item_to_chat(item: ResponsesInputItem) -> Option<ChatMessage> {
|
||||
})
|
||||
}
|
||||
ResponsesInputItem::FunctionCallOutput { call_id, output } => {
|
||||
// `output` is either a plain string or an array of content
|
||||
// parts. Render a string as-is; anything else to compact
|
||||
// JSON so the tool result text reaches the model intact.
|
||||
let output_text = match output {
|
||||
Value::String(s) => s,
|
||||
other => other.to_string(),
|
||||
};
|
||||
let mut extra = serde_json::Map::new();
|
||||
extra.insert("tool_call_id".into(), Value::String(call_id));
|
||||
Some(ChatMessage {
|
||||
role: "tool".into(),
|
||||
content: MessageContent::Text(output),
|
||||
content: MessageContent::Text(output_text),
|
||||
extra: Value::Object(extra),
|
||||
})
|
||||
}
|
||||
@@ -192,7 +217,9 @@ fn message_content_to_chat(content: ResponsesMessageContent) -> MessageContent {
|
||||
.filter_map(|p| match p {
|
||||
ResponsesContentPart::InputText { text }
|
||||
| ResponsesContentPart::OutputText { text, .. } => Some(text),
|
||||
ResponsesContentPart::InputImage { .. } => None,
|
||||
ResponsesContentPart::InputImage { .. } | ResponsesContentPart::Unknown => {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n\n");
|
||||
@@ -211,6 +238,7 @@ fn message_content_to_chat(content: ResponsesMessageContent) -> MessageContent {
|
||||
"image_url": { "url": image_url },
|
||||
}));
|
||||
}
|
||||
ResponsesContentPart::Unknown => {}
|
||||
}
|
||||
}
|
||||
MessageContent::Parts(out)
|
||||
@@ -309,6 +337,9 @@ async fn run_projection(
|
||||
prompt_tokens,
|
||||
completion_tokens,
|
||||
reasoning_tokens,
|
||||
// Responses-side `helexa_timing` surfacing not wired yet;
|
||||
// the bench harness reads timing off the chat path (#85).
|
||||
timing: _,
|
||||
} => {
|
||||
finish = Some(reason);
|
||||
// Surface usage on the streaming `response.completed`
|
||||
@@ -535,6 +566,18 @@ mod tests {
|
||||
use super::*;
|
||||
use cortex_core::openai::MessageContent;
|
||||
|
||||
/// Wrap typed items as `input` elements. Most translator tests
|
||||
/// exercise the typed path; the bare easy-message and unknown-item
|
||||
/// paths have dedicated tests below.
|
||||
fn typed_items(items: Vec<ResponsesInputItem>) -> ResponsesInput {
|
||||
ResponsesInput::Items(
|
||||
items
|
||||
.into_iter()
|
||||
.map(ResponsesInputElement::Typed)
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
fn meta() -> ResponseMeta {
|
||||
ResponseMeta {
|
||||
response_id: "resp_1".into(),
|
||||
@@ -614,7 +657,7 @@ mod tests {
|
||||
fn translates_input_items_to_chat_messages() {
|
||||
let req = ResponsesRequest {
|
||||
model: "m".into(),
|
||||
input: ResponsesInput::Items(vec![
|
||||
input: typed_items(vec![
|
||||
ResponsesInputItem::Message {
|
||||
role: "user".into(),
|
||||
content: ResponsesMessageContent::Text("first".into()),
|
||||
@@ -646,7 +689,7 @@ mod tests {
|
||||
fn image_input_translates_to_chat_parts_array() {
|
||||
let req = ResponsesRequest {
|
||||
model: "m".into(),
|
||||
input: ResponsesInput::Items(vec![ResponsesInputItem::Message {
|
||||
input: typed_items(vec![ResponsesInputItem::Message {
|
||||
role: "user".into(),
|
||||
content: ResponsesMessageContent::Parts(vec![
|
||||
ResponsesContentPart::InputText {
|
||||
@@ -687,7 +730,7 @@ mod tests {
|
||||
// it's dropped — but it must not break translation.
|
||||
let req = ResponsesRequest {
|
||||
model: "m".into(),
|
||||
input: ResponsesInput::Items(vec![ResponsesInputItem::Message {
|
||||
input: typed_items(vec![ResponsesInputItem::Message {
|
||||
role: "user".into(),
|
||||
content: ResponsesMessageContent::Parts(vec![
|
||||
ResponsesContentPart::InputText {
|
||||
@@ -729,7 +772,7 @@ mod tests {
|
||||
fn text_only_parts_collapse_to_string() {
|
||||
let req = ResponsesRequest {
|
||||
model: "m".into(),
|
||||
input: ResponsesInput::Items(vec![ResponsesInputItem::Message {
|
||||
input: typed_items(vec![ResponsesInputItem::Message {
|
||||
role: "user".into(),
|
||||
content: ResponsesMessageContent::Parts(vec![
|
||||
ResponsesContentPart::InputText {
|
||||
@@ -759,7 +802,7 @@ mod tests {
|
||||
fn reasoning_items_are_silently_dropped() {
|
||||
let req = ResponsesRequest {
|
||||
model: "m".into(),
|
||||
input: ResponsesInput::Items(vec![
|
||||
input: typed_items(vec![
|
||||
ResponsesInputItem::Reasoning { content: vec![] },
|
||||
ResponsesInputItem::Message {
|
||||
role: "user".into(),
|
||||
@@ -779,6 +822,74 @@ mod tests {
|
||||
assert_eq!(chat.messages[0].role, "user");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bare_easy_messages_translate_like_typed_messages() {
|
||||
// The agent-zero / litellm shape: bare `{role, content}` items
|
||||
// with no `type`. Deserialize from raw JSON (not hand-built)
|
||||
// so this exercises the real parse path end to end.
|
||||
let raw = r#"{
|
||||
"model": "Qwen/Qwen3.6-27B",
|
||||
"store": true,
|
||||
"input": [
|
||||
{"role": "system", "content": "be terse"},
|
||||
{"role": "assistant", "content": "{\"tool_name\":\"response\"}"},
|
||||
{"role": "user", "content": "alpha"}
|
||||
]
|
||||
}"#;
|
||||
let req: ResponsesRequest = serde_json::from_str(raw).unwrap();
|
||||
let chat = request_to_chat(req).unwrap();
|
||||
let roles: Vec<&str> = chat.messages.iter().map(|m| m.role.as_str()).collect();
|
||||
assert_eq!(roles, vec!["system", "assistant", "user"]);
|
||||
assert!(matches!(
|
||||
&chat.messages[2].content,
|
||||
MessageContent::Text(t) if t == "alpha"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn null_content_and_unknown_items_survive_translation() {
|
||||
// An assistant turn with `content: null` is kept (empty text);
|
||||
// an unmodeled item type is dropped, not rejected.
|
||||
let raw = r#"{
|
||||
"model": "m",
|
||||
"input": [
|
||||
{"role": "assistant", "content": null},
|
||||
{"type": "item_reference", "id": "x"},
|
||||
{"role": "user", "content": "go"}
|
||||
]
|
||||
}"#;
|
||||
let req: ResponsesRequest = serde_json::from_str(raw).unwrap();
|
||||
let chat = request_to_chat(req).unwrap();
|
||||
// assistant(null) kept, item_reference dropped, user kept.
|
||||
let roles: Vec<&str> = chat.messages.iter().map(|m| m.role.as_str()).collect();
|
||||
assert_eq!(roles, vec!["assistant", "user"]);
|
||||
assert!(matches!(
|
||||
&chat.messages[0].content,
|
||||
MessageContent::Text(t) if t.is_empty()
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn function_call_output_array_renders_to_text() {
|
||||
// OpenAI allows `function_call_output.output` to be an array of
|
||||
// content parts; the tool result must reach the model as text.
|
||||
let raw = r#"{
|
||||
"model": "m",
|
||||
"input": [
|
||||
{"type": "function_call_output", "call_id": "c1",
|
||||
"output": [{"type": "output_text", "text": "42"}]}
|
||||
]
|
||||
}"#;
|
||||
let req: ResponsesRequest = serde_json::from_str(raw).unwrap();
|
||||
let chat = request_to_chat(req).unwrap();
|
||||
assert_eq!(chat.messages.len(), 1);
|
||||
assert_eq!(chat.messages[0].role, "tool");
|
||||
match &chat.messages[0].content {
|
||||
MessageContent::Text(t) => assert!(t.contains("42"), "got {t:?}"),
|
||||
other => panic!("expected text, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
// ── streaming projector ─────────────────────────────────────────
|
||||
|
||||
async fn collect(mut rx: mpsc::Receiver<ResponseStreamFrame>) -> Vec<ResponseStreamFrame> {
|
||||
@@ -806,6 +917,7 @@ mod tests {
|
||||
prompt_tokens: 0,
|
||||
completion_tokens: 0,
|
||||
reasoning_tokens: 0,
|
||||
timing: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -856,6 +968,7 @@ mod tests {
|
||||
prompt_tokens: 30,
|
||||
completion_tokens: 12,
|
||||
reasoning_tokens: 4,
|
||||
timing: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -886,6 +999,7 @@ mod tests {
|
||||
prompt_tokens: 8,
|
||||
completion_tokens: 3,
|
||||
reasoning_tokens: 0,
|
||||
timing: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -910,6 +1024,7 @@ mod tests {
|
||||
prompt_tokens: 0,
|
||||
completion_tokens: 0,
|
||||
reasoning_tokens: 0,
|
||||
timing: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -956,6 +1071,7 @@ mod tests {
|
||||
prompt_tokens: 0,
|
||||
completion_tokens: 0,
|
||||
reasoning_tokens: 0,
|
||||
timing: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
6
data/helexa-upstream-firewalld.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<service>
|
||||
<short>helexa-upstream</short>
|
||||
<description>helexa-upstream — mesh account + budget authority API (/authz/v1 for cortex, /web/v1 for the frontend)</description>
|
||||
<port protocol="tcp" port="8090"/>
|
||||
</service>
|
||||
3
data/helexa-upstream-sysusers.conf
Normal file
@@ -0,0 +1,3 @@
|
||||
g helexa-upstream - -
|
||||
u helexa-upstream - "helexa-upstream authority" /var/lib/helexa-upstream /sbin/nologin
|
||||
m helexa-upstream helexa-upstream
|
||||
22
data/helexa-upstream.service
Normal file
@@ -0,0 +1,22 @@
|
||||
[Unit]
|
||||
Description=helexa-upstream — mesh account + budget authority (accounts, API keys, allocation ledger, top-up codes)
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/usr/bin/helexa-upstream serve --config /etc/helexa-upstream/helexa-upstream.toml
|
||||
# HTTP authority for cortex (/authz/v1) and the frontend (/web/v1); restart
|
||||
# unconditionally if it ever exits. It connects out to PostgreSQL (the
|
||||
# system of record) and runs schema migrations on startup.
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
User=helexa-upstream
|
||||
Group=helexa-upstream
|
||||
# Service user home; no local state (PostgreSQL holds everything), but
|
||||
# StateDirectory gives the user a writable, correctly-owned home.
|
||||
StateDirectory=helexa-upstream
|
||||
StateDirectoryMode=0755
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -35,3 +35,18 @@ url = "postgres://helexa:helexa@localhost/helexa_upstream"
|
||||
# reservation whose settle/release from a cortex was lost.
|
||||
# reservation_ttl_secs = 120
|
||||
# sweep_interval_secs = 60
|
||||
|
||||
[auth]
|
||||
# HMAC secret for signing web-session JWTs. MUST be overridden in prod via
|
||||
# UPSTREAM_AUTH__JWT_SECRET; the built-in default is dev-only.
|
||||
# jwt_secret = "change-me"
|
||||
# session_ttl_secs = 604800 # 7 days
|
||||
# email_token_ttl_secs = 86400 # 24 hours
|
||||
# Frontend base URL used to build verify/reset links in emails.
|
||||
app_base_url = "https://helexa.ai"
|
||||
|
||||
[email]
|
||||
# "log" (dev: logs the link) or "smtp".
|
||||
provider = "log"
|
||||
# smtp_url = "smtp://user:pass@smtp.example.com:587"
|
||||
from_addr = "helexa <no-reply@helexa.ai>"
|
||||
|
||||
@@ -32,3 +32,22 @@ F0 scaffold. Theming + i18n (33 languages, usage-ordered selector), the
|
||||
`/mission` page, the chat workspace (Dexie + streaming), and the account
|
||||
dashboard land in subsequent phases — see
|
||||
`~/.claude/plans/we-need-to-plan-modular-graham.md`.
|
||||
|
||||
## Deploy (public beta)
|
||||
|
||||
Build the SPA and serve it from edge nginx on the **same origin** as the
|
||||
two backends — so the browser makes no cross-origin request (no CORS) and
|
||||
the user's API key rides as a first-party bearer.
|
||||
|
||||
```sh
|
||||
npm ci && npm run build # → dist/
|
||||
sudo cp -r dist/* /var/www/helexa.ai/
|
||||
sudo cp deploy/nginx.conf /etc/nginx/conf.d/helexa.ai.conf # adjust upstreams + TLS
|
||||
sudo nginx -t && sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
`deploy/nginx.conf` routes `/` → SPA (history fallback), `/v1` + `/health`
|
||||
→ helexa-router, and `/api/` → helexa-upstream `/web/v1/`. Set
|
||||
`VITE_PUBLIC_BETA=true` at build time for the beta banner. There is **no
|
||||
server-side chat history**: conversations live only in the browser
|
||||
(IndexedDB).
|
||||
|
||||
60
helexa.ai/deploy/nginx.conf
Normal file
@@ -0,0 +1,60 @@
|
||||
# helexa.ai — edge nginx for the public beta.
|
||||
#
|
||||
# Serves the built SPA (helexa.ai/dist) and reverse-proxies the two
|
||||
# backends on the SAME ORIGIN, so the browser never makes a cross-origin
|
||||
# request: no CORS, and the user's API key rides as a first-party bearer.
|
||||
#
|
||||
# / → static SPA (history fallback to index.html)
|
||||
# /v1, /health → helexa-router (OpenAI-compatible inference data-plane)
|
||||
# /api/ → helexa-upstream /web/v1/ (account control-plane)
|
||||
#
|
||||
# TLS is terminated here (certs omitted — wire up certbot / your CA). The
|
||||
# upstream hosts below are examples; point them at your router/upstream.
|
||||
|
||||
upstream helexa_router { server 127.0.0.1:8088; }
|
||||
upstream helexa_upstream { server 127.0.0.1:8090; }
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
listen [::]:443 ssl http2;
|
||||
server_name helexa.ai;
|
||||
|
||||
# ssl_certificate /etc/letsencrypt/live/helexa.ai/fullchain.pem;
|
||||
# ssl_certificate_key /etc/letsencrypt/live/helexa.ai/privkey.pem;
|
||||
|
||||
root /var/www/helexa.ai;
|
||||
index index.html;
|
||||
|
||||
# Long-cache fingerprinted assets; never cache the HTML shell.
|
||||
location /assets/ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# Inference data-plane → router. Streaming (SSE): disable buffering so
|
||||
# tokens reach the browser as they arrive.
|
||||
location /v1/ {
|
||||
proxy_pass http://helexa_router;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header Connection "";
|
||||
proxy_buffering off;
|
||||
proxy_read_timeout 300s;
|
||||
}
|
||||
location = /health {
|
||||
proxy_pass http://helexa_router;
|
||||
}
|
||||
|
||||
# Account control-plane → upstream /web/v1/ (strip the /api prefix).
|
||||
location /api/ {
|
||||
proxy_pass http://helexa_upstream/web/v1/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
|
||||
# SPA history fallback: anything else serves index.html.
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,10 @@
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint .",
|
||||
"typecheck": "tsc -b"
|
||||
"typecheck": "tsc -b",
|
||||
"i18n:check": "node ./scripts/check-i18n-keys.mjs",
|
||||
"i18n:meta": "node ./scripts/check-i18n-metadata.mjs",
|
||||
"i18n:lang-labels": "node ./scripts/check-i18n-lang-labels.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fingerprintjs/fingerprintjs": "^4.6.2",
|
||||
|
||||
BIN
helexa.ai/public/banner.png
Normal file
|
After Width: | Height: | Size: 304 KiB |
BIN
helexa.ai/public/bg-logo-right.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
helexa.ai/public/logo.png
Normal file
|
After Width: | Height: | Size: 111 KiB |
BIN
helexa.ai/public/people-mesh-1200x630.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
helexa.ai/public/people-mesh-1536x1024.png
Normal file
|
After Width: | Height: | Size: 2.7 MiB |
BIN
helexa.ai/public/person-helix-1200x630.png
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
BIN
helexa.ai/public/person-helix-1536x1024.png
Normal file
|
After Width: | Height: | Size: 2.2 MiB |
332
helexa.ai/scripts/check-i18n-keys.mjs
Normal file
@@ -0,0 +1,332 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Simple i18n key consistency checker.
|
||||
*
|
||||
* Compares translation JSONs for all configured languages against the English
|
||||
* baseline, for each namespace (common, home, chat).
|
||||
*
|
||||
* Exit codes:
|
||||
* - 0: all good
|
||||
* - 1: inconsistencies found or unexpected error
|
||||
*/
|
||||
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import url from "url";
|
||||
|
||||
// Adjust these if your i18n structure changes.
|
||||
const ROOT = path.resolve(
|
||||
path.dirname(url.fileURLToPath(import.meta.url)),
|
||||
"..",
|
||||
);
|
||||
const RESOURCES_DIR = path.join(ROOT, "src", "i18n", "resources");
|
||||
|
||||
// Namespaces to validate.
|
||||
const NAMESPACES = ["common", "mission", "chat", "account"];
|
||||
|
||||
// Languages to validate should track SUPPORTED_LANGUAGES in src/i18n/languages.ts.
|
||||
// NOTE: This list is intentionally narrower than SUPPORTED_LANGUAGES and does not
|
||||
// enforce that every supported language has wired resources. That enforcement is
|
||||
// implemented further below by checking the i18n index.
|
||||
const LANGUAGES = [
|
||||
"bg",
|
||||
"de",
|
||||
"el",
|
||||
"en",
|
||||
"es",
|
||||
"et",
|
||||
"fr",
|
||||
"it",
|
||||
"pt",
|
||||
"ro",
|
||||
"ru",
|
||||
];
|
||||
|
||||
function readJson(filePath) {
|
||||
try {
|
||||
const raw = fs.readFileSync(filePath, "utf8");
|
||||
return JSON.parse(raw);
|
||||
} catch (err) {
|
||||
throw new Error(`Failed to read/parse JSON at ${filePath}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively walk an object and collect all key paths using dot-notation.
|
||||
* Arrays are traversed structurally but their indexes are not part of the key path
|
||||
* (we only care that the shape exists, not array lengths).
|
||||
*/
|
||||
function collectKeyPaths(obj, prefix = "") {
|
||||
const keys = new Set();
|
||||
|
||||
if (obj === null || obj === undefined) {
|
||||
return keys;
|
||||
}
|
||||
|
||||
if (typeof obj !== "object") {
|
||||
if (prefix) keys.add(prefix);
|
||||
return keys;
|
||||
}
|
||||
|
||||
// If this is an array, walk its elements but don't add indices to the path.
|
||||
if (Array.isArray(obj)) {
|
||||
obj.forEach((item) => {
|
||||
for (const childKey of collectKeyPaths(item, prefix)) {
|
||||
keys.add(childKey);
|
||||
}
|
||||
});
|
||||
return keys;
|
||||
}
|
||||
|
||||
// Plain object
|
||||
for (const [k, v] of Object.entries(obj)) {
|
||||
const nextPrefix = prefix ? `${prefix}.${k}` : k;
|
||||
if (v !== null && typeof v === "object") {
|
||||
for (const childKey of collectKeyPaths(v, nextPrefix)) {
|
||||
keys.add(childKey);
|
||||
}
|
||||
} else {
|
||||
keys.add(nextPrefix);
|
||||
}
|
||||
}
|
||||
|
||||
return keys;
|
||||
}
|
||||
|
||||
function diffKeys(baseSet, targetSet) {
|
||||
const missing = [];
|
||||
const extra = [];
|
||||
|
||||
for (const k of baseSet) {
|
||||
if (!targetSet.has(k)) missing.push(k);
|
||||
}
|
||||
|
||||
for (const k of targetSet) {
|
||||
if (!baseSet.has(k)) extra.push(k);
|
||||
}
|
||||
|
||||
missing.sort();
|
||||
extra.sort();
|
||||
|
||||
return { missing, extra };
|
||||
}
|
||||
|
||||
function logHeader(title) {
|
||||
// Simple console formatting without external deps.
|
||||
console.log("\n" + "=".repeat(title.length));
|
||||
console.log(title);
|
||||
console.log("=".repeat(title.length));
|
||||
}
|
||||
|
||||
function main() {
|
||||
let hadIssues = false;
|
||||
|
||||
console.log("helexa.ai i18n key consistency check");
|
||||
console.log(`Root: ${ROOT}`);
|
||||
console.log(`Resources dir: ${RESOURCES_DIR}`);
|
||||
console.log(`Languages: ${LANGUAGES.join(", ")}`);
|
||||
console.log(`Namespaces: ${NAMESPACES.join(", ")}`);
|
||||
|
||||
// --- Wiring check: ensure each SUPPORTED_LANGUAGES entry has resources registered ---
|
||||
//
|
||||
// This prevents cases where a language is:
|
||||
// - present in LanguageCode and SUPPORTED_LANGUAGES
|
||||
// - has translation JSONs under src/i18n/resources/<code>/
|
||||
// but is *not* wired into the i18n `resources` object (and thus silently
|
||||
// falls back to English at runtime).
|
||||
try {
|
||||
const languagesTs = fs.readFileSync(
|
||||
path.join(ROOT, "src", "i18n", "languages.ts"),
|
||||
"utf8",
|
||||
);
|
||||
const i18nIndexTs = fs.readFileSync(
|
||||
path.join(ROOT, "src", "i18n", "index.ts"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
// Parse SUPPORTED_LANGUAGES from languages.ts
|
||||
const marker = "export const SUPPORTED_LANGUAGES";
|
||||
const start = languagesTs.indexOf(marker);
|
||||
if (start === -1) {
|
||||
throw new Error("Could not find `SUPPORTED_LANGUAGES` in languages.ts");
|
||||
}
|
||||
const after = languagesTs.slice(start);
|
||||
const bracketIndex = after.indexOf("[");
|
||||
const closingIndex = after.indexOf("];");
|
||||
if (bracketIndex === -1 || closingIndex === -1) {
|
||||
throw new Error("Malformed SUPPORTED_LANGUAGES array");
|
||||
}
|
||||
const arraySlice = after.slice(bracketIndex + 1, closingIndex);
|
||||
const supportedFromTs = new Set();
|
||||
const codeRegex = /"([^"]+)"/g;
|
||||
let m;
|
||||
while ((m = codeRegex.exec(arraySlice)) !== null) {
|
||||
supportedFromTs.add(m[1]);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`SUPPORTED_LANGUAGES from TS for wiring check: ${Array.from(
|
||||
supportedFromTs,
|
||||
)
|
||||
.sort()
|
||||
.join(", ")}`,
|
||||
);
|
||||
|
||||
// Languages that are intentionally "future" and may not yet have
|
||||
// resources wired in. Keep them out of the hard failure path so
|
||||
// CI does not break while their translations are still pending.
|
||||
const FUTURE_LANGUAGES = new Set(["ig", "om", "so", "ti", "wo"]);
|
||||
|
||||
// Parse i18n resources object keys from index.ts.
|
||||
// We look for a block like:
|
||||
// const resources: Resource = {
|
||||
// en: { ... },
|
||||
// fr: { ... },
|
||||
// };
|
||||
const resourcesMarker = "const resources: Resource = {";
|
||||
const resStart = i18nIndexTs.indexOf(resourcesMarker);
|
||||
if (resStart === -1) {
|
||||
throw new Error("Could not find `const resources: Resource` in index.ts");
|
||||
}
|
||||
const resAfter = i18nIndexTs.slice(resStart + resourcesMarker.length);
|
||||
const resEndIndex = resAfter.indexOf("};");
|
||||
if (resEndIndex === -1) {
|
||||
throw new Error("Malformed resources object in index.ts");
|
||||
}
|
||||
const resourcesBlock = resAfter.slice(0, resEndIndex);
|
||||
|
||||
// Extract top-level language keys: lines starting with two spaces then <code>:
|
||||
// Example: " en: {" or " fr: {"
|
||||
const wiredLangs = new Set();
|
||||
const lineRegex = /^\s*([a-z]{2}):\s*{\s*$/gm;
|
||||
let lm;
|
||||
while ((lm = lineRegex.exec(resourcesBlock)) !== null) {
|
||||
wiredLangs.add(lm[1]);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Languages wired in i18n resources: ${Array.from(wiredLangs)
|
||||
.sort()
|
||||
.join(", ")}`,
|
||||
);
|
||||
|
||||
// Now compare SUPPORTED_LANGUAGES vs wired resources.
|
||||
const missingWiring = [];
|
||||
for (const code of supportedFromTs) {
|
||||
if (FUTURE_LANGUAGES.has(code)) {
|
||||
// These are explicitly allowed to be missing until their
|
||||
// translations and wiring land.
|
||||
continue;
|
||||
}
|
||||
if (!wiredLangs.has(code)) {
|
||||
missingWiring.push(code);
|
||||
}
|
||||
}
|
||||
|
||||
if (missingWiring.length > 0) {
|
||||
hadIssues = true;
|
||||
console.error(
|
||||
"\nERROR: Some SUPPORTED_LANGUAGES codes are not wired into the i18n `resources` object in src/i18n/index.ts:",
|
||||
);
|
||||
for (const code of missingWiring.sort()) {
|
||||
console.error(` - ${code}`);
|
||||
}
|
||||
console.error(
|
||||
"These languages will silently fall back to English at runtime. Ensure that:",
|
||||
);
|
||||
console.error(
|
||||
" 1) ./resources/<code>/{common,home,chat}.json exist, and",
|
||||
);
|
||||
console.error(
|
||||
" 2) They are imported and registered in the `resources` object.",
|
||||
);
|
||||
console.error("");
|
||||
} else {
|
||||
console.log(
|
||||
"OK: Every SUPPORTED_LANGUAGES entry (excluding future languages) is wired into the i18n resources object.",
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
hadIssues = true;
|
||||
console.error(
|
||||
"ERROR: Failed while checking SUPPORTED_LANGUAGES wiring in i18n index:",
|
||||
err.message,
|
||||
);
|
||||
}
|
||||
|
||||
for (const ns of NAMESPACES) {
|
||||
logHeader(`Namespace: ${ns}`);
|
||||
|
||||
const basePath = path.join(RESOURCES_DIR, "en", `${ns}.json`);
|
||||
let baseJson;
|
||||
try {
|
||||
baseJson = readJson(basePath);
|
||||
} catch (err) {
|
||||
console.error(err.message);
|
||||
hadIssues = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
const baseKeys = collectKeyPaths(baseJson);
|
||||
console.log(`Baseline (en) keys: ${baseKeys.size}`);
|
||||
|
||||
for (const lang of LANGUAGES) {
|
||||
if (lang === "en") continue;
|
||||
|
||||
const langPath = path.join(RESOURCES_DIR, lang, `${ns}.json`);
|
||||
if (!fs.existsSync(langPath)) {
|
||||
console.error(` [${lang}] MISSING file: ${langPath}`);
|
||||
hadIssues = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
let langJson;
|
||||
try {
|
||||
langJson = readJson(langPath);
|
||||
} catch (err) {
|
||||
console.error(` [${lang}] ${err.message}`);
|
||||
hadIssues = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
const langKeys = collectKeyPaths(langJson);
|
||||
const { missing, extra } = diffKeys(baseKeys, langKeys);
|
||||
|
||||
if (missing.length === 0 && extra.length === 0) {
|
||||
console.log(` [${lang}] OK (keys: ${langKeys.size})`);
|
||||
} else {
|
||||
hadIssues = true;
|
||||
console.log(` [${lang}] Issues found:`);
|
||||
if (missing.length > 0) {
|
||||
console.log(" Missing keys (present in en, absent here):");
|
||||
for (const k of missing) {
|
||||
console.log(` - ${k}`);
|
||||
}
|
||||
}
|
||||
if (extra.length > 0) {
|
||||
console.log(" Extra keys (present here, absent in en):");
|
||||
for (const k of extra) {
|
||||
console.log(` + ${k}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log("");
|
||||
if (hadIssues) {
|
||||
console.error("i18n check completed with inconsistencies.");
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
console.log("i18n check completed successfully. All keys are consistent.");
|
||||
process.exitCode = 0;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
main();
|
||||
} catch (err) {
|
||||
console.error("Unexpected error while running i18n check:", err);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
249
helexa.ai/scripts/check-i18n-lang-labels.mjs
Normal file
@@ -0,0 +1,249 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* helexa.ai i18n language label consistency check
|
||||
*
|
||||
* This script validates that for every supported language code in
|
||||
* `SUPPORTED_LANGUAGES`:
|
||||
*
|
||||
* 1. The English `common.lang` map (`src/i18n/resources/en/common.json`)
|
||||
* contains a human-readable label at `lang.<code>`.
|
||||
* 2. Optionally (and more leniently), other languages that define a
|
||||
* `lang` block do not omit supported language codes.
|
||||
*
|
||||
* At minimum, it enforces that the English UI always has labels for all
|
||||
* supported languages, since the header language selector renders
|
||||
* `t("lang.<code>")` using the active language.
|
||||
*
|
||||
* Exit codes:
|
||||
* - 0: all checks pass
|
||||
* - 1: one or more inconsistencies found
|
||||
*/
|
||||
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import url from "url";
|
||||
|
||||
const ROOT = path.resolve(path.dirname(url.fileURLToPath(import.meta.url)), "..");
|
||||
const I18N_DIR = path.join(ROOT, "src", "i18n");
|
||||
const LANGUAGES_TS = path.join(I18N_DIR, "languages.ts");
|
||||
const EN_COMMON_JSON = path.join(I18N_DIR, "resources", "en", "common.json");
|
||||
|
||||
/**
|
||||
* Utility: read a file as UTF-8 or throw with a helpful message.
|
||||
*/
|
||||
function readFileOrDie(filePath) {
|
||||
try {
|
||||
return fs.readFileSync(filePath, "utf8");
|
||||
} catch (err) {
|
||||
throw new Error(`Failed to read ${filePath}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse SUPPORTED_LANGUAGES from languages.ts.
|
||||
*
|
||||
* Expects a definition like:
|
||||
* export const SUPPORTED_LANGUAGES: LanguageCode[] = [
|
||||
* "en",
|
||||
* "bg",
|
||||
* ];
|
||||
*/
|
||||
function parseSupportedLanguages(source) {
|
||||
const marker = "export const SUPPORTED_LANGUAGES";
|
||||
const start = source.indexOf(marker);
|
||||
if (start === -1) {
|
||||
throw new Error("Could not find `SUPPORTED_LANGUAGES` in languages.ts");
|
||||
}
|
||||
|
||||
const after = source.slice(start);
|
||||
const bracketIndex = after.indexOf("[");
|
||||
const closingIndex = after.indexOf("];");
|
||||
if (bracketIndex === -1 || closingIndex === -1) {
|
||||
throw new Error("Malformed SUPPORTED_LANGUAGES array");
|
||||
}
|
||||
|
||||
const arraySlice = after.slice(bracketIndex + 1, closingIndex);
|
||||
const codes = new Set();
|
||||
const regex = /"([^"]+)"/g;
|
||||
let m;
|
||||
while ((m = regex.exec(arraySlice)) !== null) {
|
||||
codes.add(m[1]);
|
||||
}
|
||||
|
||||
if (codes.size === 0) {
|
||||
throw new Error("No entries found in SUPPORTED_LANGUAGES");
|
||||
}
|
||||
|
||||
return codes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely read and parse a JSON file.
|
||||
*/
|
||||
function readJsonOrDie(filePath) {
|
||||
const raw = readFileOrDie(filePath);
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch (err) {
|
||||
throw new Error(`Failed to parse JSON at ${filePath}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute set difference: a \ b
|
||||
*/
|
||||
function difference(a, b) {
|
||||
const result = new Set();
|
||||
for (const x of a) {
|
||||
if (!b.has(x)) result.add(x);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience: sorted array from a Set.
|
||||
*/
|
||||
function toSortedArray(set) {
|
||||
return [...set].sort();
|
||||
}
|
||||
|
||||
function main() {
|
||||
console.log("helexa.ai i18n language label check");
|
||||
console.log(`Root: ${ROOT}`);
|
||||
console.log(`Languages file: ${LANGUAGES_TS}`);
|
||||
console.log(`English common.json: ${EN_COMMON_JSON}`);
|
||||
console.log("");
|
||||
|
||||
let hadIssues = false;
|
||||
|
||||
// 1) Load SUPPORTED_LANGUAGES
|
||||
let languagesSource;
|
||||
try {
|
||||
languagesSource = readFileOrDie(LANGUAGES_TS);
|
||||
} catch (err) {
|
||||
console.error(err.message);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
let supportedLanguages;
|
||||
try {
|
||||
supportedLanguages = parseSupportedLanguages(languagesSource);
|
||||
console.log(
|
||||
`SUPPORTED_LANGUAGES (${supportedLanguages.size}): ${toSortedArray(
|
||||
supportedLanguages,
|
||||
).join(", ")}`,
|
||||
);
|
||||
} catch (err) {
|
||||
console.error(`ERROR: ${err.message}`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("");
|
||||
|
||||
// 2) Load English common.json and extract lang map
|
||||
let enCommon;
|
||||
try {
|
||||
enCommon = readJsonOrDie(EN_COMMON_JSON);
|
||||
} catch (err) {
|
||||
console.error(`ERROR: ${err.message}`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const enLangMap = enCommon?.lang ?? {};
|
||||
if (!enLangMap || typeof enLangMap !== "object") {
|
||||
console.error("ERROR: `en/common.json` does not contain a `lang` object.");
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const enLangKeys = new Set(Object.keys(enLangMap));
|
||||
console.log(
|
||||
`English lang map keys (${enLangKeys.size}): ${toSortedArray(
|
||||
enLangKeys,
|
||||
).join(", ")}`,
|
||||
);
|
||||
console.log("");
|
||||
|
||||
// 3) Ensure every supported language has a corresponding key in en.lang
|
||||
const missingInEnglish = difference(supportedLanguages, enLangKeys);
|
||||
if (missingInEnglish.size > 0) {
|
||||
hadIssues = true;
|
||||
console.error(
|
||||
"ERROR: The following supported languages are missing labels in `en/common.json` under `lang`:",
|
||||
);
|
||||
for (const code of toSortedArray(missingInEnglish)) {
|
||||
console.error(` - lang.${code}`);
|
||||
}
|
||||
} else {
|
||||
console.log(
|
||||
"OK: Every supported language has a `lang.<code>` entry in `en/common.json`.",
|
||||
);
|
||||
}
|
||||
|
||||
console.log("");
|
||||
|
||||
// 4) Optional: scan other locales for informational consistency
|
||||
// (non-fatal, just warnings).
|
||||
const resourcesDir = path.join(I18N_DIR, "resources");
|
||||
let otherLocales = [];
|
||||
try {
|
||||
otherLocales = fs
|
||||
.readdirSync(resourcesDir, { withFileTypes: true })
|
||||
.filter((d) => d.isDirectory() && d.name !== "en")
|
||||
.map((d) => d.name);
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
`WARN: Could not list locales in ${resourcesDir}: ${err.message}`,
|
||||
);
|
||||
}
|
||||
|
||||
for (const locale of otherLocales) {
|
||||
const filePath = path.join(resourcesDir, locale, "common.json");
|
||||
if (!fs.existsSync(filePath)) continue;
|
||||
|
||||
let localeJson;
|
||||
try {
|
||||
localeJson = readJsonOrDie(filePath);
|
||||
} catch (err) {
|
||||
console.warn(`WARN: Failed to read ${filePath}: ${err.message}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const langObj = localeJson?.lang;
|
||||
if (!langObj || typeof langObj !== "object") {
|
||||
// Not all locales need to maintain a full lang map; skip silently.
|
||||
continue;
|
||||
}
|
||||
|
||||
const localeKeys = new Set(Object.keys(langObj));
|
||||
const missingHere = difference(supportedLanguages, localeKeys);
|
||||
if (missingHere.size > 0) {
|
||||
console.warn(
|
||||
`WARN: Locale '${locale}' is missing some supported language labels under "lang":`,
|
||||
);
|
||||
for (const code of toSortedArray(missingHere)) {
|
||||
console.warn(` - lang.${code}`);
|
||||
}
|
||||
console.warn("");
|
||||
}
|
||||
}
|
||||
|
||||
if (hadIssues) {
|
||||
console.error("Language label check completed with inconsistencies.");
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
console.log("Language label check completed successfully.");
|
||||
process.exitCode = 0;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
main();
|
||||
} catch (err) {
|
||||
console.error("Unexpected error while running language label check:", err);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
327
helexa.ai/scripts/check-i18n-metadata.mjs
Normal file
@@ -0,0 +1,327 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* helexa.ai i18n metadata consistency check
|
||||
*
|
||||
* This script validates that:
|
||||
* - Every `LanguageCode` used in the project appears in `TRANSLATION_PRIORITY`
|
||||
* - Every `TRANSLATION_PRIORITY` entry refers to a valid `LanguageCode`
|
||||
* - `REMAINING_LANGUAGES` is a subset of `LanguageCode`
|
||||
* - `REMAINING_LANGUAGES` is disjoint from `SUPPORTED_LANGUAGES`
|
||||
*
|
||||
* It is intentionally implemented as a standalone Node script (no TypeScript
|
||||
* build step required) and does a very lightweight parse of the TypeScript
|
||||
* source files to avoid pulling in a full TS compiler.
|
||||
*
|
||||
* Exit codes:
|
||||
* - 0: all checks pass
|
||||
* - 1: one or more metadata inconsistencies found
|
||||
*/
|
||||
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import url from "url";
|
||||
|
||||
/**
|
||||
* Resolve project root as the directory containing this script.
|
||||
*/
|
||||
const ROOT = path.resolve(path.dirname(url.fileURLToPath(import.meta.url)), "..");
|
||||
const I18N_DIR = path.join(ROOT, "src", "i18n");
|
||||
const LANGUAGES_TS = path.join(I18N_DIR, "languages.ts");
|
||||
const PRIORITY_TS = path.join(I18N_DIR, "translation-priority.ts");
|
||||
|
||||
function readFileOrDie(filePath) {
|
||||
try {
|
||||
return fs.readFileSync(filePath, "utf8");
|
||||
} catch (err) {
|
||||
console.error(`Failed to read ${filePath}: ${err.message}`);
|
||||
process.exitCode = 1;
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract union members from a definition like:
|
||||
*
|
||||
* export type LanguageCode =
|
||||
* | "en"
|
||||
* | "bg"
|
||||
* | "cs";
|
||||
*/
|
||||
function parseLanguageCodeUnion(source) {
|
||||
const start = source.indexOf("export type LanguageCode");
|
||||
if (start === -1) {
|
||||
throw new Error("Could not find `export type LanguageCode` in languages.ts");
|
||||
}
|
||||
|
||||
const after = source.slice(start);
|
||||
const eqIndex = after.indexOf("=");
|
||||
if (eqIndex === -1) {
|
||||
throw new Error("Malformed LanguageCode definition (no '=')");
|
||||
}
|
||||
|
||||
const unionBlock = after.slice(eqIndex + 1);
|
||||
const semicolonIndex = unionBlock.indexOf(";");
|
||||
const unionSlice = semicolonIndex === -1 ? unionBlock : unionBlock.slice(0, semicolonIndex);
|
||||
|
||||
const codes = new Set();
|
||||
const regex = /\|\s*"([^"]+)"/g;
|
||||
let m;
|
||||
while ((m = regex.exec(unionSlice)) !== null) {
|
||||
codes.add(m[1]);
|
||||
}
|
||||
|
||||
if (codes.size === 0) {
|
||||
throw new Error("No language codes found in LanguageCode union");
|
||||
}
|
||||
|
||||
return codes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse SUPPORTED_LANGUAGES from languages.ts
|
||||
*
|
||||
* export const SUPPORTED_LANGUAGES: LanguageCode[] = [
|
||||
* "en",
|
||||
* "bg",
|
||||
* ];
|
||||
*/
|
||||
function parseSupportedLanguages(source) {
|
||||
const marker = "export const SUPPORTED_LANGUAGES";
|
||||
const start = source.indexOf(marker);
|
||||
if (start === -1) {
|
||||
throw new Error("Could not find `SUPPORTED_LANGUAGES` in languages.ts");
|
||||
}
|
||||
|
||||
const after = source.slice(start);
|
||||
const bracketIndex = after.indexOf("[");
|
||||
const closingIndex = after.indexOf("];");
|
||||
if (bracketIndex === -1 || closingIndex === -1) {
|
||||
throw new Error("Malformed SUPPORTED_LANGUAGES array");
|
||||
}
|
||||
|
||||
const arraySlice = after.slice(bracketIndex + 1, closingIndex);
|
||||
const codes = new Set();
|
||||
const regex = /"([^"]+)"/g;
|
||||
let m;
|
||||
while ((m = regex.exec(arraySlice)) !== null) {
|
||||
codes.add(m[1]);
|
||||
}
|
||||
|
||||
if (codes.size === 0) {
|
||||
throw new Error("No entries found in SUPPORTED_LANGUAGES");
|
||||
}
|
||||
|
||||
return codes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse REMAINING_LANGUAGES from translation-priority.ts
|
||||
*
|
||||
* export const REMAINING_LANGUAGES: LanguageCode[] = [
|
||||
* "tr",
|
||||
* "pl",
|
||||
* ];
|
||||
*/
|
||||
function parseRemainingLanguages(source) {
|
||||
const marker = "export const REMAINING_LANGUAGES";
|
||||
const start = source.indexOf(marker);
|
||||
if (start === -1) {
|
||||
// It's valid for this list to not exist; treat as empty if missing
|
||||
return new Set();
|
||||
}
|
||||
|
||||
const after = source.slice(start);
|
||||
const bracketIndex = after.indexOf("[");
|
||||
const closingIndex = after.indexOf("];");
|
||||
if (bracketIndex === -1 || closingIndex === -1) {
|
||||
throw new Error("Malformed REMAINING_LANGUAGES array");
|
||||
}
|
||||
|
||||
const arraySlice = after.slice(bracketIndex + 1, closingIndex);
|
||||
const codes = new Set();
|
||||
const regex = /"([^"]+)"/g;
|
||||
let m;
|
||||
while ((m = regex.exec(arraySlice)) !== null) {
|
||||
codes.add(m[1]);
|
||||
}
|
||||
|
||||
return codes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse TRANSLATION_PRIORITY entries from translation-priority.ts
|
||||
*
|
||||
* export const TRANSLATION_PRIORITY: TranslationPriorityEntry[] = [
|
||||
* {
|
||||
* code: "tr",
|
||||
* bucket: "high",
|
||||
* nativeSpeakers: "70–90M",
|
||||
* },
|
||||
* ...
|
||||
* ];
|
||||
*/
|
||||
function parseTranslationPriority(source) {
|
||||
const marker = "export const TRANSLATION_PRIORITY";
|
||||
const start = source.indexOf(marker);
|
||||
if (start === -1) {
|
||||
throw new Error("Could not find `TRANSLATION_PRIORITY` in translation-priority.ts");
|
||||
}
|
||||
|
||||
const after = source.slice(start);
|
||||
const bracketIndex = after.indexOf("[");
|
||||
const closingIndex = after.indexOf("];");
|
||||
if (bracketIndex === -1 || closingIndex === -1) {
|
||||
throw new Error("Malformed TRANSLATION_PRIORITY array");
|
||||
}
|
||||
|
||||
const arraySlice = after.slice(bracketIndex + 1, closingIndex);
|
||||
|
||||
// Simple heuristic: find code: "<value>" inside objects
|
||||
const codes = new Set();
|
||||
const regex = /code:\s*"([^"]+)"/g;
|
||||
let m;
|
||||
while ((m = regex.exec(arraySlice)) !== null) {
|
||||
codes.add(m[1]);
|
||||
}
|
||||
|
||||
if (codes.size === 0) {
|
||||
throw new Error("No `code` entries found in TRANSLATION_PRIORITY");
|
||||
}
|
||||
|
||||
return codes;
|
||||
}
|
||||
|
||||
function difference(a, b) {
|
||||
const result = new Set();
|
||||
for (const x of a) {
|
||||
if (!b.has(x)) result.add(x);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function toSortedArray(set) {
|
||||
return [...set].sort();
|
||||
}
|
||||
|
||||
function main() {
|
||||
console.log("helexa.ai i18n metadata consistency check");
|
||||
console.log(`Root: ${ROOT}`);
|
||||
console.log(`Languages file: ${LANGUAGES_TS}`);
|
||||
console.log(`Priority file: ${PRIORITY_TS}`);
|
||||
console.log("");
|
||||
|
||||
const languagesSource = readFileOrDie(LANGUAGES_TS);
|
||||
const prioritySource = readFileOrDie(PRIORITY_TS);
|
||||
|
||||
let hadIssues = false;
|
||||
|
||||
let languageCodes;
|
||||
let supportedLanguages;
|
||||
let remainingLanguages;
|
||||
let priorityCodes;
|
||||
|
||||
try {
|
||||
languageCodes = parseLanguageCodeUnion(languagesSource);
|
||||
console.log(`LanguageCode entries: ${languageCodes.size}`);
|
||||
} catch (err) {
|
||||
console.error(`ERROR: ${err.message}`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
supportedLanguages = parseSupportedLanguages(languagesSource);
|
||||
console.log(`SUPPORTED_LANGUAGES entries: ${supportedLanguages.size}`);
|
||||
} catch (err) {
|
||||
console.error(`ERROR: ${err.message}`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
remainingLanguages = parseRemainingLanguages(prioritySource);
|
||||
console.log(`REMAINING_LANGUAGES entries: ${remainingLanguages.size}`);
|
||||
} catch (err) {
|
||||
console.error(`ERROR: ${err.message}`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
priorityCodes = parseTranslationPriority(prioritySource);
|
||||
console.log(`TRANSLATION_PRIORITY entries: ${priorityCodes.size}`);
|
||||
} catch (err) {
|
||||
console.error(`ERROR: ${err.message}`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("");
|
||||
|
||||
// 1) Every LanguageCode should have a TRANSLATION_PRIORITY entry
|
||||
const missingInPriority = difference(languageCodes, priorityCodes);
|
||||
if (missingInPriority.size > 0) {
|
||||
hadIssues = true;
|
||||
console.error("ERROR: The following LanguageCode values are missing from TRANSLATION_PRIORITY:");
|
||||
for (const code of toSortedArray(missingInPriority)) {
|
||||
console.error(` - ${code}`);
|
||||
}
|
||||
} else {
|
||||
console.log("OK: All LanguageCode values are present in TRANSLATION_PRIORITY.");
|
||||
}
|
||||
|
||||
// 2) Every TRANSLATION_PRIORITY code must be a valid LanguageCode
|
||||
const unknownPriorityCodes = difference(priorityCodes, languageCodes);
|
||||
if (unknownPriorityCodes.size > 0) {
|
||||
hadIssues = true;
|
||||
console.error("ERROR: The following TRANSLATION_PRIORITY codes are not present in LanguageCode:");
|
||||
for (const code of toSortedArray(unknownPriorityCodes)) {
|
||||
console.error(` - ${code}`);
|
||||
}
|
||||
} else {
|
||||
console.log("OK: All TRANSLATION_PRIORITY codes are valid LanguageCode values.");
|
||||
}
|
||||
|
||||
// 3) REMAINING_LANGUAGES must be subset of LanguageCode
|
||||
const remainingNotInLanguageCode = difference(remainingLanguages, languageCodes);
|
||||
if (remainingNotInLanguageCode.size > 0) {
|
||||
hadIssues = true;
|
||||
console.error("ERROR: The following REMAINING_LANGUAGES entries are not in LanguageCode:");
|
||||
for (const code of toSortedArray(remainingNotInLanguageCode)) {
|
||||
console.error(` - ${code}`);
|
||||
}
|
||||
} else {
|
||||
console.log("OK: All REMAINING_LANGUAGES are valid LanguageCode values.");
|
||||
}
|
||||
|
||||
// 4) REMAINING_LANGUAGES must be disjoint from SUPPORTED_LANGUAGES
|
||||
const remainingThatAreSupported = difference(remainingLanguages, difference(remainingLanguages, supportedLanguages));
|
||||
// Equivalent to intersection(remainingLanguages, supportedLanguages)
|
||||
if (remainingThatAreSupported.size > 0) {
|
||||
hadIssues = true;
|
||||
console.error("ERROR: The following REMAINING_LANGUAGES are already in SUPPORTED_LANGUAGES:");
|
||||
for (const code of toSortedArray(remainingThatAreSupported)) {
|
||||
console.error(` - ${code}`);
|
||||
}
|
||||
} else {
|
||||
console.log("OK: REMAINING_LANGUAGES does not include any SUPPORTED_LANGUAGES.");
|
||||
}
|
||||
|
||||
console.log("");
|
||||
|
||||
if (hadIssues) {
|
||||
console.error("i18n metadata check completed with inconsistencies.");
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
console.log("i18n metadata check completed successfully. All metadata is consistent.");
|
||||
process.exitCode = 0;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
main();
|
||||
} catch (err) {
|
||||
console.error("Unexpected error while running i18n metadata check:", err);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
423
helexa.ai/src/App.css
Normal file
@@ -0,0 +1,423 @@
|
||||
:root {
|
||||
--color-bg: #f5f7fb;
|
||||
--color-bg-elevated: #ffffff;
|
||||
--color-bg-subtle: #eef1f7;
|
||||
--color-border-subtle: rgba(15, 23, 42, 0.12);
|
||||
--color-text: #020617;
|
||||
--color-text-muted: #64748b;
|
||||
--color-accent: #22d3ee;
|
||||
--color-accent-hot: #ec4899;
|
||||
--color-accent-soft: rgba(34, 211, 238, 0.12);
|
||||
--color-navbar-bg: rgba(255, 255, 255, 0.85);
|
||||
--color-footer-bg: rgba(255, 255, 255, 0.9);
|
||||
--shadow-soft: 0 18px 45px rgba(15, 23, 42, 0.08);
|
||||
--transition-fast: 150ms ease-out;
|
||||
}
|
||||
|
||||
/* Dark theme overrides, driven by ThemeProvider via [data-theme] */
|
||||
:root[data-theme="dark"] {
|
||||
--color-bg: #000618;
|
||||
--color-bg-elevated: #020617;
|
||||
--color-bg-subtle: #020617;
|
||||
--color-border-subtle: rgba(148, 163, 184, 0.24);
|
||||
--color-text: #e2e8f0;
|
||||
--color-text-muted: #cbd5f5;
|
||||
--color-accent: #22d3ee;
|
||||
--color-accent-soft: rgba(34, 211, 238, 0.18);
|
||||
--color-navbar-bg: rgba(2, 6, 23, 0.92);
|
||||
--color-footer-bg: rgba(2, 6, 23, 0.96);
|
||||
--shadow-soft: 0 22px 55px rgba(15, 23, 42, 0.8);
|
||||
}
|
||||
|
||||
/* Global base styles */
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family:
|
||||
system-ui,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
"SF Pro Text",
|
||||
"Segoe UI",
|
||||
sans-serif;
|
||||
background-color: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
/* Basic RTL reset: when the root document is RTL, ensure logical direction */
|
||||
html[dir="rtl"] body {
|
||||
direction: rtl;
|
||||
}
|
||||
|
||||
.app-root {
|
||||
min-height: 100vh;
|
||||
background-color: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
transition:
|
||||
background-color var(--transition-fast),
|
||||
color var(--transition-fast);
|
||||
}
|
||||
|
||||
/* Typography helpers */
|
||||
|
||||
.app-main h1,
|
||||
.app-main h2,
|
||||
.app-main h3,
|
||||
.app-main h4,
|
||||
.app-main h5 {
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.app-main p.lead {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* Hot pink accent utility for occasional emphasis */
|
||||
.text-hot-pink {
|
||||
color: var(--color-accent-hot) !important;
|
||||
}
|
||||
|
||||
/* Header / Navbar */
|
||||
|
||||
.app-header {
|
||||
background-color: var(--color-navbar-bg);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
box-shadow: 0 1px 0 var(--color-border-subtle);
|
||||
transition:
|
||||
background-color var(--transition-fast),
|
||||
box-shadow var(--transition-fast),
|
||||
border-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.app-header .navbar-brand {
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.app-header .navbar-brand:hover {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.app-header .nav-link {
|
||||
color: var(--color-text-muted);
|
||||
font-weight: 500;
|
||||
font-size: 0.9rem;
|
||||
transition:
|
||||
color var(--transition-fast),
|
||||
background-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.app-header .nav-link:hover,
|
||||
.app-header .nav-link:focus {
|
||||
color: var(--color-accent);
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.app-header .nav-link.active {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.app-header .btn.btn-outline-secondary {
|
||||
--bs-btn-color: var(--color-text-muted);
|
||||
--bs-btn-border-color: var(--color-border-subtle);
|
||||
--bs-btn-hover-bg: var(--color-accent-soft);
|
||||
--bs-btn-hover-border-color: var(--color-accent);
|
||||
--bs-btn-hover-color: var(--color-accent);
|
||||
--bs-btn-focus-shadow-rgb: 34, 211, 238;
|
||||
padding-inline: 0.55rem;
|
||||
padding-block: 0.3rem;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
/* Language dropdown: theme-aware styling */
|
||||
.app-header .dropdown-menu-dark {
|
||||
--bs-dropdown-bg: var(--color-bg-elevated);
|
||||
--bs-dropdown-link-color: var(--color-text);
|
||||
--bs-dropdown-link-hover-bg: var(--color-accent-soft);
|
||||
--bs-dropdown-link-hover-color: var(--color-accent);
|
||||
--bs-dropdown-link-active-bg: var(--color-accent-soft);
|
||||
--bs-dropdown-link-active-color: var(--color-accent);
|
||||
--bs-dropdown-border-color: var(--color-border-subtle);
|
||||
background-color: var(--color-bg-elevated);
|
||||
border-color: var(--color-border-subtle);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
/* Prevent language dropdown from overflowing off-screen in RTL */
|
||||
html[dir="rtl"] .app-header .dropdown-menu-end {
|
||||
left: auto;
|
||||
right: 0;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.app-header .dropdown-menu-dark .dropdown-item.active,
|
||||
.app-header .dropdown-menu-dark .dropdown-item:active {
|
||||
background-color: var(--color-accent-soft);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.app-header .dropdown-menu-dark-context .dropdown-toggle.btn-secondary {
|
||||
--bs-btn-bg: transparent;
|
||||
--bs-btn-border-color: var(--color-border-subtle);
|
||||
--bs-btn-color: var(--color-text-muted);
|
||||
--bs-btn-hover-bg: var(--color-accent-soft);
|
||||
--bs-btn-hover-border-color: var(--color-accent);
|
||||
--bs-btn-hover-color: var(--color-accent);
|
||||
--bs-btn-focus-shadow-rgb: 34, 211, 238;
|
||||
}
|
||||
|
||||
.app-header .btn.btn-outline-secondary svg {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Generic helper class for mirroring directional icons in RTL.
|
||||
Used by DirectionalIcon when `mirrorInRtl` is enabled. */
|
||||
html[dir="rtl"] .diricon-mirror-rtl {
|
||||
transform: scaleX(-1);
|
||||
}
|
||||
|
||||
/* Provide a visible toggler icon when using default Bootstrap styles */
|
||||
.navbar-toggler {
|
||||
border-color: var(--color-border-subtle);
|
||||
}
|
||||
|
||||
.navbar-toggler-icon {
|
||||
background-image: none;
|
||||
position: relative;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
}
|
||||
|
||||
.navbar-toggler-icon::before,
|
||||
.navbar-toggler-icon::after,
|
||||
.navbar-toggler-icon span {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
border-radius: 999px;
|
||||
background-color: var(--color-text);
|
||||
transition:
|
||||
transform 150ms ease-out,
|
||||
opacity 150ms ease-out;
|
||||
}
|
||||
|
||||
.navbar-toggler-icon::before {
|
||||
top: 0.25rem;
|
||||
}
|
||||
|
||||
.navbar-toggler-icon::after {
|
||||
bottom: 0.25rem;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
|
||||
.app-footer {
|
||||
background-color: var(--color-footer-bg);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
box-shadow: 0 -1px 0 var(--color-border-subtle);
|
||||
transition:
|
||||
background-color var(--transition-fast),
|
||||
box-shadow var(--transition-fast),
|
||||
border-color var(--transition-fast);
|
||||
}
|
||||
|
||||
/* Main content layout */
|
||||
|
||||
.app-main {
|
||||
max-width: 1120px;
|
||||
}
|
||||
|
||||
/* When in RTL mode, flip common flex directions and alignment where needed */
|
||||
html[dir="rtl"] .app-root {
|
||||
direction: rtl;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
html[dir="rtl"] .app-header .navbar-brand {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
html[dir="rtl"] .app-header .nav-link {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
html[dir="rtl"] .app-header .d-flex.align-items-center.gap-2 {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
html[dir="rtl"] .app-main .card-body {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* Ensure footer content also respects RTL direction */
|
||||
html[dir="rtl"] .app-footer {
|
||||
direction: rtl;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.app-main .card,
|
||||
.app-main .card-body {
|
||||
background-color: var(--color-bg-elevated);
|
||||
border-color: var(--color-border-subtle);
|
||||
}
|
||||
|
||||
/* Generic utility styles */
|
||||
|
||||
.surface-elevated {
|
||||
background-color: var(--color-bg-elevated);
|
||||
border-radius: 1rem;
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
box-shadow: var(--shadow-soft);
|
||||
}
|
||||
|
||||
/* Mesh / principle icon containers */
|
||||
.mesh-icon,
|
||||
.principle-icon {
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
border-radius: 999px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
background: radial-gradient(
|
||||
circle,
|
||||
var(--color-accent-soft),
|
||||
transparent 60%
|
||||
);
|
||||
color: var(--color-accent);
|
||||
margin-bottom: 0.35rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.principle-icon {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
}
|
||||
|
||||
/* CTA background texture */
|
||||
.join-mesh-cta {
|
||||
background:
|
||||
radial-gradient(
|
||||
circle at top,
|
||||
rgba(34, 211, 238, 0.18),
|
||||
transparent 60%
|
||||
),
|
||||
url("/bg-logo-right.png") center/cover no-repeat,
|
||||
var(--color-bg-elevated);
|
||||
background-blend-mode: screen, normal;
|
||||
}
|
||||
|
||||
.badge-accent {
|
||||
background-color: var(--color-accent-soft);
|
||||
color: var(--color-accent);
|
||||
border-radius: 999px;
|
||||
padding: 0.1rem 0.55rem;
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
/* Ensure muted text uses our theme variable instead of Bootstrap defaults */
|
||||
.text-muted,
|
||||
small {
|
||||
color: var(--color-text-muted) !important;
|
||||
}
|
||||
|
||||
/* Small cyan dot used inside hero and CTA badges */
|
||||
.bg-cyan-500-dot {
|
||||
display: inline-block;
|
||||
width: 0.45rem;
|
||||
height: 0.45rem;
|
||||
border-radius: 999px;
|
||||
background: radial-gradient(
|
||||
circle,
|
||||
var(--color-accent) 0%,
|
||||
transparent 65%
|
||||
);
|
||||
}
|
||||
|
||||
/* Links */
|
||||
|
||||
a {
|
||||
color: var(--color-accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: var(--color-accent);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Scrollbar theming (WebKit) */
|
||||
|
||||
*::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(148, 163, 184, 0.6);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] *::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(148, 163, 184, 0.8);
|
||||
}
|
||||
|
||||
/* Code blocks / preformatted text placeholders */
|
||||
|
||||
pre,
|
||||
code {
|
||||
font-family:
|
||||
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
|
||||
"Liberation Mono", "Courier New", monospace;
|
||||
}
|
||||
|
||||
pre {
|
||||
background-color: var(--color-bg-subtle);
|
||||
border-radius: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
/* Small-screen adjustments */
|
||||
|
||||
@media (max-width: 576px) {
|
||||
.app-main {
|
||||
padding-left: 1rem !important;
|
||||
padding-right: 1rem !important;
|
||||
}
|
||||
|
||||
.app-header .navbar-brand {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Public-beta banner (F6) — slim accent strip above the header. */
|
||||
.beta-banner {
|
||||
background: var(--bs-warning-bg-subtle, #fff3cd);
|
||||
color: var(--bs-warning-text-emphasis, #664d03);
|
||||
border-bottom: 1px solid var(--bs-warning-border-subtle, #ffe69c);
|
||||
}
|
||||
[data-bs-theme="dark"] .beta-banner {
|
||||
background: rgba(255, 193, 7, 0.12);
|
||||
color: #ffda6a;
|
||||
border-bottom-color: rgba(255, 193, 7, 0.25);
|
||||
}
|
||||
@@ -1,12 +1,61 @@
|
||||
import { Container } from "react-bootstrap";
|
||||
import { BrowserRouter, Routes, Route } from "react-router-dom";
|
||||
import ThemeProvider from "./layout/ThemeProvider";
|
||||
import AuthProvider from "./auth/AuthProvider";
|
||||
import RequireAuth from "./auth/RequireAuth";
|
||||
import Header from "./components/Header";
|
||||
import Footer from "./components/Footer";
|
||||
import BetaBanner from "./components/BetaBanner";
|
||||
import Mission from "./pages/Mission";
|
||||
import Chat from "./pages/Chat";
|
||||
import Login from "./pages/auth/Login";
|
||||
import Register from "./pages/auth/Register";
|
||||
import VerifyEmail from "./pages/auth/VerifyEmail";
|
||||
import RequestReset from "./pages/auth/RequestReset";
|
||||
import ResetPassword from "./pages/auth/ResetPassword";
|
||||
import Dashboard from "./pages/account/Dashboard";
|
||||
import ApiKeys from "./pages/account/ApiKeys";
|
||||
import "./App.css";
|
||||
|
||||
// F0 scaffold shell. Theming, i18n, routing, the chat workspace, mission
|
||||
// page and account dashboard land in the F1+ phases.
|
||||
// Composition root: theme → router → auth → layout shell. `/` is the chat
|
||||
// workspace (F3); `/mission` the EU-sovereignty narrative (F2); the auth +
|
||||
// account routes (F4) follow, with /account guarded.
|
||||
export default function App() {
|
||||
return (
|
||||
<Container className="py-5">
|
||||
<h1 className="mb-2">helexa.ai</h1>
|
||||
<p className="text-muted">Public beta — coming online.</p>
|
||||
</Container>
|
||||
<ThemeProvider>
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<div className="d-flex flex-column min-vh-100">
|
||||
<BetaBanner />
|
||||
<Header />
|
||||
<Routes>
|
||||
<Route path="/" element={<Chat />} />
|
||||
<Route path="/mission" element={<Mission />} />
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/register" element={<Register />} />
|
||||
<Route path="/verify" element={<VerifyEmail />} />
|
||||
<Route path="/forgot" element={<RequestReset />} />
|
||||
<Route path="/reset" element={<ResetPassword />} />
|
||||
<Route
|
||||
path="/account"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<Dashboard />
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/account/keys"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<ApiKeys />
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
<Footer />
|
||||
</div>
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
213
helexa.ai/src/api/account.ts
Normal file
@@ -0,0 +1,213 @@
|
||||
// Account API client over helexa-upstream's /web/v1 (B4/B5). The browser
|
||||
// calls a same-origin `/api` prefix (vite-proxied in dev, nginx-routed in
|
||||
// prod). A MockAccountApi behind VITE_USE_MOCK_ACCOUNT_API lets the
|
||||
// dashboard be built/demoed before the upstream service is reachable.
|
||||
|
||||
import {
|
||||
ApiError,
|
||||
type AccountBalance,
|
||||
type ApiKeySummary,
|
||||
type CreatedKey,
|
||||
type Session,
|
||||
} from "./types";
|
||||
|
||||
export interface AccountApi {
|
||||
register(email: string, password: string, fingerprint?: string): Promise<void>;
|
||||
verify(token: string): Promise<void>;
|
||||
login(email: string, password: string): Promise<Session>;
|
||||
requestReset(email: string): Promise<void>;
|
||||
confirmReset(token: string, newPassword: string): Promise<void>;
|
||||
account(token: string): Promise<AccountBalance>;
|
||||
listKeys(token: string): Promise<ApiKeySummary[]>;
|
||||
createKey(
|
||||
token: string,
|
||||
label: string,
|
||||
limitKind: "percent" | "hardcap",
|
||||
limitValue: number,
|
||||
): Promise<CreatedKey>;
|
||||
archiveKey(token: string, id: string): Promise<void>;
|
||||
updateKeyLimit(
|
||||
token: string,
|
||||
id: string,
|
||||
limitKind: "percent" | "hardcap",
|
||||
limitValue: number,
|
||||
): Promise<void>;
|
||||
redeem(token: string, code: string): Promise<AccountBalance>;
|
||||
}
|
||||
|
||||
const BASE = (import.meta.env.VITE_ACCOUNT_BASE_URL || "/api").replace(/\/$/, "");
|
||||
|
||||
async function call<T>(
|
||||
path: string,
|
||||
init: RequestInit & { token?: string } = {},
|
||||
): Promise<T> {
|
||||
const headers: Record<string, string> = { "content-type": "application/json" };
|
||||
if (init.token) headers.authorization = `Bearer ${init.token}`;
|
||||
let resp: Response;
|
||||
try {
|
||||
resp = await fetch(`${BASE}${path}`, { ...init, headers });
|
||||
} catch {
|
||||
throw new ApiError(0, "network_error", "Could not reach the account service.");
|
||||
}
|
||||
if (resp.status === 204) return undefined as T;
|
||||
let body: unknown = null;
|
||||
try {
|
||||
body = await resp.json();
|
||||
} catch {
|
||||
/* empty body */
|
||||
}
|
||||
if (!resp.ok) {
|
||||
const err = (body as { error?: { code?: string; message?: string } })?.error;
|
||||
throw new ApiError(resp.status, err?.code ?? "error", err?.message ?? "Request failed.");
|
||||
}
|
||||
return body as T;
|
||||
}
|
||||
|
||||
class RealAccountApi implements AccountApi {
|
||||
async register(email: string, password: string, fingerprint?: string) {
|
||||
await call("/register", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ email, password, fingerprint }),
|
||||
});
|
||||
}
|
||||
async verify(token: string) {
|
||||
await call("/verify", { method: "POST", body: JSON.stringify({ token }) });
|
||||
}
|
||||
login(email: string, password: string) {
|
||||
return call<Session>("/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
}
|
||||
async requestReset(email: string) {
|
||||
await call("/password-reset/request", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ email }),
|
||||
});
|
||||
}
|
||||
async confirmReset(token: string, newPassword: string) {
|
||||
await call("/password-reset/confirm", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ token, new_password: newPassword }),
|
||||
});
|
||||
}
|
||||
account(token: string) {
|
||||
return call<AccountBalance>("/account", { token });
|
||||
}
|
||||
listKeys(token: string) {
|
||||
return call<{ keys: ApiKeySummary[] }>("/keys", { token }).then((r) => r.keys);
|
||||
}
|
||||
createKey(token: string, label: string, limit_kind: "percent" | "hardcap", limit_value: number) {
|
||||
return call<CreatedKey>("/keys", {
|
||||
method: "POST",
|
||||
token,
|
||||
body: JSON.stringify({ label, limit_kind, limit_value }),
|
||||
});
|
||||
}
|
||||
async archiveKey(token: string, id: string) {
|
||||
await call(`/keys/${id}/archive`, { method: "POST", token, body: "{}" });
|
||||
}
|
||||
async updateKeyLimit(
|
||||
token: string,
|
||||
id: string,
|
||||
limit_kind: "percent" | "hardcap",
|
||||
limit_value: number,
|
||||
) {
|
||||
await call(`/keys/${id}/limit`, {
|
||||
method: "PATCH",
|
||||
token,
|
||||
body: JSON.stringify({ limit_kind, limit_value }),
|
||||
});
|
||||
}
|
||||
redeem(token: string, code: string) {
|
||||
return call<AccountBalance>("/redeem", {
|
||||
method: "POST",
|
||||
token,
|
||||
body: JSON.stringify({ code }),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Mock (VITE_USE_MOCK_ACCOUNT_API) ────────────────────────────────
|
||||
// Minimal in-memory account so the dashboard is fully developable offline.
|
||||
|
||||
class MockAccountApi implements AccountApi {
|
||||
private total = 1_000_000;
|
||||
private spent = 0;
|
||||
private reserved = 0;
|
||||
private keys: ApiKeySummary[] = [];
|
||||
private seq = 1;
|
||||
|
||||
async register() {}
|
||||
async verify() {}
|
||||
async login(): Promise<Session> {
|
||||
return { token: "mock-token", expires_in: 604800 };
|
||||
}
|
||||
async requestReset() {}
|
||||
async confirmReset() {}
|
||||
async account(): Promise<AccountBalance> {
|
||||
return {
|
||||
account_id: "mock-account",
|
||||
allocation_total: this.total,
|
||||
allocation_spent: this.spent,
|
||||
allocation_reserved: this.reserved,
|
||||
};
|
||||
}
|
||||
async listKeys(): Promise<ApiKeySummary[]> {
|
||||
return [...this.keys];
|
||||
}
|
||||
async createKey(
|
||||
_t: string,
|
||||
label: string,
|
||||
limit_kind: "percent" | "hardcap",
|
||||
limit_value: number,
|
||||
): Promise<CreatedKey> {
|
||||
const id = `mock-${this.seq++}`;
|
||||
const prefix = `sk-helexa-mock${this.seq}`;
|
||||
this.keys.push({
|
||||
id,
|
||||
prefix,
|
||||
label,
|
||||
status: "active",
|
||||
limit_kind,
|
||||
limit_value,
|
||||
spent: 0,
|
||||
reserved: 0,
|
||||
created_at: new Date().toISOString(),
|
||||
});
|
||||
return { id, key: `${prefix}-RAWSECRETSHOWNONCE`, prefix, limit_kind, limit_value };
|
||||
}
|
||||
async archiveKey(_t: string, id: string) {
|
||||
const k = this.keys.find((x) => x.id === id);
|
||||
if (k) k.status = "archived";
|
||||
}
|
||||
async updateKeyLimit(
|
||||
_t: string,
|
||||
id: string,
|
||||
limit_kind: "percent" | "hardcap",
|
||||
limit_value: number,
|
||||
) {
|
||||
const k = this.keys.find((x) => x.id === id);
|
||||
if (k) {
|
||||
k.limit_kind = limit_kind;
|
||||
k.limit_value = limit_value;
|
||||
}
|
||||
}
|
||||
async redeem(_t: string, code: string): Promise<AccountBalance> {
|
||||
if (!code.startsWith("helexa-topup-")) {
|
||||
throw new ApiError(400, "bad_request", "invalid or already-redeemed code");
|
||||
}
|
||||
this.total += 500_000;
|
||||
return this.account();
|
||||
}
|
||||
}
|
||||
|
||||
let instance: AccountApi | null = null;
|
||||
export function accountApi(): AccountApi {
|
||||
if (!instance) {
|
||||
instance = import.meta.env.VITE_USE_MOCK_ACCOUNT_API
|
||||
? new MockAccountApi()
|
||||
: new RealAccountApi();
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
45
helexa.ai/src/api/types.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
// Wire types for the helexa-upstream /web/v1 account API (B4/B5).
|
||||
|
||||
export interface ApiKeySummary {
|
||||
id: string;
|
||||
prefix: string;
|
||||
label: string;
|
||||
status: "active" | "archived";
|
||||
limit_kind: "percent" | "hardcap";
|
||||
limit_value: number;
|
||||
spent: number;
|
||||
reserved: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface CreatedKey {
|
||||
id: string;
|
||||
/** Raw secret — shown exactly once at creation. */
|
||||
key: string;
|
||||
prefix: string;
|
||||
limit_kind: "percent" | "hardcap";
|
||||
limit_value: number;
|
||||
}
|
||||
|
||||
export interface AccountBalance {
|
||||
account_id: string;
|
||||
allocation_total: number;
|
||||
allocation_spent: number;
|
||||
allocation_reserved: number;
|
||||
}
|
||||
|
||||
export interface Session {
|
||||
token: string;
|
||||
expires_in: number;
|
||||
}
|
||||
|
||||
/** Typed error carrying the backend's machine-readable code. */
|
||||
export class ApiError extends Error {
|
||||
code: string;
|
||||
status: number;
|
||||
constructor(status: number, code: string, message: string) {
|
||||
super(message);
|
||||
this.code = code;
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
76
helexa.ai/src/auth/AuthProvider.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { accountApi } from "../api/account";
|
||||
import { claimAnonymousData } from "../data/repositories";
|
||||
import { getFingerprint } from "../lib/fingerprint";
|
||||
import { AuthContext } from "./context";
|
||||
|
||||
const TOKEN_KEY = "helexa.token";
|
||||
const EMAIL_KEY = "helexa.email";
|
||||
|
||||
export default function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [token, setToken] = useState<string | null>(() =>
|
||||
localStorage.getItem(TOKEN_KEY),
|
||||
);
|
||||
const [email, setEmail] = useState<string | null>(() =>
|
||||
localStorage.getItem(EMAIL_KEY),
|
||||
);
|
||||
const [accountId, setAccountId] = useState<string | null>(null);
|
||||
|
||||
// Resolve the account id for an existing session (page reload) so the chat
|
||||
// workspace can scope its IndexedDB owner without a fresh login.
|
||||
useEffect(() => {
|
||||
if (!token || accountId) return;
|
||||
accountApi()
|
||||
.account(token)
|
||||
.then((a) => setAccountId(a.account_id))
|
||||
.catch(() => {
|
||||
/* token may be stale; chat falls back to anon until re-login */
|
||||
});
|
||||
}, [token, accountId]);
|
||||
|
||||
async function login(em: string, password: string): Promise<void> {
|
||||
const api = accountApi();
|
||||
const session = await api.login(em, password);
|
||||
localStorage.setItem(TOKEN_KEY, session.token);
|
||||
localStorage.setItem(EMAIL_KEY, em);
|
||||
setToken(session.token);
|
||||
setEmail(em);
|
||||
// Claim anonymous local history into the account (stays client-side).
|
||||
try {
|
||||
const acct = await api.account(session.token);
|
||||
setAccountId(acct.account_id);
|
||||
await claimAnonymousData(acct.account_id);
|
||||
} catch {
|
||||
/* non-fatal */
|
||||
}
|
||||
}
|
||||
|
||||
async function register(em: string, password: string): Promise<void> {
|
||||
const fingerprint = await getFingerprint();
|
||||
await accountApi().register(em, password, fingerprint);
|
||||
}
|
||||
|
||||
function logout(): void {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
localStorage.removeItem(EMAIL_KEY);
|
||||
setToken(null);
|
||||
setEmail(null);
|
||||
setAccountId(null);
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthContext.Provider
|
||||
value={{
|
||||
token,
|
||||
email,
|
||||
accountId,
|
||||
status: token ? "authed" : "anon",
|
||||
login,
|
||||
register,
|
||||
logout,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
14
helexa.ai/src/auth/RequireAuth.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { type ReactNode } from "react";
|
||||
import { Navigate, useLocation } from "react-router-dom";
|
||||
import { useAuth } from "./context";
|
||||
|
||||
/** Route guard: redirect unauthenticated users to /login?next=…. */
|
||||
export default function RequireAuth({ children }: { children: ReactNode }) {
|
||||
const { status } = useAuth();
|
||||
const location = useLocation();
|
||||
if (status !== "authed") {
|
||||
const next = encodeURIComponent(location.pathname + location.search);
|
||||
return <Navigate to={`/login?next=${next}`} replace />;
|
||||
}
|
||||
return <>{children}</>;
|
||||
}
|
||||
26
helexa.ai/src/auth/context.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { createContext, useContext } from "react";
|
||||
|
||||
export interface AuthContextValue {
|
||||
token: string | null;
|
||||
email: string | null;
|
||||
/** The signed-in account id (for the Dexie owner + usage queries). */
|
||||
accountId: string | null;
|
||||
status: "anon" | "authed";
|
||||
login: (email: string, password: string) => Promise<void>;
|
||||
register: (email: string, password: string) => Promise<void>;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
export const AuthContext = createContext<AuthContextValue>({
|
||||
token: null,
|
||||
email: null,
|
||||
accountId: null,
|
||||
status: "anon",
|
||||
login: async () => {},
|
||||
register: async () => {},
|
||||
logout: () => {},
|
||||
});
|
||||
|
||||
export function useAuth(): AuthContextValue {
|
||||
return useContext(AuthContext);
|
||||
}
|
||||
35
helexa.ai/src/components/BetaBanner.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
/**
|
||||
* Slim public-beta notice shown above the header when VITE_PUBLIC_BETA is
|
||||
* set. Dismissible for the session (sessionStorage) so it doesn't nag, but
|
||||
* returns on the next visit while the beta lasts.
|
||||
*/
|
||||
const SHOWN = import.meta.env.VITE_PUBLIC_BETA === "true";
|
||||
const DISMISS_KEY = "helexa.betaDismissed";
|
||||
|
||||
export default function BetaBanner() {
|
||||
const { t } = useTranslation("common");
|
||||
const [hidden, setHidden] = useState(
|
||||
() => sessionStorage.getItem(DISMISS_KEY) === "1",
|
||||
);
|
||||
if (!SHOWN || hidden) return null;
|
||||
return (
|
||||
<div className="beta-banner d-flex align-items-center justify-content-center gap-2 px-3 py-1 small">
|
||||
<span>
|
||||
<strong>{t("beta.tag")}</strong> {t("beta.message")}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-close btn-close-white ms-2"
|
||||
style={{ fontSize: "0.6rem" }}
|
||||
aria-label={t("beta.dismiss")}
|
||||
onClick={() => {
|
||||
sessionStorage.setItem(DISMISS_KEY, "1");
|
||||
setHidden(true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
127
helexa.ai/src/components/DirectionalIcon.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { isRtlLanguage, type LanguageCode } from "../i18n/languages";
|
||||
|
||||
export type Direction = "forward" | "back";
|
||||
|
||||
/**
|
||||
* DirectionalIcon
|
||||
*
|
||||
* Small helper component to render direction-aware icons that respect
|
||||
* the current UI writing direction (LTR vs RTL).
|
||||
*
|
||||
* Usage example:
|
||||
*
|
||||
* <DirectionalIcon
|
||||
* direction="forward"
|
||||
* ltrIcon={FaArrowRight}
|
||||
* rtlIcon={FaArrowLeft}
|
||||
* />
|
||||
*
|
||||
* - `direction="forward"` means “toward the natural reading direction”
|
||||
* (right in LTR, left in RTL).
|
||||
* - `direction="back"` means the opposite (left in LTR, right in RTL).
|
||||
*
|
||||
* You can either:
|
||||
* - pass explicit `ltrIcon` and `rtlIcon` React components, or
|
||||
* - pass a single `icon` component and set `mirrorInRtl` to flip it
|
||||
* horizontally when in RTL (via CSS transform).
|
||||
*
|
||||
* In most cases, using explicit LTR / RTL icons is clearer and avoids
|
||||
* surprises with asymmetric icon shapes.
|
||||
*/
|
||||
export interface DirectionalIconProps {
|
||||
/**
|
||||
* Logical direction relative to reading order.
|
||||
* - "forward": in the direction of the text flow
|
||||
* - "back": opposite the direction of the text flow
|
||||
*/
|
||||
direction: Direction;
|
||||
|
||||
/**
|
||||
* Icon component to use for LTR contexts (e.g. FaArrowRight).
|
||||
*/
|
||||
ltrIcon?: React.ComponentType<{ size?: number | string; className?: string }>;
|
||||
|
||||
/**
|
||||
* Icon component to use for RTL contexts (e.g. FaArrowLeft).
|
||||
*/
|
||||
rtlIcon?: React.ComponentType<{ size?: number | string; className?: string }>;
|
||||
|
||||
/**
|
||||
* Single base icon component. When provided together with
|
||||
* `mirrorInRtl={true}`, it will be mirrored horizontally in RTL.
|
||||
*/
|
||||
icon?: React.ComponentType<{ size?: number | string; className?: string }>;
|
||||
|
||||
/**
|
||||
* Whether to flip the `icon` horizontally in RTL.
|
||||
* Ignored if both `ltrIcon` and `rtlIcon` are supplied.
|
||||
*/
|
||||
mirrorInRtl?: boolean;
|
||||
|
||||
/**
|
||||
* Optional size forwarded to the rendered icon.
|
||||
*/
|
||||
size?: number | string;
|
||||
|
||||
/**
|
||||
* Additional className to apply to the rendered icon.
|
||||
*/
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if current language is RTL based on i18next language code.
|
||||
*
|
||||
* Delegates to the shared `isRtlLanguage` helper from i18n/languages.ts
|
||||
* so that all RTL logic lives in one place.
|
||||
*/
|
||||
const isRtlLanguageCode = (code: string | undefined | null): boolean => {
|
||||
if (!code) return false;
|
||||
const lang = code.split("-")[0].toLowerCase() as LanguageCode;
|
||||
return isRtlLanguage(lang);
|
||||
};
|
||||
|
||||
const DirectionalIcon: React.FC<DirectionalIconProps> = ({
|
||||
direction,
|
||||
ltrIcon: LtrIcon,
|
||||
rtlIcon: RtlIcon,
|
||||
icon: BaseIcon,
|
||||
mirrorInRtl = false,
|
||||
size,
|
||||
className,
|
||||
}) => {
|
||||
const { i18n } = useTranslation();
|
||||
const isRtl = isRtlLanguageCode(i18n.language);
|
||||
|
||||
// If explicit LTR/RTL icons are provided, prefer those.
|
||||
if (LtrIcon && RtlIcon) {
|
||||
const IconComponent =
|
||||
(direction === "forward" && !isRtl) || (direction === "back" && isRtl)
|
||||
? LtrIcon
|
||||
: RtlIcon;
|
||||
|
||||
return <IconComponent size={size} className={className} />;
|
||||
}
|
||||
|
||||
// Fallback: single base icon, optionally mirrored in RTL.
|
||||
if (!BaseIcon) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const shouldMirror =
|
||||
mirrorInRtl &&
|
||||
((direction === "forward" && isRtl) || (direction === "back" && !isRtl));
|
||||
|
||||
const combinedClassName = [
|
||||
className,
|
||||
shouldMirror ? "diricon-mirror-rtl" : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
return <BaseIcon size={size} className={combinedClassName} />;
|
||||
};
|
||||
|
||||
export default DirectionalIcon;
|
||||
23
helexa.ai/src/components/Footer.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
/**
|
||||
* Footer
|
||||
*
|
||||
* Simple application footer used in the main layout.
|
||||
* Renders a subtle, theme-aware bar with copyright text.
|
||||
*/
|
||||
const Footer: React.FC = () => {
|
||||
const year = new Date().getFullYear();
|
||||
const { t } = useTranslation("common");
|
||||
|
||||
return (
|
||||
<footer className="app-footer border-top py-3 mt-auto">
|
||||
<div className="container-fluid text-center text-muted small">
|
||||
<span>{t("footer.copyright", { year })}</span>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
};
|
||||
|
||||
export default Footer;
|
||||
160
helexa.ai/src/components/Header.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
import React from "react";
|
||||
import { Link, NavLink } from "react-router-dom";
|
||||
import { Navbar, Container, Nav, Button, Dropdown } from "react-bootstrap";
|
||||
import { FaRegMoon, FaRegSun } from "react-icons/fa6";
|
||||
import { useTheme } from "../layout/theme";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AUTONYM_MAP, type LanguageCode, isRtlLanguage } from "../i18n/languages";
|
||||
import { getLanguageOptionsByUsage } from "../i18n/translation-priority";
|
||||
import { useAuth } from "../auth/context";
|
||||
|
||||
/**
|
||||
* Top navigation: brand, primary routes (chat at `/`, `/mission`), an
|
||||
* auth-aware cluster (Account/Sign out when signed in, else Sign in/up),
|
||||
* the theme toggle, and the language selector.
|
||||
*
|
||||
* The language picker is ordered by **estimated usage**
|
||||
* (getLanguageOptionsByUsage), not alphabetically — a deliberate choice that
|
||||
* foregrounds helexa's international grounding. Each item shows the autonym
|
||||
* (language in its own script) plus a secondary label in the current
|
||||
* language; RTL-aware alignment.
|
||||
*/
|
||||
const Header: React.FC = () => {
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
const { t, i18n } = useTranslation("common");
|
||||
const { status, logout } = useAuth();
|
||||
|
||||
const currentLanguage: LanguageCode = (i18n.language.split("-")[0] ||
|
||||
"en") as LanguageCode;
|
||||
const isRtl = isRtlLanguage(currentLanguage);
|
||||
const languageOptions = getLanguageOptionsByUsage();
|
||||
|
||||
return (
|
||||
<Navbar
|
||||
expand="lg"
|
||||
className="app-header border-bottom"
|
||||
variant={theme === "dark" ? "dark" : "light"}
|
||||
>
|
||||
<Container fluid>
|
||||
<Navbar.Brand
|
||||
as={Link}
|
||||
to="/"
|
||||
className="d-flex align-items-center gap-2"
|
||||
>
|
||||
<img
|
||||
src="/logo.png"
|
||||
alt="helexa logo"
|
||||
width={28}
|
||||
height={28}
|
||||
style={{ borderRadius: "999px" }}
|
||||
/>
|
||||
<span className="fw-semibold text-uppercase small tracking-wide">
|
||||
{t("app.name")}
|
||||
</span>
|
||||
</Navbar.Brand>
|
||||
|
||||
<Navbar.Toggle aria-controls="main-navbar" />
|
||||
|
||||
<Navbar.Collapse id="main-navbar">
|
||||
<Nav className="me-auto">
|
||||
<NavLink
|
||||
to="/"
|
||||
end
|
||||
className={({ isActive }): string =>
|
||||
isActive ? "nav-link active" : "nav-link"
|
||||
}
|
||||
>
|
||||
{t("nav.chat")}
|
||||
</NavLink>
|
||||
<NavLink
|
||||
to="/mission"
|
||||
className={({ isActive }): string =>
|
||||
isActive ? "nav-link active" : "nav-link"
|
||||
}
|
||||
>
|
||||
{t("nav.mission")}
|
||||
</NavLink>
|
||||
</Nav>
|
||||
|
||||
<div className="d-flex align-items-center gap-2">
|
||||
{/* Auth-aware cluster. */}
|
||||
{status === "authed" ? (
|
||||
<>
|
||||
<NavLink to="/account" className="nav-link">
|
||||
{t("nav.account")}
|
||||
</NavLink>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline-secondary"
|
||||
onClick={logout}
|
||||
className="me-1"
|
||||
>
|
||||
{t("nav.logout")}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<NavLink to="/login" className="nav-link">
|
||||
{t("nav.login")}
|
||||
</NavLink>
|
||||
<NavLink to="/register" className="nav-link">
|
||||
{t("nav.register")}
|
||||
</NavLink>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline-secondary"
|
||||
type="button"
|
||||
onClick={toggleTheme}
|
||||
aria-label={
|
||||
theme === "dark"
|
||||
? t("theme.toggle.toLight")
|
||||
: t("theme.toggle.toDark")
|
||||
}
|
||||
className="d-inline-flex align-items-center justify-content-center"
|
||||
>
|
||||
{theme === "dark" ? <FaRegSun size={16} /> : <FaRegMoon size={16} />}
|
||||
</Button>
|
||||
|
||||
<Dropdown
|
||||
align={isRtl ? "start" : "end"}
|
||||
className={theme === "dark" ? "dropdown-menu-dark-context" : ""}
|
||||
>
|
||||
<Dropdown.Toggle
|
||||
size="sm"
|
||||
variant={theme === "dark" ? "secondary" : "outline-secondary"}
|
||||
id="language-switcher"
|
||||
>
|
||||
<span className="me-1" aria-hidden="true">
|
||||
文A
|
||||
</span>
|
||||
<span>{AUTONYM_MAP[currentLanguage]}</span>
|
||||
</Dropdown.Toggle>
|
||||
<Dropdown.Menu
|
||||
className={theme === "dark" ? "dropdown-menu-dark" : ""}
|
||||
>
|
||||
{languageOptions.map(({ code, autonym }) => (
|
||||
<Dropdown.Item
|
||||
key={code}
|
||||
active={code === currentLanguage}
|
||||
onClick={() => void i18n.changeLanguage(code)}
|
||||
className="d-flex align-items-center gap-2"
|
||||
>
|
||||
<span>{autonym}</span>
|
||||
<span className="text-muted small fw-light">
|
||||
· {t(`lang.${code}`)}
|
||||
</span>
|
||||
</Dropdown.Item>
|
||||
))}
|
||||
</Dropdown.Menu>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</Navbar.Collapse>
|
||||
</Container>
|
||||
</Navbar>
|
||||
);
|
||||
};
|
||||
|
||||
export default Header;
|
||||
72
helexa.ai/src/data/db.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
// IndexedDB (Dexie) — the ONLY home for chat history and project
|
||||
// organisation. Nothing here is ever sent to a server (#69/#F3): the mesh
|
||||
// serves inference, but conversations live exclusively in the browser.
|
||||
//
|
||||
// `owner` namespaces data: `"anon"` for the fingerprinted anonymous visitor,
|
||||
// or an account id once signed in. On login, anonymous data can be claimed
|
||||
// into the account (F4) — still purely client-side.
|
||||
|
||||
import Dexie, { type Table } from "dexie";
|
||||
|
||||
export interface Project {
|
||||
id: string;
|
||||
owner: string;
|
||||
name: string;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
archived: boolean;
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
export interface Conversation {
|
||||
id: string;
|
||||
owner: string;
|
||||
projectId: string | null; // null → "Unsorted"
|
||||
title: string;
|
||||
model: string;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
pinned: boolean;
|
||||
}
|
||||
|
||||
export type MessageRole = "system" | "user" | "assistant";
|
||||
export type MessageStatus = "complete" | "streaming" | "error";
|
||||
|
||||
export interface Message {
|
||||
id: string;
|
||||
conversationId: string;
|
||||
role: MessageRole;
|
||||
content: string;
|
||||
createdAt: number;
|
||||
status: MessageStatus;
|
||||
errorCode?: string;
|
||||
promptTokens?: number;
|
||||
completionTokens?: number;
|
||||
}
|
||||
|
||||
/** Small key/value store: fingerprint, active conversation, anon usage. */
|
||||
export interface Meta {
|
||||
key: string;
|
||||
value: unknown;
|
||||
}
|
||||
|
||||
class HelexaDB extends Dexie {
|
||||
projects!: Table<Project, string>;
|
||||
conversations!: Table<Conversation, string>;
|
||||
messages!: Table<Message, string>;
|
||||
meta!: Table<Meta, string>;
|
||||
|
||||
constructor() {
|
||||
super("helexa");
|
||||
this.version(1).stores({
|
||||
// Indexes only — Dexie stores the whole object. Compound indexes
|
||||
// drive the common queries (by owner, by conversation in time order).
|
||||
projects: "id, owner, [owner+archived], updatedAt",
|
||||
conversations: "id, owner, projectId, [owner+projectId], updatedAt",
|
||||
messages: "id, conversationId, [conversationId+createdAt]",
|
||||
meta: "key",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const db = new HelexaDB();
|
||||
154
helexa.ai/src/data/repositories.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
// Typed CRUD + queries over the Dexie store. UI components use the
|
||||
// `useLiveQuery` hook (dexie-react-hooks) with the list helpers here so the
|
||||
// sidebar/thread react to writes automatically.
|
||||
|
||||
import Dexie from "dexie";
|
||||
import {
|
||||
db,
|
||||
type Conversation,
|
||||
type Message,
|
||||
type MessageRole,
|
||||
type Project,
|
||||
} from "./db";
|
||||
|
||||
function uuid(): string {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
function now(): number {
|
||||
return Date.now();
|
||||
}
|
||||
|
||||
// ── projects ────────────────────────────────────────────────────────
|
||||
|
||||
export async function listProjects(owner: string): Promise<Project[]> {
|
||||
const rows = await db.projects.where({ owner }).toArray();
|
||||
return rows
|
||||
.filter((p) => !p.archived)
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder || a.createdAt - b.createdAt);
|
||||
}
|
||||
|
||||
export async function createProject(owner: string, name: string): Promise<string> {
|
||||
const id = uuid();
|
||||
const ts = now();
|
||||
await db.projects.add({
|
||||
id,
|
||||
owner,
|
||||
name,
|
||||
createdAt: ts,
|
||||
updatedAt: ts,
|
||||
archived: false,
|
||||
sortOrder: ts,
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
export async function renameProject(id: string, name: string): Promise<void> {
|
||||
await db.projects.update(id, { name, updatedAt: now() });
|
||||
}
|
||||
|
||||
export async function archiveProject(id: string): Promise<void> {
|
||||
// Detach its conversations to "Unsorted" so nothing is orphaned.
|
||||
await db.transaction("rw", db.projects, db.conversations, async () => {
|
||||
await db.projects.update(id, { archived: true, updatedAt: now() });
|
||||
const convs = await db.conversations.where({ projectId: id }).toArray();
|
||||
await Promise.all(
|
||||
convs.map((c) => db.conversations.update(c.id, { projectId: null })),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// ── conversations ───────────────────────────────────────────────────
|
||||
|
||||
export async function listConversations(owner: string): Promise<Conversation[]> {
|
||||
const rows = await db.conversations.where({ owner }).toArray();
|
||||
return rows.sort(
|
||||
(a, b) => Number(b.pinned) - Number(a.pinned) || b.updatedAt - a.updatedAt,
|
||||
);
|
||||
}
|
||||
|
||||
export async function createConversation(
|
||||
owner: string,
|
||||
model: string,
|
||||
projectId: string | null = null,
|
||||
title = "New chat",
|
||||
): Promise<string> {
|
||||
const id = uuid();
|
||||
const ts = now();
|
||||
await db.conversations.add({
|
||||
id,
|
||||
owner,
|
||||
projectId,
|
||||
title,
|
||||
model,
|
||||
createdAt: ts,
|
||||
updatedAt: ts,
|
||||
pinned: false,
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
export async function renameConversation(id: string, title: string): Promise<void> {
|
||||
await db.conversations.update(id, { title, updatedAt: now() });
|
||||
}
|
||||
|
||||
export async function moveConversation(
|
||||
id: string,
|
||||
projectId: string | null,
|
||||
): Promise<void> {
|
||||
await db.conversations.update(id, { projectId, updatedAt: now() });
|
||||
}
|
||||
|
||||
export async function deleteConversation(id: string): Promise<void> {
|
||||
await db.transaction("rw", db.conversations, db.messages, async () => {
|
||||
await db.messages.where({ conversationId: id }).delete();
|
||||
await db.conversations.delete(id);
|
||||
});
|
||||
}
|
||||
|
||||
// ── messages ────────────────────────────────────────────────────────
|
||||
|
||||
export async function listMessages(conversationId: string): Promise<Message[]> {
|
||||
return db.messages
|
||||
.where("[conversationId+createdAt]")
|
||||
.between([conversationId, Dexie.minKey], [conversationId, Dexie.maxKey])
|
||||
.toArray();
|
||||
}
|
||||
|
||||
export async function addMessage(
|
||||
conversationId: string,
|
||||
role: MessageRole,
|
||||
content: string,
|
||||
status: Message["status"] = "complete",
|
||||
): Promise<string> {
|
||||
const id = uuid();
|
||||
await db.messages.add({ id, conversationId, role, content, createdAt: now(), status });
|
||||
await db.conversations.update(conversationId, { updatedAt: now() });
|
||||
return id;
|
||||
}
|
||||
|
||||
export async function appendToMessage(id: string, delta: string): Promise<void> {
|
||||
const msg = await db.messages.get(id);
|
||||
if (!msg) return;
|
||||
await db.messages.update(id, { content: msg.content + delta });
|
||||
}
|
||||
|
||||
export async function finalizeMessage(
|
||||
id: string,
|
||||
patch: Partial<Pick<Message, "status" | "errorCode" | "promptTokens" | "completionTokens">>,
|
||||
): Promise<void> {
|
||||
await db.messages.update(id, patch);
|
||||
}
|
||||
|
||||
/** Rewrite all `anon` data to `accountId` on first login (stays local). */
|
||||
export async function claimAnonymousData(accountId: string): Promise<void> {
|
||||
await db.transaction("rw", db.projects, db.conversations, async () => {
|
||||
const projects = await db.projects.where({ owner: "anon" }).toArray();
|
||||
await Promise.all(
|
||||
projects.map((p) => db.projects.update(p.id, { owner: accountId })),
|
||||
);
|
||||
const convs = await db.conversations.where({ owner: "anon" }).toArray();
|
||||
await Promise.all(
|
||||
convs.map((c) => db.conversations.update(c.id, { owner: accountId })),
|
||||
);
|
||||
});
|
||||
}
|
||||
424
helexa.ai/src/i18n/index.ts
Normal file
@@ -0,0 +1,424 @@
|
||||
import i18n, { type Resource } from "i18next";
|
||||
import { initReactI18next } from "react-i18next";
|
||||
import {
|
||||
SUPPORTED_LANGUAGES,
|
||||
normalizeLocaleToLanguage,
|
||||
isRtlLanguage,
|
||||
} from "./languages";
|
||||
import type { LanguageCode } from "./languages";
|
||||
|
||||
// Core languages
|
||||
import enCommon from "./resources/en/common.json";
|
||||
import ruCommon from "./resources/ru/common.json";
|
||||
import enMission from "./resources/en/mission.json";
|
||||
import ruMission from "./resources/ru/mission.json";
|
||||
import enChat from "./resources/en/chat.json";
|
||||
import enAccount from "./resources/en/account.json";
|
||||
import ruChat from "./resources/ru/chat.json";
|
||||
import ruAccount from "./resources/ru/account.json";
|
||||
|
||||
// Scandinavian & Nordic languages
|
||||
import daCommon from "./resources/da/common.json";
|
||||
import daMission from "./resources/da/mission.json";
|
||||
import daChat from "./resources/da/chat.json";
|
||||
import daAccount from "./resources/da/account.json";
|
||||
|
||||
import fiCommon from "./resources/fi/common.json";
|
||||
import fiMission from "./resources/fi/mission.json";
|
||||
import fiChat from "./resources/fi/chat.json";
|
||||
import fiAccount from "./resources/fi/account.json";
|
||||
|
||||
import noCommon from "./resources/no/common.json";
|
||||
import noMission from "./resources/no/mission.json";
|
||||
import noChat from "./resources/no/chat.json";
|
||||
import noAccount from "./resources/no/account.json";
|
||||
|
||||
import svCommon from "./resources/sv/common.json";
|
||||
import svMission from "./resources/sv/mission.json";
|
||||
import svChat from "./resources/sv/chat.json";
|
||||
import svAccount from "./resources/sv/account.json";
|
||||
|
||||
import bgCommon from "./resources/bg/common.json";
|
||||
import bgMission from "./resources/bg/mission.json";
|
||||
import bgChat from "./resources/bg/chat.json";
|
||||
import bgAccount from "./resources/bg/account.json";
|
||||
|
||||
import etCommon from "./resources/et/common.json";
|
||||
import etMission from "./resources/et/mission.json";
|
||||
import etChat from "./resources/et/chat.json";
|
||||
import etAccount from "./resources/et/account.json";
|
||||
|
||||
// African & MENA languages
|
||||
import swCommon from "./resources/sw/common.json";
|
||||
import swMission from "./resources/sw/mission.json";
|
||||
import swChat from "./resources/sw/chat.json";
|
||||
import swAccount from "./resources/sw/account.json";
|
||||
|
||||
import arCommon from "./resources/ar/common.json";
|
||||
import arMission from "./resources/ar/mission.json";
|
||||
import arChat from "./resources/ar/chat.json";
|
||||
import arAccount from "./resources/ar/account.json";
|
||||
|
||||
import faCommon from "./resources/fa/common.json";
|
||||
import faMission from "./resources/fa/mission.json";
|
||||
import faChat from "./resources/fa/chat.json";
|
||||
import faAccount from "./resources/fa/account.json";
|
||||
|
||||
import haCommon from "./resources/ha/common.json";
|
||||
import haMission from "./resources/ha/mission.json";
|
||||
import haChat from "./resources/ha/chat.json";
|
||||
import haAccount from "./resources/ha/account.json";
|
||||
|
||||
import amCommon from "./resources/am/common.json";
|
||||
import amMission from "./resources/am/mission.json";
|
||||
import amChat from "./resources/am/chat.json";
|
||||
import amAccount from "./resources/am/account.json";
|
||||
|
||||
import yoCommon from "./resources/yo/common.json";
|
||||
import yoMission from "./resources/yo/mission.json";
|
||||
import yoChat from "./resources/yo/chat.json";
|
||||
import yoAccount from "./resources/yo/account.json";
|
||||
|
||||
import zuCommon from "./resources/zu/common.json";
|
||||
import zuMission from "./resources/zu/mission.json";
|
||||
import zuChat from "./resources/zu/chat.json";
|
||||
import zuAccount from "./resources/zu/account.json";
|
||||
|
||||
// Darija (Moroccan Arabic)
|
||||
import maCommon from "./resources/ma/common.json";
|
||||
import maMission from "./resources/ma/mission.json";
|
||||
import maChat from "./resources/ma/chat.json";
|
||||
import maAccount from "./resources/ma/account.json";
|
||||
|
||||
// European / other languages
|
||||
import esCommon from "./resources/es/common.json";
|
||||
import esMission from "./resources/es/mission.json";
|
||||
import esChat from "./resources/es/chat.json";
|
||||
import esAccount from "./resources/es/account.json";
|
||||
|
||||
import frCommon from "./resources/fr/common.json";
|
||||
import frMission from "./resources/fr/mission.json";
|
||||
import frChat from "./resources/fr/chat.json";
|
||||
import frAccount from "./resources/fr/account.json";
|
||||
|
||||
import deCommon from "./resources/de/common.json";
|
||||
import deMission from "./resources/de/mission.json";
|
||||
import deChat from "./resources/de/chat.json";
|
||||
import deAccount from "./resources/de/account.json";
|
||||
|
||||
import elCommon from "./resources/el/common.json";
|
||||
import elMission from "./resources/el/mission.json";
|
||||
import elChat from "./resources/el/chat.json";
|
||||
import elAccount from "./resources/el/account.json";
|
||||
|
||||
import itCommon from "./resources/it/common.json";
|
||||
import itMission from "./resources/it/mission.json";
|
||||
import itChat from "./resources/it/chat.json";
|
||||
import itAccount from "./resources/it/account.json";
|
||||
|
||||
import heCommon from "./resources/he/common.json";
|
||||
import heMission from "./resources/he/mission.json";
|
||||
import heChat from "./resources/he/chat.json";
|
||||
import heAccount from "./resources/he/account.json";
|
||||
|
||||
import ptCommon from "./resources/pt/common.json";
|
||||
import ptMission from "./resources/pt/mission.json";
|
||||
import ptChat from "./resources/pt/chat.json";
|
||||
import ptAccount from "./resources/pt/account.json";
|
||||
|
||||
import roCommon from "./resources/ro/common.json";
|
||||
import roMission from "./resources/ro/mission.json";
|
||||
import roChat from "./resources/ro/chat.json";
|
||||
import roAccount from "./resources/ro/account.json";
|
||||
|
||||
import kaCommon from "./resources/ka/common.json";
|
||||
import kaMission from "./resources/ka/mission.json";
|
||||
import kaChat from "./resources/ka/chat.json";
|
||||
import kaAccount from "./resources/ka/account.json";
|
||||
|
||||
import trCommon from "./resources/tr/common.json";
|
||||
import trMission from "./resources/tr/mission.json";
|
||||
import trChat from "./resources/tr/chat.json";
|
||||
import trAccount from "./resources/tr/account.json";
|
||||
|
||||
import plCommon from "./resources/pl/common.json";
|
||||
import plMission from "./resources/pl/mission.json";
|
||||
import plChat from "./resources/pl/chat.json";
|
||||
import plAccount from "./resources/pl/account.json";
|
||||
|
||||
import ukCommon from "./resources/uk/common.json";
|
||||
import ukMission from "./resources/uk/mission.json";
|
||||
import ukChat from "./resources/uk/chat.json";
|
||||
import ukAccount from "./resources/uk/account.json";
|
||||
|
||||
import nlCommon from "./resources/nl/common.json";
|
||||
import nlMission from "./resources/nl/mission.json";
|
||||
import nlChat from "./resources/nl/chat.json";
|
||||
import nlAccount from "./resources/nl/account.json";
|
||||
|
||||
import srCommon from "./resources/sr/common.json";
|
||||
import srMission from "./resources/sr/mission.json";
|
||||
import srChat from "./resources/sr/chat.json";
|
||||
import srAccount from "./resources/sr/account.json";
|
||||
|
||||
import kkCommon from "./resources/kk/common.json";
|
||||
import kkMission from "./resources/kk/mission.json";
|
||||
import kkChat from "./resources/kk/chat.json";
|
||||
import kkAccount from "./resources/kk/account.json";
|
||||
|
||||
import uzCommon from "./resources/uz/common.json";
|
||||
import uzMission from "./resources/uz/mission.json";
|
||||
import uzChat from "./resources/uz/chat.json";
|
||||
import uzAccount from "./resources/uz/account.json";
|
||||
|
||||
/**
|
||||
* Application translation resources, split by language and namespace.
|
||||
*
|
||||
* - `common`: shared UI elements (navigation, theme toggle, etc.)
|
||||
* - `home`: marketing / narrative copy on the landing page
|
||||
* - `chat`: copy for the chat workspace
|
||||
*/
|
||||
const resources: Resource = {
|
||||
en: {
|
||||
common: enCommon,
|
||||
mission: enMission,
|
||||
chat: enChat,
|
||||
account: enAccount,
|
||||
},
|
||||
ru: {
|
||||
common: ruCommon,
|
||||
mission: ruMission,
|
||||
chat: ruChat,
|
||||
account: ruAccount,
|
||||
},
|
||||
bg: {
|
||||
common: bgCommon,
|
||||
mission: bgMission,
|
||||
chat: bgChat,
|
||||
account: bgAccount,
|
||||
},
|
||||
da: {
|
||||
common: daCommon,
|
||||
mission: daMission,
|
||||
chat: daChat,
|
||||
account: daAccount,
|
||||
},
|
||||
et: {
|
||||
common: etCommon,
|
||||
mission: etMission,
|
||||
chat: etChat,
|
||||
account: etAccount,
|
||||
},
|
||||
fi: {
|
||||
common: fiCommon,
|
||||
mission: fiMission,
|
||||
chat: fiChat,
|
||||
account: fiAccount,
|
||||
},
|
||||
kk: {
|
||||
common: kkCommon,
|
||||
mission: kkMission,
|
||||
chat: kkChat,
|
||||
account: kkAccount,
|
||||
},
|
||||
uz: {
|
||||
common: uzCommon,
|
||||
mission: uzMission,
|
||||
chat: uzChat,
|
||||
account: uzAccount,
|
||||
},
|
||||
|
||||
// African & MENA languages (LTR unless marked RTL via isRtlLanguage)
|
||||
sw: {
|
||||
common: swCommon,
|
||||
mission: swMission,
|
||||
chat: swChat,
|
||||
account: swAccount,
|
||||
},
|
||||
ar: {
|
||||
common: arCommon,
|
||||
mission: arMission,
|
||||
chat: arChat,
|
||||
account: arAccount,
|
||||
},
|
||||
fa: {
|
||||
common: faCommon,
|
||||
mission: faMission,
|
||||
chat: faChat,
|
||||
account: faAccount,
|
||||
},
|
||||
ha: {
|
||||
common: haCommon,
|
||||
mission: haMission,
|
||||
chat: haChat,
|
||||
account: haAccount,
|
||||
},
|
||||
am: {
|
||||
common: amCommon,
|
||||
mission: amMission,
|
||||
chat: amChat,
|
||||
account: amAccount,
|
||||
},
|
||||
yo: {
|
||||
common: yoCommon,
|
||||
mission: yoMission,
|
||||
chat: yoChat,
|
||||
account: yoAccount,
|
||||
},
|
||||
zu: {
|
||||
common: zuCommon,
|
||||
mission: zuMission,
|
||||
chat: zuChat,
|
||||
account: zuAccount,
|
||||
},
|
||||
ma: {
|
||||
common: maCommon,
|
||||
mission: maMission,
|
||||
chat: maChat,
|
||||
account: maAccount,
|
||||
},
|
||||
|
||||
// European & other languages
|
||||
es: {
|
||||
common: esCommon,
|
||||
mission: esMission,
|
||||
chat: esChat,
|
||||
account: esAccount,
|
||||
},
|
||||
fr: {
|
||||
common: frCommon,
|
||||
mission: frMission,
|
||||
chat: frChat,
|
||||
account: frAccount,
|
||||
},
|
||||
de: {
|
||||
common: deCommon,
|
||||
mission: deMission,
|
||||
chat: deChat,
|
||||
account: deAccount,
|
||||
},
|
||||
el: {
|
||||
common: elCommon,
|
||||
mission: elMission,
|
||||
chat: elChat,
|
||||
account: elAccount,
|
||||
},
|
||||
it: {
|
||||
common: itCommon,
|
||||
mission: itMission,
|
||||
chat: itChat,
|
||||
account: itAccount,
|
||||
},
|
||||
he: {
|
||||
common: heCommon,
|
||||
mission: heMission,
|
||||
chat: heChat,
|
||||
account: heAccount,
|
||||
},
|
||||
pt: {
|
||||
common: ptCommon,
|
||||
mission: ptMission,
|
||||
chat: ptChat,
|
||||
account: ptAccount,
|
||||
},
|
||||
ro: {
|
||||
common: roCommon,
|
||||
mission: roMission,
|
||||
chat: roChat,
|
||||
account: roAccount,
|
||||
},
|
||||
ka: {
|
||||
common: kaCommon,
|
||||
mission: kaMission,
|
||||
chat: kaChat,
|
||||
account: kaAccount,
|
||||
},
|
||||
tr: {
|
||||
common: trCommon,
|
||||
mission: trMission,
|
||||
chat: trChat,
|
||||
account: trAccount,
|
||||
},
|
||||
pl: {
|
||||
common: plCommon,
|
||||
mission: plMission,
|
||||
chat: plChat,
|
||||
account: plAccount,
|
||||
},
|
||||
uk: {
|
||||
common: ukCommon,
|
||||
mission: ukMission,
|
||||
chat: ukChat,
|
||||
account: ukAccount,
|
||||
},
|
||||
nl: {
|
||||
common: nlCommon,
|
||||
mission: nlMission,
|
||||
chat: nlChat,
|
||||
account: nlAccount,
|
||||
},
|
||||
sr: {
|
||||
common: srCommon,
|
||||
mission: srMission,
|
||||
chat: srChat,
|
||||
account: srAccount,
|
||||
},
|
||||
no: {
|
||||
common: noCommon,
|
||||
mission: noMission,
|
||||
chat: noChat,
|
||||
account: noAccount,
|
||||
},
|
||||
sv: {
|
||||
common: svCommon,
|
||||
mission: svMission,
|
||||
chat: svChat,
|
||||
account: svAccount,
|
||||
},
|
||||
};
|
||||
|
||||
// Determine initial language from browser, normalised to language-only.
|
||||
const browserLang: LanguageCode =
|
||||
typeof navigator !== "undefined"
|
||||
? normalizeLocaleToLanguage(navigator.language)
|
||||
: "en";
|
||||
|
||||
// Keep document direction (ltr/rtl) in sync with the active language.
|
||||
if (typeof document !== "undefined") {
|
||||
document.documentElement.dir = isRtlLanguage(browserLang) ? "rtl" : "ltr";
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize i18next with React bindings.
|
||||
*
|
||||
* This module is imported once in src/main.tsx before any React
|
||||
* rendering so that `useTranslation` is ready everywhere.
|
||||
*/
|
||||
i18n.use(initReactI18next).init({
|
||||
resources,
|
||||
lng: browserLang,
|
||||
fallbackLng: "en",
|
||||
supportedLngs: SUPPORTED_LANGUAGES,
|
||||
ns: ["common", "mission", "chat", "account"],
|
||||
defaultNS: "common",
|
||||
// Because we control the keys and interpolate only simple values.
|
||||
interpolation: {
|
||||
escapeValue: false,
|
||||
},
|
||||
// For now we stay language-only; we already normalise the browser locale.
|
||||
load: "languageOnly",
|
||||
// Be explicit about react options for clarity.
|
||||
react: {
|
||||
useSuspense: false,
|
||||
},
|
||||
});
|
||||
|
||||
// Ensure that when the language changes at runtime, document direction
|
||||
// tracks the new language's natural writing direction.
|
||||
i18n.on("languageChanged", (lng) => {
|
||||
if (typeof document === "undefined") return;
|
||||
const lang = normalizeLocaleToLanguage(lng);
|
||||
document.documentElement.dir = isRtlLanguage(lang) ? "rtl" : "ltr";
|
||||
});
|
||||
|
||||
export default i18n;
|
||||
232
helexa.ai/src/i18n/languages.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
import type { Resource } from "i18next";
|
||||
|
||||
/**
|
||||
* Supported language codes for the application.
|
||||
*
|
||||
* For the foreseeable future we deliberately stay at the language level
|
||||
* (e.g. "en", "ru") rather than full locales (e.g. "en-GB") to keep
|
||||
* translation overhead manageable.
|
||||
*
|
||||
* When you add a new language:
|
||||
* - Add its code to `SUPPORTED_LANGUAGES`
|
||||
* - Add its autonym to `AUTONYM_MAP`
|
||||
* - Add its resources to the i18n configuration
|
||||
*/
|
||||
export type LanguageCode =
|
||||
| "en"
|
||||
| "bg"
|
||||
| "cs"
|
||||
| "da"
|
||||
| "de"
|
||||
| "el"
|
||||
| "es"
|
||||
| "he"
|
||||
| "et"
|
||||
| "ar"
|
||||
| "fa"
|
||||
| "fi"
|
||||
| "sw"
|
||||
| "ha"
|
||||
| "am"
|
||||
| "yo"
|
||||
| "zu"
|
||||
| "fr"
|
||||
| "ma"
|
||||
| "ga"
|
||||
| "hr"
|
||||
| "hu"
|
||||
| "is"
|
||||
| "it"
|
||||
| "ka"
|
||||
| "lt"
|
||||
| "lv"
|
||||
| "mt"
|
||||
| "nl"
|
||||
| "no"
|
||||
| "pl"
|
||||
| "pt"
|
||||
| "ro"
|
||||
| "ru"
|
||||
| "sk"
|
||||
| "sl"
|
||||
| "sr"
|
||||
| "sv"
|
||||
| "tr"
|
||||
| "uk"
|
||||
| "bs"
|
||||
| "mk"
|
||||
| "kk"
|
||||
| "uz"
|
||||
| "ig"
|
||||
| "om"
|
||||
| "so"
|
||||
| "ti"
|
||||
| "wo";
|
||||
|
||||
/**
|
||||
* Ordered list of languages enabled in the UI.
|
||||
*
|
||||
* For now you can keep `SUPPORTED_LANGUAGES` in sync with the
|
||||
* actually configured i18n resources (e.g. ["en", "ru"]) and grow
|
||||
* it as translations land.
|
||||
*/
|
||||
export const SUPPORTED_LANGUAGES: LanguageCode[] = [
|
||||
"bg",
|
||||
"da",
|
||||
"de",
|
||||
"el",
|
||||
"en",
|
||||
"es",
|
||||
"et",
|
||||
"fi",
|
||||
"fr",
|
||||
"he",
|
||||
"it",
|
||||
"ka",
|
||||
"kk",
|
||||
"nl",
|
||||
"no",
|
||||
"sv",
|
||||
"uz",
|
||||
"ar",
|
||||
"fa",
|
||||
"sw",
|
||||
"ha",
|
||||
"am",
|
||||
"yo",
|
||||
"zu",
|
||||
"ma",
|
||||
"pl",
|
||||
"pt",
|
||||
"ro",
|
||||
"ru",
|
||||
"sr",
|
||||
"tr",
|
||||
"uk",
|
||||
// Future Afro‑European / Eurasian candidates; keep out of SUPPORTED_LANGUAGES until translated:
|
||||
// "ig", // Igbo
|
||||
// "om", // Oromo
|
||||
// "so", // Somali
|
||||
// "ti", // Tigrinya
|
||||
// "wo", // Wolof
|
||||
];
|
||||
|
||||
/**
|
||||
* Autonym map.
|
||||
*
|
||||
* Each language is named in its own language so that a user only
|
||||
* needs to know their own language to find it in the selector.
|
||||
*/
|
||||
export const AUTONYM_MAP: Record<LanguageCode, string> = {
|
||||
en: "English",
|
||||
bg: "български",
|
||||
cs: "čeština",
|
||||
da: "dansk",
|
||||
de: "Deutsch",
|
||||
el: "Ελληνικά",
|
||||
es: "español",
|
||||
et: "eesti",
|
||||
he: "עברית",
|
||||
ar: "العربية",
|
||||
fa: "فارسی",
|
||||
sw: "Kiswahili",
|
||||
ha: "Hausa",
|
||||
am: "አማርኛ",
|
||||
yo: "Yorùbá",
|
||||
zu: "isiZulu",
|
||||
ma: "Darija",
|
||||
fi: "suomi",
|
||||
fr: "français",
|
||||
ga: "Gaeilge",
|
||||
hr: "hrvatski",
|
||||
hu: "magyar",
|
||||
is: "íslenska",
|
||||
it: "italiano",
|
||||
lt: "lietuvių",
|
||||
lv: "latviešu",
|
||||
mt: "Malti",
|
||||
nl: "Nederlands",
|
||||
no: "norsk",
|
||||
pl: "polski",
|
||||
pt: "português",
|
||||
ro: "română",
|
||||
ru: "русский",
|
||||
sk: "slovenčina",
|
||||
sl: "slovenščina",
|
||||
sr: "српски",
|
||||
sv: "svenska",
|
||||
tr: "Türkçe",
|
||||
uk: "українська",
|
||||
bs: "bosanski",
|
||||
mk: "македонски",
|
||||
ka: "ქართული", // Georgian
|
||||
kk: "қазақ тілі", // Kazakh
|
||||
uz: "oʻzbekcha", // Uzbek
|
||||
ig: "Igbo", // Igbo
|
||||
om: "Afaan Oromoo", // Oromo
|
||||
so: "Af-Soomaali", // Somali
|
||||
ti: "ትግርኛ", // Tigrinya
|
||||
wo: "Wolof", // Wolof
|
||||
};
|
||||
|
||||
/**
|
||||
* Normalize a full locale (e.g. "en-GB") down to a `LanguageCode`.
|
||||
*
|
||||
* - Uses the first segment of the locale (before "-")
|
||||
* - Falls back to "en" if the language is unsupported or invalid
|
||||
*/
|
||||
export const normalizeLocaleToLanguage = (
|
||||
locale: string | null | undefined,
|
||||
): LanguageCode => {
|
||||
if (!locale) return "en";
|
||||
const lang = locale.split("-")[0]?.toLowerCase() ?? "en";
|
||||
|
||||
if (SUPPORTED_LANGUAGES.includes(lang as LanguageCode)) {
|
||||
return lang as LanguageCode;
|
||||
}
|
||||
|
||||
return "en";
|
||||
};
|
||||
|
||||
/**
|
||||
* Build a stable list of language options for UI components
|
||||
* such as dropdowns.
|
||||
*/
|
||||
export type LanguageOption = {
|
||||
code: LanguageCode;
|
||||
autonym: string;
|
||||
};
|
||||
|
||||
export const getLanguageOptions = (): LanguageOption[] =>
|
||||
[...SUPPORTED_LANGUAGES]
|
||||
.map((code) => ({
|
||||
code,
|
||||
autonym: AUTONYM_MAP[code],
|
||||
}))
|
||||
.sort((a, b) => a.autonym.localeCompare(b.autonym));
|
||||
|
||||
/**
|
||||
* Utility to derive i18next `supportedLngs` from our language codes,
|
||||
* so configuration can import from this module instead of hardcoding
|
||||
* the list in multiple places.
|
||||
*/
|
||||
export const getSupportedLngsForI18Next = (): Resource["en"] extends never
|
||||
? string[]
|
||||
: string[] => {
|
||||
// i18next accepts string[], while we keep a stricter LanguageCode[]
|
||||
return [...SUPPORTED_LANGUAGES];
|
||||
};
|
||||
|
||||
/**
|
||||
* Languages whose natural writing direction is right-to-left.
|
||||
*
|
||||
* This is used by layout code (outside this module) to switch
|
||||
* document direction and RTL-aware styling when needed.
|
||||
*/
|
||||
export const RTL_LANGUAGES: LanguageCode[] = ["he", "ar", "fa", "ma"];
|
||||
|
||||
/**
|
||||
* Utility to check whether a given language code is RTL.
|
||||
*/
|
||||
export const isRtlLanguage = (code: LanguageCode): boolean =>
|
||||
RTL_LANGUAGES.includes(code);
|
||||
71
helexa.ai/src/i18n/resources/am/account.json
Normal file
@@ -0,0 +1,71 @@
|
||||
{
|
||||
"login": {
|
||||
"title": "Sign in",
|
||||
"email": "Email",
|
||||
"password": "Password",
|
||||
"submit": "Sign in",
|
||||
"noAccount": "No account? Sign up"
|
||||
},
|
||||
"register": {
|
||||
"title": "Create your account",
|
||||
"email": "Email",
|
||||
"password": "Password",
|
||||
"submit": "Sign up",
|
||||
"haveAccount": "Already have an account? Sign in",
|
||||
"checkEmail": "Almost there — check your email to verify your account."
|
||||
},
|
||||
"verify": {
|
||||
"verifying": "Verifying…",
|
||||
"ok": "Email verified. You can now sign in.",
|
||||
"failed": "This verification link is invalid or has expired.",
|
||||
"toLogin": "Go to sign in"
|
||||
},
|
||||
"reset": {
|
||||
"requestTitle": "Reset your password",
|
||||
"email": "Email",
|
||||
"requestSubmit": "Send reset link",
|
||||
"requestDone": "If that email has an account, a reset link is on its way.",
|
||||
"confirmTitle": "Choose a new password",
|
||||
"newPassword": "New password",
|
||||
"confirmSubmit": "Set password",
|
||||
"ok": "Password updated. You can now sign in."
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Account",
|
||||
"balance": "Allocation",
|
||||
"total": "Total",
|
||||
"spent": "Spent",
|
||||
"reserved": "Reserved",
|
||||
"remaining": "Remaining",
|
||||
"manageKeys": "Manage API keys",
|
||||
"redeemTitle": "Redeem a top-up code",
|
||||
"redeemPlaceholder": "helexa-topup-…",
|
||||
"redeem": "Redeem",
|
||||
"redeemed": "Code redeemed.",
|
||||
"logout": "Sign out"
|
||||
},
|
||||
"keys": {
|
||||
"title": "API keys",
|
||||
"create": "Create key",
|
||||
"label": "Label",
|
||||
"limitKind": "Limit",
|
||||
"percent": "% of allocation",
|
||||
"hardcap": "Hard cap (tokens)",
|
||||
"value": "Value",
|
||||
"none": "No keys yet.",
|
||||
"createdTitle": "Your new API key",
|
||||
"createdWarn": "Copy it now — you won't see it again.",
|
||||
"copy": "Copy",
|
||||
"copied": "Copied",
|
||||
"archive": "Archive",
|
||||
"save": "Save",
|
||||
"status": "Status",
|
||||
"usage": "Used",
|
||||
"useForChat": "Use for chat on this device",
|
||||
"usedForChat": "Enabled for chat ✓"
|
||||
},
|
||||
"error": {
|
||||
"generic": "Something went wrong.",
|
||||
"unauthorized": "Please sign in again."
|
||||
}
|
||||
}
|
||||
21
helexa.ai/src/i18n/resources/am/chat.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"title": "የውይይት ቦታ",
|
||||
"badge": "ውይይት",
|
||||
"lead": "ይህ የውይይት እይታ ነው። የውይይት ሎጂክዎን እና የተጠቃሚ በስተጀርባ ክፍሎችን ወደዚህ ገፅ ያገናኙ።",
|
||||
"transcriptPlaceholder": "የውይይቱ ሪኮርድ እዚህ ይታያል። የሞዴሉን እና የተጠቃሚውን መልዕክቶች በሚንቀሳቀስ ኮንቴይነር ውስጥ ያቀርቡ፣ приወይም በዙር ዙር በመከፈል ማቅረብ ይችላሉ።",
|
||||
"inputPlaceholder": "ውይይትን ለመጀምር መልዕክት ይፃፉ…",
|
||||
"send": "መላክ",
|
||||
"clear": "ማጽዳት",
|
||||
"newChat": "New chat",
|
||||
"newProject": "New project",
|
||||
"newProjectName": "New project",
|
||||
"unsorted": "Unsorted",
|
||||
"emptyState": "Start a conversation. Your history stays in this browser.",
|
||||
"anonBanner": "You have reached the anonymous limit. Sign up for a free allocation.",
|
||||
"signUp": "Sign up",
|
||||
"stop": "Stop",
|
||||
"topUp": "Top up",
|
||||
"rateLimited": "Rate limited — wait a moment and retry.",
|
||||
"needsKey": "Create an API key and enable it for chat to send as yourself.",
|
||||
"manageKeysLink": "Manage keys"
|
||||
}
|
||||
68
helexa.ai/src/i18n/resources/am/common.json
Normal file
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"app": {
|
||||
"name": "helexa.ai"
|
||||
},
|
||||
"nav": {
|
||||
"home": "መነሻ ገፅ",
|
||||
"docs": "ሰነዶች",
|
||||
"chat": "ውይይት",
|
||||
"mission": "Mission",
|
||||
"login": "Sign in",
|
||||
"register": "Sign up",
|
||||
"account": "Account",
|
||||
"logout": "Sign out"
|
||||
},
|
||||
"theme": {
|
||||
"toggle": {
|
||||
"toLight": "ወደ ብርሃን ሁኔታ መቀየር",
|
||||
"toDark": "ወደ ጨለማ ሁኔታ መቀየር"
|
||||
}
|
||||
},
|
||||
"lang": {
|
||||
"bg": "ቡልጋሪኛ",
|
||||
"de": "ጀርመንኛ",
|
||||
"el": "ግሪከኛ",
|
||||
"en": "እንግሊዝኛ",
|
||||
"es": "ስፓኒሽኛ",
|
||||
"et": "ኤስቶኒያን",
|
||||
"fr": "ፈረንሳይኛ",
|
||||
"he": "እብራስጥ",
|
||||
"it": "ጣሊያንኛ",
|
||||
"nl": "ደችኛ",
|
||||
"da": "ዴኒሽኛ",
|
||||
"fi": "ፊኒሽኛ",
|
||||
"no": "ኖርዌጂያንኛ",
|
||||
"sv": "ስዊድንኛ",
|
||||
"ar": "ዐርቢኛ",
|
||||
"fa": "ፐርሺያኛ",
|
||||
"sw": "ስዋሂሊኛ",
|
||||
"ha": "ሃውሳኛ",
|
||||
"am": "አማርኛ",
|
||||
"yo": "ዮሩባ",
|
||||
"zu": "ዙሉ",
|
||||
"ma": "ዳሪጃ",
|
||||
"ig": "ኢግቦኛ",
|
||||
"ka": "ጊዮርጂያንኛ",
|
||||
"kk": "ካዛክኛ",
|
||||
"om": "ኦሮሞኛ",
|
||||
"so": "ሶማሊኛ",
|
||||
"ti": "ትግርኛ",
|
||||
"uz": "ኡዝቤክኛ",
|
||||
"wo": "ዎሎፍኛ",
|
||||
"pl": "ፖሊሽኛ",
|
||||
"pt": "ፖርቱጋልኛ",
|
||||
"ro": "ሮማኒያን",
|
||||
"ru": "ራሽኛ",
|
||||
"sr": "ሰርቢኛ",
|
||||
"tr": "ቱርክኛ",
|
||||
"uk": "ዩክሬንኛ"
|
||||
},
|
||||
"footer": {
|
||||
"copyright": "© {{year}} helexa.ai"
|
||||
},
|
||||
"beta": {
|
||||
"tag": "Public beta",
|
||||
"message": "helexa is in open beta — expect rough edges. Your chats stay in your browser.",
|
||||
"dismiss": "Dismiss"
|
||||
}
|
||||
}
|
||||
103
helexa.ai/src/i18n/resources/am/mission.json
Normal file
@@ -0,0 +1,103 @@
|
||||
{
|
||||
"hero": {
|
||||
"badge": "አዲስ የአእምሮ ቅርጽ",
|
||||
"title": "አዲስ የአእምሮ ቅርጽ",
|
||||
"lead": "Helexa በተገናኙ ነጻ ኦፕሬተሮች የሚነሳ ራሷን የሚያደርግ የኤ.አይ. መረብ ነው። ክፍት ነው። ተበታተነ። በመደጋገም ይቀየራል።",
|
||||
"ctaJoinMesh": "ወደ መረቡ ይቀላቀሉ",
|
||||
"ctaFollowProject": "ፕሮጀክቱን ይከተሉ",
|
||||
"subcopy": "AI ክፍት፣ ጠንካራ እና የሚጋራ መሆን አለበት በሚሉ ለኦፕሬተሮች፣ ለአበልጣጫዎች እና ለማህበረሰቦች ተሠርቷል።",
|
||||
"imageAlt": "የHelexa ሄሊክስ ምስል"
|
||||
},
|
||||
|
||||
"intent": {
|
||||
"title": "Helexa ለምን አለች",
|
||||
"p1": "AI በምድር ላይ በጣም ኃይለኛው መሠረታዊ መዋቅር እየሆነ ነው። ነገር ግን ዛሬ፣ ያ ኃይል በግል ቅድሚያዎች፣ በጂኦግራፊያዊ ገደቦች እና በደካማ ኢኮኖሚዎች የተሳሰበ በጥቂት ኩባንያዎች ውስጥ ተሰብስቧል።",
|
||||
"p2Intro": "Helexa የተለየ ነገር ታስባለች፦",
|
||||
"bullet1": "ከአንድ ቦታ ሳይሆን ከሁሉም ቦታ የሚያድግ አእምሮ።",
|
||||
"bullet2": "ማንኛውም ሰው ሊያስተዋውቅበትና ሊጠቀምበት የሚችለው መረብ።",
|
||||
"bullet3": "በትእዛዝ ሳይሆን በፍላጎት ላይ የሚማር ስርዓት።",
|
||||
"bullet4": "ማህበረሰቦችን ለመተካት ሳይሆን የሚጠናከል ቴክኖሎጂ።",
|
||||
"closing": "Helexa መድረክ አይደለችም። ደመና አይደለችም።\nመረብ ነች — የተወለደ እና የሚሻሻል የነጻ ኦፕሬተሮች መረብ ሆና አዲስ ዓይነት አእምሮ የምታቀና."
|
||||
},
|
||||
|
||||
"whyNow": {
|
||||
"title": "ለAI የሚለው የመቀየር ጊዜ",
|
||||
"problemTitle": "ችግኙ",
|
||||
"problemBullet1": "AI ከቀድሞው ማንኛውም ቴክኖሎጂ የማይተካ ፍጥነት በመሰብሰብ ላይ ነው።",
|
||||
"problemBullet2": "የኮምፒውተር ኃይል መዳረሻ ችሎታን ያወራል፣ ይህም መዳረሻ ግን በቀስታ እየተጠበቀ ነው።",
|
||||
"problemBullet3": "የወጪ መከልከያዎች ምርምር እና የኢንተርፕራይዝ ጀማሪዎችን እና ማህበረሰቦችን እያወጡ ነው።",
|
||||
"problemBullet4": "የጄኦፖለቲካ እና የህግ ግፊት የዓለም አቀፍ መዳረሻን እያቀረበ ነው።",
|
||||
"problemBullet5": "የሞዴሎች ፈጣሪዎችና የሀርድዌር ኦፕሬተሮች ብዙ ጊዜ በራሳቸው የሚፈጥሩትን ዋጋ አያጋሩም።",
|
||||
"opportunityTitle": "እድሉ",
|
||||
"opportunityIntro": "ግን የተሰራጨ ዓለም የሚቻል ነው።",
|
||||
"opportunityBullet1": "ሺዎች የሚቆጠሩ የGPU ካርዶች በዓለም ዙሪያ አሁንም በትንሽ እየተጠቀሙባቸው ነው።",
|
||||
"opportunityBullet2": "ኦፕሬተሮች ለየሚሰጡት ኮምፒውተር ኃይል እውነተኛ እና ፍትሃዊ ክፍያ ይፈልጋሉ።",
|
||||
"opportunityBullet3": "ዲቨሎፐሮች ክፍት እና ከመቆጣጠር የተጠበቀ መሠረታዊ መዋቅር ይፈልጋሉ።",
|
||||
"opportunityBullet4": "ማህበረሰቦች በዲጂታል ስርዓቶቻቸው ውስጥ ስብስብ መንግስታዊነትን እና ጽኑ መሆን ይፈልጋሉ።",
|
||||
"opportunityBullet5": "የAI እድገት ባለመለኪያ ሁኔታ ከባለሙያ ደመናዎች በላይ ቀድሞ ሄዷል — አዳዲስ ቅርጾች ያስፈልጋሉ።",
|
||||
"opportunityClosing": "Helexa እነዚህ ኃይሎች በአንድ ጊዜ የሚገናኙበት ጊዜ ነው።"
|
||||
},
|
||||
|
||||
"howItWorks": {
|
||||
"title": "መረቡ እንዴት እንደሚሰራ",
|
||||
"operators": {
|
||||
"eyebrow": "ኦፕሬተሮች ኖዶችን ይያዙ",
|
||||
"title": "ማንኛውም ሰው የኮምፒውተር ኃይል ሊያቀርብ ይችላል።",
|
||||
"body": "ኦፕሬተሮች የHelexa ኖዶችን ያስኬዳሉ። የማንኛውንም ሞዴል እንዲሰሩ ያስችላሉ። በሀርድዌሩና በኢኮኖሚያዊ ውሳኔዎቻቸው ላይ ቁጥጥር ይጠብቃሉ። ምንም የፈቃድ ሂደት የለም፣ ጠባቂ መከላከያ የለም።"
|
||||
},
|
||||
"routing": {
|
||||
"eyebrow": "መረቡ አእምሮን ይመራል",
|
||||
"title": "ፍላጎት በመረቡ ውስጥ ይፈሳሳል።",
|
||||
"body": "Helexa እየማረከች የቆየበት አቅም ወዴት እንደሚገኝ፣ ፍላጎት የሚጨምርበት ቦታ የት እንደሆነ እና ምን ኖድ ለማቅረብ ተስማሚ እንደሆነ ትማራለች። መረቡ በተፈጥሯዊ መንገድ ይለመዳል — እያደገ ያለ ሄሊክስ እንደሆነ።"
|
||||
},
|
||||
"value": {
|
||||
"eyebrow": "ዋጋው ወደ ኋላ ይመለሳል",
|
||||
"title": "ሥራ ታረጋገጠ። ክፍያ ፍትሃዊ ነው።",
|
||||
"body": "እያንዳንዱ ስራ ክሪፕቶግራፊ የተፈረመ ደረሰኝ ይይዛል። ኦፕሬተሮች በሚሰጡት አእምሮ ላይ ገንዘብ ያገኛሉ። የመድረክ ታክስ የለም። ግልጽ ካልሆነ የክፍያ ሂደት የለም።"
|
||||
}
|
||||
},
|
||||
|
||||
"principles": {
|
||||
"title": "በመድረኮች ላይ ሳይሆን በመርሆዎች ላይ የተገነባ",
|
||||
"distributed": {
|
||||
"title": "በንድፍ የተበታተነ",
|
||||
"body": "አንድ የብቻ የእርምጃ መደበቂያ የለም። ማዕከላዊ ሥልጣን የለም። በእያንዳንዱ አዲስ ኦፕሬተር ጋር የሚጠናከር መረብ ነው።"
|
||||
},
|
||||
"participation": {
|
||||
"title": "ተሳትፎ ለሁሉም ክፍት",
|
||||
"body": "ኮምፒውተር ኃይል ካለህ ልዩ ልዩ ድርሻ ሊያስተዋውቅ ትችላለህ። መረቡ ሁሉንም ይቀበላል — ከኤጀጅ መሣሪያዎች፣ እስከ የቤት አገልጋዮች እና የዳታ ማእከላት ድረስ።"
|
||||
},
|
||||
"fairness": {
|
||||
"title": "ፍትህና ተመልካችነት",
|
||||
"body": "ገቢዎች በእውነተኛ ስራ ላይ ተመስርተዋል፣ በክሪፕቶግራፊ መሠረት ተረጋግጠዋል። ጥቁር ሳጥን የለም። የተደበቀ ክፍያ የለም።"
|
||||
},
|
||||
"evolving": {
|
||||
"title": "የሚያድግ አእምሮ",
|
||||
"body": "መረቡ ከፍላጎት ይማራል። ሞዴሎች የሚፈለጉበት ቦታ ላይ ይጫናሉ። አእምሮ በተባባሪነት ይበተናል።"
|
||||
}
|
||||
},
|
||||
|
||||
"roadAhead": {
|
||||
"title": "Helexa ምን ለመሆን እየተሻሻለች ነው",
|
||||
"p1": "ለሁሉም የሚያገለግል የዓለም አቀፍ የአእምሮ ንብርብር፣ በሄሊክስ ያለ የኖዶችና የማህበረሰቦች መረብ የሚደገፍ።",
|
||||
"p2": "ማቋረጥ፣ ፖለቲካ፣ ነጠላ ባለቤቶች እና ወደፊት ሊከሰት በሚችል እንኳን የሚቋቋም መረብ።",
|
||||
"p3": "ኦፕሬተሮች፣ አበልጣጫዎች እና ተጠቃሚዎች ሁሉም ተዋጊ የሚሆኑበት አዲስ የኢኮኖሚ ሞዴል።",
|
||||
"p4": "ፈጠራ ከጠርዝ የሚያድግ፣ ከማዕከል ሳይሆን፣ የሥራ አካባቢ ኢኮሲስተም።",
|
||||
"card": {
|
||||
"eyebrow": "የራዕይ አጭር እትም",
|
||||
"title": "ወደ የተጋራ የአእምሮ መረብ",
|
||||
"body": "Helexa ገና በቀዳሚ ደረጃ ላይ ይ beታል። ሀሳቦቹ ከአሁን እና ከተፈጠረው ተቀባይነት የበለጠ ትልቅ ናቸው — ይህም በተወሰነ ዓላማ ነው። መረቡ በእያንዳንዱ ደረጃ ይበቃል፣ ኦፕሬተሮችና አበልጣጫዎችም እድገቷን በጋራ ይፀናሉ።"
|
||||
}
|
||||
},
|
||||
|
||||
"joinMesh": {
|
||||
"badge": "የመጀመሪያ ደረጃ",
|
||||
"title": "መረቡ እየተፈጠረ ነው።",
|
||||
"titleHighlight": "እርስዎም ክፍል ሆነው ሊሳተፉበት ይችላሉ።",
|
||||
"lead": "ሀርድዌር ብትከናወኑ፣ ሞዴሎችን ብታበርኩ፣ ወይም በአጠቃላይ AI እንዴት እንደሚመራ ብታስቡ ሆነው፣ በዚህ መረብ ውስጥ ለእርስዎ የተዘጋበ ቦታ አለ።",
|
||||
"ctaRunNode": "ኖድ ያስኬዱ (በቅርቡ)",
|
||||
"ctaJoinAnnouncements": "የቀድሞ ማስታወቂያዎችን ይቀበሉ",
|
||||
"ctaExploreCode": "ኮድን ያስሱ",
|
||||
"footer": "የተጣሉ ፍራንቻዎች የሉም። አንድ ብቻ ባለቤት የለም። የሚመጣውን የአእምሮ ወደፊት በሚያቀና መልኩ በሰዎች፣ በሀርድዌር እና በሀሳቦች የተሠራ መረብ ብቻ ነው።"
|
||||
}
|
||||
}
|
||||
71
helexa.ai/src/i18n/resources/ar/account.json
Normal file
@@ -0,0 +1,71 @@
|
||||
{
|
||||
"login": {
|
||||
"title": "Sign in",
|
||||
"email": "Email",
|
||||
"password": "Password",
|
||||
"submit": "Sign in",
|
||||
"noAccount": "No account? Sign up"
|
||||
},
|
||||
"register": {
|
||||
"title": "Create your account",
|
||||
"email": "Email",
|
||||
"password": "Password",
|
||||
"submit": "Sign up",
|
||||
"haveAccount": "Already have an account? Sign in",
|
||||
"checkEmail": "Almost there — check your email to verify your account."
|
||||
},
|
||||
"verify": {
|
||||
"verifying": "Verifying…",
|
||||
"ok": "Email verified. You can now sign in.",
|
||||
"failed": "This verification link is invalid or has expired.",
|
||||
"toLogin": "Go to sign in"
|
||||
},
|
||||
"reset": {
|
||||
"requestTitle": "Reset your password",
|
||||
"email": "Email",
|
||||
"requestSubmit": "Send reset link",
|
||||
"requestDone": "If that email has an account, a reset link is on its way.",
|
||||
"confirmTitle": "Choose a new password",
|
||||
"newPassword": "New password",
|
||||
"confirmSubmit": "Set password",
|
||||
"ok": "Password updated. You can now sign in."
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Account",
|
||||
"balance": "Allocation",
|
||||
"total": "Total",
|
||||
"spent": "Spent",
|
||||
"reserved": "Reserved",
|
||||
"remaining": "Remaining",
|
||||
"manageKeys": "Manage API keys",
|
||||
"redeemTitle": "Redeem a top-up code",
|
||||
"redeemPlaceholder": "helexa-topup-…",
|
||||
"redeem": "Redeem",
|
||||
"redeemed": "Code redeemed.",
|
||||
"logout": "Sign out"
|
||||
},
|
||||
"keys": {
|
||||
"title": "API keys",
|
||||
"create": "Create key",
|
||||
"label": "Label",
|
||||
"limitKind": "Limit",
|
||||
"percent": "% of allocation",
|
||||
"hardcap": "Hard cap (tokens)",
|
||||
"value": "Value",
|
||||
"none": "No keys yet.",
|
||||
"createdTitle": "Your new API key",
|
||||
"createdWarn": "Copy it now — you won't see it again.",
|
||||
"copy": "Copy",
|
||||
"copied": "Copied",
|
||||
"archive": "Archive",
|
||||
"save": "Save",
|
||||
"status": "Status",
|
||||
"usage": "Used",
|
||||
"useForChat": "Use for chat on this device",
|
||||
"usedForChat": "Enabled for chat ✓"
|
||||
},
|
||||
"error": {
|
||||
"generic": "Something went wrong.",
|
||||
"unauthorized": "Please sign in again."
|
||||
}
|
||||
}
|
||||
21
helexa.ai/src/i18n/resources/ar/chat.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"title": "مساحة محادثة",
|
||||
"badge": "محادثة",
|
||||
"lead": "هذه هي واجهة المحادثة. قم بتوصيل منطق الحوار الخاص بك ومكوّنات واجهة المستخدم بهذه الصفحة.",
|
||||
"transcriptPlaceholder": "سجل المحادثة سيظهر هنا. اعرض رسائل النموذج والمستخدم في حاوية قابلة للتمرير، ويمكنك تجميعها حسب أدوار الحوار إذا رغبت.",
|
||||
"inputPlaceholder": "اكتب رسالة لبدء المحادثة…",
|
||||
"send": "إرسال",
|
||||
"clear": "مسح",
|
||||
"newChat": "New chat",
|
||||
"newProject": "New project",
|
||||
"newProjectName": "New project",
|
||||
"unsorted": "Unsorted",
|
||||
"emptyState": "Start a conversation. Your history stays in this browser.",
|
||||
"anonBanner": "You have reached the anonymous limit. Sign up for a free allocation.",
|
||||
"signUp": "Sign up",
|
||||
"stop": "Stop",
|
||||
"topUp": "Top up",
|
||||
"rateLimited": "Rate limited — wait a moment and retry.",
|
||||
"needsKey": "Create an API key and enable it for chat to send as yourself.",
|
||||
"manageKeysLink": "Manage keys"
|
||||
}
|
||||
68
helexa.ai/src/i18n/resources/ar/common.json
Normal file
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"app": {
|
||||
"name": "helexa.ai"
|
||||
},
|
||||
"nav": {
|
||||
"home": "الصفحة الرئيسية",
|
||||
"docs": "التوثيق",
|
||||
"chat": "المحادثة",
|
||||
"mission": "Mission",
|
||||
"login": "Sign in",
|
||||
"register": "Sign up",
|
||||
"account": "Account",
|
||||
"logout": "Sign out"
|
||||
},
|
||||
"theme": {
|
||||
"toggle": {
|
||||
"toLight": "التبديل إلى الوضع الفاتح",
|
||||
"toDark": "التبديل إلى الوضع الداكن"
|
||||
}
|
||||
},
|
||||
"lang": {
|
||||
"bg": "البلغارية",
|
||||
"de": "الألمانية",
|
||||
"el": "اليونانية",
|
||||
"en": "الإنجليزية",
|
||||
"es": "الإسبانية",
|
||||
"et": "الإستونية",
|
||||
"fr": "الفرنسية",
|
||||
"he": "العبرية",
|
||||
"it": "الإيطالية",
|
||||
"nl": "الهولندية",
|
||||
"da": "الدنماركية",
|
||||
"fi": "الفنلندية",
|
||||
"ar": "العربية",
|
||||
"fa": "الفارسية",
|
||||
"sw": "السواحيلية",
|
||||
"ha": "الهوسا",
|
||||
"am": "الأمهرية",
|
||||
"yo": "اليوربا",
|
||||
"zu": "الزولو",
|
||||
"ma": "الدارجة المغربية",
|
||||
"ig": "الإيجبو",
|
||||
"ka": "الجورجية",
|
||||
"kk": "الكازاخية",
|
||||
"no": "النرويجية",
|
||||
"om": "الأورومو",
|
||||
"so": "الصومالية",
|
||||
"sv": "السويدية",
|
||||
"ti": "التيغرينية",
|
||||
"uz": "الأوزبكية",
|
||||
"wo": "الولوف",
|
||||
"pl": "البولندية",
|
||||
"pt": "البرتغالية",
|
||||
"ro": "الرومانية",
|
||||
"ru": "الروسية",
|
||||
"sr": "الصربية",
|
||||
"tr": "التركية",
|
||||
"uk": "الأوكرانية"
|
||||
},
|
||||
"footer": {
|
||||
"copyright": "© {{year}} helexa.ai"
|
||||
},
|
||||
"beta": {
|
||||
"tag": "Public beta",
|
||||
"message": "helexa is in open beta — expect rough edges. Your chats stay in your browser.",
|
||||
"dismiss": "Dismiss"
|
||||
}
|
||||
}
|
||||
103
helexa.ai/src/i18n/resources/ar/mission.json
Normal file
@@ -0,0 +1,103 @@
|
||||
{
|
||||
"hero": {
|
||||
"badge": "شكل جديد من الذكاء",
|
||||
"title": "شكل جديد من الذكاء",
|
||||
"lead": "هيليكسـا هي شبكة ذكاء اصطناعي ذاتية التنظيم، يشغّلها مشغّلون مستقلون. مفتوحة. موزعة. تتطور باستمرار.",
|
||||
"ctaJoinMesh": "انضم إلى الشبكة",
|
||||
"ctaFollowProject": "تابع المشروع",
|
||||
"subcopy": "صُممت للمشغّلين والبنّائين والمجتمعات التي تؤمن بأن الذكاء الاصطناعي يجب أن يكون مفتوحًا، مرنًا، ومشتركًا.",
|
||||
"imageAlt": "تصور حلزوني لشكل Helexa"
|
||||
},
|
||||
|
||||
"intent": {
|
||||
"title": "لماذا وُجدت Helexa",
|
||||
"p1": "الذكاء الاصطناعي يتحول إلى أقوى بنية تحتية على كوكب الأرض. لكن اليوم، هذه القوة مركّزة في أيدي عدد قليل من الشركات، تشكلها أولويات خاصة، وحدود جغرافية، واقتصادات هشة.",
|
||||
"p2Intro": "هيليكسـا تتخيل شيئًا مختلفًا:",
|
||||
"bullet1": "ذكاء ينمو من كل مكان، لا من نقطة واحدة.",
|
||||
"bullet2": "شبكة يمكن للجميع فيها أن يساهموا ويستفيدوا.",
|
||||
"bullet3": "نظام يتكيّف مع الطلب، لا مع الأوامر الفوقية.",
|
||||
"bullet4": "تكنولوجيا تقوّي المجتمعات بدلًا من أن تستبدلها.",
|
||||
"closing": "هيليكسـا ليست منصة. وليست سحابة.\nإنها شبكة — نسيج حيّ ومتطوّر من مشغّلين مستقلين يشكّلون نوعًا جديدًا من الذكاء."
|
||||
},
|
||||
|
||||
"whyNow": {
|
||||
"title": "نقطة تحوّل للذكاء الاصطناعي",
|
||||
"problemTitle": "المشكلة",
|
||||
"problemBullet1": "الذكاء الاصطناعي يتمركز أسرع من أي تكنولوجيا سبقته.",
|
||||
"problemBullet2": "الوصول إلى القدرة الحاسوبية يحدد الإمكانات، وهذا الوصول يضيق باستمرار.",
|
||||
"problemBullet3": "حواجز التكلفة تستبعد الباحثين والشركات الناشئة والمجتمعات.",
|
||||
"problemBullet4": "الضغوط الجيوسياسية والتنظيمية تهدّد التوافر العالمي.",
|
||||
"problemBullet5": "مُنشئو النماذج ومشغّلو العتاد نادرًا ما يشاركون في القيمة التي ينتجونها.",
|
||||
"opportunityTitle": "الفرصة",
|
||||
"opportunityIntro": "لكن عالمًا موزعًا ممكن.",
|
||||
"opportunityBullet1": "آلاف وحدات الـGPU حول العالم تعمل بأقل من طاقتها.",
|
||||
"opportunityBullet2": "المشغّلون يريدون تعويضًا عادلًا عن الحوسبة التي يقدّمونها.",
|
||||
"opportunityBullet3": "المطورون يريدون بنية تحتية مفتوحة ومقاوِمة للرقابة.",
|
||||
"opportunityBullet4": "المجتمعات تريد سيادة ومرونة في أنظمتها الرقمية.",
|
||||
"opportunityBullet5": "نمو الذكاء الاصطناعي تجاوز البنى السحابية التقليدية — نحن بحاجة إلى أشكال جديدة.",
|
||||
"opportunityClosing": "هيليكسـا هي لحظة تلاقي هذه القوى."
|
||||
},
|
||||
|
||||
"howItWorks": {
|
||||
"title": "كيف تتشكّل الشبكة",
|
||||
"operators": {
|
||||
"eyebrow": "المشغّلون يديرون العقد",
|
||||
"title": "أي شخص يمكنه المساهمة بقوة حاسوبية.",
|
||||
"body": "المشغّلون يشغّلون عُقد Helexa. يقررون أي النماذج يستضيفون. يبقون متحكمين في عتادهم واقتصادهم. لا موافقات، ولا حراس بوابة."
|
||||
},
|
||||
"routing": {
|
||||
"eyebrow": "الشبكة توجّه الذكاء",
|
||||
"title": "الطلب يتدفق عبر الشبكة.",
|
||||
"body": "هيليكسـا تتعلم أين توجد السعة، وأين يرتفع الطلب، وأي العقد أنسب لخدمة الطلبات. الشبكة تتكيّف بشكل عضوي — مثل حلزون ينمو."
|
||||
},
|
||||
"value": {
|
||||
"eyebrow": "القيمة تعود إلى المصدر",
|
||||
"title": "العمل مُثبت. والدفع عادل.",
|
||||
"body": "كل مهمة تحمل إيصالًا تشفيريًا. يكسب المشغّلون مقابل الذكاء الذي يساعدون في توفيره. لا ضرائب منصة. لا فواتير غامضة."
|
||||
}
|
||||
},
|
||||
|
||||
"principles": {
|
||||
"title": "مبنية على مبادئ، لا على منصات",
|
||||
"distributed": {
|
||||
"title": "موزعة بالتصميم",
|
||||
"body": "لا توجد نقطة فشل واحدة. لا توجد سلطة مركزية. شبكة تقوى مع كل مشغّل جديد."
|
||||
},
|
||||
"participation": {
|
||||
"title": "مشاركة مفتوحة",
|
||||
"body": "إذا كان لديك قدرة حاسوبية، يمكنك المساهمة. الشبكة ترحّب بالجميع — من الحافة، إلى الخوادم المنزلية، إلى مراكز البيانات."
|
||||
},
|
||||
"fairness": {
|
||||
"title": "العدالة والشفافية",
|
||||
"body": "الأرباح مبنية على عمل حقيقي، مُثبت تشفيريًا. لا صناديق سوداء. لا رسوم خفية."
|
||||
},
|
||||
"evolving": {
|
||||
"title": "ذكاء يتطور باستمرار",
|
||||
"body": "الشبكة تتعلم من الطلب. تُحمَّل النماذج حيث تكون مطلوبة. الذكاء ينتشر من خلال التعاون."
|
||||
}
|
||||
},
|
||||
|
||||
"roadAhead": {
|
||||
"title": "إلى ماذا تطمح Helexa أن تصبح",
|
||||
"p1": "طبقة ذكاء عالمية تنتمي للجميع، مدفوعة بحلزونة من العقد والمجتمعات.",
|
||||
"p2": "شبكة مقاومة للانقطاعات، والسياسة، والاحتكارات، والإخفاق.",
|
||||
"p3": "نموذج اقتصادي جديد يستفيد منه المشغّلون والبنّاؤون والمستخدمون جميعًا.",
|
||||
"p4": "نظام بيئي تنمو فيه الابتكارات من الأطراف — لا من المركز.",
|
||||
"card": {
|
||||
"eyebrow": "لمحة عن الرؤية",
|
||||
"title": "نحو شبكة ذكاء مشتركة",
|
||||
"body": "هيليكسـا في مرحلة مبكرة. الأفكار أكبر من التنفيذ الحالي — وهذا مقصود. الشبكة ستنمو تدريجيًا، بينما يشكّل المشغّلون والبنّاؤون مسار تطورها."
|
||||
}
|
||||
},
|
||||
|
||||
"joinMesh": {
|
||||
"badge": "مرحلة مبكرة",
|
||||
"title": "الشبكة قيد التشكل.",
|
||||
"titleHighlight": "ويمكنك أن تكون جزءًا منها.",
|
||||
"lead": "سواءً كنت تدير عتادًا، تبني نماذج، أو يهمّك ببساطة كيف يُدار الذكاء الاصطناعي — هناك مكان لك داخل الشبكة.",
|
||||
"ctaRunNode": "تشغيل عقدة (قريبًا)",
|
||||
"ctaJoinAnnouncements": "انضم إلى الإعلانات المبكرة",
|
||||
"ctaExploreCode": "استكشف الشفرة",
|
||||
"footer": "لا حدائق مسوّرة. لا مالك واحد. مجرد شبكة من أشخاص وعتاد وأفكار — تصوغ مستقبلًا مختلفًا للذكاء."
|
||||
}
|
||||
}
|
||||
71
helexa.ai/src/i18n/resources/bg/account.json
Normal file
@@ -0,0 +1,71 @@
|
||||
{
|
||||
"login": {
|
||||
"title": "Sign in",
|
||||
"email": "Email",
|
||||
"password": "Password",
|
||||
"submit": "Sign in",
|
||||
"noAccount": "No account? Sign up"
|
||||
},
|
||||
"register": {
|
||||
"title": "Create your account",
|
||||
"email": "Email",
|
||||
"password": "Password",
|
||||
"submit": "Sign up",
|
||||
"haveAccount": "Already have an account? Sign in",
|
||||
"checkEmail": "Almost there — check your email to verify your account."
|
||||
},
|
||||
"verify": {
|
||||
"verifying": "Verifying…",
|
||||
"ok": "Email verified. You can now sign in.",
|
||||
"failed": "This verification link is invalid or has expired.",
|
||||
"toLogin": "Go to sign in"
|
||||
},
|
||||
"reset": {
|
||||
"requestTitle": "Reset your password",
|
||||
"email": "Email",
|
||||
"requestSubmit": "Send reset link",
|
||||
"requestDone": "If that email has an account, a reset link is on its way.",
|
||||
"confirmTitle": "Choose a new password",
|
||||
"newPassword": "New password",
|
||||
"confirmSubmit": "Set password",
|
||||
"ok": "Password updated. You can now sign in."
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Account",
|
||||
"balance": "Allocation",
|
||||
"total": "Total",
|
||||
"spent": "Spent",
|
||||
"reserved": "Reserved",
|
||||
"remaining": "Remaining",
|
||||
"manageKeys": "Manage API keys",
|
||||
"redeemTitle": "Redeem a top-up code",
|
||||
"redeemPlaceholder": "helexa-topup-…",
|
||||
"redeem": "Redeem",
|
||||
"redeemed": "Code redeemed.",
|
||||
"logout": "Sign out"
|
||||
},
|
||||
"keys": {
|
||||
"title": "API keys",
|
||||
"create": "Create key",
|
||||
"label": "Label",
|
||||
"limitKind": "Limit",
|
||||
"percent": "% of allocation",
|
||||
"hardcap": "Hard cap (tokens)",
|
||||
"value": "Value",
|
||||
"none": "No keys yet.",
|
||||
"createdTitle": "Your new API key",
|
||||
"createdWarn": "Copy it now — you won't see it again.",
|
||||
"copy": "Copy",
|
||||
"copied": "Copied",
|
||||
"archive": "Archive",
|
||||
"save": "Save",
|
||||
"status": "Status",
|
||||
"usage": "Used",
|
||||
"useForChat": "Use for chat on this device",
|
||||
"usedForChat": "Enabled for chat ✓"
|
||||
},
|
||||
"error": {
|
||||
"generic": "Something went wrong.",
|
||||
"unauthorized": "Please sign in again."
|
||||
}
|
||||
}
|
||||
21
helexa.ai/src/i18n/resources/bg/chat.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"title": "Работно пространство за разговори",
|
||||
"badge": "Чат",
|
||||
"lead": "Това е изгледът за чат. Тук можеш да свържеш своята логика за разговори и потребителски интерфейс.",
|
||||
"transcriptPlaceholder": "Тук ще се показва историята на чата. Визуализирай съобщенията от модела и потребителя в превъртащ се контейнер, по желание групирани по ход.",
|
||||
"inputPlaceholder": "Напиши съобщение, за да започнеш разговора…",
|
||||
"send": "Изпрати",
|
||||
"clear": "Изчисти",
|
||||
"newChat": "New chat",
|
||||
"newProject": "New project",
|
||||
"newProjectName": "New project",
|
||||
"unsorted": "Unsorted",
|
||||
"emptyState": "Start a conversation. Your history stays in this browser.",
|
||||
"anonBanner": "You have reached the anonymous limit. Sign up for a free allocation.",
|
||||
"signUp": "Sign up",
|
||||
"stop": "Stop",
|
||||
"topUp": "Top up",
|
||||
"rateLimited": "Rate limited — wait a moment and retry.",
|
||||
"needsKey": "Create an API key and enable it for chat to send as yourself.",
|
||||
"manageKeysLink": "Manage keys"
|
||||
}
|
||||
68
helexa.ai/src/i18n/resources/bg/common.json
Normal file
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"app": {
|
||||
"name": "helexa.ai"
|
||||
},
|
||||
"nav": {
|
||||
"home": "Начало",
|
||||
"docs": "Документация",
|
||||
"chat": "Чат",
|
||||
"mission": "Mission",
|
||||
"login": "Sign in",
|
||||
"register": "Sign up",
|
||||
"account": "Account",
|
||||
"logout": "Sign out"
|
||||
},
|
||||
"theme": {
|
||||
"toggle": {
|
||||
"toLight": "Превключване към светла тема",
|
||||
"toDark": "Превключване към тъмна тема"
|
||||
}
|
||||
},
|
||||
"lang": {
|
||||
"bg": "български",
|
||||
"de": "немски",
|
||||
"el": "гръцки",
|
||||
"en": "английски",
|
||||
"es": "испански",
|
||||
"et": "естонски",
|
||||
"fr": "френски",
|
||||
"he": "иврит",
|
||||
"it": "италиански",
|
||||
"nl": "Нидерландски",
|
||||
"da": "Датски",
|
||||
"fi": "Фински",
|
||||
"no": "Норвежки",
|
||||
"sv": "Шведски",
|
||||
"ar": "Арабски",
|
||||
"fa": "персийски",
|
||||
"sw": "суахили",
|
||||
"ha": "хауза",
|
||||
"am": "амхарски",
|
||||
"yo": "йоруба",
|
||||
"zu": "зулу",
|
||||
"ma": "дариджа",
|
||||
"ig": "игбо",
|
||||
"ka": "грузински",
|
||||
"kk": "казахски",
|
||||
"om": "оромо",
|
||||
"so": "сомалийски",
|
||||
"ti": "тигриња",
|
||||
"uz": "узбекски",
|
||||
"wo": "волоф",
|
||||
"pl": "полски",
|
||||
"pt": "португалски",
|
||||
"ro": "румънски",
|
||||
"ru": "руски",
|
||||
"sr": "сръбски",
|
||||
"tr": "турски",
|
||||
"uk": "украински"
|
||||
},
|
||||
"footer": {
|
||||
"copyright": "© {{year}} helexa.ai"
|
||||
},
|
||||
"beta": {
|
||||
"tag": "Public beta",
|
||||
"message": "helexa is in open beta — expect rough edges. Your chats stay in your browser.",
|
||||
"dismiss": "Dismiss"
|
||||
}
|
||||
}
|
||||
103
helexa.ai/src/i18n/resources/bg/mission.json
Normal file
@@ -0,0 +1,103 @@
|
||||
{
|
||||
"hero": {
|
||||
"badge": "Нова форма на интелигентност",
|
||||
"title": "Нова форма на интелигентност",
|
||||
"lead": "Helexa е самоорганизираща се AI мрежа, задвижвана от независими оператори. Отворена. Разпределена. Еволюираща.",
|
||||
"ctaJoinMesh": "Присъедини се към мрежата",
|
||||
"ctaFollowProject": "Следвай проекта",
|
||||
"subcopy": "Създадена за оператори, разработчици и общности, които вярват, че AI трябва да бъде отворен, устойчив и споделен.",
|
||||
"imageAlt": "Визуализация на хеликса на Helexa"
|
||||
},
|
||||
|
||||
"intent": {
|
||||
"title": "Защо съществува Helexa",
|
||||
"p1": "AI се превръща в най-мощната инфраструктура на Земята. Но днес тази сила е концентрирана в шепа корпорации, оформяна от частни приоритети, географски ограничения и крехка икономика.",
|
||||
"p2Intro": "Helexa си представя нещо различно:",
|
||||
"bullet1": "Интелигентност, която расте от всякъде, не от едно място.",
|
||||
"bullet2": "Мрежа, в която всеки може да допринася и да се възползва.",
|
||||
"bullet3": "Система, която се адаптира към търсенето, не към заповеди.",
|
||||
"bullet4": "Технология, която подсилва общностите вместо да ги заменя.",
|
||||
"closing": "Helexa не е платформа. Не е и облак.\nТя е мрежа — жива, еволюираща решетка от независими оператори, която оформя нов вид интелигентност."
|
||||
},
|
||||
|
||||
"whyNow": {
|
||||
"title": "Преломен момент за AI",
|
||||
"problemTitle": "Проблемът",
|
||||
"problemBullet1": "AI се централизира по-бързо от всяка предишна технология.",
|
||||
"problemBullet2": "Достъпът до изчислителни ресурси определя възможностите, а достъпът се стеснява.",
|
||||
"problemBullet3": "Ценовите бариери изключват изследователи, стартъпи и общности.",
|
||||
"problemBullet4": "Геополитически и регулаторни натиск застрашават глобалната достъпност.",
|
||||
"problemBullet5": "Създателите на модели и операторите на хардуер рядко споделят стойността, която създават.",
|
||||
"opportunityTitle": "Възможността",
|
||||
"opportunityIntro": "Но един разпределен свят е възможен.",
|
||||
"opportunityBullet1": "Хиляди GPU машини вече са недоизползвани по целия свят.",
|
||||
"opportunityBullet2": "Операторите искат честно заплащане за изчислителната си мощ.",
|
||||
"opportunityBullet3": "Разработчиците искат отворена, устойчива на цензура инфраструктура.",
|
||||
"opportunityBullet4": "Общностите искат суверенитет и устойчивост в дигиталните системи.",
|
||||
"opportunityBullet5": "Ръстът на AI изпревари традиционните облаци — нужни са нови форми.",
|
||||
"opportunityClosing": "Helexa е моментът, в който тези сили се подравняват."
|
||||
},
|
||||
|
||||
"howItWorks": {
|
||||
"title": "Как се формира мрежата",
|
||||
"operators": {
|
||||
"eyebrow": "Операторите стартират възли",
|
||||
"title": "Всеки може да допринесе с изчислителна мощ.",
|
||||
"body": "Операторите стартират възли на Helexa. Те решават кои модели да хостват. Остават в контрол над своя хардуер и своята икономика. Без одобрения, без посредници."
|
||||
},
|
||||
"routing": {
|
||||
"eyebrow": "Мрежата маршрутизира интелигентността",
|
||||
"title": "Търсенето тече през мрежата.",
|
||||
"body": "Helexa научава къде има капацитет, къде търсенето нараства и кои възли са най-подходящи да обслужват заявките. Мрежата се адаптира органично — като растяща спирала."
|
||||
},
|
||||
"value": {
|
||||
"eyebrow": "Стойността се връща обратно",
|
||||
"title": "Работата е доказуема. Заплащането е честно.",
|
||||
"body": "Всяка задача носи криптографско потвърждение. Операторите печелят за интелигентността, която помагат да се предостави. Без платформи данъкоплатци. Без непрозрачни сметки."
|
||||
}
|
||||
},
|
||||
|
||||
"principles": {
|
||||
"title": "Изградена върху принципи, не върху платформи",
|
||||
"distributed": {
|
||||
"title": "Разпределена по замисъл",
|
||||
"body": "Без една-единствена точка на отказ. Без централен орган. Мрежа, която става по-силна с всеки нов оператор."
|
||||
},
|
||||
"participation": {
|
||||
"title": "Отворено участие",
|
||||
"body": "Ако имаш изчислителни ресурси, можеш да допринесеш. Мрежата посреща всички — edge устройства, домашни сървъри, центрове за данни."
|
||||
},
|
||||
"fairness": {
|
||||
"title": "Справедливост и прозрачност",
|
||||
"body": "Приходите се базират на реална свършена работа, криптографски потвърдена. Без черни кутии. Без скрити такси."
|
||||
},
|
||||
"evolving": {
|
||||
"title": "Еволюираща интелигентност",
|
||||
"body": "Мрежата учи от търсенето. Моделите се зареждат там, където са нужни. Интелигентността се разпространява чрез сътрудничество."
|
||||
}
|
||||
},
|
||||
|
||||
"roadAhead": {
|
||||
"title": "Какво цели да стане Helexa",
|
||||
"p1": "Глобален слой от интелигентност, който принадлежи на всички, задвижван от спирала от възли и общности.",
|
||||
"p2": "Мрежа, устойчива на сривове, политика, монополи и провали.",
|
||||
"p3": "Нов икономически модел, в който оператори, създатели и потребители печелят заедно.",
|
||||
"p4": "Екосистема, в която иновациите растат от периферията — не от центъра.",
|
||||
"card": {
|
||||
"eyebrow": "Миг от визията",
|
||||
"title": "Към споделена мрежа от интелигентност",
|
||||
"body": "Helexa е в ранен етап. Идеите са по-големи от реализацията — и това е умишлено. Мрежата ще расте итеративно, оформяна от операторите и разработчиците."
|
||||
}
|
||||
},
|
||||
|
||||
"joinMesh": {
|
||||
"badge": "Ранен етап",
|
||||
"title": "Мрежата се формира.",
|
||||
"titleHighlight": "Ти можеш да бъдеш част от нея.",
|
||||
"lead": "Независимо дали управляваш хардуер, изграждаш модели или просто ти пука как се управлява AI, има място за теб в мрежата.",
|
||||
"ctaRunNode": "Пусни възел (скоро)",
|
||||
"ctaJoinAnnouncements": "Включи се в ранните известия",
|
||||
"ctaExploreCode": "Разгледай кода",
|
||||
"footer": "Без оградени градини. Без един собственик. Само мрежа от хора, хардуер и идеи — които заедно създават различно бъдеще за интелигентността."
|
||||
}
|
||||
}
|
||||
71
helexa.ai/src/i18n/resources/da/account.json
Normal file
@@ -0,0 +1,71 @@
|
||||
{
|
||||
"login": {
|
||||
"title": "Sign in",
|
||||
"email": "Email",
|
||||
"password": "Password",
|
||||
"submit": "Sign in",
|
||||
"noAccount": "No account? Sign up"
|
||||
},
|
||||
"register": {
|
||||
"title": "Create your account",
|
||||
"email": "Email",
|
||||
"password": "Password",
|
||||
"submit": "Sign up",
|
||||
"haveAccount": "Already have an account? Sign in",
|
||||
"checkEmail": "Almost there — check your email to verify your account."
|
||||
},
|
||||
"verify": {
|
||||
"verifying": "Verifying…",
|
||||
"ok": "Email verified. You can now sign in.",
|
||||
"failed": "This verification link is invalid or has expired.",
|
||||
"toLogin": "Go to sign in"
|
||||
},
|
||||
"reset": {
|
||||
"requestTitle": "Reset your password",
|
||||
"email": "Email",
|
||||
"requestSubmit": "Send reset link",
|
||||
"requestDone": "If that email has an account, a reset link is on its way.",
|
||||
"confirmTitle": "Choose a new password",
|
||||
"newPassword": "New password",
|
||||
"confirmSubmit": "Set password",
|
||||
"ok": "Password updated. You can now sign in."
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Account",
|
||||
"balance": "Allocation",
|
||||
"total": "Total",
|
||||
"spent": "Spent",
|
||||
"reserved": "Reserved",
|
||||
"remaining": "Remaining",
|
||||
"manageKeys": "Manage API keys",
|
||||
"redeemTitle": "Redeem a top-up code",
|
||||
"redeemPlaceholder": "helexa-topup-…",
|
||||
"redeem": "Redeem",
|
||||
"redeemed": "Code redeemed.",
|
||||
"logout": "Sign out"
|
||||
},
|
||||
"keys": {
|
||||
"title": "API keys",
|
||||
"create": "Create key",
|
||||
"label": "Label",
|
||||
"limitKind": "Limit",
|
||||
"percent": "% of allocation",
|
||||
"hardcap": "Hard cap (tokens)",
|
||||
"value": "Value",
|
||||
"none": "No keys yet.",
|
||||
"createdTitle": "Your new API key",
|
||||
"createdWarn": "Copy it now — you won't see it again.",
|
||||
"copy": "Copy",
|
||||
"copied": "Copied",
|
||||
"archive": "Archive",
|
||||
"save": "Save",
|
||||
"status": "Status",
|
||||
"usage": "Used",
|
||||
"useForChat": "Use for chat on this device",
|
||||
"usedForChat": "Enabled for chat ✓"
|
||||
},
|
||||
"error": {
|
||||
"generic": "Something went wrong.",
|
||||
"unauthorized": "Please sign in again."
|
||||
}
|
||||
}
|
||||
21
helexa.ai/src/i18n/resources/da/chat.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"title": "Samtalearbejdsområde",
|
||||
"badge": "Chat",
|
||||
"lead": "Dette er chatvisningen. Tilføj din samtalelogik og dine UI‑komponenter til denne side.",
|
||||
"transcriptPlaceholder": "Chattransskriptionen vises her. Gengiv beskeder fra modellen og brugeren i en rullende container, eventuelt grupperet efter tur.",
|
||||
"inputPlaceholder": "Skriv en besked for at starte en chat…",
|
||||
"send": "Send",
|
||||
"clear": "Ryd",
|
||||
"newChat": "New chat",
|
||||
"newProject": "New project",
|
||||
"newProjectName": "New project",
|
||||
"unsorted": "Unsorted",
|
||||
"emptyState": "Start a conversation. Your history stays in this browser.",
|
||||
"anonBanner": "You have reached the anonymous limit. Sign up for a free allocation.",
|
||||
"signUp": "Sign up",
|
||||
"stop": "Stop",
|
||||
"topUp": "Top up",
|
||||
"rateLimited": "Rate limited — wait a moment and retry.",
|
||||
"needsKey": "Create an API key and enable it for chat to send as yourself.",
|
||||
"manageKeysLink": "Manage keys"
|
||||
}
|
||||
68
helexa.ai/src/i18n/resources/da/common.json
Normal file
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"app": {
|
||||
"name": "helexa.ai"
|
||||
},
|
||||
"nav": {
|
||||
"home": "Hjem",
|
||||
"docs": "Dokumentation",
|
||||
"chat": "Chat",
|
||||
"mission": "Mission",
|
||||
"login": "Sign in",
|
||||
"register": "Sign up",
|
||||
"account": "Account",
|
||||
"logout": "Sign out"
|
||||
},
|
||||
"theme": {
|
||||
"toggle": {
|
||||
"toLight": "Skift til lyst tema",
|
||||
"toDark": "Skift til mørkt tema"
|
||||
}
|
||||
},
|
||||
"lang": {
|
||||
"am": "Amharisk",
|
||||
"ar": "Arabisk",
|
||||
"bg": "Bulgarsk",
|
||||
"da": "Dansk",
|
||||
"de": "Tysk",
|
||||
"el": "Græsk",
|
||||
"en": "Engelsk",
|
||||
"es": "Spansk",
|
||||
"et": "Estisk",
|
||||
"fa": "Persisk",
|
||||
"fi": "Finsk",
|
||||
"fr": "Fransk",
|
||||
"ha": "Hausa",
|
||||
"he": "Hebraisk",
|
||||
"ig": "Igbo",
|
||||
"it": "Italiensk",
|
||||
"ka": "Georgisk",
|
||||
"kk": "Kasakhisk",
|
||||
"ma": "Darija",
|
||||
"nl": "Hollandsk",
|
||||
"no": "Norsk",
|
||||
"om": "Oromo",
|
||||
"pl": "Polsk",
|
||||
"pt": "Portugisisk",
|
||||
"ro": "Rumænsk",
|
||||
"ru": "Russisk",
|
||||
"so": "Somalisk",
|
||||
"sr": "Serbisk",
|
||||
"sv": "Svensk",
|
||||
"sw": "Swahili",
|
||||
"ti": "Tigrinya",
|
||||
"tr": "Tyrkisk",
|
||||
"uk": "Ukrainsk",
|
||||
"uz": "Usbekisk",
|
||||
"wo": "Wolof",
|
||||
"yo": "Yoruba",
|
||||
"zu": "Zulu"
|
||||
},
|
||||
"footer": {
|
||||
"copyright": "© {{year}} helexa.ai"
|
||||
},
|
||||
"beta": {
|
||||
"tag": "Public beta",
|
||||
"message": "helexa is in open beta — expect rough edges. Your chats stay in your browser.",
|
||||
"dismiss": "Dismiss"
|
||||
}
|
||||
}
|
||||
103
helexa.ai/src/i18n/resources/da/mission.json
Normal file
@@ -0,0 +1,103 @@
|
||||
{
|
||||
"hero": {
|
||||
"badge": "En ny form for intelligens",
|
||||
"title": "En ny form for intelligens",
|
||||
"lead": "Helexa er et selvorganiserende AI‑mesh drevet af uafhængige operatører. Åbent. Distribueret. Under udvikling.",
|
||||
"ctaJoinMesh": "Deltag i mesh'en",
|
||||
"ctaFollowProject": "Følg projektet",
|
||||
"subcopy": "Bygget til operatører, udviklere og fællesskaber, der mener, at AI skal være åbent, robust og delt.",
|
||||
"imageAlt": "Helexa helix‑visualisering"
|
||||
},
|
||||
|
||||
"intent": {
|
||||
"title": "Hvorfor Helexa findes",
|
||||
"p1": "AI er ved at blive den mest magtfulde infrastruktur på Jorden. Men i dag er den magt koncentreret hos en håndfuld virksomheder, formet af private prioriteter, geografiske begrænsninger og skrøbelig økonomi.",
|
||||
"p2Intro": "Helexa forestiller sig noget andet:",
|
||||
"bullet1": "Intelligens, der vokser frem overalt – ikke ét sted.",
|
||||
"bullet2": "Et netværk, hvor alle kan bidrage og få gavn.",
|
||||
"bullet3": "Et system, der tilpasser sig efter efterspørgsel, ikke direktiver.",
|
||||
"bullet4": "Teknologi, der styrker fællesskaber i stedet for at erstatte dem.",
|
||||
"closing": "Helexa er ikke en platform. Det er ikke en cloud.\nDet er et mesh – et levende, udviklende gitter af uafhængige operatører, der danner en ny form for intelligens."
|
||||
},
|
||||
|
||||
"whyNow": {
|
||||
"title": "Et vendepunkt for AI",
|
||||
"problemTitle": "Problemet",
|
||||
"problemBullet1": "AI centraliseres hurtigere end nogen tidligere teknologi.",
|
||||
"problemBullet2": "Adgang til compute definerer kapacitet, og adgangen bliver snævrere.",
|
||||
"problemBullet3": "Omkostninger udelukker forskere, startups og fællesskaber.",
|
||||
"problemBullet4": "Geopolitiske og regulatoriske pres truer global tilgængelighed.",
|
||||
"problemBullet5": "Skaberne af modeller og operatørerne af hardware deler sjældent i den værdi, de producerer.",
|
||||
"opportunityTitle": "Muligheden",
|
||||
"opportunityIntro": "Men en distribueret verden er mulig.",
|
||||
"opportunityBullet1": "Tusindvis af GPU'er står allerede uudnyttede rundt om i verden.",
|
||||
"opportunityBullet2": "Operatører ønsker retfærdig kompensation for compute.",
|
||||
"opportunityBullet3": "Udviklere ønsker åben, censurresistent infrastruktur.",
|
||||
"opportunityBullet4": "Fællesskaber ønsker suverænitet og robusthed i digitale systemer.",
|
||||
"opportunityBullet5": "AI's vækst har overhalet traditionelle clouds – der er brug for nye former.",
|
||||
"opportunityClosing": "Helexa er øjeblikket, hvor disse kræfter mødes."
|
||||
},
|
||||
|
||||
"howItWorks": {
|
||||
"title": "Sådan dannes mesh'en",
|
||||
"operators": {
|
||||
"eyebrow": "Operatører kører noder",
|
||||
"title": "Alle kan bidrage med compute.",
|
||||
"body": "Operatører kører Helexa‑noder. De beslutter, hvilke modeller der skal hostes. De bevarer kontrollen over deres hardware og økonomi. Ingen godkendelser, ingen gatekeepere."
|
||||
},
|
||||
"routing": {
|
||||
"eyebrow": "Mesh'en dirigerer intelligens",
|
||||
"title": "Efterspørgslen flyder gennem netværket.",
|
||||
"body": "Helexa lærer, hvor kapacitet findes, hvor efterspørgslen stiger, og hvilke noder der er bedst egnet til at håndtere forespørgsler. Mesh'en tilpasser sig organisk – som en voksende helix."
|
||||
},
|
||||
"value": {
|
||||
"eyebrow": "Værdien flyder tilbage",
|
||||
"title": "Arbejdet bevises. Betalingen er retfærdig.",
|
||||
"body": "Hver opgave bærer en kryptografisk kvittering. Operatører tjener på den intelligens, de hjælper med at levere. Ingen platformsskat. Ingen uigennemsigtig fakturering."
|
||||
}
|
||||
},
|
||||
|
||||
"principles": {
|
||||
"title": "Bygget på principper, ikke platforme",
|
||||
"distributed": {
|
||||
"title": "Distribueret fra starten",
|
||||
"body": "Ingen enkelt fejlkilde. Ingen central autoritet. Et netværk, der bliver stærkere for hver ny operatør."
|
||||
},
|
||||
"participation": {
|
||||
"title": "Åben deltagelse",
|
||||
"body": "Hvis du har compute, kan du bidrage. Mesh'en byder alle velkommen – edge, hjemmeserver, datacenter."
|
||||
},
|
||||
"fairness": {
|
||||
"title": "Retfærdighed og gennemsigtighed",
|
||||
"body": "Indtjening er baseret på reelt arbejde, kryptografisk verificeret. Ingen sorte bokse. Ingen skjulte gebyrer."
|
||||
},
|
||||
"evolving": {
|
||||
"title": "Udviklende intelligens",
|
||||
"body": "Mesh'en lærer af efterspørgslen. Modeller loades dér, hvor de behøves. Intelligens spredes gennem samarbejde."
|
||||
}
|
||||
},
|
||||
|
||||
"roadAhead": {
|
||||
"title": "Hvad Helexa sigter mod at blive",
|
||||
"p1": "Et globalt intelligenslag, der tilhører alle, drevet af en helix af noder og fællesskaber.",
|
||||
"p2": "Et netværk, der er robust over for nedbrud, politik, monopoler og fejl.",
|
||||
"p3": "En ny økonomisk model, hvor operatører, udviklere og brugere alle får udbytte.",
|
||||
"p4": "Et økosystem, hvor innovation vokser ud fra kanterne – ikke centrum.",
|
||||
"card": {
|
||||
"eyebrow": "Visionsoversigt",
|
||||
"title": "Mod et delt intelligens‑mesh",
|
||||
"body": "Helexa er i en tidlig fase. Idéerne er større end implementationen – og det er med vilje. Netværket vil vokse gradvist, hvor operatører og udviklere sammen former dets udvikling."
|
||||
}
|
||||
},
|
||||
|
||||
"joinMesh": {
|
||||
"badge": "Tidlig fase",
|
||||
"title": "Mesh'en tager form.",
|
||||
"titleHighlight": "Du kan være en del af den.",
|
||||
"lead": "Uanset om du kører hardware, bygger modeller eller blot går op i, hvordan AI styres, er der en plads til dig i mesh'en.",
|
||||
"ctaRunNode": "Kør en node (snart)",
|
||||
"ctaJoinAnnouncements": "Deltag i tidlige annonceringer",
|
||||
"ctaExploreCode": "Udforsk koden",
|
||||
"footer": "Ingen lukkede haver. Ingen enkelt ejer. Bare et mesh af mennesker, hardware og idéer – der sammen former en anderledes fremtid for intelligens."
|
||||
}
|
||||
}
|
||||
71
helexa.ai/src/i18n/resources/de/account.json
Normal file
@@ -0,0 +1,71 @@
|
||||
{
|
||||
"login": {
|
||||
"title": "Sign in",
|
||||
"email": "Email",
|
||||
"password": "Password",
|
||||
"submit": "Sign in",
|
||||
"noAccount": "No account? Sign up"
|
||||
},
|
||||
"register": {
|
||||
"title": "Create your account",
|
||||
"email": "Email",
|
||||
"password": "Password",
|
||||
"submit": "Sign up",
|
||||
"haveAccount": "Already have an account? Sign in",
|
||||
"checkEmail": "Almost there — check your email to verify your account."
|
||||
},
|
||||
"verify": {
|
||||
"verifying": "Verifying…",
|
||||
"ok": "Email verified. You can now sign in.",
|
||||
"failed": "This verification link is invalid or has expired.",
|
||||
"toLogin": "Go to sign in"
|
||||
},
|
||||
"reset": {
|
||||
"requestTitle": "Reset your password",
|
||||
"email": "Email",
|
||||
"requestSubmit": "Send reset link",
|
||||
"requestDone": "If that email has an account, a reset link is on its way.",
|
||||
"confirmTitle": "Choose a new password",
|
||||
"newPassword": "New password",
|
||||
"confirmSubmit": "Set password",
|
||||
"ok": "Password updated. You can now sign in."
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Account",
|
||||
"balance": "Allocation",
|
||||
"total": "Total",
|
||||
"spent": "Spent",
|
||||
"reserved": "Reserved",
|
||||
"remaining": "Remaining",
|
||||
"manageKeys": "Manage API keys",
|
||||
"redeemTitle": "Redeem a top-up code",
|
||||
"redeemPlaceholder": "helexa-topup-…",
|
||||
"redeem": "Redeem",
|
||||
"redeemed": "Code redeemed.",
|
||||
"logout": "Sign out"
|
||||
},
|
||||
"keys": {
|
||||
"title": "API keys",
|
||||
"create": "Create key",
|
||||
"label": "Label",
|
||||
"limitKind": "Limit",
|
||||
"percent": "% of allocation",
|
||||
"hardcap": "Hard cap (tokens)",
|
||||
"value": "Value",
|
||||
"none": "No keys yet.",
|
||||
"createdTitle": "Your new API key",
|
||||
"createdWarn": "Copy it now — you won't see it again.",
|
||||
"copy": "Copy",
|
||||
"copied": "Copied",
|
||||
"archive": "Archive",
|
||||
"save": "Save",
|
||||
"status": "Status",
|
||||
"usage": "Used",
|
||||
"useForChat": "Use for chat on this device",
|
||||
"usedForChat": "Enabled for chat ✓"
|
||||
},
|
||||
"error": {
|
||||
"generic": "Something went wrong.",
|
||||
"unauthorized": "Please sign in again."
|
||||
}
|
||||
}
|
||||
21
helexa.ai/src/i18n/resources/de/chat.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"title": "Konversationsbereich",
|
||||
"badge": "Chat",
|
||||
"lead": "Dies ist die Chat-Ansicht. Binde hier deine Konversationslogik und UI-Komponenten ein.",
|
||||
"transcriptPlaceholder": "Das Chat-Protokoll erscheint hier. Rendern die Nachrichten des Modells und der Nutzerin bzw. des Nutzers in einem scrollbaren Container, optional nach Gesprächsrunden gruppiert.",
|
||||
"inputPlaceholder": "Schreibe eine Nachricht, um mit dem Chat zu beginnen…",
|
||||
"send": "Senden",
|
||||
"clear": "Leeren",
|
||||
"newChat": "New chat",
|
||||
"newProject": "New project",
|
||||
"newProjectName": "New project",
|
||||
"unsorted": "Unsorted",
|
||||
"emptyState": "Start a conversation. Your history stays in this browser.",
|
||||
"anonBanner": "You have reached the anonymous limit. Sign up for a free allocation.",
|
||||
"signUp": "Sign up",
|
||||
"stop": "Stop",
|
||||
"topUp": "Top up",
|
||||
"rateLimited": "Rate limited — wait a moment and retry.",
|
||||
"needsKey": "Create an API key and enable it for chat to send as yourself.",
|
||||
"manageKeysLink": "Manage keys"
|
||||
}
|
||||
68
helexa.ai/src/i18n/resources/de/common.json
Normal file
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"app": {
|
||||
"name": "helexa.ai"
|
||||
},
|
||||
"nav": {
|
||||
"home": "Startseite",
|
||||
"docs": "Dokumentation",
|
||||
"chat": "Chat",
|
||||
"mission": "Mission",
|
||||
"login": "Sign in",
|
||||
"register": "Sign up",
|
||||
"account": "Account",
|
||||
"logout": "Sign out"
|
||||
},
|
||||
"theme": {
|
||||
"toggle": {
|
||||
"toLight": "In den hellen Modus wechseln",
|
||||
"toDark": "In den dunklen Modus wechseln"
|
||||
}
|
||||
},
|
||||
"lang": {
|
||||
"bg": "Bulgarisch",
|
||||
"de": "Deutsch",
|
||||
"el": "Griechisch",
|
||||
"en": "Englisch",
|
||||
"es": "Spanisch",
|
||||
"et": "Estnisch",
|
||||
"fr": "Französisch",
|
||||
"he": "Hebräisch",
|
||||
"it": "Italienisch",
|
||||
"nl": "Niederländisch",
|
||||
"da": "Dänisch",
|
||||
"fi": "Finnisch",
|
||||
"no": "Norwegisch",
|
||||
"sv": "Schwedisch",
|
||||
"ar": "Arabisch",
|
||||
"fa": "Persisch",
|
||||
"sw": "Suaheli",
|
||||
"ha": "Hausa",
|
||||
"am": "Amharisch",
|
||||
"yo": "Yoruba",
|
||||
"zu": "Zulu",
|
||||
"ma": "Darija",
|
||||
"ig": "Igbo",
|
||||
"ka": "Georgisch",
|
||||
"kk": "Kasachisch",
|
||||
"om": "Oromo",
|
||||
"so": "Somali",
|
||||
"ti": "Tigrinya",
|
||||
"uz": "Usbekisch",
|
||||
"wo": "Wolof",
|
||||
"pl": "Polnisch",
|
||||
"pt": "Portugiesisch",
|
||||
"ro": "Rumänisch",
|
||||
"ru": "Russisch",
|
||||
"sr": "Serbisch",
|
||||
"tr": "Türkisch",
|
||||
"uk": "Ukrainisch"
|
||||
},
|
||||
"footer": {
|
||||
"copyright": "© {{year}} helexa.ai"
|
||||
},
|
||||
"beta": {
|
||||
"tag": "Public beta",
|
||||
"message": "helexa is in open beta — expect rough edges. Your chats stay in your browser.",
|
||||
"dismiss": "Dismiss"
|
||||
}
|
||||
}
|
||||
103
helexa.ai/src/i18n/resources/de/mission.json
Normal file
@@ -0,0 +1,103 @@
|
||||
{
|
||||
"hero": {
|
||||
"badge": "Eine neue Form von Intelligenz",
|
||||
"title": "Eine neue Form von Intelligenz",
|
||||
"lead": "Helexa ist ein selbstorganisiertes KI‑Mesh, betrieben von unabhängigen Operatoren. Offen. Dezentral. Im Wandel.",
|
||||
"ctaJoinMesh": "Dem Mesh beitreten",
|
||||
"ctaFollowProject": "Dem Projekt folgen",
|
||||
"subcopy": "Geschaffen für Operatoren, Builder und Communities, die glauben, dass KI offen, widerstandsfähig und geteilt sein sollte.",
|
||||
"imageAlt": "Helexa‑Helix‑Visualisierung"
|
||||
},
|
||||
|
||||
"intent": {
|
||||
"title": "Warum es Helexa gibt",
|
||||
"p1": "KI wird zur mächtigsten Infrastruktur auf der Erde. Doch heute ist diese Macht in den Händen weniger Konzerne konzentriert – geprägt von privaten Prioritäten, geografischen Grenzen und fragilen Geschäftsmodellen.",
|
||||
"p2Intro": "Helexa stellt sich etwas anderes vor:",
|
||||
"bullet1": "Intelligenz, die überall wächst – nicht nur an einem Ort.",
|
||||
"bullet2": "Ein Netzwerk, in dem jeder beitragen und profitieren kann.",
|
||||
"bullet3": "Ein System, das sich an Nachfrage anpasst, nicht an Vorgaben.",
|
||||
"bullet4": "Technologie, die Gemeinschaften stärkt, statt sie zu ersetzen.",
|
||||
"closing": "Helexa ist keine Plattform. Es ist keine Cloud.\nEs ist ein Mesh – ein lebendiges, sich entwickelndes Geflecht unabhängiger Operatoren, das eine neue Form von Intelligenz bildet."
|
||||
},
|
||||
|
||||
"whyNow": {
|
||||
"title": "Ein Wendepunkt für KI",
|
||||
"problemTitle": "Das Problem",
|
||||
"problemBullet1": "KI zentralisiert sich schneller als jede Technologie zuvor.",
|
||||
"problemBullet2": "Zugang zu Rechenressourcen bestimmt die Fähigkeiten – und dieser Zugang verengt sich.",
|
||||
"problemBullet3": "Kostenbarrieren schließen Forschende, Startups und Communities aus.",
|
||||
"problemBullet4": "Geopolitische und regulatorische Spannungen bedrohen die globale Verfügbarkeit.",
|
||||
"problemBullet5": "Die Schöpfer von Modellen und die Betreiber von Hardware teilen selten den Wert, den sie erzeugen.",
|
||||
"opportunityTitle": "Die Chance",
|
||||
"opportunityIntro": "Aber eine verteilte Welt ist möglich.",
|
||||
"opportunityBullet1": "Tausende GPUs weltweit sind heute bereits unterausgelastet.",
|
||||
"opportunityBullet2": "Operatoren wollen eine faire Vergütung für ihre Rechenleistung.",
|
||||
"opportunityBullet3": "Entwicklerinnen und Entwickler wünschen sich offene, zensurresistente Infrastruktur.",
|
||||
"opportunityBullet4": "Communities wollen Souveränität und Resilienz in digitalen Systemen.",
|
||||
"opportunityBullet5": "Das Wachstum der KI hat klassische Clouds überholt – es braucht neue Formen.",
|
||||
"opportunityClosing": "Helexa ist der Moment, in dem sich diese Kräfte ausrichten."
|
||||
},
|
||||
|
||||
"howItWorks": {
|
||||
"title": "Wie sich das Mesh bildet",
|
||||
"operators": {
|
||||
"eyebrow": "Operatoren betreiben Nodes",
|
||||
"title": "Jede und jeder kann Rechenleistung beisteuern.",
|
||||
"body": "Operatoren betreiben Helexa‑Nodes. Sie entscheiden, welche Modelle sie hosten. Sie behalten die Kontrolle über ihre Hardware und ihre Wirtschaftlichkeit. Keine Genehmigungen, keine Gatekeeper."
|
||||
},
|
||||
"routing": {
|
||||
"eyebrow": "Das Mesh leitet Intelligenz",
|
||||
"title": "Nachfrage fließt durch das Netzwerk.",
|
||||
"body": "Helexa lernt, wo Kapazität vorhanden ist, wo die Nachfrage steigt und welche Nodes sich am besten für Anfragen eignen. Das Mesh passt sich organisch an – wie eine wachsende Helix."
|
||||
},
|
||||
"value": {
|
||||
"eyebrow": "Wert fließt zurück",
|
||||
"title": "Arbeit wird bewiesen. Bezahlung ist fair.",
|
||||
"body": "Jeder Job trägt einen kryptografischen Beleg. Operatoren verdienen an der Intelligenz mit, die sie bereitstellen. Keine Plattformsteuer. Keine intransparente Abrechnung."
|
||||
}
|
||||
},
|
||||
|
||||
"principles": {
|
||||
"title": "Auf Prinzipien gebaut, nicht auf Plattformen",
|
||||
"distributed": {
|
||||
"title": "Von Grund auf verteilt",
|
||||
"body": "Kein Single Point of Failure. Keine zentrale Instanz. Ein Netzwerk, das mit jedem neuen Operator stärker wird."
|
||||
},
|
||||
"participation": {
|
||||
"title": "Offene Teilhabe",
|
||||
"body": "Wenn du Rechenleistung hast, kannst du beitragen. Das Mesh heißt alle willkommen – Edge, Heimserver oder Rechenzentrum."
|
||||
},
|
||||
"fairness": {
|
||||
"title": "Fairness & Transparenz",
|
||||
"body": "Einnahmen basieren auf echter, kryptografisch verifizierter Arbeit. Keine Blackboxen. Keine versteckten Gebühren."
|
||||
},
|
||||
"evolving": {
|
||||
"title": "Sich entwickelnde Intelligenz",
|
||||
"body": "Das Mesh lernt aus der Nachfrage. Modelle landen dort, wo sie gebraucht werden. Intelligenz breitet sich durch Kooperation aus."
|
||||
}
|
||||
},
|
||||
|
||||
"roadAhead": {
|
||||
"title": "Was Helexa werden will",
|
||||
"p1": "Eine globale Intelligenzschicht, die allen gehört – angetrieben von einer Helix aus Nodes und Communities.",
|
||||
"p2": "Ein Netzwerk, das Ausfällen, Politik, Monopolen und Störungen standhält.",
|
||||
"p3": "Ein neues Wirtschaftsmodell, in dem Operatoren, Builder und Nutzerinnen gleichermaßen profitieren.",
|
||||
"p4": "Ein Ökosystem, in dem Innovation von den Rändern ausgeht – nicht aus der Mitte.",
|
||||
"card": {
|
||||
"eyebrow": "Visions‑Snapshot",
|
||||
"title": "Auf dem Weg zu einem geteilten Intelligenz‑Mesh",
|
||||
"body": "Helexa steht am Anfang. Die Ideen sind größer als die aktuelle Implementierung – und das ist beabsichtigt. Das Netzwerk wird schrittweise wachsen, während Operatoren und Builder seine Entwicklung mitgestalten."
|
||||
}
|
||||
},
|
||||
|
||||
"joinMesh": {
|
||||
"badge": "Frühe Phase",
|
||||
"title": "Das Mesh formt sich.",
|
||||
"titleHighlight": "Du kannst Teil davon sein.",
|
||||
"lead": "Ob du Hardware betreibst, Modelle baust oder dir einfach wichtig ist, wie KI gesteuert wird – im Mesh gibt es einen Platz für dich.",
|
||||
"ctaRunNode": "Node betreiben (bald)",
|
||||
"ctaJoinAnnouncements": "Frühe Ankündigungen abonnieren",
|
||||
"ctaExploreCode": "Code erkunden",
|
||||
"footer": "Keine geschlossenen Gärten. Kein einzelner Besitzer. Nur ein Mesh aus Menschen, Hardware und Ideen – das eine andere Zukunft für Intelligenz komponiert."
|
||||
}
|
||||
}
|
||||
71
helexa.ai/src/i18n/resources/el/account.json
Normal file
@@ -0,0 +1,71 @@
|
||||
{
|
||||
"login": {
|
||||
"title": "Sign in",
|
||||
"email": "Email",
|
||||
"password": "Password",
|
||||
"submit": "Sign in",
|
||||
"noAccount": "No account? Sign up"
|
||||
},
|
||||
"register": {
|
||||
"title": "Create your account",
|
||||
"email": "Email",
|
||||
"password": "Password",
|
||||
"submit": "Sign up",
|
||||
"haveAccount": "Already have an account? Sign in",
|
||||
"checkEmail": "Almost there — check your email to verify your account."
|
||||
},
|
||||
"verify": {
|
||||
"verifying": "Verifying…",
|
||||
"ok": "Email verified. You can now sign in.",
|
||||
"failed": "This verification link is invalid or has expired.",
|
||||
"toLogin": "Go to sign in"
|
||||
},
|
||||
"reset": {
|
||||
"requestTitle": "Reset your password",
|
||||
"email": "Email",
|
||||
"requestSubmit": "Send reset link",
|
||||
"requestDone": "If that email has an account, a reset link is on its way.",
|
||||
"confirmTitle": "Choose a new password",
|
||||
"newPassword": "New password",
|
||||
"confirmSubmit": "Set password",
|
||||
"ok": "Password updated. You can now sign in."
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Account",
|
||||
"balance": "Allocation",
|
||||
"total": "Total",
|
||||
"spent": "Spent",
|
||||
"reserved": "Reserved",
|
||||
"remaining": "Remaining",
|
||||
"manageKeys": "Manage API keys",
|
||||
"redeemTitle": "Redeem a top-up code",
|
||||
"redeemPlaceholder": "helexa-topup-…",
|
||||
"redeem": "Redeem",
|
||||
"redeemed": "Code redeemed.",
|
||||
"logout": "Sign out"
|
||||
},
|
||||
"keys": {
|
||||
"title": "API keys",
|
||||
"create": "Create key",
|
||||
"label": "Label",
|
||||
"limitKind": "Limit",
|
||||
"percent": "% of allocation",
|
||||
"hardcap": "Hard cap (tokens)",
|
||||
"value": "Value",
|
||||
"none": "No keys yet.",
|
||||
"createdTitle": "Your new API key",
|
||||
"createdWarn": "Copy it now — you won't see it again.",
|
||||
"copy": "Copy",
|
||||
"copied": "Copied",
|
||||
"archive": "Archive",
|
||||
"save": "Save",
|
||||
"status": "Status",
|
||||
"usage": "Used",
|
||||
"useForChat": "Use for chat on this device",
|
||||
"usedForChat": "Enabled for chat ✓"
|
||||
},
|
||||
"error": {
|
||||
"generic": "Something went wrong.",
|
||||
"unauthorized": "Please sign in again."
|
||||
}
|
||||
}
|
||||
21
helexa.ai/src/i18n/resources/el/chat.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"title": "Χώρος συνομιλίας",
|
||||
"badge": "Συνομιλία",
|
||||
"lead": "Αυτή είναι η προβολή συνομιλίας. Σύνδεσε εδώ τη λογική της συζήτησης και τα components του περιβάλλοντος χρήστη σου.",
|
||||
"transcriptPlaceholder": "Το ιστορικό της συνομιλίας θα εμφανίζεται εδώ. Απόδωσε τα μηνύματα του μοντέλου και του χρήστη σε ένα κυλιόμενο container, προαιρετικά ομαδοποιημένα ανά γύρο.",
|
||||
"inputPlaceholder": "Πληκτρολόγησε ένα μήνυμα για να ξεκινήσεις τη συνομιλία…",
|
||||
"send": "Αποστολή",
|
||||
"clear": "Καθαρισμός",
|
||||
"newChat": "New chat",
|
||||
"newProject": "New project",
|
||||
"newProjectName": "New project",
|
||||
"unsorted": "Unsorted",
|
||||
"emptyState": "Start a conversation. Your history stays in this browser.",
|
||||
"anonBanner": "You have reached the anonymous limit. Sign up for a free allocation.",
|
||||
"signUp": "Sign up",
|
||||
"stop": "Stop",
|
||||
"topUp": "Top up",
|
||||
"rateLimited": "Rate limited — wait a moment and retry.",
|
||||
"needsKey": "Create an API key and enable it for chat to send as yourself.",
|
||||
"manageKeysLink": "Manage keys"
|
||||
}
|
||||
68
helexa.ai/src/i18n/resources/el/common.json
Normal file
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"app": {
|
||||
"name": "helexa.ai"
|
||||
},
|
||||
"nav": {
|
||||
"home": "Αρχική",
|
||||
"docs": "Τεκμηρίωση",
|
||||
"chat": "Συνομιλία",
|
||||
"mission": "Mission",
|
||||
"login": "Sign in",
|
||||
"register": "Sign up",
|
||||
"account": "Account",
|
||||
"logout": "Sign out"
|
||||
},
|
||||
"theme": {
|
||||
"toggle": {
|
||||
"toLight": "Μετάβαση σε φωτεινή λειτουργία",
|
||||
"toDark": "Μετάβαση σε σκοτεινή λειτουργία"
|
||||
}
|
||||
},
|
||||
"lang": {
|
||||
"bg": "Βουλγαρικά",
|
||||
"de": "Γερμανικά",
|
||||
"el": "Ελληνικά",
|
||||
"en": "Αγγλικά",
|
||||
"es": "Ισπανικά",
|
||||
"et": "Εσθονικά",
|
||||
"fr": "Γαλλικά",
|
||||
"he": "Εβραϊκά",
|
||||
"it": "Ιταλικά",
|
||||
"nl": "Ολλανδικά",
|
||||
"da": "Δανικά",
|
||||
"fi": "Φινλανδικά",
|
||||
"no": "Νορβηγικά",
|
||||
"sv": "Σουηδικά",
|
||||
"ar": "Αραβικά",
|
||||
"fa": "Περσικά",
|
||||
"sw": "Σουαχίλι",
|
||||
"ha": "Χάουσα",
|
||||
"am": "Αμχαρικά",
|
||||
"yo": "Γιορούμπα",
|
||||
"zu": "Ζουλού",
|
||||
"ma": "Νταρίτζα",
|
||||
"ig": "Ίγκμπο",
|
||||
"ka": "Γεωργιανά",
|
||||
"kk": "Καζακικά",
|
||||
"om": "Ορόμο",
|
||||
"so": "Σομαλικά",
|
||||
"ti": "Τιγκρινια",
|
||||
"uz": "Ουζμπεκικά",
|
||||
"wo": "Γουόλοφ",
|
||||
"pl": "Πολωνικά",
|
||||
"pt": "Πορτογαλικά",
|
||||
"ro": "Ρουμανικά",
|
||||
"ru": "Ρωσικά",
|
||||
"sr": "Σερβικά",
|
||||
"tr": "Τουρκικά",
|
||||
"uk": "Ουκρανικά"
|
||||
},
|
||||
"footer": {
|
||||
"copyright": "© {{year}} helexa.ai"
|
||||
},
|
||||
"beta": {
|
||||
"tag": "Public beta",
|
||||
"message": "helexa is in open beta — expect rough edges. Your chats stay in your browser.",
|
||||
"dismiss": "Dismiss"
|
||||
}
|
||||
}
|
||||
103
helexa.ai/src/i18n/resources/el/mission.json
Normal file
@@ -0,0 +1,103 @@
|
||||
{
|
||||
"hero": {
|
||||
"badge": "Μια νέα μορφή νοημοσύνης",
|
||||
"title": "Μια νέα μορφή νοημοσύνης",
|
||||
"lead": "Η Helexa είναι ένα αυτο-οργανωμένο πλέγμα ΤΝ που τροφοδοτείται από ανεξάρτητους operators. Ανοιχτό. Κατανεμημένο. Εξελισσόμενο.",
|
||||
"ctaJoinMesh": "Γίνε μέρος του πλέγματος",
|
||||
"ctaFollowProject": "Παρακολούθησε το έργο",
|
||||
"subcopy": "Φτιαγμένο για operators, builders και κοινότητες που πιστεύουν ότι η ΤΝ πρέπει να είναι ανοιχτή, ανθεκτική και κοινόχρηστη.",
|
||||
"imageAlt": "Οπτικοποίηση της έλικας Helexa"
|
||||
},
|
||||
|
||||
"intent": {
|
||||
"title": "Γιατί υπάρχει η Helexa",
|
||||
"p1": "Η ΤΝ γίνεται η πιο ισχυρή υποδομή στον κόσμο. Σήμερα όμως, αυτή η ισχύς είναι συγκεντρωμένη σε λίγες εταιρείες, διαμορφωμένη από ιδιωτικές προτεραιότητες, γεωγραφικούς περιορισμούς και εύθραυστες οικονομίες.",
|
||||
"p2Intro": "Η Helexa φαντάζεται κάτι διαφορετικό:",
|
||||
"bullet1": "Μια νοημοσύνη που αναπτύσσεται από παντού, όχι από ένα μόνο σημείο.",
|
||||
"bullet2": "Ένα δίκτυο στο οποίο ο καθένας μπορεί να συνεισφέρει και να ωφεληθεί.",
|
||||
"bullet3": "Ένα σύστημα που προσαρμόζεται στη ζήτηση, όχι στις εντολές.",
|
||||
"bullet4": "Τεχνολογία που ενδυναμώνει τις κοινότητες αντί να τις αντικαθιστά.",
|
||||
"closing": "Η Helexa δεν είναι πλατφόρμα. Δεν είναι cloud.\nΕίναι ένα πλέγμα — ένα ζωντανό, εξελισσόμενο δίκτυο ανεξάρτητων operators που σχηματίζουν ένα νέο είδος νοημοσύνης."
|
||||
},
|
||||
|
||||
"whyNow": {
|
||||
"title": "Ένα σημείο καμπής για την ΤΝ",
|
||||
"problemTitle": "Το πρόβλημα",
|
||||
"problemBullet1": "Η ΤΝ συγκεντρώνεται πιο γρήγορα από κάθε προηγούμενη τεχνολογία.",
|
||||
"problemBullet2": "Η πρόσβαση σε υπολογιστική ισχύ καθορίζει τις δυνατότητες και αυτή η πρόσβαση περιορίζεται.",
|
||||
"problemBullet3": "Τα κόστη αποκλείουν ερευνητές, startups και κοινότητες.",
|
||||
"problemBullet4": "Γεωπολιτικές και ρυθμιστικές πιέσεις απειλούν τη συνολική διαθεσιμότητα.",
|
||||
"problemBullet5": "Οι δημιουργοί μοντέλων και οι operators υλικού σπάνια μοιράζονται την αξία που παράγουν.",
|
||||
"opportunityTitle": "Η ευκαιρία",
|
||||
"opportunityIntro": "Όμως ένας κατανεμημένος κόσμος είναι εφικτός.",
|
||||
"opportunityBullet1": "Χιλιάδες GPUs σε όλο τον κόσμο παραμένουν υποαπασχολημένες.",
|
||||
"opportunityBullet2": "Οι operators θέλουν δίκαιη αποζημίωση για την υπολογιστική ισχύ.",
|
||||
"opportunityBullet3": "Οι developers θέλουν ανοιχτή, ανθεκτική στη λογοκρισία υποδομή.",
|
||||
"opportunityBullet4": "Οι κοινότητες θέλουν κυριαρχία και ανθεκτικότητα στα ψηφιακά τους συστήματα.",
|
||||
"opportunityBullet5": "Η ανάπτυξη της ΤΝ έχει ξεπεράσει τα παραδοσιακά clouds — χρειάζονται νέες μορφές.",
|
||||
"opportunityClosing": "Η Helexa είναι η στιγμή όπου αυτές οι δυνάμεις ευθυγραμμίζονται."
|
||||
},
|
||||
|
||||
"howItWorks": {
|
||||
"title": "Πώς σχηματίζεται το πλέγμα",
|
||||
"operators": {
|
||||
"eyebrow": "Οι operators τρέχουν nodes",
|
||||
"title": "Ο καθένας μπορεί να συνεισφέρει υπολογιστική ισχύ.",
|
||||
"body": "Οι operators τρέχουν nodes της Helexa. Αποφασίζουν ποια μοντέλα θα φιλοξενήσουν. Παραμένουν σε έλεγχο του υλικού και της οικονομίας τους. Χωρίς εγκρίσεις, χωρίς μεσάζοντες gatekeepers."
|
||||
},
|
||||
"routing": {
|
||||
"eyebrow": "Το πλέγμα δρομολογεί νοημοσύνη",
|
||||
"title": "Η ζήτηση ρέει μέσα από το δίκτυο.",
|
||||
"body": "Η Helexa μαθαίνει πού υπάρχει διαθέσιμη ισχύς, πού αυξάνεται η ζήτηση και ποια nodes είναι πιο κατάλληλα για να εξυπηρετήσουν τα αιτήματα. Το πλέγμα προσαρμόζεται οργανικά — σαν μια αναπτυσσόμενη έλικα."
|
||||
},
|
||||
"value": {
|
||||
"eyebrow": "Η αξία επιστρέφει πίσω",
|
||||
"title": "Η εργασία αποδεικνύεται. Η πληρωμή είναι δίκαιη.",
|
||||
"body": "Κάθε εργασία φέρει μια κρυπτογραφική απόδειξη. Οι operators αμείβονται για τη νοημοσύνη που βοηθούν να παραχθεί. Χωρίς φόρο πλατφόρμας. Χωρίς αδιαφανή τιμολόγηση."
|
||||
}
|
||||
},
|
||||
|
||||
"principles": {
|
||||
"title": "Χτισμένη σε αρχές, όχι σε πλατφόρμες",
|
||||
"distributed": {
|
||||
"title": "Κατανεμημένη από σχεδιασμό",
|
||||
"body": "Χωρίς μοναδικό σημείο αποτυχίας. Χωρίς κεντρική αρχή. Ένα δίκτυο που γίνεται ισχυρότερο με κάθε νέο operator."
|
||||
},
|
||||
"participation": {
|
||||
"title": "Ανοιχτή συμμετοχή",
|
||||
"body": "Αν έχεις υπολογιστική ισχύ, μπορείς να συνεισφέρεις. Το πλέγμα καλωσορίζει τους πάντες — edge, οικιακό server, datacenter."
|
||||
},
|
||||
"fairness": {
|
||||
"title": "Δικαιοσύνη & διαφάνεια",
|
||||
"body": "Τα έσοδα βασίζονται σε πραγματική, κρυπτογραφικά επαληθευμένη εργασία. Χωρίς «μαύρα κουτιά». Χωρίς κρυφές χρεώσεις."
|
||||
},
|
||||
"evolving": {
|
||||
"title": "Εξελισσόμενη νοημοσύνη",
|
||||
"body": "Το πλέγμα μαθαίνει από τη ζήτηση. Τα μοντέλα φορτώνονται εκεί όπου χρειάζονται. Η νοημοσύνη εξαπλώνεται μέσω συνεργασίας."
|
||||
}
|
||||
},
|
||||
|
||||
"roadAhead": {
|
||||
"title": "Τι στοχεύει να γίνει η Helexa",
|
||||
"p1": "Ένα παγκόσμιο επίπεδο νοημοσύνης που ανήκει σε όλους, τροφοδοτούμενο από μια έλικα από nodes και κοινότητες.",
|
||||
"p2": "Ένα δίκτυο ανθεκτικό σε διακοπές, πολιτική, μονοπώλια και αστοχίες.",
|
||||
"p3": "Ένα νέο οικονομικό μοντέλο όπου operators, builders και χρήστες ωφελούνται όλοι.",
|
||||
"p4": "Ένα οικοσύστημα όπου η καινοτομία αναπτύσσεται από τις άκρες — όχι από το κέντρο.",
|
||||
"card": {
|
||||
"eyebrow": "Στιγμιότυπο του οράματος",
|
||||
"title": "Προς ένα κοινό πλέγμα νοημοσύνης",
|
||||
"body": "Η Helexa βρίσκεται σε πρώιμο στάδιο. Οι ιδέες είναι μεγαλύτερες από την υλοποίηση — και αυτό είναι σκόπιμο. Το δίκτυο θα μεγαλώνει βήμα‑βήμα, με τους operators και τους builders να διαμορφώνουν την εξέλιξή του."
|
||||
}
|
||||
},
|
||||
|
||||
"joinMesh": {
|
||||
"badge": "Πρώιμη φάση",
|
||||
"title": "Το πλέγμα σχηματίζεται.",
|
||||
"titleHighlight": "Μπορείς να είσαι μέρος του.",
|
||||
"lead": "Είτε τρέχεις hardware, είτε χτίζεις μοντέλα, είτε απλώς σε νοιάζει πώς κυβερνάται η ΤΝ, υπάρχει μια θέση για σένα μέσα στο πλέγμα.",
|
||||
"ctaRunNode": "Τρέξε ένα node (σύντομα)",
|
||||
"ctaJoinAnnouncements": "Μπες στις πρώιμες ανακοινώσεις",
|
||||
"ctaExploreCode": "Εξερεύνησε τον κώδικα",
|
||||
"footer": "Χωρίς περιφραγμένους κήπους. Χωρίς έναν μόνο ιδιοκτήτη. Μόνο ένα πλέγμα από ανθρώπους, hardware και ιδέες — που συνθέτουν ένα διαφορετικό μέλλον για τη νοημοσύνη."
|
||||
}
|
||||
}
|
||||
71
helexa.ai/src/i18n/resources/en/account.json
Normal file
@@ -0,0 +1,71 @@
|
||||
{
|
||||
"login": {
|
||||
"title": "Sign in",
|
||||
"email": "Email",
|
||||
"password": "Password",
|
||||
"submit": "Sign in",
|
||||
"noAccount": "No account? Sign up"
|
||||
},
|
||||
"register": {
|
||||
"title": "Create your account",
|
||||
"email": "Email",
|
||||
"password": "Password",
|
||||
"submit": "Sign up",
|
||||
"haveAccount": "Already have an account? Sign in",
|
||||
"checkEmail": "Almost there — check your email to verify your account."
|
||||
},
|
||||
"verify": {
|
||||
"verifying": "Verifying…",
|
||||
"ok": "Email verified. You can now sign in.",
|
||||
"failed": "This verification link is invalid or has expired.",
|
||||
"toLogin": "Go to sign in"
|
||||
},
|
||||
"reset": {
|
||||
"requestTitle": "Reset your password",
|
||||
"email": "Email",
|
||||
"requestSubmit": "Send reset link",
|
||||
"requestDone": "If that email has an account, a reset link is on its way.",
|
||||
"confirmTitle": "Choose a new password",
|
||||
"newPassword": "New password",
|
||||
"confirmSubmit": "Set password",
|
||||
"ok": "Password updated. You can now sign in."
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Account",
|
||||
"balance": "Allocation",
|
||||
"total": "Total",
|
||||
"spent": "Spent",
|
||||
"reserved": "Reserved",
|
||||
"remaining": "Remaining",
|
||||
"manageKeys": "Manage API keys",
|
||||
"redeemTitle": "Redeem a top-up code",
|
||||
"redeemPlaceholder": "helexa-topup-…",
|
||||
"redeem": "Redeem",
|
||||
"redeemed": "Code redeemed.",
|
||||
"logout": "Sign out"
|
||||
},
|
||||
"keys": {
|
||||
"title": "API keys",
|
||||
"create": "Create key",
|
||||
"label": "Label",
|
||||
"limitKind": "Limit",
|
||||
"percent": "% of allocation",
|
||||
"hardcap": "Hard cap (tokens)",
|
||||
"value": "Value",
|
||||
"none": "No keys yet.",
|
||||
"createdTitle": "Your new API key",
|
||||
"createdWarn": "Copy it now — you won't see it again.",
|
||||
"copy": "Copy",
|
||||
"copied": "Copied",
|
||||
"archive": "Archive",
|
||||
"save": "Save",
|
||||
"status": "Status",
|
||||
"usage": "Used",
|
||||
"useForChat": "Use for chat on this device",
|
||||
"usedForChat": "Enabled for chat ✓"
|
||||
},
|
||||
"error": {
|
||||
"generic": "Something went wrong.",
|
||||
"unauthorized": "Please sign in again."
|
||||
}
|
||||
}
|
||||
21
helexa.ai/src/i18n/resources/en/chat.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"title": "Conversation workspace",
|
||||
"badge": "Chat",
|
||||
"lead": "This is the chat view. Plug your conversational logic and UI components into this page.",
|
||||
"transcriptPlaceholder": "Chat transcript will appear here. Render messages from the model and user in a scrolling container, optionally grouped by turn.",
|
||||
"inputPlaceholder": "Type a message to start chatting…",
|
||||
"send": "Send",
|
||||
"clear": "Clear",
|
||||
"newChat": "New chat",
|
||||
"newProject": "New project",
|
||||
"newProjectName": "New project",
|
||||
"unsorted": "Unsorted",
|
||||
"emptyState": "Start a conversation. Your history stays in this browser.",
|
||||
"anonBanner": "You have reached the anonymous limit. Sign up for a free allocation.",
|
||||
"signUp": "Sign up",
|
||||
"stop": "Stop",
|
||||
"topUp": "Top up",
|
||||
"rateLimited": "Rate limited — wait a moment and retry.",
|
||||
"needsKey": "Create an API key and enable it for chat to send as yourself.",
|
||||
"manageKeysLink": "Manage keys"
|
||||
}
|
||||
68
helexa.ai/src/i18n/resources/en/common.json
Normal file
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"app": {
|
||||
"name": "helexa.ai"
|
||||
},
|
||||
"nav": {
|
||||
"home": "Home",
|
||||
"docs": "Docs",
|
||||
"chat": "Chat",
|
||||
"mission": "Mission",
|
||||
"login": "Sign in",
|
||||
"register": "Sign up",
|
||||
"account": "Account",
|
||||
"logout": "Sign out"
|
||||
},
|
||||
"theme": {
|
||||
"toggle": {
|
||||
"toLight": "Switch to light mode",
|
||||
"toDark": "Switch to dark mode"
|
||||
}
|
||||
},
|
||||
"lang": {
|
||||
"bg": "Bulgarian",
|
||||
"de": "German",
|
||||
"el": "Greek",
|
||||
"en": "English",
|
||||
"es": "Spanish",
|
||||
"et": "Estonian",
|
||||
"fr": "French",
|
||||
"he": "Hebrew",
|
||||
"it": "Italian",
|
||||
"nl": "Dutch",
|
||||
"da": "Danish",
|
||||
"fi": "Finnish",
|
||||
"no": "Norwegian",
|
||||
"sv": "Swedish",
|
||||
"ar": "Arabic",
|
||||
"fa": "Persian",
|
||||
"sw": "Swahili",
|
||||
"ha": "Hausa",
|
||||
"am": "Amharic",
|
||||
"yo": "Yoruba",
|
||||
"zu": "Zulu",
|
||||
"ma": "Darija",
|
||||
"ig": "Igbo",
|
||||
"ka": "Georgian",
|
||||
"kk": "Kazakh",
|
||||
"om": "Oromo",
|
||||
"so": "Somali",
|
||||
"ti": "Tigrinya",
|
||||
"uz": "Uzbek",
|
||||
"wo": "Wolof",
|
||||
"pl": "Polish",
|
||||
"pt": "Portuguese",
|
||||
"ro": "Romanian",
|
||||
"ru": "Russian",
|
||||
"sr": "Serbian",
|
||||
"tr": "Turkish",
|
||||
"uk": "Ukrainian"
|
||||
},
|
||||
"footer": {
|
||||
"copyright": "© {{year}} helexa.ai"
|
||||
},
|
||||
"beta": {
|
||||
"tag": "Public beta",
|
||||
"message": "helexa is in open beta — expect rough edges. Your chats stay in your browser.",
|
||||
"dismiss": "Dismiss"
|
||||
}
|
||||
}
|
||||
103
helexa.ai/src/i18n/resources/en/mission.json
Normal file
@@ -0,0 +1,103 @@
|
||||
{
|
||||
"hero": {
|
||||
"badge": "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."
|
||||
}
|
||||
}
|
||||
71
helexa.ai/src/i18n/resources/es/account.json
Normal file
@@ -0,0 +1,71 @@
|
||||
{
|
||||
"login": {
|
||||
"title": "Sign in",
|
||||
"email": "Email",
|
||||
"password": "Password",
|
||||
"submit": "Sign in",
|
||||
"noAccount": "No account? Sign up"
|
||||
},
|
||||
"register": {
|
||||
"title": "Create your account",
|
||||
"email": "Email",
|
||||
"password": "Password",
|
||||
"submit": "Sign up",
|
||||
"haveAccount": "Already have an account? Sign in",
|
||||
"checkEmail": "Almost there — check your email to verify your account."
|
||||
},
|
||||
"verify": {
|
||||
"verifying": "Verifying…",
|
||||
"ok": "Email verified. You can now sign in.",
|
||||
"failed": "This verification link is invalid or has expired.",
|
||||
"toLogin": "Go to sign in"
|
||||
},
|
||||
"reset": {
|
||||
"requestTitle": "Reset your password",
|
||||
"email": "Email",
|
||||
"requestSubmit": "Send reset link",
|
||||
"requestDone": "If that email has an account, a reset link is on its way.",
|
||||
"confirmTitle": "Choose a new password",
|
||||
"newPassword": "New password",
|
||||
"confirmSubmit": "Set password",
|
||||
"ok": "Password updated. You can now sign in."
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Account",
|
||||
"balance": "Allocation",
|
||||
"total": "Total",
|
||||
"spent": "Spent",
|
||||
"reserved": "Reserved",
|
||||
"remaining": "Remaining",
|
||||
"manageKeys": "Manage API keys",
|
||||
"redeemTitle": "Redeem a top-up code",
|
||||
"redeemPlaceholder": "helexa-topup-…",
|
||||
"redeem": "Redeem",
|
||||
"redeemed": "Code redeemed.",
|
||||
"logout": "Sign out"
|
||||
},
|
||||
"keys": {
|
||||
"title": "API keys",
|
||||
"create": "Create key",
|
||||
"label": "Label",
|
||||
"limitKind": "Limit",
|
||||
"percent": "% of allocation",
|
||||
"hardcap": "Hard cap (tokens)",
|
||||
"value": "Value",
|
||||
"none": "No keys yet.",
|
||||
"createdTitle": "Your new API key",
|
||||
"createdWarn": "Copy it now — you won't see it again.",
|
||||
"copy": "Copy",
|
||||
"copied": "Copied",
|
||||
"archive": "Archive",
|
||||
"save": "Save",
|
||||
"status": "Status",
|
||||
"usage": "Used",
|
||||
"useForChat": "Use for chat on this device",
|
||||
"usedForChat": "Enabled for chat ✓"
|
||||
},
|
||||
"error": {
|
||||
"generic": "Something went wrong.",
|
||||
"unauthorized": "Please sign in again."
|
||||
}
|
||||
}
|
||||
21
helexa.ai/src/i18n/resources/es/chat.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"title": "Espacio de conversación",
|
||||
"badge": "Chat",
|
||||
"lead": "Esta es la vista de chat. Conecta aquí tu lógica conversacional y los componentes de interfaz que desees.",
|
||||
"transcriptPlaceholder": "La transcripción del chat aparecerá aquí. Renderiza los mensajes del modelo y de la persona usuaria en un contenedor desplazable, opcionalmente agrupados por turno.",
|
||||
"inputPlaceholder": "Escribe un mensaje para comenzar a chatear…",
|
||||
"send": "Enviar",
|
||||
"clear": "Limpiar",
|
||||
"newChat": "New chat",
|
||||
"newProject": "New project",
|
||||
"newProjectName": "New project",
|
||||
"unsorted": "Unsorted",
|
||||
"emptyState": "Start a conversation. Your history stays in this browser.",
|
||||
"anonBanner": "You have reached the anonymous limit. Sign up for a free allocation.",
|
||||
"signUp": "Sign up",
|
||||
"stop": "Stop",
|
||||
"topUp": "Top up",
|
||||
"rateLimited": "Rate limited — wait a moment and retry.",
|
||||
"needsKey": "Create an API key and enable it for chat to send as yourself.",
|
||||
"manageKeysLink": "Manage keys"
|
||||
}
|
||||
68
helexa.ai/src/i18n/resources/es/common.json
Normal file
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"app": {
|
||||
"name": "helexa.ai"
|
||||
},
|
||||
"nav": {
|
||||
"home": "Inicio",
|
||||
"docs": "Documentación",
|
||||
"chat": "Chat",
|
||||
"mission": "Mission",
|
||||
"login": "Sign in",
|
||||
"register": "Sign up",
|
||||
"account": "Account",
|
||||
"logout": "Sign out"
|
||||
},
|
||||
"theme": {
|
||||
"toggle": {
|
||||
"toLight": "Cambiar a modo claro",
|
||||
"toDark": "Cambiar a modo oscuro"
|
||||
}
|
||||
},
|
||||
"lang": {
|
||||
"bg": "Búlgaro",
|
||||
"de": "Alemán",
|
||||
"el": "Griego",
|
||||
"en": "Inglés",
|
||||
"es": "Español",
|
||||
"et": "Estonio",
|
||||
"fr": "Francés",
|
||||
"he": "Hebreo",
|
||||
"it": "Italiano",
|
||||
"nl": "Neerlandés",
|
||||
"da": "Danés",
|
||||
"fi": "Finés",
|
||||
"no": "Noruego",
|
||||
"sv": "Sueco",
|
||||
"ar": "Árabe",
|
||||
"fa": "Persa",
|
||||
"sw": "Suajili",
|
||||
"ha": "Hausa",
|
||||
"am": "Amhárico",
|
||||
"yo": "Yoruba",
|
||||
"zu": "Zulu",
|
||||
"ma": "Darija",
|
||||
"ig": "Igbo",
|
||||
"ka": "Georgiano",
|
||||
"kk": "Kazajo",
|
||||
"om": "Oromo",
|
||||
"so": "Somalí",
|
||||
"ti": "Tigriña",
|
||||
"uz": "Uzbeko",
|
||||
"wo": "Wólof",
|
||||
"pl": "Polaco",
|
||||
"pt": "Portugués",
|
||||
"ro": "Rumano",
|
||||
"ru": "Ruso",
|
||||
"sr": "Serbio",
|
||||
"tr": "Turco",
|
||||
"uk": "Ucraniano"
|
||||
},
|
||||
"footer": {
|
||||
"copyright": "© {{year}} helexa.ai"
|
||||
},
|
||||
"beta": {
|
||||
"tag": "Public beta",
|
||||
"message": "helexa is in open beta — expect rough edges. Your chats stay in your browser.",
|
||||
"dismiss": "Dismiss"
|
||||
}
|
||||
}
|
||||
103
helexa.ai/src/i18n/resources/es/mission.json
Normal file
@@ -0,0 +1,103 @@
|
||||
{
|
||||
"hero": {
|
||||
"badge": "Una nueva forma de inteligencia",
|
||||
"title": "Una nueva forma de inteligencia",
|
||||
"lead": "Helexa es una malla de IA autoorganizada impulsada por operadores independientes. Abierta. Distribuida. En evolución.",
|
||||
"ctaJoinMesh": "Únete a la malla",
|
||||
"ctaFollowProject": "Sigue el proyecto",
|
||||
"subcopy": "Construida para operadores, desarrolladores y comunidades que creen que la IA debe ser abierta, resiliente y compartida.",
|
||||
"imageAlt": "Visual de la hélice de Helexa"
|
||||
},
|
||||
|
||||
"intent": {
|
||||
"title": "Por qué existe Helexa",
|
||||
"p1": "La IA se está convirtiendo en la infraestructura más poderosa de la Tierra. Pero hoy, ese poder está concentrado en un puñado de corporaciones, moldeado por prioridades privadas, limitaciones geográficas y economías frágiles.",
|
||||
"p2Intro": "Helexa imagina algo diferente:",
|
||||
"bullet1": "Una inteligencia que crece desde todas partes, no desde un solo lugar.",
|
||||
"bullet2": "Una red donde cualquiera puede contribuir y beneficiarse.",
|
||||
"bullet3": "Un sistema que se adapta a la demanda, no a directivas.",
|
||||
"bullet4": "Tecnología que fortalece a las comunidades en lugar de reemplazarlas.",
|
||||
"closing": "Helexa no es una plataforma. No es una nube.\nEs una malla: una red viva y evolutiva de operadores independientes que forman un nuevo tipo de inteligencia."
|
||||
},
|
||||
|
||||
"whyNow": {
|
||||
"title": "Un punto de inflexión para la IA",
|
||||
"problemTitle": "El problema",
|
||||
"problemBullet1": "La IA se está centralizando más rápido que cualquier tecnología anterior.",
|
||||
"problemBullet2": "El acceso al cómputo define la capacidad, y ese acceso se está reduciendo.",
|
||||
"problemBullet3": "Las barreras de coste excluyen a investigadores, startups y comunidades.",
|
||||
"problemBullet4": "Las presiones geopolíticas y regulatorias amenazan la disponibilidad global.",
|
||||
"problemBullet5": "Los creadores de modelos y los operadores de hardware rara vez comparten el valor que producen.",
|
||||
"opportunityTitle": "La oportunidad",
|
||||
"opportunityIntro": "Pero un mundo distribuido es posible.",
|
||||
"opportunityBullet1": "Miles de GPU ya están subutilizadas en todo el mundo.",
|
||||
"opportunityBullet2": "Los operadores quieren una compensación justa por el cómputo.",
|
||||
"opportunityBullet3": "Los desarrolladores quieren infraestructura abierta y resistente a la censura.",
|
||||
"opportunityBullet4": "Las comunidades quieren soberanía y resiliencia en los sistemas digitales.",
|
||||
"opportunityBullet5": "El crecimiento de la IA ha superado a las nubes tradicionales: se necesitan nuevas formas.",
|
||||
"opportunityClosing": "Helexa es el momento en que estas fuerzas se alinean."
|
||||
},
|
||||
|
||||
"howItWorks": {
|
||||
"title": "Cómo se forma la malla",
|
||||
"operators": {
|
||||
"eyebrow": "Los operadores ejecutan nodos",
|
||||
"title": "Cualquiera puede aportar cómputo.",
|
||||
"body": "Los operadores ejecutan nodos de Helexa. Deciden qué modelos alojar. Mantienen el control de su hardware y su economía. Sin aprobaciones, sin guardianes."
|
||||
},
|
||||
"routing": {
|
||||
"eyebrow": "La malla enruta la inteligencia",
|
||||
"title": "La demanda fluye a través de la red.",
|
||||
"body": "Helexa aprende dónde existe capacidad, dónde está creciendo la demanda y qué nodos están mejor preparados para atender las solicitudes. La malla se adapta de forma orgánica, como una hélice en crecimiento."
|
||||
},
|
||||
"value": {
|
||||
"eyebrow": "El valor fluye de vuelta",
|
||||
"title": "El trabajo se prueba. El pago es justo.",
|
||||
"body": "Cada tarea lleva un recibo criptográfico. Los operadores ganan por la inteligencia que ayudan a proporcionar. Sin impuestos de plataforma. Sin facturación opaca."
|
||||
}
|
||||
},
|
||||
|
||||
"principles": {
|
||||
"title": "Basada en principios, no en plataformas",
|
||||
"distributed": {
|
||||
"title": "Distribuida por diseño",
|
||||
"body": "Sin un único punto de fallo. Sin autoridad central. Una red que se fortalece con cada nuevo operador."
|
||||
},
|
||||
"participation": {
|
||||
"title": "Participación abierta",
|
||||
"body": "Si tienes cómputo, puedes contribuir. La malla da la bienvenida a todos: edge, servidor doméstico o centro de datos."
|
||||
},
|
||||
"fairness": {
|
||||
"title": "Equidad y transparencia",
|
||||
"body": "Las ganancias se basan en trabajo real, verificado criptográficamente. Sin cajas negras. Sin comisiones ocultas."
|
||||
},
|
||||
"evolving": {
|
||||
"title": "Inteligencia en evolución",
|
||||
"body": "La malla aprende de la demanda. Los modelos se cargan donde se necesitan. La inteligencia se extiende mediante la cooperación."
|
||||
}
|
||||
},
|
||||
|
||||
"roadAhead": {
|
||||
"title": "Lo que Helexa aspira a ser",
|
||||
"p1": "Una capa de inteligencia global que pertenece a todos, impulsada por una hélice de nodos y comunidades.",
|
||||
"p2": "Una red resiliente frente a cortes, política, monopolios y fallos.",
|
||||
"p3": "Un nuevo modelo económico donde operadores, constructores y usuarios se benefician por igual.",
|
||||
"p4": "Un ecosistema donde la innovación crece desde los bordes, no desde el centro.",
|
||||
"card": {
|
||||
"eyebrow": "Instantánea de la visión",
|
||||
"title": "Hacia una malla de inteligencia compartida",
|
||||
"body": "Helexa está en una fase temprana. Las ideas son más grandes que la implementación, y eso es intencional. La red crecerá de forma iterativa, con operadores y desarrolladores dando forma a su evolución."
|
||||
}
|
||||
},
|
||||
|
||||
"joinMesh": {
|
||||
"badge": "Fase inicial",
|
||||
"title": "La malla se está formando.",
|
||||
"titleHighlight": "Tú puedes ser parte de ella.",
|
||||
"lead": "Tanto si gestionas hardware, construyes modelos o simplemente te importa cómo se gobierna la IA, hay un lugar para ti en la malla.",
|
||||
"ctaRunNode": "Ejecutar un nodo (pronto)",
|
||||
"ctaJoinAnnouncements": "Únete a los anuncios iniciales",
|
||||
"ctaExploreCode": "Explora el código",
|
||||
"footer": "Sin jardines amurallados. Sin un único propietario. Solo una malla de personas, hardware e ideas que componen un futuro distinto para la inteligencia."
|
||||
}
|
||||
}
|
||||
71
helexa.ai/src/i18n/resources/et/account.json
Normal file
@@ -0,0 +1,71 @@
|
||||
{
|
||||
"login": {
|
||||
"title": "Sign in",
|
||||
"email": "Email",
|
||||
"password": "Password",
|
||||
"submit": "Sign in",
|
||||
"noAccount": "No account? Sign up"
|
||||
},
|
||||
"register": {
|
||||
"title": "Create your account",
|
||||
"email": "Email",
|
||||
"password": "Password",
|
||||
"submit": "Sign up",
|
||||
"haveAccount": "Already have an account? Sign in",
|
||||
"checkEmail": "Almost there — check your email to verify your account."
|
||||
},
|
||||
"verify": {
|
||||
"verifying": "Verifying…",
|
||||
"ok": "Email verified. You can now sign in.",
|
||||
"failed": "This verification link is invalid or has expired.",
|
||||
"toLogin": "Go to sign in"
|
||||
},
|
||||
"reset": {
|
||||
"requestTitle": "Reset your password",
|
||||
"email": "Email",
|
||||
"requestSubmit": "Send reset link",
|
||||
"requestDone": "If that email has an account, a reset link is on its way.",
|
||||
"confirmTitle": "Choose a new password",
|
||||
"newPassword": "New password",
|
||||
"confirmSubmit": "Set password",
|
||||
"ok": "Password updated. You can now sign in."
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Account",
|
||||
"balance": "Allocation",
|
||||
"total": "Total",
|
||||
"spent": "Spent",
|
||||
"reserved": "Reserved",
|
||||
"remaining": "Remaining",
|
||||
"manageKeys": "Manage API keys",
|
||||
"redeemTitle": "Redeem a top-up code",
|
||||
"redeemPlaceholder": "helexa-topup-…",
|
||||
"redeem": "Redeem",
|
||||
"redeemed": "Code redeemed.",
|
||||
"logout": "Sign out"
|
||||
},
|
||||
"keys": {
|
||||
"title": "API keys",
|
||||
"create": "Create key",
|
||||
"label": "Label",
|
||||
"limitKind": "Limit",
|
||||
"percent": "% of allocation",
|
||||
"hardcap": "Hard cap (tokens)",
|
||||
"value": "Value",
|
||||
"none": "No keys yet.",
|
||||
"createdTitle": "Your new API key",
|
||||
"createdWarn": "Copy it now — you won't see it again.",
|
||||
"copy": "Copy",
|
||||
"copied": "Copied",
|
||||
"archive": "Archive",
|
||||
"save": "Save",
|
||||
"status": "Status",
|
||||
"usage": "Used",
|
||||
"useForChat": "Use for chat on this device",
|
||||
"usedForChat": "Enabled for chat ✓"
|
||||
},
|
||||
"error": {
|
||||
"generic": "Something went wrong.",
|
||||
"unauthorized": "Please sign in again."
|
||||
}
|
||||
}
|
||||
21
helexa.ai/src/i18n/resources/et/chat.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"title": "Vestluse tööruum",
|
||||
"badge": "Vestlus",
|
||||
"lead": "See on vestlusvaade. Siia saad ühendada oma vestlusloogika ja kasutajaliidese komponendid.",
|
||||
"transcriptPlaceholder": "Vestluse logi kuvatakse siin. Esita mudeli ja kasutaja sõnumeid keritavas konteineris, soovi korral sammude kaupa rühmitatuna.",
|
||||
"inputPlaceholder": "Alusta vestlust, kirjutades siia sõnumi…",
|
||||
"send": "Saada",
|
||||
"clear": "Tühjenda",
|
||||
"newChat": "New chat",
|
||||
"newProject": "New project",
|
||||
"newProjectName": "New project",
|
||||
"unsorted": "Unsorted",
|
||||
"emptyState": "Start a conversation. Your history stays in this browser.",
|
||||
"anonBanner": "You have reached the anonymous limit. Sign up for a free allocation.",
|
||||
"signUp": "Sign up",
|
||||
"stop": "Stop",
|
||||
"topUp": "Top up",
|
||||
"rateLimited": "Rate limited — wait a moment and retry.",
|
||||
"needsKey": "Create an API key and enable it for chat to send as yourself.",
|
||||
"manageKeysLink": "Manage keys"
|
||||
}
|
||||
68
helexa.ai/src/i18n/resources/et/common.json
Normal file
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"app": {
|
||||
"name": "helexa.ai"
|
||||
},
|
||||
"nav": {
|
||||
"home": "Avaleht",
|
||||
"docs": "Dokumentatsioon",
|
||||
"chat": "Vestlus",
|
||||
"mission": "Mission",
|
||||
"login": "Sign in",
|
||||
"register": "Sign up",
|
||||
"account": "Account",
|
||||
"logout": "Sign out"
|
||||
},
|
||||
"theme": {
|
||||
"toggle": {
|
||||
"toLight": "Lülita heledale teemale",
|
||||
"toDark": "Lülita tumedale teemale"
|
||||
}
|
||||
},
|
||||
"lang": {
|
||||
"bg": "bulgaaria",
|
||||
"de": "saksa",
|
||||
"el": "kreeka",
|
||||
"en": "inglise",
|
||||
"es": "hispaania",
|
||||
"et": "eesti",
|
||||
"fr": "prantsuse",
|
||||
"he": "heebrea",
|
||||
"it": "itaalia",
|
||||
"nl": "hollandi",
|
||||
"da": "taani",
|
||||
"fi": "soome",
|
||||
"no": "norra",
|
||||
"sv": "rootsi",
|
||||
"ar": "araabia",
|
||||
"fa": "pärsia",
|
||||
"sw": "suahiili",
|
||||
"ha": "hausa",
|
||||
"am": "amhara",
|
||||
"yo": "joruba",
|
||||
"zu": "isuulu",
|
||||
"ma": "darija",
|
||||
"ig": "igbo",
|
||||
"ka": "gruusia",
|
||||
"kk": "kasahhi",
|
||||
"om": "oromo",
|
||||
"so": "soomeeli",
|
||||
"ti": "tigrinja",
|
||||
"uz": "usbeki",
|
||||
"wo": "wolofi",
|
||||
"pl": "poola",
|
||||
"pt": "portugali",
|
||||
"ro": "rumeenia",
|
||||
"ru": "vene",
|
||||
"sr": "serbia",
|
||||
"tr": "türgi",
|
||||
"uk": "ukraina"
|
||||
},
|
||||
"footer": {
|
||||
"copyright": "© {{year}} helexa.ai"
|
||||
},
|
||||
"beta": {
|
||||
"tag": "Public beta",
|
||||
"message": "helexa is in open beta — expect rough edges. Your chats stay in your browser.",
|
||||
"dismiss": "Dismiss"
|
||||
}
|
||||
}
|
||||