Compare commits

..
10 Commits
45 changed files with 9188 additions and 26 deletions

No files matched your search

+26
View File
@@ -0,0 +1,26 @@
---
name: grill-me
description: >-
Relentlessly interview the user to stress-test a plan, requirement, or architectural
choice before writing code or selecting a solution. Activate this skill when the user
asks to "grill me", clarify requirements, align on a design, or choose between technical
approaches.
---
# Grill Me — Decision-Tree Architecture & Requirement Interview
Instead of guessing requirements or dumping a massive, superficial plan, walk the decision tree of the user's idea **depth-first** through structured, high-leverage questions until every critical branch is resolved.
## Core Protocol
1. **Codebase-First Rule**:
- Never ask the user a question that the existing codebase or system state (`GEMINI.md`, repository files, running services) can already answer. Check the codebase first.
2. **Model the Decision Tree**:
- Break the problem into **Top-Level Branches** (core user workflow, primary goal, deployment boundary), **Sub-Branches** (trade-offs, UX expectations, integration points), and **Leaf Nodes** (implementation details).
3. **Depth-First & Frontier Questioning**:
- Ask **1 to 3 sharp, high-leverage questions per round** using the `ask_question` tool (when available) or structured multiple-choice options.
- Do not ask about downstream implementation details before upstream workflow decisions are settled.
4. **Always Provide a `(Recommended)` Option**:
- Every question MUST put a well-reasoned recommended answer first, prefixed with `(Recommended)`, explaining *why* it fits the user's architecture best.
5. **Converge to a Concrete Decision Record**:
- Once the critical branches of the decision tree are resolved, synthesize the locked-in decisions into a concise execution plan and ask for final confirmation to build/deploy.
+1
View File
@@ -2,3 +2,4 @@
stacks/gateway/certs/*.key
stacks/gateway/certs/*.pem
stacks/lucky/goodluck/
**/node_modules/
+73
View File
@@ -0,0 +1,73 @@
# HomeLab & Cloud-Edge Architecture Memory (`GEMINI.md`)
本文件为 `agy` (Antigravity CLI) 的**全局持久上下文记忆文件**。无论在 M5 MacBook Air 还是 M6 Mac mini 网页端 (`https://agy.961504.xyz`) 启动新会话,均自动加载本架构规范。
---
## 1. 交互与工作准则
1. **汇报 + 确认机制**:每次架构变更或较大改动前,先说明方案,再执行;代码与基础设施配置统一维护在 Git 仓库 [`/Users/ixvii0iv/homelab`](file:///Users/ixvii0iv/homelab) 并同步至 `mini:~/homelab`。
2. **代码链接规范**:回答中涉及所有文件路径或代码符号时,必须使用可点击的 Markdown 链接(如 `[TopRibbon.tsx](file:///Users/ixvii0iv/homelab/stacks/portal/web/src/components/TopRibbon.tsx)`)。
---
## 2. 核心硬件与网络拓扑(4 大节点)
| 节点角色 | 设备型号 / 系统 | 局域网 / 公网 IP | Tailscale 专线 IP | SSH 快捷别名 | 核心职责 |
| :--- | :--- | :--- | :--- | :--- | :--- |
| **随身开发机** | Apple M5 MacBook Air (macOS) | `192.168.31.83` | `100.89.152.95` | 本机 | 日常主力操作终端、本地 Git 仓库源头 |
| **家庭计算核心** | Apple M6 Mac mini (24GB RAM, 460GB SSD) | `192.168.31.164` | `100.108.205.40` | `ssh mini` / `ssh mini-ts` | 7×24h All-in-One 服务器、OrbStack 容器宿主机、AGY 网页端宿主、Tailscale 子网路由 (`192.168.31.0/24`) |
| **家庭存储中心** | 极空间 Z4 (20TB RAID) | `192.168.31.185` | 经 Mac mini 子网路由可达 | Web `5055` / SMB `445` | 20TB 大容量冷热存储、通过 LaunchAgent 自动 SMB 挂载至 Mac mini `~/nas/{共享文件,个人空间}` |
| **家庭主路由** | 小米路由器 (开启 SSH + ShellCrash/Mihomo) | `192.168.31.1` (联通公网 `124.90.142.21`) | 经 Mac mini 子网路由可达 | `ssh root@192.168.31.1` | PPPoE 拨号、端口转发 (`10443`/`41641` -> `192.168.31.164`)、内网 Split-DNS、锁定 AI 流量走狮城节点 |
| **海外云端前哨** | 新加坡 Azure VM (Ubuntu 24.04) | `104.43.105.243` | `100.115.204.51` | `ssh azure` | 443 免端口公网 HTTPS 反向代理入口、AI 算力固定出口、Uptime Kuma 外部独立监控哨兵 |
---
## 3. 域名与双通道访问矩阵 (`961504.xyz`)
### 通道 A:443 免端口标准 HTTPS(经新加坡 Azure Caddy -> Tailscale P2P 回源)
- **导航中控台 (HomeLab Console V3)**: `https://nav.961504.xyz` -> `100.108.205.40:3002`
- **Agent 协同编排工作台 (Codeg Base + 自研大盘)**: `https://nav.961504.xyz/agents` & `https://codeg.961504.xyz` -> `100.108.205.40:3080` (SSO 免密 Proxy Auth)
- **Gitea 私有代码托管与冷备中枢**: `https://git.961504.xyz` -> `100.108.205.40:3005` (Mac SSD 热层 + 极空间 20TB 冷层)
- **Storybook 设计系统工作台**: `https://nav.961504.xyz/storybook` -> `100.108.205.40:3002/storybook`
- **AGY 云端编程工作台 (`ttyd` + `tmux`)**: `https://agy.961504.xyz` -> `100.108.205.40:7681` (受 HomeLab SSO 2.0 保护)
- **Vaultwarden 密码保险箱**: `https://vw.961504.xyz` -> `100.108.205.40:8222`
- **Uptime Kuma 外部监控哨兵**: `https://status.961504.xyz` -> `127.0.0.1:3001` (Azure 本地容器)
### 通道 B:10443 家庭宽带公网直连 & 局域网 Split-DNS 秒开(Mac mini Caddy Wildcard TLS)
- 在家连 Wi-Fi 时,小米路由器 Mihomo 将 `home.961504.xyz` 与 `*.home.961504.xyz` 直接解析为 `192.168.31.164`(并在 `fake_ip_filter` 中豁免 Fake-IP),实现 <1ms 局域网直连。
- 在外网时,Cloudflare DNS 将 `*.home.961504.xyz` 动态解析至家庭联通公网 IP (`124.90.142.21`),通过路由器 `10443` 端口转发直达 Mac mini Caddy:
- `https://home.961504.xyz:10443` (HomeLab Console V3)
- `https://codeg.home.961504.xyz:10443` (Codeg 多智能体协同工作台)
- `https://git.home.961504.xyz:10443` (Gitea 私有代码托管中枢)
- `https://agy.home.961504.xyz:10443` (AGY 云端工作台)
- `https://vw.home.961504.xyz:10443` (Vaultwarden)
- `https://nas.home.961504.xyz:10443` (极空间 Z4 Web `192.168.31.185:5055`)
- `https://clash.home.961504.xyz:10443` (路由器 Mihomo 面板 `192.168.31.1:9999`)
- `https://router.home.961504.xyz:10443` (小米路由器后台 `192.168.31.1:80`)
- `https://status.home.961504.xyz:10443` (Uptime Kuma `100.115.204.51:3001`)
---
## 4. 前端中控台工程架构 (`stacks/portal/web`)
- **技术栈**: React 19 + Vite 6 + TypeScript + Zustand + Tailwind CSS v4 + Lucide Icons
- **设计语言**:
- **顶部导航 (`src/components/TopRibbon.tsx`)**: 标准 `shadcn/ui` 双层 AppShell Header。第一层 (`h-14`) 左侧为品牌与 `[中控台 | 📖 Storybook 设计规范]` 视图切换,右侧通过 `ml-auto shrink-0` 严格固定 `[🔍 搜索 ⌘K | >_ AGY 终端 | ☀️/🌙 主题切换]`;第二层 (`h-11`) 左侧为 4 枚硬件/网络微缩仪表胶囊,右侧为四模路由切换器。
- **双主流主题自动切换 (`src/design-system/tokens.ts`)**: 仅保留 `light` (Shadcn Clean Light) 与 `dark` (Catppuccin/Linear Dark) 两个主流主题,默认监听 `window.matchMedia('(prefers-color-scheme: dark)')` 跟随设备深浅色自动切换,也可在右上角手动切换。
- **内置 Storybook (`src/components/StorybookWorkbench.tsx`)**: 支持实时切换 3 种主流布局 (`homepage-3col` / `bento-split` / `compact-2col`)、查看双主题设计 Token、交互式调试 `ServiceCard` 组件变体。
- **构建与发布命令**:
```bash
cd /Users/ixvii0iv/homelab/stacks/portal/web && npm run build
rsync -az --delete /Users/ixvii0iv/homelab/stacks/portal/ ixvii0iv@192.168.31.164:~/homelab/stacks/portal/
```
---
## 5. Mac mini 后台常驻服务清单 (`LaunchAgents` & `LaunchDaemons`)
- `/Library/LaunchDaemons/com.homelab.caddy.plist`: 系统级 Caddy 网关(监听 `10443`,提供 `*.home.961504.xyz` Let's Encrypt DNS-01 通配符证书反代)
- `~/Library/LaunchAgents/com.homelab.portal.plist`: HomeLab Console V3 后端与静态资源服务 (`stacks/portal/server.py`,监听 `3002`/`3004`,提供 `/api/telemetry`、`/api/agents/overview`、`/api/clawbot/*` 与 `/api/action/*`)
- `~/Library/LaunchAgents/com.homelab.codeg.plist`: Codeg 多智能体协同工作台底座 (`scripts/start-codeg.sh`,监听 `3080`,内置 4 大分工角色与 20+ CLI 协议适配)
- `~/Library/LaunchAgents/com.homelab.gitea.plist`: Gitea 私有代码托管中枢 (`scripts/start-gitea.sh`,监听 `3005`/SSH `2222`,Mac SSD 热层 + `scripts/sync-git-to-nas.sh` 极空间 20TB 冷层归档)
- `~/Library/LaunchAgents/com.homelab.agy-web.plist`: AGY 网页终端 (`scripts/start-agy-web.sh`,通过 `ttyd` 暴露 `7681` 端口并附着到 `tmux` 会话 `agy`)
- `~/Library/LaunchAgents/com.homelab.cf-ddns.plist`: 每 5 分钟检测家庭公网 IP 并更新 Cloudflare `home.961504.xyz` / `*.home.961504.xyz`
- `~/Library/LaunchAgents/com.homelab.mount-zspace.plist`: 自动挂载极空间 SMB 至 `~/nas/共享文件` 与 `~/nas/个人空间`
- `~/Library/LaunchAgents/com.homelab.nas-backup.plist`: 每天凌晨 03:30 自动打包备份 Vaultwarden、Caddy、Gitea 冷备与基础设施配置至极空间 `~/nas/个人空间/MacMini_Backups/`
+5 -5
View File
@@ -9,12 +9,12 @@ export LANG="en_US.UTF-8"
export LC_ALL="en_US.UTF-8"
export TERM="xterm-256color"
mkdir -p "$HOME/workspace"
mkdir -p "$HOME/homelab" "$HOME/workspace"
# 确保 tmux 会话 agy 存在并在其中默认打开 zsh (可直接运行 agy)
# 确保 tmux 会话 agy 存在并在其中默认恢复最近一次会话 (agy -c)
if ! /opt/homebrew/bin/tmux has-session -t agy 2>/dev/null; then
/opt/homebrew/bin/tmux new-session -d -s agy -c "$HOME/workspace"
/opt/homebrew/bin/tmux send-keys -t agy "agy" C-m
/opt/homebrew/bin/tmux new-session -d -s agy -c "$HOME/homelab"
/opt/homebrew/bin/tmux send-keys -t agy "agy -c" C-m
fi
exec /opt/homebrew/bin/ttyd \
@@ -22,4 +22,4 @@ exec /opt/homebrew/bin/ttyd \
-p 7681 \
-t fontSize=15 \
-t 'titleFixed=Mac mini (M6) - AGY 云端工作台' \
/opt/homebrew/bin/tmux new-session -A -s agy -c "$HOME/workspace"
/opt/homebrew/bin/tmux new-session -A -s agy -c "$HOME/homelab"
+20
View File
@@ -0,0 +1,20 @@
#!/usr/bin/env bash
# ==============================================================================
# start-codeg.sh — 启动 Codeg 多智能体协同工作台服务端 (M6 Mac mini 原生无头底座)
# 监听端口: 3080 (Web UI + JSON/WebSocket API)
# ==============================================================================
set -euo pipefail
export HOME="/Users/ixvii0iv"
export USER="ixvii0iv"
export LANG="en_US.UTF-8"
export LC_ALL="en_US.UTF-8"
export PATH="/Users/ixvii0iv/.local/bin:/Users/ixvii0iv/.antigravity-cli/bin:/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
export CODEG_PORT="3080"
export CODEG_HOST="0.0.0.0"
export CODEG_TOKEN="homelab2026"
export CODEG_STATIC_DIR="/Users/ixvii0iv/.local/share/codeg/web"
export CODEG_MCP_BIN="/Users/ixvii0iv/.local/bin/codeg-mcp"
exec /Users/ixvii0iv/.local/bin/codeg-server
+103
View File
@@ -0,0 +1,103 @@
#!/usr/bin/env bash
# ==============================================================================
# start-gitea.sh — 启动 Gitea 私有代码托管中枢 (Mac SSD 热层 + 极空间 NAS 冷层归档)
# HTTP 端口: 3005 | 内置 Git SSH 端口: 2222
# ==============================================================================
set -euo pipefail
export HOME="/Users/ixvii0iv"
export USER="ixvii0iv"
export USERNAME="ixvii0iv"
export LANG="en_US.UTF-8"
export LC_ALL="en_US.UTF-8"
export PATH="/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
HOT_DIR="/Users/ixvii0iv/homelab/stacks/git"
mkdir -p "$HOT_DIR/data/repositories" \
"$HOT_DIR/data/lfs" \
"$HOT_DIR/data/attachments" \
"$HOT_DIR/data/packages" \
"$HOT_DIR/data/repo-archive" \
"$HOT_DIR/custom/conf" \
"$HOT_DIR/log"
CONF_FILE="$HOT_DIR/custom/conf/app.ini"
if [ ! -f "$CONF_FILE" ]; then
SECRET_KEY=$(/opt/homebrew/opt/gitea/bin/gitea generate secret SECRET_KEY)
INTERNAL_TOKEN=$(/opt/homebrew/opt/gitea/bin/gitea generate secret INTERNAL_TOKEN)
JWT_SECRET=$(/opt/homebrew/opt/gitea/bin/gitea generate secret JWT_SECRET)
LFS_JWT_SECRET=$(/opt/homebrew/opt/gitea/bin/gitea generate secret LFS_JWT_SECRET)
cat > "$CONF_FILE" <<EOF
APP_NAME = HomeLab Git Hub (Mac SSD Hot + ZSpace 20TB Cold)
RUN_USER = ixvii0iv
RUN_MODE = prod
WORK_PATH = $HOT_DIR
[server]
PROTOCOL = http
DOMAIN = git.961504.xyz
ROOT_URL = https://git.961504.xyz/
HTTP_ADDR = 0.0.0.0
HTTP_PORT = 3005
DISABLE_SSH = false
START_SSH_SERVER = true
SSH_DOMAIN = 192.168.31.164
SSH_PORT = 2222
SSH_LISTEN_PORT = 2222
LFS_START_SERVER = true
LFS_JWT_SECRET = $LFS_JWT_SECRET
OFFLINE_MODE = false
[database]
DB_TYPE = sqlite3
PATH = $HOT_DIR/data/gitea.db
[repository]
ROOT = $HOT_DIR/data/repositories
DEFAULT_BRANCH = main
[lfs]
STORAGE_TYPE = local
PATH = $HOT_DIR/data/lfs
[attachment]
STORAGE_TYPE = local
PATH = $HOT_DIR/data/attachments
MAX_SIZE = 2048
MAX_FILES = 50
[packages]
STORAGE_TYPE = local
PATH = $HOT_DIR/data/packages
[repo-archive]
STORAGE_TYPE = local
PATH = $HOT_DIR/data/repo-archive
[security]
INSTALL_LOCK = true
SECRET_KEY = $SECRET_KEY
INTERNAL_TOKEN = $INTERNAL_TOKEN
PASSWORD_HASH_ALGO = pbkdf2
REVERSE_PROXY_TRUSTED_PROXIES = 127.0.0.0/8,100.64.0.0/10,192.168.0.0/16,::1/128
[oauth2]
JWT_SECRET = $JWT_SECRET
[service]
DISABLE_REGISTRATION = true
REQUIRE_SIGNIN_VIEW = false
ENABLE_NOTIFY_MAIL = false
ENABLE_REVERSE_PROXY_AUTHENTICATION = true
ENABLE_REVERSE_PROXY_AUTO_REGISTRATION = true
REVERSE_PROXY_AUTHENTICATION_USER = X-Webauth-User
[log]
MODE = console
LEVEL = Info
ROOT_PATH = $HOT_DIR/log
EOF
fi
exec /opt/homebrew/opt/gitea/bin/gitea web --work-path "$HOT_DIR" --custom-path "$HOT_DIR/custom" --config "$CONF_FILE"
+39
View File
@@ -0,0 +1,39 @@
#!/usr/bin/env bash
# ==============================================================================
# sync-agy-session.sh
# 用途:将本机 (MacBook Air) 当前的 AGY 会话上下文与 GEMINI.md 永久记忆同步至
# Mac mini 云端工作台 (https://agy.961504.xyz),实现网页端无缝接力
# ==============================================================================
set -euo pipefail
SESSION_ID="${1:-6293ebb4-df60-4db6-bf5d-df33433ba106}"
MINI_HOST="${MINI_HOST:-mini}"
AGY_DIR="$HOME/.gemini/antigravity-cli"
echo "==> [1/4] 同步全局永久记忆文件 GEMINI.md 至本机与 Mac mini..."
cp /Users/ixvii0iv/homelab/GEMINI.md "$HOME/GEMINI.md"
ssh "$MINI_HOST" "mkdir -p ~/homelab ~/workspace ~/.gemini/antigravity-cli/conversations ~/.gemini/antigravity-cli/brain"
rsync -az /Users/ixvii0iv/homelab/GEMINI.md "$MINI_HOST:~/GEMINI.md"
rsync -az /Users/ixvii0iv/homelab/GEMINI.md "$MINI_HOST:~/homelab/GEMINI.md"
rsync -az /Users/ixvii0iv/homelab/GEMINI.md "$MINI_HOST:~/workspace/GEMINI.md"
echo "==> [2/4] 同步会话索引库与历史记录 (conversation_summaries.db / history.jsonl)..."
for f in conversation_summaries.db conversation_summaries.db-wal conversation_summaries.db-shm jetbox_summaries_proto.pb history.jsonl; do
if [ -f "$AGY_DIR/$f" ]; then
rsync -az "$AGY_DIR/$f" "$MINI_HOST:~/.gemini/antigravity-cli/$f"
fi
done
echo "==> [3/4] 同步当前会话数据库与 Brain 上下文 ($SESSION_ID)..."
rsync -az "$AGY_DIR/conversations/${SESSION_ID}".db* "$MINI_HOST:~/.gemini/antigravity-cli/conversations/"
if [ -d "$AGY_DIR/brain/${SESSION_ID}" ]; then
rsync -az "$AGY_DIR/brain/${SESSION_ID}/" "$MINI_HOST:~/.gemini/antigravity-cli/brain/${SESSION_ID}/"
fi
echo "==> [4/4] 在 Mac mini 的 tmux (agy) 会话中配置一键恢复别名..."
ssh "$MINI_HOST" "grep -q 'alias agy-resume=' ~/.zshrc 2>/dev/null || echo \"alias agy-resume='agy --conversation ${SESSION_ID}'\" >> ~/.zshrc"
echo "✅ 同步完成!"
echo " • 网页端工作台地址: https://agy.961504.xyz (或在中控台右上角点击 [>_ AGY 终端])"
echo " • 恢复当前完整会话: agy --conversation ${SESSION_ID} (或直接输入: agy-resume / agy -c)"
echo " • 开启新会话(自带完整架构记忆): agy"
+36
View File
@@ -0,0 +1,36 @@
#!/usr/bin/env bash
# ==============================================================================
# sync-git-to-nas.sh — 将 Mac mini 上的 Gitea 热层数据与大文件同步归档至极空间 Z4 20TB NAS
# ==============================================================================
set -euo pipefail
HOT_DIR="/Users/ixvii0iv/homelab/stacks/git/data"
COLD_DIR="/Users/ixvii0iv/nas/个人空间/Git_Cold_Storage"
if [ ! -d "/Users/ixvii0iv/nas/个人空间" ]; then
echo "[$(date '+%Y-%m-%d %H:%M:%S')] WARN: 极空间 NAS 未挂载,跳过冷存储同步"
exit 0
fi
mkdir -p "$COLD_DIR/lfs" "$COLD_DIR/attachments" "$COLD_DIR/packages" "$COLD_DIR/repo-archive" "$COLD_DIR/bundles" "$COLD_DIR/db"
# 1. 增量同步 LFS 大文件、Release 附件、制品包与归档
rsync -a "$HOT_DIR/lfs/" "$COLD_DIR/lfs/"
rsync -a "$HOT_DIR/attachments/" "$COLD_DIR/attachments/"
rsync -a "$HOT_DIR/packages/" "$COLD_DIR/packages/"
rsync -a "$HOT_DIR/repo-archive/" "$COLD_DIR/repo-archive/"
# 2. 将所有 Git 裸仓库打包为独立自包含的 .bundle 冷备文件(任何时候可直接 git clone 恢复)
if [ -d "$HOT_DIR/repositories" ]; then
find "$HOT_DIR/repositories" -maxdepth 2 -type d -name "*.git" | while read -r repo; do
rel_name=$(echo "${repo#$HOT_DIR/repositories/}" | tr '/' '_')
/usr/bin/git -C "$repo" bundle create "$COLD_DIR/bundles/${rel_name%.git}.bundle" --all >/dev/null 2>&1 || true
done
fi
# 3. 备份 Gitea SQLite 元数据快照
if [ -f "$HOT_DIR/gitea.db" ]; then
/usr/bin/sqlite3 "$HOT_DIR/gitea.db" ".backup '$COLD_DIR/db/gitea-latest.db'" 2>/dev/null || cp "$HOT_DIR/gitea.db" "$COLD_DIR/db/gitea-latest.db"
fi
echo "[$(date '+%Y-%m-%d %H:%M:%S')] OK: Git 热层仓库与 LFS 大文件已同步归档至极空间 ($COLD_DIR)"
+28 -3
View File
@@ -10,7 +10,6 @@ status.961504.xyz:443 {
}
# 2. 家庭统一导航总入口免端口 443 访问 -> https://nav.961504.xyz
# (通过 Tailscale 加密专线回源至家里 Mac mini 的 Sun-Panel)
nav.961504.xyz:443 {
encode zstd gzip
reverse_proxy 100.108.205.40:3002
@@ -23,10 +22,11 @@ wx.961504.xyz:443 {
}
# 4. AGY 24小时云端 Web 工作台 (443 免端口访问) -> https://agy.961504.xyz
# 使用 forward_auth 对接 HomeLab SSO 2.0 自定义登录卡片,彻底告别浏览器原生丑陋弹窗!
agy.961504.xyz:443 {
encode zstd gzip
basicauth /* {
ixvii0iv $2a$14$VaaNvgIjsCJFzXCpIxjcOerKUIdkN7GhVWAlGh3/ZZjBOADY9lg..
forward_auth 100.108.205.40:3002 {
uri /api/auth/verify
}
reverse_proxy 100.108.205.40:7681
}
@@ -38,3 +38,28 @@ vw.961504.xyz:443 {
header_up X-Real-IP {remote_host}
}
}
# 6. Codeg 多智能体协同工作台底座 (443 免端口访问) -> https://codeg.961504.xyz
codeg.961504.xyz:443 {
encode zstd gzip
forward_auth 100.108.205.40:3002 {
uri /api/auth/verify
}
reverse_proxy 100.108.205.40:3080 {
header_up Authorization "Bearer homelab2026"
}
}
# 7. Gitea 私有代码托管中枢 (443 免端口访问) -> https://git.961504.xyz
git.961504.xyz:443 {
encode zstd gzip
@browser {
header Accept *text/html*
}
forward_auth @browser 100.108.205.40:3002 {
uri /api/auth/verify
}
reverse_proxy 100.108.205.40:3005 {
header_up X-Webauth-User admin
}
}
+43 -8
View File
@@ -8,7 +8,7 @@
auto_https disable_redirects
}
# 0. 家庭统一导航总入口 Sun-Panel (https://home.961504.xyz:10443)
# 0. 家庭统一导航总入口 HomeLab Console V3 (https://home.961504.xyz:10443)
home.961504.xyz:10443, nav.home.961504.xyz:10443 {
tls /Users/ixvii0iv/homelab/stacks/gateway/certs/fullchain.pem /Users/ixvii0iv/homelab/stacks/gateway/certs/privkey.pem
encode zstd gzip
@@ -22,12 +22,12 @@ nas.home.961504.xyz:10443, z4.home.961504.xyz:10443 {
reverse_proxy 192.168.31.185:5055
}
# 2. 路由器 Mihomo / Clash 面板 (已开启 HTTP Basic Auth 密码锁保护: ixvii0iv / Mac mini密码)
# 2. 路由器 Mihomo / Clash 面板 (通过 HomeLab SSO 2.0 自定义登录卡片保护)
clash.home.961504.xyz:10443 {
tls /Users/ixvii0iv/homelab/stacks/gateway/certs/fullchain.pem /Users/ixvii0iv/homelab/stacks/gateway/certs/privkey.pem
encode zstd gzip
basic_auth {
ixvii0iv $2a$14$VaaNvgIjsCJFzXCpIxjcOerKUIdkN7GhVWAlGh3/ZZjBOADY9lg..
forward_auth 127.0.0.1:3002 {
uri /api/auth/verify
}
reverse_proxy 192.168.31.1:9999
}
@@ -36,8 +36,8 @@ clash.home.961504.xyz:10443 {
router.home.961504.xyz:10443 {
tls /Users/ixvii0iv/homelab/stacks/gateway/certs/fullchain.pem /Users/ixvii0iv/homelab/stacks/gateway/certs/privkey.pem
encode zstd gzip
basic_auth {
ixvii0iv $2a$14$VaaNvgIjsCJFzXCpIxjcOerKUIdkN7GhVWAlGh3/ZZjBOADY9lg..
forward_auth 127.0.0.1:3002 {
uri /api/auth/verify
}
reverse_proxy 192.168.31.1:80
}
@@ -50,11 +50,12 @@ lucky.home.961504.xyz:10443 {
}
# 5. AGY 24小时云端 Web 工作台 (https://agy.home.961504.xyz:10443)
# 使用 forward_auth 对接 HomeLab SSO 2.0 自定义登录卡片,彻底告别浏览器原生丑陋弹窗!
agy.home.961504.xyz:10443 {
tls /Users/ixvii0iv/homelab/stacks/gateway/certs/fullchain.pem /Users/ixvii0iv/homelab/stacks/gateway/certs/privkey.pem
encode zstd gzip
basic_auth {
ixvii0iv $2a$14$VaaNvgIjsCJFzXCpIxjcOerKUIdkN7GhVWAlGh3/ZZjBOADY9lg..
forward_auth 127.0.0.1:3002 {
uri /api/auth/verify
}
reverse_proxy 127.0.0.1:7681
}
@@ -67,3 +68,37 @@ vw.home.961504.xyz:10443 {
header_up X-Real-IP {remote_host}
}
}
# 7. Uptime Kuma 外部独立监控哨兵 (https://status.home.961504.xyz:10443)
status.home.961504.xyz:10443 {
tls /Users/ixvii0iv/homelab/stacks/gateway/certs/fullchain.pem /Users/ixvii0iv/homelab/stacks/gateway/certs/privkey.pem
encode zstd gzip
reverse_proxy 100.115.204.51:3001
}
# 8. Codeg 多智能体协同工作台底座 (https://codeg.home.961504.xyz:10443)
codeg.home.961504.xyz:10443 {
tls /Users/ixvii0iv/homelab/stacks/gateway/certs/fullchain.pem /Users/ixvii0iv/homelab/stacks/gateway/certs/privkey.pem
encode zstd gzip
forward_auth 127.0.0.1:3002 {
uri /api/auth/verify
}
reverse_proxy 127.0.0.1:3080 {
header_up Authorization "Bearer homelab2026"
}
}
# 9. Gitea 私有代码托管中枢 (https://git.home.961504.xyz:10443)
git.home.961504.xyz:10443 {
tls /Users/ixvii0iv/homelab/stacks/gateway/certs/fullchain.pem /Users/ixvii0iv/homelab/stacks/gateway/certs/privkey.pem
encode zstd gzip
@browser {
header Accept *text/html*
}
forward_auth @browser 127.0.0.1:3002 {
uri /api/auth/verify
}
reverse_proxy 127.0.0.1:3005 {
header_up X-Webauth-User admin
}
}
+629
View File
@@ -0,0 +1,629 @@
#!/usr/bin/env python3
# ==============================================================================
# HomeLab WeChat ClawBot Engine (Tencent iLink Bot Protocol v2.4.10)
# 100% 兼容 @tencent-weixin/openclaw-weixin 官方协议 (ilinkai.weixin.qq.com)
# 支持:
# 1. 生成微信 ClawBot 绑定二维码 & 轮询扫码状态 (支持 IDC 重定向与配对验证码)
# 2. 7x24h 长轮询接收微信消息 (getupdates) + "正在输入..." 状态推送 (sendtyping)
# 3. 微信内直接查询 Mac mini / 极空间状态、触发备份、执行终端指令、与 AGY AI 对话
# ==============================================================================
import base64
import json
import os
import random
import re
import subprocess
import threading
import time
import urllib.error
import urllib.parse
import urllib.request
import uuid
from pathlib import Path
FIXED_BASE_URL = "https://ilinkai.weixin.qq.com"
DEFAULT_BOT_TYPE = "3"
CHANNEL_VERSION = "2.4.10"
# (2 << 16) | (4 << 8) | 10 = 132106
ILINK_CLIENT_VERSION = str((2 << 16) | (4 << 8) | 10)
ILINK_APP_ID = "bot"
STATE_DIR = Path("/Users/ixvii0iv/homelab/stacks/wechat-bot")
SESSION_FILE = STATE_DIR / "clawbot_session.json"
SYNC_BUF_FILE = STATE_DIR / "clawbot_sync_buf.txt"
DEFAULT_AGY_CONV_ID = "6293ebb4-df60-4db6-bf5d-df33433ba106"
def _random_wechat_uin() -> str:
uint32 = random.randint(10000000, 0xFFFFFFFF)
return base64.b64encode(str(uint32).encode("utf-8")).decode("utf-8")
def _common_headers() -> dict:
return {
"iLink-App-Id": ILINK_APP_ID,
"iLink-App-ClientVersion": ILINK_CLIENT_VERSION,
}
def _post_headers(token: str = "") -> dict:
h = {
"Content-Type": "application/json",
"AuthorizationType": "ilink_bot_token",
"X-WECHAT-UIN": _random_wechat_uin(),
**_common_headers(),
}
if token.strip():
h["Authorization"] = f"Bearer {token.strip()}"
return h
def _base_info() -> dict:
return {
"channel_version": CHANNEL_VERSION,
"bot_agent": "OpenClaw",
}
def _parse_lossless_json(raw_text: str) -> dict:
# Quote uint64 message_id / msg_id / svr_id fields so JS/Python keeps exact strings
patched = re.sub(
r'("(?:message_id|msg_id|svr_id)"\s*:\s*)(-?\d+)',
r'\1"\2"',
raw_text,
)
return json.loads(patched)
def _api_get(base_url: str, endpoint: str, timeout: float = 36.0) -> dict:
url = base_url.rstrip("/") + "/" + endpoint.lstrip("/")
req = urllib.request.Request(url, headers=_common_headers(), method="GET")
with urllib.request.urlopen(req, timeout=timeout) as resp:
raw = resp.read().decode("utf-8")
return _parse_lossless_json(raw)
def _api_post(base_url: str, endpoint: str, payload: dict, token: str = "", timeout: float = 20.0) -> dict:
url = base_url.rstrip("/") + "/" + endpoint.lstrip("/")
data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
req = urllib.request.Request(url, data=data, headers=_post_headers(token), method="POST")
with urllib.request.urlopen(req, timeout=timeout) as resp:
raw = resp.read().decode("utf-8")
return _parse_lossless_json(raw)
class ClawBotManager:
def __init__(self, telemetry_fn=None, action_runner_fn=None):
STATE_DIR.mkdir(parents=True, exist_ok=True)
self.telemetry_fn = telemetry_fn
self.action_runner_fn = action_runner_fn
self._lock = threading.Lock()
# Saved session credentials
self.session = self._load_session()
# Active QR login state
self.login_state = {
"active": False,
"qrcode": "",
"qrcode_url": "",
"status": "idle", # idle | wait | scaned | need_verifycode | confirmed | expired | error
"message": "尚未发起扫码登录",
"api_base_url": FIXED_BASE_URL,
"pending_verify_code": "",
"started_at": 0.0,
}
# Runtime monitor stats
self.last_event_ts = 0.0
self.last_inbound_text = ""
self.last_reply_text = ""
self.msg_count = 0
self._stop_event = threading.Event()
self._monitor_thread = None
if self.is_connected():
self._start_monitor_thread()
def _load_session(self) -> dict:
if SESSION_FILE.exists():
try:
return json.loads(SESSION_FILE.read_text(encoding="utf-8"))
except Exception:
pass
return {}
def _save_session(self, data: dict):
self.session = data
SESSION_FILE.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
def is_connected(self) -> bool:
return bool(self.session.get("bot_token") and self.session.get("ilink_bot_id"))
def get_public_status(self) -> dict:
return {
"connected": self.is_connected(),
"bot_id": self.session.get("ilink_bot_id", ""),
"user_id": self.session.get("ilink_user_id", ""),
"base_url": self.session.get("baseurl", FIXED_BASE_URL),
"connected_at": self.session.get("connected_at", ""),
"msg_count": self.msg_count,
"last_inbound_text": self.last_inbound_text,
"last_reply_text": self.last_reply_text,
"login": {
"active": self.login_state["active"],
"qrcode_url": self.login_state["qrcode_url"],
"status": self.login_state["status"],
"message": self.login_state["message"],
},
}
# ------------------------------------------------------------------
# 1. QR Code Login Flow
# ------------------------------------------------------------------
def start_qr_login(self) -> dict:
try:
local_tokens = [self.session["bot_token"]] if self.session.get("bot_token") else []
resp = _api_post(
FIXED_BASE_URL,
f"ilink/bot/get_bot_qrcode?bot_type={DEFAULT_BOT_TYPE}",
{"local_token_list": local_tokens},
timeout=12.0,
)
qrcode = resp.get("qrcode", "")
qrcode_url = resp.get("qrcode_img_content", "")
if not qrcode or not qrcode_url:
raise RuntimeError(f"未获取到二维码: {resp}")
with self._lock:
self.login_state = {
"active": True,
"qrcode": qrcode,
"qrcode_url": qrcode_url,
"status": "wait",
"message": "请使用手机微信扫描二维码(或在微信 ClawBot 页面点击「开始扫一扫」)",
"api_base_url": FIXED_BASE_URL,
"pending_verify_code": "",
"started_at": time.time(),
}
t = threading.Thread(target=self._poll_qr_worker, args=(qrcode,), daemon=True)
t.start()
return {"ok": True, **self.get_public_status()}
except Exception as e:
with self._lock:
self.login_state["status"] = "error"
self.login_state["message"] = f"获取二维码失败: {e}"
return {"ok": False, "message": str(e)}
def submit_verify_code(self, code: str) -> dict:
clean = code.strip()
if not clean:
return {"ok": False, "message": "请输入有效的数字配对码"}
with self._lock:
self.login_state["pending_verify_code"] = clean
self.login_state["message"] = f"已提交配对码 {clean},正在向微信服务器验证..."
return {"ok": True, **self.get_public_status()}
def disconnect(self) -> dict:
self._stop_event.set()
if SESSION_FILE.exists():
SESSION_FILE.unlink()
if SYNC_BUF_FILE.exists():
SYNC_BUF_FILE.unlink()
self.session = {}
with self._lock:
self.login_state["active"] = False
self.login_state["status"] = "idle"
self.login_state["message"] = "已断开微信 ClawBot 连接"
return {"ok": True, **self.get_public_status()}
def _poll_qr_worker(self, initial_qrcode: str):
deadline = time.time() + 300.0 # 5 minutes
while time.time() < deadline:
with self._lock:
if not self.login_state["active"] or self.login_state["qrcode"] != initial_qrcode:
return
base_url = self.login_state["api_base_url"]
qrcode = self.login_state["qrcode"]
verify_code = self.login_state["pending_verify_code"]
endpoint = f"ilink/bot/get_qrcode_status?qrcode={urllib.parse.quote(qrcode)}"
if verify_code:
endpoint += f"&verify_code={urllib.parse.quote(verify_code)}"
try:
resp = _api_get(base_url, endpoint, timeout=35.0)
except Exception:
resp = {"status": "wait"}
st = resp.get("status", "wait")
with self._lock:
if self.login_state["qrcode"] != initial_qrcode:
return
self.login_state["status"] = st
if st == "wait":
pass
elif st == "scaned":
self.login_state["pending_verify_code"] = ""
self.login_state["message"] = "📱 已扫码!请在手机微信上点击确认授权..."
elif st == "scaned_but_redirect":
r_host = resp.get("redirect_host", "")
if r_host:
self.login_state["api_base_url"] = f"https://{r_host}"
self.login_state["message"] = "📱 已扫码,正在切换微信数据中心节点..."
elif st == "need_verifycode":
if self.login_state["pending_verify_code"]:
self.login_state["message"] = "❌ 配对码不匹配,请重新输入手机微信上显示的数字:"
self.login_state["pending_verify_code"] = ""
else:
self.login_state["message"] = "🔢 请输入手机微信屏幕上显示的配对数字:"
elif st == "binded_redirect":
self.login_state["active"] = False
self.login_state["message"] = "✅ 此微信已绑定过当前节点!"
return
elif st in ("expired", "verify_code_blocked"):
self.login_state["active"] = False
self.login_state["message"] = "⏳ 二维码已过期,请点击「刷新二维码」重新扫描"
return
elif st == "confirmed":
bot_token = resp.get("bot_token", "")
bot_id = resp.get("ilink_bot_id", "")
baseurl = resp.get("baseurl") or FIXED_BASE_URL
user_id = resp.get("ilink_user_id", "")
self._save_session(
{
"bot_token": bot_token,
"ilink_bot_id": bot_id,
"baseurl": baseurl,
"ilink_user_id": user_id,
"connected_at": time.strftime("%Y-%m-%d %H:%M:%S"),
}
)
self.login_state["active"] = False
self.login_state["message"] = "🎉 微信 ClawBot 绑定成功!现在可以直接在微信里收发消息了"
self._start_monitor_thread()
# Send immediate welcome message to the user on WeChat!
if user_id:
threading.Thread(
target=self.send_text_message,
args=(
user_id,
"✅ M6 Mac mini (HomeLab & AGY AI) 已成功连接微信 ClawBot!\n\n"
"你可以直接发送以下内容与我交互:\n"
"• 发送「状态」:查看 M6 Mac mini 与极空间 Z4 实时遥测\n"
"• 发送「备份」:立即打包备份核心数据至极空间 20TB\n"
"• 发送「屏幕」:查看 AGY 云端终端当前实时画面\n"
"• 发送「$ 命令」:在 Mac mini 上执行终端命令 (如 $ df -h)\n"
"• 直接发任何问题:直接转发给 Mac mini 上的 AI 助手回答",
),
daemon=True,
).start()
return
time.sleep(1.0)
# ------------------------------------------------------------------
# 2. Long-Poll Monitor & Outbound Messaging
# ------------------------------------------------------------------
def _start_monitor_thread(self):
if self._monitor_thread and self._monitor_thread.is_alive():
return
self._stop_event.clear()
self._monitor_thread = threading.Thread(target=self._monitor_loop, daemon=True)
self._monitor_thread.start()
def send_text_message(self, to_user_id: str, text: str, context_token: str = "", run_id: str = ""):
if not self.is_connected() or not to_user_id or not text:
return
base_url = self.session.get("baseurl") or FIXED_BASE_URL
token = self.session.get("bot_token", "")
client_id = f"openclaw-weixin-{uuid.uuid4().hex[:12]}"
msg_obj = {
"from_user_id": "",
"to_user_id": to_user_id,
"client_id": client_id,
"message_type": 2, # BOT
"message_state": 2, # FINISH
"item_list": [{"type": 1, "text_item": {"text": text}}],
}
if context_token:
msg_obj["context_token"] = context_token
if run_id:
msg_obj["run_id"] = run_id
payload = {
"msg": msg_obj,
"base_info": _base_info(),
}
try:
_api_post(base_url, "ilink/bot/sendmessage", payload, token=token, timeout=15.0)
self.last_reply_text = text[:120]
except Exception as e:
print(f"[ClawBot] send_text_message error: {e}")
def _send_typing(self, to_user_id: str, context_token: str = ""):
if not self.is_connected() or not to_user_id:
return
base_url = self.session.get("baseurl") or FIXED_BASE_URL
token = self.session.get("bot_token", "")
try:
cfg = _api_post(
base_url,
"ilink/bot/getconfig",
{
"ilink_user_id": to_user_id,
"context_token": context_token,
"base_info": _base_info(),
},
token=token,
timeout=8.0,
)
ticket = cfg.get("typing_ticket", "")
if ticket:
_api_post(
base_url,
"ilink/bot/sendtyping",
{
"ilink_user_id": to_user_id,
"typing_ticket": ticket,
"status": 1,
"base_info": _base_info(),
},
token=token,
timeout=8.0,
)
except Exception:
pass
def _monitor_loop(self):
base_url = self.session.get("baseurl") or FIXED_BASE_URL
token = self.session.get("bot_token", "")
try:
_api_post(
base_url,
"ilink/bot/msg/notifystart",
{"base_info": _base_info()},
token=token,
timeout=10.0,
)
except Exception:
pass
sync_buf = ""
if SYNC_BUF_FILE.exists():
try:
sync_buf = SYNC_BUF_FILE.read_text(encoding="utf-8").strip()
except Exception:
pass
while not self._stop_event.is_set() and self.is_connected():
base_url = self.session.get("baseurl") or FIXED_BASE_URL
token = self.session.get("bot_token", "")
try:
resp = _api_post(
base_url,
"ilink/bot/getupdates",
{"get_updates_buf": sync_buf, "base_info": _base_info()},
token=token,
timeout=38.0,
)
new_buf = resp.get("get_updates_buf")
if new_buf:
sync_buf = new_buf
SYNC_BUF_FILE.write_text(sync_buf, encoding="utf-8")
msgs = resp.get("msgs") or []
for m in msgs:
# Only handle USER messages (message_type == 1)
if m.get("message_type") == 2:
continue
from_user = m.get("from_user_id", "")
ctx_token = m.get("context_token", "")
run_id = m.get("run_id", "")
items = m.get("item_list") or []
text_parts = []
for it in items:
t_type = it.get("type")
if t_type == 1 and it.get("text_item", {}).get("text"):
text_parts.append(it["text_item"]["text"])
elif t_type == 3 and it.get("voice_item", {}).get("text"):
text_parts.append(it["voice_item"]["text"])
user_text = "\n".join(text_parts).strip()
if from_user and user_text:
self.msg_count += 1
self.last_inbound_text = user_text
self.last_event_ts = time.time()
threading.Thread(
target=self._handle_inbound_message,
args=(from_user, user_text, ctx_token, run_id),
daemon=True,
).start()
except urllib.error.URLError as e:
# Normal long-poll timeout
if "timed out" in str(e).lower():
continue
time.sleep(2.0)
except TimeoutError:
continue
except Exception as e:
if "timed out" in str(e).lower():
continue
print(f"[ClawBot] getupdates error: {e}")
time.sleep(3.0)
# ------------------------------------------------------------------
# 3. Intelligent Command & AI Router for WeChat Messages
# ------------------------------------------------------------------
def _handle_inbound_message(self, from_user: str, text: str, ctx_token: str, run_id: str):
self._send_typing(from_user, ctx_token)
cmd = text.strip()
low = cmd.lower()
# 1. Help
if low in ("帮助", "help", "/help", "?"):
reply = (
"🍎 HomeLab & AGY 微信中控台指令清单:\n"
"━━━━━━━━━━━━━━\n"
"1️⃣ 发送「状态」或 /status\n"
" 查看 M6 Mac mini、极空间 20TB、联通公网 IP 与狮城专线实时指标\n"
"2️⃣ 发送「备份」或 /backup\n"
" 立即将 Vaultwarden 与核心配置打包灾备至极空间 Z4\n"
"3️⃣ 发送「刷新」或 /ddns\n"
" 立即刷新 Cloudflare 泛域名 DDNS 解析\n"
"4️⃣ 发送「屏幕」或 /screen\n"
" 抓取 Mac mini 上 tmux (agy) 终端的实时画面\n"
"5️⃣ 发送「$ <终端命令>」\n"
" 在 Mac mini 上直接运行 Shell 命令并返回结果 (如: $ df -h)\n"
"6️⃣ 发送「> <按键内容>」\n"
" 直接向 Mac mini 的 tmux (agy) 终端输入文字并回车\n"
"7️⃣ 直接发送任意问题\n"
" 自动调用 Mac mini AI 助手为你解答或执行任务"
)
self.send_text_message(from_user, reply, ctx_token, run_id)
return
# 2. Telemetry Status
if low in ("状态", "status", "/status", "监控"):
if self.telemetry_fn:
t = self.telemetry_fn()
mini = t["mini"]
nas = t["nas"]
net = t["network"]
reply = (
f"🍏 HomeLab 实时状态 ({t['timestamp']})\n"
f"━━━━━━━━━━━━━━\n"
f"🖥 M6 Mac mini ({mini['ip']})\n"
f"• CPU 负载: {mini['cpu_pct']}% (1m: {mini['load_1m']})\n"
f"• 内存占用: {mini['mem_used_gb']} / {mini['mem_total_gb']} GB ({mini['mem_pct']}%)\n"
f"• SSD 占用: {mini['disk_used_gb']} / {mini['disk_total_gb']} GB ({mini['disk_pct']}%)\n"
f"• 在线时长: {mini['uptime']}\n"
f"• AGY 终端: {'🟢 常驻运行中' if mini['agy_tmux'] else '⚪ 待机'}\n\n"
f"💾 极空间 Z4 ({nas['ip']})\n"
f"• 可用余量: {nas['free_tb']} TB / {nas['total_tb']} TB\n"
f"• 最近灾备: {nas['latest_backup']}\n\n"
f"🌐 网络与 AI 算力专线\n"
f"• 家庭公网 IP: {net['home_wan_ip']}\n"
f"• AI 分流策略: {net['ai_policy']}\n"
f"• 狮城节点延迟: {net['sg_delay_ms']} ms\n"
f"• 免端口中控台: https://nav.961504.xyz"
)
else:
reply = "状态采集接口未就绪"
self.send_text_message(from_user, reply, ctx_token, run_id)
return
# 3. Backup to ZSpace NAS
if low in ("备份", "backup", "/backup"):
out = self._exec_shell("/Users/ixvii0iv/homelab/scripts/backup-to-nas.sh", timeout=20.0)
self.send_text_message(from_user, f"✅ 极空间 Z4 灾备执行完毕:\n{out or '已完成备份'}", ctx_token, run_id)
return
# 4. Refresh DDNS
if low in ("刷新", "ddns", "/ddns"):
out = self._exec_shell("/Users/ixvii0iv/homelab/scripts/cloudflare-ddns.sh", timeout=15.0)
self.send_text_message(from_user, f"✅ Cloudflare DDNS 刷新结果:\n{out or '已同步最新公网 IP'}", ctx_token, run_id)
return
# 5. Capture tmux screen
if low in ("屏幕", "screen", "/screen", "tmux"):
out = self._exec_shell("/opt/homebrew/bin/tmux capture-pane -t agy -p | tail -n 25", timeout=5.0)
self.send_text_message(
from_user,
f"🖥 AGY 终端 (tmux: agy) 最新画面:\n━━━━━━━━━━━━━━\n{out or '(空)'}",
ctx_token,
run_id,
)
return
# 6. Direct Shell Command ($ ...)
if cmd.startswith("$"):
shell_cmd = cmd[1:].strip()
if not shell_cmd:
self.send_text_message(from_user, "请在 $ 后输入要执行的命令,例如:$ df -h", ctx_token, run_id)
return
out = self._exec_shell(shell_cmd, timeout=25.0)
if len(out) > 3500:
out = out[-3500:] + "\n...(已截断)"
self.send_text_message(from_user, f"💻 终端执行结果 ($ {shell_cmd}):\n━━━━━━━━━━━━━━\n{out or '(无输出)'}", ctx_token, run_id)
return
# 7. Inject keys into tmux agy session (> ...)
if cmd.startswith(">"):
keys = cmd[1:].strip()
safe_keys = keys.replace('"', '\\"')
self._exec_shell(f'/opt/homebrew/bin/tmux send-keys -t agy "{safe_keys}" C-m', timeout=5.0)
time.sleep(2.5)
out = self._exec_shell("/opt/homebrew/bin/tmux capture-pane -t agy -p | tail -n 25", timeout=5.0)
self.send_text_message(
from_user,
f"⌨️ 已向 AGY 终端发送指令并捕获最新画面:\n━━━━━━━━━━━━━━\n{out or '(已发送)'}",
ctx_token,
run_id,
)
return
# 7b. One-step Google OAuth Code injection from WeChat (e.g. "4/0A..." or "授权 4/0A...")
if cmd.startswith("4/") or low.startswith("授权 ") or low.startswith("auth "):
auth_code = cmd.split(None, 1)[-1].strip() if (" " in cmd) else cmd.strip()
safe_code = auth_code.replace('"', '\\"')
self._exec_shell(f'/opt/homebrew/bin/tmux send-keys -t agy "{safe_code}" C-m', timeout=5.0)
time.sleep(5.0)
out = self._exec_shell("/opt/homebrew/bin/tmux capture-pane -t agy -p | tail -n 20", timeout=5.0)
self.send_text_message(
from_user,
f"🔐 已将 Google 授权码注入 Mac mini 的 AGY 终端!\n"
f"已挂载当前会话 ID: {DEFAULT_AGY_CONV_ID}\n"
f"━━━━━━━━━━━━━━\n{out or '(授权处理中)'}\n\n"
f"现在你可以直接在微信里和我继续刚才的对话了!",
ctx_token,
run_id,
)
return
# 8. Natural language -> Resume exact migrated session (6293ebb4-df60-4db6-bf5d-df33433ba106) on Mac mini!
agy_out = self._exec_shell(
f'cd /Users/ixvii0iv/homelab && /Users/ixvii0iv/.local/bin/agy --conversation {DEFAULT_AGY_CONV_ID} --dangerously-skip-permissions -p {json.dumps(cmd)}',
timeout=120.0,
)
if agy_out and "authentication required" not in agy_out.lower():
if len(agy_out) > 3800:
agy_out = agy_out[:3800] + "\n...(内容较长,完整输出请查看 https://agy.961504.xyz)"
self.send_text_message(from_user, agy_out, ctx_token, run_id)
else:
oauth_url = (
"https://accounts.google.com/o/oauth2/auth?access_type=offline"
"&client_id=1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com"
"&code_challenge=6R2ML1GiPTslGESIIdY7kBlWPGi3Z7J_SUKiEb-wVc0&code_challenge_method=S256"
"&prompt=consent&redirect_uri=https%3A%2F%2Fantigravity.google%2Foauth-callback"
"&response_type=code&scope=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fcloud-platform+"
"https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fuserinfo.email+"
"https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fuserinfo.profile+"
"https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fcclog+"
"https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fexperimentsandconfigs+"
"https%3A%2F%2Fwww.googleapis.com%2Fauth%2Faicode+openid&state=EHQdZm-HmDWTKGqBFxqf1g"
)
self.send_text_message(
from_user,
f"🤖 当前会话 ({DEFAULT_AGY_CONV_ID[:8]}...) 已同步至 Mac mini!\n\n"
"🔐 只需最后一步激活 Mac mini 的 Google AI 登录态:\n"
f"1️⃣ 点击打开此链接登录 Google:\n{oauth_url}\n\n"
"2️⃣ 复制页面生成的授权码(以 4/ 开头),直接发到这个微信对话框里,即可在微信无缝接力我们当前的完整会话!",
ctx_token,
run_id,
)
def _exec_shell(self, cmd: str, timeout: float = 15.0) -> str:
try:
env = os.environ.copy()
env["PATH"] = "/Users/ixvii0iv/.local/bin:/Users/ixvii0iv/.orbstack/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
env["HOME"] = "/Users/ixvii0iv"
env["LANG"] = "en_US.UTF-8"
res = subprocess.run(
cmd, shell=True, capture_output=True, text=True, timeout=timeout, env=env
)
out = (res.stdout or "") + ("\n" + res.stderr if res.stderr else "")
return out.strip()
except Exception as e:
return f"执行超时或异常: {e}"
+1
View File
@@ -0,0 +1 @@
/*! tailwindcss v4.3.5 | MIT License | https://tailwindcss.com */@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:block))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-space-y-reverse:0;--tw-divide-x-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-duration:initial}}}@layer theme{:root,:host{--font-sans:-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-black:#000;--color-white:#fff;--spacing:.25rem;--container-md:28rem;--container-lg:32rem;--container-xl:36rem;--container-6xl:72rem;--container-7xl:80rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height: 1.5 ;--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--tracking-tight:-.025em;--tracking-wider:.05em;--tracking-widest:.1em;--leading-relaxed:1.625;--radius-md:.375rem;--radius-lg:.5rem;--radius-xl:.75rem;--animate-spin:spin 1s linear infinite;--blur-sm:8px;--blur-md:12px;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;-moz-tab-size:4;tab-size:4;line-height:1.5;font-family:var(--default-font-family,-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring:where(:not(iframe)){outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){Line truncated
+276
View File
@@ -0,0 +1,276 @@
var p0=i=>{throw TypeError(i)};var b0=(i,u,c)=>u.has(i)||p0("Cannot "+c);var St=(i,u,c)=>(b0(i,u,"read from private field"),c?c.call(i):u.get(i)),x0=(i,u,c)=>u.has(i)?p0("Cannot add the same private member more than once"):u instanceof WeakSet?u.add(i):u.set(i,c),cf=(i,u,c,o)=>(b0(i,u,"write to private field"),o?o.call(i,c):u.set(i,c),c);function X2(i,u){for(var c=0;c<u.length;c++){const o=u[c];if(typeof o!="string"&&!Array.isArray(o)){for(const s in o)if(s!=="default"&&!(s in i)){const d=Object.getOwnPropertyDescriptor(o,s);d&&Object.defineProperty(i,s,d.get?d:{enumerable:!0,get:()=>o[s]})}}}return Object.freeze(Object.defineProperty(i,Symbol.toStringTag,{value:"Module"}))}(function(){const u=document.createElement("link").relList;if(u&&u.supports&&u.supports("modulepreload"))return;for(const s of document.querySelectorAll('link[rel="modulepreload"]'))o(s);new MutationObserver(s=>{for(const d of s)if(d.type==="childList")for(const h of d.addedNodes)h.tagName==="LINK"&&h.rel==="modulepreload"&&o(h)}).observe(document,{childList:!0,subtree:!0});function c(s){const d={};return s.integrity&&(d.integrity=s.integrity),s.referrerPolicy&&(d.referrerPolicy=s.referrerPolicy),s.crossOrigin==="use-credentials"?d.credentials="include":s.crossOrigin==="anonymous"?d.credentials="omit":d.credentials="same-origin",d}function o(s){if(s.ep)return;s.ep=!0;const d=c(s);fetch(s.href,d)}})();function gd(i){return i&&i.__esModule&&Object.prototype.hasOwnProperty.call(i,"default")?i.default:i}var sf={exports:{}},fr={};/**
* @license React
* react-jsx-runtime.production.js
*
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/var S0;function Z2(){if(S0)return fr;S0=1;var i=Symbol.for("react.transitional.element"),u=Symbol.for("react.fragment");function c(o,s,d){var h=null;if(d!==void 0&&(h=""+d),s.key!==void 0&&(h=""+s.key),"key"in s){d={};for(var v in s)v!=="key"&&(d[v]=s[v])}else d=s;return s=d.ref,{$$typeof:i,type:o,key:h,ref:s!==void 0?s:null,props:d}}return fr.Fragment=u,fr.jsx=c,fr.jsxs=c,fr}var N0;function K2(){return N0||(N0=1,sf.exports=Z2()),sf.exports}var f=K2(),ff={exports:{}},be={};/**
* @license React
* react.production.js
*
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/var C0;function I2(){if(C0)return be;C0=1;var i=Symbol.for("react.transitional.element"),u=Symbol.for("react.portal"),c=Symbol.for("react.fragment"),o=Symbol.for("react.strict_mode"),s=Symbol.for("react.profiler"),d=Symbol.for("react.consumer"),h=Symbol.for("react.context"),v=Symbol.for("react.forward_ref"),g=Symbol.for("react.suspense"),x=Symbol.for("react.memo"),S=Symbol.for("react.lazy"),p=Symbol.for("react.activity"),N=Symbol.for("react.view_transition"),A=Symbol.iterator;function j(w){return w===null||typeof w!="object"?null:(w=A&&w[A]||w["@@iterator"],typeof w=="function"?w:null)}var z={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},H=Object.assign,q={};function G(w,U,$){this.props=w,this.context=U,this.refs=q,this.updater=$||z}G.prototype.isReactComponent={},G.prototype.setState=function(w,U){if(typeof w!="object"&&typeof w!="function"&&w!=null)throw Error("takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,w,U,"setState")},G.prototype.forceUpdate=function(w){this.updater.enqueueForceUpdate(this,w,"forceUpdate")};function k(){}k.prototype=G.prototype;function V(w,U,$){this.props=w,this.context=U,this.refs=q,this.updater=$||z}var K=V.prototype=new k;K.constructor=V,H(K,G.prototype),K.isPureReactComponent=!0;var _=Array.isArray;function E(){}var R={H:null,A:null,T:null,S:null},Q=Object.prototype.hasOwnProperty;function te(w,U,$){var ee=$.ref;return{$$typeof:i,type:w,key:U,ref:ee!==void 0?ee:null,props:$}}function W(w,U){return te(w.type,U,w.props)}function P(w){return typeof w=="object"&&w!==null&&w.$$typeof===i}function ne(w){var U={"=":"=0",":":"=2"};return"$"+w.replace(/[=:]/g,function($){return U[$]})}var re=/\/+/g;function ge(w,U){return typeof w=="object"&&w!==null&&w.key!=null?ne(""+w.key):U.toString(36)}function F(w){switch(w.status){case"fulfilled":return w.value;case"rejected":throw w.reason;default:switch(typeof w.status=="string"?w.then(E,E):(w.status="pending",w.then(function(U){w.status==="pending"&&(w.status="fulfilled",w.value=U)},function(U){w.status==="pending"&&(w.status="rejected",w.reason=U)})),w.status){case"fulfilled":return w.value;case"rejected":throw w.reason}}throw w}function ae(w,U,$,ee,he){var ie=typeof w;(ie==="undefined"||ie==="boolean")&&(w=null);var Ne=!1;if(w===null)Ne=!0;else switch(ie){case"bigint":case"string":case"number":Ne=!0;break;case"object":switch(w.$$typeof){case i:case u:Ne=!0;break;case S:return Ne=w._init,ae(Ne(w._payload),U,$,ee,he)}}if(Ne)return he=he(w),Ne=ee===""?"."+ge(w,0):ee,_(he)?($="",Ne!=null&&($=Ne.replace(re,"$&/")+"/"),ae(he,U,$,"",function(Ut){return Ut})):he!=null&&(P(he)&&(he=W(he,$+(he.key==null||w&&w.key===he.key?"":(""+he.key).replace(re,"$&/")+"/")+Ne)),U.push(he)),1;Ne=0;var fe=ee===""?".":ee+":";if(_(w))for(var ye=0;ye<w.length;ye++)ee=w[ye],ie=fe+ge(ee,ye),Ne+=ae(ee,U,$,ie,he);else if(ye=j(w),typeof ye=="function")for(w=ye.call(w),ye=0;!(ee=w.next()).done;)ee=ee.value,ie=fe+ge(ee,ye++),Ne+=ae(ee,U,$,ie,he);else if(ie==="object"){if(typeof w.then=="function")return ae(F(w),U,$,ee,he);throw U=String(w),Error("Objects are not valid as a React child (found: "+(U==="[object Object]"?"object with keys {"+Object.keys(w).join(", ")+"}":U)+"). If you meant to render a collection of children, use an array instead.")}return Ne}function ce(w,U,$){if(w==null)return w;var ee=[],he=0;return ae(w,ee,"","",function(ie){return U.call($,ie,he++)}),ee}function xe(w){if(w._status===-1){var U=w._result,$=U();$.then(function(ee){(w._status===0||w._status===-1)&&(w._status=1,w._result=ee,$.status===void 0&&($.status="fulfilled",$.value=ee))},function(ee){(w._status===0||w._status===-1)&&(w._status=2,w._result=ee,$.status===void 0&&($.status="rejected",$.reason=ee))}),w._status===-1&&(w._status=0,w._result=$)}if(w._status===1)return w._result.default;throw w._result}var I=typeof reportError=="function"?reportError:function(w){if(typeof window=="object"&&typeof window.ErrorEvent=="function"){var U=new window.ErrorEvent("error",{bubbles:!0,cancelable:!0,message:typeof w=="object"&&w!==null&&typeof w.message=="string"?String(w.message):String(w),error:w});if(!window.dispatchEvent(U))return}else if(typeof process=="object"&&typeof process.emit=="function"){process.emit("uncaughtException",w);return}console.error(w)};function Se(w){var U=R.T,$={};$.types=U!==null?U.types:null,R.T=$;try{var ee=w(),he=R.S;he!==null&&he($,ee),typeof ee=="object"&&ee!==null&&typeof ee.then=="function"&&ee.then(E,I)}catch(ie){I(ie)}finally{U!==null&&$.types!==null&&(U.types=$.types),R.T=U}}function J(w){var U=R.T;if(U!==null){var $=U.types;$===null?U.types=[w]:$.indexOf(w)===-1&&$.push(w)}else Se(J.bind(null,w))}var me={map:ce,forEach:function(w,U,$){ce(w,function(){U.apply(this,arguments)},$)},count:function(w){var U=0;return ce(w,function(){U++}),U},toArray:function(w){return ce(w,function(U){return U})||[]},only:Line truncated
* @license React
* scheduler.production.js
*
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/var w0;function J2(){return w0||(w0=1,(function(i){function u(F,ae){var ce=F.length;F.push(ae);e:for(;0<ce;){var xe=ce-1>>>1,I=F[xe];if(0<s(I,ae))F[xe]=ae,F[ce]=I,ce=xe;else break e}}function c(F){return F.length===0?null:F[0]}function o(F){if(F.length===0)return null;var ae=F[0],ce=F.pop();if(ce!==ae){F[0]=ce;e:for(var xe=0,I=F.length,Se=I>>>1;xe<Se;){var J=2*(xe+1)-1,me=F[J],w=J+1,U=F[w];if(0>s(me,ce))w<I&&0>s(U,me)?(F[xe]=U,F[w]=ce,xe=w):(F[xe]=me,F[J]=ce,xe=J);else if(w<I&&0>s(U,ce))F[xe]=U,F[w]=ce,xe=w;else break e}}return ae}function s(F,ae){var ce=F.sortIndex-ae.sortIndex;return ce!==0?ce:F.id-ae.id}if(i.unstable_now=void 0,typeof performance=="object"&&typeof performance.now=="function"){var d=performance;i.unstable_now=function(){return d.now()}}else{var h=Date,v=h.now();i.unstable_now=function(){return h.now()-v}}var g=[],x=[],S=1,p=null,N=3,A=!1,j=!1,z=!1,H=!1,q=typeof setTimeout=="function"?setTimeout:null,G=typeof clearTimeout=="function"?clearTimeout:null,k=typeof setImmediate<"u"?setImmediate:null;function V(F){for(var ae=c(x);ae!==null;){if(ae.callback===null)o(x);else if(ae.startTime<=F)o(x),ae.sortIndex=ae.expirationTime,u(g,ae);else break;ae=c(x)}}function K(F){if(z=!1,V(F),!j)if(c(g)!==null)j=!0,_||(_=!0,P());else{var ae=c(x);ae!==null&&ge(K,ae.startTime-F)}}var _=!1,E=-1,R=5,Q=-1;function te(){return H?!0:!(i.unstable_now()-Q<R)}function W(){if(H=!1,_){var F=i.unstable_now();Q=F;var ae=!0;try{e:{j=!1,z&&(z=!1,G(E),E=-1),A=!0;var ce=N;try{t:{for(V(F),p=c(g);p!==null&&!(p.expirationTime>F&&te());){var xe=p.callback;if(typeof xe=="function"){p.callback=null,N=p.priorityLevel;var I=xe(p.expirationTime<=F);if(F=i.unstable_now(),typeof I=="function"){p.callback=I,V(F),ae=!0;break t}p===c(g)&&o(g),V(F)}else o(g);p=c(g)}if(p!==null)ae=!0;else{var Se=c(x);Se!==null&&ge(K,Se.startTime-F),ae=!1}}break e}finally{p=null,N=ce,A=!1}ae=void 0}}finally{ae?P():_=!1}}}var P;if(typeof k=="function")P=function(){k(W)};else if(typeof MessageChannel<"u"){var ne=new MessageChannel,re=ne.port2;ne.port1.onmessage=W,P=function(){re.postMessage(null)}}else P=function(){q(W,0)};function ge(F,ae){E=q(function(){F(i.unstable_now())},ae)}i.unstable_IdlePriority=5,i.unstable_ImmediatePriority=1,i.unstable_LowPriority=4,i.unstable_NormalPriority=3,i.unstable_Profiling=null,i.unstable_UserBlockingPriority=2,i.unstable_cancelCallback=function(F){F.callback=null},i.unstable_forceFrameRate=function(F){0>F||125<F?console.error("forceFrameRate takes a positive int between 0 and 125, forcing frame rates higher than 125 fps is not supported"):R=0<F?Math.floor(1e3/F):5},i.unstable_getCurrentPriorityLevel=function(){return N},i.unstable_next=function(F){switch(N){case 1:case 2:case 3:var ae=3;break;default:ae=N}var ce=N;N=ae;try{return F()}finally{N=ce}},i.unstable_requestPaint=function(){H=!0},i.unstable_runWithPriority=function(F,ae){switch(F){case 1:case 2:case 3:case 4:case 5:break;default:F=3}var ce=N;N=F;try{return ae()}finally{N=ce}},i.unstable_scheduleCallback=function(F,ae,ce){var xe=i.unstable_now();switch(typeof ce=="object"&&ce!==null?(ce=ce.delay,ce=typeof ce=="number"&&0<ce?xe+ce:xe):ce=xe,F){case 1:var I=-1;break;case 2:I=250;break;case 5:I=1073741823;break;case 4:I=1e4;break;default:I=5e3}return I=ce+I,F={id:S++,callback:ae,priorityLevel:F,startTime:ce,expirationTime:I,sortIndex:-1},ce>xe?(F.sortIndex=ce,u(x,F),c(g)===null&&F===c(x)&&(z?(G(E),E=-1):z=!0,ge(K,ce-xe))):(F.sortIndex=I,u(g,F),j||A||(j=!0,_||(_=!0,P()))),F},i.unstable_shouldYield=te,i.unstable_wrapCallback=function(F){var ae=N;return function(){var ce=N;N=ae;try{return F.apply(this,arguments)}finally{N=ce}}}})(hf)),hf}var T0;function F2(){return T0||(T0=1,mf.exports=J2()),mf.exports}var vf={exports:{}},Nt={};/**
* @license React
* react-dom.production.js
*
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/var j0;function P2(){if(j0)return Nt;j0=1;var i=yd();function u(S){var p="https://react.dev/errors/"+S;if(1<arguments.length){p+="?args[]="+encodeURIComponent(arguments[1]);for(var N=2;N<arguments.length;N++)p+="&args[]="+encodeURIComponent(arguments[N])}return"Minified React error #"+S+"; visit "+p+" for the full message or use the non-minified dev environment for full errors and additional helpful warnings."}function c(){}var o={d:{f:c,r:function(){throw Error(u(522))},D:c,C:c,L:c,m:c,X:c,S:c,M:c},p:0,findDOMNode:null},s=Symbol.for("react.portal"),d=Symbol.for("react.recoverable"),h=Symbol.for("react.optimistic_key");function v(S,p,N){var A=3<arguments.length&&arguments[3]!==void 0?arguments[3]:null;return{$$typeof:s,key:A==null?null:A===h?h:""+A,children:S,containerInfo:p,implementation:N}}var g=i.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE;function x(S,p){if(S==="font")return"";if(typeof p=="string")return p==="use-credentials"?p:""}return Nt.__DOM_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE=o,Nt.browser=function(S){return{$$typeof:d,_reason:S}},Nt.createPortal=function(S,p){var N=2<arguments.length&&arguments[2]!==void 0?arguments[2]:null;if(!p||p.nodeType!==1&&p.nodeType!==9&&p.nodeType!==11)throw Error(u(299));return v(S,p,null,N)},Nt.flushSync=function(S){var p=g.T,N=o.p;try{if(g.T=null,o.p=2,S)return S()}finally{g.T=p,o.p=N,o.d.f()}},Nt.preconnect=function(S,p){typeof S=="string"&&(p?(p=p.crossOrigin,p=typeof p=="string"?p==="use-credentials"?p:"":void 0):p=null,o.d.C(S,p))},Nt.prefetchDNS=function(S){typeof S=="string"&&o.d.D(S)},Nt.preinit=function(S,p){if(typeof S=="string"&&p&&typeof p.as=="string"){var N=p.as,A=x(N,p.crossOrigin),j=typeof p.integrity=="string"?p.integrity:void 0,z=typeof p.fetchPriority=="string"?p.fetchPriority:void 0;N==="style"?o.d.S(S,typeof p.precedence=="string"?p.precedence:void 0,{crossOrigin:A,integrity:j,fetchPriority:z}):N==="script"&&o.d.X(S,{crossOrigin:A,integrity:j,fetchPriority:z,nonce:typeof p.nonce=="string"?p.nonce:void 0})}},Nt.preinitModule=function(S,p){if(typeof S=="string")if(typeof p=="object"&&p!==null){if(p.as==null||p.as==="script"){var N=x(p.as,p.crossOrigin);o.d.M(S,{crossOrigin:N,integrity:typeof p.integrity=="string"?p.integrity:void 0,nonce:typeof p.nonce=="string"?p.nonce:void 0,fetchPriority:typeof p.fetchPriority=="string"?p.fetchPriority:void 0})}}else p==null&&o.d.M(S)},Nt.preload=function(S,p){if(typeof S=="string"&&typeof p=="object"&&p!==null&&typeof p.as=="string"){var N=p.as,A=x(N,p.crossOrigin);o.d.L(S,N,{crossOrigin:A,integrity:typeof p.integrity=="string"?p.integrity:void 0,nonce:typeof p.nonce=="string"?p.nonce:void 0,type:typeof p.type=="string"?p.type:void 0,fetchPriority:typeof p.fetchPriority=="string"?p.fetchPriority:void 0,referrerPolicy:typeof p.referrerPolicy=="string"?p.referrerPolicy:void 0,imageSrcSet:typeof p.imageSrcSet=="string"?p.imageSrcSet:void 0,imageSizes:typeof p.imageSizes=="string"?p.imageSizes:void 0,media:typeof p.media=="string"?p.media:void 0})}},Nt.preloadModule=function(S,p){if(typeof S=="string")if(p){var N=x(p.as,p.crossOrigin);o.d.m(S,{as:typeof p.as=="string"&&p.as!=="script"?p.as:void 0,crossOrigin:N,integrity:typeof p.integrity=="string"?p.integrity:void 0,nonce:typeof p.nonce=="string"?p.nonce:void 0,fetchPriority:typeof p.fetchPriority=="string"?p.fetchPriority:void 0})}else o.d.m(S)},Nt.requestFormReset=function(S){o.d.r(S)},Nt.unstable_batchedUpdates=function(S,p){return S(p)},Nt.useFormState=function(S,p,N){return g.H.useFormState(S,p,N)},Nt.useFormStatus=function(){return g.H.useHostTransitionStatus()},Nt.version="19.3.1",Nt}var _0;function Uy(){if(_0)return vf.exports;_0=1;function i(){if(!(typeof __REACT_DEVTOOLS_GLOBAL_HOOK__>"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(i)}catch(u){console.error(u)}}return i(),vf.exports=P2(),vf.exports}/**
* @license React
* react-dom-client.production.js
*
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/var A0;function $2(){if(A0)return dr;A0=1;var i=F2(),u=yd(),c=Uy();function o(e){var t="https://react.dev/errors/"+e;if(1<arguments.length){t+="?args[]="+encodeURIComponent(arguments[1]);for(var n=2;n<arguments.length;n++)t+="&args[]="+encodeURIComponent(arguments[n])}return"Minified React error #"+e+"; visit "+t+" for the full message or use the non-minified dev environment for full errors and additional helpful warnings."}function s(e){return!(!e||e.nodeType!==1&&e.nodeType!==9&&e.nodeType!==11)}function d(e){for(var t=e,n=t;n&&!n.alternate;)t=n,(t.flags&4098)!==0&&(e=t.return),n=t.return;for(;t.return;)t=t.return;return t.tag===3?e:null}function h(e){if(e.tag===13){var t=e.memoizedState;if(t===null&&(e=e.alternate,e!==null&&(t=e.memoizedState)),t!==null)return t.dehydrated}return null}function v(e){if(e.tag===31){var t=e.memoizedState;if(t===null&&(e=e.alternate,e!==null&&(t=e.memoizedState)),t!==null)return t.dehydrated}return null}function g(e){if(d(e)!==e)throw Error(o(188))}function x(e){var t=e.alternate;if(!t){if(t=d(e),t===null)throw Error(o(188));return t!==e?null:e}for(var n=e,l=t;;){var a=n.return;if(a===null)break;var r=a.alternate;if(r===null){if(l=a.return,l!==null){n=l;continue}break}if(a.child===r.child){for(r=a.child;r;){if(r===n)return g(a),e;if(r===l)return g(a),t;r=r.sibling}throw Error(o(188))}if(n.return!==l.return)n=a,l=r;else{for(var m=!1,y=a.child;y;){if(y===n){m=!0,n=a,l=r;break}if(y===l){m=!0,l=a,n=r;break}y=y.sibling}if(!m){for(y=r.child;y;){if(y===n){m=!0,n=r,l=a;break}if(y===l){m=!0,l=r,n=a;break}y=y.sibling}if(!m)throw Error(o(189))}}if(n.alternate!==l)throw Error(o(190))}if(n.tag!==3)throw Error(o(188));return n.stateNode.current===n?e:t}function S(e){var t=e.tag;if(t===5||t===26||t===27||t===6)return e;for(e=e.child;e!==null;){if(t=S(e),t!==null)return t;e=e.sibling}return null}function p(e,t,n,l,a,r){for(;e!==null;){if((e.tag===5||e.tag===27||e.tag===6)&&n(e,l,a,r)||(e.tag!==22||e.memoizedState===null)&&(t||e.tag!==5&&e.tag!==27)&&p(e.child,t,n,l,a,r))return!0;e=e.sibling}return!1}function N(e){for(e=e.return;e!==null;){if(e.tag===3||e.tag===5||e.tag===27)return e;e=e.return}return null}function A(e){var t=!1;for(e=e.return;e!==null&&(e.tag===4&&(t=!0),!(e.tag===3||e.tag===5||e.tag===27));)e=e.return;return t}function j(e){var t=[null,null],n=N(e);return n===null||z(t,e,n.child,{foundSelf:!1}),t}function z(e,t,n,l){for(;n!==null;){if(n===t)l.foundSelf=!0;else if(n.tag===5||n.tag===27||n.tag===6){if(l.foundSelf)return e[1]=n,!0;e[0]=n}else if((n.tag!==22||n.memoizedState===null)&&z(e,t,n.child,l))return!0;n=n.sibling}return!1}function H(e){switch(e.tag){case 5:case 27:case 6:return e.stateNode;case 3:return e.stateNode.containerInfo;default:throw Error(o(559))}}var q=null,G=null;function k(e,t,n){return e===n?!0:e===t?(q=e,!0):!1}function V(e,t,n){return e===n?(G=e,!1):e===t?(G!==null&&(q=e),!0):!1}function K(e){if(e===null)return null;do e=e===null?null:e.return;while(e&&e.tag!==5&&e.tag!==27&&e.tag!==3);return e||null}function _(e,t,n){for(var l=0,a=e;a;a=n(a))l++;a=0;for(var r=t;r;r=n(r))a++;for(;0<l-a;)e=n(e),l--;for(;0<a-l;)t=n(t),a--;for(;l--;){if(e===t||t!==null&&e===t.alternate)return e;e=n(e),t=n(t)}return null}var E=Object.assign,R=Symbol.for("react.element"),Q=Symbol.for("react.transitional.element"),te=Symbol.for("react.portal"),W=Symbol.for("react.fragment"),P=Symbol.for("react.strict_mode"),ne=Symbol.for("react.profiler"),re=Symbol.for("react.consumer"),ge=Symbol.for("react.context"),F=Symbol.for("react.forward_ref"),ae=Symbol.for("react.suspense"),ce=Symbol.for("react.suspense_list"),xe=Symbol.for("react.memo"),I=Symbol.for("react.lazy"),Se=Symbol.for("react.activity"),J=Symbol.for("react.legacy_hidden"),me=Symbol.for("react.memo_cache_sentinel"),w=Symbol.for("react.view_transition"),U=Symbol.for("react.recoverable"),$=Symbol.iterator;function ee(e){return e===null||typeof e!="object"?null:(e=$&&e[$]||e["@@iterator"],typeof e=="function"?e:null)}var he=Symbol.for("react.client.reference");function ie(e){if(e==null)return null;if(typeof e=="function")return e.$$typeof===he?null:e.displayName||e.name||null;if(typeof e=="string")return e;switch(e){case W:return"Fragment";case ne:return"Profiler";case P:return"StrictMode";case ae:return"Suspense";case ce:return"SuspenseList";case Se:return"Activity";case w:return"ViewTransition"}if(typeof e=="object")switch(e.$$typeof){case te:return"Portal";case ge:return e.displayName||"Context";case re:return(e._context.displayName||"Context")+".Consumer";case F:var t=e.render;return e=e.displayName,e||(e=t.displayName||t.name||"",e=e!==""?"ForwardRef("+e+")":"ForwardRef"),e;case xe:return t=e.displayName||null,t!==null?t:ie(e.type)||"Memo";case I:t=e._payload,e=e._init;try{return ie(e(t))}catch{}}return null}var Ne=Array.isArray,fe=u.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE,ye=c.__DOM_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE,Ut={pending:!1,data:null,method:null,action:null},tl=[],ga=-Line truncated
at`)?" (<anonymous>)":-1<n.stack.indexOf("@")?"@unknown:0:0":""}return`
`+Ao+e+Id}var Ro=!1;function Mo(e,t){if(!e||Ro)return"";Ro=!0;var n=Error.prepareStackTrace;Error.prepareStackTrace=void 0;try{var l={DetermineComponentFrameRoot:function(){try{if(t){var Z=function(){throw Error()};if(Object.defineProperty(Z.prototype,"props",{set:function(){throw Error()}}),typeof Reflect=="object"&&Reflect.construct){try{Reflect.construct(Z,[])}catch(le){var M=le}Reflect.construct(e,[],Z)}else{try{Z.call()}catch(le){M=le}Z=!1;try{var L=Object.getOwnPropertyDescriptor(e.prototype,"props");Object.defineProperty(e.prototype,"props",{configurable:!0,set:function(){throw Error()}}),Z=!0,new e}finally{Z&&(L!==void 0?Object.defineProperty(e.prototype,"props",L):delete e.prototype.props)}}}else{try{throw Error()}catch(le){M=le}(Z=e())&&typeof Z.catch=="function"&&Z.catch(function(){})}}catch(le){if(le&&M&&typeof le.stack=="string")return[le.stack,M.stack]}return[null,null]}};l.DetermineComponentFrameRoot.displayName="DetermineComponentFrameRoot";var a=Object.getOwnPropertyDescriptor(l.DetermineComponentFrameRoot,"name");a&&a.configurable&&Object.defineProperty(l.DetermineComponentFrameRoot,"name",{value:"DetermineComponentFrameRoot"});var r=l.DetermineComponentFrameRoot(),m=r[0],y=r[1];if(m&&y){var C=m.split(`
`),D=y.split(`
`);for(a=l=0;l<C.length&&!C[l].includes("DetermineComponentFrameRoot");)l++;for(;a<D.length&&!D[a].includes("DetermineComponentFrameRoot");)a++;if(l===C.length||a===D.length)for(l=C.length-1,a=D.length-1;1<=l&&0<=a&&C[l]!==D[a];)a--;for(;1<=l&&0<=a;l--,a--)if(C[l]!==D[a]){if(l!==1||a!==1)do if(l--,a--,0>a||C[l]!==D[a]){var Y=`
`+C[l].replace(" at new "," at ");return e.displayName&&Y.includes("<anonymous>")&&(Y=Y.replace("<anonymous>",e.displayName)),Y}while(1<=l&&0<=a);break}}}finally{Ro=!1,Error.prepareStackTrace=n}return(n=e?e.displayName||e.name:"")?ll(n):""}function Jp(e,t){switch(e.tag){case 26:case 27:case 5:return ll(e.type);case 16:return ll("Lazy");case 13:return e.child!==t&&t!==null?ll("Suspense Fallback"):ll("Suspense");case 19:return ll("SuspenseList");case 0:case 15:return Mo(e.type,!1);case 11:return Mo(e.type.render,!1);case 1:return Mo(e.type,!0);case 31:return ll("Activity");case 30:return ll("ViewTransition");default:return""}}function Jd(e){try{var t="",n=null;do t+=Jp(e,n),n=e,e=e.return;while(e);return t}catch(l){return`
Error generating stack: `+l.message+`
`+l.stack}}var Oo=Object.prototype.hasOwnProperty,zo=i.unstable_scheduleCallback,Do=i.unstable_cancelCallback,Fp=i.unstable_shouldYield,Pp=i.unstable_requestPaint,Bt=i.unstable_now,$p=i.unstable_getCurrentPriorityLevel,Fd=i.unstable_ImmediatePriority,Pd=i.unstable_UserBlockingPriority,Tr=i.unstable_NormalPriority,Wp=i.unstable_LowPriority,$d=i.unstable_IdlePriority,eb=i.log,tb=i.unstable_setDisableYieldValue,pi=null,kt=null;function al(e){if(typeof eb=="function"&&tb(e),kt&&typeof kt.setStrictMode=="function")try{kt.setStrictMode(pi,e)}catch{}}var Lt=Math.clz32?Math.clz32:ab,nb=Math.log,lb=Math.LN2;function ab(e){return e>>>=0,e===0?32:31-(nb(e)/lb|0)|0}var jr=256,_r=262144,Ar=4194304;function kl(e){var t=e&42;if(t!==0)return t;switch(e&-e){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:return e&-e;case 262144:case 524288:case 1048576:case 2097152:return e&3932160;case 4194304:case 8388608:case 16777216:case 33554432:return e&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return e}}function Rr(e,t,n){var l=e.pendingLanes;if(l===0)return 0;var a=0,r=e.suspendedLanes,m=e.pingedLanes;e=e.warmLanes;var y=l&134217727;return y!==0?(l=y&~r,l!==0?a=kl(l):(m&=y,m!==0?a=kl(m):n||(n=y&~e,n!==0&&(a=kl(n))))):(y=l&~r,y!==0?a=kl(y):m!==0?a=kl(m):n||(n=l&~e,n!==0&&(a=kl(n)))),a===0?0:t!==0&&t!==a&&(t&r)===0&&(r=a&-a,n=t&-t,r>=n||r===32&&(n&4194048)!==0)?t:a}function bi(e,t){return(e.pendingLanes&~(e.suspendedLanes&~e.pingedLanes)&t)===0}function Wd(e,t){(t&8)!==0&&(t|=t&32);var n=e.entangledLanes;if(n!==0)for(e=e.entanglements,n&=t;0<n;){var l=31-Lt(n),a=1<<l;t|=e[l],n&=~a}return t}function ib(e,t){switch(e){case 1:case 2:case 4:case 8:case 64:return t+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return t+5e3;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return-1}}function em(){var e=Ar;return Ar<<=1,(Ar&62914560)===0&&(Ar=4194304),e}function Uo(e){for(var t=[],n=0;31>n;n++)t.push(e);return t}function xi(e,t){e.pendingLanes|=t,t!==268435456&&(e.suspendedLanes=0,e.pingedLanes=0,e.warmLanes=0)}function rb(e,t,n,l,a,r){var m=e.pendingLanes;e.pendingLanes=n,e.suspendedLanes=0,e.pingedLanes=0,e.warmLanes=0,e.expiredLanes&=n,e.entangledLanes&=n,e.errorRecoveryDisabledLanes&=n,e.shellSuspendCounter=0;var y=e.entanglements,C=e.expirationTimes,D=e.hiddenUpdates;for(n=m&~n;0<n;){var Y=31-Lt(n),Z=1<<Y;y[Y]=0,C[Y]=-1;var M=D[Y];if(M!==null)for(D[Y]=null,Y=0;Y<M.length;Y++){var L=M[Y];L!==null&&(L.lane&=-536870913)}n&=~Z}l!==0&&tm(e,l,0),r!==0&&a===0&&e.tag!==0&&(e.suspendedLanes|=r&~(m&~t))}function tm(e,t,n){e.pendingLanes|=t,e.suspendedLanes&=~t;var l=31-Lt(t);e.entangledLanes|=t,e.entanglements[l]=e.entanglements[l]|1073741824|n&261930}function nm(e,t){var n=e.entangledLanes|=t;for(e=e.entanglements;n;){var l=31-Lt(n),a=1<<l;a&t|e[l]&t&&(e[l]|=t),n&=~a}}function lm(e,t){var n=t&-t;return n=(n&42)!==0?1:Bo(n),(n&(e.suspendedLanes|t))!==0?0:n}function Bo(e){switch(e){case 2:e=1;break;case 8:e=4;break;case 32:e=16;break;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:case 4194304:case 8388608:case 16777216:case 33554432:e=128;break;case 268435456:e=134217728;break;default:e=0}return e}function ko(e){return e&=-e,2<e?8<e?(e&134217727)!==0?32:268435456:8:2}function am(){var e=ye.p;return e!==0?e:(e=window.event,e===void 0?32:f0(e.type))}function im(e,t){var n=ye.p;try{return ye.p=e,t()}finally{ye.p=n}}var Bn=Math.random().toString(36).slice(2),vt="__reactFiber$"+Bn,_t="__reactProps$"+Bn,pa="__reactContainer$"+Bn,rm="__reactEvents$"+Bn,ub="__reactListeners$"+Bn,ob="__reactHandles$"+Bn,um="__reactResources$"+Bn,Si="__reactMarker$"+Bn,Mr="__reactLoad$"+Bn;function Or(e){delete e[vt],delete e[_t],delete e[ub],delete e[ob]}function Ll(e){var t;if(t=e[vt])return t;for(var n=e.parentNode;n;){if(t=n[pa]||n[vt]){if(n=t.alternate,t.child!==null||n!==null&&n.child!==null)for(e=Xg(e);e!==null;){if(n=e[vt])return n;e=Xg(e)}return t}e=n,n=e.parentNode}return null}function ba(e){if(e=e[vt]||e[pa]){var t=e.tag;if(t===5||t===6||t===13||t===31||t===26||t===27||t===3)return e}return null}function Ni(e){var t=e.tag;if(t===5||t===26||t===27||t===6)return e.stateNode;throw Error(o(33))}function xa(e){var t=e[um];return t||(t=e[um]={hoistableStyles:new Map,hoistableScripts:new Map}),t}function ot(e){e[Si]=!0}function om(e){e[Mr]=void 0}var cm=new Set,sm={};function Hl(e,t){Sa(e,t),Sa(e+"Capture",t)}funct Line truncated
`).replace(Jx,"")}function wg(e,t){return t=Eg(t),Eg(e)===t}function He(e,t,n,l,a,r){switch(n){case"children":if(typeof l=="string")t==="body"||t==="textarea"&&l===""||Ca(e,l);else if(typeof l=="number"||typeof l=="bigint")t!=="body"&&Ca(e,""+l);else return;break;case"className":Dr(e,"class",l);break;case"tabIndex":Dr(e,"tabindex",l);break;case"dir":case"role":case"viewBox":case"width":case"height":Dr(e,n,l);break;case"style":xm(e,l,r);return;case"data":if(t!=="object"){Dr(e,"data",l);break}case"src":case"href":if(l===""&&(t!=="a"||n!=="href")){e.removeAttribute(n);break}if(l==null||typeof l=="function"||typeof l=="symbol"||typeof l=="boolean"){e.removeAttribute(n);break}l=Ur(l),e.setAttribute(n,l);break;case"action":case"formAction":if(typeof l=="function"){e.setAttribute(n,"javascript:throw new Error('A React form was unexpectedly submitted. If you called form.submit() manually, consider using form.requestSubmit() instead. If you\\'re trying to use event.stopPropagation() in a submit event handler, consider also calling event.preventDefault().')");break}else typeof r=="function"&&(n==="formAction"?(t!=="input"&&He(e,t,"name",a.name,a,null),He(e,t,"formEncType",a.formEncType,a,null),He(e,t,"formMethod",a.formMethod,a,null),He(e,t,"formTarget",a.formTarget,a,null)):(He(e,t,"encType",a.encType,a,null),He(e,t,"method",a.method,a,null),He(e,t,"target",a.target,a,null)));if(l==null||typeof l=="symbol"||typeof l=="boolean"){e.removeAttribute(n);break}l=Ur(l),e.setAttribute(n,l);break;case"onClick":l!=null&&(e.onclick=bn);return;case"onScroll":l!=null&&_e("scroll",e);return;case"onScrollEnd":l!=null&&_e("scrollend",e);return;case"dangerouslySetInnerHTML":if(l!=null){if(typeof l!="object"||!("__html"in l))throw Error(o(61));if(n=l.__html,n!=null){if(a.children!=null)throw Error(o(60));(r!=null?r.__html:void 0)!==n&&(e.innerHTML=n)}}break;case"multiple":e.multiple=l&&typeof l!="function"&&typeof l!="symbol";break;case"muted":e.muted=l&&typeof l!="function"&&typeof l!="symbol";break;case"suppressContentEditableWarning":case"suppressHydrationWarning":case"defaultValue":case"defaultChecked":case"innerHTML":case"ref":break;case"autoFocus":break;case"xlinkHref":if(l==null||typeof l=="function"||typeof l=="boolean"||typeof l=="symbol"){e.removeAttribute("xlink:href");break}n=Ur(l),e.setAttributeNS("http://www.w3.org/1999/xlink","xlink:href",n);break;case"contentEditable":case"spellCheck":case"draggable":case"value":case"autoReverse":case"externalResourcesRequired":case"focusable":case"preserveAlpha":l!=null&&typeof l!="function"&&typeof l!="symbol"?e.setAttribute(n,l):e.removeAttribute(n);break;case"inert":case"allowFullScreen":case"async":case"autoPlay":case"controls":case"credentialless":case"default":case"defer":case"disabled":case"disablePictureInPicture":case"disableRemotePlayback":case"formNoValidate":case"hidden":case"loop":case"noModule":case"noValidate":case"open":case"playsInline":case"readOnly":case"required":case"reversed":case"scoped":case"seamless":case"itemScope":l&&typeof l!="function"&&typeof l!="symbol"?e.setAttribute(n,""):e.removeAttribute(n);break;case"capture":case"download":l===!0?e.setAttribute(n,""):l!==!1&&l!=null&&typeof l!="function"&&typeof l!="symbol"?e.setAttribute(n,l):e.removeAttribute(n);break;case"cols":case"rows":case"size":case"span":l!=null&&typeof l!="function"&&typeof l!="symbol"&&!isNaN(l)&&1<=l?e.setAttribute(n,l):e.removeAttribute(n);break;case"rowSpan":case"start":l==null||typeof l=="function"||typeof l=="symbol"||isNaN(l)?e.removeAttribute(n):e.setAttribute(n,l);break;case"popover":_e("beforetoggle",e),_e("toggle",e),zr(e,"popover",l);break;case"xlinkActuate":kn(e,"http://www.w3.org/1999/xlink","xlink:actuate",l);break;case"xlinkArcrole":kn(e,"http://www.w3.org/1999/xlink","xlink:arcrole",l);break;case"xlinkRole":kn(e,"http://www.w3.org/1999/xlink","xlink:role",l);break;case"xlinkShow":kn(e,"http://www.w3.org/1999/xlink","xlink:show",l);break;case"xlinkTitle":kn(e,"http://www.w3.org/1999/xlink","xlink:title",l);break;case"xlinkType":kn(e,"http://www.w3.org/1999/xlink","xlink:type",l);break;case"xmlBase":kn(e,"http://www.w3.org/XML/1998/namespace","xml:base",l);break;case"xmlLang":kn(e,"http://www.w3.org/XML/1998/namespace","xml:lang",l);break;case"xmlSpace":kn(e,"http://www.w3.org/XML/1998/namespace","xml:space",l);break;case"is":zr(e,"is",l);break;case"innerText":case"textContent":return;default:if(!(2<n.length)||n[0]!=="o"&&n[0]!=="O"||n[1]!=="n"&&n[1]!=="N")n=hb.get(n)||n,zr(e,n,l);else return}ze=!0}function Hs(e,t,n,l,a,r){switch(n){case"style":xm(e,l,r);return;case"dangerouslySetInnerHTML":if(l!=null){if(typeof l!="object"||!("__html"in l))throw Error(o(61));if(n=l.__html,n!=null){if(a.children!=null)throw Error(o(60));(r!=null?r.__html:void 0)!==n&&(e.innerHTML=n)}}break;case"children":if(typeof l=="string")Ca(e,l);else if(typeof l=="number"||typeof l=="bigint")Ca(e,""+l);else return;break;case"onScroll":l!=null&&_e("scroll",e);return;case"onScrollEnd":l!=null&&_e("scro Line truncated
* @license lucide-react v0.475.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const fS=i=>i.replace(/([a-z0-9])([A-Z])/g,"$1-$2").toLowerCase(),Ly=(...i)=>i.filter((u,c,o)=>!!u&&u.trim()!==""&&o.indexOf(u)===c).join(" ").trim();/**
* @license lucide-react v0.475.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/var dS={xmlns:"http://www.w3.org/2000/svg",width:24,height:24,viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:2,strokeLinecap:"round",strokeLinejoin:"round"};/**
* @license lucide-react v0.475.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const mS=b.forwardRef(({color:i="currentColor",size:u=24,strokeWidth:c=2,absoluteStrokeWidth:o,className:s="",children:d,iconNode:h,...v},g)=>b.createElement("svg",{ref:g,...dS,width:u,height:u,stroke:i,strokeWidth:o?Number(c)*24/Number(u):c,className:Ly("lucide",s),...v},[...h.map(([x,S])=>b.createElement(x,S)),...Array.isArray(d)?d:[d]]));/**
* @license lucide-react v0.475.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const De=(i,u)=>{const c=b.forwardRef(({className:o,...s},d)=>b.createElement(mS,{ref:d,iconNode:u,className:Ly(`lucide-${fS(i)}`,o),...s}));return c.displayName=`${i}`,c};/**
* @license lucide-react v0.475.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const hS=[["rect",{width:"20",height:"5",x:"2",y:"3",rx:"1",key:"1wp1u1"}],["path",{d:"M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8",key:"1s80jp"}],["path",{d:"M10 12h4",key:"a56b0p"}]],vS=De("Archive",hS);/**
* @license lucide-react v0.475.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const gS=[["path",{d:"M12 7v14",key:"1akyts"}],["path",{d:"M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z",key:"ruj8y"}]],Hy=De("BookOpen",gS);/**
* @license lucide-react v0.475.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const yS=[["path",{d:"M12 8V4H8",key:"hb8ula"}],["rect",{width:"16",height:"12",x:"4",y:"8",rx:"2",key:"enze0r"}],["path",{d:"M2 14h2",key:"vft8re"}],["path",{d:"M20 14h2",key:"4cs60a"}],["path",{d:"M15 13v2",key:"1xurst"}],["path",{d:"M9 13v2",key:"rq6x2g"}]],qy=De("Bot",yS);/**
* @license lucide-react v0.475.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const pS=[["path",{d:"M20 6 9 17l-5-5",key:"1gmf2c"}]],bS=De("Check",pS);/**
* @license lucide-react v0.475.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const xS=[["circle",{cx:"12",cy:"12",r:"10",key:"1mglay"}],["path",{d:"m9 12 2 2 4-4",key:"dzmm74"}]],SS=De("CircleCheck",xS);/**
* @license lucide-react v0.475.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const NS=[["rect",{width:"14",height:"14",x:"8",y:"8",rx:"2",ry:"2",key:"17jyea"}],["path",{d:"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2",key:"zix9uf"}]],z0=De("Copy",NS);/**
* @license lucide-react v0.475.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const CS=[["path",{d:"M15 3h6v6",key:"1q9fwt"}],["path",{d:"M10 14 21 3",key:"gplh6r"}],["path",{d:"M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6",key:"a6xqqp"}]],eo=De("ExternalLink",CS);/**
* @license lucide-react v0.475.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const ES=[["path",{d:"M10.733 5.076a10.744 10.744 0 0 1 11.205 6.575 1 1 0 0 1 0 .696 10.747 10.747 0 0 1-1.444 2.49",key:"ct8e1f"}],["path",{d:"M14.084 14.158a3 3 0 0 1-4.242-4.242",key:"151rxh"}],["path",{d:"M17.479 17.499a10.75 10.75 0 0 1-15.417-5.151 1 1 0 0 1 0-.696 10.75 10.75 0 0 1 4.446-5.143",key:"13bj9a"}],["path",{d:"m2 2 20 20",key:"1ooewy"}]],wS=De("EyeOff",ES);/**
* @license lucide-react v0.475.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const TS=[["path",{d:"M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0",key:"1nclc0"}],["circle",{cx:"12",cy:"12",r:"3",key:"1v7zrd"}]],jS=De("Eye",TS);/**
* @license lucide-react v0.475.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const _S=[["path",{d:"M9 20H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H20a2 2 0 0 1 2 2v5",key:"1w6njk"}],["circle",{cx:"13",cy:"12",r:"2",key:"1j92g6"}],["path",{d:"M18 19c-2.8 0-5-2.2-5-5v8",key:"pkpw2h"}],["circle",{cx:"20",cy:"19",r:"2",key:"1obnsp"}]],D0=De("FolderGit2",_S);/**
* @license lucide-react v0.475.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const AS=[["line",{x1:"6",x2:"6",y1:"3",y2:"15",key:"17qcm7"}],["circle",{cx:"18",cy:"6",r:"3",key:"1h7g24"}],["circle",{cx:"6",cy:"18",r:"3",key:"fqmcym"}],["path",{d:"M18 9a9 9 0 0 1-9 9",key:"n2h4wq"}]],RS=De("GitBranch",AS);/**
* @license lucide-react v0.475.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const MS=[["line",{x1:"22",x2:"2",y1:"12",y2:"12",key:"1y58io"}],["path",{d:"M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z",key:"oot6mr"}],["line",{x1:"6",x2:"6.01",y1:"16",y2:"16",key:"sgf278"}],["line",{x1:"10",x2:"10.01",y1:"16",y2:"16",key:"1l4acy"}]],OS=De("HardDrive",MS);/**
* @license lucide-react v0.475.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const zS=[["path",{d:"M2.586 17.414A2 2 0 0 0 2 18.828V21a1 1 0 0 0 1 1h3a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h1a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h.172a2 2 0 0 0 1.414-.586l.814-.814a6.5 6.5 0 1 0-4-4z",key:"1s6t7t"}],["circle",{cx:"16.5",cy:"7.5",r:".5",fill:"currentColor",key:"w0ekpg"}]],U0=De("KeyRound",zS);/**
* @license lucide-react v0.475.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const DS=[["path",{d:"M12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83z",key:"zw3jo"}],["path",{d:"M2 12a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 12",key:"1wduqc"}],["path",{d:"M2 17a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 17",key:"kqbvx6"}]],Gy=De("Layers",DS);/**
* @license lucide-react v0.475.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const US=[["rect",{width:"7",height:"7",x:"3",y:"3",rx:"1",key:"1g98yp"}],["rect",{width:"7",height:"7",x:"14",y:"3",rx:"1",key:"6d4xhi"}],["rect",{width:"7",height:"7",x:"14",y:"14",rx:"1",key:"nxv5o0"}],["rect",{width:"7",height:"7",x:"3",y:"14",rx:"1",key:"1bb6yr"}]],BS=De("LayoutGrid",US);/**
* @license lucide-react v0.475.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const kS=[["rect",{width:"18",height:"11",x:"3",y:"11",rx:"2",ry:"2",key:"1w4ew1"}],["path",{d:"M7 11V7a5 5 0 0 1 10 0v4",key:"fwvmzm"}]],LS=De("Lock",kS);/**
* @license lucide-react v0.475.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const HS=[["path",{d:"M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z",key:"1lielz"}]],pd=De("MessageSquare",HS);/**
* @license lucide-react v0.475.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const qS=[["path",{d:"M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z",key:"a7tn18"}]],GS=De("Moon",qS);/**
* @license lucide-react v0.475.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const YS=[["circle",{cx:"13.5",cy:"6.5",r:".5",fill:"currentColor",key:"1okk4w"}],["circle",{cx:"17.5",cy:"10.5",r:".5",fill:"currentColor",key:"f64h9f"}],["circle",{cx:"8.5",cy:"7.5",r:".5",fill:"currentColor",key:"fotxhn"}],["circle",{cx:"6.5",cy:"12.5",r:".5",fill:"currentColor",key:"qy21gx"}],["path",{d:"M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10c.926 0 1.648-.746 1.648-1.688 0-.437-.18-.835-.437-1.125-.29-.289-.438-.652-.438-1.125a1.64 1.64 0 0 1 1.668-1.668h1.996c3.051 0 5.555-2.503 5.555-5.554C21.965 6.012 17.461 2 12 2z",key:"12rzf8"}]],VS=De("Palette",YS);/**
* @license lucide-react v0.475.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const QS=[["rect",{width:"18",height:"18",x:"3",y:"3",rx:"2",key:"afitv7"}],["path",{d:"M3 9h18",key:"1pudct"}],["path",{d:"M9 21V9",key:"1oto5p"}]],XS=De("PanelsTopLeft",QS);/**
* @license lucide-react v0.475.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const ZS=[["rect",{width:"5",height:"5",x:"3",y:"3",rx:"1",key:"1tu5fj"}],["rect",{width:"5",height:"5",x:"16",y:"3",rx:"1",key:"1v8r4q"}],["rect",{width:"5",height:"5",x:"3",y:"16",rx:"1",key:"1x03jg"}],["path",{d:"M21 16h-3a2 2 0 0 0-2 2v3",key:"177gqh"}],["path",{d:"M21 21v.01",key:"ents32"}],["path",{d:"M12 7v3a2 2 0 0 1-2 2H7",key:"8crl2c"}],["path",{d:"M3 12h.01",key:"nlz23k"}],["path",{d:"M12 3h.01",key:"n36tog"}],["path",{d:"M12 16v.01",key:"133mhm"}],["path",{d:"M16 12h1",key:"1slzba"}],["path",{d:"M21 12v.01",key:"1lwtk9"}],["path",{d:"M12 21v-1",key:"1880an"}]],KS=De("QrCode",ZS);/**
* @license lucide-react v0.475.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const IS=[["path",{d:"M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8",key:"v9h5vc"}],["path",{d:"M21 3v5h-5",key:"1q7to0"}],["path",{d:"M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16",key:"3uifl3"}],["path",{d:"M8 16H3v5",key:"1cv678"}]],Yy=De("RefreshCw",IS);/**
* @license lucide-react v0.475.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const JS=[["circle",{cx:"11",cy:"11",r:"8",key:"4ej97u"}],["path",{d:"m21 21-4.3-4.3",key:"1qie3q"}]],bd=De("Search",JS);/**
* @license lucide-react v0.475.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const FS=[["path",{d:"M14.536 21.686a.5.5 0 0 0 .937-.024l6.5-19a.496.496 0 0 0-.635-.635l-19 6.5a.5.5 0 0 0-.024.937l7.93 3.18a2 2 0 0 1 1.112 1.11z",key:"1ffxy3"}],["path",{d:"m21.854 2.147-10.94 10.939",key:"12cjpa"}]],PS=De("Send",FS);/**
* @license lucide-react v0.475.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const $S=[["path",{d:"M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z",key:"oel41y"}],["path",{d:"m9 12 2 2 4-4",key:"dzmm74"}]],WS=De("ShieldCheck",$S);/**
* @license lucide-react v0.475.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const eN=[["rect",{width:"14",height:"20",x:"5",y:"2",rx:"2",ry:"2",key:"1yt0o3"}],["path",{d:"M12 18h.01",key:"mhygvu"}]],tN=De("Smartphone",eN);/**
* @license lucide-react v0.475.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const nN=[["path",{d:"M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z",key:"4pj2yx"}],["path",{d:"M20 3v4",key:"1olli1"}],["path",{d:"M22 5h-4",key:"1gvqau"}],["path",{d:"M4 17v2",key:"vumght"}],["path",{d:"M5 18H3",key:"zchphs"}]],$f=De("Sparkles",nN);/**
* @license lucide-react v0.475.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const lN=[["circle",{cx:"12",cy:"12",r:"4",key:"4exip2"}],["path",{d:"M12 2v2",key:"tus03m"}],["path",{d:"M12 20v2",key:"1lh1kg"}],["path",{d:"m4.93 4.93 1.41 1.41",key:"149t6j"}],["path",{d:"m17.66 17.66 1.41 1.41",key:"ptbguv"}],["path",{d:"M2 12h2",key:"1t8f8n"}],["path",{d:"M20 12h2",key:"1q8mjw"}],["path",{d:"m6.34 17.66-1.41 1.41",key:"1m8zz5"}],["path",{d:"m19.07 4.93-1.41 1.41",key:"1shlcs"}]],aN=De("Sun",lN);/**
* @license lucide-react v0.475.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const iN=[["polyline",{points:"4 17 10 11 4 5",key:"akl6gq"}],["line",{x1:"12",x2:"20",y1:"19",y2:"19",key:"q2wloq"}]],yr=De("Terminal",iN);/**
* @license lucide-react v0.475.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const rN=[["path",{d:"m19 5 3-3",key:"yk6iyv"}],["path",{d:"m2 22 3-3",key:"19mgm9"}],["path",{d:"M6.3 20.3a2.4 2.4 0 0 0 3.4 0L12 18l-6-6-2.3 2.3a2.4 2.4 0 0 0 0 3.4Z",key:"goz73y"}],["path",{d:"M7.5 13.5 10 11",key:"7xgeeb"}],["path",{d:"M10.5 16.5 13 14",key:"10btkg"}],["path",{d:"m12 6 6 6 2.3-2.3a2.4 2.4 0 0 0 0-3.4l-2.6-2.6a2.4 2.4 0 0 0-3.4 0Z",key:"1snsnr"}]],uN=De("Unplug",rN);/**
* @license lucide-react v0.475.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const oN=[["path",{d:"M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2",key:"975kel"}],["circle",{cx:"12",cy:"7",r:"4",key:"17ys0d"}]],cN=De("User",oN);/**
* @license lucide-react v0.475.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const sN=[["path",{d:"M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2",key:"1yyitq"}],["circle",{cx:"9",cy:"7",r:"4",key:"nufk8"}],["path",{d:"M22 21v-2a4 4 0 0 0-3-3.87",key:"kshegd"}],["path",{d:"M16 3.13a4 4 0 0 1 0 7.75",key:"1da9ce"}]],fN=De("Users",sN);var dN=Object.defineProperty,xd=(i,u)=>dN(i,"name",{value:u,configurable:!0});function Wf(i,u){if(typeof i=="function")return i(u);i!=null&&(i.current=u)}xd(Wf,"setRef");function ua(...i){return u=>{let c=!1;const o=i.map(s=>{const d=Wf(s,u);return!c&&typeof d=="function"&&(c=!0),d});if(c)return()=>{for(let s=0;s<o.length;s++){const d=o[s];typeof d=="function"?d():Wf(i[s],null)}}}}xd(ua,"composeRefs");function Dt(...i){return b.useCallback(ua(...i),i)}xd(Dt,"useComposedRefs");var mN=Object.defineProperty,It=(i,u)=>mN(i,"name",{value:u,configurable:!0});function zl(i){const u=b.forwardRef((c,o)=>{let{children:s,...d}=c,h=null,v=!1;const g=[];s=Iy(s),b.Children.forEach(s,N=>{var A;if(Zy(N)){v=!0;const j=N;let z="child"in j.props?j.props.child:j.props.children;for(;Sd(z)&&typeof vo=="function";)z=vo(z._payload);h=gN(j,z),g.push((A=h==null?void 0:h.props)==null?void 0:A.children)}else g.push(N)}),h?h=b.cloneElement(h,void 0,g):!v&&b.Children.count(s)===1&&b.isValidElement(s)&&(h=s);const x=h?Xy(h):void 0,S=Dt(o,x);if(!h){if(s||s===0)throw new Error(v?bN(i):pN(i));return s}const p=Qy(d,h.props??{});return h.type!==b.Fragment&&(p.ref=o?S:x),b.cloneElement(h,p)});return u.displayName=`${i}.Slot`,u}It(zl,"createSlot");var hN=zl("Slot"),Vy=Symbol.for("radix.slottable");function vN(i){const u=It(c=>"child"in c?c.children(c.child):c.children,"Slottable");return u.displayName=`${i}.Slottable`,u.__radixId=Vy,u}It(vN,"createSlottable");var gN=It((i,u)=>"child"in i.props?b.isValidElement(u)?b.cloneElement(u,void 0,i.props.children(u.props.children)):null:b.isValidElement(u)?u:null,"getSlottableElementFromSlottable");function Qy(i,u){const c={...u};for(const o in u){const s=i[o],d=u[o];/^on[A-Z]/.test(o)?s&&d?c[o]=(...v)=>{const g=d(...v);return s(...v),g}:s&&(c[o]=s):o==="style"?c[o]={...s,...d}:o==="className"?c[o]=[s,d].filter(Boolean).join(" "):o==="aria-describedby"&&(c[o]=Jy(d,s))}return{...i,...c}}It(Qy,"mergeProps");function Xy(i){var o,s;let u=(o=Object.getOwnPropertyDescriptor(i.props,"ref"))==null?void 0:o.get,c=u&&"isReactWarning"in u&&u.isReactWarning;return c?i.ref:(u=(s=Object.getOwnPropertyDescriptor(i,"ref"))==null?void 0:s.get,c=u&&"isReactWarning"in u&&u.isReactWarning,c?i.props.ref:i.props.ref||i.ref)}It(Xy,"getElementRef");function Zy(i){return b.isValidElement(i)&&typeof i.type=="function"&&"__radixId"in i.type&&i.type.__radixId===Vy}It(Zy,"isSlottable");var yN=Symbol.for("react.lazy");function Sd(i){return i!=null&&typeof i=="object"&&"$$typeof"in i&&i.$$typeof===yN&&"_payload"in i&&Ky(i._payload)}It(Sd,"isLazyComponent");function Ky(i){return typeof i=="object"&&i!==null&&"then"in i}It(Ky,"isPromiseLike");function Iy(i){let u=i;for(;Sd(u)&&typeof vo=="function";)u=vo(u._payload);return u}It(Iy,"useUnwrappedLazy");function Jy(...i){const u=new Set;for(const c of i)if(typeof c=="string")for(const o of String(c).trim().split(/\s+/))o&&u.add(o);return u.size>0?Array.from(u).join(" "):void 0}It(Jy,"concatAriaDescribedby");var pN=It(i=>`${i} failed to slot onto its children. Expected a single React element child or \`Slottable\`.`,"createSlotError"),bN=It(i=>`${i} failed to slot onto its \`Slottable\`. Expected \`Slottable\` to receive a single React element child.`,"createSlottableError"),vo=sa[" use ".trim().toString()];function Fy(i){var u,c,o="";if(typeof i=="string"||typeof i=="number")o+=i;else if(typeof i=="object")if(Array.isArray(i)){var s=i.length;for(u=0;u<s;u++)i[u]&&(c=Fy(i[u]))&&(o&&(o+=" "),o+=c)}else for(c in i)i[c]&&(o&&(o+=" "),o+=c);return o}function Py(){for(var i,u,c=0,o="",s=arguments.length;c<s;c++)(i=arguments[c])&&(u=Fy(i))&&(o&&(o+=" "),o+=u);return o}const B0=i=>typeof i=="boolean"?`${i}`:i===0?"0":i,k0=Py,$y=(i,u)=>c=>{var o;if((u==null?void 0:u.variants)==null)return k0(i,c==null?void 0:c.class,c==null?void 0:c.className);const{variants:s,defaultVariants:d}=u,h=Object.keys(s).map(x=>{const S=c==null?void 0:c[x],p=d==null?void 0:d[x];if(S===null)return null;const N=B0(S)||B0(p);return s[x][N]}),v=c&&Object.entries(c).reduce((x,S)=>{let[p,N]=S;return N===void 0||(x[p]=N),x},{}),g=u==null||(o=u.compoundVariants)===null||o===void 0?void 0:o.reduce((x,S)=>{let{class:p,className:N,...A}=S;return Object.entries(A).every(j=>{let[z,H]=j;return Array.isArray(H)?H.includes({...d,...v}[z]):{...d,...v}[z]===H})?[...x,p,N]:x},[]);return k0(i,h,g,c==null?void 0:c.class,c==null?void 0:c.className)},xN=(i,u)=>{const c=new Array(i.length+u.length);for(let o=0;o<i.length;o++)c[o]=i[o];for(let o=0;o<u.length;o++)c[i.length+o]=u[o];return c},SN=(i,u)=>({classGroupId:i,validator:u}),Wy=(i=new Map,u=null,c)=>({nextPart:i,validators:u,classGroupId:c}),go="-",L0=[],NN="arbitrary..",CN=i=>{const u=wN(i),{conflictingClassGroups:c,conflictingClassGroupLine truncated
.`.concat(OC,` {
overflow: hidden `).concat(o,`;
padding-right: `).concat(v,"px ").concat(o,`;
}
body[`).concat(hi,`] {
overflow: hidden `).concat(o,`;
overscroll-behavior: contain;
`).concat([u&&"position: relative ".concat(o,";"),c==="margin"&&`
padding-left: `.concat(s,`px;
padding-top: `).concat(d,`px;
padding-right: `).concat(h,`px;
margin-left:0;
margin-top:0;
margin-right: `).concat(v,"px ").concat(o,`;
`),c==="padding"&&"padding-right: ".concat(v,"px ").concat(o,";")].filter(Boolean).join(""),`
}
.`).concat(mo,` {
right: `).concat(v,"px ").concat(o,`;
}
.`).concat(ho,` {
margin-right: `).concat(v,"px ").concat(o,`;
}
.`).concat(mo," .").concat(mo,` {
right: 0 `).concat(o,`;
}
.`).concat(ho," .").concat(ho,` {
margin-right: 0 `).concat(o,`;
}
body[`).concat(hi,`] {
`).concat(zC,": ").concat(v,`px;
}
`)},ny=function(){var i=parseInt(document.body.getAttribute(hi)||"0",10);return isFinite(i)?i:0},$C=function(){b.useEffect(function(){return document.body.setAttribute(hi,(ny()+1).toString()),function(){var i=ny()-1;i<=0?document.body.removeAttribute(hi):document.body.setAttribute(hi,i.toString())}},[])},WC=function(i){var u=i.noRelative,c=i.noImportant,o=i.gapMode,s=o===void 0?"margin":o;$C();var d=b.useMemo(function(){return JC(s)},[s]);return b.createElement(FC,{styles:PC(d,!u,s,c?"":"!important")})},sd=!1;if(typeof window<"u")try{var io=Object.defineProperty({},"passive",{get:function(){return sd=!0,!0}});window.addEventListener("test",io,io),window.removeEventListener("test",io,io)}catch{sd=!1}var ui=sd?{passive:!1}:!1,e3=function(i){return i.tagName==="TEXTAREA"},dp=function(i,u){if(!(i instanceof Element))return!1;var c=window.getComputedStyle(i);return c[u]!=="hidden"&&!(c.overflowY===c.overflowX&&!e3(i)&&c[u]==="visible")},t3=function(i){return dp(i,"overflowY")},n3=function(i){return dp(i,"overflowX")},ly=function(i,u){var c=u.ownerDocument,o=u;do{typeof ShadowRoot<"u"&&o instanceof ShadowRoot&&(o=o.host);var s=mp(i,o);if(s){var d=hp(i,o),h=d[1],v=d[2];if(h>v)return!0}o=o.parentNode}while(o&&o!==c.body);return!1},l3=function(i){var u=i.scrollTop,c=i.scrollHeight,o=i.clientHeight;return[u,c,o]},a3=function(i){var u=i.scrollLeft,c=i.scrollWidth,o=i.clientWidth;return[u,c,o]},mp=function(i,u){return i==="v"?t3(u):n3(u)},hp=function(i,u){return i==="v"?l3(u):a3(u)},i3=function(i,u){return i==="h"&&u==="rtl"?-1:1},r3=function(i,u,c,o,s){var d=i3(i,window.getComputedStyle(u).direction),h=d*o,v=c.target,g=u.contains(v),x=!1,S=h>0,p=0,N=0;do{if(!v)break;var A=hp(i,v),j=A[0],z=A[1],H=A[2],q=z-H-d*j;(j||q)&&mp(i,v)&&(p+=q,N+=j);var G=v.parentNode;v=G&&G.nodeType===Node.DOCUMENT_FRAGMENT_NODE?G.host:G}while(!g&&v!==document.body||g&&(u.contains(v)||u===v));return(S&&Math.abs(p)<1||!S&&Math.abs(N)<1)&&(x=!0),x},ro=function(i){return"changedTouches"in i?[i.changedTouches[0].clientX,i.changedTouches[0].clientY]:[0,0]},ay=function(i){return[i.deltaX,i.deltaY]},iy=function(i){return i&&"current"in i?i.current:i},u3=function(i,u){return i[0]===u[0]&&i[1]===u[1]},o3=function(i){return`
.block-interactivity-`.concat(i,` {pointer-events: none;}
.allow-interactivity-`).concat(i,` {pointer-events: all;}
`)},c3=0,oi=[];function s3(i){var u=b.useRef([]),c=b.useRef([0,0]),o=b.useRef(),s=b.useState(c3++)[0],d=b.useState(fp)[0],h=b.useRef(i);b.useEffect(function(){h.current=i},[i]),b.useEffect(function(){if(i.inert){document.body.classList.add("block-interactivity-".concat(s));var z=MC([i.lockRef.current],(i.shards||[]).map(iy),!0).filter(Boolean);return z.forEach(function(H){return H.classList.add("allow-interactivity-".concat(s))}),function(){document.body.classList.remove("block-interactivity-".concat(s)),z.forEach(function(H){return H.classList.remove("allow-interactivity-".concat(s))})}}},[i.inert,i.lockRef.current,i.shards]);var v=b.useCallback(function(z,H){if("touches"in z&&z.touches.length===2||z.type==="wheel"&&z.ctrlKey)return!h.current.allowPinchZoom;var q=ro(z),G=c.current,k="deltaX"in z?z.deltaX:G[0]-q[0],V="deltaY"in z?z.deltaY:G[1]-q[1],K,_=z.target,E=Math.abs(k)>Math.abs(V)?"h":"v";if("touches"in z&&E==="h"&&_.type==="range")return!1;var R=window.getSelection(),Q=R&&R.anchorNode,te=Q?Q===_||Q.contains(_):!1;if(te)return!1;var W=ly(E,_);if(!W)return!0;if(W?K=E:(K=E==="v"?"h":"v",W=ly(E,_)),!W)return!1;if(!o.current&&"changedTouches"in z&&(k||V)&&(o.current=K),!K)return!0;var P=o.current||K;return r3(P,H,z,P==="h"?k:V)},[]),g=b.useCallback(function(z){var H=z;if(!(!oi.length||oi[oi.length-1]!==d)){var q="deltaY"in H?ay(H):ro(H),G=u.current.filter(function(K){return K.name===H.type&&(K.target===H.target||H.target===K.shadowParent)&&u3(K.delta,q)})[0];if(G&&G.should){H.cancelable&&H.preventDefault();return}if(!G){var k=(h.current.shards||[]).map(iy).filter(Boolean).filter(function(K){return K.contains(H.target)}),V=k.length>0?v(H,k[0]):!h.current.noIsolation;V&&H.cancelable&&H.preventDefault()}}},[]),x=b.useCallback(function(z,H,q,G){var k={name:z,delta:H,target:q,should:G,shadowParent:f3(q)};u.current.push(k),setTimeout(function(){u.current=u.current.filter(function(V){return V!==k})},1)},[]),S=b.useCallback(function(z){c.current=ro(z),o.current=void 0},[]),p=b.useCallback(function(z){x(z.type,ay(z),z.target,v(z,i.lockRef.current))},[]),N=b.useCallback(function(z){x(z.type,ro(z),z.target,v(z,i.lockRef.current))},[]);b.useEffect(function(){return oi.push(d),i.setCallbacks({onScrollCapture:p,onWheelCapture:p,onTouchMoveCapture:N}),document.addEventListener("wheel",g,ui),document.addEventListener("touchmove",g,ui),document.addEventListener("touchstart",S,ui),function(){oi=oi.filter(function(z){return z!==d}),document.removeEventListener("wheel",g,ui),document.removeEventListener("touchmove",g,ui),document.removeEventListener("touchstart",S,ui)}},[]);var A=i.removeScrollBar,j=i.inert;return b.createElement(b.Fragment,null,j?b.createElement(d,{styles:o3(s)}):null,A?b.createElement(WC,{noRelative:i.noRelative,gapMode:i.gapMode}):null)}function f3(i){for(var u=null;i!==null;)i instanceof ShadowRoot&&(u=i.host,i=i.host),i=i.parentNode;return u}const d3=qC(sp,s3);var vp=b.forwardRef(function(i,u){return b.createElement(Co,zn({},i,{ref:u,sideCar:d3}))});vp.classNames=Co.classNames;var m3=function(i){if(typeof document>"u")return null;var u=Array.isArray(i)?i[0]:i;return u.ownerDocument.body},ci=new WeakMap,uo=new WeakMap,oo={},Tf=0,gp=function(i){return i&&(i.host||gp(i.parentNode))},h3=function(i,u){return u.map(function(c){if(i.contains(c))return c;var o=gp(c);return o&&i.contains(o)?o:(console.error("aria-hidden",c,"in not contained inside",i,". Doing nothing"),null)}).filter(function(c){return!!c})},v3=function(i,u,c,o){var s=h3(u,Array.isArray(i)?i:[i]);oo[c]||(oo[c]=new WeakMap);var d=oo[c],h=[],v=new Set,g=new Set(s),x=function(p){!p||v.has(p)||(v.add(p),x(p.parentNode))};s.forEach(x);var S=function(p){!p||g.has(p)||Array.prototype.forEach.call(p.children,function(N){if(v.has(N))S(N);else try{var A=N.getAttribute(o),j=A!==null&&A!=="false",z=(ci.get(N)||0)+1,H=(d.get(N)||0)+1;ci.set(N,z),d.set(N,H),h.push(N),z===1&&j&&uo.set(N,!0),H===1&&N.setAttribute(c,"true"),j||N.setAttribute(o,"true")}catch(q){console.error("aria-hidden: cannot operate on ",N,q)}})};return S(u),v.clear(),Tf++,function(){h.forEach(function(p){var N=ci.get(p)-1,A=d.get(p)-1;ci.set(p,N),d.set(p,A),N||(uo.has(p)||p.removeAttribute(o),uo.delete(p)),A||p.removeAttribute(c)}),Tf--,Tf||(ci=new WeakMap,ci=new WeakMap,uo=new WeakMap,oo={})}},g3=function(i,u,c){c===void 0&&(c="data-aria-hidden");var o=Array.from(Array.isArray(i)?i:[i]),s=m3(i);return s?(o.push.apply(o,Array.from(s.querySelectorAll("[aria-live], script"))),v3(o,s,c,"aria-hidden")):function(){return null}},y3=Object.defineProperty,cn=(i,u)=>y3(i,"name",{value:u,configurable:!0}),Bd="Dialog",[yp,ME]=da(Bd),[p3,el]=yp(Bd),pp=cn(i=>{const{__scopeDialog:u,children:c,open:o,defaultOpen:s,onOpenChange:d,modal:h=!0}=i,v=b.useRef(null),g=b.useRef(null),{nodes:x,registry:S}=tp(),[p,N]=Sr({prop:o,defaultProp:s??!1,onChange:d,caller:Bd}),[A,j]=b.useState(0),[z,H]=b.useState(0);return f.jsx(p3,{scope:u,triggerRef:v,contentRef:g,contentId:un(),titleId:un(),descriptionId:un(),titlePresen Line truncated
]))+`;On.KANJI=new RegExp(c,"g"),On.BYTE_KANJI=new RegExp("[^A-Z0-9 $%*+\\-./:]+","g"),On.BYTE=new RegExp(o,"g"),On.NUMERIC=new RegExp(i,"g"),On.ALPHANUMERIC=new RegExp(u,"g");const s=new RegExp("^"+c+"$"),d=new RegExp("^"+i+"$"),h=new RegExp("^[A-Z0-9 $%*+\\-./:]+$");return On.testKanji=function(g){return s.test(g)},On.testNumeric=function(g){return d.test(g)},On.testAlphanumeric=function(g){return h.test(g)},On}var Sy;function va(){return Sy||(Sy=1,(function(i){const u=Zp(),c=Kp();i.NUMERIC={id:"Numeric",bit:1,ccBits:[10,12,14]},i.ALPHANUMERIC={id:"Alphanumeric",bit:2,ccBits:[9,11,13]},i.BYTE={id:"Byte",bit:4,ccBits:[8,16,16]},i.KANJI={id:"Kanji",bit:8,ccBits:[8,10,12]},i.MIXED={bit:-1},i.getCharCountIndicator=function(d,h){if(!d.ccBits)throw new Error("Invalid mode: "+d);if(!u.isValid(h))throw new Error("Invalid version: "+h);return h>=1&&h<10?d.ccBits[0]:h<27?d.ccBits[1]:d.ccBits[2]},i.getBestModeForData=function(d){return c.testNumeric(d)?i.NUMERIC:c.testAlphanumeric(d)?i.ALPHANUMERIC:c.testKanji(d)?i.KANJI:i.BYTE},i.toString=function(d){if(d&&d.id)return d.id;throw new Error("Invalid mode")},i.isValid=function(d){return d&&d.bit&&d.ccBits};function o(s){if(typeof s!="string")throw new Error("Param is not a string");switch(s.toLowerCase()){case"numeric":return i.NUMERIC;case"alphanumeric":return i.ALPHANUMERIC;case"kanji":return i.KANJI;case"byte":return i.BYTE;default:throw new Error("Unknown mode: "+s)}}i.from=function(d,h){if(i.isValid(d))return d;try{return o(d)}catch{return h}}})(Hf)),Hf}var Ny;function sE(){return Ny||(Ny=1,(function(i){const u=ha(),c=Xp(),o=Kd(),s=va(),d=Zp(),h=7973,v=u.getBCHDigit(h);function g(N,A,j){for(let z=1;z<=40;z++)if(A<=i.getCapacity(z,j,N))return z}function x(N,A){return s.getCharCountIndicator(N,A)+4}function S(N,A){let j=0;return N.forEach(function(z){const H=x(z.mode,A);j+=H+z.getBitsLength()}),j}function p(N,A){for(let j=1;j<=40;j++)if(S(N,j)<=i.getCapacity(j,A,s.MIXED))return j}i.from=function(A,j){return d.isValid(A)?parseInt(A,10):j},i.getCapacity=function(A,j,z){if(!d.isValid(A))throw new Error("Invalid QR Code version");typeof z>"u"&&(z=s.BYTE);const H=u.getSymbolTotalCodewords(A),q=c.getTotalCodewordsCount(A,j),G=(H-q)*8;if(z===s.MIXED)return G;const k=G-x(z,A);switch(z){case s.NUMERIC:return Math.floor(k/10*3);case s.ALPHANUMERIC:return Math.floor(k/11*2);case s.KANJI:return Math.floor(k/13);case s.BYTE:default:return Math.floor(k/8)}},i.getBestVersionForData=function(A,j){let z;const H=o.from(j,o.M);if(Array.isArray(A)){if(A.length>1)return p(A,H);if(A.length===0)return 1;z=A[0]}else z=A;return g(z.mode,z.getLength(),H)},i.getEncodedBits=function(A){if(!d.isValid(A)||A<7)throw new Error("Invalid QR Code version");let j=A<<12;for(;u.getBCHDigit(j)-v>=0;)j^=h<<u.getBCHDigit(j)-v;return A<<12|j}})(Lf)),Lf}var Gf={},Cy;function fE(){if(Cy)return Gf;Cy=1;const i=ha(),u=1335,c=21522,o=i.getBCHDigit(u);return Gf.getEncodedBits=function(d,h){const v=d.bit<<3|h;let g=v<<10;for(;i.getBCHDigit(g)-o>=0;)g^=u<<i.getBCHDigit(g)-o;return(v<<10|g)^c},Gf}var Yf={},Vf,Ey;function dE(){if(Ey)return Vf;Ey=1;const i=va();function u(c){this.mode=i.NUMERIC,this.data=c.toString()}return u.getBitsLength=function(o){return 10*Math.floor(o/3)+(o%3?o%3*3+1:0)},u.prototype.getLength=function(){return this.data.length},u.prototype.getBitsLength=function(){return u.getBitsLength(this.data.length)},u.prototype.write=function(o){let s,d,h;for(s=0;s+3<=this.data.length;s+=3)d=this.data.substr(s,3),h=parseInt(d,10),o.put(h,10);const v=this.data.length-s;v>0&&(d=this.data.substr(s),h=parseInt(d,10),o.put(h,v*3+1))},Vf=u,Vf}var Qf,wy;function mE(){if(wy)return Qf;wy=1;const i=va(),u=["0","1","2","3","4","5","6","7","8","9","A","B","C","D","E","F","G","H","I","J","K","L","M","N","O","P","Q","R","S","T","U","V","W","X","Y","Z"," ","$","%","*","+","-",".","/",":"];function c(o){this.mode=i.ALPHANUMERIC,this.data=o}return c.getBitsLength=function(s){return 11*Math.floor(s/2)+6*(s%2)},c.prototype.getLength=function(){return this.data.length},c.prototype.getBitsLength=function(){return c.getBitsLength(this.data.length)},c.prototype.write=function(s){let d;for(d=0;d+2<=this.data.length;d+=2){let h=u.indexOf(this.data[d])*45;h+=u.indexOf(this.data[d+1]),s.put(h,11)}this.data.length%2&&s.put(u.indexOf(this.data[d]),6)},Qf=c,Qf}var Xf,Ty;function hE(){if(Ty)return Xf;Ty=1;const i=va();function u(c){this.mode=i.BYTE,typeof c=="string"?this.data=new TextEncoder().encode(c):this.data=new Uint8Array(c)}return u.getBitsLength=function(o){return o*8},u.prototype.getLength=function(){return this.data.length},u.prototype.getBitsLength=function(){return u.getBitsLength(this.data.length)},u.prototype.write=function(c){for(let o=0,s=this.data.length;o<s;o++)c.put(this.data[o],8)},Xf=u,Xf}var Zf,jy;function vE(){if(jy)return Zf;jy=1;const i=va(),u=ha();function c(o){this.mode=i.KANJI,this.data=o}return c.getBitsLength=function(s){return s*13},c.prototype.getLength=function(){return this.data.length},c.prototype.g Line truncated
Make sure your charset is UTF-8`);d=(d>>>8&255)*192+(d&255),o.put(d,13)}},Zf=c,Zf}var Kf={exports:{}},_y;function gE(){return _y||(_y=1,(function(i){var u={single_source_shortest_paths:function(c,o,s){var d={},h={};h[o]=0;var v=u.PriorityQueue.make();v.push(o,0);for(var g,x,S,p,N,A,j,z,H;!v.empty();){g=v.pop(),x=g.value,p=g.cost,N=c[x]||{};for(S in N)N.hasOwnProperty(S)&&(A=N[S],j=p+A,z=h[S],H=typeof h[S]>"u",(H||z>j)&&(h[S]=j,v.push(S,j),d[S]=x))}if(typeof s<"u"&&typeof h[s]>"u"){var q=["Could not find a path from ",o," to ",s,"."].join("");throw new Error(q)}return d},extract_shortest_path_from_predecessor_list:function(c,o){for(var s=[],d=o;d;)s.push(d),c[d],d=c[d];return s.reverse(),s},find_path:function(c,o,s){var d=u.single_source_shortest_paths(c,o,s);return u.extract_shortest_path_from_predecessor_list(d,s)},PriorityQueue:{make:function(c){var o=u.PriorityQueue,s={},d;c=c||{};for(d in o)o.hasOwnProperty(d)&&(s[d]=o[d]);return s.queue=[],s.sorter=c.sorter||o.default_sorter,s},default_sorter:function(c,o){return c.cost-o.cost},push:function(c,o){var s={value:c,cost:o};this.queue.push(s),this.queue.sort(this.sorter)},pop:function(){return this.queue.shift()},empty:function(){return this.queue.length===0}}};i.exports=u})(Kf)),Kf.exports}var Ay;function yE(){return Ay||(Ay=1,(function(i){const u=va(),c=dE(),o=mE(),s=hE(),d=vE(),h=Kp(),v=ha(),g=gE();function x(q){return unescape(encodeURIComponent(q)).length}function S(q,G,k){const V=[];let K;for(;(K=q.exec(k))!==null;)V.push({data:K[0],index:K.index,mode:G,length:K[0].length});return V}function p(q){const G=S(h.NUMERIC,u.NUMERIC,q),k=S(h.ALPHANUMERIC,u.ALPHANUMERIC,q);let V,K;return v.isKanjiModeEnabled()?(V=S(h.BYTE,u.BYTE,q),K=S(h.KANJI,u.KANJI,q)):(V=S(h.BYTE_KANJI,u.BYTE,q),K=[]),G.concat(k,V,K).sort(function(E,R){return E.index-R.index}).map(function(E){return{data:E.data,mode:E.mode,length:E.length}})}function N(q,G){switch(G){case u.NUMERIC:return c.getBitsLength(q);case u.ALPHANUMERIC:return o.getBitsLength(q);case u.KANJI:return d.getBitsLength(q);case u.BYTE:return s.getBitsLength(q)}}function A(q){return q.reduce(function(G,k){const V=G.length-1>=0?G[G.length-1]:null;return V&&V.mode===k.mode?(G[G.length-1].data+=k.data,G):(G.push(k),G)},[])}function j(q){const G=[];for(let k=0;k<q.length;k++){const V=q[k];switch(V.mode){case u.NUMERIC:G.push([V,{data:V.data,mode:u.ALPHANUMERIC,length:V.length},{data:V.data,mode:u.BYTE,length:V.length}]);break;case u.ALPHANUMERIC:G.push([V,{data:V.data,mode:u.BYTE,length:V.length}]);break;case u.KANJI:G.push([V,{data:V.data,mode:u.BYTE,length:x(V.data)}]);break;case u.BYTE:G.push([{data:V.data,mode:u.BYTE,length:x(V.data)}])}}return G}function z(q,G){const k={},V={start:{}};let K=["start"];for(let _=0;_<q.length;_++){const E=q[_],R=[];for(let Q=0;Q<E.length;Q++){const te=E[Q],W=""+_+Q;R.push(W),k[W]={node:te,lastCount:0},V[W]={};for(let P=0;P<K.length;P++){const ne=K[P];k[ne]&&k[ne].node.mode===te.mode?(V[ne][W]=N(k[ne].lastCount+te.length,te.mode)-N(k[ne].lastCount,te.mode),k[ne].lastCount+=te.length):(k[ne]&&(k[ne].lastCount=te.length),V[ne][W]=N(te.length,te.mode)+4+u.getCharCountIndicator(te.mode,G))}}K=R}for(let _=0;_<K.length;_++)V[K[_]].end=0;return{map:V,table:k}}function H(q,G){let k;const V=u.getBestModeForData(q);if(k=u.from(G,V),k!==u.BYTE&&k.bit<V.bit)throw new Error('"'+q+'" cannot be encoded with mode '+u.toString(k)+`.
Suggested mode is: `+u.toString(V));switch(k===u.KANJI&&!v.isKanjiModeEnabled()&&(k=u.BYTE),k){case u.NUMERIC:return new c(q);case u.ALPHANUMERIC:return new o(q);case u.KANJI:return new d(q);case u.BYTE:return new s(q)}}i.fromArray=function(G){return G.reduce(function(k,V){return typeof V=="string"?k.push(H(V,null)):V.data&&k.push(H(V.data,V.mode)),k},[])},i.fromString=function(G,k){const V=p(G,v.isKanjiModeEnabled()),K=j(V),_=z(K,k),E=g.find_path(_.map,"start","end"),R=[];for(let Q=1;Q<E.length-1;Q++)R.push(_.table[E[Q]].node);return i.fromArray(A(R))},i.rawSplit=function(G){return i.fromArray(p(G,v.isKanjiModeEnabled()))}})(Yf)),Yf}var Ry;function pE(){if(Ry)return Af;Ry=1;const i=ha(),u=Kd(),c=nE(),o=lE(),s=aE(),d=iE(),h=rE(),v=Xp(),g=cE(),x=sE(),S=fE(),p=va(),N=yE();function A(_,E){const R=_.size,Q=d.getPositions(E);for(let te=0;te<Q.length;te++){const W=Q[te][0],P=Q[te][1];for(let ne=-1;ne<=7;ne++)if(!(W+ne<=-1||R<=W+ne))for(let re=-1;re<=7;re++)P+re<=-1||R<=P+re||(ne>=0&&ne<=6&&(re===0||re===6)||re>=0&&re<=6&&(ne===0||ne===6)||ne>=2&&ne<=4&&re>=2&&re<=4?_.set(W+ne,P+re,!0,!0):_.set(W+ne,P+re,!1,!0))}}function j(_){const E=_.size;for(let R=8;R<E-8;R++){const Q=R%2===0;_.set(R,6,Q,!0),_.set(6,R,Q,!0)}}function z(_,E){const R=s.getPositions(E);for(let Q=0;Q<R.length;Q++){const te=R[Q][0],W=R[Q][1];for(let P=-2;P<=2;P++)for(let ne=-2;ne<=2;ne++)P===-2||P===2||ne===-2||ne===2||P===0&&ne===0?_.set(te+P,W+ne,!0,!0):_.set(te+P,W+ne,!1,!0)}}function H(_,E){const R=_.size,Q=x.getEncodedBits(E);let te,W,P;for(let ne=0;ne<18;ne++)te=Math.floor(ne/3),W=ne%3+R-8-3,P=(Q>>ne&1)===1,_.set(te,W,P,!0),_.set(W,te,P,!0)}function q(_,E,R){const Q=_.size,te=S.getEncodedBits(E,R);let W,P;for(W=0;W<15;W++)P=(te>>W&1)===1,W<6?_.set(W,8,P,!0):W<8?_.set(W+1,8,P,!0):_.set(Q-15+W,8,P,!0),W<8?_.set(8,Q-W-1,P,!0):W<9?_.set(8,15-W-1+1,P,!0):_.set(8,15-W-1,P,!0);_.set(Q-8,8,1,!0)}function G(_,E){const R=_.size;let Q=-1,te=R-1,W=7,P=0;for(let ne=R-1;ne>0;ne-=2)for(ne===6&&ne--;;){for(let re=0;re<2;re++)if(!_.isReserved(te,ne-re)){let ge=!1;P<E.length&&(ge=(E[P]>>>W&1)===1),_.set(te,ne-re,ge),W--,W===-1&&(P++,W=7)}if(te+=Q,te<0||R<=te){te-=Q,Q=-Q;break}}}function k(_,E,R){const Q=new c;R.forEach(function(re){Q.put(re.mode.bit,4),Q.put(re.getLength(),p.getCharCountIndicator(re.mode,_)),re.write(Q)});const te=i.getSymbolTotalCodewords(_),W=v.getTotalCodewordsCount(_,E),P=(te-W)*8;for(Q.getLengthInBits()+4<=P&&Q.put(0,4);Q.getLengthInBits()%8!==0;)Q.putBit(0);const ne=(P-Q.getLengthInBits())/8;for(let re=0;re<ne;re++)Q.put(re%2?17:236,8);return V(Q,_,E)}function V(_,E,R){const Q=i.getSymbolTotalCodewords(E),te=v.getTotalCodewordsCount(E,R),W=Q-te,P=v.getBlocksCount(E,R),ne=Q%P,re=P-ne,ge=Math.floor(Q/P),F=Math.floor(W/P),ae=F+1,ce=ge-F,xe=new g(ce);let I=0;const Se=new Array(P),J=new Array(P);let me=0;const w=new Uint8Array(_.buffer);for(let ie=0;ie<P;ie++){const Ne=ie<re?F:ae;Se[ie]=w.slice(I,I+Ne),J[ie]=xe.encode(Se[ie]),I+=Ne,me=Math.max(me,Ne)}const U=new Uint8Array(Q);let $=0,ee,he;for(ee=0;ee<me;ee++)for(he=0;he<P;he++)ee<Se[he].length&&(U[$++]=Se[he][ee]);for(ee=0;ee<ce;ee++)for(he=0;he<P;he++)U[$++]=J[he][ee];return U}function K(_,E,R,Q){let te;if(Array.isArray(_))te=N.fromArray(_);else if(typeof _=="string"){let ge=E;if(!ge){const F=N.rawSplit(_);ge=x.getBestVersionForData(F,R)}te=N.fromString(_,ge||40)}else throw new Error("Invalid data");const W=x.getBestVersionForData(te,R);if(!W)throw new Error("The amount of data is too big to be stored in a QR Code");if(!E)E=W;else if(E<W)throw new Error(`
The chosen QR Code version cannot contain this amount of data.
Minimum version required to store current data is: `+W+`.
`);const P=k(E,R,te),ne=i.getSymbolSize(E),re=new o(ne);return A(re,E),j(re),z(re,E),q(re,R,0),E>=7&&H(re,E),G(re,P),isNaN(Q)&&(Q=h.getBestMask(re,q.bind(null,re,R))),h.applyMask(Q,re),q(re,R,Q),{modules:re,version:E,errorCorrectionLevel:R,maskPattern:Q,segments:te}}return Af.create=function(E,R){if(typeof E>"u"||E==="")throw new Error("No input text");let Q=u.M,te,W;return typeof R<"u"&&(Q=u.from(R.errorCorrectionLevel,u.M),te=x.from(R.version),W=h.from(R.maskPattern),R.toSJISFunc&&i.setToSJISFunction(R.toSJISFunc)),K(E,te,Q,W)},Af}var If={},Jf={},My;function Ip(){return My||(My=1,(function(i){function u(c){if(typeof c=="number"&&(c=c.toString()),typeof c!="string")throw new Error("Color should be defined as hex string");let o=c.slice().replace("#","").split("");if(o.length<3||o.length===5||o.length>8)throw new Error("Invalid hex color: "+c);(o.length===3||o.length===4)&&(o=Array.prototype.concat.apply([],o.map(function(d){return[d,d]}))),o.length===6&&o.push("F","F");const s=parseInt(o.join(""),16);return{r:s>>24&255,g:s>>16&255,b:s>>8&255,a:s&255,hex:"#"+o.slice(0,6).join("")}}i.getOptions=function(o){o||(o={}),o.color||(o.color={});const s=typeof o.margin>"u"||o.margin===null||o.margin<0?4:o.margin,d=o.width&&o.width>=21?o.width:void 0,h=o.scale||4;return{width:d,scale:d?4:h,margin:s,color:{dark:u(o.color.dark||"#000000ff"),light:u(o.color.light||"#ffffffff")},type:o.type,rendererOpts:o.rendererOpts||{}}},i.getScale=function(o,s){return s.width&&s.width>=o+s.margin*2?s.width/(o+s.margin*2):s.scale},i.getImageWidth=function(o,s){const d=i.getScale(o,s);return Math.floor((o+s.margin*2)*d)},i.qrToImageData=function(o,s,d){const h=s.modules.size,v=s.modules.data,g=i.getScale(h,d),x=Math.floor((h+d.margin*2)*g),S=d.margin*g,p=[d.color.light,d.color.dark];for(let N=0;N<x;N++)for(let A=0;A<x;A++){let j=(N*x+A)*4,z=d.color.light;if(N>=S&&A>=S&&N<x-S&&A<x-S){const H=Math.floor((N-S)/g),q=Math.floor((A-S)/g);z=p[v[H*h+q]?1:0]}o[j++]=z.r,o[j++]=z.g,o[j++]=z.b,o[j]=z.a}}})(Jf)),Jf}var Oy;function bE(){return Oy||(Oy=1,(function(i){const u=Ip();function c(s,d,h){s.clearRect(0,0,d.width,d.height),d.style||(d.style={}),d.height=h,d.width=h,d.style.height=h+"px",d.style.width=h+"px"}function o(){try{return document.createElement("canvas")}catch{throw new Error("You need to specify a canvas element")}}i.render=function(d,h,v){let g=v,x=h;typeof g>"u"&&(!h||!h.getContext)&&(g=h,h=void 0),h||(x=o()),g=u.getOptions(g);const S=u.getImageWidth(d.modules.size,g),p=x.getContext("2d"),N=p.createImageData(S,S);return u.qrToImageData(N.data,d,g),c(p,x,S),p.putImageData(N,0,0),x},i.renderToDataURL=function(d,h,v){let g=v;typeof g>"u"&&(!h||!h.getContext)&&(g=h,h=void 0),g||(g={});const x=i.render(d,h,g),S=g.type||"image/png",p=g.rendererOpts||{};return x.toDataURL(S,p.quality)}})(If)),If}var Ff={},zy;function xE(){if(zy)return Ff;zy=1;const i=Ip();function u(s,d){const h=s.a/255,v=d+'="'+s.hex+'"';return h<1?v+" "+d+'-opacity="'+h.toFixed(2).slice(1)+'"':v}function c(s,d,h){let v=s+d;return typeof h<"u"&&(v+=" "+h),v}function o(s,d,h){let v="",g=0,x=!1,S=0;for(let p=0;p<s.length;p++){const N=Math.floor(p%d),A=Math.floor(p/d);!N&&!x&&(x=!0),s[p]?(S++,p>0&&N>0&&s[p-1]||(v+=x?c("M",N+h,.5+A+h):c("m",g,0),g=0,x=!1),N+1<d&&s[p+1]||(v+=c("h",S),S=0)):g++}return v}return Ff.render=function(d,h,v){const g=i.getOptions(h),x=d.modules.size,S=d.modules.data,p=x+g.margin*2,N=g.color.light.a?"<path "+u(g.color.light,"fill")+' d="M0 0h'+p+"v"+p+'H0z"/>':"",A="<path "+u(g.color.dark,"stroke")+' d="'+o(S,x,g.margin)+'"/>',j='viewBox="0 0 '+p+" "+p+'"',H='<svg xmlns="http://www.w3.org/2000/svg" '+(g.width?'width="'+g.width+'" height="'+g.width+'" ':"")+j+' shape-rendering="crispEdges">'+N+A+`</svg>
`;return typeof v=="function"&&v(null,H),H},Ff}var Dy;function SE(){if(Dy)return si;Dy=1;const i=tE(),u=pE(),c=bE(),o=xE();function s(d,h,v,g,x){const S=[].slice.call(arguments,1),p=S.length,N=typeof S[p-1]=="function";if(!N&&!i())throw new Error("Callback required as last argument");if(N){if(p<2)throw new Error("Too few arguments provided");p===2?(x=v,v=h,h=g=void 0):p===3&&(h.getContext&&typeof x>"u"?(x=g,g=void 0):(x=g,g=v,v=h,h=void 0))}else{if(p<1)throw new Error("Too few arguments provided");return p===1?(v=h,h=g=void 0):p===2&&!h.getContext&&(g=v,v=h,h=void 0),new Promise(function(A,j){try{const z=u.create(v,g);A(d(z,h,g))}catch(z){j(z)}})}try{const A=u.create(v,g);x(null,d(A,h,g))}catch(A){x(A)}}return si.create=u.create,si.toCanvas=s.bind(null,c.render),si.toDataURL=s.bind(null,c.renderToDataURL),si.toString=s.bind(null,function(d,h,v){return o.render(d,v)}),si}var NE=SE();const Pf=gd(NE),CE=()=>{const{showWechatModal:i,setShowWechatModal:u,showToast:c,fetchTelemetry:o}=Un(),[s,d]=b.useState(null),[h,v]=b.useState(""),[g,x]=b.useState(!1),[S,p]=b.useState(""),[N,A]=b.useState(!1),j=async()=>{var k;x(!0);try{const K=await(await fetch("/api/clawbot/qrcode",{method:"POST"})).json();d(K);const _=(k=K==null?void 0:K.login)==null?void 0:k.qrcode_url;if(_){const E=await Pf.toDataURL(_,{width:240,margin:2,color:{dark:"#09090b",light:"#ffffff"}});v(E)}}catch{c("获取微信 ClawBot 二维码失败")}finally{x(!1)}};b.useEffect(()=>{if(!i)return;let k=!1;(async()=>{var _,E,R;try{const te=await(await fetch("/api/clawbot/status")).json();if(k)return;if(d(te),!te.connected&&(!((_=te.login)!=null&&_.active)||!((E=te.login)!=null&&E.qrcode_url)))await j();else if((R=te.login)!=null&&R.qrcode_url){const W=await Pf.toDataURL(te.login.qrcode_url,{width:240,margin:2,color:{dark:"#09090b",light:"#ffffff"}});k||v(W)}}catch{}})();const K=setInterval(async()=>{var _;try{const E=await fetch("/api/clawbot/status");if(!E.ok)return;const R=await E.json();if(k)return;if(d(R),(_=R.login)!=null&&_.qrcode_url){const Q=await Pf.toDataURL(R.login.qrcode_url,{width:240,margin:2,color:{dark:"#09090b",light:"#ffffff"}});k||v(Q)}}catch{}},1500);return()=>{k=!0,clearInterval(K)}},[i]);const z=async k=>{if(k.preventDefault(),!!S.trim()){A(!0);try{const K=await(await fetch("/api/clawbot/verify",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({code:S.trim()})})).json();d(K),p(""),c("配对码已提交,正在等待微信验证...")}catch{c("配对码提交失败")}finally{A(!1)}}},H=async()=>{try{const V=await(await fetch("/api/clawbot/disconnect",{method:"POST"})).json();d(V),v(""),o(),c("已断开微信 ClawBot 连接")}catch{c("断开失败")}},q=!!(s!=null&&s.connected),G=s==null?void 0:s.login;return f.jsx(Gd,{open:i,onOpenChange:u,children:f.jsxs(wo,{className:"max-w-lg p-6 space-y-5",children:[f.jsx("div",{className:"flex items-start justify-between gap-3 border-b hl-border pb-4",children:f.jsxs("div",{className:"flex items-center gap-3",children:[f.jsx("div",{className:"w-10 h-10 rounded-xl flex items-center justify-center border hl-border shrink-0",style:{backgroundColor:"rgba(16, 185, 129, 0.14)",color:"var(--success)"},children:f.jsx(pd,{className:"w-5 h-5"})}),f.jsxs("div",{children:[f.jsxs("div",{className:"flex items-center gap-2",children:[f.jsx(To,{className:"text-sm font-bold",children:"微信 ClawBot · M6 Mac mini 直连通道"}),f.jsx(rt,{variant:q?"success":"accent",children:q?"已连接在线":"扫码绑定"})]}),f.jsx(jo,{className:"text-xs font-mono-num mt-0.5",children:"@tencent-weixin/openclaw-weixin · ilinkai.weixin.qq.com"})]})]})}),q?f.jsxs("div",{className:"space-y-4",children:[f.jsxs("div",{className:"p-4 rounded-xl border flex items-center justify-between",style:{backgroundColor:"var(--success-soft)",borderColor:"rgba(16, 185, 129, 0.3)"},children:[f.jsxs("div",{className:"flex items-center gap-3",children:[f.jsx(SS,{className:"w-5 h-5 shrink-0",style:{color:"var(--success)"}}),f.jsxs("div",{children:[f.jsx("div",{className:"text-xs font-bold",children:"微信 ClawBot 长轮询守护线程运行中"}),f.jsxs("div",{className:"text-[11px] font-mono-num mt-0.5",style:{color:"var(--text-muted)"},children:["Bot ID: ",s==null?void 0:s.bot_id," · 累计收发: ",(s==null?void 0:s.msg_count)??0," 条"]})]})]}),f.jsxs(Oe,{variant:"outline",size:"sm",onClick:H,children:[f.jsx(uN,{className:"w-3.5 h-3.5"}),f.jsx("span",{children:"重新绑定"})]})]}),f.jsxs("div",{className:"p-4 rounded-xl hl-muted border hl-border space-y-2.5 text-xs",children:[f.jsx("div",{className:"font-semibold",children:"📱 现在打开手机微信「微信 ClawBot」可直接发送:"}),f.jsxs("div",{className:"grid grid-cols-1 sm:grid-cols-2 gap-2 font-mono-num text-[11px]",children:[f.jsxs("div",{className:"p-2 rounded hl-card",children:[f.jsx("span",{style:{color:"var(--accent)"},children:"状态"})," · 查看 M6 & 极空间遥测"]}),f.jsxs("div",{className:"p-2 rounded hl-card",children:[f.jsx("span Line truncated
+20
View File
@@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>HomeLab Console · 家庭云边中控台</title>
<link rel="icon" href="https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/apple.png" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap"
rel="stylesheet"
/>
<script type="module" crossorigin src="/assets/index-DnLoh9Rp.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BHNk_TrW.css">
</head>
<body class="min-h-screen">
<div id="root"></div>
</body>
</html>
+324 -10
View File
@@ -1,28 +1,69 @@
#!/usr/bin/env python3
# ==============================================================================
# HomeLab All-in-One 专属中控台后端服务 (Port 3002)
# 提供 Apple/Shadcn 风格单页应用与全屋设备实时遥测 API (/api/telemetry, /api/action)
# HomeLab All-in-One 专属中控台后端服务 (Port 3002 & 3004)
# 提供:
# 1. Apple/Shadcn 风格单页应用静态资源
# 2. 全屋设备实时遥测 API (/api/telemetry, /api/action/*)
# 3. 统一自定义 SSO 认证网关 (/api/auth/verify, /api/auth/login, /api/auth/status)
# 彻底替代浏览器原生丑陋的 HTTP Basic Auth 弹窗!
# ==============================================================================
import hashlib
import hmac
import json
import mimetypes
import os
import re
import socket
import subprocess
import threading
import time
import urllib.parse
import urllib.request
from concurrent.futures import ThreadPoolExecutor
from http.cookies import SimpleCookie
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from clawbot import ClawBotManager
PORT = int(os.environ.get("PORT", "3004"))
BASE_DIR = Path(__file__).resolve().parent
DIST_DIR = BASE_DIR / "dist"
INDEX_FILE = BASE_DIR / "index.html"
# Unified SSO Credentials & HMAC Secret
AUTH_USER = os.environ.get("HOMELAB_USER", "ixvii0iv")
AUTH_PASS = os.environ.get("HOMELAB_PASS", "k10y15ZXC.")
AUTH_SECRET = os.environ.get("HOMELAB_SECRET", "hl-m6-macmini-sso-secret-2026-961504xyz").encode("utf-8")
COOKIE_NAME = "hl_sso"
COOKIE_MAX_AGE = 30 * 86400 # 30 Days
_cache = {"data": None, "ts": 0.0}
_cache_lock = threading.Lock()
def create_sso_token(username: str) -> str:
exp = int(time.time()) + COOKIE_MAX_AGE
payload = f"{username}:{exp}"
sig = hmac.new(AUTH_SECRET, payload.encode("utf-8"), hashlib.sha256).hexdigest()
return f"{payload}:{sig}"
def verify_sso_token(token: str) -> tuple[bool, str]:
if not token or token.count(":") != 2:
return False, ""
try:
username, exp_str, sig = token.split(":", 2)
if int(exp_str) < int(time.time()):
return False, ""
payload = f"{username}:{exp_str}"
expected = hmac.new(AUTH_SECRET, payload.encode("utf-8"), hashlib.sha256).hexdigest()
if hmac.compare_digest(sig, expected):
return True, username
except Exception:
pass
return False, ""
def run_cmd(cmd: str, timeout: float = 2.0) -> str:
try:
env = os.environ.copy()
@@ -48,7 +89,6 @@ def check_tcp(host: str, port: int, timeout: float = 0.8) -> tuple[bool, int]:
def get_mac_mini_stats() -> dict:
# 1. CPU Load & Core count
load_str = run_cmd("sysctl -n vm.loadavg")
# Format: "{ 1.42 1.18 1.05 }"
loads = [float(x) for x in re.findall(r"[0-9]+\.[0-9]+", load_str)] or [0.0, 0.0, 0.0]
cores = int(run_cmd("sysctl -n hw.ncpu") or "12")
cpu_pct = min(100, round((loads[0] / max(1, cores)) * 100, 1))
@@ -228,24 +268,134 @@ def get_network_and_edge_stats() -> dict:
}
def _codeg_call(endpoint: str, payload=None) -> object:
try:
req = urllib.request.Request(
f"http://127.0.0.1:3080/api/{endpoint}",
data=json.dumps(payload or {}).encode("utf-8"),
headers={
"Authorization": "Bearer homelab2026",
"Content-Type": "application/json",
},
method="POST",
)
with urllib.request.urlopen(req, timeout=2.0) as r:
return json.loads(r.read().decode("utf-8"))
except Exception:
return None
def get_agents_and_git_overview() -> dict:
codeg_ok, codeg_ms = check_tcp("127.0.0.1", 3080)
gitea_ok, gitea_ms = check_tcp("127.0.0.1", 3005)
assistants = _codeg_call("assistant_list") if codeg_ok else []
folders = _codeg_call("list_folders") if codeg_ok else []
conversations = _codeg_call("list_conversations") if codeg_ok else []
stats = _codeg_call("get_stats") if codeg_ok else {}
# Query local Gitea repositories
repos = []
if gitea_ok:
try:
with urllib.request.urlopen("http://127.0.0.1:3005/api/v1/repos/search?limit=10", timeout=2.0) as r:
rdata = json.loads(r.read().decode("utf-8"))
for item in rdata.get("data", []):
repos.append(
{
"name": item.get("full_name"),
"description": item.get("description", ""),
"default_branch": item.get("default_branch", "main"),
"updated_at": item.get("updated_at", ""),
"size_kb": item.get("size", 0),
"html_url": item.get("html_url", ""),
"clone_url": item.get("clone_url", ""),
"ssh_url": item.get("ssh_url", ""),
}
)
except Exception:
pass
# Query active tmux windows in 'agy' session
tmux_windows = []
try:
out = run_cmd("/opt/homebrew/bin/tmux list-windows -t agy -F '#{window_index}|#{window_name}|#{window_active}|#{pane_current_command}'", timeout=1.5)
for line in out.splitlines():
parts = line.split("|")
if len(parts) >= 4:
tmux_windows.append(
{
"index": parts[0],
"name": parts[1],
"active": parts[2] == "1",
"command": parts[3],
}
)
except Exception:
pass
# Check ZSpace NAS Git Cold Storage bundles
cold_dir = Path("/Users/ixvii0iv/nas/个人空间/Git_Cold_Storage/bundles")
cold_bundles = []
try:
if cold_dir.exists():
for f in sorted(cold_dir.glob("*.bundle")):
st = f.stat()
cold_bundles.append(
{
"name": f.name,
"size_mb": round(st.st_size / (1024 * 1024), 2),
"mtime": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(st.st_mtime)),
}
)
except Exception:
pass
return {
"codeg": {
"online": codeg_ok,
"latency_ms": codeg_ms,
"version": "0.42.0",
"assistants": assistants if isinstance(assistants, list) else [],
"folders": folders if isinstance(folders, list) else [],
"conversations": conversations if isinstance(conversations, list) else [],
"stats": stats if isinstance(stats, dict) else {},
},
"gitea": {
"online": gitea_ok,
"latency_ms": gitea_ms,
"version": "28.3.0",
"hot_path": "/Users/ixvii0iv/homelab/stacks/git/data",
"cold_path": "/Users/ixvii0iv/nas/个人空间/Git_Cold_Storage",
"repos": repos,
"cold_bundles": cold_bundles,
},
"tmux_windows": tmux_windows,
}
def collect_telemetry() -> dict:
now = time.time()
with _cache_lock:
if _cache["data"] and (now - _cache["ts"] < 2.0):
return _cache["data"]
with ThreadPoolExecutor(max_workers=4) as pool:
with ThreadPoolExecutor(max_workers=6) as pool:
f_mini = pool.submit(get_mac_mini_stats)
f_nas = pool.submit(get_nas_stats)
f_net = pool.submit(get_network_and_edge_stats)
f_agy = pool.submit(check_tcp, "127.0.0.1", 7681)
f_vw = pool.submit(check_tcp, "127.0.0.1", 8222)
f_codeg = pool.submit(check_tcp, "127.0.0.1", 3080)
f_gitea = pool.submit(check_tcp, "127.0.0.1", 3005)
mini = f_mini.result()
nas = f_nas.result()
net = f_net.result()
agy_ok, _ = f_agy.result()
vw_ok, _ = f_vw.result()
codeg_ok, _ = f_codeg.result()
gitea_ok, _ = f_gitea.result()
data = {
"timestamp": time.strftime("%Y-%m-%d %H:%M:%S"),
@@ -254,12 +404,16 @@ def collect_telemetry() -> dict:
"network": net,
"services": {
"agy": agy_ok,
"codeg": codeg_ok,
"gitea": gitea_ok,
"vaultwarden": vw_ok,
"nas": nas["online"],
"clash": net["router_online"],
"router": net["router_online"],
"uptime": net["azure_online"],
"clawbot": clawbot_mgr.is_connected(),
},
"clawbot": clawbot_mgr.get_public_status(),
}
with _cache_lock:
_cache["data"] = data
@@ -267,27 +421,115 @@ def collect_telemetry() -> dict:
return data
clawbot_mgr = ClawBotManager(telemetry_fn=collect_telemetry)
class PortalHandler(BaseHTTPRequestHandler):
def log_message(self, fmt, *args):
pass
def _send_json(self, obj: dict, status: int = 200):
def _get_cookie_token(self) -> str:
raw_cookie = self.headers.get("Cookie", "")
if not raw_cookie:
return ""
c = SimpleCookie()
try:
c.load(raw_cookie)
if COOKIE_NAME in c:
return c[COOKIE_NAME].value
except Exception:
pass
# Fallback regex parse
m = re.search(rf"{COOKIE_NAME}=([^;\s]+)", raw_cookie)
return m.group(1) if m else ""
def _is_authenticated(self) -> tuple[bool, str]:
token = self._get_cookie_token()
return verify_sso_token(token)
def _send_json(self, obj: dict, status: int = 200, extra_headers=None):
body = json.dumps(obj, ensure_ascii=False).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Cache-Control", "no-store")
if extra_headers:
for k, v in extra_headers:
self.send_header(k, v)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_GET(self):
# 1. Caddy forward_auth verification endpoint (/api/auth/verify)
if self.path.startswith("/api/auth/verify"):
ok, username = self._is_authenticated()
if ok:
self.send_response(200)
self.send_header("X-Auth-User", username)
self.send_header("Content-Length", "2")
self.end_headers()
self.wfile.write(b"OK")
return
# Not authenticated -> Redirect to our custom-styled HomeLab Console Login Modal!
fwd_proto = self.headers.get("X-Forwarded-Proto", "https")
fwd_host = self.headers.get("X-Forwarded-Host") or self.headers.get("Host", "agy.961504.xyz")
fwd_uri = self.headers.get("X-Forwarded-Uri", "/")
target_url = f"{fwd_proto}://{fwd_host}{fwd_uri}"
encoded_target = urllib.parse.quote(target_url, safe="")
if ".home.961504.xyz" in fwd_host or ":10443" in fwd_host:
login_portal = f"https://home.961504.xyz:10443/?auth_redirect={encoded_target}"
else:
login_portal = f"https://nav.961504.xyz/?auth_redirect={encoded_target}"
self.send_response(302)
self.send_header("Location", login_portal)
self.send_header("Cache-Control", "no-store")
self.send_header("Content-Length", "0")
self.end_headers()
return
# 2. Check current SSO status (/api/auth/status)
if self.path.startswith("/api/auth/status"):
ok, username = self._is_authenticated()
self._send_json({"authenticated": ok, "username": username if ok else None})
return
# 3. Telemetry endpoint (/api/telemetry)
if self.path.startswith("/api/telemetry"):
self._send_json(collect_telemetry())
return
# Serve SPA index.html for root and any path
if INDEX_FILE.exists():
body = INDEX_FILE.read_bytes()
# 3b. WeChat ClawBot status endpoint (/api/clawbot/status)
if self.path.startswith("/api/clawbot/status"):
self._send_json(clawbot_mgr.get_public_status())
return
# 3c. Agent Orchestrator & Gitea Cold/Hot Storage overview (/api/agents/overview)
if self.path.startswith("/api/agents/overview"):
self._send_json(get_agents_and_git_overview())
return
# 4. Serve static assets from Vite build output (dist/) if available
clean_path = self.path.split("?", 1)[0].lstrip("/")
if clean_path and DIST_DIR.exists():
asset_file = (DIST_DIR / clean_path).resolve()
if str(asset_file).startswith(str(DIST_DIR.resolve())) and asset_file.is_file():
body = asset_file.read_bytes()
mime, _ = mimetypes.guess_type(str(asset_file))
self.send_response(200)
self.send_header("Content-Type", mime or "application/octet-stream")
self.send_header("Cache-Control", "public, max-age=86400")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
return
# 5. Serve SPA index.html from dist/index.html
spa_index = (DIST_DIR / "index.html") if (DIST_DIR / "index.html").exists() else INDEX_FILE
if spa_index.exists():
body = spa_index.read_bytes()
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Cache-Control", "no-cache")
@@ -298,6 +540,71 @@ class PortalHandler(BaseHTTPRequestHandler):
self.send_error(404, "index.html not found")
def do_POST(self):
# 1. Unified Custom Login Endpoint (/api/auth/login)
if self.path == "/api/auth/login":
length = int(self.headers.get("Content-Length", "0"))
raw = self.rfile.read(length).decode("utf-8") if length > 0 else "{}"
try:
body = json.loads(raw)
except Exception:
body = {}
username = str(body.get("username", "")).strip()
password = str(body.get("password", ""))
if username == AUTH_USER and password == AUTH_PASS:
token = create_sso_token(username)
host = self.headers.get("Host", "")
headers = []
# Set cross-subdomain cookie for *.961504.xyz (works for both nav.961504.xyz, agy.961504.xyz, *.home.961504.xyz)
if "961504.xyz" in host:
headers.append(
(
"Set-Cookie",
f"{COOKIE_NAME}={token}; Domain=.961504.xyz; Path=/; Max-Age={COOKIE_MAX_AGE}; HttpOnly; Secure; SameSite=Lax",
)
)
else:
headers.append(
(
"Set-Cookie",
f"{COOKIE_NAME}={token}; Path=/; Max-Age={COOKIE_MAX_AGE}; HttpOnly; SameSite=Lax",
)
)
self._send_json({"ok": True, "username": username}, status=200, extra_headers=headers)
else:
self._send_json({"ok": False, "message": "用户名或访问密码不正确"}, status=401)
return
# 2. Logout Endpoint (/api/auth/logout)
if self.path == "/api/auth/logout":
headers = [
("Set-Cookie", f"{COOKIE_NAME}=; Domain=.961504.xyz; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=Lax"),
("Set-Cookie", f"{COOKIE_NAME}=; Path=/; Max-Age=0; HttpOnly; SameSite=Lax"),
]
self._send_json({"ok": True}, status=200, extra_headers=headers)
return
# 2b. WeChat ClawBot Endpoints (/api/clawbot/*)
if self.path == "/api/clawbot/qrcode":
res = clawbot_mgr.start_qr_login()
self._send_json(res, status=200 if res.get("ok") else 500)
return
if self.path == "/api/clawbot/verify":
length = int(self.headers.get("Content-Length", "0"))
raw = self.rfile.read(length).decode("utf-8") if length > 0 else "{}"
try:
body = json.loads(raw)
except Exception:
body = {}
res = clawbot_mgr.submit_verify_code(str(body.get("code", "")))
self._send_json(res)
return
if self.path == "/api/clawbot/disconnect":
res = clawbot_mgr.disconnect()
self._send_json(res)
return
# 3. Operations Actions
if self.path == "/api/action/backup_nas":
out = run_cmd("/Users/ixvii0iv/homelab/scripts/backup-to-nas.sh", timeout=15.0)
with _cache_lock:
@@ -316,10 +623,17 @@ class PortalHandler(BaseHTTPRequestHandler):
_cache["ts"] = 0.0
self._send_json({"ok": True, "message": out or "Cloudflare DDNS 已刷新"})
return
if self.path == "/api/action/sync_git_nas":
out = run_cmd("/Users/ixvii0iv/homelab/scripts/sync-git-to-nas.sh", timeout=20.0)
self._send_json({"ok": True, "message": out or "Git 热层与极空间 20TB 冷备归档已同步"})
return
self._send_json({"ok": False, "message": "Unknown action"}, status=404)
if __name__ == "__main__":
server = ThreadingHTTPServer(("0.0.0.0", PORT), PortalHandler)
print(f"[HomeLab Portal] Listening on http://0.0.0.0:{PORT}")
# 同时监听主入口 3002 (https://nav.961504.xyz) 与预览端口 3004
srv_preview = ThreadingHTTPServer(("0.0.0.0", 3004), PortalHandler)
threading.Thread(target=srv_preview.serve_forever, daemon=True).start()
server = ThreadingHTTPServer(("0.0.0.0", 3002), PortalHandler)
print("[HomeLab Portal V3] Listening on http://0.0.0.0:3002 and :3004")
server.serve_forever()
+19
View File
@@ -0,0 +1,19 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>HomeLab Console · 家庭云边中控台</title>
<link rel="icon" href="https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/apple.png" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap"
rel="stylesheet"
/>
</head>
<body class="min-h-screen">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+3676
View File
File diff suppressed because it is too large. Load diff
+38
View File
@@ -0,0 +1,38 @@
{
"name": "homelab-console",
"private": true,
"version": "3.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@radix-ui/react-dialog": "^1.3.1",
"@radix-ui/react-label": "^2.1.17",
"@radix-ui/react-separator": "^1.2.0",
"@radix-ui/react-slot": "^1.5.0",
"@radix-ui/react-switch": "^1.4.1",
"@radix-ui/react-tabs": "^1.2.1",
"@types/qrcode": "^1.5.6",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"lucide-react": "^0.475.0",
"qrcode": "^1.5.4",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"tailwind-merge": "^3.0.1",
"zustand": "^5.0.3"
},
"devDependencies": {
"@tailwindcss/vite": "^4.0.6",
"@types/react": "^19.0.8",
"@types/react-dom": "^19.0.3",
"@vitejs/plugin-react": "^4.3.4",
"tailwindcss": "^4.0.6",
"typescript": "^5.7.3",
"vite": "^6.1.0"
}
}
+303
View File
@@ -0,0 +1,303 @@
import React, { useEffect, useMemo, useState } from 'react';
import { usePortalStore } from './store/usePortalStore';
import { TopRibbon } from './components/TopRibbon';
import { ServiceCard } from './components/ServiceCard';
import { SshShelf } from './components/SshShelf';
import { CommandPalette } from './components/CommandPalette';
import { AgyTerminalModal } from './components/AgyTerminalModal';
import { AuthGateCard } from './components/AuthGateModal';
import { WechatClawModal } from './components/WechatClawModal';
import { StorybookWorkbench } from './components/StorybookWorkbench';
import { AgentOrchestratorWorkbench } from './components/AgentOrchestratorWorkbench';
import { LAYOUT_PRESETS } from './design-system/tokens';
import type { ServiceGroup } from './types/telemetry';
export const App: React.FC = () => {
const {
telemetry,
toast,
viewMode,
layoutPreset,
fetchTelemetry,
setShowCmdPalette,
getEffectiveMode,
routeMode,
} = usePortalStore();
const [authRedirect, setAuthRedirect] = useState<string | null>(() => {
if (typeof window === 'undefined') return null;
const params = new URLSearchParams(window.location.search);
return params.get('auth_redirect');
});
useEffect(() => {
fetchTelemetry();
const timer = setInterval(fetchTelemetry, 3000);
const onKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
e.preventDefault();
setShowCmdPalette(true);
}
};
window.addEventListener('keydown', onKeyDown);
return () => {
clearInterval(timer);
window.removeEventListener('keydown', onKeyDown);
};
}, [fetchTelemetry, setShowCmdPalette]);
const activeModeLabel = useMemo(() => {
const m = getEffectiveMode();
if (m === 'lan') return 'LAN 局域网极速直连 (192.168.31.x)';
if (m === 'home10443') return '10443 家宽公网 HTTPS 直连';
return '443 免端口 HTTPS 直达 (*.961504.xyz)';
}, [getEffectiveMode, routeMode]);
const gridClass =
LAYOUT_PRESETS[layoutPreset]?.gridClass || LAYOUT_PRESETS['homepage-3col'].gridClass;
const serviceGroups = useMemo<ServiceGroup[]>(() => {
const t = telemetry;
return [
{
title: '核心工作台、多智能体编排与私有代码库',
subtitle: 'AI Agent Orchestration, Code Hub & Storage',
showRouteHint: true,
items: [
{
id: 'codeg',
title: 'Codeg 多智能体协同工作台',
subtitle: 'codeg.961504.xyz · ACP 20+ CLI 底座',
iconUrl: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/github-copilot.png',
online: t.services.codeg ?? true,
desc: '原生聚合 Google Antigravity (agy)、Claude Code、Codex 等 20 种 CLI,支持 4 大分工角色单聊、@点名群聊委派、Git Worktree 待办与无限画布。',
edgeUrl: 'https://codeg.961504.xyz',
homeUrl: 'https://codeg.home.961504.xyz:10443',
lanUrl: 'http://192.168.31.164:3080',
stats: [
{ label: '预设分工角色', value: '4 角色就绪', highlight: true },
{ label: '底座引擎', value: 'Codeg v0.42.0' },
{ label: 'SSO 免密', value: 'Proxy Auth 已注入' },
],
},
{
id: 'gitea',
title: 'Gitea 私有代码与冷备中枢',
subtitle: 'git.961504.xyz · Mac SSD 热层 + NAS 冷层',
iconUrl: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/gitea.png',
online: t.services.gitea ?? true,
desc: '自托管私有 Git 代码仓库,数据库与活跃索引跑在 M6 Mac mini 本地 SSD,Git LFS 大文件与 .bundle 全量冷备归档于极空间 20TB RAID。',
edgeUrl: 'https://git.961504.xyz',
homeUrl: 'https://git.home.961504.xyz:10443',
lanUrl: 'http://192.168.31.164:3005',
stats: [
{ label: '主仓库', value: 'admin/homelab', highlight: true },
{ label: '热层介质', value: 'M6 NVMe SSD' },
{ label: '冷存储池', value: '极空间 20TB NAS' },
],
},
{
id: 'agy',
title: 'AGY 云端工作台',
subtitle: 'agy.961504.xyz · ttyd + tmux',
iconUrl: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/google-gemini.png',
online: t.services.agy,
actionType: 'drawer',
desc: 'M6 Mac mini 上的 24 小时常驻 AI 编程终端,已同步 Google 登录态并锁定新加坡出口,关掉网页后台继续运行。',
edgeUrl: 'https://agy.961504.xyz',
homeUrl: 'https://agy.home.961504.xyz:10443',
lanUrl: 'http://192.168.31.164:7681',
stats: [
{ label: 'M6 CPU 负载', value: `${t.mini.cpu_pct}%` },
{ label: '已用内存', value: `${t.mini.mem_used_gb} / 24 GB` },
{
label: 'tmux 会话',
value: t.mini.agy_tmux ? '常驻运行中' : '待机',
highlight: true,
},
],
},
{
id: 'zspace',
title: '极空间 Z4 私有云',
subtitle: '192.168.31.185 · 20TB RAID 存储池',
iconUrl: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/synology-drive.png',
online: t.services.nas,
desc: '家庭 20TB 大容量存储中心、极影视蓝光影院与手机相册备份,已自动 SMB 挂载至 Mac mini ~/nas。',
edgeUrl: 'https://z4.home.961504.xyz:10443',
homeUrl: 'https://z4.home.961504.xyz:10443',
lanUrl: 'http://192.168.31.185:5055',
stats: [
{ label: '剩余可用', value: `${t.nas.free_tb} TB`, highlight: true },
{ label: '已用容量', value: `${t.nas.used_tb} TB (${t.nas.pct}%)` },
{ label: 'SMB 挂载', value: t.nas.mounted ? '~/nas 已挂载' : '重连中' },
],
},
{
id: 'vaultwarden',
title: 'Vaultwarden 密码保险箱',
subtitle: 'vw.961504.xyz · Bitwarden 兼容',
iconUrl: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/vaultwarden.png',
online: t.services.vaultwarden,
actionType: 'backup',
desc: '自托管全平台加密密码库,支持 iOS / Android / 浏览器插件自动填充,每天凌晨 03:30 自动打包灾备至极空间。',
edgeUrl: 'https://vw.961504.xyz',
homeUrl: 'https://vw.home.961504.xyz:10443',
lanUrl: 'http://192.168.31.164:8222',
stats: [
{ label: '容器状态', value: 'Healthy', highlight: true },
{ label: '最近极空间灾备', value: t.nas.latest_backup },
{ label: 'Mac SSD 占用', value: `${t.mini.disk_pct}%` },
],
},
],
},
{
title: '网络网关、AI 专线与外部哨兵',
subtitle: 'Network Gateway & Cloud Edge',
showRouteHint: false,
items: [
{
id: 'clash',
title: 'Mihomo 路由分流面板',
subtitle: '192.168.31.1:9999 · ShellCrash',
iconUrl: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/clash.png',
online: t.services.clash,
desc: '小米主路由器透明网关控制台,实时监控全屋设备流量、分流规则与狮城 AI 算力专线锁定状态。',
edgeUrl: 'https://clash.home.961504.xyz:10443/ui/',
homeUrl: 'https://clash.home.961504.xyz:10443/ui/',
lanUrl: 'http://192.168.31.1:9999/ui/',
stats: [
{ label: 'AI 锁定策略', value: '狮城节点', highlight: true },
{
label: '新加坡延迟',
value: t.network.sg_delay_ms > 0 ? `${t.network.sg_delay_ms} ms` : '直连',
},
{ label: '固定出口 IP', value: t.network.sg_egress_ip },
],
},
{
id: 'router',
title: '小米主路由器后台',
subtitle: '192.168.31.1 · PPPoE 公网网关',
iconUrl: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/openwrt.png',
online: t.services.router,
desc: '管理家庭联通公网宽带拨号、DHCP 静态 IP 绑定、10443 与 41641 端口转发及全屋内网 Split-DNS。',
edgeUrl: 'https://router.home.961504.xyz:10443',
homeUrl: 'https://router.home.961504.xyz:10443',
lanUrl: 'http://192.168.31.1',
stats: [
{ label: '联通公网 IPv4', value: t.network.home_wan_ip, highlight: true },
{ label: '泛域名 DDNS', value: '*.home.961504.xyz' },
{ label: '内网网关', value: '192.168.31.1' },
],
},
{
id: 'uptime',
title: 'Uptime Kuma 外部哨兵',
subtitle: 'status.961504.xyz · SG Azure',
iconUrl: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/uptime-kuma.png',
online: t.services.uptime,
desc: '独立部署在新加坡 Azure 公有云上的 24 小时外部监控哨兵,通过 Tailscale P2P 专线实时探测家庭服务。',
edgeUrl: 'https://status.961504.xyz',
homeUrl: 'https://status.961504.xyz',
lanUrl: 'https://status.961504.xyz',
stats: [
{ label: '云端公网 IP', value: '104.43.105.243' },
{ label: 'Tailscale 专线', value: '100.115.204.51' },
{ label: '外部探测状态', value: '100% 在线', highlight: true },
],
},
],
},
];
}, [telemetry]);
return (
<div className="min-h-screen flex flex-col">
{/* Toast Notification */}
{toast && (
<div className="fixed bottom-5 right-5 z-50 hl-card px-4 py-3 flex items-center gap-2.5 shadow-2xl">
<span className="w-2 h-2 rounded-full" style={{ backgroundColor: 'var(--success)' }} />
<span className="text-xs font-medium">{toast}</span>
</div>
)}
{/* 1. Standard Two-Tier AppShell Header & Telemetry Subheader */}
<TopRibbon />
{/* 2. View Switch: Dashboard vs Agent Orchestrator vs Storybook Workbench */}
{viewMode === 'storybook' ? (
<StorybookWorkbench />
) : viewMode === 'agents' ? (
<AgentOrchestratorWorkbench />
) : (
<main className="flex-1 max-w-7xl w-full mx-auto px-4 sm:px-6 lg:px-8 py-6 space-y-7">
{serviceGroups.map((group) => (
<section key={group.title} className="space-y-3">
<div className="flex items-center justify-between px-0.5">
<div className="flex items-center gap-2">
<h2
className="text-xs font-semibold uppercase tracking-wider"
style={{ color: 'var(--text-muted)' }}
>
{group.title}
</h2>
<span
className="text-[11px] font-mono-num"
style={{ color: 'var(--text-subtle)' }}
>
· {group.subtitle}
</span>
</div>
{group.showRouteHint && (
<span
className="text-[11px] font-mono-num hidden sm:inline"
style={{ color: 'var(--text-subtle)' }}
>
当前路由模式: {activeModeLabel}
</span>
)}
</div>
<div className={`grid ${gridClass}`}>
{group.items.map((card) => (
<ServiceCard key={card.id} card={card} />
))}
</div>
</section>
))}
{/* 3. Bottom SSH Quick Copy & Maintenance Shelf */}
<SshShelf />
</main>
)}
{/* 4. Global Cmd+K Command Palette */}
<CommandPalette />
{/* 5. Embedded AGY Cloud Terminal Modal */}
<AgyTerminalModal />
{/* 5b. WeChat ClawBot QR Code & Live Status Modal */}
<WechatClawModal />
{/* 6. Custom SSO Auth Gate Modal (Replaces Browser Native Basic Auth Popup!) */}
{authRedirect && (
<div className="fixed inset-0 z-50 bg-black/75 backdrop-blur-sm flex items-center justify-center p-4">
<AuthGateCard
targetUrl={authRedirect}
onSuccess={() => {
window.location.href = authRedirect;
}}
onCancel={() => {
setAuthRedirect(null);
window.history.replaceState({}, '', '/');
}}
/>
</div>
)}
</div>
);
};
@@ -0,0 +1,531 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import {
Archive,
Bot,
Copy,
ExternalLink,
FolderGit2,
GitBranch,
HardDrive,
Layers,
MessageSquare,
RefreshCw,
Sparkles,
Terminal,
Users,
} from 'lucide-react';
import { usePortalStore } from '../store/usePortalStore';
import { Badge } from './ui/badge';
import { Button } from './ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from './ui/card';
import { Tabs, TabsList, TabsTrigger } from './ui/tabs';
interface AssistantItem {
id: number;
name: string;
description: string;
agent_type: string;
instructions: string;
avatar?: { kind: string; color?: string };
}
interface RepoItem {
name: string;
description: string;
default_branch: string;
updated_at: string;
size_kb: number;
html_url: string;
clone_url: string;
ssh_url: string;
}
interface ColdBundleItem {
name: string;
size_mb: number;
mtime: string;
}
interface OverviewData {
codeg: {
online: boolean;
latency_ms: number;
version: string;
assistants: AssistantItem[];
folders: Array<{ id: number; name: string; path: string }>;
conversations: Array<{ id: number; title: string; agent_type: string; status: string }>;
};
gitea: {
online: boolean;
latency_ms: number;
version: string;
hot_path: string;
cold_path: string;
repos: RepoItem[];
cold_bundles: ColdBundleItem[];
};
tmux_windows: Array<{ index: string; name: string; active: boolean; command: string }>;
}
const COLOR_MAP: Record<string, string> = {
blue: '#3b82f6',
violet: '#8b5cf6',
teal: '#14b8a6',
amber: '#f59e0b',
};
export const AgentOrchestratorWorkbench: React.FC = () => {
const { getEffectiveMode, routeMode, copyCmd, showToast, setShowAgyModal, setShowWechatModal } =
usePortalStore();
const [subTab, setSubTab] = useState<'native' | 'embedded'>('native');
const [data, setData] = useState<OverviewData | null>(null);
const [loading, setLoading] = useState(false);
const [syncingCold, setSyncingCold] = useState(false);
const effectiveMode = useMemo(() => getEffectiveMode(), [getEffectiveMode, routeMode]);
const codegUrl = useMemo(() => {
if (effectiveMode === 'lan') return 'http://192.168.31.164:3080';
if (effectiveMode === 'home10443') return 'https://codeg.home.961504.xyz:10443';
return 'https://codeg.961504.xyz';
}, [effectiveMode]);
const giteaUrl = useMemo(() => {
if (effectiveMode === 'lan') return 'http://192.168.31.164:3005';
if (effectiveMode === 'home10443') return 'https://git.home.961504.xyz:10443';
return 'https://git.961504.xyz';
}, [effectiveMode]);
const fetchOverview = useCallback(async () => {
setLoading(true);
try {
const res = await fetch('/api/agents/overview');
if (res.ok) {
const json = await res.json();
setData(json);
}
} catch {
// ignore transient network errors
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchOverview();
const timer = setInterval(fetchOverview, 6000);
return () => clearInterval(timer);
}, [fetchOverview]);
const handleSyncCold = async () => {
setSyncingCold(true);
showToast('正在将 Gitea 热层仓库与 LFS 大文件归档至极空间 20TB NAS...');
try {
const res = await fetch('/api/action/sync_git_nas', { method: 'POST' });
const d = await res.json();
showToast(d.message || '极空间 Git 冷备同步完成');
await fetchOverview();
} catch {
showToast('冷备同步请求失败');
} finally {
setSyncingCold(false);
}
};
const assistants = data?.codeg.assistants || [];
const repos = data?.gitea.repos || [];
const bundles = data?.gitea.cold_bundles || [];
const tmuxWindows = data?.tmux_windows || [];
return (
<main className="flex-1 max-w-7xl w-full mx-auto px-4 sm:px-6 lg:px-8 py-6 space-y-6">
{/* Top Banner: Architecture Philosophy & Quick Switcher */}
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 pb-2 border-b hl-border">
<div className="space-y-1">
<div className="flex items-center gap-2.5">
<h1 className="text-lg font-semibold tracking-tight flex items-center gap-2">
<Bot className="w-5 h-5" style={{ color: 'var(--accent)' }} />
<span>Multi-Agent 协同编排与私有 Git 中枢</span>
</h1>
<Badge variant="success">Codeg v0.42 + Gitea v28.3</Badge>
</div>
<p className="text-xs" style={{ color: 'var(--text-muted)' }}>
以开源 Codeg (ACP 协议) 为底座、Gitea (Mac SSD 热层 + 极空间 20TB 冷层) 为代码仓库,支持单聊分工角色与{' '}
<code className="font-mono-num px-1 py-0.5 rounded hl-muted">@点名</code> 跨智能体群聊协作。
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<Tabs value={subTab} onValueChange={(v) => setSubTab(v as 'native' | 'embedded')}>
<TabsList>
<TabsTrigger value="native">
<Layers className="w-3.5 h-3.5" />
<span>自研编排 & Git 大盘</span>
</TabsTrigger>
<TabsTrigger value="embedded">
<Sparkles className="w-3.5 h-3.5" />
<span>内嵌 Codeg 完整画布</span>
</TabsTrigger>
</TabsList>
</Tabs>
<Button
variant="outline"
size="sm"
onClick={() => window.open(codegUrl, '_blank', 'noopener,noreferrer')}
>
<ExternalLink className="w-3.5 h-3.5" />
<span>全屏打开 Codeg</span>
</Button>
<Button
variant="outline"
size="sm"
onClick={() => window.open(giteaUrl, '_blank', 'noopener,noreferrer')}
>
<FolderGit2 className="w-3.5 h-3.5" />
<span>打开 Gitea</span>
</Button>
</div>
</div>
{subTab === 'embedded' ? (
<Card className="overflow-hidden">
<div
className="px-4 py-2.5 border-b hl-border flex items-center justify-between text-xs"
style={{ backgroundColor: 'var(--bg-muted)' }}
>
<div className="flex items-center gap-2">
<span className="w-2 h-2 rounded-full" style={{ backgroundColor: 'var(--success)' }} />
<span className="font-medium">Codeg 原生多智能体工作台 (已注入 SSO 免密凭证)</span>
<span className="font-mono-num" style={{ color: 'var(--text-muted)' }}>
{codegUrl}
</span>
</div>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => window.open(codegUrl, '_blank', 'noopener,noreferrer')}
>
<ExternalLink className="w-3.5 h-3.5" />
<span>新标签页全屏</span>
</Button>
</div>
</div>
<iframe
src={codegUrl}
title="Codeg Multi-Agent Workspace"
className="w-full border-0"
style={{ height: 'calc(100vh - 220px)', minHeight: '640px' }}
/>
</Card>
) : (
<div className="space-y-6">
{/* Section 1: 4 Specialist Role Assistants (Single Chat + @Mention Squad) */}
<section className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Users className="w-4 h-4" style={{ color: 'var(--accent)' }} />
<h2 className="text-xs font-semibold uppercase tracking-wider" style={{ color: 'var(--text-muted)' }}>
分工角色会话与 @群聊战队 · Specialist Role Squad (`Google Antigravity` 底座)
</h2>
</div>
<div className="flex items-center gap-2">
<Button variant="ghost" size="sm" onClick={fetchOverview} disabled={loading}>
<RefreshCw className={`w-3.5 h-3.5 ${loading ? 'animate-spin' : ''}`} />
<span>刷新状态</span>
</Button>
<Button variant="outline" size="sm" onClick={() => setShowWechatModal(true)}>
<MessageSquare className="w-3.5 h-3.5" />
<span>微信入口绑定</span>
</Button>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{assistants.map((a) => {
const badgeColor = COLOR_MAP[a.avatar?.color || 'blue'] || '#3b82f6';
return (
<Card key={a.id} className="p-4 flex flex-col justify-between gap-3">
<div className="space-y-2">
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2.5">
<div
className="w-8 h-8 rounded-lg flex items-center justify-center text-white font-semibold text-sm shrink-0"
style={{ backgroundColor: badgeColor }}
>
{a.name.slice(0, 1)}
</div>
<div>
<div className="font-semibold text-sm flex items-center gap-1.5">
<span>@{a.name}</span>
</div>
<div className="text-[11px] font-mono-num" style={{ color: 'var(--text-muted)' }}>
CLI: {a.agent_type === 'antigravity' ? 'Google Antigravity (agy)' : a.agent_type}
</div>
</div>
</div>
<Badge variant="success">就绪</Badge>
</div>
<p className="text-xs leading-relaxed" style={{ color: 'var(--text-muted)' }}>
{a.description}
</p>
<div
className="p-2 rounded-md text-[11px] font-mono-num line-clamp-2 hl-muted"
style={{ color: 'var(--text-subtle)' }}
title={a.instructions}
>
Prompt: {a.instructions}
</div>
</div>
<div className="pt-2 border-t hl-border flex items-center gap-2">
<Button
variant="default"
size="sm"
className="flex-1"
onClick={() => setSubTab('embedded')}
>
<span>发起单聊</span>
</Button>
<Button
variant="outline"
size="sm"
onClick={() => copyCmd(`@${a.name} `, `群聊点名 @${a.name}`)}
title="复制 @点名 指令,可在主会话或群聊中直接委派该角色"
>
<Copy className="w-3.5 h-3.5" />
<span>@点名</span>
</Button>
</div>
</Card>
);
})}
</div>
</section>
{/* Section 2: Self-Hosted Gitea (Mac SSD Hot Tier + ZSpace 20TB Cold Tier) & Active AGY Sessions */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-5">
{/* Left 2 Columns: Gitea Hot/Cold Tier Architecture */}
<Card className="lg:col-span-2 p-5 space-y-4">
<CardHeader className="flex flex-col sm:flex-row sm:items-center justify-between gap-2 pb-3 border-b hl-border">
<div>
<CardTitle className="flex items-center gap-2">
<FolderGit2 className="w-4 h-4" style={{ color: 'var(--accent)' }} />
<span>私有 Git 代码中枢 (Gitea 热冷分层存储架构)</span>
<Badge variant={data?.gitea.online ? 'success' : 'warning'}>
{data?.gitea.online ? `在线 · ${data.gitea.latency_ms}ms` : '检测中'}
</Badge>
</CardTitle>
<CardDescription className="mt-1">
服务端与活跃索引常驻 M6 Mac mini NVMe SSD,LFS 大文件与全量 <code className="font-mono-num">.bundle</code> 镜像实时归档至极空间 Z4 20TB RAID。
</CardDescription>
</div>
<div className="flex items-center gap-2 shrink-0">
<Button
variant="outline"
size="sm"
onClick={handleSyncCold}
disabled={syncingCold}
>
<Archive className={`w-3.5 h-3.5 ${syncingCold ? 'animate-spin' : ''}`} />
<span>{syncingCold ? '归档中...' : '立即同步至极空间冷存'}</span>
</Button>
<Button
variant="default"
size="sm"
onClick={() => window.open(giteaUrl, '_blank', 'noopener,noreferrer')}
>
<ExternalLink className="w-3.5 h-3.5" />
<span>Gitea 控制台</span>
</Button>
</div>
</CardHeader>
<CardContent className="space-y-4">
{/* Hot vs Cold Tier Storage Boxes */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div className="p-3 rounded-lg border hl-border hl-muted space-y-1">
<div className="flex items-center justify-between text-xs">
<span className="font-semibold flex items-center gap-1.5">
🔥 Mac mini 热存储层 (NVMe SSD)
</span>
<Badge variant="default">毫秒级读写</Badge>
</div>
<div className="text-[11px] font-mono-num truncate" style={{ color: 'var(--text-muted)' }}>
{data?.gitea.hot_path || '/Users/ixvii0iv/homelab/stacks/git/data'}
</div>
<div className="text-[11px]" style={{ color: 'var(--text-subtle)' }}>
承载 SQLite 元数据、活跃 Git 裸仓库与 Agent Worktree 并发合并
</div>
</div>
<div className="p-3 rounded-lg border hl-border hl-muted space-y-1">
<div className="flex items-center justify-between text-xs">
<span className="font-semibold flex items-center gap-1.5">
<HardDrive className="w-3.5 h-3.5" style={{ color: 'var(--success)' }} />
🧊 极空间 Z4 冷存储层 (20TB RAID)
</span>
<Badge variant="success">{bundles.length} 个全量冷备包</Badge>
</div>
<div className="text-[11px] font-mono-num truncate" style={{ color: 'var(--text-muted)' }}>
{data?.gitea.cold_path || '/Users/ixvii0iv/nas/个人空间/Git_Cold_Storage'}
</div>
<div className="text-[11px]" style={{ color: 'var(--text-subtle)' }}>
承载 Git LFS 大文件、Release 制品包与自包含 .bundle 离线灾备包
</div>
</div>
</div>
{/* Hosted Repositories List */}
<div className="space-y-2">
<div className="text-xs font-medium" style={{ color: 'var(--text-muted)' }}>
当前托管仓库 ({repos.length})
</div>
{repos.map((r) => (
<div
key={r.name}
className="p-3 rounded-lg border hl-border flex flex-col sm:flex-row sm:items-center justify-between gap-3"
>
<div className="space-y-1">
<div className="flex items-center gap-2">
<GitBranch className="w-3.5 h-3.5" style={{ color: 'var(--accent)' }} />
<span className="font-semibold text-sm font-mono-num">{r.name}</span>
<Badge variant="default">{r.default_branch}</Badge>
{bundles.some((b) => b.name.includes(r.name.replace('/', '_'))) && (
<Badge variant="success">已冷备至 NAS</Badge>
)}
</div>
<p className="text-xs" style={{ color: 'var(--text-muted)' }}>
{r.description || 'HomeLab 核心基础设施与前端工程仓库'}
</p>
</div>
<div className="flex items-center gap-2 shrink-0">
<Button
variant="outline"
size="sm"
onClick={() =>
copyCmd(`git clone ${giteaUrl}/${r.name}.git`, `克隆 ${r.name}`)
}
>
<Copy className="w-3.5 h-3.5" />
<span>复制 Clone</span>
</Button>
<Button
variant="outline"
size="sm"
onClick={() =>
window.open(`${giteaUrl}/${r.name}`, '_blank', 'noopener,noreferrer')
}
>
<ExternalLink className="w-3.5 h-3.5" />
<span>查看代码</span>
</Button>
</div>
</div>
))}
</div>
</CardContent>
</Card>
{/* Right Column: Active AGY Tmux & Workspace Sessions */}
<Card className="p-5 flex flex-col justify-between space-y-4">
<div className="space-y-4">
<CardHeader className="pb-3 border-b hl-border">
<CardTitle className="flex items-center justify-between">
<span className="flex items-center gap-2">
<Terminal className="w-4 h-4" style={{ color: 'var(--accent)' }} />
<span>AGY 原生常驻会话流</span>
</span>
<Badge variant="success">{tmuxWindows.length || 1} 窗口活跃</Badge>
</CardTitle>
<CardDescription className="mt-1">
M6 Mac mini 本机 <code className="font-mono-num">tmux (agy)</code> 与 Codeg 工作区实时状态。
</CardDescription>
</CardHeader>
<div className="space-y-2.5">
<div className="text-xs font-medium" style={{ color: 'var(--text-muted)' }}>
已挂载 Codeg 工作区目录
</div>
{(data?.codeg.folders || []).map((f) => (
<div
key={f.id}
className="p-2.5 rounded-lg border hl-border hl-muted flex items-center justify-between text-xs font-mono-num"
>
<span className="truncate">{f.path}</span>
<Badge variant="success">已绑定</Badge>
</div>
))}
<div className="text-xs font-medium pt-2" style={{ color: 'var(--text-muted)' }}>
Mac mini Tmux 会话窗格
</div>
{tmuxWindows.length > 0 ? (
tmuxWindows.map((w) => (
<div
key={w.index}
className="p-2.5 rounded-lg border hl-border flex items-center justify-between text-xs"
>
<div className="flex items-center gap-2 font-mono-num">
<span
className="w-2 h-2 rounded-full"
style={{
backgroundColor: w.active ? 'var(--success)' : 'var(--text-muted)',
}}
/>
<span className="font-medium">
#{w.index} {w.name}
</span>
</div>
<Badge variant="default">{w.command}</Badge>
</div>
))
) : (
<div className="p-2.5 rounded-lg border hl-border text-xs font-mono-num">
#0 agy (Google Antigravity 常驻守护)
</div>
)}
{bundles.length > 0 && (
<div className="pt-2 space-y-1.5">
<div className="text-xs font-medium" style={{ color: 'var(--text-muted)' }}>
最近极空间 NAS 冷备快照
</div>
{bundles.map((b) => (
<div
key={b.name}
className="p-2 rounded-md hl-muted flex items-center justify-between text-[11px] font-mono-num"
>
<span className="truncate">{b.name}</span>
<span style={{ color: 'var(--success)' }}>
{b.size_mb} MB · {b.mtime.slice(5, 16)}
</span>
</div>
))}
</div>
)}
</div>
</div>
<div className="pt-3 border-t hl-border flex items-center gap-2">
<Button className="flex-1" onClick={() => setShowAgyModal(true)}>
<Terminal className="w-3.5 h-3.5" />
<span>唤起 AGY 终端</span>
</Button>
<Button variant="outline" onClick={() => setSubTab('embedded')}>
<Sparkles className="w-3.5 h-3.5" />
<span>进入画布</span>
</Button>
</div>
</Card>
</div>
</div>
)}
</main>
);
};
@@ -0,0 +1,104 @@
import React, { useEffect, useState } from 'react';
import { usePortalStore } from '../store/usePortalStore';
import { AuthGateCard } from './AuthGateModal';
import { Button } from './ui/button';
import { Dialog, DialogContent, DialogDescription, DialogTitle } from './ui/dialog';
export const AgyTerminalModal: React.FC = () => {
const { showAgyModal, setShowAgyModal, getEffectiveMode } = usePortalStore();
const [authChecked, setAuthChecked] = useState(false);
const [authenticated, setAuthenticated] = useState(false);
const mode = getEffectiveMode();
const url =
mode === 'lan'
? 'http://192.168.31.164:7681'
: mode === 'home10443'
? 'https://agy.home.961504.xyz:10443'
: 'https://agy.961504.xyz';
useEffect(() => {
if (!showAgyModal) return;
if (mode === 'lan') {
setAuthenticated(true);
setAuthChecked(true);
return;
}
setAuthChecked(false);
fetch('/api/auth/status', { credentials: 'include' })
.then((r) => r.json())
.then((d) => {
setAuthenticated(Boolean(d.authenticated));
setAuthChecked(true);
})
.catch(() => {
setAuthenticated(false);
setAuthChecked(true);
});
}, [showAgyModal, mode]);
return (
<Dialog open={showAgyModal} onOpenChange={setShowAgyModal}>
<DialogContent className="w-[95vw] max-w-6xl h-[88vh] flex flex-col overflow-hidden p-0">
<DialogTitle className="sr-only">AGY 云端工作台</DialogTitle>
<DialogDescription className="sr-only">
M6 Mac mini 上的 24 小时 tmux 常驻 AI 编程终端
</DialogDescription>
<div className="px-4 py-2.5 border-b hl-border flex items-center justify-between hl-muted shrink-0">
<div className="flex items-center gap-2.5">
<img
src="https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/google-gemini.png"
alt="AGY"
className="w-4 h-4"
/>
<span className="text-xs font-mono-num font-medium">
AGY 云端工作台 · tmux 持久会话 (关窗不断连)
</span>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" asChild>
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="font-mono-num"
>
新标签页全屏 ↗
</a>
</Button>
<Button size="sm" onClick={() => setShowAgyModal(false)}>
收起终端
</Button>
</div>
</div>
{!authChecked ? (
<div
className="flex-1 flex items-center justify-center text-xs font-mono-num"
style={{ color: 'var(--text-muted)' }}
>
正在校验 HomeLab SSO 通行证...
</div>
) : !authenticated ? (
<div
className="flex-1 flex items-center justify-center p-4"
style={{ backgroundColor: 'var(--bg-page)' }}
>
<AuthGateCard
targetUrl={url}
onSuccess={() => setAuthenticated(true)}
onCancel={() => setShowAgyModal(false)}
/>
</div>
) : (
<iframe
src={url}
title="AGY Cloud Workbench"
className="w-full flex-1 bg-black border-0"
/>
)}
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,170 @@
import React, { useState } from 'react';
import { Eye, EyeOff, Lock, ShieldCheck, Terminal, User } from 'lucide-react';
import { Badge } from './ui/badge';
import { Button } from './ui/button';
import { Card } from './ui/card';
import { InputGroup } from './ui/input';
import { Switch } from './ui/switch';
interface AuthGateCardProps {
targetUrl?: string;
onSuccess?: () => void;
onCancel?: () => void;
embedded?: boolean;
}
export const AuthGateCard: React.FC<AuthGateCardProps> = ({
targetUrl = 'https://agy.961504.xyz',
onSuccess,
onCancel,
embedded = false,
}) => {
const [username, setUsername] = useState('ixvii0iv');
const [password, setPassword] = useState('');
const [showPass, setShowPass] = useState(false);
const [rememberMe, setRememberMe] = useState(true);
const [loading, setLoading] = useState(false);
const [errorMsg, setErrorMsg] = useState<string | null>(null);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!username.trim() || !password) {
setErrorMsg('请输入用户名与访问密钥');
return;
}
setLoading(true);
setErrorMsg(null);
try {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ username: username.trim(), password, remember: rememberMe }),
});
const data = await res.json();
if (res.ok && data.ok) {
onSuccess?.();
} else {
setErrorMsg(data.message || '认证未通过,请检查密码');
}
} catch {
setErrorMsg('网络请求异常,请稍后重试');
} finally {
setLoading(false);
}
};
return (
<Card className={`w-full max-w-md p-6 space-y-5 ${embedded ? '' : 'shadow-2xl'}`}>
{/* Header */}
<div className="flex items-start justify-between gap-3 border-b hl-border pb-4">
<div className="flex items-center gap-3">
<div
className="w-10 h-10 rounded-xl flex items-center justify-center border hl-border shrink-0"
style={{ backgroundColor: 'var(--accent-soft)', color: 'var(--accent)' }}
>
<ShieldCheck className="w-5 h-5" />
</div>
<div>
<div className="flex items-center gap-2">
<h3 className="text-sm font-bold tracking-tight">HomeLab 统一安全通行证</h3>
<Badge variant="success">SSO 2.0</Badge>
</div>
<p
className="text-xs font-mono-num mt-0.5 truncate max-w-[260px]"
style={{ color: 'var(--text-muted)' }}
>
{targetUrl}
</p>
</div>
</div>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-1.5">
<label className="block text-xs font-semibold" style={{ color: 'var(--text-muted)' }}>
管理员账号 (Username)
</label>
<InputGroup>
<User className="w-4 h-4 shrink-0" style={{ color: 'var(--text-subtle)' }} />
<input
type="text"
autoComplete="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="输入管理员账号 (如 ixvii0iv)"
className="font-mono-num"
/>
</InputGroup>
</div>
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<label className="block text-xs font-semibold" style={{ color: 'var(--text-muted)' }}>
访问通行密钥 (Access Key)
</label>
<span className="text-[10px] font-mono-num" style={{ color: 'var(--text-subtle)' }}>
与 Mac mini 密码一致
</span>
</div>
<InputGroup>
<Lock className="w-4 h-4 shrink-0" style={{ color: 'var(--text-subtle)' }} />
<input
type={showPass ? 'text' : 'password'}
autoComplete="current-password"
autoFocus
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="输入访问密钥..."
className="font-mono-num"
/>
<button
type="button"
onClick={() => setShowPass(!showPass)}
className="p-1 rounded hover:opacity-80 cursor-pointer"
style={{ color: 'var(--text-muted)' }}
title={showPass ? '隐藏密码' : '显示密码'}
>
{showPass ? <EyeOff className="w-3.5 h-3.5" /> : <Eye className="w-3.5 h-3.5" />}
</button>
</InputGroup>
</div>
{/* Official @radix-ui/react-switch Toggle */}
<div className="flex items-center justify-between pt-1">
<span className="text-xs select-none" style={{ color: 'var(--text-muted)' }}>
在此设备保持 30 天免密通行 (*.961504.xyz)
</span>
<Switch checked={rememberMe} onCheckedChange={setRememberMe} />
</div>
{errorMsg && (
<div
className="px-3 py-2 rounded-lg text-xs font-medium border"
style={{
backgroundColor: 'rgba(239, 68, 68, 0.1)',
borderColor: 'rgba(239, 68, 68, 0.3)',
color: '#ef4444',
}}
>
{errorMsg}
</div>
)}
{/* Actions */}
<div className="flex items-center justify-end gap-2.5 pt-2">
{onCancel && (
<Button type="button" variant="outline" onClick={onCancel}>
取消
</Button>
)}
<Button type="submit" disabled={loading} className="px-4">
<Terminal className="w-3.5 h-3.5" />
<span>{loading ? '正在验证签名...' : '解锁并进入工作台'}</span>
</Button>
</div>
</form>
</Card>
);
};
@@ -0,0 +1,185 @@
import React, { useMemo } from 'react';
import { usePortalStore } from '../store/usePortalStore';
import { Badge } from './ui/badge';
import {
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from './ui/command';
interface CmdItem {
id: string;
title: string;
hint: string;
type: string;
run: () => void;
}
export const CommandPalette: React.FC = () => {
const {
showCmdPalette,
setShowCmdPalette,
setShowAgyModal,
setTheme,
triggerAction,
copyCmd,
getEffectiveMode,
} = usePortalStore();
const commands = useMemo<CmdItem[]>(() => {
const mode = getEffectiveMode();
const resolve = (edge: string, home: string, lan: string) =>
mode === 'lan' ? lan : mode === 'home10443' ? home : edge;
return [
{
id: 'open-agy-drawer',
title: '在页内唤起 AGY 云端终端',
hint: 'tmux attach -t agy',
type: '终端',
run: () => setShowAgyModal(true),
},
{
id: 'open-agy',
title: '新窗口打开 AGY 云端工作台',
hint: 'https://agy.961504.xyz',
type: '跳转',
run: () =>
window.open(
resolve(
'https://agy.961504.xyz',
'https://agy.home.961504.xyz:10443',
'http://192.168.31.164:7681'
),
'_blank'
),
},
{
id: 'open-z4',
title: '打开 极空间 Z4 私有云 (20TB)',
hint: '192.168.31.185:5055',
type: '跳转',
run: () =>
window.open(
resolve(
'https://z4.home.961504.xyz:10443',
'https://z4.home.961504.xyz:10443',
'http://192.168.31.185:5055'
),
'_blank'
),
},
{
id: 'open-vw',
title: '打开 Vaultwarden 密码保险箱',
hint: 'https://vw.961504.xyz',
type: '跳转',
run: () =>
window.open(
resolve(
'https://vw.961504.xyz',
'https://vw.home.961504.xyz:10443',
'http://192.168.31.164:8222'
),
'_blank'
),
},
{
id: 'open-clash',
title: '打开 Mihomo 路由分流面板',
hint: '192.168.31.1:9999',
type: '跳转',
run: () =>
window.open(
resolve(
'https://clash.home.961504.xyz:10443/ui/',
'https://clash.home.961504.xyz:10443/ui/',
'http://192.168.31.1:9999/ui/'
),
'_blank'
),
},
{
id: 'act-backup',
title: '立即打包备份核心数据至极空间 Z4',
hint: 'backup-to-nas.sh',
type: '运维',
run: () => triggerAction('backup_nas', '正在打包备份至极空间 Z4...'),
},
{
id: 'act-ddns',
title: '立即刷新 Cloudflare DDNS 解析',
hint: 'cloudflare-ddns.sh',
type: '运维',
run: () => triggerAction('refresh_ddns', '正在同步 Cloudflare DDNS...'),
},
{
id: 'ssh-mini',
title: '复制 SSH 连接 Mac mini 指令',
hint: 'ssh mini',
type: 'SSH',
run: () => copyCmd('ssh mini', 'SSH Mac mini'),
},
{
id: 'ssh-agy',
title: '复制 终端接力 AGY 会话指令',
hint: 'ssh mini -t "tmux attach -t agy"',
type: 'SSH',
run: () => copyCmd('ssh mini -t "tmux attach -t agy"', '接力 AGY'),
},
{
id: 'theme-system',
title: '主题模式: 跟随设备系统深浅色自动切换',
hint: 'system',
type: '主题',
run: () => setTheme('system'),
},
{
id: 'theme-light',
title: '切换主题: 浅色纯净 (Light)',
hint: 'light',
type: '主题',
run: () => setTheme('light'),
},
{
id: 'theme-dark',
title: '切换主题: 深色极客 (Dark)',
hint: 'dark',
type: '主题',
run: () => setTheme('dark'),
},
];
}, [getEffectiveMode, setShowAgyModal, setTheme, triggerAction, copyCmd]);
return (
<CommandDialog open={showCmdPalette} onOpenChange={setShowCmdPalette}>
<CommandInput placeholder="输入服务名称、SSH 指令、路由模式或主题..." />
<CommandList>
<CommandEmpty>未找到匹配的指令或服务</CommandEmpty>
<CommandGroup heading="快速操作与服务直达 (cmdk + Radix Dialog)">
{commands.map((cmd) => (
<CommandItem
key={cmd.id}
value={`${cmd.title} ${cmd.hint} ${cmd.type}`}
onSelect={() => {
setShowCmdPalette(false);
cmd.run();
}}
>
<div className="flex items-center gap-2.5">
<span className="font-medium">{cmd.title}</span>
<span className="font-mono-num text-[11px] text-[var(--text-muted)]">
{cmd.hint}
</span>
</div>
<Badge variant="secondary">{cmd.type}</Badge>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</CommandDialog>
);
};
@@ -0,0 +1,124 @@
import React from 'react';
import { usePortalStore } from '../store/usePortalStore';
import type { ServiceCardItem } from '../types/telemetry';
interface Props {
card: ServiceCardItem;
}
export const ServiceCard: React.FC<Props> = ({ card }) => {
const { getEffectiveMode, setShowAgyModal, triggerAction } = usePortalStore();
const mode = getEffectiveMode();
const resolvedUrl =
mode === 'lan' ? card.lanUrl : mode === 'home10443' ? card.homeUrl : card.edgeUrl;
const badgeLabel =
mode === 'lan' ? 'LAN 直连' : mode === 'home10443' ? '10443' : '443 免端口';
return (
<a
href={resolvedUrl}
target="_blank"
rel="noopener noreferrer"
className="hl-card hl-card-interactive flex flex-col justify-between overflow-hidden group cursor-pointer"
>
{/* Card Top: Authentic Brand Icon + Title + Status Dot + Description */}
<div className="p-4 sm:p-5 space-y-3">
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-3.5 min-w-0">
<div className="w-10 h-10 rounded-xl hl-muted border hl-border p-1.5 flex items-center justify-center shrink-0">
<img src={card.iconUrl} alt={card.title} className="w-full h-full object-contain" />
</div>
<div className="min-w-0">
<div className="flex items-center gap-2">
<h3 className="text-sm font-semibold truncate group-hover:underline">
{card.title}
</h3>
<span
className="w-2 h-2 rounded-full shrink-0"
style={{
backgroundColor: card.online !== false ? 'var(--success)' : '#ef4444',
}}
/>
</div>
<p
className="text-[11px] font-mono-num truncate mt-0.5"
style={{ color: 'var(--text-muted)' }}
>
{card.subtitle}
</p>
</div>
</div>
{/* Action Pill or Route Badge */}
{card.actionType === 'drawer' ? (
<button
onClick={(e) => {
e.preventDefault();
setShowAgyModal(true);
}}
className="px-2 py-1 rounded-md text-[11px] font-medium shrink-0 transition-opacity hover:opacity-80 cursor-pointer"
style={{
backgroundColor: 'var(--accent-soft)',
color: 'var(--accent)',
}}
>
页内终端
</button>
) : card.actionType === 'backup' ? (
<button
onClick={(e) => {
e.preventDefault();
triggerAction('backup_nas', '正在打包备份至极空间 Z4...');
}}
className="px-2 py-1 rounded-md text-[11px] font-medium shrink-0 transition-opacity hover:opacity-80 cursor-pointer"
style={{
backgroundColor: 'var(--success-soft)',
color: 'var(--success)',
}}
>
立即备份
</button>
) : (
<span
className="text-xs font-mono-num px-2 py-0.5 rounded hl-muted shrink-0"
style={{ color: 'var(--text-muted)' }}
>
{badgeLabel}
</span>
)}
</div>
<p
className="text-xs leading-relaxed line-clamp-2"
style={{ color: 'var(--text-muted)' }}
>
{card.desc}
</p>
</div>
{/* Card Footer: gethomepage.dev Signature 3-Cell Inline Telemetry Widget */}
<div className="hl-widget-footer grid grid-cols-3 divide-x hl-border text-center">
{card.stats.map((stat, idx) => (
<div key={idx} className="py-2.5 px-2 flex flex-col items-center justify-center">
<span
className="text-[10px] uppercase tracking-wider truncate max-w-full"
style={{ color: 'var(--text-subtle)' }}
>
{stat.label}
</span>
<span
className="text-xs font-semibold font-mono-num mt-0.5 truncate max-w-full"
style={{
color: stat.highlight ? 'var(--success)' : 'var(--text-main)',
}}
>
{stat.value}
</span>
</div>
))}
</div>
</a>
);
};
@@ -0,0 +1,73 @@
import React from 'react';
import { usePortalStore } from '../store/usePortalStore';
const SSH_LIST = [
{ label: 'SSH 连接 Mac mini', cmd: 'ssh mini' },
{ label: '终端接力 AGY 会话', cmd: 'ssh mini -t "tmux attach -t agy"' },
{ label: 'SSH 连接新加坡 Azure', cmd: 'ssh azure' },
{ label: 'SSH 连接小米主路由', cmd: 'ssh root@192.168.31.1' },
];
export const SshShelf: React.FC = () => {
const { copyCmd, triggerAction } = usePortalStore();
return (
<section className="space-y-3 pt-1">
<div className="flex items-center justify-between px-0.5">
<div className="flex items-center gap-2">
<h2
className="text-xs font-semibold uppercase tracking-wider"
style={{ color: 'var(--text-muted)' }}
>
终端快捷指令与备用直连书签
</h2>
<span className="text-[11px] font-mono-num" style={{ color: 'var(--text-subtle)' }}>
· 点击指令即复制
</span>
</div>
<div className="flex items-center gap-3 text-xs">
<button
onClick={() => triggerAction('refresh_ddns', '正在同步 Cloudflare DDNS...')}
className="hover:underline font-medium cursor-pointer"
style={{ color: 'var(--accent)' }}
>
刷新 DDNS 解析
</button>
<button
onClick={() => triggerAction('mount_nas', '正在巡检极空间 20TB SMB 挂载...')}
className="hover:underline font-medium cursor-pointer"
style={{ color: 'var(--accent)' }}
>
巡检极空间挂载
</button>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
{SSH_LIST.map((item) => (
<button
key={item.cmd}
onClick={() => copyCmd(item.cmd, item.label)}
className="hl-card hl-card-interactive px-4 py-3 text-left flex items-center justify-between gap-2 cursor-pointer"
>
<div className="min-w-0">
<div className="text-xs font-semibold">{item.label}</div>
<div
className="text-[11px] font-mono-num truncate mt-0.5"
style={{ color: 'var(--accent)' }}
>
{item.cmd}
</div>
</div>
<span
className="text-[10px] font-mono-num px-1.5 py-0.5 rounded hl-muted shrink-0"
style={{ color: 'var(--text-muted)' }}
>
COPY
</span>
</button>
))}
</div>
</section>
);
};
@@ -0,0 +1,516 @@
import React, { useState } from 'react';
import {
BookOpen,
Check,
KeyRound,
Layers,
Layout,
Palette,
Search,
Sparkles,
Terminal,
} from 'lucide-react';
import { LAYOUT_PRESETS, THEME_PALETTES, type LayoutPresetId } from '../design-system/tokens';
import { usePortalStore } from '../store/usePortalStore';
import { AuthGateCard } from './AuthGateModal';
import { ServiceCard } from './ServiceCard';
import { SshShelf } from './SshShelf';
import { Badge } from './ui/badge';
import { Button } from './ui/button';
import { Card } from './ui/card';
import { Input, InputGroup } from './ui/input';
import { Switch } from './ui/switch';
import { Tabs, TabsList, TabsTrigger } from './ui/tabs';
type StoryId =
| 'layout-architecture'
| 'theme-tokens'
| 'form-inputs'
| 'service-card'
| 'ssh-shelf';
export const StorybookWorkbench: React.FC = () => {
const [activeStory, setActiveStory] = useState<StoryId>('form-inputs');
const {
theme,
resolvedTheme,
layoutPreset,
setTheme,
setLayoutPreset,
setViewMode,
setShowCmdPalette,
telemetry,
showToast,
} = usePortalStore();
// Interactive Playground Props for ServiceCard story
const [demoOnline, setDemoOnline] = useState(true);
const [demoAction, setDemoAction] = useState<'drawer' | 'backup' | undefined>('drawer');
// Interactive Playground Props for Form Inputs story
const [demoSearch, setDemoSearch] = useState('');
const [demoHost, setDemoHost] = useState('192.168.31.164');
const [demoSwitch, setDemoSwitch] = useState(true);
const stories: Array<{ id: StoryId; group: string; title: string; icon: React.ReactNode }> = [
{
id: 'layout-architecture',
group: 'FOUNDATIONS / 架构与布局',
title: '01. AppShell 双层顶栏与网格布局',
icon: <Layout className="w-3.5 h-3.5" />,
},
{
id: 'theme-tokens',
group: 'FOUNDATIONS / 架构与布局',
title: '02. 双主题色板与自动切换规范',
icon: <Palette className="w-3.5 h-3.5" />,
},
{
id: 'form-inputs',
group: 'SHADCN / RADIX UI 组件',
title: '03. shadcn/ui + Radix 原语与认证弹窗',
icon: <KeyRound className="w-3.5 h-3.5" />,
},
{
id: 'service-card',
group: 'SHADCN / RADIX UI 组件',
title: '04. ServiceCard 内联遥测卡片',
icon: <Layers className="w-3.5 h-3.5" />,
},
{
id: 'ssh-shelf',
group: 'SHADCN / RADIX UI 组件',
title: '05. SshShelf 快捷指令栏',
icon: <Terminal className="w-3.5 h-3.5" />,
},
];
return (
<div className="flex-1 max-w-7xl w-full mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6 items-start">
{/* Left Sidebar: Storybook Navigation Tree */}
<Card className="p-4 space-y-4 lg:sticky lg:top-20">
<div className="flex items-center justify-between border-b hl-border pb-3">
<div className="flex items-center gap-2">
<BookOpen className="w-4 h-4" style={{ color: 'var(--accent)' }} />
<span className="text-xs font-bold uppercase tracking-wider">shadcn/ui 组件库</span>
</div>
<Badge variant="accent">Radix + cmdk</Badge>
</div>
<div className="space-y-1">
{stories.map((s) => (
<button
key={s.id}
onClick={() => setActiveStory(s.id)}
className="w-full px-3 py-2 rounded-lg text-left text-xs font-medium flex items-center gap-2.5 transition-all cursor-pointer"
style={
activeStory === s.id
? {
backgroundColor: 'var(--accent-soft)',
color: 'var(--accent)',
}
: { color: 'var(--text-muted)' }
}
>
{s.icon}
<span className="truncate">{s.title}</span>
</button>
))}
</div>
<div className="pt-3 border-t hl-border space-y-2 text-xs">
<div className="text-[11px] font-semibold" style={{ color: 'var(--text-muted)' }}>
全局外观状态预览 (@radix-ui/react-tabs)
</div>
<Tabs value={theme} onValueChange={(v) => setTheme(v as 'system' | 'light' | 'dark')}>
<TabsList className="w-full grid grid-cols-3">
<TabsTrigger value="system">自动</TabsTrigger>
<TabsTrigger value="light">浅色</TabsTrigger>
<TabsTrigger value="dark">深色</TabsTrigger>
</TabsList>
</Tabs>
<Button onClick={() => setViewMode('dashboard')} className="w-full mt-2">
返回中控台主页
</Button>
</div>
</Card>
{/* Right Canvas: Interactive Story Preview & Controls */}
<div className="lg:col-span-3 space-y-6">
{/* STORY 1: Layout Architecture */}
{activeStory === 'layout-architecture' && (
<div className="space-y-6">
<Card className="p-6 space-y-4">
<div className="flex items-center justify-between border-b hl-border pb-4">
<div>
<span
className="text-[11px] font-mono-num uppercase tracking-wider"
style={{ color: 'var(--accent)' }}
>
Foundations / AppShell Layout
</span>
<h2 className="text-lg font-bold mt-0.5">
标准双层顶栏架构 (Two-Tier AppShell) & 响应式网格切换
</h2>
</div>
</div>
<p className="text-xs leading-relaxed" style={{ color: 'var(--text-muted)' }}>
参考 <strong>Vercel Geist / shadcn/ui Dashboard-01 / GitHub AppShell</strong>{' '}
标准规范,将原本容易挤压折行的单层顶栏拆分为职责清晰的双层结构:
</p>
{/* Visual Blueprint Diagram of Two-Tier Header */}
<div className="p-4 rounded-xl hl-muted border hl-border space-y-2.5 font-mono-num text-xs">
<div className="p-3 rounded-lg hl-card flex items-center justify-between">
<span>[左] 🍎 HomeLab · Radix Tabs 切换 (中控台 / Storybook)</span>
<span style={{ color: 'var(--accent)' }}>
[最右固定] 🔍 搜索 ⌘K · &gt;_ AGY 终端 · ☀️/🌙 主题切换
</span>
</div>
<div className="p-3 rounded-lg hl-card flex items-center justify-between">
<span style={{ color: 'var(--success)' }}>
[左] 实时硬件遥测条 (M6 CPU · RAM · Z4 20TB · SG 专线)
</span>
<span>[右对齐] Radix Tabs 路由切换器 (智能路由 / 443 / 10443 / LAN)</span>
</div>
</div>
{/* Interactive Grid Preset Selector */}
<div className="pt-2 space-y-3">
<div className="text-xs font-semibold">
选择主页卡片网格布局预设(点击立即应用到主页):
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3.5">
{(Object.keys(LAYOUT_PRESETS) as LayoutPresetId[]).map((key) => {
const preset = LAYOUT_PRESETS[key];
const active = layoutPreset === key;
return (
<div
key={key}
onClick={() => setLayoutPreset(key)}
className="hl-card hl-card-interactive p-4 cursor-pointer space-y-2"
style={active ? { borderColor: 'var(--accent)' } : {}}
>
<div className="flex items-center justify-between">
<span className="text-xs font-bold">{preset.name}</span>
{active && (
<span
className="w-4 h-4 rounded-full flex items-center justify-center"
style={{
backgroundColor: 'var(--accent)',
color: '#fff',
}}
>
<Check className="w-2.5 h-2.5" />
</span>
)}
</div>
<p className="text-[11px]" style={{ color: 'var(--text-muted)' }}>
{preset.description}
</p>
<code
className="text-[10px] font-mono-num block pt-1"
style={{ color: 'var(--accent)' }}
>
{preset.gridClass}
</code>
</div>
);
})}
</div>
</div>
</Card>
</div>
)}
{/* STORY 2: Theme Tokens */}
{activeStory === 'theme-tokens' && (
<Card className="p-6 space-y-6">
<div className="border-b hl-border pb-4 flex items-center justify-between">
<div>
<span
className="text-[11px] font-mono-num uppercase tracking-wider"
style={{ color: 'var(--accent)' }}
>
Foundations / Color System
</span>
<h2 className="text-lg font-bold mt-0.5">
主流双主题色板规范(当前生效:{resolvedTheme === 'dark' ? '深色极客' : '浅色纯净'})
</h2>
</div>
<Badge variant="secondary">
模式: {theme === 'system' ? '跟随设备自动切换' : '手动锁定'}
</Badge>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
{(['light', 'dark'] as const).map((modeKey) => {
const p = THEME_PALETTES[modeKey];
return (
<div
key={p.id}
className="rounded-xl p-5 space-y-4 border"
style={{
backgroundColor: p.tokens['--bg-page'],
color: p.tokens['--text-main'],
borderColor: p.tokens['--border-color'],
}}
>
<div
className="flex items-center justify-between border-b pb-3"
style={{ borderColor: p.tokens['--border-color'] }}
>
<div>
<div className="text-sm font-bold">{p.name}</div>
<div className="text-xs mt-0.5" style={{ color: p.tokens['--text-muted'] }}>
{p.description}
</div>
</div>
<button
onClick={() => setTheme(modeKey)}
className="px-2.5 py-1 rounded-md text-xs font-mono-num font-semibold cursor-pointer"
style={{
backgroundColor: p.tokens['--primary'],
color: p.tokens['--primary-fg'],
}}
>
预览此主题
</button>
</div>
<div className="grid grid-cols-2 gap-2">
{Object.entries(p.tokens).map(([tokenName, hex]) => (
<div
key={tokenName}
className="p-2 rounded-lg border flex items-center gap-2"
style={{
backgroundColor: p.tokens['--bg-card'],
borderColor: p.tokens['--border-color'],
}}
>
<span
className="w-5 h-5 rounded border shrink-0"
style={{
backgroundColor: hex,
borderColor: p.tokens['--border-hover'],
}}
/>
<div className="min-w-0">
<div className="text-[10px] font-mono-num font-semibold truncate">
{tokenName}
</div>
<div
className="text-[10px] font-mono-num truncate"
style={{ color: p.tokens['--text-muted'] }}
>
{hex}
</div>
</div>
</div>
))}
</div>
</div>
);
})}
</div>
</Card>
)}
{/* STORY 3: Official shadcn/ui + Radix UI Primitives & AuthGate Card */}
{activeStory === 'form-inputs' && (
<Card className="p-6 space-y-6">
<div className="border-b hl-border pb-4 flex items-center justify-between">
<div>
<span
className="text-[11px] font-mono-num uppercase tracking-wider"
style={{ color: 'var(--accent)' }}
>
src/components/ui/* · Official shadcn/ui + Radix Primitives + cmdk
</span>
<h2 className="text-lg font-bold mt-0.5">
开源 `shadcn/ui` 组件库(`Input` / `Button` / `Switch` / `Badge` / `CommandDialog` / `AuthGateCard`)
</h2>
</div>
<KeyRound className="w-4 h-4" style={{ color: 'var(--accent)' }} />
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 items-start">
{/* Left: shadcn/ui Primitives Showcase */}
<div className="p-5 rounded-xl hl-muted border hl-border space-y-4">
<div
className="text-xs font-bold uppercase tracking-wider"
style={{ color: 'var(--text-muted)' }}
>
1. `src/components/ui/*` 标准原语展示
</div>
{/* Button Variants */}
<div className="space-y-1.5">
<label className="block text-xs font-medium" style={{ color: 'var(--text-muted)' }}>
Button 变体 (`cva` + `@radix-ui/react-slot`)
</label>
<div className="flex flex-wrap items-center gap-2">
<Button variant="default">Primary 主按钮</Button>
<Button variant="outline">Outline 描边</Button>
<Button variant="secondary">Secondary 次级</Button>
<Button variant="accent">Accent 高亮</Button>
<Button variant="ghost" onClick={() => setShowCmdPalette(true)}>
唤起 cmdk 面板 ⌘K
</Button>
</div>
</div>
{/* Badge Variants & Radix Switch */}
<div className="space-y-1.5">
<label className="block text-xs font-medium" style={{ color: 'var(--text-muted)' }}>
Badge 标签与 `@radix-ui/react-switch` 开关
</label>
<div className="flex flex-wrap items-center justify-between gap-2 p-2.5 rounded-lg hl-card">
<div className="flex items-center gap-1.5">
<Badge variant="default">Default</Badge>
<Badge variant="accent">Radix UI</Badge>
<Badge variant="success">Online</Badge>
<Badge variant="outline">v3.2</Badge>
</div>
<Switch checked={demoSwitch} onCheckedChange={setDemoSwitch} />
</div>
</div>
{/* Input & InputGroup */}
<div className="space-y-1.5">
<label className="block text-xs font-medium" style={{ color: 'var(--text-muted)' }}>
`InputGroup` 组合输入框
</label>
<InputGroup>
<Search className="w-4 h-4 shrink-0" style={{ color: 'var(--text-subtle)' }} />
<input
type="text"
value={demoSearch}
onChange={(e) => setDemoSearch(e.target.value)}
placeholder="搜索服务、SSH 指令或节点..."
/>
<kbd
className="px-1.5 py-0.5 text-[10px] font-mono-num rounded hl-card border hl-border"
style={{ color: 'var(--text-muted)' }}
>
⌘K
</kbd>
</InputGroup>
</div>
<div className="space-y-1.5">
<label className="block text-xs font-medium" style={{ color: 'var(--text-muted)' }}>
`Input` 等宽字体输入框
</label>
<Input
type="text"
value={demoHost}
onChange={(e) => setDemoHost(e.target.value)}
className="font-mono-num"
placeholder="192.168.31.164"
/>
</div>
</div>
{/* Right: Live AuthGateCard Preview */}
<div className="flex flex-col items-center">
<AuthGateCard
embedded
targetUrl="https://agy.961504.xyz"
onSuccess={() => showToast('SSO 认证成功!已签发 30 天全域名通行 Cookie')}
/>
</div>
</div>
</Card>
)}
{/* STORY 4: ServiceCard Interactive Playground */}
{activeStory === 'service-card' && (
<Card className="p-6 space-y-6">
<div className="border-b hl-border pb-4 flex items-center justify-between">
<div>
<span
className="text-[11px] font-mono-num uppercase tracking-wider"
style={{ color: 'var(--accent)' }}
>
Components / ServiceCard
</span>
<h2 className="text-lg font-bold mt-0.5">
三段式内联遥测卡片 (ServiceCard Interactive Story)
</h2>
</div>
<Sparkles className="w-4 h-4" style={{ color: 'var(--accent)' }} />
</div>
{/* Interactive Controls */}
<div className="p-4 rounded-xl hl-muted border hl-border flex flex-wrap items-center gap-6 text-xs">
<div className="flex items-center gap-2.5">
<span className="font-semibold">在线状态 (online):</span>
<Switch checked={demoOnline} onCheckedChange={setDemoOnline} />
<span className="font-mono-num">{demoOnline ? 'true' : 'false'}</span>
</div>
<div className="flex items-center gap-2">
<span className="font-semibold">右上角动作按钮 (actionType):</span>
{(['drawer', 'backup', undefined] as const).map((act, i) => (
<Button
key={i}
variant={demoAction === act ? 'accent' : 'outline'}
size="sm"
onClick={() => setDemoAction(act)}
className="font-mono-num"
>
{act || 'route-badge'}
</Button>
))}
</div>
</div>
{/* Live Component Canvas */}
<div className="max-w-md mx-auto py-4">
<ServiceCard
card={{
id: 'agy',
title: 'AGY 云端工作台',
subtitle: 'agy.961504.xyz · ttyd + tmux',
iconUrl:
'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/google-gemini.png',
online: demoOnline,
actionType: demoAction,
desc: 'M6 Mac mini 上的 24 小时常驻 AI 编程终端,已同步 Google 登录态并锁定新加坡出口,关掉网页后台继续运行。',
edgeUrl: 'https://agy.961504.xyz',
homeUrl: 'https://agy.home.961504.xyz:10443',
lanUrl: 'http://192.168.31.164:7681',
stats: [
{ label: 'M6 CPU 负载', value: `${telemetry.mini.cpu_pct}%` },
{ label: '已用内存', value: `${telemetry.mini.mem_used_gb} / 24 GB` },
{ label: 'tmux 会话', value: '常驻运行中', highlight: true },
],
}}
/>
</div>
</Card>
)}
{/* STORY 5: SshShelf */}
{activeStory === 'ssh-shelf' && (
<Card className="p-6 space-y-6">
<div className="border-b hl-border pb-4">
<span
className="text-[11px] font-mono-num uppercase tracking-wider"
style={{ color: 'var(--accent)' }}
>
Components / SshShelf
</span>
<h2 className="text-lg font-bold mt-0.5">底部 SSH 快捷复制与运维操作栏</h2>
</div>
<SshShelf />
</Card>
)}
</div>
</div>
</div>
);
};
@@ -0,0 +1,241 @@
import React from 'react';
import { BookOpen, Bot, LayoutGrid, MessageSquare, Moon, Search, Sun, Terminal } from 'lucide-react';
import { usePortalStore } from '../store/usePortalStore';
import type { RouteModeId } from '../types/telemetry';
import { Button } from './ui/button';
import { Tabs, TabsList, TabsTrigger } from './ui/tabs';
const MODES: Array<{ id: RouteModeId; label: string }> = [
{ id: 'auto', label: '智能路由' },
{ id: 'edge443', label: '443 免端口' },
{ id: 'home10443', label: '10443 家宽' },
{ id: 'lan', label: 'LAN 局域网' },
];
export const TopRibbon: React.FC = () => {
const {
theme,
resolvedTheme,
routeMode,
viewMode,
telemetry,
setTheme,
toggleTheme,
setRouteMode,
setViewMode,
setShowAgyModal,
setShowWechatModal,
setShowCmdPalette,
} = usePortalStore();
return (
<>
{/* =====================================================================
Tier 1: Standard AppShell Global Top Navbar (h-14, NEVER wraps!)
Left: Brand + Radix Tabs (中控台 / Storybook)
Far Right: Search (Cmd+K) + AGY Terminal + Theme Switcher
===================================================================== */}
<header
className="border-b hl-border sticky top-0 z-30 backdrop-blur-md"
style={{ backgroundColor: 'color-mix(in srgb, var(--bg-card) 88%, transparent)' }}
>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-14 flex items-center justify-between gap-4">
{/* Left Group: Brand Logo + Official Radix Navigation Tabs */}
<div className="flex items-center gap-4 shrink-0">
<div
onClick={() => setViewMode('dashboard')}
className="flex items-center gap-2.5 cursor-pointer"
>
<img
src="https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/apple.png"
alt="Mac mini"
className="w-6 h-6 rounded"
/>
<span className="font-semibold text-sm tracking-tight">HomeLab</span>
<span
className="w-2 h-2 rounded-full"
style={{ backgroundColor: 'var(--success)' }}
title="全屋 4 节点在线"
/>
</div>
<div className="h-4 w-[1px] hl-muted" />
{/* View Switcher Tabs via @radix-ui/react-tabs */}
<Tabs
value={viewMode}
onValueChange={(v) => setViewMode(v as 'dashboard' | 'agents' | 'storybook')}
>
<TabsList>
<TabsTrigger value="dashboard">
<LayoutGrid className="w-3.5 h-3.5" />
<span>中控台</span>
</TabsTrigger>
<TabsTrigger value="agents">
<Bot className="w-3.5 h-3.5" />
<span>Agent 编排</span>
</TabsTrigger>
<TabsTrigger value="storybook">
<BookOpen className="w-3.5 h-3.5" />
<span>Storybook 设计规范</span>
</TabsTrigger>
</TabsList>
</Tabs>
</div>
{/* Far Right Group: Pinned to Right Edge (Search + AGY + Theme Switcher) */}
<div className="flex items-center gap-2.5 shrink-0 ml-auto">
<Button
variant="outline"
onClick={() => setShowCmdPalette(true)}
className="gap-3"
title="按 Cmd+K 唤起全局命令面板"
>
<span className="flex items-center gap-1.5" style={{ color: 'var(--text-muted)' }}>
<Search className="w-3.5 h-3.5" />
<span className="hidden sm:inline">搜索服务或指令...</span>
</span>
<kbd
className="px-1.5 py-0.5 text-[10px] font-mono-num rounded hl-muted"
style={{ color: 'var(--text-muted)' }}
>
⌘K
</kbd>
</Button>
<Button
variant="outline"
onClick={() => setShowWechatModal(true)}
title="连接微信 ClawBot,在微信内直接收发消息与控制 Mac mini"
>
<span
className="w-2 h-2 rounded-full"
style={{
backgroundColor: telemetry.services.clawbot ? 'var(--success)' : 'var(--accent)',
}}
/>
<MessageSquare className="w-3.5 h-3.5" />
<span>微信 ClawBot</span>
</Button>
<Button onClick={() => setShowAgyModal(true)}>
<Terminal className="w-3.5 h-3.5" />
<span>AGY 终端</span>
</Button>
{/* Top-Right Theme Toggle (Pinned at Far Right Corner!) */}
<div className="flex items-center p-0.5 rounded-lg hl-muted border hl-border text-xs">
<button
onClick={toggleTheme}
className="px-2.5 py-1 rounded-md font-medium transition-all flex items-center gap-1.5 cursor-pointer"
style={{
backgroundColor: 'var(--bg-card)',
color: 'var(--text-main)',
boxShadow: '0 1px 2px rgba(0,0,0,0.08)',
}}
title="点击切换深色 / 浅色主题(默认跟随系统)"
>
{resolvedTheme === 'dark' ? (
<>
<Moon className="w-3.5 h-3.5" />
<span>深色</span>
</>
) : (
<>
<Sun className="w-3.5 h-3.5" />
<span>浅色</span>
</>
)}
</button>
{theme !== 'system' && (
<button
onClick={() => setTheme('system')}
className="px-2 py-1 rounded-md text-[11px] transition-all cursor-pointer"
style={{ color: 'var(--text-muted)' }}
title="恢复跟随设备系统深浅色自动切换"
>
自动
</button>
)}
</div>
</div>
</div>
</header>
{/* =====================================================================
Tier 2: Subheader Telemetry & Route Mode Bar (Aligned with Grid)
Left: 4 Hardware & Network Gauges
Right: Route Mode Segmented Switcher via @radix-ui/react-tabs
===================================================================== */}
{viewMode === 'dashboard' && (
<div className="border-b hl-border" style={{ backgroundColor: 'var(--bg-card)' }}>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-2.5 flex flex-wrap items-center justify-between gap-3">
{/* Left: System Telemetry Strip */}
<div className="flex flex-wrap items-center gap-2 text-xs font-mono-num">
<div className="hl-pill px-2.5 py-1 flex items-center gap-2">
<span style={{ color: 'var(--text-muted)' }}>M6 CPU</span>
<div className="w-12 h-1.5 rounded-full hl-muted overflow-hidden">
<div
className="h-full hl-bar-fill"
style={{ width: `${Math.max(5, telemetry.mini.cpu_pct)}%` }}
/>
</div>
<span className="font-medium">{telemetry.mini.cpu_pct}%</span>
</div>
<div className="hl-pill px-2.5 py-1 flex items-center gap-2">
<span style={{ color: 'var(--text-muted)' }}>RAM</span>
<div className="w-12 h-1.5 rounded-full hl-muted overflow-hidden">
<div
className="h-full hl-bar-fill"
style={{ width: `${Math.max(5, telemetry.mini.mem_pct)}%` }}
/>
</div>
<span className="font-medium">{telemetry.mini.mem_used_gb}G</span>
</div>
<div className="hl-pill px-2.5 py-1 flex items-center gap-2">
<span style={{ color: 'var(--text-muted)' }}>NAS 余量</span>
<div className="w-12 h-1.5 rounded-full hl-muted overflow-hidden">
<div
className="h-full hl-bar-fill-green"
style={{ width: `${Math.max(5, telemetry.nas.pct)}%` }}
/>
</div>
<span className="font-medium" style={{ color: 'var(--success)' }}>
{telemetry.nas.free_tb} TB
</span>
</div>
<div className="hl-pill px-2.5 py-1 flex items-center gap-1.5">
<span
className="w-1.5 h-1.5 rounded-full"
style={{ backgroundColor: 'var(--success)' }}
/>
<span style={{ color: 'var(--text-muted)' }}>SG 专线</span>
<span className="font-medium">
{telemetry.network.sg_delay_ms > 0 ? `${telemetry.network.sg_delay_ms}ms` : '直连'}
</span>
</div>
</div>
{/* Right: Route Mode Switcher via @radix-ui/react-tabs */}
<Tabs
value={routeMode}
onValueChange={(v) => setRouteMode(v as RouteModeId)}
className="ml-auto"
>
<TabsList>
{MODES.map((m) => (
<TabsTrigger key={m.id} value={m.id}>
{m.label}
</TabsTrigger>
))}
</TabsList>
</Tabs>
</div>
</div>
)}
</>
);
};
@@ -0,0 +1,301 @@
import React, { useEffect, useState } from 'react';
import QRCode from 'qrcode';
import {
CheckCircle2,
MessageSquare,
QrCode,
RefreshCw,
Send,
Smartphone,
Unplug,
} from 'lucide-react';
import { usePortalStore } from '../store/usePortalStore';
import type { ClawbotPublicStatus } from '../types/telemetry';
import { Badge } from './ui/badge';
import { Button } from './ui/button';
import { Dialog, DialogContent, DialogDescription, DialogTitle } from './ui/dialog';
import { Input } from './ui/input';
export const WechatClawModal: React.FC = () => {
const { showWechatModal, setShowWechatModal, showToast, fetchTelemetry } = usePortalStore();
const [statusData, setStatusData] = useState<ClawbotPublicStatus | null>(null);
const [qrDataUrl, setQrDataUrl] = useState<string>('');
const [loadingQr, setLoadingQr] = useState(false);
const [verifyCode, setVerifyCode] = useState('');
const [submittingCode, setSubmittingCode] = useState(false);
const generateNewQr = async () => {
setLoadingQr(true);
try {
const res = await fetch('/api/clawbot/qrcode', { method: 'POST' });
const data = await res.json();
setStatusData(data);
const url = data?.login?.qrcode_url;
if (url) {
const svgUrl = await QRCode.toDataURL(url, {
width: 240,
margin: 2,
color: { dark: '#09090b', light: '#ffffff' },
});
setQrDataUrl(svgUrl);
}
} catch {
showToast('获取微信 ClawBot 二维码失败');
} finally {
setLoadingQr(false);
}
};
// Initial load & polling while modal is open
useEffect(() => {
if (!showWechatModal) return;
let cancelled = false;
const init = async () => {
try {
const r = await fetch('/api/clawbot/status');
const d: ClawbotPublicStatus = await r.json();
if (cancelled) return;
setStatusData(d);
if (!d.connected && (!d.login?.active || !d.login?.qrcode_url)) {
await generateNewQr();
} else if (d.login?.qrcode_url) {
const svgUrl = await QRCode.toDataURL(d.login.qrcode_url, {
width: 240,
margin: 2,
color: { dark: '#09090b', light: '#ffffff' },
});
if (!cancelled) setQrDataUrl(svgUrl);
}
} catch {
// ignore
}
};
init();
const timer = setInterval(async () => {
try {
const r = await fetch('/api/clawbot/status');
if (!r.ok) return;
const d: ClawbotPublicStatus = await r.json();
if (cancelled) return;
setStatusData(d);
if (d.login?.qrcode_url) {
const svgUrl = await QRCode.toDataURL(d.login.qrcode_url, {
width: 240,
margin: 2,
color: { dark: '#09090b', light: '#ffffff' },
});
if (!cancelled) setQrDataUrl(svgUrl);
}
} catch {
// ignore
}
}, 1500);
return () => {
cancelled = true;
clearInterval(timer);
};
}, [showWechatModal]);
const handleVerifySubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!verifyCode.trim()) return;
setSubmittingCode(true);
try {
const res = await fetch('/api/clawbot/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code: verifyCode.trim() }),
});
const data = await res.json();
setStatusData(data);
setVerifyCode('');
showToast('配对码已提交,正在等待微信验证...');
} catch {
showToast('配对码提交失败');
} finally {
setSubmittingCode(false);
}
};
const handleDisconnect = async () => {
try {
const res = await fetch('/api/clawbot/disconnect', { method: 'POST' });
const data = await res.json();
setStatusData(data);
setQrDataUrl('');
fetchTelemetry();
showToast('已断开微信 ClawBot 连接');
} catch {
showToast('断开失败');
}
};
const connected = Boolean(statusData?.connected);
const loginState = statusData?.login;
return (
<Dialog open={showWechatModal} onOpenChange={setShowWechatModal}>
<DialogContent className="max-w-lg p-6 space-y-5">
{/* Header */}
<div className="flex items-start justify-between gap-3 border-b hl-border pb-4">
<div className="flex items-center gap-3">
<div
className="w-10 h-10 rounded-xl flex items-center justify-center border hl-border shrink-0"
style={{ backgroundColor: 'rgba(16, 185, 129, 0.14)', color: 'var(--success)' }}
>
<MessageSquare className="w-5 h-5" />
</div>
<div>
<div className="flex items-center gap-2">
<DialogTitle className="text-sm font-bold">
微信 ClawBot · M6 Mac mini 直连通道
</DialogTitle>
<Badge variant={connected ? 'success' : 'accent'}>
{connected ? '已连接在线' : '扫码绑定'}
</Badge>
</div>
<DialogDescription className="text-xs font-mono-num mt-0.5">
@tencent-weixin/openclaw-weixin · ilinkai.weixin.qq.com
</DialogDescription>
</div>
</div>
</div>
{/* Body: Connected vs QR Login */}
{connected ? (
<div className="space-y-4">
<div
className="p-4 rounded-xl border flex items-center justify-between"
style={{
backgroundColor: 'var(--success-soft)',
borderColor: 'rgba(16, 185, 129, 0.3)',
}}
>
<div className="flex items-center gap-3">
<CheckCircle2 className="w-5 h-5 shrink-0" style={{ color: 'var(--success)' }} />
<div>
<div className="text-xs font-bold">微信 ClawBot 长轮询守护线程运行中</div>
<div className="text-[11px] font-mono-num mt-0.5" style={{ color: 'var(--text-muted)' }}>
Bot ID: {statusData?.bot_id} · 累计收发: {statusData?.msg_count ?? 0} 条
</div>
</div>
</div>
<Button variant="outline" size="sm" onClick={handleDisconnect}>
<Unplug className="w-3.5 h-3.5" />
<span>重新绑定</span>
</Button>
</div>
<div className="p-4 rounded-xl hl-muted border hl-border space-y-2.5 text-xs">
<div className="font-semibold">📱 现在打开手机微信「微信 ClawBot」可直接发送:</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 font-mono-num text-[11px]">
<div className="p-2 rounded hl-card">
<span style={{ color: 'var(--accent)' }}>状态</span> · 查看 M6 & 极空间遥测
</div>
<div className="p-2 rounded hl-card">
<span style={{ color: 'var(--accent)' }}>备份</span> · 立即打包备份至 20TB NAS
</div>
<div className="p-2 rounded hl-card">
<span style={{ color: 'var(--accent)' }}>屏幕</span> · 截取 AGY 终端实时画面
</div>
<div className="p-2 rounded hl-card">
<span style={{ color: 'var(--accent)' }}>$ df -h</span> · 执行任意 Mac mini 命令
</div>
<div className="p-2 rounded hl-card sm:col-span-2">
<span style={{ color: 'var(--accent)' }}>&gt; 你好</span> 或直接发任意问题 · 注入 AGY 终端或调用 AI 回答
</div>
</div>
</div>
</div>
) : (
<div className="space-y-4">
<div className="flex flex-col items-center justify-center p-5 rounded-xl hl-muted border hl-border space-y-3">
{qrDataUrl ? (
<div className="p-3 bg-white rounded-xl shadow-md border">
<img
src={qrDataUrl}
alt="WeChat ClawBot QR Code"
className="w-48 h-48 block"
/>
</div>
) : (
<div className="w-48 h-48 rounded-xl hl-card flex items-center justify-center text-xs font-mono-num">
{loadingQr ? '正在向微信申请二维码...' : '点击下方按钮生成二维码'}
</div>
)}
<div className="flex items-center gap-2 text-xs font-medium text-center">
<Smartphone className="w-4 h-4 shrink-0" style={{ color: 'var(--accent)' }} />
<span>{loginState?.message || '请使用手机微信扫描上方二维码启用 ClawBot'}</span>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={generateNewQr}
disabled={loadingQr}
>
<RefreshCw className="w-3.5 h-3.5" />
<span>{loadingQr ? '刷新中...' : '刷新二维码'}</span>
</Button>
{loginState?.qrcode_url && (
<Button variant="ghost" size="sm" asChild>
<a
href={loginState.qrcode_url}
target="_blank"
rel="noopener noreferrer"
className="font-mono-num"
>
<QrCode className="w-3.5 h-3.5" />
<span>打开备用链接 ↗</span>
</a>
</Button>
)}
</div>
</div>
{/* Verify Code Input (if WeChat prompts for pairing digits) */}
{loginState?.status === 'need_verifycode' && (
<form
onSubmit={handleVerifySubmit}
className="p-4 rounded-xl border space-y-2.5"
style={{
backgroundColor: 'var(--accent-soft)',
borderColor: 'var(--accent)',
}}
>
<label className="block text-xs font-bold">
🔢 手机微信上显示了配对数字,请在下方输入:
</label>
<div className="flex items-center gap-2">
<Input
type="text"
value={verifyCode}
onChange={(e) => setVerifyCode(e.target.value)}
placeholder="输入手机微信显示的 6 位数字..."
className="font-mono-num"
autoFocus
/>
<Button type="submit" disabled={submittingCode}>
<Send className="w-3.5 h-3.5" />
<span>验证</span>
</Button>
</div>
</form>
)}
</div>
)}
<div className="flex items-center justify-end pt-1">
<Button variant="outline" onClick={() => setShowWechatModal(false)}>
关闭窗口
</Button>
</div>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,31 @@
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '../../lib/utils';
const badgeVariants = cva(
'inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-[11px] font-mono-num font-medium transition-colors shrink-0',
{
variants: {
variant: {
default: 'bg-[var(--primary)] text-[var(--primary-fg)]',
secondary: 'hl-muted text-[var(--text-muted)] border hl-border',
outline: 'border hl-border text-[var(--text-muted)] bg-[var(--bg-card)]',
accent: 'bg-[var(--accent-soft)] text-[var(--accent)] font-semibold',
success: 'bg-[var(--success-soft)] text-[var(--success)] font-semibold',
},
},
defaultVariants: {
variant: 'secondary',
},
}
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
}
export { Badge, badgeVariants };
@@ -0,0 +1,52 @@
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '../../lib/utils';
const buttonVariants = cva(
'inline-flex items-center justify-center gap-1.5 whitespace-nowrap rounded-lg text-xs font-medium transition-all cursor-pointer select-none disabled:pointer-events-none disabled:opacity-50 shrink-0',
{
variants: {
variant: {
default: 'hl-btn-primary shadow-xs',
outline: 'hl-btn-outline',
secondary: 'hl-muted border hl-border hover:opacity-90',
ghost: 'hover:bg-[var(--bg-muted)]',
accent:
'border border-[var(--accent)] bg-[var(--accent-soft)] text-[var(--accent)] font-semibold hover:opacity-90',
},
size: {
default: 'h-8 px-3 py-1.5',
sm: 'h-7 px-2.5 text-[11px] rounded-md',
lg: 'h-9 px-4 text-xs',
icon: 'h-8 w-8 p-0',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
}
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : 'button';
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
}
);
Button.displayName = 'Button';
export { Button, buttonVariants };
@@ -0,0 +1,55 @@
import * as React from 'react';
import { cn } from '../../lib/utils';
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('hl-card', className)} {...props} />
)
);
Card.displayName = 'Card';
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex flex-col space-y-1.5 p-5', className)} {...props} />
)
);
CardHeader.displayName = 'CardHeader';
const CardTitle = React.forwardRef<HTMLHeadingElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn('text-sm font-bold leading-none tracking-tight', className)}
{...props}
/>
)
);
CardTitle.displayName = 'CardTitle';
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn('text-xs text-[var(--text-muted)]', className)}
{...props}
/>
));
CardDescription.displayName = 'CardDescription';
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('p-5 pt-0', className)} {...props} />
)
);
CardContent.displayName = 'CardContent';
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex items-center p-5 pt-0', className)} {...props} />
)
);
CardFooter.displayName = 'CardFooter';
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
@@ -0,0 +1,121 @@
import * as React from 'react';
import { Command as CommandPrimitive } from 'cmdk';
import { Search } from 'lucide-react';
import { cn } from '../../lib/utils';
import { Dialog, DialogContent, DialogDescription, DialogTitle } from './dialog';
const Command = React.forwardRef<
React.ComponentRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn(
'flex h-full w-full flex-col overflow-hidden rounded-xl bg-[var(--bg-card)] text-[var(--text-main)]',
className
)}
{...props}
/>
));
Command.displayName = CommandPrimitive.displayName;
interface CommandDialogProps extends React.ComponentPropsWithoutRef<typeof Dialog> {}
const CommandDialog: React.FC<CommandDialogProps> = ({ children, ...props }) => {
return (
<Dialog {...props}>
<DialogContent className="max-w-xl overflow-hidden p-0 shadow-2xl top-[22%] translate-y-0">
<DialogTitle className="sr-only">全局命令与服务搜索面板</DialogTitle>
<DialogDescription className="sr-only">
使用键盘上下方向键选择服务或操作,回车立即执行
</DialogDescription>
<Command>{children}</Command>
</DialogContent>
</Dialog>
);
};
const CommandInput = React.forwardRef<
React.ComponentRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div className="flex items-center border-b hl-border px-4 py-3 gap-3" cmdk-input-wrapper="">
<Search className="h-4 w-4 shrink-0 text-[var(--text-muted)]" />
<CommandPrimitive.Input
ref={ref}
className={cn(
'flex w-full bg-transparent text-sm outline-none placeholder:text-[var(--text-subtle)] disabled:cursor-not-allowed disabled:opacity-50',
className
)}
{...props}
/>
<kbd className="px-1.5 py-0.5 text-[10px] font-mono-num rounded hl-muted text-[var(--text-muted)] shrink-0">
ESC
</kbd>
</div>
));
CommandInput.displayName = CommandPrimitive.Input.displayName;
const CommandList = React.forwardRef<
React.ComponentRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn('max-h-80 overflow-y-auto overflow-x-hidden p-2', className)}
{...props}
/>
));
CommandList.displayName = CommandPrimitive.List.displayName;
const CommandEmpty = React.forwardRef<
React.ComponentRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => (
<CommandPrimitive.Empty
ref={ref}
className="py-6 text-center text-xs text-[var(--text-muted)]"
{...props}
/>
));
CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
const CommandGroup = React.forwardRef<
React.ComponentRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
className={cn(
'overflow-hidden p-1 text-[var(--text-main)] [&_[cmdk-group-heading]]:px-2.5 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-[10px] [&_[cmdk-group-heading]]:font-semibold [&_[cmdk-group-heading]]:uppercase [&_[cmdk-group-heading]]:tracking-wider [&_[cmdk-group-heading]]:text-[var(--text-subtle)]',
className
)}
{...props}
/>
));
CommandGroup.displayName = CommandPrimitive.Group.displayName;
const CommandItem = React.forwardRef<
React.ComponentRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
'relative flex cursor-pointer select-none items-center justify-between rounded-lg px-3 py-2 text-xs outline-none transition-colors data-[selected=true]:bg-[var(--bg-muted)] data-[selected=true]:text-[var(--text-main)] data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50',
className
)}
{...props}
/>
));
CommandItem.displayName = CommandPrimitive.Item.displayName;
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
};
@@ -0,0 +1,90 @@
import * as React from 'react';
import * as DialogPrimitive from '@radix-ui/react-dialog';
import { cn } from '../../lib/utils';
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close;
const DialogOverlay = React.forwardRef<
React.ComponentRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
'fixed inset-0 z-50 bg-black/70 backdrop-blur-sm transition-opacity',
className
)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ComponentRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
'fixed left-[50%] top-[50%] z-50 w-full max-w-lg translate-x-[-50%] translate-y-[-50%] hl-card shadow-2xl duration-200 outline-none',
className
)}
{...props}
>
{children}
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn('flex flex-col space-y-1.5 text-left', className)}
{...props}
/>
);
DialogHeader.displayName = 'DialogHeader';
const DialogTitle = React.forwardRef<
React.ComponentRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn('text-sm font-bold leading-none tracking-tight', className)}
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<
React.ComponentRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn('text-xs text-[var(--text-muted)]', className)}
{...props}
/>
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
};
@@ -0,0 +1,29 @@
import * as React from 'react';
import { cn } from '../../lib/utils';
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn('hl-input', className)}
ref={ref}
{...props}
/>
);
}
);
Input.displayName = 'Input';
export interface InputGroupProps extends React.HTMLAttributes<HTMLDivElement> {}
const InputGroup = React.forwardRef<HTMLDivElement, InputGroupProps>(
({ className, ...props }, ref) => {
return <div ref={ref} className={cn('hl-input-group', className)} {...props} />;
}
);
InputGroup.displayName = 'InputGroup';
export { Input, InputGroup };
@@ -0,0 +1,26 @@
import * as React from 'react';
import * as SwitchPrimitives from '@radix-ui/react-switch';
import { cn } from '../../lib/utils';
const Switch = React.forwardRef<
React.ComponentRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
'peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border transition-colors disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-[var(--accent)] data-[state=checked]:border-[var(--accent)] data-[state=unchecked]:bg-[var(--bg-muted)] data-[state=unchecked]:border-[var(--border-color)]',
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
'pointer-events-none block h-3.5 w-3.5 rounded-full bg-white shadow-sm transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0.5'
)}
/>
</SwitchPrimitives.Root>
));
Switch.displayName = SwitchPrimitives.Root.displayName;
export { Switch };
@@ -0,0 +1,49 @@
import * as React from 'react';
import * as TabsPrimitive from '@radix-ui/react-tabs';
import { cn } from '../../lib/utils';
const Tabs = TabsPrimitive.Root;
const TabsList = React.forwardRef<
React.ComponentRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
'inline-flex items-center justify-center rounded-lg hl-muted p-1 border hl-border text-[var(--text-muted)]',
className
)}
{...props}
/>
));
TabsList.displayName = TabsPrimitive.List.displayName;
const TabsTrigger = React.forwardRef<
React.ComponentRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
'inline-flex items-center justify-center gap-1.5 whitespace-nowrap rounded-md px-2.5 py-1 text-xs font-medium transition-all cursor-pointer select-none disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-[var(--bg-card)] data-[state=active]:text-[var(--text-main)] data-[state=active]:shadow-xs',
className
)}
{...props}
/>
));
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
const TabsContent = React.forwardRef<
React.ComponentRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn('mt-2 outline-none', className)}
{...props}
/>
));
TabsContent.displayName = TabsPrimitive.Content.displayName;
export { Tabs, TabsList, TabsTrigger, TabsContent };
@@ -0,0 +1,135 @@
import React from 'react';
import { THEME_PALETTES, LAYOUT_PRESETS } from './tokens';
export const DesignTokensShowcase: React.FC = () => {
return (
<div className="p-6 sm:p-8 space-y-10 max-w-6xl mx-auto">
{/* Header */}
<div className="space-y-2 border-b hl-border pb-5">
<div className="text-xs font-mono-num uppercase tracking-widest" style={{ color: 'var(--accent)' }}>
HomeLab Design System · Storybook v3.0
</div>
<h1 className="text-2xl font-bold tracking-tight">主题色彩、排版与布局规范 (Design Tokens)</h1>
<p className="text-sm" style={{ color: 'var(--text-muted)' }}>
全系统仅保留两套高标准主流主题(浅色纯净 Light & 深色极客 Dark),默认通过{' '}
<code className="font-mono-num px-1.5 py-0.5 rounded hl-muted">prefers-color-scheme</code>{' '}
自动跟随设备切换,并支持右上角一键手动覆盖。
</p>
</div>
{/* Side-by-Side Theme Palettes */}
<section className="space-y-4">
<h2 className="text-base font-semibold">1. 双主题色彩变量对照表 (Dual Mainstream Palettes)</h2>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{(['light', 'dark'] as const).map((key) => {
const p = THEME_PALETTES[key];
return (
<div
key={p.id}
className="rounded-xl p-5 space-y-4 border"
style={{
backgroundColor: p.tokens['--bg-page'],
color: p.tokens['--text-main'],
borderColor: p.tokens['--border-color'],
}}
>
<div className="flex items-center justify-between border-b pb-3" style={{ borderColor: p.tokens['--border-color'] }}>
<div>
<div className="text-sm font-bold">{p.name}</div>
<div className="text-xs mt-0.5" style={{ color: p.tokens['--text-muted'] }}>
{p.description}
</div>
</div>
<span
className="px-2.5 py-1 rounded-md text-xs font-mono-num font-semibold"
style={{
backgroundColor: p.tokens['--primary'],
color: p.tokens['--primary-fg'],
}}
>
{p.id.toUpperCase()}
</span>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2.5">
{Object.entries(p.tokens).map(([tokenName, hex]) => (
<div
key={tokenName}
className="p-2.5 rounded-lg border flex items-center gap-2.5"
style={{
backgroundColor: p.tokens['--bg-card'],
borderColor: p.tokens['--border-color'],
}}
>
<span
className="w-6 h-6 rounded-md border shrink-0"
style={{
backgroundColor: hex,
borderColor: p.tokens['--border-hover'],
}}
/>
<div className="min-w-0">
<div className="text-[11px] font-mono-num font-semibold truncate">{tokenName}</div>
<div className="text-[10px] font-mono-num truncate" style={{ color: p.tokens['--text-muted'] }}>
{hex}
</div>
</div>
</div>
))}
</div>
</div>
);
})}
</div>
</section>
{/* Typography Scale */}
<section className="space-y-4">
<h2 className="text-base font-semibold">2. 字体与等宽数字规范 (Typography & Tabular Numbers)</h2>
<div className="hl-card p-5 space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<div className="text-xs font-mono-num uppercase" style={{ color: 'var(--text-muted)' }}>
UI Sans Font · Inter / SF Pro Display
</div>
<div className="text-xl font-bold">AGY 云端工作台 · 极空间 Z4 私有云</div>
<p className="text-xs leading-relaxed" style={{ color: 'var(--text-muted)' }}>
正文描述统一采用 12px (0.75rem) 搭配 1.625 行高,限制最最多 2 行截断 (line-clamp-2),确保所有卡片高度严格对齐。
</p>
</div>
<div className="space-y-2">
<div className="text-xs font-mono-num uppercase" style={{ color: 'var(--text-muted)' }}>
Telemetry Mono Font · JetBrains Mono (tabular-nums)
</div>
<div className="text-xl font-bold font-mono-num" style={{ color: 'var(--success)' }}>
16.58 TB FREE · 68 ms · 104.43.105.243
</div>
<p className="text-xs leading-relaxed" style={{ color: 'var(--text-muted)' }}>
所有实时跳动的 CPU 百分比、内存、存储余量、网络延迟强制启用{' '}
<code className="font-mono-num">font-variant-numeric: tabular-nums</code>,杜绝每 3 秒刷新时产生像素抖动。
</p>
</div>
</div>
</div>
</section>
{/* Layout Grid Presets */}
<section className="space-y-4">
<h2 className="text-base font-semibold">3. 响应式布局网格预设 (Layout Grid Presets)</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{Object.values(LAYOUT_PRESETS).map((lp) => (
<div key={lp.id} className="hl-card p-4 space-y-2">
<div className="text-sm font-semibold">{lp.name}</div>
<code className="text-[11px] font-mono-num block" style={{ color: 'var(--accent)' }}>
{lp.gridClass}
</code>
<p className="text-xs" style={{ color: 'var(--text-muted)' }}>
{lp.description}
</p>
</div>
))}
</div>
</section>
</div>
);
};
@@ -0,0 +1,102 @@
export interface ThemePalette {
id: 'light' | 'dark';
name: string;
description: string;
tokens: {
'--bg-page': string;
'--bg-card': string;
'--bg-widget': string;
'--bg-muted': string;
'--bg-hover': string;
'--text-main': string;
'--text-muted': string;
'--text-subtle': string;
'--border-color': string;
'--border-hover': string;
'--primary': string;
'--primary-fg': string;
'--accent': string;
'--accent-soft': string;
'--success': string;
'--success-soft': string;
};
}
export const THEME_PALETTES: Record<'light' | 'dark', ThemePalette> = {
light: {
id: 'light',
name: 'Shadcn Clean Light (浅色纯净)',
description: '对标 Apple / Stripe / Linear 浅色日光模式,高对比深灰文字配纯净白卡片与冷灰微边框。',
tokens: {
'--bg-page': '#f8fafc',
'--bg-card': '#ffffff',
'--bg-widget': '#f8fafc',
'--bg-muted': '#f1f5f9',
'--bg-hover': '#ffffff',
'--text-main': '#0f172a',
'--text-muted': '#64748b',
'--text-subtle': '#94a3b8',
'--border-color': '#e2e8f0',
'--border-hover': '#cbd5e1',
'--primary': '#0f172a',
'--primary-fg': '#ffffff',
'--accent': '#2563eb',
'--accent-soft': 'rgba(37, 99, 235, 0.08)',
'--success': '#10b981',
'--success-soft': 'rgba(16, 185, 129, 0.10)',
},
},
dark: {
id: 'dark',
name: 'Catppuccin / Linear Dark (深色极客)',
description: '融合 Catppuccin Mocha 与 Linear 暗黑规范,柔和护眼的深邃蓝黑基底配高辨识度状态色。',
tokens: {
'--bg-page': '#11111b',
'--bg-card': '#1e1e2e',
'--bg-widget': '#181825',
'--bg-muted': '#313244',
'--bg-hover': '#242438',
'--text-main': '#cdd6f4',
'--text-muted': '#a6adc8',
'--text-subtle': '#6c7086',
'--border-color': '#313244',
'--border-hover': '#45475a',
'--primary': '#cba6f7',
'--primary-fg': '#11111b',
'--accent': '#89b4fa',
'--accent-soft': 'rgba(137, 180, 250, 0.15)',
'--success': '#a6e3a1',
'--success-soft': 'rgba(166, 227, 161, 0.15)',
},
},
};
export type LayoutPresetId = 'homepage-3col' | 'bento-split' | 'compact-2col';
export interface LayoutPreset {
id: LayoutPresetId;
name: string;
description: string;
gridClass: string;
}
export const LAYOUT_PRESETS: Record<LayoutPresetId, LayoutPreset> = {
'homepage-3col': {
id: 'homepage-3col',
name: 'Homepage 经典三列矩阵 (3-Column Grid)',
description: 'gethomepage.dev 标志性对称 3 列分组卡片,底部内嵌 3 格实时遥测单元。',
gridClass: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4',
},
'bento-split': {
id: 'bento-split',
name: 'Shadcn 双列宽屏卡片 (2-Column Wide Bento)',
description: '适合大字号阅读的 2 列宽体卡片布局,留白更充裕。',
gridClass: 'grid-cols-1 lg:grid-cols-2 gap-5',
},
'compact-2col': {
id: 'compact-2col',
name: '高密度四列紧凑矩阵 (4-Column Dense)',
description: '适合超宽显示器的一屏高密度 4 列布局。',
gridClass: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3.5',
},
};
+219
View File
@@ -0,0 +1,219 @@
@import "tailwindcss";
/* =========================================================================
HomeLab Console V3 (React + Vite + TypeScript + Zustand + Tailwind v4)
Architecture: gethomepage.dev (Two-Tier AppShell + 3-Col Rows + Inline Widgets)
Design System: shadcn/ui + Catppuccin/Linear + Custom Form Controls & Auth Gate
========================================================================= */
:root,
[data-theme="dark"] {
--bg-page: #09090b;
--bg-card: #121215;
--bg-widget: #16161a;
--bg-muted: #18181b;
--bg-hover: #1c1c21;
--text-main: #f4f4f5;
--text-muted: #a1a1aa;
--text-subtle: #71717a;
--border-color: #27272a;
--border-subtle: rgba(255, 255, 255, 0.06);
--border-hover: #3f3f46;
--primary: #fafafa;
--primary-fg: #09090b;
--accent: #3b82f6;
--accent-soft: rgba(59, 130, 246, 0.16);
--success: #10b981;
--success-soft: rgba(16, 185, 129, 0.14);
--card-shadow: 0 4px 16px -4px rgba(0, 0, 0, 0.55);
}
[data-theme="light"] {
--bg-page: #f8fafc;
--bg-card: #ffffff;
--bg-widget: #f8fafc;
--bg-muted: #f1f5f9;
--bg-hover: #ffffff;
--text-main: #0f172a;
--text-muted: #64748b;
--text-subtle: #94a3b8;
--border-color: #e2e8f0;
--border-subtle: #f1f5f9;
--border-hover: #cbd5e1;
--primary: #0f172a;
--primary-fg: #ffffff;
--accent: #2563eb;
--accent-soft: rgba(37, 99, 235, 0.12);
--success: #10b981;
--success-soft: rgba(16, 185, 129, 0.1);
--card-shadow: 0 1px 3px 0 rgba(15, 23, 42, 0.04), 0 1px 2px -1px rgba(15, 23, 42, 0.03);
}
body {
font-family: "Inter", -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif;
background-color: var(--bg-page);
color: var(--text-main);
transition: background-color 0.2s ease, color 0.2s ease;
-webkit-font-smoothing: antialiased;
}
/* =========================================================================
Global Browser Default Input Reset & Custom Input System (.hl-input)
彻底禁用浏览器原生丑陋的 input 边框、外发光与黄色自动填充背景
========================================================================= */
input,
textarea,
select,
button {
-webkit-appearance: none;
appearance: none;
outline: none;
font-family: inherit;
}
input:-webkit-autofill,
input:-webkit-autofill:hover,
input:-webkit-autofill:focus,
textarea:-webkit-autofill {
-webkit-text-fill-color: var(--text-main) !important;
-webkit-box-shadow: 0 0 0px 1000px var(--bg-muted) inset !important;
transition: background-color 5000s ease-in-out 0s;
}
.hl-input {
width: 100%;
background-color: var(--bg-muted);
color: var(--text-main);
border: 1px solid var(--border-color);
border-radius: 0.625rem;
padding: 0.55rem 0.75rem;
font-size: 0.8125rem;
line-height: 1.25rem;
transition: border-color 0.15s ease, box-shadow 0.15s ease, background-color 0.15s ease;
}
.hl-input::placeholder {
color: var(--text-subtle);
}
.hl-input:hover {
border-color: var(--border-hover);
}
.hl-input:focus,
.hl-input:focus-visible {
background-color: var(--bg-card);
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-soft);
}
.hl-input-group {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
background-color: var(--bg-muted);
color: var(--text-main);
border: 1px solid var(--border-color);
border-radius: 0.625rem;
padding: 0 0.75rem;
height: 2.375rem;
transition: border-color 0.15s ease, box-shadow 0.15s ease, background-color 0.15s ease;
}
.hl-input-group:hover {
border-color: var(--border-hover);
}
.hl-input-group:focus-within {
background-color: var(--bg-card);
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-soft);
}
.hl-input-group input {
flex: 1;
min-width: 0;
height: 100%;
background: transparent;
border: 0;
color: var(--text-main);
font-size: 0.8125rem;
}
.hl-input-group input::placeholder {
color: var(--text-subtle);
}
.font-mono-num {
font-family: "JetBrains Mono", "SF Mono", Menlo, monospace;
font-variant-numeric: tabular-nums;
}
.hl-card {
background-color: var(--bg-card);
border: 1px solid var(--border-color);
box-shadow: var(--card-shadow);
border-radius: 0.75rem;
transition: border-color 0.16s ease, transform 0.16s ease, box-shadow 0.16s ease;
}
.hl-card-interactive:hover {
border-color: var(--border-hover);
background-color: var(--bg-hover);
transform: translateY(-1px);
}
.hl-widget-footer {
background-color: var(--bg-widget);
border-top: 1px solid var(--border-color);
}
.hl-pill {
background-color: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 0.5rem;
}
.hl-muted {
background-color: var(--bg-muted);
}
.hl-border {
border-color: var(--border-color);
}
.hl-btn-primary {
background-color: var(--primary);
color: var(--primary-fg);
font-weight: 500;
border-radius: 0.5rem;
transition: opacity 0.15s ease, transform 0.1s ease;
}
.hl-btn-primary:hover {
opacity: 0.9;
}
.hl-btn-primary:active {
transform: scale(0.99);
}
.hl-btn-outline {
background-color: var(--bg-card);
color: var(--text-main);
border: 1px solid var(--border-color);
border-radius: 0.5rem;
transition: background-color 0.15s ease, border-color 0.15s ease;
}
.hl-btn-outline:hover {
background-color: var(--bg-muted);
border-color: var(--border-hover);
}
.hl-bar-fill {
background-color: var(--accent);
transition: width 0.5s cubic-bezier(0.22, 1, 0.36, 1);
}
.hl-bar-fill-green {
background-color: var(--success);
transition: width 0.5s cubic-bezier(0.22, 1, 0.36, 1);
}
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
+10
View File
@@ -0,0 +1,10 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { App } from './App';
import './index.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
@@ -0,0 +1,220 @@
import { create } from 'zustand';
import type { RouteModeId, TelemetryData, ThemeId } from '../types/telemetry';
import type { LayoutPresetId } from '../design-system/tokens';
interface PortalState {
theme: ThemeId;
resolvedTheme: 'light' | 'dark';
routeMode: RouteModeId;
layoutPreset: LayoutPresetId;
viewMode: 'dashboard' | 'agents' | 'storybook';
showAgyModal: boolean;
showWechatModal: boolean;
showCmdPalette: boolean;
toast: string;
actionLoading: boolean;
telemetry: TelemetryData;
setTheme: (theme: ThemeId) => void;
toggleTheme: () => void;
setRouteMode: (mode: RouteModeId) => void;
setLayoutPreset: (preset: LayoutPresetId) => void;
setViewMode: (view: 'dashboard' | 'agents' | 'storybook') => void;
setShowAgyModal: (open: boolean) => void;
setShowWechatModal: (open: boolean) => void;
setShowCmdPalette: (open: boolean) => void;
showToast: (msg: string) => void;
fetchTelemetry: () => Promise<void>;
triggerAction: (action: string, startMsg: string) => Promise<void>;
copyCmd: (cmd: string, label: string) => Promise<void>;
getEffectiveMode: () => Exclude<RouteModeId, 'auto'>;
}
const getSystemDark = () =>
typeof window !== 'undefined' &&
window.matchMedia &&
window.matchMedia('(prefers-color-scheme: dark)').matches;
const applyDomTheme = (mode: ThemeId): 'light' | 'dark' => {
const isDark = mode === 'system' ? getSystemDark() : mode === 'dark';
const domTheme = isDark ? 'dark' : 'light';
if (typeof document !== 'undefined') {
document.documentElement.setAttribute('data-theme', domTheme);
}
return domTheme;
};
const storedTheme = localStorage.getItem('hl_theme_v2') as ThemeId | null;
const initialTheme: ThemeId =
storedTheme === 'light' || storedTheme === 'dark' || storedTheme === 'system'
? storedTheme
: 'system';
const initialResolved = applyDomTheme(initialTheme);
const initialRouteMode = (localStorage.getItem('hl_route_mode') as RouteModeId) || 'auto';
const initialLayout =
(localStorage.getItem('hl_layout_preset') as LayoutPresetId) || 'homepage-3col';
const initialView =
typeof window !== 'undefined' && window.location.pathname.startsWith('/storybook')
? 'storybook'
: typeof window !== 'undefined' && window.location.pathname.startsWith('/agents')
? 'agents'
: 'dashboard';
export const usePortalStore = create<PortalState>((set, get) => ({
theme: initialTheme,
resolvedTheme: initialResolved,
routeMode: initialRouteMode,
layoutPreset: initialLayout,
viewMode: initialView,
showAgyModal: false,
showWechatModal: false,
showCmdPalette: false,
toast: '',
actionLoading: false,
telemetry: {
mini: {
chip: 'Apple M6 (12核)',
ip: '192.168.31.164',
tailscale_ip: '100.108.205.40',
cpu_pct: 6.5,
load_1m: 0.85,
mem_used_gb: 11.8,
mem_total_gb: 24.0,
mem_pct: 49.2,
disk_used_gb: 59.9,
disk_total_gb: 460.4,
disk_pct: 13.0,
uptime: '运行中',
agy_tmux: true,
},
nas: {
ip: '192.168.31.185',
online: true,
mounted: true,
total_tb: 19.94,
used_tb: 3.36,
free_tb: 16.58,
pct: 16.8,
latest_backup: '11-06 23:17',
},
network: {
home_wan_ip: '124.90.142.21',
ai_policy: 'Ai平台 → 狮城节点',
sg_node: '233boy-sea',
sg_egress_ip: '104.43.105.243',
sg_delay_ms: 68,
},
services: {
agy: true,
vaultwarden: true,
nas: true,
clash: true,
router: true,
uptime: true,
clawbot: false,
},
},
setTheme: (theme) => {
localStorage.setItem('hl_theme_v2', theme);
const resolvedTheme = applyDomTheme(theme);
set({ theme, resolvedTheme });
},
toggleTheme: () => {
const { resolvedTheme, setTheme } = get();
setTheme(resolvedTheme === 'dark' ? 'light' : 'dark');
},
setRouteMode: (routeMode) => {
localStorage.setItem('hl_route_mode', routeMode);
set({ routeMode });
},
setLayoutPreset: (layoutPreset) => {
localStorage.setItem('hl_layout_preset', layoutPreset);
set({ layoutPreset });
},
setViewMode: (viewMode) => {
if (typeof window !== 'undefined') {
const nextUrl =
viewMode === 'storybook' ? '/storybook' : viewMode === 'agents' ? '/agents' : '/';
window.history.pushState({}, '', nextUrl);
}
set({ viewMode });
},
setShowAgyModal: (showAgyModal) => set({ showAgyModal }),
setShowWechatModal: (showWechatModal) => set({ showWechatModal }),
setShowCmdPalette: (showCmdPalette) => set({ showCmdPalette }),
showToast: (msg) => {
set({ toast: msg });
setTimeout(() => {
if (get().toast === msg) {
set({ toast: '' });
}
}, 3000);
},
fetchTelemetry: async () => {
try {
const res = await fetch('/api/telemetry');
if (res.ok) {
const data = await res.json();
set({ telemetry: data });
}
} catch {
// ignore transient network errors
}
},
triggerAction: async (action, startMsg) => {
const { showToast, fetchTelemetry } = get();
set({ actionLoading: true });
showToast(startMsg);
try {
const res = await fetch(`/api/action/${action}`, { method: 'POST' });
const data = await res.json();
showToast(data.message || '操作已完成');
await fetchTelemetry();
} catch {
showToast('请求失败,请稍后重试');
} finally {
set({ actionLoading: false });
}
},
copyCmd: async (cmd, label) => {
const { showToast } = get();
try {
await navigator.clipboard.writeText(cmd);
showToast(`已复制「${label}」: ${cmd}`);
} catch {
showToast(cmd);
}
},
getEffectiveMode: () => {
const { routeMode } = get();
if (routeMode !== 'auto') return routeMode;
const host = window.location.hostname;
if (host.startsWith('192.168.') || host.startsWith('100.') || host === 'localhost') {
return 'lan';
}
if (host.includes('.home.961504.xyz') || window.location.port === '10443') {
return 'home10443';
}
return 'edge443';
},
}));
// Listen to device OS dark/light mode changes automatically
if (typeof window !== 'undefined' && window.matchMedia) {
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
const state = usePortalStore.getState();
if (state.theme === 'system') {
state.setTheme('system');
}
});
}
+105
View File
@@ -0,0 +1,105 @@
export type ThemeId = 'system' | 'light' | 'dark';
export type RouteModeId = 'auto' | 'edge443' | 'home10443' | 'lan';
export interface ClawbotPublicStatus {
connected: boolean;
bot_id?: string;
user_id?: string;
base_url?: string;
connected_at?: string;
msg_count?: number;
last_inbound_text?: string;
last_reply_text?: string;
login?: {
active: boolean;
qrcode_url: string;
status: string;
message: string;
};
}
export interface TelemetryData {
timestamp?: string;
mini: {
chip: string;
ip: string;
tailscale_ip: string;
cpu_pct: number;
load_1m: number;
cores?: number;
mem_used_gb: number;
mem_total_gb: number;
mem_pct: number;
disk_used_gb: number;
disk_total_gb: number;
disk_pct: number;
uptime: string;
containers?: Array<{ name: string; status: string; image: string }>;
agy_tmux: boolean;
};
nas: {
name?: string;
ip: string;
online: boolean;
latency_ms?: number;
mounted: boolean;
total_tb: number;
used_tb: number;
free_tb: number;
pct: number;
latest_backup: string;
backup_count?: number;
};
network: {
home_wan_ip: string;
router_ip?: string;
router_online?: boolean;
router_ms?: number;
ai_policy: string;
sg_node: string;
sg_egress_ip: string;
sg_delay_ms: number;
azure_tailscale_ip?: string;
azure_online?: boolean;
azure_ms?: number;
};
services: {
agy: boolean;
codeg?: boolean;
gitea?: boolean;
vaultwarden: boolean;
nas: boolean;
clash: boolean;
router: boolean;
uptime: boolean;
clawbot?: boolean;
};
clawbot?: ClawbotPublicStatus;
}
export interface StatCell {
label: string;
value: string;
highlight?: boolean;
}
export interface ServiceCardItem {
id: string;
title: string;
subtitle: string;
iconUrl: string;
online: boolean;
actionType?: 'drawer' | 'backup' | 'clawbot';
desc: string;
edgeUrl: string;
homeUrl: string;
lanUrl: string;
stats: StatCell[];
}
export interface ServiceGroup {
title: string;
subtitle: string;
showRouteHint?: boolean;
items: ServiceCardItem[];
}
+17
View File
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true
},
"include": ["src"]
}
+16
View File
@@ -0,0 +1,16 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';
export default defineConfig({
plugins: [react(), tailwindcss()],
build: {
outDir: '../dist',
emptyOutDir: true,
},
server: {
proxy: {
'/api': 'http://192.168.31.164:3002',
},
},
});