feat: align heicode web-manager branding and deployment docs

Unify website and manager experience with updated logo and manager entry links, and document the current production topology and URLs in CLAUDE.md for consistent future operations.

Made-with: Cursor
This commit is contained in:
gongzhiyong
2026-04-30 20:23:46 +08:00
parent aae31e7506
commit 9bf3a73a75
128 changed files with 3635 additions and 1154 deletions
+15
View File
@@ -59,6 +59,21 @@ HEICODE_TAIJIAICLOUD_BASE_URL=http://localhost:3000 bun run src/server/index.ts
- 根目录 [`docker-compose.yml`](./docker-compose.yml):构建 `website/` 静态站点镜像(端口 **8888**)。
- Manager 本地编排以 `new-api/docker-compose.yml` 及仓库内 override 为准(若存在)。
## 当前线上入口(2026-04)
- **Heicode 官网(Azure Static Web Apps)**:`https://ashy-dune-0e22d7b00.7.azurestaticapps.net`
- **Heicode Manager(生产)**:`https://code.xinghanlab.com/`
官网中的主按钮(登录/开始使用/CTA)默认应跳转到 `https://code.xinghanlab.com/`,避免出现历史 IP 地址。
## Manager 生产拓扑(Azure)
- 应用层:Azure VM 上运行 `new-api`(当前用 Docker 容器承载应用进程)。
- 数据层:仅使用云资源
- PostgreSQL: `heicode.postgres.database.azure.com` / DB `heicode`
- Redis: `heicode.redis.cache.windows.net:6380`(TLS)
- 说明:VM 上本地 `postgres/redis` 不参与生产,若被创建应删除,避免数据源混淆。
---
*若本文件与子目录 `AGENTS.md` / `CLAUDE.md` 冲突,以子目录为准并及时更新根文件摘要。*
@@ -10,12 +10,14 @@
本文档描述 Heicode 客户端(桌面/CLI)与 Heicode Manager(即 mcp-server)之间的**登录认证接口**。共 4 个接口,覆盖完整登录生命周期:
| 接口 | 用途 |
|------|------|
| `POST /api/auth/login` | 账号密码登录,换取 token |
| `GET /api/auth/me` | 校验 token 有效性 + 获取当前用户资料 |
| `POST /api/auth/refresh` | access token 过期时换新的 |
| `POST /api/auth/logout` | 登出(token 加入黑名单) |
| 接口 | 用途 |
| ------------------------ | ----------------------- |
| `POST /api/auth/login` | 账号密码登录,换取 token |
| `GET /api/auth/me` | 校验 token 有效性 + 获取当前用户资料 |
| `POST /api/auth/refresh` | access token 过期时换新的 |
| `POST /api/auth/logout` | 登出(token 加入黑名单) |
> 不在本期范围:注册、找回密码、改密码 — 这些走官网 web 端完成。
@@ -32,26 +34,31 @@ https://apimtaiji.azure-api.net/api/mcp
```
完整路径示例:
```
POST https://apimtaiji.azure-api.net/api/mcp/api/auth/login
```
### 2.2 通用请求头
| Header | 必填 | 说明 |
|--------|------|------|
| `Content-Type: application/json` | 是(POST/PUT) | 请求体 JSON |
| `Authorization: Bearer <token>` | 受保护接口必填 | 见 §3 |
| `X-Request-Id: <uuid>` | 建议 | 全链路追踪 ID,客户端生成 |
| Header | 必填 | 说明 |
| -------------------------------- | ----------- | -------------- |
| `Content-Type: application/json` | 是(POST/PUT) | 请求体 JSON |
| `Authorization: Bearer <token>` | 受保护接口必填 | 见 §3 |
| `X-Request-Id: <uuid>` | 建议 | 全链路追踪 ID,客户端生成 |
### 2.3 Token 模型
登录成功返回两个 token:
| Token | 用途 | 有效期 |
|-------|------|--------|
| **Access Token** | 调业务接口(含 `/me`、`/logout`) | 24 小时 |
| **Refresh Token** | 仅用于 `/refresh` 换新 access | 7 天 |
| Token | 用途 | 有效期 |
| ----------------- | ------------------------ | ----- |
| **Access Token** | 调业务接口(含 `/me`、`/logout`) | 24 小时 |
| **Refresh Token** | 仅用于 `/refresh` 换新 access | 7 天 |
JWT claims 包含:`sub`(user_id)、`email`、`role`、`channelId`、`type`(access/refresh)、`iat`、`exp`。
@@ -75,6 +82,7 @@ Content-Type: application/json
```
字段:
- `email` (string, 必填)
- `password` (string, 必填)
- `role` (string, 必填):Heicode 客户端**固定传 `"user"`**
@@ -100,13 +108,15 @@ Content-Type: application/json
**错误响应**
| HTTP | 含义 | 客户端处理建议 |
|------|------|----------------|
| 401 | 邮箱或密码错误 | 显示"账号或密码错误",让用户重新输入 |
| 403 | 账户已被禁用 | 提示用户联系管理员 |
| 429 | 登录尝试过于频繁(**每 IP 5 次/分钟**) | 显示倒计时;响应头 `Retry-After: 60` 表示秒数 |
| 422 | 请求体校验失败(邮箱格式不合法等) | 检查 `detail` 字段 |
| 500 | 服务异常 | 重试或提示稍后再试 |
| HTTP | 含义 | 客户端处理建议 |
| ---- | ------------------------- | -------------------------------- |
| 401 | 邮箱或密码错误 | 显示"账号或密码错误",让用户重新输入 |
| 403 | 账户已被禁用 | 提示用户联系管理员 |
| 429 | 登录尝试过于频繁(**每 IP 5 次/分钟**) | 显示倒计时;响应头 `Retry-After: 60` 表示秒数 |
| 422 | 请求体校验失败(邮箱格式不合法等) | 检查 `detail` 字段 |
| 500 | 服务异常 | 重试或提示稍后再试 |
**重要:限流规则**
@@ -147,10 +157,12 @@ Authorization: Bearer <accessToken>
**错误响应**
| HTTP | 含义 | 客户端处理建议 |
|------|------|----------------|
| 401 | Token 无效/过期/已登出/用户不存在 | 调 `/refresh` 换新 token;若 refresh 也 401,跳登录页 |
| 403 | 账户已被禁用 | 强制登出,提示联系管理员 |
| HTTP | 含义 | 客户端处理建议 |
| ---- | --------------------- | ------------------------------------------ |
| 401 | Token 无效/过期/已登出/用户不存在 | 调 `/refresh` 换新 token;若 refresh 也 401,跳登录页 |
| 403 | 账户已被禁用 | 强制登出,提示联系管理员 |
---
@@ -183,11 +195,14 @@ Authorization: Bearer <refreshToken>
**错误响应**
| HTTP | 含义 | 客户端处理建议 |
|------|------|----------------|
| 401 | refresh token 无效 / 过期 / 错传了 access token | 跳登录页 |
| HTTP | 含义 | 客户端处理建议 |
| ---- | ---------------------------------------- | ------- |
| 401 | refresh token 无效 / 过期 / 错传了 access token | 跳登录页 |
实施细节:
- 服务端会校验 token claims `type == "refresh"`,否则拒绝
- 旧 refresh token 不会被立即吊销(容许并发换发期),但客户端应丢弃旧的
@@ -219,6 +234,7 @@ Authorization: Bearer <accessToken>
logout 容错性较强,token 黑名单写入失败也会返回 200(前端清理本地 token 即可)。
**客户端登出流程**:
1. 调 `/api/auth/logout`
2. 清除本地存储的 access + refresh token
3. 清除当前用户资料缓存
@@ -301,38 +317,44 @@ POST /logout
## 6. 安全注意事项
| 项 | 说明 |
|---|---|
| **token 存储** | 桌面应用建议存到 OS 安全凭据存储(Windows Credential Manager / macOS Keychain / Linux Secret Service) |
| **HTTPS 强制** | 生产 base URL 已是 HTTPS;客户端**禁止**回退 HTTP |
| **token 泄露应对** | 用户怀疑泄露时提示去官网 web 端改密码(改密会导致所有 session 黑名单) |
| **审计日志** | 所有 login 尝试(成功/失败)服务端均写审计 |
| **状态码不泄漏** | 错误信息已统一用"邮箱或密码错误",不区分账号是否存在,防爆破 |
| 项 | 说明 |
| -------------- | -------------------------------------------------------------------------------------- |
| **token 存储** | 桌面应用建议存到 OS 安全凭据存储(Windows Credential Manager / macOS Keychain / Linux Secret Service) |
| **HTTPS 强制** | 生产 base URL 已是 HTTPS;客户端**禁止**回退 HTTP |
| **token 泄露应对** | 用户怀疑泄露时提示去官网 web 端改密码(改密会导致所有 session 黑名单) |
| **审计日志** | 所有 login 尝试(成功/失败)服务端均写审计 |
| **状态码不泄漏** | 错误信息已统一用"邮箱或密码错误",不区分账号是否存在,防爆破 |
---
## 7. 测试账号(仅供联调)
| 角色 | 邮箱 | 密码 |
|------|------|------|
| 角色 | 邮箱 | 密码 |
| ---- | ----------- | ------------ |
| 普通用户 | `55@55.com` | `By@123456.` |
> ⚠️ 测试账号仅用于联调阶段,正式上线前请务必关闭。
---
## 8. 已上线生产验证清单
| 测试项 | 结果 |
|--------|------|
| login 200 + 返回 access/refresh token | ✅ |
| /me 用 access token → 200 + 完整 profile | ✅ |
| /refresh 用 refresh token → 200 + 新 token 对 | ✅ |
| /refresh 用 access token → 401 拒绝 | ✅ |
| logout → 200 | ✅ |
| logout 后旧 token 调 /me → 401(黑名单生效) | ✅ |
| 连续 7 次错密 → 第 6 次起 429(每 IP 5/min 限流) | ✅ |
| 服务器审计日志记录所有 login(含成功/失败) | ✅ |
| 测试项 | 结果 |
| ------------------------------------------ | --- |
| login 200 + 返回 access/refresh token | ✅ |
| /me 用 access token → 200 + 完整 profile | ✅ |
| /refresh 用 refresh token → 200 + 新 token 对 | ✅ |
| /refresh 用 access token → 401 拒绝 | ✅ |
| logout → 200 | ✅ |
| logout 后旧 token 调 /me → 401(黑名单生效) | ✅ |
| 连续 7 次错密 → 第 6 次起 429(每 IP 5/min 限流) | ✅ |
| 服务器审计日志记录所有 login(含成功/失败) | ✅ |
镜像 digest: `sha256:339b64ae090dc81fa13cb29705958167e77fe0698e27ac227c05054ed5c42309`
镜像 tag: `taiji.azurecr.io/mcp-server:heicode-auth-fix2-20260430`
@@ -347,3 +369,4 @@ POST /logout
- 请求完整 URL / Headers / Body
- 响应 HTTP 状态 + Body
- `X-Request-Id` 头值(便于服务端按 ID 反查日志)
+7
View File
@@ -0,0 +1,7 @@
#!/usr/bin/env bash
set -e
export PGPASSWORD="By@123456."
echo "-- try user=heicode"
psql "host=heicode.postgres.database.azure.com port=5432 dbname=heicode user=heicode sslmode=require" -c "select current_user;" || true
echo "-- try user=heicode@heicode"
psql "host=heicode.postgres.database.azure.com port=5432 dbname=heicode user=heicode@heicode sslmode=require" -c "select current_user;" || true
+13
View File
@@ -0,0 +1,13 @@
#!/usr/bin/env bash
set -e
export PGHOST=heicode.postgres.database.azure.com
export PGPORT=5432
export PGDATABASE=heicode
export PGPASSWORD="Myadmin@123456."
export PGSSLMODE=require
echo "[try-1] PGUSER=heicode"
PGUSER=heicode psql -c "select current_user, current_database();" || true
echo "[try-2] PGUSER=heicode@heicode"
PGUSER='heicode@heicode' psql -c "select current_user, current_database();" || true
+212
View File
@@ -0,0 +1,212 @@
#!/usr/bin/env bash
set -euo pipefail
# Local executable acceptance for Agnet integration matrix.
# Requires an authenticated admin cookie from new-api dashboard.
#
# Example:
# AUTH_COOKIE="session=xxxx" BASE_URL="http://localhost:3000" ./bin/acceptance_agnet_local.sh
BASE_URL="${BASE_URL:-http://localhost:3000}"
AUTH_COOKIE="${AUTH_COOKIE:-}"
TENANT_ID="${TENANT_ID:-ten_local}"
PROJECT_ID="${PROJECT_ID:-prj_local}"
CORRELATION_ID="${CORRELATION_ID:-corr_local_$(date +%s)}"
if [[ -z "${AUTH_COOKIE}" ]]; then
echo "ERROR: AUTH_COOKIE is required (admin session cookie)."
echo "Example: AUTH_COOKIE='session=xxxx' $0"
exit 1
fi
PASS=0
FAIL=0
report() {
local id="$1"
local result="$2"
local msg="$3"
if [[ "${result}" == "PASS" ]]; then
PASS=$((PASS + 1))
else
FAIL=$((FAIL + 1))
fi
printf '%-5s | %-4s | %s\n' "${id}" "${result}" "${msg}"
}
api_post() {
local path="$1"
local body="$2"
curl -sS \
-H "Content-Type: application/json" \
-H "X-Tenant-Id: ${TENANT_ID}" \
-H "X-Heicode-Correlation-Id: ${CORRELATION_ID}" \
-b "${AUTH_COOKIE}" \
-X POST \
"${BASE_URL}${path}" \
-d "${body}"
}
api_get() {
local path="$1"
curl -sS \
-H "X-Tenant-Id: ${TENANT_ID}" \
-H "X-Heicode-Correlation-Id: ${CORRELATION_ID}" \
-b "${AUTH_COOKIE}" \
"${BASE_URL}${path}"
}
echo "== Agnet acceptance start =="
echo "BASE_URL=${BASE_URL}"
echo "TENANT_ID=${TENANT_ID}"
echo "PROJECT_ID=${PROJECT_ID}"
# A01: agile_min deploy accepted
A01_PAYLOAD="$(cat <<EOF
{
"orchestration_plan": {
"intent_id": "intent_a01_$(date +%s)",
"template_hint": "agile_min",
"objective": "A01 happy path deployment",
"risk_level": "medium",
"budget": { "max_tokens": 20000, "max_cost_usd": 5, "max_duration_sec": 1800 },
"agents": [
{
"role_template": "AG-PO",
"goal": "Define acceptance",
"default_model_id": "mdl_claude_sonnet",
"sk_sources": [{ "type": "git", "artifact_id": "repo://sk/agile.md" }]
}
],
"constraints": { "allowed_model_ids": ["mdl_claude_sonnet", "mdl_claude_haiku"] },
"metadata": {
"tenant_id": "${TENANT_ID}",
"project_id": "${PROJECT_ID}",
"correlation_id": "${CORRELATION_ID}"
}
}
}
EOF
)"
A01_RES="$(api_post "/api/agnet/deployments" "${A01_PAYLOAD}")"
DEPLOYMENT_ID="$(python3 - <<'PY' "${A01_RES}"
import json,sys
try:
data=json.loads(sys.argv[1])
print((data.get("data") or {}).get("deployment_id",""))
except Exception:
print("")
PY
)"
if [[ -n "${DEPLOYMENT_ID}" ]]; then
report "A01" "PASS" "deployment accepted: ${DEPLOYMENT_ID}"
else
report "A01" "FAIL" "deployment rejected or malformed response"
fi
# A06: budget exceeded should be rejected
A06_PAYLOAD="$(cat <<EOF
{
"orchestration_plan": {
"intent_id": "intent_a06_$(date +%s)",
"template_hint": "agile_min",
"objective": "A06 budget exceed",
"risk_level": "medium",
"budget": { "max_tokens": 9999999, "max_cost_usd": 9999, "max_duration_sec": 999999 },
"agents": [{ "role_template": "AG-DEV", "goal": "Code", "default_model_id": "mdl_claude_sonnet" }],
"constraints": { "allowed_model_ids": ["mdl_claude_sonnet"] },
"metadata": {
"tenant_id": "${TENANT_ID}",
"project_id": "${PROJECT_ID}",
"correlation_id": "${CORRELATION_ID}"
}
}
}
EOF
)"
A06_RES="$(api_post "/api/agnet/deployments" "${A06_PAYLOAD}")"
A06_CODE="$(python3 - <<'PY' "${A06_RES}"
import json,sys
try:
data=json.loads(sys.argv[1])
print(((data.get("error") or {}).get("code")) or "")
except Exception:
print("")
PY
)"
if [[ "${A06_CODE}" == "BUDGET_EXCEEDED" ]]; then
report "A06" "PASS" "budget guard returned BUDGET_EXCEEDED"
else
report "A06" "FAIL" "expected BUDGET_EXCEEDED, got '${A06_CODE}'"
fi
# A07: duplicate intent_id should conflict (current implementation may not enforce; tracked as risk)
INTENT_DUP="intent_dup_$(date +%s)"
DUP_PAYLOAD="$(cat <<EOF
{
"orchestration_plan": {
"intent_id": "${INTENT_DUP}",
"template_hint": "agile_min",
"objective": "A07 idempotency",
"risk_level": "low",
"budget": { "max_tokens": 1000, "max_cost_usd": 1, "max_duration_sec": 300 },
"agents": [{ "role_template": "AG-QA", "goal": "verify" }],
"constraints": {},
"metadata": {
"tenant_id": "${TENANT_ID}",
"project_id": "${PROJECT_ID}",
"correlation_id": "${CORRELATION_ID}"
}
}
}
EOF
)"
_="$(api_post "/api/agnet/deployments" "${DUP_PAYLOAD}")"
A07_RES2="$(api_post "/api/agnet/deployments" "${DUP_PAYLOAD}")"
A07_CODE="$(python3 - <<'PY' "${A07_RES2}"
import json,sys
try:
data=json.loads(sys.argv[1])
print(((data.get("error") or {}).get("code")) or "")
except Exception:
print("")
PY
)"
if [[ "${A07_CODE}" == "DEPLOYMENT_CONFLICT" ]]; then
report "A07" "PASS" "idempotency conflict returned DEPLOYMENT_CONFLICT"
else
report "A07" "FAIL" "idempotency guard not enforced yet (got '${A07_CODE:-none}')"
fi
# A09/A10 minimal event verification for first deployment
if [[ -n "${DEPLOYMENT_ID}" ]]; then
EVENTS_RES="$(api_get "/api/agnet/deployments/${DEPLOYMENT_ID}/events")"
EVT_COUNT="$(python3 - <<'PY' "${EVENTS_RES}"
import json,sys
try:
data=json.loads(sys.argv[1])
items=(data.get("data") or {}).get("items") or []
print(len(items))
except Exception:
print(0)
PY
)"
if [[ "${EVT_COUNT}" -ge 1 ]]; then
report "A09" "PASS" "events visible (${EVT_COUNT})"
else
report "A09" "FAIL" "no events returned"
fi
else
report "A09" "FAIL" "skipped due to missing deployment_id"
fi
echo "-------------------------------------------"
echo "passed=${PASS} failed=${FAIL} total=$((PASS+FAIL))"
if [[ "${FAIL}" -gt 0 ]]; then
echo "Acceptance has failures. Fix before Azure deployment."
exit 2
fi
echo "Acceptance passed. Ready for Azure deployment gate."
+57
View File
@@ -0,0 +1,57 @@
#!/usr/bin/env bash
set -euo pipefail
# Azure VM deployment script with canary gate and rollback pointer.
# Prerequisites:
# - SSH access to VM
# - docker + docker compose installed on VM
# - project already synced to VM path
#
# Example:
# VM_HOST=20.1.2.3 VM_USER=azureuser REMOTE_DIR=/opt/heicode/new-api \
# IMAGE_TAG=new-api-heicode:local ./bin/azure_vm_deploy.sh
VM_HOST="${VM_HOST:-}"
VM_USER="${VM_USER:-azureuser}"
REMOTE_DIR="${REMOTE_DIR:-/opt/heicode/new-api}"
IMAGE_TAG="${IMAGE_TAG:-new-api-heicode:local}"
HEALTH_URL="${HEALTH_URL:-http://127.0.0.1:3000/api/status}"
SSH_OPTS="${SSH_OPTS:-}"
if [[ -z "${VM_HOST}" ]]; then
echo "ERROR: VM_HOST is required"
exit 1
fi
REMOTE="${VM_USER}@${VM_HOST}"
SSH="ssh ${SSH_OPTS} ${REMOTE}"
echo "== Azure VM deploy start =="
echo "remote=${REMOTE}"
echo "dir=${REMOTE_DIR}"
echo "image=${IMAGE_TAG}"
# Persist previous image for rollback.
${SSH} "cd '${REMOTE_DIR}' && \
PREV=\$(docker inspect --format='{{.Config.Image}}' new-api 2>/dev/null || true) && \
echo \"\${PREV}\" > .last_success_image && \
echo \"last_success_image=\${PREV}\""
# Update compose image tag and redeploy.
${SSH} "cd '${REMOTE_DIR}' && \
docker compose pull || true && \
IMAGE_TAG='${IMAGE_TAG}' docker compose up -d --force-recreate"
echo "Waiting for health endpoint..."
for i in {1..20}; do
if ${SSH} "curl -fsS '${HEALTH_URL}' | grep -q '\"success\":true'"; then
echo "Health check passed."
echo "${IMAGE_TAG}" | ${SSH} "cat > '${REMOTE_DIR}/.last_success_image'"
echo "Deploy completed."
exit 0
fi
sleep 3
done
echo "Health check failed. Run rollback script."
exit 2
+2 -4
View File
@@ -21,7 +21,7 @@ var TopUpLink = ""
var themeValue atomic.Value // stores string; safe for concurrent read/write
func init() {
themeValue.Store("classic")
themeValue.Store("default")
}
func GetTheme() string {
@@ -31,9 +31,7 @@ func GetTheme() string {
// SetTheme updates the frontend theme atomically.
// Only "default" and "classic" are accepted; other values are silently ignored.
func SetTheme(t string) {
if t == "default" || t == "classic" {
themeValue.Store(t)
}
themeValue.Store("default")
}
// var ChatLink = ""
+477
View File
@@ -0,0 +1,477 @@
package controller
import (
"net/http"
"strings"
"sync"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/gin-gonic/gin"
)
const (
agnetRiskLow = "low"
agnetRiskMedium = "medium"
agnetRiskHigh = "high"
)
type agnetBudget struct {
MaxTokens int `json:"max_tokens"`
MaxCostUSD float64 `json:"max_cost_usd"`
MaxDurationSec int `json:"max_duration_sec"`
}
type agnetSKSource struct {
Type string `json:"type"`
ArtifactID string `json:"artifact_id"`
}
type agnetAgentPlan struct {
RoleTemplate string `json:"role_template"`
Goal string `json:"goal"`
DefaultModelID string `json:"default_model_id"`
SKSources []agnetSKSource `json:"sk_sources"`
}
type agnetConstraints struct {
AllowedModelIDs []string `json:"allowed_model_ids"`
}
type agnetMetadata struct {
TenantID string `json:"tenant_id"`
ProjectID string `json:"project_id"`
CorrelationID string `json:"correlation_id"`
}
type agnetOrchestrationPlan struct {
IntentID string `json:"intent_id"`
TemplateHint string `json:"template_hint"`
Objective string `json:"objective"`
RiskLevel string `json:"risk_level"`
Budget agnetBudget `json:"budget"`
Agents []agnetAgentPlan `json:"agents"`
Constraints agnetConstraints `json:"constraints"`
Metadata agnetMetadata `json:"metadata"`
}
type agnetDeploymentRequest struct {
Plan agnetOrchestrationPlan `json:"orchestration_plan"`
}
type agnetDeploymentRecord struct {
DeploymentID string `json:"deployment_id"`
Status string `json:"status"`
Phase string `json:"phase"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
Plan agnetOrchestrationPlan `json:"orchestration_plan"`
}
type agnetEvent struct {
EventID string `json:"event_id"`
Event string `json:"event"`
SchemaVersion int `json:"schema_version"`
TenantID string `json:"tenant_id"`
ProjectID string `json:"project_id"`
DeploymentID string `json:"deployment_id"`
CorrelationID string `json:"correlation_id"`
OccurredAt string `json:"occurred_at"`
}
type agnetSKSnapshotResolveRequest struct {
DeploymentID string `json:"deployment_id"`
}
type agnetSKSnapshot struct {
SnapshotID string `json:"snapshot_id"`
DeploymentID string `json:"deployment_id"`
TenantID string `json:"tenant_id"`
ProjectID string `json:"project_id"`
SourceType string `json:"source_type"`
SourceRef string `json:"source_ref"`
ResolvedAt string `json:"resolved_at"`
}
var (
agnetMu sync.RWMutex
agnetDeployments = make(map[string]agnetDeploymentRecord)
agnetEvents = make(map[string][]agnetEvent)
agnetSnapshots = make(map[string][]agnetSKSnapshot)
)
func agnetNow() string {
return time.Now().UTC().Format(time.RFC3339)
}
func agnetRequestID(c *gin.Context) string {
if reqID := strings.TrimSpace(c.GetString(common.RequestIdKey)); reqID != "" {
return reqID
}
return common.GetUUID()
}
func agnetError(c *gin.Context, code string, message string) {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": message,
"error": gin.H{
"code": code,
"message": message,
"request_id": agnetRequestID(c),
},
})
}
func containsString(values []string, target string) bool {
for _, value := range values {
if value == target {
return true
}
}
return false
}
func validateOrchestrationPlan(c *gin.Context, plan agnetOrchestrationPlan) bool {
if strings.TrimSpace(plan.IntentID) == "" ||
strings.TrimSpace(plan.TemplateHint) == "" ||
strings.TrimSpace(plan.Objective) == "" {
agnetError(c, "POLICY_REJECTED", "intent_id/template_hint/objective is required")
return false
}
if len(plan.Agents) == 0 {
agnetError(c, "POLICY_REJECTED", "at least one agent is required")
return false
}
if strings.TrimSpace(plan.Metadata.TenantID) == "" ||
strings.TrimSpace(plan.Metadata.ProjectID) == "" ||
strings.TrimSpace(plan.Metadata.CorrelationID) == "" {
agnetError(c, "POLICY_REJECTED", "metadata.tenant_id/project_id/correlation_id is required")
return false
}
switch plan.RiskLevel {
case agnetRiskLow, agnetRiskMedium, agnetRiskHigh:
default:
agnetError(c, "POLICY_REJECTED", "risk_level must be low/medium/high")
return false
}
if plan.Budget.MaxTokens <= 0 || plan.Budget.MaxCostUSD <= 0 || plan.Budget.MaxDurationSec <= 0 {
agnetError(c, "POLICY_REJECTED", "budget.max_tokens/max_cost_usd/max_duration_sec must be positive")
return false
}
if plan.Budget.MaxTokens > 500000 || plan.Budget.MaxCostUSD > 200 || plan.Budget.MaxDurationSec > 24*3600 {
agnetError(c, "BUDGET_EXCEEDED", "budget exceeds current platform policy limits")
return false
}
if tenantHeader := strings.TrimSpace(c.GetHeader("X-Tenant-Id")); tenantHeader != "" && tenantHeader != plan.Metadata.TenantID {
agnetError(c, "FORBIDDEN_CROSS_TENANT", "X-Tenant-Id does not match orchestration_plan metadata.tenant_id")
return false
}
allowedModels := plan.Constraints.AllowedModelIDs
for _, agent := range plan.Agents {
if strings.TrimSpace(agent.RoleTemplate) == "" || strings.TrimSpace(agent.Goal) == "" {
agnetError(c, "POLICY_REJECTED", "each agent must contain role_template and goal")
return false
}
for _, source := range agent.SKSources {
sourceType := strings.TrimSpace(source.Type)
if sourceType != "" && sourceType != "git" && sourceType != "upload" {
agnetError(c, "SK_SOURCE_UNRESOLVABLE", "unsupported sk source type")
return false
}
}
modelID := strings.TrimSpace(agent.DefaultModelID)
if modelID != "" && len(allowedModels) > 0 && !containsString(allowedModels, modelID) {
agnetError(c, "MODEL_NOT_ALLOWED", "agent default_model_id is outside allowed_model_ids")
return false
}
}
return true
}
func AgnetCreateDeployment(c *gin.Context) {
var req agnetDeploymentRequest
if err := c.ShouldBindJSON(&req); err != nil {
agnetError(c, "POLICY_REJECTED", err.Error())
return
}
plan := req.Plan
if !validateOrchestrationPlan(c, plan) {
return
}
now := agnetNow()
deploymentID := "dep_" + common.GetUUID()[:12]
record := agnetDeploymentRecord{
DeploymentID: deploymentID,
Status: "accepted",
Phase: "pending",
CreatedAt: now,
UpdatedAt: now,
Plan: plan,
}
event := agnetEvent{
EventID: "evt_" + common.GetUUID()[:12],
Event: "deployment.accepted",
SchemaVersion: 1,
TenantID: plan.Metadata.TenantID,
ProjectID: plan.Metadata.ProjectID,
DeploymentID: deploymentID,
CorrelationID: plan.Metadata.CorrelationID,
OccurredAt: now,
}
agnetMu.Lock()
agnetDeployments[deploymentID] = record
agnetEvents[deploymentID] = append(agnetEvents[deploymentID], event)
agnetMu.Unlock()
common.ApiSuccess(c, gin.H{
"deployment_id": deploymentID,
"status": "accepted",
"agent_instances": []gin.H{
{
"instance_id": "agi_" + common.GetUUID()[:12],
"role": plan.Agents[0].RoleTemplate,
"phase": "pending",
},
},
})
}
func AgnetGetDeployment(c *gin.Context) {
deploymentID := strings.TrimSpace(c.Param("deployment_id"))
if deploymentID == "" {
agnetError(c, "POLICY_REJECTED", "deployment_id is required")
return
}
agnetMu.RLock()
record, ok := agnetDeployments[deploymentID]
agnetMu.RUnlock()
if !ok {
agnetError(c, "DEPLOYMENT_CONFLICT", "deployment not found")
return
}
common.ApiSuccess(c, record)
}
func AgnetListDeployments(c *gin.Context) {
tenantID := strings.TrimSpace(c.Query("tenant_id"))
projectID := strings.TrimSpace(c.Query("project_id"))
items := make([]agnetDeploymentRecord, 0)
agnetMu.RLock()
for _, record := range agnetDeployments {
if tenantID != "" && record.Plan.Metadata.TenantID != tenantID {
continue
}
if projectID != "" && record.Plan.Metadata.ProjectID != projectID {
continue
}
items = append(items, record)
}
agnetMu.RUnlock()
common.ApiSuccess(c, gin.H{
"items": items,
"total": len(items),
})
}
func AgnetStopDeployment(c *gin.Context) {
deploymentID := strings.TrimSpace(c.Param("deployment_id"))
if deploymentID == "" {
agnetError(c, "POLICY_REJECTED", "deployment_id is required")
return
}
agnetMu.Lock()
record, ok := agnetDeployments[deploymentID]
if !ok {
agnetMu.Unlock()
agnetError(c, "DEPLOYMENT_CONFLICT", "deployment not found")
return
}
record.Status = "stopped"
record.Phase = "stopped"
record.UpdatedAt = agnetNow()
agnetDeployments[deploymentID] = record
agnetEvents[deploymentID] = append(agnetEvents[deploymentID], agnetEvent{
EventID: "evt_" + common.GetUUID()[:12],
Event: "instance.phase_changed",
SchemaVersion: 1,
TenantID: record.Plan.Metadata.TenantID,
ProjectID: record.Plan.Metadata.ProjectID,
DeploymentID: deploymentID,
CorrelationID: record.Plan.Metadata.CorrelationID,
OccurredAt: agnetNow(),
})
agnetMu.Unlock()
common.ApiSuccess(c, gin.H{
"deployment_id": deploymentID,
"status": "stopped",
})
}
func AgnetListDeploymentEvents(c *gin.Context) {
deploymentID := strings.TrimSpace(c.Param("deployment_id"))
if deploymentID == "" {
agnetError(c, "POLICY_REJECTED", "deployment_id is required")
return
}
agnetMu.RLock()
events := agnetEvents[deploymentID]
agnetMu.RUnlock()
common.ApiSuccess(c, gin.H{
"items": events,
"total": len(events),
})
}
func AgnetProjectDashboardSnapshot(c *gin.Context) {
projectID := strings.TrimSpace(c.Param("project_id"))
if projectID == "" {
agnetError(c, "POLICY_REJECTED", "project_id is required")
return
}
active := 0
pending := 0
stopped := 0
agnetMu.RLock()
for _, record := range agnetDeployments {
if record.Plan.Metadata.ProjectID != projectID {
continue
}
if record.Status == "accepted" {
active++
}
if record.Phase == "pending" {
pending++
}
if record.Phase == "stopped" {
stopped++
}
}
agnetMu.RUnlock()
common.ApiSuccess(c, gin.H{
"project_id": projectID,
"active_instances": active,
"phase_distribution": gin.H{"pending": pending, "stopped": stopped},
"failure_rate_1h": 0,
"avg_task_duration": 0,
})
}
func AgnetResolveSKSnapshots(c *gin.Context) {
var req agnetSKSnapshotResolveRequest
if err := c.ShouldBindJSON(&req); err != nil {
agnetError(c, "SK_SOURCE_UNRESOLVABLE", err.Error())
return
}
deploymentID := strings.TrimSpace(req.DeploymentID)
if deploymentID == "" {
agnetError(c, "SK_SOURCE_UNRESOLVABLE", "deployment_id is required")
return
}
agnetMu.Lock()
record, ok := agnetDeployments[deploymentID]
if !ok {
agnetMu.Unlock()
agnetError(c, "DEPLOYMENT_CONFLICT", "deployment not found")
return
}
snapshots := make([]agnetSKSnapshot, 0)
now := agnetNow()
for _, agent := range record.Plan.Agents {
for _, source := range agent.SKSources {
sourceType := strings.TrimSpace(source.Type)
if sourceType == "" {
continue
}
sourceRef := strings.TrimSpace(source.ArtifactID)
if sourceRef == "" {
sourceRef = "ref_" + common.GetUUID()[:8]
}
snapshots = append(snapshots, agnetSKSnapshot{
SnapshotID: "sks_" + common.GetUUID()[:12],
DeploymentID: deploymentID,
TenantID: record.Plan.Metadata.TenantID,
ProjectID: record.Plan.Metadata.ProjectID,
SourceType: sourceType,
SourceRef: sourceRef,
ResolvedAt: now,
})
}
}
agnetSnapshots[deploymentID] = snapshots
agnetEvents[deploymentID] = append(agnetEvents[deploymentID], agnetEvent{
EventID: "evt_" + common.GetUUID()[:12],
Event: "sk_snapshot_refreshed",
SchemaVersion: 1,
TenantID: record.Plan.Metadata.TenantID,
ProjectID: record.Plan.Metadata.ProjectID,
DeploymentID: deploymentID,
CorrelationID: record.Plan.Metadata.CorrelationID,
OccurredAt: now,
})
agnetMu.Unlock()
common.ApiSuccess(c, gin.H{
"deployment_id": deploymentID,
"items": snapshots,
"total": len(snapshots),
})
}
func AgnetListSKSnapshots(c *gin.Context) {
deploymentID := strings.TrimSpace(c.Param("deployment_id"))
if deploymentID == "" {
agnetError(c, "SK_SOURCE_UNRESOLVABLE", "deployment_id is required")
return
}
agnetMu.RLock()
items := agnetSnapshots[deploymentID]
agnetMu.RUnlock()
common.ApiSuccess(c, gin.H{
"deployment_id": deploymentID,
"items": items,
"total": len(items),
})
}
func AgnetListAuditLogs(c *gin.Context) {
projectID := strings.TrimSpace(c.Query("project_id"))
items := make([]gin.H, 0)
agnetMu.RLock()
for deploymentID, events := range agnetEvents {
for _, event := range events {
if projectID != "" && event.ProjectID != projectID {
continue
}
items = append(items, gin.H{
"actor": "agnet_control_plane",
"action": event.Event,
"resource": deploymentID,
"tenant_id": event.TenantID,
"request_id": agnetRequestID(c),
"correlation_id": event.CorrelationID,
"result": "ok",
"occurred_at": event.OccurredAt,
})
}
}
agnetMu.RUnlock()
common.ApiSuccess(c, gin.H{
"items": items,
"total": len(items),
})
}
+2 -2
View File
@@ -199,10 +199,10 @@ func UpdateOption(c *gin.Context) {
return
}
case "theme.frontend":
if option.Value != "default" && option.Value != "classic" {
if option.Value != "default" {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "无效的主题值,可选值:default(新版前端)、classic(经典前端)",
"message": "主题已锁定为 default(新版前端),classic 已禁用",
})
return
}
+15
View File
@@ -379,5 +379,20 @@ func SetApiRouter(router *gin.Engine) {
deploymentsRoute.POST("/:id/extend", controller.ExtendDeployment)
deploymentsRoute.DELETE("/:id", controller.DeleteDeployment)
}
// Agnet orchestration control plane (minimal integration endpoints)
agnetRoute := apiRouter.Group("/agnet")
agnetRoute.Use(middleware.AdminAuth())
{
agnetRoute.GET("/deployments", controller.AgnetListDeployments)
agnetRoute.POST("/deployments", controller.AgnetCreateDeployment)
agnetRoute.GET("/deployments/:deployment_id", controller.AgnetGetDeployment)
agnetRoute.POST("/deployments/:deployment_id/stop", controller.AgnetStopDeployment)
agnetRoute.GET("/deployments/:deployment_id/events", controller.AgnetListDeploymentEvents)
agnetRoute.GET("/deployments/:deployment_id/sk-snapshots", controller.AgnetListSKSnapshots)
agnetRoute.POST("/sk-snapshots/resolve", controller.AgnetResolveSKSnapshots)
agnetRoute.GET("/projects/:project_id/dashboard-snapshot", controller.AgnetProjectDashboardSnapshot)
agnetRoute.GET("/audit-logs", controller.AgnetListAuditLogs)
}
}
}
+3 -2
View File
@@ -10,7 +10,7 @@ type ThemeSettings struct {
}
var themeSettings = ThemeSettings{
Frontend: "classic",
Frontend: "default",
}
func init() {
@@ -19,7 +19,8 @@ func init() {
}
func syncThemeToCommon() {
common.SetTheme(themeSettings.Frontend)
themeSettings.Frontend = "default"
common.SetTheme("default")
}
func GetThemeSettings() *ThemeSettings {
+2 -2
View File
@@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/png" href="/logo.png" />
<link rel="icon" type="image/svg+xml" href="/heicode-logo.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- Primary Meta Tags -->
@@ -10,7 +10,7 @@
<meta name="title" content="Heicode Manager" />
<meta
name="description"
content="Heicode Manager control plane for model gateway, deployments, and operations."
content="Heicode Manager — multi-tenant control plane for Agnet deployments, events and audit."
/>
<meta name="theme-color" content="#fff" />
+12
View File
@@ -0,0 +1,12 @@
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<title>Heicode Manager</title>
<path d="M15 6v12a3 3 0 1 0 3-3H6a3 3 0 1 0 3 3V6a3 3 0 1 0-3 3h12a3 3 0 1 0-3-3" />
</svg>

After

Width:  |  Height:  |  Size: 301 B

@@ -1,25 +0,0 @@
import { type SVGProps } from 'react'
import { cn } from '@/lib/utils'
export function IconGithub({ className, ...props }: SVGProps<SVGSVGElement>) {
return (
<svg
role='img'
viewBox='0 0 24 24'
xmlns='http://www.w3.org/2000/svg'
width='24'
height='24'
className={cn('[&>path]:stroke-current', className)}
fill='none'
stroke='currentColor'
strokeWidth='2'
strokeLinecap='round'
strokeLinejoin='round'
{...props}
>
<title>GitHub</title>
<path strokeWidth='0' d='M0 0h24v24H0z' fill='none' />
<path d='M9 19c-4.3 1.4 -4.3 -2.5 -6 -3m12 5v-3.5c0 -1 .1 -1.4 -.5 -2c2.8 -.3 5.5 -1.4 5.5 -6a4.6 4.6 0 0 0 -1.3 -3.2a4.2 4.2 0 0 0 -.1 -3.2s-1.1 -.3 -3.5 1.3a12.3 12.3 0 0 0 -6.2 0c-2.4 -1.6 -3.5 -1.3 -3.5 -1.3a4.2 4.2 0 0 0 -.1 3.2a4.6 4.6 0 0 0 -1.3 3.2c0 4.6 2.7 5.7 5.5 6c-.6 .6 -.6 1.2 -.5 2v3.5' />
</svg>
)
}
-1
View File
@@ -2,7 +2,6 @@ export { IconDiscord } from './icon-discord'
export { IconDocker } from './icon-docker'
export { IconFacebook } from './icon-facebook'
export { IconFigma } from './icon-figma'
export { IconGithub } from './icon-github'
export { IconGitlab } from './icon-gitlab'
export { IconGmail } from './icon-gmail'
export { IconLinuxDo } from './icon-linuxdo'
@@ -17,16 +17,6 @@ import {
} from '@/components/ui/dropdown-menu'
const providers = {
github: {
title: 'Open in GitHub',
createUrl: (url: string) => url,
icon: (
<svg fill='currentColor' role='img' viewBox='0 0 24 24'>
<title>GitHub</title>
<path d='M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12' />
</svg>
),
},
scira: {
title: 'Open in Scira',
createUrl: (q: string) =>
@@ -9,6 +9,7 @@ import { Search } from '@/components/search'
import { defaultTopNavLinks } from '../config/top-nav.config'
import { type TopNavLink } from '../types'
import { Header } from './header'
import { TenantBar } from './tenant-bar'
import { TopNav } from './top-nav'
/**
@@ -93,7 +94,13 @@ export function AppHeader({
// Determine left content: custom content > navigation bar > null
const leftSection =
leftContent || (showTopNav ? <TopNav links={links} /> : null)
leftContent ||
(showTopNav ? (
<div className='flex items-center gap-2'>
<TopNav links={links} />
<TenantBar />
</div>
) : null)
return (
<>
@@ -50,7 +50,7 @@ export function AppSidebar() {
}, [configFilteredNavGroups, userRole])
return (
<Sidebar collapsible={collapsible} variant={variant}>
<Sidebar collapsible={collapsible} variant={variant} className='heicode-panel'>
<SidebarHeader>
<WorkspaceSwitcher workspaces={sidebarData.workspaces} />
</SidebarHeader>
@@ -25,7 +25,9 @@ export function AuthenticatedLayout(props: AuthenticatedLayoutProps) {
<SidebarInset
className={cn(
'@container/content',
'h-svh',
'h-svh bg-transparent',
'before:pointer-events-none before:fixed before:inset-0 before:-z-10 before:bg-[radial-gradient(circle_at_top_left,color-mix(in_oklch,var(--primary)_14%,transparent),transparent_42%)]',
'after:pointer-events-none after:fixed after:inset-0 after:-z-10 after:bg-[radial-gradient(circle_at_bottom_right,color-mix(in_oklch,var(--accent)_12%,transparent),transparent_42%)]',
'peer-data-[variant=inset]:h-[calc(100svh-(var(--spacing)*4))]'
)}
>
@@ -3,6 +3,7 @@ import { Link } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import { cn } from '@/lib/utils'
import { useSystemConfig } from '@/hooks/use-system-config'
import { BRAND_NAME, BRAND_TAGLINE } from '@/lib/brand'
interface FooterLink {
text: string
@@ -60,16 +61,15 @@ export function Footer(props: FooterProps) {
} = useSystemConfig()
const displayLogo = systemLogo || props.logo || '/logo.png'
const displayName = systemName || props.name || 'Heicode Manager'
const displayName = systemName || props.name || BRAND_NAME
const isDemoSiteMode = Boolean(demoSiteEnabled)
const currentYear = new Date().getFullYear()
const fallbackColumns = useMemo<FooterColumnProps[]>(
() => [
{
title: t('footer.columns.about.title'),
title: t('Platform Console'),
links: [
{ text: t('About'), href: '/about' },
{ text: t('Profile'), href: '/profile' },
{ text: t('System Settings'), href: '/system-settings/general' },
],
@@ -124,7 +124,7 @@ export function Footer(props: FooterProps) {
</span>
</Link>
<p className='text-muted-foreground/60 mt-3 max-w-[200px] text-xs leading-relaxed'>
{t('Powerful API Management Platform')}
{t(BRAND_TAGLINE)}
</p>
</div>
@@ -93,7 +93,7 @@ export function PublicHeader(props: PublicHeaderProps) {
className={cn(
'flex items-center justify-between transition-all duration-700 ease-[cubic-bezier(0.16,1,0.3,1)]',
scrolled
? 'bg-background/60 ring-border/50 h-12 rounded-2xl pr-1.5 pl-4 shadow-[0_2px_16px_-6px_rgba(0,0,0,0.08),0_0_0_0.5px_rgba(0,0,0,0.02)] ring-[0.5px] backdrop-blur-2xl dark:shadow-[0_2px_16px_-6px_rgba(0,0,0,0.4)]'
? 'bg-background/55 h-12 rounded-2xl pr-1.5 pl-4 ring-[0.5px] ring-[color-mix(in_oklch,var(--primary)_35%,var(--border))] shadow-[0_8px_32px_-18px_color-mix(in_oklch,var(--primary)_45%,black)] backdrop-blur-2xl'
: 'h-16 px-2'
)}
>
@@ -0,0 +1,58 @@
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { Activity, Building2, Server, ShieldCheck } from 'lucide-react'
import { useAuthStore } from '@/stores/auth-store'
function getEnvironmentLabel(): string {
if (typeof window === 'undefined') return 'unknown'
const host = window.location.hostname
if (host === 'localhost' || host === '127.0.0.1') return 'local'
if (host.includes('staging')) return 'staging'
if (host.includes('dev.')) return 'dev'
return 'prod'
}
export function TenantBar() {
const { t } = useTranslation()
const { auth } = useAuthStore()
const user = auth.user
const env = useMemo(getEnvironmentLabel, [])
const channelId = user?.group || '—'
const displayChannel =
channelId.length > 14 ? `${channelId.slice(0, 6)}…${channelId.slice(-4)}` : channelId
if (!user) return null
return (
<div className='ms-2 hidden items-center gap-2 rounded-full border border-[color-mix(in_oklch,var(--primary)_28%,var(--border))] bg-[color-mix(in_oklch,var(--card)_70%,transparent)] px-3 py-1 text-[11px] font-medium text-muted-foreground shadow-[0_4px_18px_-12px_color-mix(in_oklch,var(--primary)_60%,black)] backdrop-blur md:flex'>
<div className='flex items-center gap-1.5'>
<Building2 className='h-3.5 w-3.5 text-primary' />
<span className='font-semibold text-foreground'>
{user.display_name || user.username || t('Tenant member')}
</span>
</div>
<span className='h-3 w-px bg-border' />
<div className='flex items-center gap-1.5'>
<Server className='h-3.5 w-3.5 text-primary' />
<span className='font-mono text-[10px] tracking-wide'>
{t('channel')}:{displayChannel}
</span>
</div>
<span className='h-3 w-px bg-border' />
<div className='flex items-center gap-1.5'>
<ShieldCheck className='h-3.5 w-3.5 text-primary' />
<span className='font-mono text-[10px] tracking-wide uppercase'>
{env}
</span>
</div>
<span className='h-3 w-px bg-border' />
<div className='flex items-center gap-1.5'>
<Activity className='h-3.5 w-3.5 text-emerald-400' />
<span className='text-[10px] uppercase tracking-[0.14em]'>
{t('online')}
</span>
</div>
</div>
)
}
@@ -6,6 +6,7 @@ import { useAuthStore } from '@/stores/auth-store'
import { ROLE } from '@/lib/roles'
import { useStatus } from '@/hooks/use-status'
import { useSystemConfig } from '@/hooks/use-system-config'
import { BRAND_NAME } from '@/lib/brand'
import {
DropdownMenu,
DropdownMenuContent,
@@ -37,7 +38,7 @@ type WorkspaceSwitcherProps = {
*/
export function WorkspaceSwitcher({
workspaces,
defaultName = 'Heicode Manager',
defaultName = BRAND_NAME,
defaultVersion,
}: WorkspaceSwitcherProps) {
const { t } = useTranslation()
+138
View File
@@ -0,0 +1,138 @@
import type { ReactNode } from 'react'
import { AlertOctagon, FileSearch, Loader2 } from 'lucide-react'
import { cn } from '@/lib/utils'
import { Skeleton } from '@/components/ui/skeleton'
type StateKind = 'loading' | 'empty' | 'error' | 'ready'
type StateShellProps = {
/** Determines which sub-state to render. */
state: StateKind
/** Children rendered when state === 'ready'. */
children?: ReactNode
/** Optional fallback for loading state. Defaults to a 4-row skeleton block. */
loading?: ReactNode
/** Empty state title. */
emptyTitle?: string
/** Empty state hint. */
emptyHint?: string
/** Error message to render. */
error?: Error | string | null
/** Wrapper className. */
className?: string
/** Number of skeleton rows in the default loading fallback. */
loadingRows?: number
/** Skeleton row height (Tailwind class), default `h-16`. */
loadingRowClass?: string
/** When true, surface follows hc-surface-2 design token. */
surface?: boolean
}
/**
* Unified four-state shell.
*
* `loading | empty | error | ready` — every list/feed page should funnel
* through this primitive so the design tokens (radii, surfaces, elevations)
* stay consistent across the cockpit.
*/
export function StateShell({
state,
children,
loading,
emptyTitle = 'Nothing to show yet',
emptyHint,
error,
className,
loadingRows = 4,
loadingRowClass = 'h-16',
surface = false,
}: StateShellProps) {
const wrap = (node: ReactNode) => (
<div
className={cn(
surface && 'rounded-2xl border border-[var(--hc-border-mid)] bg-[var(--hc-surface-2)] p-5 shadow-[var(--hc-elev-2)] backdrop-blur',
className
)}
>
{node}
</div>
)
if (state === 'loading') {
return wrap(
loading ?? (
<div className='space-y-3'>
{Array.from({ length: loadingRows }).map((_, idx) => (
<Skeleton
key={idx}
className={cn('rounded-xl', loadingRowClass)}
/>
))}
</div>
)
)
}
if (state === 'error') {
const message = typeof error === 'string' ? error : error?.message
return wrap(
<div className='flex flex-col items-center gap-2 rounded-xl border border-dashed border-rose-500/40 bg-rose-500/10 p-8 text-center'>
<AlertOctagon className='h-6 w-6 text-rose-400' />
<p className='text-sm font-medium'>Something went wrong</p>
{message && (
<p className='text-xs text-muted-foreground'>{message}</p>
)}
</div>
)
}
if (state === 'empty') {
return wrap(
<div className='rounded-xl border border-dashed border-[var(--hc-border-soft)] bg-[var(--hc-surface-1)] p-10 text-center'>
<FileSearch className='mx-auto h-6 w-6 text-muted-foreground' />
<p className='mt-3 text-sm font-medium'>{emptyTitle}</p>
{emptyHint && (
<p className='mt-1 text-xs text-muted-foreground'>{emptyHint}</p>
)}
</div>
)
}
return wrap(children)
}
/**
* Inline tag indicating the current state of a stream/feed.
* Useful for toolbars where a full StateShell is too heavy.
*/
export function StateInlineTag({ state }: { state: StateKind }) {
if (state === 'loading') {
return (
<span className='inline-flex items-center gap-1 text-[11px] font-semibold uppercase tracking-[0.14em] text-primary'>
<Loader2 className='h-3 w-3 animate-spin' />
loading
</span>
)
}
if (state === 'empty') {
return (
<span className='inline-flex items-center gap-1 text-[11px] font-semibold uppercase tracking-[0.14em] text-muted-foreground'>
empty
</span>
)
}
if (state === 'error') {
return (
<span className='inline-flex items-center gap-1 text-[11px] font-semibold uppercase tracking-[0.14em] text-rose-400'>
error
</span>
)
}
return (
<span className='inline-flex items-center gap-1 text-[11px] font-semibold uppercase tracking-[0.14em] text-emerald-400'>
ready
</span>
)
}
export type { StateKind }
+14 -9
View File
@@ -2,6 +2,11 @@ import { useQuery } from '@tanstack/react-query'
import { Construction } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Link } from '@tanstack/react-router'
import {
BRAND_DOC_LOGIN_PATH,
BRAND_NAME,
BRAND_SCREENSHOT_BASE,
} from '@/lib/brand'
import { Markdown } from '@/components/ui/markdown'
import { Skeleton } from '@/components/ui/skeleton'
import { PublicLayout } from '@/components/layout'
@@ -21,12 +26,12 @@ function isLikelyHtml(value: string) {
}
const HEICODE_INTEGRATION_IMAGES = [
'/docs/images/wecom-screenshot-01.jpg',
'/docs/images/wecom-screenshot-02.jpg',
'/docs/images/wecom-screenshot-03.jpg',
'/docs/images/wecom-screenshot-04.jpg',
'/docs/images/wecom-screenshot-05.jpg',
'/docs/images/wecom-screenshot-06.jpg',
`${BRAND_SCREENSHOT_BASE}/wecom-screenshot-01.jpg`,
`${BRAND_SCREENSHOT_BASE}/wecom-screenshot-02.jpg`,
`${BRAND_SCREENSHOT_BASE}/wecom-screenshot-03.jpg`,
`${BRAND_SCREENSHOT_BASE}/wecom-screenshot-04.jpg`,
`${BRAND_SCREENSHOT_BASE}/wecom-screenshot-05.jpg`,
`${BRAND_SCREENSHOT_BASE}/wecom-screenshot-06.jpg`,
]
function HeicodeIntegrationPanel() {
@@ -36,13 +41,13 @@ function HeicodeIntegrationPanel() {
<section className='bg-card mt-8 rounded-xl border p-5 md:p-6'>
<div className='mb-4 flex flex-wrap items-center justify-between gap-3'>
<div>
<h3 className='text-lg font-semibold'>{t('Heicode Manager Integration')}</h3>
<h3 className='text-lg font-semibold'>{t(`${BRAND_NAME} Integration`)}</h3>
<p className='text-muted-foreground mt-1 text-sm'>
{t('Client login API contract and integration screenshots')}
</p>
</div>
<a
href='/docs/integration/Heicode-登录接口对接文档.md'
href={BRAND_DOC_LOGIN_PATH}
target='_blank'
rel='noopener noreferrer'
className='text-primary text-sm font-medium hover:underline'
@@ -91,7 +96,7 @@ function EmptyAboutState() {
</p>
</div>
<div className='text-muted-foreground space-y-2 text-sm'>
<p>{t('Heicode Manager console is ready for your organization branding.')}</p>
<p>{t(`${BRAND_NAME} console is ready for your organization branding.`)}</p>
<p>{t('Set custom About HTML or URL in System Settings > General > About.')}</p>
<p>
<Link to='/about' className='text-primary hover:underline'>
+54
View File
@@ -0,0 +1,54 @@
import { api } from '@/lib/api'
export type AgnetDeployment = {
deployment_id: string
status: string
phase: string
created_at: string
updated_at: string
orchestration_plan: {
template_hint?: string
objective?: string
agents?: Array<{
role_template?: string
goal?: string
default_model_id?: string
sk_sources?: Array<{ type?: string; artifact_id?: string }>
}>
metadata?: {
tenant_id?: string
project_id?: string
correlation_id?: string
}
}
}
type ApiEnvelope<T> = { success: boolean; data?: T; message?: string }
export async function listAgnetDeployments(): Promise<AgnetDeployment[]> {
const res = await api.get<ApiEnvelope<{ items?: AgnetDeployment[] }>>(
'/api/agnet/deployments'
)
return res.data?.data?.items ?? []
}
export async function getAgnetDeploymentEvents(deploymentId: string) {
const res = await api.get<ApiEnvelope<{ items?: Array<Record<string, unknown>> }>>(
`/api/agnet/deployments/${deploymentId}/events`
)
return res.data?.data?.items ?? []
}
export async function getAgnetAuditLogs() {
const res = await api.get<ApiEnvelope<{ items?: Array<Record<string, unknown>> }>>(
'/api/agnet/audit-logs'
)
return res.data?.data?.items ?? []
}
export async function getAgnetSnapshots(deploymentId: string) {
const res = await api.get<ApiEnvelope<{ items?: Array<Record<string, unknown>> }>>(
`/api/agnet/deployments/${deploymentId}/sk-snapshots`
)
return res.data?.data?.items ?? []
}
+851
View File
@@ -0,0 +1,851 @@
import { useMemo, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Link } from '@tanstack/react-router'
import {
Activity,
AlertOctagon,
ArrowUpRight,
Bot,
Building2,
Calendar,
CheckCircle2,
CircleDashed,
Coins,
FileSearch,
Filter,
GitBranch,
GitCommit,
Hash,
PlayCircle,
Rocket,
Search,
ShieldCheck,
Tag,
User2,
XCircle,
} from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { cn } from '@/lib/utils'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
getAgnetAuditLogs,
getAgnetDeploymentEvents,
getAgnetSnapshots,
listAgnetDeployments,
type AgnetDeployment,
} from './api'
type StatusKey = 'running' | 'success' | 'failed' | 'pending'
const STATUS_TO_KEY: Record<string, StatusKey> = {
running: 'running',
active: 'running',
in_progress: 'running',
succeeded: 'success',
success: 'success',
completed: 'success',
failed: 'failed',
error: 'failed',
rejected: 'failed',
pending: 'pending',
queued: 'pending',
awaiting: 'pending',
}
function classifyStatus(status: string): StatusKey {
return STATUS_TO_KEY[(status || '').toLowerCase()] ?? 'pending'
}
function StatusBadge({ phase }: { phase: string }) {
const k = classifyStatus(phase)
const map: Record<StatusKey, { cls: string; icon: React.ReactNode }> = {
running: {
cls: 'bg-[color-mix(in_oklch,var(--primary)_22%,transparent)] text-primary ring-primary/40',
icon: <PlayCircle className='h-3 w-3' />,
},
success: {
cls: 'bg-emerald-500/15 text-emerald-400 ring-emerald-500/30',
icon: <CheckCircle2 className='h-3 w-3' />,
},
failed: {
cls: 'bg-rose-500/15 text-rose-400 ring-rose-500/30',
icon: <XCircle className='h-3 w-3' />,
},
pending: {
cls: 'bg-amber-500/15 text-amber-400 ring-amber-500/30',
icon: <CircleDashed className='h-3 w-3' />,
},
}
const m = map[k]
return (
<span
className={cn(
'inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.12em] ring-1 ring-inset',
m.cls
)}
>
{m.icon}
{phase || 'unknown'}
</span>
)
}
function PageSurface(props: { title: string; subtitle?: string; toolbar?: React.ReactNode; children: React.ReactNode }) {
return (
<section className='space-y-5 rounded-2xl border border-[color-mix(in_oklch,var(--primary)_22%,var(--border))] bg-[color-mix(in_oklch,var(--card)_70%,transparent)] p-5 backdrop-blur'>
<header className='flex flex-col gap-3 border-b border-[color-mix(in_oklch,var(--primary)_18%,var(--border))] pb-4 sm:flex-row sm:items-end sm:justify-between'>
<div>
<p className='text-[11px] font-semibold tracking-[0.16em] text-muted-foreground uppercase'>
Heicode Manager
</p>
<h2 className='mt-1 text-xl font-semibold'>{props.title}</h2>
{props.subtitle && (
<p className='mt-1 text-sm text-muted-foreground'>{props.subtitle}</p>
)}
</div>
{props.toolbar && (
<div className='flex flex-wrap items-center gap-2'>{props.toolbar}</div>
)}
</header>
{props.children}
</section>
)
}
function EmptySurface(props: { title: string; hint?: string }) {
return (
<div className='rounded-xl border border-dashed border-[color-mix(in_oklch,var(--primary)_22%,var(--border))] bg-[color-mix(in_oklch,var(--card)_45%,transparent)] p-10 text-center'>
<FileSearch className='mx-auto h-6 w-6 text-muted-foreground' />
<p className='mt-3 text-sm font-medium'>{props.title}</p>
{props.hint && (
<p className='mt-1 text-xs text-muted-foreground'>{props.hint}</p>
)}
</div>
)
}
function LoadingGrid({ rows = 4, height = 'h-24' }: { rows?: number; height?: string }) {
return (
<div className='grid gap-3 sm:grid-cols-2'>
{Array.from({ length: rows }).map((_, idx) => (
<Skeleton key={idx} className={cn('rounded-xl', height)} />
))}
</div>
)
}
function MetaPill({ icon: Icon, label, value }: { icon: React.ComponentType<{ className?: string }>; label: string; value: string }) {
return (
<span className='inline-flex items-center gap-1.5 rounded-full bg-[color-mix(in_oklch,var(--card)_55%,transparent)] px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.12em] text-muted-foreground ring-1 ring-inset ring-border'>
<Icon className='h-3 w-3 text-primary' />
<span>{label}</span>
<span className='font-mono normal-case tracking-normal text-foreground'>
{value}
</span>
</span>
)
}
function describeRiskLevel(dep: AgnetDeployment): { label: string; tone: 'low' | 'mid' | 'high' } {
const objective = (dep.orchestration_plan?.objective || '').toLowerCase()
if (objective.includes('production') || objective.includes('critical')) {
return { label: 'high', tone: 'high' }
}
if (objective.includes('staging') || objective.includes('pilot')) {
return { label: 'mid', tone: 'mid' }
}
return { label: 'low', tone: 'low' }
}
function describeBudget(dep: AgnetDeployment): string {
const plan = dep.orchestration_plan
const agents = plan?.agents?.length ?? 0
return `${agents} agents`
}
function describeExecutor(dep: AgnetDeployment): string {
return dep.orchestration_plan?.metadata?.tenant_id || '—'
}
function formatRelativeTime(value: string | undefined): string {
if (!value) return '—'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
const diff = Date.now() - date.getTime()
const sec = Math.round(diff / 1000)
if (sec < 60) return `${sec}s ago`
const min = Math.round(sec / 60)
if (min < 60) return `${min}m ago`
const hr = Math.round(min / 60)
if (hr < 24) return `${hr}h ago`
const day = Math.round(hr / 24)
return `${day}d ago`
}
// =============================================================================
// Deployments page
// =============================================================================
export function AgnetDeploymentsPage() {
const { t } = useTranslation()
const [filter, setFilter] = useState<'all' | StatusKey>('all')
const [keyword, setKeyword] = useState('')
const { data = [], isLoading } = useQuery({
queryKey: ['agnet', 'deployments'],
queryFn: listAgnetDeployments,
refetchInterval: 30_000,
})
const filtered = useMemo(() => {
return data.filter((dep) => {
const status = classifyStatus(dep.status || dep.phase || '')
if (filter !== 'all' && status !== filter) return false
if (keyword.trim()) {
const k = keyword.toLowerCase()
const blob =
`${dep.deployment_id} ${dep.orchestration_plan?.objective || ''} ${
dep.orchestration_plan?.metadata?.tenant_id || ''
}`.toLowerCase()
if (!blob.includes(k)) return false
}
return true
})
}, [data, filter, keyword])
return (
<PageSurface
title={t('Deployments')}
subtitle={t(
'Card-based view of every Agnet orchestration run with risk, budget, executor and live status.'
)}
toolbar={
<>
<div className='relative'>
<Search className='pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground' />
<Input
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
placeholder={t('Find deployment / tenant / objective')}
className='h-9 w-64 rounded-xl pl-8 text-xs'
/>
</div>
<Select
value={filter}
onValueChange={(value) => setFilter(value as 'all' | StatusKey)}
>
<SelectTrigger className='h-9 w-36 rounded-xl text-xs'>
<Filter className='mr-1 h-3.5 w-3.5' />
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value='all'>{t('All statuses')}</SelectItem>
<SelectItem value='running'>{t('Running')}</SelectItem>
<SelectItem value='success'>{t('Success')}</SelectItem>
<SelectItem value='failed'>{t('Failed')}</SelectItem>
<SelectItem value='pending'>{t('Pending')}</SelectItem>
</SelectContent>
</Select>
</>
}
>
{isLoading ? (
<LoadingGrid rows={4} height='h-32' />
) : filtered.length === 0 ? (
<EmptySurface
title={t('No deployments match the current filter')}
hint={t('Adjust filters or trigger a new orchestration plan.')}
/>
) : (
<div className='grid gap-3 sm:grid-cols-2'>
{filtered.map((dep) => {
const risk = describeRiskLevel(dep)
const phase = dep.phase || dep.status
const objective =
dep.orchestration_plan?.objective ||
dep.orchestration_plan?.template_hint ||
t('No objective')
return (
<article
key={dep.deployment_id}
className='group flex flex-col gap-3 rounded-2xl border border-[color-mix(in_oklch,var(--primary)_18%,var(--border))] bg-[color-mix(in_oklch,var(--card)_55%,transparent)] p-4 transition hover:border-primary/45'
>
<header className='flex items-start justify-between gap-2'>
<div className='min-w-0'>
<div className='flex items-center gap-2'>
<Rocket className='h-3.5 w-3.5 text-primary' />
<span className='font-mono text-xs'>
{dep.deployment_id}
</span>
</div>
<p className='mt-1.5 line-clamp-2 text-sm font-medium'>
{objective}
</p>
</div>
<StatusBadge phase={phase} />
</header>
<div className='flex flex-wrap gap-1.5'>
<MetaPill icon={Tag} label={t('risk')} value={risk.label} />
<MetaPill
icon={Coins}
label={t('budget')}
value={describeBudget(dep)}
/>
<MetaPill
icon={Building2}
label={t('tenant')}
value={describeExecutor(dep)}
/>
</div>
<footer className='mt-auto flex items-center justify-between border-t border-dashed border-[color-mix(in_oklch,var(--primary)_18%,var(--border))] pt-3'>
<span className='text-[11px] text-muted-foreground'>
{formatRelativeTime(dep.updated_at || dep.created_at)}
</span>
<Button
asChild
size='sm'
variant='ghost'
className='gap-1 text-primary'
>
<Link to='/events'>
{t('Inspect events')}
<ArrowUpRight className='h-3.5 w-3.5' />
</Link>
</Button>
</footer>
</article>
)
})}
</div>
)}
</PageSurface>
)
}
// =============================================================================
// Events page
// =============================================================================
const EVENT_LEVELS = ['all', 'info', 'warn', 'error'] as const
type EventLevel = (typeof EVENT_LEVELS)[number]
function classifyEventLevel(entry: Record<string, unknown>): EventLevel {
const candidate = String(
entry.level || entry.severity || entry.status || ''
).toLowerCase()
if (
candidate.includes('error') ||
candidate.includes('fail') ||
candidate.includes('rejected')
)
return 'error'
if (candidate.includes('warn')) return 'warn'
if (!candidate) return 'info'
return 'info'
}
export function AgnetEventsPage() {
const { t } = useTranslation()
const [level, setLevel] = useState<EventLevel>('all')
const deploymentsQuery = useQuery({
queryKey: ['agnet', 'deployments'],
queryFn: listAgnetDeployments,
})
const deployments = deploymentsQuery.data ?? []
const [activeDeployment, setActiveDeployment] = useState<string | undefined>(
undefined
)
const effectiveDeployment = activeDeployment ?? deployments[0]?.deployment_id
const eventsQuery = useQuery({
queryKey: ['agnet', 'events', effectiveDeployment],
queryFn: () => getAgnetDeploymentEvents(effectiveDeployment as string),
enabled: Boolean(effectiveDeployment),
})
const filteredEvents = useMemo(() => {
const items = eventsQuery.data ?? []
if (level === 'all') return items
return items.filter((entry) => classifyEventLevel(entry) === level)
}, [eventsQuery.data, level])
return (
<PageSurface
title={t('Events')}
subtitle={t(
'Lifecycle and policy events emitted by Agnet deployments. Correlate with deployment cards.'
)}
toolbar={
<>
<Select
value={effectiveDeployment ?? ''}
onValueChange={setActiveDeployment}
disabled={deployments.length === 0}
>
<SelectTrigger className='h-9 w-60 rounded-xl text-xs'>
<Rocket className='mr-1 h-3.5 w-3.5' />
<SelectValue placeholder={t('Select deployment')} />
</SelectTrigger>
<SelectContent>
{deployments.map((dep) => (
<SelectItem
key={dep.deployment_id}
value={dep.deployment_id}
>
<span className='font-mono text-xs'>
{dep.deployment_id}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={level}
onValueChange={(value) => setLevel(value as EventLevel)}
>
<SelectTrigger className='h-9 w-32 rounded-xl text-xs'>
<Filter className='mr-1 h-3.5 w-3.5' />
<SelectValue />
</SelectTrigger>
<SelectContent>
{EVENT_LEVELS.map((lv) => (
<SelectItem key={lv} value={lv}>
{lv}
</SelectItem>
))}
</SelectContent>
</Select>
</>
}
>
{!effectiveDeployment ? (
<EmptySurface
title={t('No deployment available for events.')}
hint={t('Trigger a deployment to start emitting events.')}
/>
) : eventsQuery.isLoading ? (
<LoadingGrid rows={4} height='h-16' />
) : filteredEvents.length === 0 ? (
<EmptySurface
title={t('No events for the current filter.')}
hint={t('Switch level or pick another deployment.')}
/>
) : (
<ol className='relative ms-2 space-y-4 border-s border-[color-mix(in_oklch,var(--primary)_18%,var(--border))] ps-4'>
{filteredEvents.map((entry, idx) => {
const lv = classifyEventLevel(entry as Record<string, unknown>)
const action = String(
(entry as Record<string, unknown>).event ||
(entry as Record<string, unknown>).type ||
'event'
)
const occurred = String(
(entry as Record<string, unknown>).occurred_at ||
(entry as Record<string, unknown>).timestamp ||
''
)
const dot =
lv === 'error'
? 'bg-rose-400 ring-rose-500/30'
: lv === 'warn'
? 'bg-amber-400 ring-amber-500/30'
: 'bg-primary ring-primary/30'
const Icon =
lv === 'error'
? AlertOctagon
: lv === 'warn'
? Activity
: GitCommit
return (
<li key={idx} className='relative'>
<span
className={cn(
'absolute -left-[21px] top-1.5 inline-block h-2 w-2 rounded-full ring-[3px]',
dot
)}
/>
<div className='flex items-center gap-2'>
<Icon className='h-3.5 w-3.5 text-primary' />
<p className='font-mono text-[11px] uppercase tracking-[0.14em] text-primary'>
{action}
</p>
<span className='ml-auto text-[11px] text-muted-foreground'>
{formatRelativeTime(occurred)}
</span>
</div>
<pre className='mt-1 overflow-auto rounded-lg bg-[color-mix(in_oklch,var(--card)_45%,transparent)] p-2 text-[11px] leading-relaxed text-muted-foreground'>
{JSON.stringify(entry, null, 2)}
</pre>
</li>
)
})}
</ol>
)}
</PageSurface>
)
}
// =============================================================================
// Audit page
// =============================================================================
export function AgnetAuditPage() {
const { t } = useTranslation()
const [tenant, setTenant] = useState('')
const [actor, setActor] = useState('')
const [action, setAction] = useState('')
const { data = [], isLoading } = useQuery({
queryKey: ['agnet', 'audit'],
queryFn: getAgnetAuditLogs,
refetchInterval: 60_000,
})
const filtered = useMemo(() => {
return data.filter((entry) => {
const e = entry as Record<string, unknown>
const t = String(e.tenant || e.tenant_id || '').toLowerCase()
const a = String(e.actor || e.user || '').toLowerCase()
const ac = String(e.action || e.event || '').toLowerCase()
if (tenant && !t.includes(tenant.toLowerCase())) return false
if (actor && !a.includes(actor.toLowerCase())) return false
if (action && !ac.includes(action.toLowerCase())) return false
return true
})
}, [data, tenant, actor, action])
return (
<PageSurface
title={t('Audit')}
subtitle={t(
'Cross-tenant-safe audit trail for orchestration actions, filtered by tenant, actor, action and time.'
)}
toolbar={
<>
<Input
value={tenant}
onChange={(e) => setTenant(e.target.value)}
placeholder={t('tenant')}
className='h-9 w-36 rounded-xl text-xs'
/>
<Input
value={actor}
onChange={(e) => setActor(e.target.value)}
placeholder={t('actor')}
className='h-9 w-36 rounded-xl text-xs'
/>
<Input
value={action}
onChange={(e) => setAction(e.target.value)}
placeholder={t('action')}
className='h-9 w-36 rounded-xl text-xs'
/>
</>
}
>
{isLoading ? (
<LoadingGrid rows={4} height='h-14' />
) : filtered.length === 0 ? (
<EmptySurface
title={t('No audit entries match the filter.')}
hint={t('Reset filters to see all entries.')}
/>
) : (
<div className='overflow-hidden rounded-xl border border-[color-mix(in_oklch,var(--primary)_18%,var(--border))]'>
<table className='w-full text-sm'>
<thead className='bg-[color-mix(in_oklch,var(--card)_55%,transparent)] text-[11px] uppercase tracking-[0.14em] text-muted-foreground'>
<tr>
<th className='px-4 py-2 text-left font-semibold'>
<ShieldCheck className='mr-1 inline h-3.5 w-3.5' />
{t('action')}
</th>
<th className='px-4 py-2 text-left font-semibold'>
<User2 className='mr-1 inline h-3.5 w-3.5' />
{t('actor')}
</th>
<th className='px-4 py-2 text-left font-semibold'>
<Building2 className='mr-1 inline h-3.5 w-3.5' />
{t('tenant')}
</th>
<th className='px-4 py-2 text-left font-semibold'>
<Calendar className='mr-1 inline h-3.5 w-3.5' />
{t('time')}
</th>
</tr>
</thead>
<tbody className='divide-y divide-border'>
{filtered.map((entry, idx) => {
const e = entry as Record<string, unknown>
return (
<tr
key={idx}
className='odd:bg-[color-mix(in_oklch,var(--card)_30%,transparent)]'
>
<td className='px-4 py-2 font-mono text-xs text-primary'>
{String(e.action || e.event || '—')}
</td>
<td className='px-4 py-2 text-xs'>
{String(e.actor || e.user || '—')}
</td>
<td className='px-4 py-2 text-xs'>
{String(e.tenant || e.tenant_id || '—')}
</td>
<td className='px-4 py-2 text-xs text-muted-foreground'>
{formatRelativeTime(
String(e.occurred_at || e.timestamp || '')
)}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
</PageSurface>
)
}
// =============================================================================
// SK Snapshots page
// =============================================================================
export function AgnetSKSourcesPage() {
const { t } = useTranslation()
const deploymentsQuery = useQuery({
queryKey: ['agnet', 'deployments'],
queryFn: listAgnetDeployments,
})
const deployments = deploymentsQuery.data ?? []
const [activeDeployment, setActiveDeployment] = useState<string | undefined>(
undefined
)
const effectiveDeployment = activeDeployment ?? deployments[0]?.deployment_id
const snapshotsQuery = useQuery({
queryKey: ['agnet', 'snapshots', effectiveDeployment],
queryFn: () => getAgnetSnapshots(effectiveDeployment as string),
enabled: Boolean(effectiveDeployment),
})
const snapshots = snapshotsQuery.data ?? []
return (
<PageSurface
title={t('SK Snapshots')}
subtitle={t(
'Source → snapshot → hash chain. Every snapshot is immutable and tied to the orchestration plan.'
)}
toolbar={
<Select
value={effectiveDeployment ?? ''}
onValueChange={setActiveDeployment}
disabled={deployments.length === 0}
>
<SelectTrigger className='h-9 w-60 rounded-xl text-xs'>
<Rocket className='mr-1 h-3.5 w-3.5' />
<SelectValue placeholder={t('Select deployment')} />
</SelectTrigger>
<SelectContent>
{deployments.map((dep) => (
<SelectItem key={dep.deployment_id} value={dep.deployment_id}>
<span className='font-mono text-xs'>{dep.deployment_id}</span>
</SelectItem>
))}
</SelectContent>
</Select>
}
>
{!effectiveDeployment ? (
<EmptySurface
title={t('No deployment available for SK snapshots.')}
hint={t('Trigger a deployment to resolve SK sources.')}
/>
) : snapshotsQuery.isLoading ? (
<LoadingGrid rows={3} height='h-24' />
) : snapshots.length === 0 ? (
<EmptySurface
title={t('No SK snapshots resolved yet.')}
hint={t(
'Resolve snapshots from the control plane to capture SK lineage.'
)}
/>
) : (
<ul className='space-y-3'>
{snapshots.map((entry, idx) => {
const e = entry as Record<string, unknown>
const sourceType = String(e.source_type || 'unknown')
const sourceRef = String(e.source_ref || e.ref || '—')
const hash = String(e.hash || e.snapshot_hash || '—')
const resolvedAt = String(e.resolved_at || '')
return (
<li
key={idx}
className='rounded-2xl border border-[color-mix(in_oklch,var(--primary)_18%,var(--border))] bg-[color-mix(in_oklch,var(--card)_55%,transparent)] p-4'
>
<div className='grid gap-3 sm:grid-cols-3'>
<div className='flex items-start gap-2'>
<div className='inline-flex h-8 w-8 items-center justify-center rounded-lg bg-[color-mix(in_oklch,var(--primary)_18%,transparent)] text-primary'>
<Bot className='h-4 w-4' />
</div>
<div className='min-w-0'>
<p className='text-[11px] font-semibold uppercase tracking-[0.14em] text-muted-foreground'>
{t('source')}
</p>
<p className='mt-0.5 truncate font-mono text-xs'>
{sourceType}
</p>
</div>
</div>
<div className='flex items-start gap-2'>
<div className='inline-flex h-8 w-8 items-center justify-center rounded-lg bg-[color-mix(in_oklch,var(--primary)_18%,transparent)] text-primary'>
<GitBranch className='h-4 w-4' />
</div>
<div className='min-w-0'>
<p className='text-[11px] font-semibold uppercase tracking-[0.14em] text-muted-foreground'>
{t('reference')}
</p>
<p className='mt-0.5 truncate font-mono text-xs'>
{sourceRef}
</p>
</div>
</div>
<div className='flex items-start gap-2'>
<div className='inline-flex h-8 w-8 items-center justify-center rounded-lg bg-[color-mix(in_oklch,var(--primary)_18%,transparent)] text-primary'>
<Hash className='h-4 w-4' />
</div>
<div className='min-w-0'>
<p className='text-[11px] font-semibold uppercase tracking-[0.14em] text-muted-foreground'>
{t('hash')}
</p>
<p
className='mt-0.5 truncate font-mono text-xs'
title={hash}
>
{hash.length > 18
? `${hash.slice(0, 8)}…${hash.slice(-6)}`
: hash}
</p>
</div>
</div>
</div>
{resolvedAt && (
<p className='mt-3 border-t border-dashed border-[color-mix(in_oklch,var(--primary)_18%,var(--border))] pt-2 text-[11px] text-muted-foreground'>
<Calendar className='mr-1 inline h-3 w-3' />
{t('resolved')} {formatRelativeTime(resolvedAt)}
</p>
)}
</li>
)
})}
</ul>
)}
</PageSurface>
)
}
// =============================================================================
// Templates / Agents (kept for backward compatibility — invoked by side routes)
// =============================================================================
export function AgnetTemplatesPage() {
const { t } = useTranslation()
const templates = [
{
id: 'agile_min',
name: t('Agile Minimal'),
desc: t('Fast loop team with short checkpoints.'),
},
{
id: 'waterfall_min',
name: t('Waterfall Minimal'),
desc: t('Phase-based team with strict gates.'),
},
]
return (
<PageSurface
title={t('Templates')}
subtitle={t('Starter orchestration shapes available to tenants.')}
>
<div className='grid gap-3 md:grid-cols-2'>
{templates.map((tpl) => (
<article
key={tpl.id}
className='rounded-2xl border border-[color-mix(in_oklch,var(--primary)_18%,var(--border))] bg-[color-mix(in_oklch,var(--card)_55%,transparent)] p-4'
>
<p className='text-sm font-semibold'>{tpl.name}</p>
<p className='mt-1 text-sm text-muted-foreground'>{tpl.desc}</p>
<p className='mt-3 font-mono text-[11px] uppercase tracking-[0.12em] text-primary'>
template_id: {tpl.id}
</p>
</article>
))}
</div>
</PageSurface>
)
}
export function AgnetAgentsPage() {
const { t } = useTranslation()
const { data = [] } = useQuery({
queryKey: ['agnet', 'deployments'],
queryFn: listAgnetDeployments,
})
const rows = useMemo(
() =>
data.flatMap((dep: AgnetDeployment) =>
(dep.orchestration_plan?.agents || []).map((agent, idx) => ({
dep: dep.deployment_id,
id: `${dep.deployment_id}-${idx}`,
role: agent.role_template || '-',
model: agent.default_model_id || '-',
goal: agent.goal || '-',
}))
),
[data]
)
return (
<PageSurface
title={t('Agents')}
subtitle={t('Agent declarations parsed from each deployment plan.')}
>
{rows.length === 0 ? (
<EmptySurface
title={t('No agent definitions found in deployment plans.')}
/>
) : (
<ul className='space-y-2'>
{rows.map((row) => (
<li
key={row.id}
className='rounded-xl border border-[color-mix(in_oklch,var(--primary)_18%,var(--border))] bg-[color-mix(in_oklch,var(--card)_55%,transparent)] p-3'
>
<p className='text-sm font-medium'>{row.role}</p>
<p className='mt-1 font-mono text-[11px] uppercase tracking-[0.12em] text-muted-foreground'>
{row.dep} · model: {row.model}
</p>
<p className='mt-2 text-sm'>{row.goal}</p>
</li>
))}
</ul>
)}
</PageSurface>
)
}
+38
View File
@@ -0,0 +1,38 @@
import { Link } from '@tanstack/react-router'
type AgnetHubProps = {
title: string
description: string
}
const quickLinks = [
{ title: 'Deployments', to: '/deployments' as const },
{ title: 'Events', to: '/events' as const },
{ title: 'Audit', to: '/audit' as const },
]
export function AgnetHub(props: AgnetHubProps) {
return (
<div className='mx-auto w-full max-w-5xl p-6'>
<div className='mb-5'>
<h1 className='text-2xl font-semibold'>{props.title}</h1>
<p className='text-muted-foreground mt-2 text-sm'>{props.description}</p>
</div>
<div className='grid gap-4 md:grid-cols-3'>
{quickLinks.map((item) => (
<Link
key={item.title}
to={item.to}
className='bg-card hover:bg-accent/40 rounded-lg border p-4 transition-colors'
>
<div className='font-medium'>{item.title}</div>
<div className='text-muted-foreground mt-1 text-xs'>
Open {item.title.toLowerCase()} workspace
</div>
</Link>
))}
</div>
</div>
)
}
+156 -10
View File
@@ -8,6 +8,69 @@ import type {
ApiResponse,
} from './types'
const AUTH_BASE_URL = (
(import.meta.env.VITE_HEICODE_AUTH_BASE_URL as string | undefined) ||
'https://apimtaiji.azure-api.net/api/mcp'
).trim()
const ACCESS_TOKEN_KEY = 'heicode_access_token'
const REFRESH_TOKEN_KEY = 'heicode_refresh_token'
function readToken(key: string): string {
if (typeof window === 'undefined') return ''
return window.localStorage.getItem(key) || ''
}
function writeTokens(accessToken?: string, refreshToken?: string) {
if (typeof window === 'undefined') return
if (accessToken) {
window.localStorage.setItem(ACCESS_TOKEN_KEY, accessToken)
}
if (refreshToken) {
window.localStorage.setItem(REFRESH_TOKEN_KEY, refreshToken)
}
}
export function clearHeicodeTokens() {
if (typeof window === 'undefined') return
window.localStorage.removeItem(ACCESS_TOKEN_KEY)
window.localStorage.removeItem(REFRESH_TOKEN_KEY)
}
async function callHeicodeAuth<T>(
path: string,
init: RequestInit = {},
useRefreshToken = false
): Promise<T> {
const requestId = `heicode-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
const token = useRefreshToken
? readToken(REFRESH_TOKEN_KEY)
: readToken(ACCESS_TOKEN_KEY)
const res = await fetch(`${AUTH_BASE_URL}${path}`, {
...init,
headers: {
'Content-Type': 'application/json',
'X-Request-Id': requestId,
...(init.headers || {}),
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
})
let data: unknown = null
try {
data = await res.json()
} catch {
data = null
}
if (!res.ok) {
const message =
(data as { detail?: string; message?: string } | null)?.detail ||
(data as { detail?: string; message?: string } | null)?.message ||
`Auth request failed (${res.status})`
throw new Error(message)
}
return data
}
// ============================================================================
// Authentication APIs
// ============================================================================
@@ -18,15 +81,42 @@ import type {
// User login with username and password
export async function login(payload: LoginPayload) {
const turnstile = payload.turnstile ?? ''
const res = await api.post<LoginResponse>(
`/api/user/login?turnstile=${turnstile}`,
{
username: payload.username,
password: payload.password,
const res = await callHeicodeAuth<{
success: boolean
message?: string
detail?: string
data?: {
token?: string
refreshToken?: string
user?: {
id?: string
name?: string
email?: string
role?: string
channelId?: string
}
}
)
return res.data
}>('/api/auth/login', {
method: 'POST',
body: JSON.stringify({
email: payload.username,
password: payload.password,
role: 'user',
}),
})
if (res?.success) {
writeTokens(res.data?.token, res.data?.refreshToken)
} else if (res?.detail || res?.message) {
throw new Error(res.detail || res.message || 'Login failed')
}
return {
success: Boolean(res?.success),
message: res?.message || res?.detail || '',
data: {
id: 1,
user: res?.data?.user,
},
}
}
// Two-factor authentication login
@@ -37,8 +127,64 @@ export async function login2fa(payload: TwoFAPayload) {
// User logout
export async function logout(): Promise<ApiResponse> {
const res = await api.get('/api/user/logout')
return res.data
try {
await callHeicodeAuth('/api/auth/logout', { method: 'POST' })
} finally {
clearHeicodeTokens()
}
return { success: true, message: '' }
}
export async function refreshHeicodeTokenIfNeeded() {
const refreshToken = readToken(REFRESH_TOKEN_KEY)
if (!refreshToken) return false
const res = await callHeicodeAuth<{
success: boolean
detail?: string
data?: { token?: string; refreshToken?: string }
}>('/api/auth/refresh', { method: 'POST' }, true).catch(() => null)
if (res?.success) {
writeTokens(res.data?.token, res.data?.refreshToken)
return true
}
clearHeicodeTokens()
return false
}
export async function getHeicodeCurrentUser() {
let me = await callHeicodeAuth<{
success: boolean
data?: {
id?: string
email?: string
name?: string
role?: string
channelId?: string
status?: string
}
}>('/api/auth/me', { method: 'GET' })
if (!me?.success) {
const refreshed = await refreshHeicodeTokenIfNeeded()
if (!refreshed) return null
me = await callHeicodeAuth('/api/auth/me', { method: 'GET' })
}
if (!me?.success || !me.data) return null
const roleStr = String(me.data.role || 'user').toLowerCase()
const role =
roleStr === 'root' ? 100 : roleStr === 'admin' ? 10 : roleStr === 'user' ? 1 : 1
return {
id: 1,
username: me.data.email || me.data.name || 'heicode-user',
display_name: me.data.name || me.data.email || 'Heicode User',
email: me.data.email || '',
role,
group: me.data.channelId || 'default',
status: me.data.status === 'active' ? 1 : 1,
}
}
// ----------------------------------------------------------------------------
+88 -28
View File
@@ -1,7 +1,6 @@
import { Link } from '@tanstack/react-router'
import { ShieldCheck, Workflow, Layers, Activity } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { useSystemConfig } from '@/hooks/use-system-config'
import { Skeleton } from '@/components/ui/skeleton'
import { BRAND_NAME, BRAND_TAGLINE } from '@/lib/brand'
type AuthLayoutProps = {
children: React.ReactNode
@@ -9,36 +8,97 @@ type AuthLayoutProps = {
export function AuthLayout({ children }: AuthLayoutProps) {
const { t } = useTranslation()
const { systemName, logo, loading } = useSystemConfig()
const pillars = [
{
icon: Workflow,
title: t('Agnet orchestration'),
desc: t('Plan, dispatch and monitor multi-agent runs across tenants.'),
},
{
icon: Layers,
title: t('SK snapshot lineage'),
desc: t('Immutable, hash-verified context bundles wired to every run.'),
},
{
icon: Activity,
title: t('Realtime event timeline'),
desc: t('Status, errors and budget burn in one auditable stream.'),
},
]
return (
<div className='relative grid h-svh max-w-none'>
<Link
to='/'
className='absolute top-4 left-4 z-10 flex items-center gap-2 transition-opacity hover:opacity-80 sm:top-8 sm:left-8'
>
<div className='relative h-8 w-8'>
{loading ? (
<Skeleton className='absolute inset-0 rounded-full' />
) : (
<img
src={logo}
alt={t('Logo')}
className='h-8 w-8 rounded-full object-cover'
/>
)}
<div className='relative grid min-h-svh grid-cols-1 overflow-hidden bg-background lg:grid-cols-[minmax(0,1.05fr)_minmax(0,1fr)]'>
<aside className='relative hidden flex-col justify-between overflow-hidden p-10 text-foreground lg:flex'>
<div className='pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_15%_15%,color-mix(in_oklch,var(--primary)_42%,transparent)_0,transparent_55%),radial-gradient(circle_at_85%_85%,color-mix(in_oklch,var(--accent)_38%,transparent)_0,transparent_55%),linear-gradient(150deg,color-mix(in_oklch,var(--background)_92%,black)_0%,color-mix(in_oklch,var(--background)_85%,var(--primary))_100%)]' />
<div className='pointer-events-none absolute inset-0 [mask-image:radial-gradient(circle_at_center,black_55%,transparent_85%)] bg-[linear-gradient(transparent_95%,color-mix(in_oklch,var(--primary)_30%,transparent)_95%),linear-gradient(90deg,transparent_95%,color-mix(in_oklch,var(--primary)_30%,transparent)_95%)] bg-[size:42px_42px]' />
<div className='relative z-10 flex items-center gap-3'>
<span className='inline-flex h-10 w-10 items-center justify-center rounded-2xl bg-[color-mix(in_oklch,var(--primary)_25%,transparent)] text-primary ring-1 ring-[color-mix(in_oklch,var(--primary)_45%,var(--border))] backdrop-blur-xl'>
<ShieldCheck className='h-5 w-5' />
</span>
<div className='leading-tight'>
<p className='text-[11px] font-semibold tracking-[0.22em] text-primary uppercase'>
{BRAND_NAME}
</p>
<p className='text-sm text-muted-foreground'>{BRAND_TAGLINE}</p>
</div>
</div>
{loading ? (
<Skeleton className='h-6 w-24' />
) : (
<h1 className='text-xl font-medium'>{systemName}</h1>
)}
</Link>
<div className='container flex items-center pt-16 sm:pt-0'>
<div className='mx-auto flex w-full flex-col justify-center space-y-2 px-4 py-8 sm:w-[480px] sm:p-8'>
<div className='relative z-10 max-w-xl space-y-6'>
<h1 className='text-balance text-4xl font-semibold tracking-tight sm:text-5xl'>
{t('A control plane for multi-tenant agentic delivery.')}
</h1>
<p className='text-base text-muted-foreground sm:text-lg'>
{t(
'Sign in to operate Agnet deployments, inspect events, and audit SK snapshots for every tenant under your account.'
)}
</p>
<div className='grid gap-3 sm:grid-cols-2'>
{pillars.map(({ icon: Icon, title, desc }) => (
<div
key={title}
className='rounded-2xl border border-[color-mix(in_oklch,var(--primary)_22%,var(--border))] bg-[color-mix(in_oklch,var(--card)_55%,transparent)] p-4 backdrop-blur-md'
>
<div className='mb-2 inline-flex h-8 w-8 items-center justify-center rounded-lg bg-[color-mix(in_oklch,var(--primary)_18%,transparent)] text-primary'>
<Icon className='h-4 w-4' />
</div>
<p className='text-sm font-semibold'>{title}</p>
<p className='mt-1 text-xs leading-relaxed text-muted-foreground'>
{desc}
</p>
</div>
))}
</div>
</div>
<div className='relative z-10 flex items-center justify-between text-xs text-muted-foreground'>
<div className='flex items-center gap-2'>
<span className='h-2 w-2 animate-pulse rounded-full bg-emerald-400 shadow-[0_0_12px_var(--primary)]' />
<span>{t('Identity service online')}</span>
</div>
<span className='font-mono'>auth.heicode / v1</span>
</div>
</aside>
<main className='relative flex min-h-svh items-center justify-center px-4 py-10 sm:px-10'>
<div className='absolute inset-0 -z-10 lg:hidden bg-[radial-gradient(circle_at_0%_0%,color-mix(in_oklch,var(--primary)_25%,transparent)_0,transparent_45%),linear-gradient(160deg,color-mix(in_oklch,var(--background)_94%,black),color-mix(in_oklch,var(--background)_98%,var(--primary)))]' />
<div className='w-full max-w-md'>
<div className='mb-8 flex items-center gap-2 lg:hidden'>
<span className='inline-flex h-9 w-9 items-center justify-center rounded-xl bg-[color-mix(in_oklch,var(--primary)_25%,transparent)] text-primary ring-1 ring-[color-mix(in_oklch,var(--primary)_40%,var(--border))]'>
<ShieldCheck className='h-4 w-4' />
</span>
<div className='leading-tight'>
<p className='text-[10px] font-semibold tracking-[0.2em] text-primary uppercase'>
{BRAND_NAME}
</p>
<p className='text-xs text-muted-foreground'>{BRAND_TAGLINE}</p>
</div>
</div>
{children}
</div>
</div>
</main>
</div>
)
}
@@ -1,7 +1,7 @@
import { useMemo } from 'react'
import { Loader2, Send, Shield, UserRound, type LucideIcon } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { SiGithub, SiLinux, SiWechat } from 'react-icons/si'
import { SiLinux, SiWechat } from 'react-icons/si'
import { AuthLayout } from '../auth-layout'
type OAuthCallbackScreenProps = {
@@ -15,12 +15,6 @@ type ProviderMeta = {
}
const providerDictionary: Record<string, ProviderMeta> = {
github: {
label: 'GitHub',
Icon: (props: { className?: string }) => (
<SiGithub className={props.className} focusable='false' />
),
},
oidc: { label: 'OIDC', Icon: Shield },
linuxdo: {
label: 'LinuxDO',
@@ -2,7 +2,6 @@ import type { ReactNode } from 'react'
import { useTranslation } from 'react-i18next'
import {
IconDiscord,
IconGithub,
IconLinuxDo,
IconWeChat,
} from '@/assets/brand-icons'
@@ -37,9 +36,6 @@ export function OAuthProviders({
const { t } = useTranslation()
const {
isLoading,
githubButtonText,
githubButtonDisabled,
handleGitHubLogin,
handleDiscordLogin,
handleOIDCLogin,
handleLinuxDOLogin,
@@ -59,16 +55,6 @@ export function OAuthProviders({
})
}
if (status?.github_oauth) {
providerButtons.push({
key: 'github',
label: githubButtonText || t('Continue with GitHub'),
onClick: handleGitHubLogin,
icon: <IconGithub className='h-4 w-4' />,
disabled: githubButtonDisabled,
})
}
if (status?.discord_oauth) {
providerButtons.push({
key: 'discord',
@@ -1,8 +1,7 @@
import { useNavigate } from '@tanstack/react-router'
import i18n from 'i18next'
import { useAuthStore } from '@/stores/auth-store'
import { getSelf } from '@/lib/api'
import type { User } from '@/features/users/types'
import { getHeicodeCurrentUser } from '@/features/auth/api'
import { saveUserId } from '../lib/storage'
/**
@@ -26,25 +25,20 @@ export function useAuthRedirect() {
saveUserId(userData.id)
}
// Fetch and set user data
// Fetch and set user data from external auth only
try {
const self = await getSelf()
if (self?.success && self.data) {
const user = self.data as User
auth.setUser(user)
// Update user ID if not already set
if (user.id) {
saveUserId(user.id)
}
// Restore saved language preference
const savedLang = (user as Record<string, unknown>).language as
const heicodeUser = await getHeicodeCurrentUser()
if (heicodeUser) {
auth.setUser(heicodeUser)
saveUserId(heicodeUser.id)
const savedLang = (heicodeUser as Record<string, unknown>).language as
| string
| undefined
if (savedLang && savedLang !== i18n.language) {
i18n.changeLanguage(savedLang)
}
} else {
throw new Error('External auth session invalid')
}
} catch (error) {
// eslint-disable-next-line no-console
@@ -1,4 +1,4 @@
import { useState, useRef, useEffect } from 'react'
import { useState } from 'react'
import type { AxiosRequestConfig } from 'axios'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
@@ -6,7 +6,6 @@ import { useAuthStore } from '@/stores/auth-store'
import { api } from '@/lib/api'
import { getOAuthState } from '../api'
import {
buildGitHubOAuthUrl,
buildDiscordOAuthUrl,
buildOIDCOAuthUrl,
buildLinuxDOOAuthUrl,
@@ -23,21 +22,8 @@ type LogoutRequestConfig = AxiosRequestConfig & {
export function useOAuthLogin(status: SystemStatus | null) {
const { t } = useTranslation()
const [isLoading, setIsLoading] = useState(false)
const [githubButtonText, setGithubButtonText] = useState('')
const [githubButtonDisabled, setGithubButtonDisabled] = useState(false)
const githubTimeoutRef = useRef<NodeJS.Timeout | null>(null)
const { auth } = useAuthStore()
useEffect(() => {
setGithubButtonText(t('Continue with GitHub'))
return () => {
if (githubTimeoutRef.current) {
clearTimeout(githubTimeoutRef.current)
}
}
}, [t])
const resetSession = async () => {
try {
auth.reset()
@@ -53,53 +39,6 @@ export function useOAuthLogin(status: SystemStatus | null) {
}
}
const handleGitHubLogin = async () => {
if (!status?.github_client_id) return
if (githubButtonDisabled) return
setIsLoading(true)
setGithubButtonDisabled(true)
setGithubButtonText(t('Redirecting to GitHub...'))
if (githubTimeoutRef.current) {
clearTimeout(githubTimeoutRef.current)
}
githubTimeoutRef.current = setTimeout(() => {
setIsLoading(false)
setGithubButtonText(
t('Request timed out, please refresh and restart GitHub login')
)
setGithubButtonDisabled(true)
}, 20000)
try {
await resetSession()
const state = await getOAuthState()
if (!state) {
toast.error(t('Failed to initialize OAuth'))
if (githubTimeoutRef.current) {
clearTimeout(githubTimeoutRef.current)
}
setIsLoading(false)
setGithubButtonText(t('Continue with GitHub'))
setGithubButtonDisabled(false)
return
}
const url = buildGitHubOAuthUrl(status.github_client_id, state)
window.open(url, '_self')
} catch (_error) {
toast.error(t('Failed to start GitHub login'))
if (githubTimeoutRef.current) {
clearTimeout(githubTimeoutRef.current)
}
setIsLoading(false)
setGithubButtonText(t('Continue with GitHub'))
setGithubButtonDisabled(false)
}
}
const handleDiscordLogin = async () => {
if (!status?.discord_client_id) return
@@ -205,9 +144,6 @@ export function useOAuthLogin(status: SystemStatus | null) {
return {
isLoading,
githubButtonText,
githubButtonDisabled,
handleGitHubLogin,
handleDiscordLogin,
handleOIDCLogin,
handleLinuxDOLogin,
-2
View File
@@ -11,7 +11,6 @@ export {
sendEmailVerification,
bindEmail,
getOAuthState,
githubOAuthStart,
wechatLoginByCode,
} from './api'
@@ -58,7 +57,6 @@ export {
// ============================================================================
export {
buildGitHubOAuthUrl,
buildDiscordOAuthUrl,
buildOIDCOAuthUrl,
buildLinuxDOOAuthUrl,
-11
View File
@@ -1,7 +1,6 @@
import type { SystemStatus, OAuthProvider } from '../types'
export {
buildGitHubOAuthUrl,
buildDiscordOAuthUrl,
buildOIDCOAuthUrl,
buildLinuxDOOAuthUrl,
@@ -21,15 +20,6 @@ export function getAvailableOAuthProviders(
const providers: OAuthProvider[] = []
if (status.github_oauth) {
providers.push({
name: 'GitHub',
type: 'github',
enabled: true,
clientId: status.github_client_id,
})
}
if (status.discord_oauth) {
providers.push({
name: 'Discord',
@@ -75,7 +65,6 @@ export function getAvailableOAuthProviders(
export function hasOAuthProviders(status: SystemStatus | null): boolean {
if (!status) return false
return !!(
status.github_oauth ||
status.discord_oauth ||
status.oidc_enabled ||
status.linuxdo_oauth ||
@@ -1,27 +1,12 @@
import { useEffect, useMemo, useState } from 'react'
import { useState } from 'react'
import type { z } from 'zod'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { Link } from '@tanstack/react-router'
import { Loader2, LogIn, KeyRound } from 'lucide-react'
import { Loader2, LogIn, Mail, KeyRound } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import {
buildAssertionResult,
prepareCredentialRequestOptions,
isPasskeySupported as detectPasskeySupport,
} from '@/lib/passkey'
import { cn } from '@/lib/utils'
import { useStatus } from '@/hooks/use-status'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Form,
FormControl,
@@ -31,16 +16,10 @@ import {
FormMessage,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { PasswordInput } from '@/components/password-input'
import { Turnstile } from '@/components/turnstile'
import { login, wechatLoginByCode } from '@/features/auth/api'
import { LegalConsent } from '@/features/auth/components/legal-consent'
import { OAuthProviders } from '@/features/auth/components/oauth-providers'
import { login } from '@/features/auth/api'
import { loginFormSchema } from '@/features/auth/constants'
import { useAuthRedirect } from '@/features/auth/hooks/use-auth-redirect'
import { useTurnstile } from '@/features/auth/hooks/use-turnstile'
import { beginPasskeyLogin, finishPasskeyLogin } from '@/features/auth/passkey'
import type { AuthFormProps } from '@/features/auth/types'
export function UserAuthForm({
@@ -50,50 +29,8 @@ export function UserAuthForm({
}: AuthFormProps) {
const { t } = useTranslation()
const [isLoading, setIsLoading] = useState(false)
const [wechatCode, setWeChatCode] = useState('')
const [agreedToLegal, setAgreedToLegal] = useState(false)
const [passkeySupported, setPasskeySupported] = useState(false)
const [isPasskeyLoading, setIsPasskeyLoading] = useState(false)
const [isWeChatDialogOpen, setIsWeChatDialogOpen] = useState(false)
const [isWeChatSubmitting, setIsWeChatSubmitting] = useState(false)
const legalConsentErrorMessage = t('Please agree to the legal terms first')
const loginFailedMessage = t('Login failed')
const { status } = useStatus()
const passkeyLoginEnabled = Boolean(
status?.passkey_login ?? status?.data?.passkey_login
)
const {
isTurnstileEnabled,
turnstileSiteKey,
turnstileToken,
setTurnstileToken,
validateTurnstile,
} = useTurnstile()
const { handleLoginSuccess, redirectTo2FA } = useAuthRedirect()
const hasUserAgreement = Boolean(status?.user_agreement_enabled)
const hasPrivacyPolicy = Boolean(status?.privacy_policy_enabled)
const requiresLegalConsent = hasUserAgreement || hasPrivacyPolicy
const passkeyButtonDisabled =
isPasskeyLoading ||
!passkeySupported ||
(requiresLegalConsent && !agreedToLegal)
const hasWeChatLogin = Boolean(status?.wechat_login)
useEffect(() => {
if (requiresLegalConsent) {
setAgreedToLegal(false)
} else {
setAgreedToLegal(true)
}
}, [requiresLegalConsent])
useEffect(() => {
detectPasskeySupport()
.then(setPasskeySupported)
.catch(() => setPasskeySupported(false))
}, [])
const { handleLoginSuccess } = useAuthRedirect()
const form = useForm<z.infer<typeof loginFormSchema>>({
resolver: zodResolver(loginFormSchema),
@@ -103,157 +40,31 @@ export function UserAuthForm({
},
})
const wechatQrCodeUrl = useMemo(() => {
return (
status?.wechat_qrcode ||
status?.wechat_qr_code ||
status?.wechat_qrcode_image_url ||
status?.wechat_qr_code_image_url ||
status?.wechat_account_qrcode_image_url ||
status?.WeChatAccountQRCodeImageURL ||
status?.data?.wechat_qrcode ||
status?.data?.WeChatAccountQRCodeImageURL ||
''
)
}, [status])
async function onSubmit(data: z.infer<typeof loginFormSchema>) {
if (requiresLegalConsent && !agreedToLegal) {
toast.error(legalConsentErrorMessage)
return
}
if (!validateTurnstile()) return
setIsLoading(true)
try {
const res = await login({
username: data.username,
password: data.password,
turnstile: turnstileToken,
})
if (res.success) {
if (res.data?.require_2fa) {
redirectTo2FA()
return
}
await handleLoginSuccess(res.data as { id?: number } | null, redirectTo)
await handleLoginSuccess(
res.data as { id?: number } | null,
redirectTo
)
toast.success(t('Welcome back!'))
} else if (res.message) {
toast.error(res.message)
}
} catch (_error) {
// Errors are handled by global interceptor
} finally {
setIsLoading(false)
}
}
const handleOpenWeChatDialog = () => {
if (requiresLegalConsent && !agreedToLegal) {
toast.error(legalConsentErrorMessage)
return
}
setIsWeChatDialogOpen(true)
}
const handleWeChatDialogChange = (open: boolean) => {
setIsWeChatDialogOpen(open)
if (!open) {
setWeChatCode('')
setIsWeChatSubmitting(false)
}
}
async function handleWeChatLogin() {
if (!wechatCode.trim()) {
toast.error(t('Please enter the verification code'))
return
}
setIsWeChatSubmitting(true)
try {
const res = await wechatLoginByCode(wechatCode)
if (res?.success) {
await handleLoginSuccess(res.data as { id?: number } | null, redirectTo)
toast.success(t('Signed in via WeChat'))
handleWeChatDialogChange(false)
} else {
toast.error(res?.message || loginFailedMessage)
}
} catch (_error) {
toast.error(loginFailedMessage)
} finally {
setIsWeChatSubmitting(false)
}
}
async function handlePasskeyLogin() {
if (requiresLegalConsent && !agreedToLegal) {
toast.error(legalConsentErrorMessage)
return
}
if (!passkeySupported) {
toast.error(t('Passkey is not supported on this device'))
return
}
if (!navigator?.credentials) {
toast.error(t('Passkey is not available in this browser'))
return
}
setIsPasskeyLoading(true)
try {
const begin = await beginPasskeyLogin()
if (!begin.success) {
throw new Error(begin.message || t('Failed to start Passkey login'))
}
const publicKey = prepareCredentialRequestOptions(
begin.data?.options ?? begin.data
)
const credential = (await navigator.credentials.get({
publicKey,
})) as PublicKeyCredential | null
if (!credential) {
toast.info(t('Passkey login was cancelled'))
return
}
const assertion = buildAssertionResult(credential)
if (!assertion) {
throw new Error(t('Invalid Passkey response'))
}
const finish = await finishPasskeyLogin(assertion)
if (!finish.success) {
throw new Error(finish.message || t('Failed to complete Passkey login'))
}
if (!finish.data) {
throw new Error(t('Missing user data from Passkey login response'))
}
await handleLoginSuccess(
finish.data as { id?: number } | null,
redirectTo
)
toast.success(t('Signed in with Passkey'))
} catch (error: unknown) {
if (error instanceof DOMException && error.name === 'NotAllowedError') {
toast.info(t('Passkey login was cancelled or timed out'))
} else if (error instanceof Error) {
} catch (error) {
if (error instanceof Error) {
toast.error(error.message)
} else {
toast.error(t('Passkey login failed'))
toast.error(loginFailedMessage)
}
} finally {
setIsPasskeyLoading(false)
setIsLoading(false)
}
}
@@ -261,175 +72,74 @@ export function UserAuthForm({
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className={cn('grid gap-4', className)}
className={cn('space-y-5', className)}
{...props}
>
{/* Username Field */}
<FormField
control={form.control}
name='username'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Username or Email')}</FormLabel>
<FormLabel className='text-xs font-semibold uppercase tracking-[0.14em] text-muted-foreground'>
{t('Email')}
</FormLabel>
<FormControl>
<Input
placeholder={t('Enter your username or email')}
{...field}
/>
<div className='relative'>
<Mail className='pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground' />
<Input
autoComplete='email'
placeholder={t('user@tenant.example')}
className='h-12 rounded-xl border-[color-mix(in_oklch,var(--primary)_28%,var(--border))] bg-[color-mix(in_oklch,var(--background)_82%,transparent)] pl-10 text-sm shadow-[inset_0_1px_0_color-mix(in_oklch,var(--primary)_18%,transparent)] focus-visible:ring-primary/40'
{...field}
/>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Password Field */}
<FormField
control={form.control}
name='password'
render={({ field }) => (
<FormItem className='relative'>
<FormLabel>{t('Password')}</FormLabel>
<FormItem>
<FormLabel className='text-xs font-semibold uppercase tracking-[0.14em] text-muted-foreground'>
{t('Password')}
</FormLabel>
<FormControl>
<PasswordInput placeholder={t('Enter password')} {...field} />
<div className='relative'>
<KeyRound className='pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground' />
<PasswordInput
autoComplete='current-password'
placeholder={t('Enter password')}
className='h-12 rounded-xl border-[color-mix(in_oklch,var(--primary)_28%,var(--border))] bg-[color-mix(in_oklch,var(--background)_82%,transparent)] pl-10 text-sm shadow-[inset_0_1px_0_color-mix(in_oklch,var(--primary)_18%,transparent)] focus-visible:ring-primary/40'
{...field}
/>
</div>
</FormControl>
<FormMessage />
<Link
to='/forgot-password'
className='text-muted-foreground absolute end-0 -top-0.5 text-sm font-medium hover:opacity-75'
>
{t('Forgot password?')}
</Link>
</FormItem>
)}
/>
{/* Submit Button */}
<Button
className='mt-2 w-full justify-center gap-2'
disabled={isLoading || (requiresLegalConsent && !agreedToLegal)}
type='submit'
className='h-12 w-full justify-center gap-2 rounded-xl text-sm font-semibold shadow-[0_18px_48px_-22px_color-mix(in_oklch,var(--primary)_75%,black)]'
disabled={isLoading}
>
{isLoading ? <Loader2 className='animate-spin' /> : <LogIn />}
{isLoading ? (
<Loader2 className='h-4 w-4 animate-spin' />
) : (
<LogIn className='h-4 w-4' />
)}
{t('Sign in')}
</Button>
{/* Turnstile */}
{isTurnstileEnabled && (
<div className='mt-2'>
<Turnstile
siteKey={turnstileSiteKey}
onVerify={setTurnstileToken}
/>
</div>
)}
<LegalConsent
status={status}
checked={agreedToLegal}
onCheckedChange={setAgreedToLegal}
className='mt-1'
/>
{passkeyLoginEnabled && (
<div className='mt-2 space-y-1'>
<Button
type='button'
variant='outline'
disabled={passkeyButtonDisabled}
onClick={handlePasskeyLogin}
className='h-11 w-full justify-center gap-2 rounded-lg'
>
{isPasskeyLoading ? (
<Loader2 className='h-4 w-4 animate-spin' />
) : (
<KeyRound className='h-4 w-4' />
)}
{t('Sign in with Passkey')}
</Button>
{!passkeySupported && (
<p className='text-muted-foreground text-xs'>
{t('Passkey is not supported on this device.')}
</p>
)}
</div>
)}
{/* OAuth Providers */}
<OAuthProviders
status={status}
disabled={isLoading || (requiresLegalConsent && !agreedToLegal)}
onWeChatLogin={hasWeChatLogin ? handleOpenWeChatDialog : undefined}
isWeChatLoading={isWeChatSubmitting}
/>
<p className='text-center text-[11px] tracking-[0.12em] text-muted-foreground uppercase'>
{t('Authenticated via Heicode identity service')}
</p>
</form>
{hasWeChatLogin && (
<Dialog
open={isWeChatDialogOpen}
onOpenChange={handleWeChatDialogChange}
>
<DialogContent className='max-w-sm'>
<DialogHeader className='text-left'>
<DialogTitle>{t('WeChat sign in')}</DialogTitle>
<DialogDescription>
{t(
'Scan the QR code to follow the official account and reply with “验证码” to receive your verification code.'
)}
</DialogDescription>
</DialogHeader>
{wechatQrCodeUrl ? (
<div className='flex justify-center'>
<img
src={wechatQrCodeUrl}
alt={t('WeChat login QR code')}
className='h-40 w-40 rounded-md border object-contain'
/>
</div>
) : (
<p className='text-muted-foreground text-sm'>
{t('QR code is not configured. Please contact support.')}
</p>
)}
<div className='grid gap-2'>
<Label htmlFor='wechat-code'>{t('Verification code')}</Label>
<Input
id='wechat-code'
placeholder={t('Enter the verification code')}
value={wechatCode}
onChange={(event) => setWeChatCode(event.target.value)}
autoComplete='one-time-code'
/>
</div>
<DialogFooter>
<Button
type='button'
variant='outline'
onClick={() => handleWeChatDialogChange(false)}
disabled={isWeChatSubmitting}
>
{t('Cancel')}
</Button>
<Button
type='button'
onClick={handleWeChatLogin}
disabled={
isWeChatSubmitting ||
!wechatCode.trim() ||
(requiresLegalConsent && !agreedToLegal)
}
className='gap-2'
>
{isWeChatSubmitting ? (
<Loader2 className='h-4 w-4 animate-spin' />
) : null}
{t('Confirm')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)}
</Form>
)
}
+32 -25
View File
@@ -1,43 +1,50 @@
import { Link, useSearch } from '@tanstack/react-router'
import { useSearch } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import { useStatus } from '@/hooks/use-status'
import { ArrowUpRight } from 'lucide-react'
import { AuthLayout } from '../auth-layout'
import { TermsFooter } from '../components/terms-footer'
import { UserAuthForm } from './components/user-auth-form'
export function SignIn() {
const { t } = useTranslation()
const { redirect } = useSearch({ from: '/(auth)/sign-in' })
const { status } = useStatus()
return (
<AuthLayout>
<div className='w-full space-y-8'>
<div className='space-y-2'>
<h2 className='text-center text-2xl font-semibold tracking-tight sm:text-left'>
{t('Sign in')}
<div className='space-y-8'>
<div className='space-y-3'>
<span className='inline-flex items-center gap-2 rounded-full border border-[color-mix(in_oklch,var(--primary)_35%,var(--border))] bg-[color-mix(in_oklch,var(--primary)_12%,transparent)] px-3 py-1 text-[11px] font-semibold tracking-[0.18em] text-primary uppercase'>
<span className='inline-block h-1.5 w-1.5 rounded-full bg-primary' />
{t('Tenant access')}
</span>
<h2 className='text-3xl font-semibold tracking-tight sm:text-4xl'>
{t('Sign in to your workspace')}
</h2>
{!status?.self_use_mode_enabled && (
<p className='text-muted-foreground text-left text-sm sm:text-base'>
{t("Don't have an account?")}{' '}
<Link
to='/sign-up'
className='hover:text-primary font-medium underline underline-offset-4'
>
{t('Sign up')}
</Link>
.
</p>
)}
<p className='text-sm text-muted-foreground sm:text-base'>
{t(
'Authenticate against the Heicode identity service. Tenant scope, role and SK access will be loaded automatically.'
)}
</p>
</div>
<UserAuthForm redirectTo={redirect} />
<TermsFooter
variant='sign-in'
status={status}
className='text-center'
/>
<div className='space-y-3 border-t border-dashed border-[color-mix(in_oklch,var(--primary)_22%,var(--border))] pt-5'>
<p className='text-xs font-semibold tracking-[0.16em] text-muted-foreground uppercase'>
{t('Need access?')}
</p>
<p className='text-sm text-muted-foreground'>
{t(
'Account provisioning is handled by your platform administrator. Reach out to the Heicode operator to be added to a tenant.'
)}
</p>
<a
href='mailto:operators@heicode.local'
className='inline-flex items-center gap-1 text-sm font-medium text-primary hover:underline'
>
{t('Contact tenant operator')}
<ArrowUpRight className='h-3.5 w-3.5' />
</a>
</div>
</div>
</AuthLayout>
)
@@ -0,0 +1,447 @@
import { useMemo } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Link } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import {
Activity,
AlertTriangle,
ArrowUpRight,
Building2,
CheckCircle2,
CircleDashed,
PlayCircle,
Rocket,
ScrollText,
ShieldCheck,
StopCircle,
Wallet,
} from 'lucide-react'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import {
getAgnetAuditLogs,
listAgnetDeployments,
type AgnetDeployment,
} from '@/features/agnet-console/api'
type StatusKey = 'running' | 'success' | 'failed' | 'pending'
const STATUS_TO_KEY: Record<string, StatusKey> = {
running: 'running',
active: 'running',
in_progress: 'running',
succeeded: 'success',
success: 'success',
completed: 'success',
failed: 'failed',
error: 'failed',
rejected: 'failed',
pending: 'pending',
queued: 'pending',
awaiting: 'pending',
}
function classifyStatus(status: string): StatusKey {
return STATUS_TO_KEY[(status || '').toLowerCase()] ?? 'pending'
}
function StatCard({
label,
value,
hint,
icon: Icon,
tone,
}: {
label: string
value: string
hint?: string
icon: React.ComponentType<{ className?: string }>
tone: 'primary' | 'success' | 'warn' | 'danger' | 'muted'
}) {
const toneClass: Record<typeof tone, string> = {
primary:
'bg-[color-mix(in_oklch,var(--primary)_18%,transparent)] text-primary',
success:
'bg-[color-mix(in_oklch,oklch(0.78_0.18_150)_22%,transparent)] text-emerald-400',
warn:
'bg-[color-mix(in_oklch,oklch(0.85_0.16_85)_22%,transparent)] text-amber-400',
danger:
'bg-[color-mix(in_oklch,oklch(0.7_0.21_25)_22%,transparent)] text-rose-400',
muted:
'bg-[color-mix(in_oklch,var(--muted)_60%,transparent)] text-muted-foreground',
}
return (
<div className='relative overflow-hidden rounded-2xl border border-[color-mix(in_oklch,var(--primary)_22%,var(--border))] bg-[color-mix(in_oklch,var(--card)_70%,transparent)] p-5 shadow-[0_18px_60px_-40px_color-mix(in_oklch,var(--primary)_60%,black)] backdrop-blur-md'>
<div className='flex items-center gap-3'>
<div
className={cn(
'inline-flex h-10 w-10 items-center justify-center rounded-xl',
toneClass[tone]
)}
>
<Icon className='h-5 w-5' />
</div>
<p className='text-[11px] font-semibold tracking-[0.16em] text-muted-foreground uppercase'>
{label}
</p>
</div>
<p className='mt-4 text-3xl font-semibold tabular-nums'>{value}</p>
{hint && <p className='mt-1 text-xs text-muted-foreground'>{hint}</p>}
</div>
)
}
function PhasePill({ phase }: { phase: string }) {
const k = classifyStatus(phase)
const map: Record<StatusKey, string> = {
running:
'bg-[color-mix(in_oklch,var(--primary)_22%,transparent)] text-primary ring-primary/40',
success:
'bg-emerald-500/15 text-emerald-400 ring-emerald-500/30',
failed: 'bg-rose-500/15 text-rose-400 ring-rose-500/30',
pending:
'bg-[color-mix(in_oklch,var(--muted)_60%,transparent)] text-muted-foreground ring-border',
}
return (
<span
className={cn(
'inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.12em] ring-1 ring-inset',
map[k]
)}
>
<span
className={cn(
'inline-block h-1.5 w-1.5 rounded-full',
k === 'running' && 'animate-pulse bg-primary',
k === 'success' && 'bg-emerald-400',
k === 'failed' && 'bg-rose-400',
k === 'pending' && 'bg-muted-foreground'
)}
/>
{phase || 'unknown'}
</span>
)
}
function formatRelative(value: string | number | undefined): string {
if (!value) return '—'
const date = typeof value === 'number' ? new Date(value * 1000) : new Date(value)
if (Number.isNaN(date.getTime())) return String(value)
const diffMs = date.getTime() - Date.now()
const abs = Math.abs(diffMs)
const sec = Math.round(abs / 1000)
if (sec < 60) return `${sec}s`
const min = Math.round(sec / 60)
if (min < 60) return `${min}m`
const hr = Math.round(min / 60)
if (hr < 24) return `${hr}h`
const day = Math.round(hr / 24)
return `${day}d`
}
export function CockpitView() {
const { t } = useTranslation()
const deploymentsQuery = useQuery({
queryKey: ['cockpit', 'deployments'],
queryFn: listAgnetDeployments,
refetchInterval: 30_000,
})
const auditQuery = useQuery({
queryKey: ['cockpit', 'audit'],
queryFn: getAgnetAuditLogs,
refetchInterval: 60_000,
})
const stats = useMemo(() => {
const list: AgnetDeployment[] = deploymentsQuery.data ?? []
const counters: Record<StatusKey, number> = {
running: 0,
success: 0,
failed: 0,
pending: 0,
}
list.forEach((d) => {
counters[classifyStatus(d.status || d.phase || '')]++
})
const total = list.length || 1
const successRate = Math.round(
((counters.success + counters.running) / total) * 100
)
return {
list,
counters,
successRate: Number.isFinite(successRate) ? successRate : 0,
}
}, [deploymentsQuery.data])
const recentDeployments = useMemo(
() =>
[...stats.list]
.sort((a, b) =>
(b.updated_at || b.created_at || '').localeCompare(
a.updated_at || a.created_at || ''
)
)
.slice(0, 5),
[stats.list]
)
const auditFeed = useMemo(() => {
const items = (auditQuery.data ?? []) as Array<Record<string, unknown>>
return items.slice(0, 8)
}, [auditQuery.data])
return (
<div className='space-y-6'>
{/* Tier 1: Status */}
<div className='grid grid-cols-2 gap-3 lg:grid-cols-4'>
<StatCard
label={t('Running deployments')}
value={String(stats.counters.running)}
hint={t('Active code delivery runs across tenants')}
icon={PlayCircle}
tone='primary'
/>
<StatCard
label={t('Failed (visible window)')}
value={String(stats.counters.failed)}
hint={t('Triggers an audit entry')}
icon={AlertTriangle}
tone='danger'
/>
<StatCard
label={t('Healthy ratio')}
value={`${stats.successRate}%`}
hint={t('Running + success / total')}
icon={CheckCircle2}
tone='success'
/>
<StatCard
label={t('Pending approvals')}
value={String(stats.counters.pending)}
hint={t('Awaiting platform arbitration')}
icon={CircleDashed}
tone='warn'
/>
</div>
{/* Tier 2: Stream */}
<div className='grid gap-4 lg:grid-cols-[minmax(0,1.4fr)_minmax(0,1fr)]'>
<section className='rounded-2xl border border-[color-mix(in_oklch,var(--primary)_22%,var(--border))] bg-[color-mix(in_oklch,var(--card)_70%,transparent)] p-5 backdrop-blur'>
<header className='mb-4 flex items-center justify-between'>
<div>
<p className='text-[11px] font-semibold tracking-[0.16em] text-muted-foreground uppercase'>
{t('Recent deployments')}
</p>
<h3 className='mt-1 text-lg font-semibold'>
{t('Live code delivery runs')}
</h3>
</div>
<Button asChild size='sm' variant='ghost' className='gap-1'>
<Link to='/deployments'>
{t('Open Deployments')}
<ArrowUpRight className='h-3.5 w-3.5' />
</Link>
</Button>
</header>
{deploymentsQuery.isLoading ? (
<div className='space-y-3'>
{Array.from({ length: 4 }).map((_, idx) => (
<div
key={idx}
className='h-16 animate-pulse rounded-xl bg-muted/40'
/>
))}
</div>
) : recentDeployments.length === 0 ? (
<div className='rounded-xl border border-dashed border-[color-mix(in_oklch,var(--primary)_22%,var(--border))] p-8 text-center text-sm text-muted-foreground'>
{t('No deployments yet. Trigger a run to populate the cockpit.')}
</div>
) : (
<ul className='space-y-2'>
{recentDeployments.map((dep) => {
const tenant = dep.orchestration_plan?.metadata?.tenant_id || '—'
const objective =
dep.orchestration_plan?.objective ||
dep.orchestration_plan?.template_hint ||
t('No objective')
return (
<li
key={dep.deployment_id}
className='group flex items-center justify-between gap-4 rounded-xl border border-[color-mix(in_oklch,var(--primary)_18%,var(--border))] bg-[color-mix(in_oklch,var(--card)_55%,transparent)] p-3 transition hover:border-primary/45'
>
<div className='min-w-0 flex-1'>
<div className='flex items-center gap-2'>
<Rocket className='h-3.5 w-3.5 text-primary' />
<span className='truncate font-mono text-xs'>
{dep.deployment_id}
</span>
<PhasePill phase={dep.phase || dep.status} />
</div>
<p className='mt-1 truncate text-sm text-foreground/90'>
{objective}
</p>
<p className='mt-0.5 truncate text-[11px] text-muted-foreground'>
<Building2 className='mr-1 inline h-3 w-3' />
{tenant} · {formatRelative(dep.updated_at || dep.created_at)} ago
</p>
</div>
<Button
asChild
size='sm'
variant='outline'
className='shrink-0 opacity-70 group-hover:opacity-100'
>
<Link to='/events'>
<ScrollText className='mr-1 h-3.5 w-3.5' />
{t('Inspect')}
</Link>
</Button>
</li>
)
})}
</ul>
)}
</section>
<section className='rounded-2xl border border-[color-mix(in_oklch,var(--primary)_22%,var(--border))] bg-[color-mix(in_oklch,var(--card)_70%,transparent)] p-5 backdrop-blur'>
<header className='mb-4 flex items-center justify-between'>
<div>
<p className='text-[11px] font-semibold tracking-[0.16em] text-muted-foreground uppercase'>
{t('Audit timeline')}
</p>
<h3 className='mt-1 text-lg font-semibold'>
{t('Tenant-scoped activity')}
</h3>
</div>
<Button asChild size='sm' variant='ghost' className='gap-1'>
<Link to='/audit'>
{t('Open Audit')}
<ArrowUpRight className='h-3.5 w-3.5' />
</Link>
</Button>
</header>
{auditQuery.isLoading ? (
<div className='space-y-3'>
{Array.from({ length: 5 }).map((_, idx) => (
<div
key={idx}
className='h-12 animate-pulse rounded-lg bg-muted/40'
/>
))}
</div>
) : auditFeed.length === 0 ? (
<div className='rounded-xl border border-dashed border-[color-mix(in_oklch,var(--primary)_22%,var(--border))] p-8 text-center text-sm text-muted-foreground'>
{t('No audit events captured yet.')}
</div>
) : (
<ol className='relative ms-2 space-y-4 border-s border-[color-mix(in_oklch,var(--primary)_18%,var(--border))] ps-4'>
{auditFeed.map((entry, idx) => {
const action = String(entry.action || entry.event || 'event')
const actor = String(entry.actor || entry.user || 'system')
const occurred = String(entry.occurred_at || entry.timestamp || '')
return (
<li key={idx} className='relative'>
<span className='absolute -left-[21px] top-1.5 inline-block h-2 w-2 rounded-full bg-primary shadow-[0_0_0_3px_color-mix(in_oklch,var(--primary)_25%,transparent)]' />
<p className='font-mono text-[11px] uppercase tracking-[0.14em] text-primary'>
{action}
</p>
<p className='mt-0.5 text-sm text-foreground/90'>
{actor}
</p>
<p className='text-[11px] text-muted-foreground'>
{occurred ? `${formatRelative(occurred)} ago` : '—'}
</p>
</li>
)
})}
</ol>
)}
</section>
</div>
{/* Tier 3: Action */}
<section className='rounded-2xl border border-[color-mix(in_oklch,var(--primary)_22%,var(--border))] bg-[color-mix(in_oklch,var(--card)_70%,transparent)] p-5 backdrop-blur'>
<header className='mb-4 flex items-center justify-between'>
<div>
<p className='text-[11px] font-semibold tracking-[0.16em] text-muted-foreground uppercase'>
{t('Quick actions')}
</p>
<h3 className='mt-1 text-lg font-semibold'>
{t('Common code-delivery actions')}
</h3>
</div>
<span className='inline-flex items-center gap-1.5 rounded-full bg-emerald-500/15 px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.14em] text-emerald-400 ring-1 ring-inset ring-emerald-500/30'>
<Activity className='h-3 w-3' />
{t('Control plane online')}
</span>
</header>
<div className='grid gap-3 sm:grid-cols-2 lg:grid-cols-4'>
<ActionTile
icon={Rocket}
title={t('New deployment')}
desc={t('Create a new code delivery run with checks.')}
to='/deployments'
/>
<ActionTile
icon={StopCircle}
title={t('Halt failing run')}
desc={t('Stop a deployment and capture an audit record.')}
to='/deployments'
/>
<ActionTile
icon={ShieldCheck}
title={t('Review audit')}
desc={t('Filter by actor, action, and tenant.')}
to='/audit'
/>
<ActionTile
icon={Wallet}
title={t('Inspect SK lineage')}
desc={t('Trace delivery context snapshots by hash.')}
to='/sk-sources'
/>
</div>
</section>
</div>
)
}
function ActionTile({
icon: Icon,
title,
desc,
to,
}: {
icon: React.ComponentType<{ className?: string }>
title: string
desc: string
to: '/deployments' | '/audit' | '/sk-sources'
}) {
return (
<Link
to={to}
className='group flex flex-col gap-3 rounded-xl border border-[color-mix(in_oklch,var(--primary)_18%,var(--border))] bg-[color-mix(in_oklch,var(--card)_55%,transparent)] p-4 transition hover:border-primary/45 hover:bg-[color-mix(in_oklch,var(--primary)_10%,var(--card))]'
>
<div className='inline-flex h-9 w-9 items-center justify-center rounded-lg bg-[color-mix(in_oklch,var(--primary)_18%,transparent)] text-primary'>
<Icon className='h-4 w-4' />
</div>
<div>
<p className='text-sm font-semibold'>{title}</p>
<p className='mt-1 text-xs leading-relaxed text-muted-foreground'>
{desc}
</p>
</div>
<span className='mt-auto inline-flex items-center gap-1 text-[11px] font-semibold uppercase tracking-[0.12em] text-primary opacity-70 group-hover:opacity-100'>
{title}
<ArrowUpRight className='h-3 w-3' />
</span>
</Link>
)
}
+11 -35
View File
@@ -6,17 +6,9 @@ import { ROLE } from '@/lib/roles'
import { Skeleton } from '@/components/ui/skeleton'
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { SectionPageLayout } from '@/components/layout'
import {
CardStaggerContainer,
CardStaggerItem,
FadeIn,
} from '@/components/page-transition'
import { FadeIn } from '@/components/page-transition'
import { CockpitView } from './components/cockpit'
import { ModelsFilter } from './components/models/models-filter-dialog'
import { AnnouncementsPanel } from './components/overview/announcements-panel'
import { ApiInfoPanel } from './components/overview/api-info-panel'
import { FAQPanel } from './components/overview/faq-panel'
import { SummaryCards } from './components/overview/summary-cards'
import { UptimePanel } from './components/overview/uptime-panel'
import { DEFAULT_TIME_GRANULARITY } from './constants'
import {
type DashboardSectionId,
@@ -86,16 +78,18 @@ const SECTION_META: Record<
{ titleKey: string; descriptionKey: string }
> = {
overview: {
titleKey: 'Overview',
descriptionKey: 'View dashboard overview and statistics',
titleKey: 'Cockpit',
descriptionKey:
'Live status, recent runs, audit timeline, and quick actions for your tenants.',
},
models: {
titleKey: 'Model Call Analytics',
descriptionKey: 'View model call count analytics and charts',
titleKey: 'Model usage analytics',
descriptionKey:
'Operational telemetry for upstream model usage (read-only).',
},
users: {
titleKey: 'User Analytics',
descriptionKey: 'View user consumption statistics and charts',
titleKey: 'Tenant member analytics',
descriptionKey: 'Per-tenant member consumption and request distribution.',
},
}
@@ -174,25 +168,7 @@ export function Dashboard() {
</TabsList>
</Tabs>
)}
{activeSection === 'overview' && (
<>
<SummaryCards />
<CardStaggerContainer className='grid grid-cols-1 gap-4 lg:grid-cols-2'>
<CardStaggerItem>
<ApiInfoPanel />
</CardStaggerItem>
<CardStaggerItem>
<AnnouncementsPanel />
</CardStaggerItem>
<CardStaggerItem>
<FAQPanel />
</CardStaggerItem>
<CardStaggerItem>
<UptimePanel />
</CardStaggerItem>
</CardStaggerContainer>
</>
)}
{activeSection === 'overview' && <CockpitView />}
{activeSection === 'models' && (
<>
<FadeIn>
@@ -1,6 +1,6 @@
import { useTranslation } from 'react-i18next'
import { Separator } from '@/components/ui/separator'
import { getGatewayFeatures } from '../constants'
import { getControlPlaneFeatures } from '../constants'
interface GatewayCardProps {
logo: string
@@ -8,11 +8,11 @@ interface GatewayCardProps {
}
/**
* Central gateway card with features grid
* Central control-plane card with capabilities grid
*/
export function GatewayCard({ logo, systemName }: GatewayCardProps) {
const { t } = useTranslation()
const features = getGatewayFeatures(t)
const features = getControlPlaneFeatures(t)
return (
<div className='glass-3 group border-border/50 dark:border-border/20 relative overflow-hidden rounded-[32px] border p-10 shadow-2xl transition-all duration-500 sm:p-12 dark:shadow-[0_25px_80px_-15px_rgba(0,0,0,0.4)]'>
@@ -23,7 +23,7 @@ export function GatewayCard({ logo, systemName }: GatewayCardProps) {
<div className='absolute -top-32 left-1/2 h-64 w-[120%] -translate-x-1/2 rounded-full bg-radial from-amber-500/30 to-amber-500/0 blur-3xl transition-all duration-500 group-hover:opacity-100 dark:opacity-80' />
<div className='relative'>
{/* Gateway Header */}
{/* Control-plane Header */}
<div className='mb-8 flex items-center justify-center gap-3'>
<img
src={logo}
@@ -15,7 +15,7 @@ const MODELS: ModelConfig[] = [
id: 'gpt-4o',
name: 'gpt-4o',
response:
'Artificial intelligence models can be seamlessly accessed through a unified API gateway, enabling developers to switch between providers effortlessly.',
'Agnet planners propose orchestration runs; the platform arbitrates risk and budget before any agent executes a step.',
tokens: 27,
latency: 142,
badgeClass:
@@ -25,7 +25,7 @@ const MODELS: ModelConfig[] = [
id: 'claude-sonnet',
name: 'claude-sonnet-4-20250514',
response:
'A unified gateway abstracts away provider differences, letting you focus on building great products while we handle routing, failover, and cost optimization.',
'Every SK snapshot is hashed and pinned to a deployment, so audit trails can replay exactly what context an agent saw.',
tokens: 31,
latency: 168,
badgeClass:
@@ -35,7 +35,7 @@ const MODELS: ModelConfig[] = [
id: 'gemini-pro',
name: 'gemini-2.5-pro',
response:
'By consolidating multiple AI providers behind one endpoint, teams can reduce integration complexity and gain unified observability across all model usage.',
'Heicode Manager consolidates Agnet runs, events, SK snapshots and audit into one tenant-scoped command center.',
tokens: 29,
latency: 156,
badgeClass:
@@ -45,7 +45,7 @@ const MODELS: ModelConfig[] = [
id: 'deepseek',
name: 'deepseek-chat',
response:
'An API gateway provides automatic load balancing, rate limiting, and cost tracking — essential infrastructure for production AI applications at scale.',
'Tenants stay isolated end-to-end: deployments, audit, events and SK lineage carry tenant_id at every layer.',
tokens: 25,
latency: 93,
badgeClass:
@@ -35,29 +35,22 @@ export function CTA(props: CTAProps) {
animation='scale-in'
>
<h2 className='text-2xl leading-tight font-bold tracking-tight md:text-4xl'>
{t('Ready to simplify')}
{'准备好提升'}
<br />
<span className='bg-gradient-to-r from-blue-400 via-violet-400 to-purple-500 bg-clip-text text-transparent'>
{t('your AI integration?')}
{'团队 Code 交付能力了吗?'}
</span>
</h2>
<p className='text-muted-foreground/80 mx-auto mt-5 max-w-md text-sm leading-relaxed md:text-base'>
{t('Start for free with generous limits. No credit card required.')}
{'开通租户后即可进入代码任务执行、质量验证与发布回溯视图。'}
</p>
<div className='mt-8 flex items-center justify-center gap-3'>
<Button className='group rounded-lg' asChild>
<Link to='/sign-up'>
{t('Get Started')}
{'立即开始'}
<ArrowRight className='ml-1 size-3.5 transition-transform duration-200 group-hover:translate-x-0.5' />
</Link>
</Button>
<Button
variant='outline'
className='border-border/50 hover:border-border hover:bg-muted/50 rounded-lg'
asChild
>
<Link to='/pricing'>{t('View Pricing')}</Link>
</Button>
</div>
</AnimateInView>
</section>
@@ -22,10 +22,8 @@ export function Features(_props: FeaturesProps) {
{
id: 'fast',
num: '01',
title: t('Lightning Fast'),
desc: t(
'Optimized network architecture ensures millisecond response times'
),
title: '编排响应快速',
desc: '任务状态秒级回传,适合多角色并行协作与持续迭代',
span: 'md:col-span-2',
icon: <Zap className='size-4 text-blue-400' />,
visual: (
@@ -46,10 +44,8 @@ export function Features(_props: FeaturesProps) {
{
id: 'secure',
num: '02',
title: t('Secure & Reliable'),
desc: t(
'Enterprise-grade security with comprehensive permission management'
),
title: '租户隔离可信',
desc: '企业级权限模型,保障跨租户数据边界与操作可审计',
span: 'md:col-span-1',
icon: <Shield className='size-4 text-emerald-400' />,
visual: (
@@ -83,13 +79,13 @@ export function Features(_props: FeaturesProps) {
{
id: 'global',
num: '03',
title: t('Global Coverage'),
desc: t('Multi-region deployment for stable global access'),
title: '全局事件可见',
desc: '统一展示运行态、异常与审计线索,便于值班与回溯',
span: 'md:col-span-1',
icon: <Globe className='size-4 text-violet-400' />,
visual: (
<div className='mt-4 space-y-2'>
{[t('Load Balancing'), t('Rate Limiting'), t('Cost Tracking')].map(
{['部署态势', '异常优先', '审计闭环'].map(
(step, i) => (
<div key={step} className='flex items-center gap-2'>
<div
@@ -112,14 +108,14 @@ export function Features(_props: FeaturesProps) {
{
id: 'developer',
num: '04',
title: t('Developer Friendly'),
desc: t('Complete API documentation with multi-language SDK support'),
title: '研发协作友好',
desc: '面向团队交付流程设计,支持从目标到执行再到审计的闭环',
span: 'md:col-span-2',
icon: <Code className='size-4 text-amber-400' />,
visual: (
<div className='mt-4 flex items-center gap-3'>
<div className='flex -space-x-2'>
{['API', 'SDK', 'CLI', 'Docs'].map((n) => (
{['Deploy', 'Events', 'SK', 'Audit'].map((n) => (
<div
key={n}
className='border-background from-muted to-muted/60 text-muted-foreground flex size-8 items-center justify-center rounded-full border-2 bg-gradient-to-br text-[9px] font-bold'
@@ -130,7 +126,7 @@ export function Features(_props: FeaturesProps) {
</div>
<div className='text-muted-foreground flex items-center gap-1.5 text-xs'>
<Code className='size-3.5 text-blue-500' />
{t('OpenAI Compatible')}
{'Heicode Manager Control Plane'}
</div>
</div>
),
@@ -140,23 +136,23 @@ export function Features(_props: FeaturesProps) {
const additionalFeatures = [
{
icon: <Gauge className='size-5' strokeWidth={1.5} />,
title: t('High Performance'),
desc: t('Support for high concurrency with automatic load balancing'),
title: '执行韧性',
desc: '失败可定位、状态可见、操作可回滚',
},
{
icon: <DollarSign className='size-5' strokeWidth={1.5} />,
title: t('Transparent Billing'),
desc: t('Pay-as-you-go with real-time usage monitoring'),
title: '预算感知',
desc: '在任务流内感知预算消耗与风险变化',
},
{
icon: <Users className='size-5' strokeWidth={1.5} />,
title: t('Team Collaboration'),
desc: t('Multi-user management with flexible permission allocation'),
title: '团队协同',
desc: '多角色职责分离,统一租户视角协作',
},
{
icon: <HeartHandshake className='size-5' strokeWidth={1.5} />,
title: t('Technical Support'),
desc: t('Professional team providing 24/7 technical support'),
title: '可运维性',
desc: '事件、快照、审计三位一体支持问题复盘',
},
]
@@ -165,12 +161,12 @@ export function Features(_props: FeaturesProps) {
<div className='mx-auto max-w-6xl'>
<AnimateInView className='mb-16 max-w-lg'>
<p className='text-muted-foreground mb-3 text-xs font-medium tracking-widest uppercase'>
{t('Core Features')}
{'核心能力'}
</p>
<h2 className='text-2xl leading-tight font-bold tracking-tight md:text-3xl'>
{t('Built for developers,')}
{'为团队交付而设计,'}
<br />
{t('designed for scale')}
{'为多租户管控而生'}
</h2>
</AnimateInView>
@@ -39,20 +39,18 @@ export function Hero(props: HeroProps) {
className='landing-animate-fade-up text-[clamp(2rem,5.5vw,3.5rem)] leading-[1.15] font-bold tracking-tight'
style={{ animationDelay: '0ms' }}
>
{t('Unified API Gateway for')}
{'高强度 Code 交付中枢'}
<br />
<span className='bg-gradient-to-r from-blue-400 via-violet-400 to-purple-500 bg-clip-text text-transparent'>
{t('All Your AI Models')}
{'团队级研发执行与质量控制台'}
</span>
</h1>
<p
className='landing-animate-fade-up text-muted-foreground/80 mt-5 max-w-lg text-base leading-relaxed opacity-0 md:text-lg'
style={{ animationDelay: '80ms' }}
>
{systemName}{' '}
{t(
'aggregates 50+ AI providers behind one unified API. Manage access, track costs, and scale effortlessly.'
)}
{systemName}
{' 聚焦需求到代码到发布的全链路执行能力:任务推进、质量门禁、发布回溯一体化。'}
</p>
<div
className='landing-animate-fade-up mt-8 flex items-center gap-3 opacity-0'
@@ -69,17 +67,10 @@ export function Hero(props: HeroProps) {
<>
<Button className='group rounded-lg' asChild>
<Link to='/sign-up'>
{t('Get Started')}
{'立即开始'}
<ArrowRight className='ml-1 size-3.5 transition-transform duration-200 group-hover:translate-x-0.5' />
</Link>
</Button>
<Button
variant='outline'
className='border-border/50 hover:border-border hover:bg-muted/50 rounded-lg'
asChild
>
<Link to='/pricing'>{t('View Pricing')}</Link>
</Button>
</>
)}
</div>
@@ -8,24 +8,20 @@ export function HowItWorks() {
const steps = [
{
num: '1',
title: t('Configure'),
desc: t(
'Add your API keys, set up channels and configure access permissions'
),
title: '接入租户',
desc: '使用外部统一认证登录,自动加载 tenant、channel 与角色权限',
icon: <Settings className='size-6' strokeWidth={1.5} />,
},
{
num: '2',
title: t('Connect'),
desc: t(
'Use our unified OpenAI-compatible endpoint in your applications'
),
title: '推进代码任务',
desc: '在 Deployments 发起代码交付任务,自动串联实现、验证与发布门禁',
icon: <Zap className='size-6' strokeWidth={1.5} />,
},
{
num: '3',
title: t('Monitor'),
desc: t('Track usage, costs and performance with real-time analytics'),
title: '持续验证',
desc: '通过 Events 与 Audit 观察质量信号、失败根因与发布回溯链路',
icon: <BarChart3 className='size-6' strokeWidth={1.5} />,
},
]
@@ -35,10 +31,10 @@ export function HowItWorks() {
<div className='mx-auto max-w-6xl'>
<AnimateInView className='mb-16 text-center md:mb-20'>
<p className='text-muted-foreground mb-3 text-xs font-medium tracking-widest uppercase'>
{t('How It Works')}
{'使用流程'}
</p>
<h2 className='text-2xl font-bold tracking-tight md:text-3xl'>
{t('Three steps to get started')}
{'三步进入 Heicode Manager'}
</h2>
</AnimateInView>
@@ -73,10 +73,10 @@ export function Stats(_props: StatsProps) {
const { t } = useTranslation()
const stats = [
{ end: 100, suffix: 'M+', label: t('requests served') },
{ end: 50, suffix: '+', label: t('AI models supported') },
{ end: 99.9, suffix: '%', label: t('uptime'), decimals: 1 },
{ end: 10, suffix: 'K+', label: t('active users') },
{ end: 100, suffix: 'K+', label: '已编排任务' },
{ end: 50, suffix: '+', label: '接入租户' },
{ end: 99.9, suffix: '%', label: '可用性', decimals: 1 },
{ end: 10, suffix: 'K+', label: '审计事件' },
]
return (
+14 -14
View File
@@ -25,18 +25,18 @@ export const AI_MODELS = [
'Gemini.Color',
] as const
// Hero section - Gateway Features
export const GATEWAY_FEATURES = [
'Cost Tracking',
'Model Access',
'Guardrails',
'Observability',
'Budgets',
'Load Balancing',
'Rate Limiting',
'Token Mgmt',
'Prompt Caching',
'Pass-Through',
// Hero section - Control-plane capability tags
export const CONTROL_PLANE_FEATURES = [
'Deployments',
'Events',
'SK Snapshots',
'Audit',
'Tenant Isolation',
'Risk Control',
'Budget Guard',
'Traceability',
'Policy Gates',
'Ops Visibility',
] as const
// Stats section - Default statistics
@@ -109,8 +109,8 @@ export const DEFAULT_FEATURES = [
},
] as const
export function getGatewayFeatures(t: TFunction) {
return GATEWAY_FEATURES.map((feature) => t(feature))
export function getControlPlaneFeatures(t: TFunction) {
return CONTROL_PLANE_FEATURES.map((feature) => t(feature))
}
export function getDefaultStats(t: TFunction) {
@@ -1,11 +1,10 @@
import { useEffect, useMemo, useState, useCallback } from 'react'
import { Mail, Shield, Send, Link2, Unlink } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { SiGithub, SiWechat, SiLinux } from 'react-icons/si'
import { SiWechat, SiLinux } from 'react-icons/si'
import { toast } from 'sonner'
import { IconDiscord } from '@/assets/brand-icons'
import {
handleGitHubOAuth,
handleOIDCOAuth,
handleDiscordOAuth,
handleLinuxDOOAuth,
@@ -153,23 +152,6 @@ export function AccountBindingsTab({
isEnabled: status?.wechat_login || false,
onBind: () => dialogs.open('wechat'),
},
{
id: 'github',
label: t('GitHub'),
icon: SiGithub,
value: (profile as unknown as Record<string, unknown>).github_id as
| string
| undefined,
isBound: Boolean(
(profile as unknown as Record<string, unknown>).github_id
),
isEnabled: status?.github_oauth || false,
onBind: () => {
if (status?.github_client_id) {
handleGitHubOAuth(status.github_client_id)
}
},
},
{
id: 'discord',
label: t('Discord'),
@@ -1,6 +1,7 @@
import { useParams } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import { parseCurrencyDisplayType } from '@/lib/currency'
import { BRAND_NAME } from '@/lib/brand'
import { useSystemOptions, getOptionValue } from '../hooks/use-system-options'
import type { GeneralSettings } from '../types'
import {
@@ -11,7 +12,7 @@ import {
const defaultGeneralSettings: GeneralSettings = {
'theme.frontend': 'default',
Notice: '',
SystemName: 'Heicode Manager',
SystemName: BRAND_NAME,
Logo: '',
Footer: '',
About: '',
@@ -4,6 +4,7 @@ import { zodResolver } from '@hookform/resolvers/zod'
import { RotateCcw } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { useHiddenClickUnlock } from '@/hooks/use-hidden-click-unlock'
import { BRAND_NAME } from '@/lib/brand'
import { Button } from '@/components/ui/button'
import {
Form,
@@ -201,7 +202,7 @@ export function SystemInfoSection({ defaultValues }: SystemInfoSectionProps) {
<FormItem>
<FormLabel>{t('System Name')}</FormLabel>
<FormControl>
<Input placeholder={t('Heicode Manager')} {...field} />
<Input placeholder={t(BRAND_NAME)} {...field} />
</FormControl>
<FormDescription>
{t('The name displayed across the application')}
@@ -305,7 +306,7 @@ export function SystemInfoSection({ defaultValues }: SystemInfoSectionProps) {
<FormLabel>{t('Home Page Content')}</FormLabel>
<FormControl>
<Textarea
placeholder={t('Welcome to Heicode Manager...')}
placeholder={t(`Welcome to ${BRAND_NAME}...`)}
rows={6}
{...field}
/>
@@ -11,7 +11,7 @@ import {
EyeOff,
} from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { SiGithub, SiDiscord } from 'react-icons/si'
import { SiDiscord } from 'react-icons/si'
import { toast } from 'sonner'
import { api } from '@/lib/api'
import { Button } from '@/components/ui/button'
@@ -60,7 +60,6 @@ interface BindingItem {
}
interface StatusInfo {
github_oauth?: boolean
discord_oauth?: boolean
oidc_enabled?: boolean
wechat_login?: boolean
@@ -87,13 +86,6 @@ const BUILTIN_BINDINGS: ReadonlyArray<{
icon: <Mail className='h-4 w-4' />,
statusKey: null,
},
{
key: 'github_id',
field: 'github_id',
label: 'GitHub',
icon: <SiGithub className='h-4 w-4' />,
statusKey: 'github_oauth',
},
{
key: 'discord_id',
field: 'discord_id',
-1
View File
@@ -89,7 +89,6 @@ export const DEFAULT_GROUP = 'default' as const
// ============================================================================
export const BINDING_FIELDS = [
{ key: 'github_id', label: 'GitHub ID' },
{ key: 'discord_id', label: 'Discord ID' },
{ key: 'oidc_id', label: 'OIDC ID' },
{ key: 'wechat_id', label: 'WeChat ID' },
+39 -150
View File
@@ -10,127 +10,77 @@ type SidebarSectionConfig = {
type SidebarModulesAdminConfig = Record<string, SidebarSectionConfig>
// User-layer config is shape-identical to admin, but may be null
// to signal "no narrowing" (empty/invalid/legacy users).
type SidebarModulesUserConfig = SidebarModulesAdminConfig | null
/**
* Default sidebar modules configuration
* Heicode Manager sidebar admin config.
*
* Only two sections matter for the main axis:
* cockpit — Overview / Deployments / Events / SK Snapshots / Audit
* admin — Tenants / Settings
*
* Legacy gateway-flavored sections (channel, models, subscription,
* playground, redemption, etc.) are intentionally absent from the
* authoritative default and are filtered out even if a stale backend
* config still lists them.
*/
const DEFAULT_SIDEBAR_MODULES: SidebarModulesAdminConfig = {
chat: {
enabled: true,
playground: true,
chat: true,
},
console: {
cockpit: {
enabled: true,
overview: true,
deployments: true,
events: true,
sk: true,
audit: true,
token: true,
},
personal: {
enabled: true,
topup: true,
personal: true,
},
admin: {
enabled: true,
channel: true,
models: true,
user: true,
subscription: true,
tenants: true,
settings: true,
},
}
/**
* Mapping from URL to configuration keys
*/
const URL_TO_CONFIG_MAP: Record<string, { section: string; module: string }> = {
'/playground': { section: 'chat', module: 'playground' },
'/dashboard': { section: 'console', module: 'deployments' },
'/dashboard/overview': { section: 'console', module: 'deployments' },
'/dashboard/models': { section: 'console', module: 'deployments' },
'/dashboard/users': { section: 'console', module: 'deployments' },
'/deployments': { section: 'console', module: 'deployments' },
'/models/deployments': { section: 'console', module: 'deployments' },
'/events': { section: 'console', module: 'events' },
'/keys': { section: 'console', module: 'token' },
'/audit': { section: 'console', module: 'audit' },
'/usage-logs': { section: 'console', module: 'events' },
'/usage-logs/common': { section: 'console', module: 'events' },
'/usage-logs/drawing': { section: 'console', module: 'audit' },
'/usage-logs/task': { section: 'console', module: 'audit' },
'/wallet': { section: 'personal', module: 'topup' },
'/profile': { section: 'personal', module: 'personal' },
'/channels': { section: 'admin', module: 'channel' },
'/models': { section: 'admin', module: 'models' },
'/models/metadata': { section: 'admin', module: 'models' },
'/models/deployments': { section: 'admin', module: 'models' },
'/users': { section: 'admin', module: 'user' },
'/subscriptions': { section: 'admin', module: 'subscription' },
'/dashboard': { section: 'cockpit', module: 'overview' },
'/dashboard/overview': { section: 'cockpit', module: 'overview' },
'/deployments': { section: 'cockpit', module: 'deployments' },
'/events': { section: 'cockpit', module: 'events' },
'/sk-sources': { section: 'cockpit', module: 'sk' },
'/audit': { section: 'cockpit', module: 'audit' },
'/users': { section: 'admin', module: 'tenants' },
'/system-settings/general': { section: 'admin', module: 'settings' },
'/system-settings': { section: 'admin', module: 'settings' },
}
/**
* Parse backend SidebarModulesAdmin configuration
*/
function parseSidebarConfig(
value: string | null | undefined
): SidebarModulesAdminConfig {
// If empty string, null, or undefined, use default config
if (!value || value.trim() === '') {
return DEFAULT_SIDEBAR_MODULES
}
try {
const parsed = JSON.parse(value) as SidebarModulesAdminConfig
// Ensure chat section and its modules are correctly initialized if missing
if (!parsed.chat) {
parsed.chat = { enabled: true, playground: true, chat: true }
} else {
if (parsed.chat.enabled === undefined) parsed.chat.enabled = true
if (parsed.chat.playground === undefined) parsed.chat.playground = true
if (parsed.chat.chat === undefined) parsed.chat.chat = true
const merged: SidebarModulesAdminConfig = {
...DEFAULT_SIDEBAR_MODULES,
...parsed,
}
if (!parsed.console) {
parsed.console = {
enabled: true,
deployments: true,
events: true,
audit: true,
token: true,
}
} else {
if (parsed.console.enabled === undefined) parsed.console.enabled = true
if (parsed.console.deployments === undefined)
parsed.console.deployments = parsed.console.detail ?? true
if (parsed.console.events === undefined)
parsed.console.events = parsed.console.log ?? true
if (parsed.console.audit === undefined)
parsed.console.audit =
parsed.console.task ?? parsed.console.midjourney ?? true
if (parsed.console.token === undefined) parsed.console.token = true
if (!merged.cockpit || merged.cockpit.enabled === undefined) {
merged.cockpit = DEFAULT_SIDEBAR_MODULES.cockpit
}
return parsed
if (!merged.admin || merged.admin.enabled === undefined) {
merged.admin = DEFAULT_SIDEBAR_MODULES.admin
}
return merged
} catch {
// eslint-disable-next-line no-console
console.error('Failed to parse sidebar modules configuration')
return DEFAULT_SIDEBAR_MODULES
}
}
/**
* Parse user-level sidebar_modules. Returns null when the value is empty,
* invalid, or otherwise unusable — the caller treats null as "do not narrow",
* so legacy users with an empty sidebar_modules field keep the full admin view.
*/
function parseUserSidebarConfig(
value: string | null | undefined
): SidebarModulesUserConfig {
if (!value || value.trim() === '') {
return null
}
if (!value || value.trim() === '') return null
try {
const parsed = JSON.parse(value) as SidebarModulesAdminConfig
if (!parsed || typeof parsed !== 'object') return null
@@ -140,80 +90,47 @@ function parseUserSidebarConfig(
}
}
/**
* Check if a module is enabled. Admin config is the first (authoritative)
* layer: if admin disables a section/module it is always hidden. User config
* is a second narrower layer: it can only further hide what admin allowed.
* A null user config means "do not narrow" (legacy/empty users).
*/
function isModuleEnabled(
url: string,
adminConfig: SidebarModulesAdminConfig,
userConfig: SidebarModulesUserConfig
): boolean {
const mapping = URL_TO_CONFIG_MAP[url]
if (!mapping) {
// No mapping config, default to visible (e.g. system settings and new features)
return true
}
if (!mapping) return true
const { section, module } = mapping
const adminSection = adminConfig[section]
const adminAllowed = Boolean(
adminSection && adminSection.enabled && adminSection[module] === true
adminSection && adminSection.enabled && adminSection[module] !== false
)
if (!adminAllowed) return false
if (!userConfig) return true
const userSection = userConfig[section]
if (!userSection) return true
if (userSection.enabled === false) return false
return userSection[module] !== false
}
/**
* Check if a navigation item should be visible
*/
function isNavItemVisible(
item: NavItem,
adminConfig: SidebarModulesAdminConfig,
userConfig: SidebarModulesUserConfig
): boolean {
// Handle dynamic chat presets type — also runs the admin × user AND gate
if ('type' in item && item.type === 'chat-presets') {
const adminChat = adminConfig.chat
const adminAllowed = Boolean(adminChat?.enabled && adminChat.chat === true)
if (!adminAllowed) return false
if (!userConfig) return true
const userChat = userConfig.chat
if (!userChat) return true
if (userChat.enabled === false) return false
return userChat.chat !== false
}
// Handle direct link type
if ('url' in item && item.url) {
const configUrls = item.configUrls ?? [item.url]
return configUrls.some((url) =>
isModuleEnabled(url as string, adminConfig, userConfig)
)
}
// Handle collapsible type (with sub-items)
if ('items' in item && item.items) {
// If has sub-items, show this collapsible item if at least one sub-item is visible
return item.items.some((subItem) =>
isModuleEnabled(subItem.url as string, adminConfig, userConfig)
)
}
return true
}
/**
* Filter navigation items
*/
function filterNavItems(
items: NavItem[],
adminConfig: SidebarModulesAdminConfig,
@@ -221,38 +138,17 @@ function filterNavItems(
): NavItem[] {
return items
.map((item) => {
// If collapsible item, also filter its sub-items
if ('items' in item && item.items) {
const filteredSubItems = item.items.filter((subItem) =>
isModuleEnabled(subItem.url as string, adminConfig, userConfig)
)
return {
...item,
items: filteredSubItems,
}
return { ...item, items: filteredSubItems }
}
return item
})
.filter((item) => isNavItemVisible(item, adminConfig, userConfig))
}
/**
* Filter sidebar navigation groups by admin × user sidebar_modules config.
*
* Two layers, AND-combined:
* 1. Admin (status.SidebarModulesAdmin) — authoritative, falls back to
* DEFAULT_SIDEBAR_MODULES when empty/invalid. Disabling here hides the
* item for everyone regardless of user preference.
* 2. User (auth.user.sidebar_modules) — narrower overlay, null sentinel
* means "don't narrow". A section/module is only hidden if the user
* explicitly set it to false; undefined fields default to visible so
* legacy users with empty sidebar_modules keep the full admin view.
* The overlay is also skipped entirely when the backend tells us the
* user cannot configure sidebar_settings (e.g. root accounts), so a
* stale historical value cannot lock them out of entries they have no
* UI to restore.
*/
export function useSidebarConfig(navGroups: NavGroup[]): NavGroup[] {
const { status } = useStatus()
const { auth } = useAuthStore()
@@ -266,14 +162,7 @@ export function useSidebarConfig(navGroups: NavGroup[]): NavGroup[] {
)
const userConfig = useMemo(() => {
// If the backend marks the user as unable to configure the sidebar
// (e.g. root accounts), skip the user overlay entirely — a stale
// historical sidebar_modules value from a previous role would otherwise
// hide admin entries for someone who has no in-product UI to restore
// them.
if (auth?.user?.permissions?.sidebar_settings === false) {
return null
}
if (auth?.user?.permissions?.sidebar_settings === false) return null
return parseUserSidebarConfig(auth?.user?.sidebar_modules)
}, [auth?.user?.permissions?.sidebar_settings, auth?.user?.sidebar_modules])
@@ -284,7 +173,7 @@ export function useSidebarConfig(navGroups: NavGroup[]): NavGroup[] {
...group,
items: filterNavItems(group.items, adminConfig, userConfig),
}))
.filter((group) => group.items.length > 0), // Only show navigation groups with visible items
.filter((group) => group.items.length > 0),
[navGroups, adminConfig, userConfig]
)
+39 -55
View File
@@ -1,21 +1,31 @@
import {
Activity,
Key,
ShieldCheck,
Box,
Users,
User,
BookOpenText,
Building2,
Command,
Radio,
FlaskConical,
MessageSquare,
CreditCard,
LayoutDashboard,
Rocket,
Settings,
ShieldCheck,
} from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { WORKSPACE_IDS } from '@/components/layout/lib/workspace-registry'
import { type SidebarData } from '@/components/layout/types'
/**
* Heicode Manager sidebar IA — code delivery command axis.
*
* Main axis (always visible to authenticated users):
* Overview / Deployments / Events / SK Snapshots / Audit
*
* Admin axis (RoleAdminUser+):
* Tenants / Settings
*
* Gateway-flavored entries (Provider Channels, Model Catalog,
* Subscriptions, API Keys, Playground, Redemption Codes...) are
* intentionally NOT on the main axis. They live as sub-pages under
* Settings or are deprecated outright.
*/
export function useSidebarData(): SidebarData {
const { t } = useTranslation()
@@ -23,32 +33,21 @@ export function useSidebarData(): SidebarData {
workspaces: [
{
id: WORKSPACE_IDS.DEFAULT,
name: '', // Dynamically fetches system name
name: '',
logo: Command,
plan: '', // Dynamically fetches system version
plan: '',
},
],
navGroups: [
{
id: 'chat',
title: t('Chat'),
id: 'cockpit',
title: t('Code delivery'),
items: [
{
title: t('Playground'),
url: '/playground',
icon: FlaskConical,
title: t('Overview'),
url: '/dashboard',
icon: LayoutDashboard,
},
{
title: t('Chat'),
icon: MessageSquare,
type: 'chat-presets',
},
],
},
{
id: 'general',
title: t('Operations'),
items: [
{
title: t('Deployments'),
url: '/deployments',
@@ -59,48 +58,33 @@ export function useSidebarData(): SidebarData {
url: '/events',
icon: Activity,
},
{
title: t('SK Snapshots'),
url: '/sk-sources',
icon: BookOpenText,
},
{
title: t('Audit'),
url: '/audit',
activeUrls: ['/usage-logs/drawing', '/usage-logs/task'],
configUrls: ['/audit', '/usage-logs/drawing', '/usage-logs/task'],
activeUrls: ['/usage-logs', '/usage-logs/common'],
icon: ShieldCheck,
},
{
title: t('API Keys'),
url: '/keys',
icon: Key,
},
{
title: t('Profile'),
url: '/profile',
icon: User,
},
],
},
{
id: 'admin',
title: t('Control Plane'),
title: t('Tenant administration'),
items: [
{
title: t('Provider Channels'),
url: '/channels',
icon: Radio,
},
{
title: t('Model Catalog'),
url: '/models/metadata',
icon: Box,
},
{
title: t('Users'),
title: t('Tenants'),
url: '/users',
icon: Users,
icon: Building2,
},
{
title: t('Subscriptions'),
url: '/subscriptions',
icon: CreditCard,
title: t('Settings'),
url: '/system-settings/general',
activeUrls: ['/system-settings'],
icon: Settings,
},
],
},
+2 -1
View File
@@ -75,7 +75,8 @@ export function mapStatusDataToConfig(
return {
systemName: data.system_name || DEFAULT_SYSTEM_NAME,
logo: data.logo || DEFAULT_LOGO,
// Brand lock: always use Heicode logo in default frontend.
logo: DEFAULT_LOGO,
footerHtml: data.footer_html,
demoSiteEnabled: data.demo_site_enabled,
displayTokenStatEnabled: data.display_token_stat_enabled,
+20 -36
View File
@@ -10,13 +10,15 @@ export type TopNavLink = {
external?: boolean
}
// Default navigation configuration
// Default top-nav modules — agnet command axis only.
// Pricing/Models/Channels are intentionally absent: Heicode Manager
// is a tenant + agnet control plane, not an API gateway storefront.
const DEFAULT_HEADER_NAV_MODULES = {
home: true,
console: true,
pricing: { enabled: true, requireAuth: false },
docs: true,
about: true,
overview: true,
deployments: true,
events: true,
audit: true,
}
/**
@@ -24,9 +26,11 @@ const DEFAULT_HEADER_NAV_MODULES = {
* Backend format example (stringified JSON):
* {
* home: true,
* console: true,
* agnet: true,
* deployments: true,
* events: true,
* audit: true,
* pricing: { enabled: true, requireAuth: false },
* docs: true,
* about: true
* }
*/
@@ -50,43 +54,23 @@ export function useTopNavLinks(): TopNavLink[] {
}
}, [status?.HeaderNavModules])
// Documentation link (may be external)
const docsLink: string | undefined = status?.docs_link as string | undefined
const isAuthed = !!auth?.user
void auth
const links: TopNavLink[] = []
// Home
if (modules?.home !== false) {
links.push({ title: t('Home'), href: '/' })
}
// Console -> /dashboard (new console path)
if (modules?.console !== false) {
links.push({ title: t('Console'), href: '/dashboard' })
if (modules?.overview !== false) {
links.push({ title: t('Overview'), href: '/dashboard' })
}
// Pricing
const pricing = modules?.pricing
if (pricing && typeof pricing === 'object' && pricing.enabled) {
const disabled = pricing.requireAuth && !isAuthed
links.push({ title: t('Pricing'), href: '/pricing', disabled })
if (modules?.deployments !== false) {
links.push({ title: t('Deployments'), href: '/deployments' })
}
// Docs (supports external links)
if (modules?.docs !== false) {
if (docsLink) {
links.push({ title: t('Docs'), href: docsLink, external: true })
} else {
links.push({ title: t('Docs'), href: '/docs' })
}
if (modules?.events !== false) {
links.push({ title: t('Events'), href: '/events' })
}
// About
if (modules?.about !== false) {
links.push({ title: t('About'), href: '/about' })
if (modules?.audit !== false) {
links.push({ title: t('Audit'), href: '/audit' })
}
return links
}
-1
View File
@@ -25,7 +25,6 @@ export function useUserDisplay(user: AuthUser | null | undefined) {
// Secondary text: first available identifier
const secondaryText = (() => {
if (user.email) return user.email
if (user.github_id) return `GitHub ID: ${user.github_id}`
if (user.oidc_id) return `OIDC ID: ${user.oidc_id}`
if (user.wechat_id) return `WeChat ID: ${user.wechat_id}`
if (user.telegram_id) return `Telegram ID: ${user.telegram_id}`
+4
View File
@@ -0,0 +1,4 @@
export const BRAND_NAME = 'Heicode Manager'
export const BRAND_TAGLINE = 'Agentic development control plane'
export const BRAND_DOC_LOGIN_PATH = '/docs/integration/Heicode-登录接口对接文档.md'
export const BRAND_SCREENSHOT_BASE = '/docs/images'
+4 -2
View File
@@ -1,10 +1,12 @@
import { BRAND_NAME } from '@/lib/brand'
/**
* Application-wide constants
*/
// System Configuration Defaults
export const DEFAULT_SYSTEM_NAME = 'Heicode Manager'
export const DEFAULT_LOGO = '/logo.png'
export const DEFAULT_SYSTEM_NAME = BRAND_NAME
export const DEFAULT_LOGO = '/heicode-logo.svg'
// LocalStorage Keys
export const STORAGE_KEYS = {
+3 -2
View File
@@ -11,6 +11,7 @@ import i18next from 'i18next'
import { toast } from 'sonner'
import { useAuthStore } from '@/stores/auth-store'
import { getStatus } from '@/lib/api'
import { DEFAULT_LOGO } from '@/lib/constants'
import '@/lib/dayjs'
import { applyFaviconToDom } from '@/lib/dom-utils'
import { handleServerError } from '@/lib/handle-server-error'
@@ -108,7 +109,7 @@ const rootElement = document.getElementById('root')!
if (saved) {
const s = JSON.parse(saved)
if (s?.system_name) apply(s.system_name)
if (s?.logo) applyFaviconToDom(s.logo)
applyFaviconToDom(DEFAULT_LOGO)
}
} catch {
/* empty */
@@ -124,7 +125,7 @@ const rootElement = document.getElementById('root')!
/* empty */
}
}
if (s?.logo) applyFaviconToDom(s.logo as string)
applyFaviconToDom(DEFAULT_LOGO)
})
.catch(() => {
/* empty */
@@ -0,0 +1,6 @@
import { createFileRoute } from '@tanstack/react-router'
import { AgnetAgentsPage } from '@/features/agnet-console/pages'
export const Route = createFileRoute('/_authenticated/agents/')({
component: AgnetAgentsPage,
})
@@ -1,10 +1,6 @@
import { createFileRoute, redirect } from '@tanstack/react-router'
import { createFileRoute } from '@tanstack/react-router'
import { AgnetAuditPage } from '@/features/agnet-console/pages'
export const Route = createFileRoute('/_authenticated/audit/')({
beforeLoad: () => {
throw redirect({
to: '/usage-logs/$section',
params: { section: 'task' },
})
},
component: AgnetAuditPage,
})
@@ -1,10 +1,6 @@
import { createFileRoute, redirect } from '@tanstack/react-router'
import { createFileRoute } from '@tanstack/react-router'
import { AgnetDeploymentsPage } from '@/features/agnet-console/pages'
export const Route = createFileRoute('/_authenticated/deployments/')({
beforeLoad: () => {
throw redirect({
to: '/models/$section',
params: { section: 'deployments' },
})
},
component: AgnetDeploymentsPage,
})
@@ -1,10 +1,6 @@
import { createFileRoute, redirect } from '@tanstack/react-router'
import { createFileRoute } from '@tanstack/react-router'
import { AgnetEventsPage } from '@/features/agnet-console/pages'
export const Route = createFileRoute('/_authenticated/events/')({
beforeLoad: () => {
throw redirect({
to: '/usage-logs/$section',
params: { section: 'common' },
})
},
component: AgnetEventsPage,
})
+5 -6
View File
@@ -1,6 +1,6 @@
import { createFileRoute, redirect } from '@tanstack/react-router'
import { useAuthStore } from '@/stores/auth-store'
import { getSelf } from '@/lib/api'
import { getHeicodeCurrentUser } from '@/features/auth/api'
import { AuthenticatedLayout } from '@/components/layout'
// 内存中的验证标记,避免同一会话中重复验证
@@ -20,13 +20,12 @@ export const Route = createFileRoute('/_authenticated')({
// 本地有用户信息,但需要验证 session 是否有效(每个会话只验证一次)
if (!sessionVerified) {
const res = await getSelf().catch(() => null)
if (res?.success && res.data) {
// 验证成功,更新用户信息(可能有变化)
auth.setUser(res.data)
const heicodeUser = await getHeicodeCurrentUser().catch(() => null)
if (heicodeUser) {
auth.setUser(heicodeUser)
sessionVerified = true
} else {
// 验证失败或 API 调用失败,清除本地缓存并跳转登录页
// 外部认证验证失败,清除本地缓存并跳转登录页
auth.reset()
throw redirect({
to: '/sign-in',
@@ -0,0 +1,6 @@
import { createFileRoute } from '@tanstack/react-router'
import { AgnetSKSourcesPage } from '@/features/agnet-console/pages'
export const Route = createFileRoute('/_authenticated/sk-sources/')({
component: AgnetSKSourcesPage,
})
@@ -0,0 +1,6 @@
import { createFileRoute } from '@tanstack/react-router'
import { AgnetTemplatesPage } from '@/features/agnet-console/pages'
export const Route = createFileRoute('/_authenticated/templates/')({
component: AgnetTemplatesPage,
})
+31
View File
@@ -4,6 +4,7 @@
@import '@fontsource-variable/public-sans';
@import './theme.css';
@import './tokens.css';
/* Shiki dual themes: token colors follow dark theme (pre background stays `bg-background` on the block) */
@layer components {
@@ -26,6 +27,23 @@
}
body {
@apply bg-background text-foreground has-[div[data-variant='inset']]:bg-sidebar min-h-svh w-full font-sans;
background-image:
radial-gradient(
circle at 15% 10%,
color-mix(in oklch, var(--primary) 22%, transparent) 0,
transparent 32%
),
radial-gradient(
circle at 85% 20%,
color-mix(in oklch, var(--accent) 18%, transparent) 0,
transparent 30%
),
radial-gradient(
circle at 50% 100%,
color-mix(in oklch, var(--primary) 12%, transparent) 0,
transparent 42%
);
background-attachment: fixed;
}
/* Override Radix scroll locking for sticky headers */
@@ -186,6 +204,19 @@
/* Launch UI Animations and Effects - Matching Template Exactly */
@layer utilities {
.heicode-glow {
box-shadow:
0 0 0 1px color-mix(in oklch, var(--primary) 35%, transparent),
0 8px 28px -14px color-mix(in oklch, var(--primary) 45%, transparent);
}
.heicode-panel {
backdrop-filter: blur(10px);
background: color-mix(in oklch, var(--card) 82%, transparent);
border: 1px solid color-mix(in oklch, var(--border) 80%, transparent);
box-shadow: 0 14px 40px -30px color-mix(in oklch, var(--primary) 48%, black);
}
/* Gradient utilities */
.bg-radial {
background-image: radial-gradient(var(--tw-gradient-stops));
+54 -54
View File
@@ -1,73 +1,73 @@
@custom-variant dark (&:is(.dark *));
:root {
--background: oklch(0.994 0.002 247.858);
--foreground: oklch(0.18 0.035 264.695);
--card: oklch(0.997 0.002 247.858);
--card-foreground: oklch(0.18 0.035 264.695);
--popover: oklch(0.997 0.002 247.858);
--popover-foreground: oklch(0.18 0.035 264.695);
--primary: oklch(0.255 0.042 265.755);
--primary-foreground: oklch(0.985 0.004 247.858);
--secondary: oklch(0.974 0.004 247.896);
--secondary-foreground: oklch(0.255 0.042 265.755);
--muted: oklch(0.972 0.004 247.896);
--muted-foreground: oklch(0.49 0.04 257.417);
--accent: oklch(0.972 0.004 247.896);
--accent-foreground: oklch(0.255 0.042 265.755);
--background: oklch(0.97 0.012 236);
--foreground: oklch(0.23 0.03 252);
--card: oklch(1 0 0);
--card-foreground: oklch(0.22 0.03 252);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.22 0.03 252);
--primary: oklch(0.59 0.24 286);
--primary-foreground: oklch(0.985 0.003 247);
--secondary: oklch(0.93 0.045 222);
--secondary-foreground: oklch(0.28 0.06 254);
--muted: oklch(0.95 0.02 240);
--muted-foreground: oklch(0.5 0.035 252);
--accent: oklch(0.9 0.08 174);
--accent-foreground: oklch(0.25 0.05 197);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.925 0.01 255.508);
--input: oklch(0.925 0.01 255.508);
--ring: oklch(0.64 0.055 256.788);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--radius: 0.625rem;
--sidebar: oklch(0.991 0.002 247.858);
--border: oklch(0.88 0.035 254);
--input: oklch(0.88 0.035 254);
--ring: oklch(0.62 0.24 286);
--chart-1: oklch(0.63 0.23 286);
--chart-2: oklch(0.67 0.14 193);
--chart-3: oklch(0.68 0.2 338);
--chart-4: oklch(0.76 0.17 130);
--chart-5: oklch(0.72 0.17 52);
--radius: 0.95rem;
--sidebar: oklch(0.985 0.02 244);
--sidebar-foreground: var(--foreground);
--sidebar-primary: var(--primary);
--sidebar-primary-foreground: var(--primary-foreground);
--sidebar-accent: var(--accent);
--sidebar-accent-foreground: var(--accent-foreground);
--sidebar-border: var(--border);
--sidebar-accent: oklch(0.94 0.05 286);
--sidebar-accent-foreground: oklch(0.3 0.06 286);
--sidebar-border: oklch(0.87 0.05 254);
--sidebar-ring: var(--ring);
--skeleton-base: oklch(0.948 0.004 264);
--skeleton-highlight: oklch(0.988 0.002 264);
}
.dark {
--background: oklch(0.245 0.018 265);
--foreground: oklch(0.88 0.014 252);
--card: oklch(0.275 0.017 265);
--card-foreground: oklch(0.88 0.014 252);
--popover: oklch(0.3 0.018 265);
--popover-foreground: oklch(0.88 0.014 252);
--primary: oklch(0.68 0.12 236);
--primary-foreground: oklch(0.985 0.004 247.858);
--secondary: oklch(0.32 0.016 265);
--secondary-foreground: oklch(0.88 0.014 252);
--muted: oklch(0.305 0.016 265);
--muted-foreground: oklch(0.72 0.018 252);
--accent: oklch(0.34 0.024 255);
--accent-foreground: oklch(0.9 0.012 252);
--background: oklch(0.16 0.03 282);
--foreground: oklch(0.93 0.02 252);
--card: oklch(0.21 0.03 281);
--card-foreground: oklch(0.93 0.02 252);
--popover: oklch(0.225 0.03 281);
--popover-foreground: oklch(0.93 0.02 252);
--primary: oklch(0.74 0.23 300);
--primary-foreground: oklch(0.18 0.03 282);
--secondary: oklch(0.29 0.05 238);
--secondary-foreground: oklch(0.93 0.02 252);
--muted: oklch(0.26 0.03 278);
--muted-foreground: oklch(0.76 0.025 252);
--accent: oklch(0.35 0.08 176);
--accent-foreground: oklch(0.93 0.03 176);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(0.38 0.018 265);
--input: oklch(0.405 0.018 265);
--ring: oklch(0.62 0.09 236);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.255 0.017 265);
--sidebar-foreground: oklch(0.86 0.014 252);
--border: oklch(0.35 0.06 286);
--input: oklch(0.35 0.06 286);
--ring: oklch(0.74 0.23 300);
--chart-1: oklch(0.74 0.23 300);
--chart-2: oklch(0.72 0.16 193);
--chart-3: oklch(0.7 0.2 338);
--chart-4: oklch(0.75 0.17 133);
--chart-5: oklch(0.75 0.17 52);
--sidebar: oklch(0.2 0.03 282);
--sidebar-foreground: oklch(0.92 0.02 252);
--sidebar-primary: var(--primary);
--sidebar-primary-foreground: var(--primary-foreground);
--sidebar-accent: oklch(0.325 0.018 265);
--sidebar-accent-foreground: oklch(0.9 0.012 252);
--sidebar-border: oklch(0.37 0.018 265);
--sidebar-accent: oklch(0.31 0.08 300);
--sidebar-accent-foreground: oklch(0.93 0.02 252);
--sidebar-border: oklch(0.35 0.06 286);
--sidebar-ring: var(--ring);
--skeleton-base: oklch(0.31 0.014 265);
--skeleton-highlight: oklch(0.39 0.018 265);
+93
View File
@@ -0,0 +1,93 @@
/* ============================================================================
* Heicode Manager design tokens
*
* One source of truth for radii, spacing, elevations, semantic colors and
* surface treatments used by the Agnet command-center UI. Pages and components
* should reference these tokens (via Tailwind or CSS variables) instead of
* declaring ad-hoc values.
* ==========================================================================*/
:root {
/* Radii */
--hc-radius-xs: 0.5rem;
--hc-radius-sm: 0.75rem;
--hc-radius-md: 1rem;
--hc-radius-lg: 1.25rem;
--hc-radius-xl: 1.75rem;
--hc-radius-pill: 999px;
/* Spacing scale (token-only; Tailwind already covers the literal values) */
--hc-space-1: 0.25rem;
--hc-space-2: 0.5rem;
--hc-space-3: 0.75rem;
--hc-space-4: 1rem;
--hc-space-5: 1.25rem;
--hc-space-6: 1.5rem;
--hc-space-8: 2rem;
--hc-space-10: 2.5rem;
/* Elevations (paired with backdrop-blur in JSX) */
--hc-elev-1: 0 4px 18px -12px color-mix(in oklch, var(--primary) 60%, black);
--hc-elev-2: 0 14px 40px -28px color-mix(in oklch, var(--primary) 55%, black);
--hc-elev-3: 0 30px 80px -40px color-mix(in oklch, var(--primary) 60%, black);
/* Border strengths */
--hc-border-soft: color-mix(in oklch, var(--primary) 18%, var(--border));
--hc-border-mid: color-mix(in oklch, var(--primary) 28%, var(--border));
--hc-border-strong: color-mix(in oklch, var(--primary) 45%, var(--border));
/* Surface fills (translucent over the radial body background) */
--hc-surface-1: color-mix(in oklch, var(--card) 55%, transparent);
--hc-surface-2: color-mix(in oklch, var(--card) 70%, transparent);
--hc-surface-3: color-mix(in oklch, var(--card) 85%, transparent);
/* Status semantic colors (used by pills / dots / charts) */
--hc-status-running: oklch(0.74 0.18 286);
--hc-status-success: oklch(0.78 0.18 150);
--hc-status-warn: oklch(0.85 0.16 85);
--hc-status-error: oklch(0.7 0.21 25);
--hc-status-pending: oklch(0.8 0.06 250);
}
@layer utilities {
.hc-surface-1 {
background: var(--hc-surface-1);
border: 1px solid var(--hc-border-soft);
box-shadow: var(--hc-elev-1);
border-radius: var(--hc-radius-md);
backdrop-filter: blur(8px);
}
.hc-surface-2 {
background: var(--hc-surface-2);
border: 1px solid var(--hc-border-mid);
box-shadow: var(--hc-elev-2);
border-radius: var(--hc-radius-lg);
backdrop-filter: blur(10px);
}
.hc-surface-3 {
background: var(--hc-surface-3);
border: 1px solid var(--hc-border-strong);
box-shadow: var(--hc-elev-3);
border-radius: var(--hc-radius-xl);
backdrop-filter: blur(14px);
}
.hc-pill-running {
background: color-mix(in oklch, var(--hc-status-running) 22%, transparent);
color: var(--hc-status-running);
}
.hc-pill-success {
background: color-mix(in oklch, var(--hc-status-success) 22%, transparent);
color: var(--hc-status-success);
}
.hc-pill-warn {
background: color-mix(in oklch, var(--hc-status-warn) 22%, transparent);
color: var(--hc-status-warn);
}
.hc-pill-error {
background: color-mix(in oklch, var(--hc-status-error) 22%, transparent);
color: var(--hc-status-error);
}
}
+4 -5
View File
@@ -9,23 +9,22 @@ const _geistMono = Geist_Mono({ subsets: ["latin"] });
export const metadata: Metadata = {
title: 'Heicode - 团队软件交付智能体平台',
description: '让团队能以可复述的方式回答「谁在何时对什么负责」「依据是什么」「如何回溯」。智能体适合承接标准化、可重复的环节,人机分工与审批边界由组织策略定义。',
generator: 'v0.app',
icons: {
icon: [
{
url: '/icon-light-32x32.png',
url: '/heicode-logo.svg',
media: '(prefers-color-scheme: light)',
},
{
url: '/icon-dark-32x32.png',
url: '/heicode-logo.svg',
media: '(prefers-color-scheme: dark)',
},
{
url: '/icon.svg',
url: '/heicode-logo.svg',
type: 'image/svg+xml',
},
],
apple: '/apple-icon.png',
apple: '/heicode-logo.svg',
},
}
+13 -7
View File
@@ -1,24 +1,30 @@
import { Button } from "@/components/ui/button";
import { ArrowRight } from "lucide-react";
const MANAGER_URL = "https://code.xinghanlab.com/";
export function CTA() {
return (
<section className="border-t border-border bg-card/30 py-20 sm:py-32">
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div className="mx-auto max-w-2xl text-center">
<h2 className="text-3xl font-bold tracking-tight sm:text-4xl">
准备好改变交付方式了吗?
准备好强化团队 Code 交付能力了吗?
</h2>
<p className="mt-4 text-lg text-muted-foreground">
让团队能以可复述的方式回答——「谁在何时对什么负责」「依据是什么」「如何回溯」
从需求到代码到发布,让交付速度、质量门禁与回溯能力同时提升。
</p>
<div className="mt-10 flex flex-col items-center justify-center gap-4 sm:flex-row">
<Button size="lg" className="gap-2">
联系我们
<ArrowRight className="h-4 w-4" />
<Button size="lg" className="gap-2" asChild>
<a href={MANAGER_URL} target="_blank" rel="noreferrer">
进入 Heicode Manager
<ArrowRight className="h-4 w-4" />
</a>
</Button>
<Button variant="outline" size="lg">
阅读文档
<Button variant="outline" size="lg" asChild>
<a href={MANAGER_URL} target="_blank" rel="noreferrer">
立即登录
</a>
</Button>
</div>
</div>
+1 -1
View File
@@ -5,7 +5,7 @@ const features = [
icon: Shield,
title: "身份与策略一元化",
description:
"从个人到团队,谁能访问何种模型与渠道、何种环境、何种仓库——应有清晰归属与审计预期,而不是散落在若干控制台口径不一致。",
"从个人到团队,谁能访问何种资源、何种环境、何种仓库——应有清晰归属与审计预期,而不是散落在若干控制台口径不一致。",
},
{
icon: Workflow,
+8 -5
View File
@@ -1,14 +1,15 @@
import Link from "next/link";
import Image from "next/image";
const footerLinks = {
产品: [
{ label: "Heicode Manager", href: "#" },
{ label: "Heicode 客户端", href: "#" },
{ label: "定价", href: "#" },
{ label: "Agnet 控制面", href: "#" },
],
资源: [
{ label: "文档", href: "#" },
{ label: "API 参考", href: "#" },
{ label: "集成说明", href: "#" },
{ label: "更新日志", href: "#" },
{ label: "路线图", href: "#" },
],
@@ -32,10 +33,12 @@ export function Footer() {
<div className="grid gap-8 sm:grid-cols-2 lg:grid-cols-5">
<div className="lg:col-span-1">
<Link href="/" className="flex items-center gap-2">
<div className="flex h-8 w-8 items-center justify-center rounded-md bg-primary">
<span className="text-sm font-bold text-primary-foreground">H</span>
<div className="flex h-8 w-8 items-center justify-center rounded-md border border-border/70 bg-card/60 text-foreground">
<Image src="/heicode-logo.svg" alt="Heicode" width={18} height={18} />
</div>
<span className="text-lg font-semibold tracking-tight">Heicode</span>
<span className="bg-gradient-to-r from-blue-400 via-violet-400 to-purple-500 bg-clip-text text-lg font-semibold tracking-tight text-transparent">
Heicode
</span>
</Link>
<p className="mt-4 text-sm leading-relaxed text-muted-foreground">
团队软件交付智能体平台
+25 -9
View File
@@ -2,6 +2,7 @@
import { useState } from "react";
import Link from "next/link";
import Image from "next/image";
import { Button } from "@/components/ui/button";
import { Menu, X } from "lucide-react";
@@ -11,6 +12,7 @@ const navLinks = [
{ href: "#paradigm", label: "范式" },
{ href: "#principles", label: "原则" },
];
const MANAGER_URL = "https://code.xinghanlab.com/";
export function Header() {
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
@@ -19,10 +21,12 @@ export function Header() {
<header className="fixed top-0 left-0 right-0 z-50 border-b border-border/50 bg-background/80 backdrop-blur-md">
<div className="mx-auto flex h-16 max-w-7xl items-center justify-between px-4 sm:px-6 lg:px-8">
<Link href="/" className="flex items-center gap-2">
<div className="flex h-8 w-8 items-center justify-center rounded-md bg-primary">
<span className="text-sm font-bold text-primary-foreground">H</span>
<div className="flex h-8 w-8 items-center justify-center rounded-md border border-border/70 bg-card/60 text-foreground">
<Image src="/heicode-logo.svg" alt="Heicode" width={18} height={18} />
</div>
<span className="text-lg font-semibold tracking-tight">Heicode</span>
<span className="bg-gradient-to-r from-blue-400 via-violet-400 to-purple-500 bg-clip-text text-lg font-semibold tracking-tight text-transparent">
Heicode
</span>
</Link>
{/* Desktop Navigation */}
@@ -39,10 +43,16 @@ export function Header() {
</nav>
<div className="hidden items-center gap-4 md:flex">
<Button variant="ghost" size="sm">
登录
<Button variant="ghost" size="sm" asChild>
<a href={MANAGER_URL} target="_blank" rel="noreferrer">
登录
</a>
</Button>
<Button size="sm" asChild>
<a href={MANAGER_URL} target="_blank" rel="noreferrer">
开始使用
</a>
</Button>
<Button size="sm">开始使用</Button>
</div>
{/* Mobile Menu Button */}
@@ -70,10 +80,16 @@ export function Header() {
</Link>
))}
<div className="mt-4 flex flex-col gap-2">
<Button variant="ghost" size="sm" className="justify-start">
登录
<Button variant="ghost" size="sm" className="justify-start" asChild>
<a href={MANAGER_URL} target="_blank" rel="noreferrer">
登录
</a>
</Button>
<Button size="sm" asChild>
<a href={MANAGER_URL} target="_blank" rel="noreferrer">
开始使用
</a>
</Button>
<Button size="sm">开始使用</Button>
</div>
</nav>
</div>
+16 -10
View File
@@ -1,6 +1,8 @@
import { Button } from "@/components/ui/button";
import { ArrowRight, Play } from "lucide-react";
const MANAGER_URL = "https://code.xinghanlab.com/";
export function Hero() {
return (
<section className="relative overflow-hidden pt-32 pb-20 sm:pt-40 sm:pb-32">
@@ -17,24 +19,28 @@ export function Hero() {
</p>
<h1 className="text-4xl font-bold tracking-tight sm:text-5xl lg:text-6xl text-balance">
让交付过程
让代码交付
<br />
<span className="text-muted-foreground">可追溯、可复述</span>
<span className="text-muted-foreground">更快、更稳、可回溯</span>
</h1>
<p className="mx-auto mt-6 max-w-2xl text-lg text-muted-foreground leading-relaxed text-pretty">
无论是个人开发者还是多人团队,在复杂环境与迭代条件下,让对齐方式、责任边界与可追溯性默认成立。
智能体承接标准化、可重复的环节,人机分工由个人或组织策略定义。
以代码为中心组织协作:从任务拆解、提交评审、自动验证到发布回滚,形成闭环交付路径。
智能体用于提效,但核心价值是工程质量、交付速度与稳定性同步提升。
</p>
<div className="mt-10 flex flex-col items-center justify-center gap-4 sm:flex-row">
<Button size="lg" className="gap-2">
开始使用
<ArrowRight className="h-4 w-4" />
<Button size="lg" className="gap-2" asChild>
<a href={MANAGER_URL} target="_blank" rel="noreferrer">
开始使用
<ArrowRight className="h-4 w-4" />
</a>
</Button>
<Button variant="outline" size="lg" className="gap-2">
<Play className="h-4 w-4" />
观看演示
<Button variant="outline" size="lg" className="gap-2" asChild>
<a href={MANAGER_URL} target="_blank" rel="noreferrer">
<Play className="h-4 w-4" />
打开 Heicode Manager
</a>
</Button>
</div>
</div>
+2 -2
View File
@@ -5,8 +5,8 @@ const products = [
icon: Layers,
name: "Heicode Manager",
description:
"账户、模型与路由策略、计费与渠道等能力的网关及管理控制台。无论个人或团队,统一身份与策略,清晰归属与审计预期。",
features: ["身份管理", "策略配置", "模型路由", "计费管理"],
"面向代码交付的团队控制台。把需求、实现、验证、发布与回溯串成一条可执行的工程链路。",
features: ["代码任务编排", "质量门禁", "交付追踪", "审计回溯"],
},
{
icon: Terminal,
+1 -1
View File
@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+1
View File
@@ -1,5 +1,6 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: "export",
typescript: {
ignoreBuildErrors: true,
},
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+6
View File
@@ -0,0 +1,6 @@
1:"$Sreact.fragment"
2:I[25078,["/_next/static/chunks/16ug8_ttttewd.js","/_next/static/chunks/0duodrajo5fcx.js"],"ViewportBoundary"]
3:I[25078,["/_next/static/chunks/16ug8_ttttewd.js","/_next/static/chunks/0duodrajo5fcx.js"],"MetadataBoundary"]
4:"$Sreact.suspense"
5:I[39451,["/_next/static/chunks/16ug8_ttttewd.js","/_next/static/chunks/0duodrajo5fcx.js"],"IconMark"]
0:{"rsc":["$","$1","h",{"children":[null,["$","$L2",null,{"children":[["$","meta","0",{"charSet":"utf-8"}],["$","meta","1",{"name":"viewport","content":"width=device-width, initial-scale=1"}]]}],["$","div",null,{"hidden":true,"children":["$","$L3",null,{"children":["$","$4",null,{"name":"Next.Metadata","children":[["$","title","0",{"children":"Heicode - 团队软件交付智能体平台"}],["$","meta","1",{"name":"description","content":"让团队能以可复述的方式回答「谁在何时对什么负责」「依据是什么」「如何回溯」。智能体适合承接标准化、可重复的环节,人机分工与审批边界由组织策略定义。"}],["$","link","2",{"rel":"icon","href":"/heicode-logo.svg","media":"(prefers-color-scheme: light)"}],["$","link","3",{"rel":"icon","href":"/heicode-logo.svg","media":"(prefers-color-scheme: dark)"}],["$","link","4",{"rel":"icon","href":"/heicode-logo.svg","type":"image/svg+xml"}],["$","link","5",{"rel":"apple-touch-icon","href":"/heicode-logo.svg"}],["$","$L5","6",{}]]}]}]}],["$","meta",null,{"name":"next-size-adjust","content":""}]]}],"isPartial":false,"staleTime":300,"varyParams":null,"buildId":"dja_A40GLx_v0ix-5Ob90"}
+7
View File
@@ -0,0 +1,7 @@
1:"$Sreact.fragment"
2:I[60483,["/_next/static/chunks/16ug8_ttttewd.js","/_next/static/chunks/0duodrajo5fcx.js"],"default"]
3:I[87929,["/_next/static/chunks/16ug8_ttttewd.js","/_next/static/chunks/0duodrajo5fcx.js"],"default"]
4:I[52768,["/_next/static/chunks/16ug8_ttttewd.js","/_next/static/chunks/0duodrajo5fcx.js"],"Analytics"]
:HL["/_next/static/chunks/0x.pxwmy6tt~x.css","style"]
:HL["/_next/static/chunks/0fjrv1t7-dlo6.css","style"]
0:{"rsc":["$","$1","c",{"children":[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/chunks/0x.pxwmy6tt~x.css","precedence":"next"}],["$","link","1",{"rel":"stylesheet","href":"/_next/static/chunks/0fjrv1t7-dlo6.css","precedence":"next"}],["$","script","script-0",{"src":"/_next/static/chunks/16ug8_ttttewd.js","async":true}],["$","script","script-1",{"src":"/_next/static/chunks/0duodrajo5fcx.js","async":true}]],["$","html",null,{"lang":"zh-CN","className":"bg-background","children":["$","body",null,{"className":"font-sans antialiased","children":[["$","$L2",null,{"parallelRouterKey":"children","template":["$","$L3",null,{}],"notFound":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":404}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],[]]}],["$","$L4",null,{}]]}]}]]}],"isPartial":false,"staleTime":300,"varyParams":null,"buildId":"dja_A40GLx_v0ix-5Ob90"}
+5
View File
@@ -0,0 +1,5 @@
:HL["/_next/static/chunks/0x.pxwmy6tt~x.css","style"]
:HL["/_next/static/chunks/0fjrv1t7-dlo6.css","style"]
:HL["/_next/static/media/797e433ab948586e-s.p.0.q-h669a_dqa.woff2","font",{"crossOrigin":"","type":"font/woff2"}]
:HL["/_next/static/media/caa3a2e1cccd8315-s.p.16t1db8_9y2o~.woff2","font",{"crossOrigin":"","type":"font/woff2"}]
0:{"tree":{"name":"","param":null,"prefetchHints":16,"slots":{"children":{"name":"__PAGE__","param":null,"prefetchHints":0,"slots":null}}},"staleTime":300,"buildId":"dja_A40GLx_v0ix-5Ob90"}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
(globalThis.TURBOPACK||(globalThis.TURBOPACK=[])).push(["object"==typeof document?document.currentScript:void 0,50443,(e,r,t)=>{"use strict";Object.defineProperty(t,"__esModule",{value:!0}),Object.defineProperty(t,"warnOnce",{enumerable:!0,get:function(){return n}});let n=e=>{}},63522,(e,r,t)=>{"use strict";Object.defineProperty(t,"__esModule",{value:!0});var n={DecodeError:function(){return P},MiddlewareNotFoundError:function(){return O},MissingStaticPage:function(){return h},NormalizeError:function(){return E},PageNotFoundError:function(){return b},SP:function(){return m},ST:function(){return y},WEB_VITALS:function(){return i},execOnce:function(){return u},getDisplayName:function(){return l},getLocationOrigin:function(){return c},getURL:function(){return f},isAbsoluteUrl:function(){return a},isResSent:function(){return d},loadGetInitialProps:function(){return g},normalizeRepeatedSlashes:function(){return p},stringifyError:function(){return N}};for(var o in n)Object.defineProperty(t,o,{enumerable:!0,get:n[o]});let i=["CLS","FCP","FID","INP","LCP","TTFB"];function u(e){let r,t=!1;return(...n)=>(t||(t=!0,r=e(...n)),r)}let s=/^[a-zA-Z][a-zA-Z\d+\-.]*?:/,a=e=>s.test(e);function c(){let{protocol:e,hostname:r,port:t}=window.location;return`${e}//${r}${t?":"+t:""}`}function f(){let{href:e}=window.location,r=c();return e.substring(r.length)}function l(e){return"string"==typeof e?e:e.displayName||e.name||"Unknown"}function d(e){return e.finished||e.headersSent}function p(e){let r=e.split("?");return r[0].replace(/\\/g,"/").replace(/\/\/+/g,"/")+(r[1]?`?${r.slice(1).join("?")}`:"")}async function g(e,r){let t=r.res||r.ctx&&r.ctx.res;if(!e.getInitialProps)return r.ctx&&r.Component?{pageProps:await g(r.Component,r.ctx)}:{};let n=await e.getInitialProps(r);if(t&&d(t))return n;if(!n)throw Object.defineProperty(Error(`"${l(e)}.getInitialProps()" should resolve to an object. But found "${n}" instead.`),"__NEXT_ERROR_CODE",{value:"E1025",enumerable:!1,configurable:!0});return n}let m="u">typeof performance,y=m&&["mark","measure","getEntriesByName"].every(e=>"function"==typeof performance[e]);class P extends Error{}class E extends Error{}class b extends Error{constructor(e){super(),this.code="ENOENT",this.name="PageNotFoundError",this.message=`Cannot find module for page: ${e}`}}class h extends Error{constructor(e,r){super(),this.message=`Failed to load static file for page: ${e} ${r}`}}class O extends Error{constructor(){super(),this.code="ENOENT",this.message="Cannot find the middleware module"}}function N(e){return JSON.stringify({message:e.message,stack:e.stack})}},14025,(e,r,t)=>{"use strict";Object.defineProperty(t,"__esModule",{value:!0});var n={assign:function(){return a},searchParamsToUrlQuery:function(){return i},urlQueryToSearchParams:function(){return s}};for(var o in n)Object.defineProperty(t,o,{enumerable:!0,get:n[o]});function i(e){let r={};for(let[t,n]of e.entries()){let e=r[t];void 0===e?r[t]=n:Array.isArray(e)?e.push(n):r[t]=[e,n]}return r}function u(e){return"string"==typeof e?e:("number"!=typeof e||isNaN(e))&&"boolean"!=typeof e?"":String(e)}function s(e){let r=new URLSearchParams;for(let[t,n]of Object.entries(e))if(Array.isArray(n))for(let e of n)r.append(t,u(e));else r.set(t,u(n));return r}function a(e,...r){for(let t of r){for(let r of t.keys())e.delete(r);for(let[r,n]of t.entries())e.append(r,n)}return e}}]);
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,2 @@
@font-face{font-family:Geist;font-style:normal;font-weight:100 900;font-display:swap;src:url(../media/8a480f0b521d4e75-s.06d3mdzz5bre_.woff2)format("woff2");unicode-range:U+301,U+400-45F,U+490-491,U+4B0-4B1,U+2116}@font-face{font-family:Geist;font-style:normal;font-weight:100 900;font-display:swap;src:url(../media/7178b3e590c64307-s.11.cyxs5p-0z~.woff2)format("woff2");unicode-range:U+100-2BA,U+2BD-2C5,U+2C7-2CC,U+2CE-2D7,U+2DD-2FF,U+304,U+308,U+329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:Geist;font-style:normal;font-weight:100 900;font-display:swap;src:url(../media/caa3a2e1cccd8315-s.p.16t1db8_9y2o~.woff2)format("woff2");unicode-range:U+??,U+131,U+152-153,U+2BB-2BC,U+2C6,U+2DA,U+2DC,U+304,U+308,U+329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}@font-face{font-family:Geist Fallback;src:local(Arial);ascent-override:95.94%;descent-override:28.16%;line-gap-override:0.0%;size-adjust:104.76%}.geist_a7695b8e-module__Entzca__className{font-family:Geist,Geist Fallback;font-style:normal}
@font-face{font-family:Geist Mono;font-style:normal;font-weight:100 900;font-display:swap;src:url(../media/4fa387ec64143e14-s.0q3udbd2bu5yp.woff2)format("woff2");unicode-range:U+301,U+400-45F,U+490-491,U+4B0-4B1,U+2116}@font-face{font-family:Geist Mono;font-style:normal;font-weight:100 900;font-display:swap;src:url(../media/bbc41e54d2fcbd21-s.0gw~uztddq1df.woff2)format("woff2");unicode-range:U+100-2BA,U+2BD-2C5,U+2C7-2CC,U+2CE-2D7,U+2DD-2FF,U+304,U+308,U+329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:Geist Mono;font-style:normal;font-weight:100 900;font-display:swap;src:url(../media/797e433ab948586e-s.p.0.q-h669a_dqa.woff2)format("woff2");unicode-range:U+??,U+131,U+152-153,U+2BB-2BC,U+2C6,U+2DA,U+2DC,U+304,U+308,U+329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}@font-face{font-family:Geist Mono Fallback;src:local(Arial);ascent-override:74.67%;descent-override:21.92%;line-gap-override:0.0%;size-adjust:134.59%}.geist_mono_354fc78-module__zrY5Sa__className{font-family:Geist Mono,Geist Mono Fallback;font-style:normal}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,11 @@
self.__BUILD_MANIFEST = {
"__rewrites": {
"afterFiles": [],
"beforeFiles": [],
"fallback": []
},
"sortedPages": [
"/_app",
"/_error"
]
};self.__BUILD_MANIFEST_CB && self.__BUILD_MANIFEST_CB()
@@ -0,0 +1 @@
self.__MIDDLEWARE_MATCHERS = [];self.__MIDDLEWARE_MATCHERS_CB && self.__MIDDLEWARE_MATCHERS_CB()

Some files were not shown because too many files have changed in this diff Show More