feat(local-agent): Phase 0.5 visionA Agent — Wails 桌面 + tunnel client + 配對 UI

從 local-tool 複製出獨立的「visionA Agent」桌面應用(A3 純橋樑:
tunnel client + 配對 UI + 設定,不開 HTTP port、不做本機裝置/推論 UI)。
Bundle ID 與 local-tool 不同(com.innovedus.visiona-agent vs visiona-local),
雙 app 可共存。fork 後不主動 sync,需要時手動 cherry-pick。

Backend / Wails Go(AB1-AB13):
- internal/tunnel:6 狀態機(Idle/Connecting/Connected/Reconnecting/Failed/Stopped)
  + Pair/Unpair/Reconnect/Disconnect binding + ClientHooks event
- internal/auth:encrypted file token store(AES-GCM + scrypt + machineID
  fallback salt + 13 tests)
- internal/config:YAML validation + atomic write + 11 tests
- internal/log:ring buffer + ExportLog 升級 zip
- visionA-backend /api/pairing/exchange:SessionTokenStore + 17 new tests
- 三平台 build 驗證(macOS DMG 160 MB / Windows EXE / Linux AppImage)
- end-to-end 5 milestone 全綠(pairing → tunnel → forward → reuse 防護
  → tunnel drop failover)

Frontend / Next.js(AF1-AF7,沿用 visionA-frontend 基礎):
- AppShell + Header + TabNav(StatusView / PairView / SettingsView 三 tab)
- ConnectionStatusBadge 5 種狀態
- TokenInput regex 驗證 + 7 種錯誤 + 0.5s auto-switch 到狀態頁
- 設定頁 4 區塊(含重新配對 AlertDialog)
- agent-api.ts 封裝 Wails bindings(mock/real 雙實作)+ 90 tests

Phase 0.7 review-driven fix(Round 2):
- A1 Session fixation 防護(RotateSessionID)
- A3 mock pairing 預設改 false(必須明確 opt-in)+ startup log
- A4 Pair 失敗後 state 清理矩陣(exchange/Save/Start fail 各自終態)
- A5 Pair/Unpair/Reconnect lifecycleMu + 50 goroutine race test
- F1 重新配對次按鈕 / F2 PairView Esc cancel / F3 Wails BrowserOpenURL
  / F4 Settings draft 持久 + 未儲存 badge

驗證:agent backend go test -race -count=3 ./... 4 packages 全綠 /
agent frontend pnpm test 119 tests 全綠

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
jim800121chen 2026-05-01 11:22:01 +08:00
parent 99dea42239
commit 3f0175f1a9
274 changed files with 40135 additions and 0 deletions

258
local-agent/.github/workflows/build.yml vendored Normal file
View File

@ -0,0 +1,258 @@
name: Build
on:
push:
branches: [main]
tags: ['v*']
pull_request:
branches: [main]
workflow_dispatch:
env:
GO_VERSION: '1.26'
NODE_VERSION: '22'
PNPM_VERSION: '9'
jobs:
# ────────────────────────────────────────────────────────────────
# macOS (Intel / x86_64)
# 使用 macos-13目前最後的 Intel Mac runner避免 macos-latest 被
# 切到 Apple Silicon 後產出 ARM binary。
# ────────────────────────────────────────────────────────────────
build-macos:
name: Build macOS (x86_64)
runs-on: macos-13
timeout-minutes: 60
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: ${{ env.GO_VERSION }}
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: ${{ env.PNPM_VERSION }}
- name: Install Wails CLI
run: |
go install github.com/wailsapp/wails/v2/cmd/wails@latest
echo "$HOME/go/bin" >> "$GITHUB_PATH"
- name: Cache vendor/
uses: actions/cache@v4
with:
path: vendor/
key: vendor-darwin-${{ hashFiles('Makefile') }}-${{ hashFiles('visiona-agent/wheels/macos/**') }}
restore-keys: |
vendor-darwin-${{ hashFiles('Makefile') }}-
vendor-darwin-
- name: vendor-sync (macOS deps)
run: make vendor-sync
- name: Build .dmg
run: make dmg
- name: Verify .dmg
run: |
ls -lh dist/visiona-agent.dmg
file dist/visiona-agent.dmg
hdiutil imageinfo dist/visiona-agent.dmg | head -20
- name: Upload .dmg artifact
uses: actions/upload-artifact@v4
with:
name: visiona-agent-macos-x64
path: dist/visiona-agent.dmg
retention-days: 30
if-no-files-found: error
# ────────────────────────────────────────────────────────────────
# Windows (x86_64)
# 使用 windows-2022。vendor-sync 的 macOS/Linux 部分會失敗,
# 因此只跑 *-windows 的 vendor targets。
# ────────────────────────────────────────────────────────────────
build-windows:
name: Build Windows (x86_64)
runs-on: windows-2022
timeout-minutes: 60
defaults:
run:
shell: bash
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: ${{ env.GO_VERSION }}
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: ${{ env.PNPM_VERSION }}
- name: Install Wails CLI
run: |
go install github.com/wailsapp/wails/v2/cmd/wails@latest
echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH"
- name: Install Inno Setup
run: choco install innosetup -y --no-progress
shell: pwsh
- name: Add Inno Setup to PATH
run: echo "/c/Program Files (x86)/Inno Setup 6" >> "$GITHUB_PATH"
- name: Cache vendor/
uses: actions/cache@v4
with:
path: vendor/
key: vendor-windows-${{ hashFiles('Makefile') }}-${{ hashFiles('visiona-agent/wheels/windows/**') }}
restore-keys: |
vendor-windows-${{ hashFiles('Makefile') }}-
vendor-windows-
- name: vendor-sync (Windows-only deps)
run: |
make vendor-python-windows \
vendor-wheels-windows \
vendor-ffmpeg-windows
- name: Build server.exe
env:
GOOS: windows
GOARCH: amd64
run: |
mkdir -p payload/windows/bin
cd server
go build -o ../payload/windows/bin/visiona-agent-server.exe .
ls -lh ../payload/windows/bin/visiona-agent-server.exe
- name: Build frontend (for go:embed)
run: make build-embed
- name: Build .exe installer
run: make exe
- name: Verify .exe
run: |
ls -lh dist/visiona-agent-*.exe || ls -lh dist/*.exe
file dist/visiona-agent-*.exe 2>/dev/null || true
- name: Upload .exe artifact
uses: actions/upload-artifact@v4
with:
name: visiona-agent-windows-x64
path: dist/visiona-agent-*.exe
retention-days: 30
if-no-files-found: error
# ────────────────────────────────────────────────────────────────
# Linux (x86_64)
# 使用 ubuntu-22.04glibc 2.35,相容性比 24.04 更好)。
# ────────────────────────────────────────────────────────────────
build-linux:
name: Build Linux (x86_64)
runs-on: ubuntu-22.04
timeout-minutes: 60
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: ${{ env.GO_VERSION }}
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: ${{ env.PNPM_VERSION }}
- name: Install system deps
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
build-essential \
pkg-config \
libgtk-3-dev \
libwebkit2gtk-4.1-dev \
libusb-1.0-0-dev \
fuse \
libfuse2 \
desktop-file-utils
- name: Install Wails CLI
run: |
go install github.com/wailsapp/wails/v2/cmd/wails@latest
echo "$HOME/go/bin" >> "$GITHUB_PATH"
- name: Install appimagetool
run: |
curl -fL -o /tmp/appimagetool \
"https://github.com/AppImage/AppImageKit/releases/latest/download/appimagetool-x86_64.AppImage"
chmod +x /tmp/appimagetool
sudo mv /tmp/appimagetool /usr/local/bin/appimagetool
appimagetool --version || true
- name: Cache vendor/
uses: actions/cache@v4
with:
path: vendor/
key: vendor-linux-${{ hashFiles('Makefile') }}-${{ hashFiles('visiona-agent/wheels/linux/**') }}
restore-keys: |
vendor-linux-${{ hashFiles('Makefile') }}-
vendor-linux-
- name: vendor-sync (Linux-only deps)
run: |
make vendor-python-linux \
vendor-wheels-linux \
vendor-ffmpeg-linux
- name: Build server (Linux)
run: |
mkdir -p payload/linux/bin
cd server
GOOS=linux GOARCH=amd64 go build -o ../payload/linux/bin/visiona-agent-server .
ls -lh ../payload/linux/bin/visiona-agent-server
- name: Build frontend (for go:embed)
run: make build-embed
- name: Build AppImage
run: make appimage
- name: Verify AppImage
run: |
ls -lh dist/visiona-agent-*.AppImage
file dist/visiona-agent-*.AppImage
- name: Upload AppImage artifact
uses: actions/upload-artifact@v4
with:
name: visiona-agent-linux-x64
path: dist/visiona-agent-*.AppImage
retention-days: 30
if-no-files-found: error

View File

@ -0,0 +1,61 @@
# TODO: enable when first release ready
#
# 此 workflow 會在推送 v* tag 時自動建立 GitHub Release
# 並把 build.yml 產出的三平台 artifact 上傳。
#
# 啟用方式:
# 1. 確認 build.yml 三個 job 都能穩定成功
# 2. 把 `on.push.tags` 區塊取消註解
# 3. 推送 tag`git tag v0.1.0 && git push origin v0.1.0`
name: Release
on:
workflow_dispatch:
# push:
# tags: ['v*']
jobs:
release:
name: Create GitHub Release
runs-on: ubuntu-22.04
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v4
# 觸發並等待 build.yml 完成(或改用 workflow_run 觸發)
# 這裡先用最簡單的做法:假設 build.yml 已經跑過,直接抓 artifact
- name: Download macOS artifact
uses: actions/download-artifact@v4
with:
name: visiona-agent-macos-x64
path: release/
- name: Download Windows artifact
uses: actions/download-artifact@v4
with:
name: visiona-agent-windows-x64
path: release/
- name: Download Linux artifact
uses: actions/download-artifact@v4
with:
name: visiona-agent-linux-x64
path: release/
- name: List release assets
run: ls -lh release/
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ github.ref_name }}
name: visionA Agent ${{ github.ref_name }}
draft: true
generate_release_notes: true
files: |
release/visiona-agent.dmg
release/visiona-agent-*.exe
release/visiona-agent-*.AppImage

80
local-agent/.gitignore vendored Normal file
View File

@ -0,0 +1,80 @@
# Autoflow Agent由 autoflow-agent init 自動產生)
.claude/
.autoflow/CLAUDE.md.backup.*
.autoflow/.backups/
# ── 第三方依賴(由 make vendor-sync 下載,不進 git第三輪決策 Q-D=D2 ──
/vendor/**
!/vendor/.gitkeep
!/vendor/README.md
# R5-6bmacOS LGPL ffmpeg binary 進 git沒有現成 LGPL binary 來源,自 build 成本高,
# commit 後開發者 clone 即可用,不必每次重 build ~15 分鐘)
!/vendor/ffmpeg/
!/vendor/ffmpeg/macos/
!/vendor/ffmpeg/macos/**
# ── 建置產出 ──
/dist/
/visiona-agent/build/bin/
/visiona-agent/build/darwin/Resources/
# M8-3頂層 build/ffmpeg-macos/ 是 ffmpeg self-build 中間產物source + obj + install~180MB不進 git
/build/
/visiona-agent/payload/
!/visiona-agent/payload/.gitkeep
# M1-11頂層 payload/ 是 build 產物,不進 git除了 .gitkeep
/payload/**
!/payload/.gitkeep
/payload/*.tar.gz
/payload/*.zip
# ── Go ──
*.exe
*.exe~
*.dll
*.so
*.dylib
*.test
*.out
go.work.sum
# ── Node / Next.js / pnpm ──
node_modules/
.pnpm-store/
.next/
out/
*.tsbuildinfo
.npm
.pnpm-debug.log*
# ── Pythondev 時可能出現的 venv / cache ──
__pycache__/
*.py[cod]
*$py.class
.venv/
venv/
*.egg-info/
# ── Editor / IDE ──
.vscode/
.idea/
*.swp
*.swo
# ── OS ──
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
desktop.ini
# ── Env / Secrets ──
.env
.env.local
.env.*.local
*.pem
*.key

706
local-agent/Makefile Normal file
View File

@ -0,0 +1,706 @@
# visionA Agent — Makefile骨架M1-1
#
# 這份 Makefile 目前所有 targets 都只是 placeholder實際內容會在後續任務逐步填入
# - M1-2 複製 server core
# - M1-4 複製 frontend
# - M1-8 build-server / build-frontend 實作
# - M1-9 visiona-agent (Wails) shell
# - M1-11 payload-macos
# - M1-12 installer-macos (dmg)
# - M2+ Windows / Linux / CI
#
# 詳見 .autoflow/04-architecture/build-pipeline.md
SHELL := /bin/bash
VERSION ?= v0.1.0-dev
BUILD_TIME := $(shell date -u +%Y-%m-%dT%H:%M:%SZ)
OS := $(shell uname -s | tr A-Z a-z)
DIST := dist
PAYLOAD := visiona-agent/payload
.PHONY: help \
vendor-sync vendor-python vendor-wheels vendor-ffmpeg vendor-ffmpeg-macos-build \
vendor-python-windows vendor-wheels-windows vendor-ffmpeg-windows \
vendor-python-linux vendor-wheels-linux vendor-ffmpeg-linux \
server build-server \
frontend build-frontend build-embed \
payload payload-macos payload-windows payload-linux \
stage-macos stage-windows \
wails-macos wails-windows wails-linux wails-sync-frontend \
dmg exe exe-only _run-iscc appimage \
dev test lint fmt \
clean clean-all clean-build-exe clean-build-dmg clean-build-appimage
# ── 幫助 ───────────────────────────────────────────────────────────
help: ## 列出所有 make targets
@echo "visionA Agent — available targets (M1-1 skeleton)"
@echo ""
@echo " 依賴同步:"
@echo " vendor-sync 下載 python-build-standalone / wheels / ffmpeg → vendor/"
@echo ""
@echo " Build單元"
@echo " server build Go server binary (→ dist/visiona-agent-server)"
@echo " frontend pnpm build Next.js 靜態產物 (→ frontend/out)"
@echo ""
@echo " Payload 準備:"
@echo " payload-macos stage macOS payload → visiona-agent/payload/"
@echo " payload-windows stage Windows payload"
@echo " payload-linux stage Linux payload"
@echo ""
@echo " Wails 應用 build"
@echo " wails-macos wails build darwin/amd64"
@echo " wails-windows wails build windows/amd64"
@echo " wails-linux wails build linux/amd64"
@echo ""
@echo " 安裝檔打包:"
@echo " dmg macOS .dmgdmgbuild"
@echo " exe Windows .exeInno Setup"
@echo " appimage Linux .AppImage"
@echo ""
@echo " 工具:"
@echo " clean 清除 dist/ payload/"
@echo ""
@echo "Note: 目前所有 target 都是 placeholder只印 TODO尚未實作。"
# ── 依賴同步 ───────────────────────────────────────────────────────
PYTHON_VERSION := 3.12.9
PBS_RELEASE := 20250317
PBS_URL_DARWIN := https://github.com/astral-sh/python-build-standalone/releases/download/$(PBS_RELEASE)/cpython-$(PYTHON_VERSION)+$(PBS_RELEASE)-x86_64-apple-darwin-install_only.tar.gz
# ── ffmpegLGPL v3方案 B 混合)──
# v2 TDD §2.2macOS 自 build decoder-only~20 MBcommit 到 vendor/ffmpeg/macos/
# Windows / Linux 用 BtbN 的 n7.1 LGPL build。
FFMPEG_VERSION := n7.1
FFMPEG_SRC_URL := https://github.com/FFmpeg/FFmpeg/archive/refs/tags/$(FFMPEG_VERSION).tar.gz
# sha256 於第一次 build 時由 `make vendor-ffmpeg-macos-build` 計算後填入 vendor/ffmpeg/macos/BUILD.md
# 之後每次 build 使用此值做 integrity check下方 Makefile 變數亦同步更新)。
FFMPEG_SRC_SHA256 := 7ddad2d992bd250a6c56053c26029f7e728bebf0f37f80cf3f8a0e6ec706431a
vendor-sync: vendor-python vendor-wheels vendor-ffmpeg ## 下載所有第三方依賴到 vendor/(不進 git第三輪決策 Q-D=D2
@echo "==> vendor-sync 完成"
vendor-python: ## 下載 python-build-standalone tarball → vendor/python/darwin/
@mkdir -p vendor/python/darwin
@if [ ! -f vendor/python/darwin/python.tar.gz ]; then \
echo "==> 下載 python-build-standalone $(PYTHON_VERSION)+$(PBS_RELEASE) (macOS x86_64 install_only)..."; \
curl -fL -o vendor/python/darwin/python.tar.gz "$(PBS_URL_DARWIN)"; \
echo "==> tarball 大小:$$(du -sh vendor/python/darwin/python.tar.gz | cut -f1)"; \
else \
echo "==> python tarball 已存在,跳過下載 ($$(du -sh vendor/python/darwin/python.tar.gz | cut -f1))"; \
fi
vendor-wheels: ## 同步 wheels → vendor/wheels/darwin/(內部 wheel 從 visiona-agent/wheels 複製,公開相依用 pip download
@mkdir -p vendor/wheels/darwin
@echo "==> 同步內部 wheelsKneronPLUS 等)..."
@if [ -d visiona-agent/wheels/macos ]; then \
cp visiona-agent/wheels/macos/*.whl vendor/wheels/darwin/ 2>/dev/null || true; \
fi
@echo "==> 從 PyPI 下載公開相依 wheels (cp312, macosx_x86_64)..."
@pip3 download \
--only-binary=:all: \
--platform macosx_10_9_x86_64 \
--platform macosx_11_0_x86_64 \
--platform macosx_12_0_x86_64 \
--python-version 3.12 \
--implementation cp \
--dest vendor/wheels/darwin \
numpy opencv-python-headless pyusb requests || echo "WARN: pip download 部分失敗(詳見上方訊息)"
@echo "==> wheels 總覽:"
@ls -1 vendor/wheels/darwin/*.whl 2>/dev/null | wc -l | xargs -I{} echo " 共 {} 個 wheel"
@du -sh vendor/wheels/darwin
vendor-ffmpeg: ## macOSLGPL v3 ffmpeg + ffprobe 已 commit 到 vendor/ffmpeg/macos/,本 target 只驗證存在 + LGPL 合規
@if [ ! -f vendor/ffmpeg/macos/ffmpeg ]; then \
echo "❌ vendor/ffmpeg/macos/ffmpeg 不存在。"; \
echo " 第一次 build 請執行make vendor-ffmpeg-macos-build"; \
echo " (只需要在升級 ffmpeg 版本時跑一次;平常 clone repo 後 binary 已在 git 內)"; \
exit 1; \
fi
@if [ ! -f vendor/ffmpeg/macos/ffprobe ]; then \
echo "❌ vendor/ffmpeg/macos/ffprobe 不存在。"; \
echo " 請執行make vendor-ffmpeg-macos-build"; \
exit 1; \
fi
@echo "==> vendor/ffmpeg/macos/ffmpeg 存在:$$(du -h vendor/ffmpeg/macos/ffmpeg | cut -f1)"
@echo "==> vendor/ffmpeg/macos/ffprobe 存在:$$(du -h vendor/ffmpeg/macos/ffprobe | cut -f1)"
@if vendor/ffmpeg/macos/ffmpeg -version 2>&1 | grep -qE -- '--enable-gpl|libx264|libx265'; then \
echo "❌ LGPL 驗證失敗binary 含 --enable-gpl / libx264 / libx265"; \
exit 1; \
fi
@echo "==> LGPL 驗證通過(無 --enable-gpl / libx264 / libx265"
# 只有升級 ffmpeg 版本時才跑此 targetbinary 產出後 commit 到 gitv2 TDD R5-6b
# 需要的系統依賴macOS
# brew install pkg-config nasm # 或 yasm
vendor-ffmpeg-macos-build: ## macOS從源碼 build LGPL v3 decoder-only ffmpeg + ffprobe升級時才跑~15 分鐘)
@if [ "$$(uname -s)" != "Darwin" ]; then \
echo "❌ vendor-ffmpeg-macos-build 只能在 macOS 上跑"; exit 1; \
fi
@command -v pkg-config >/dev/null 2>&1 || { echo "❌ 需要 pkg-configbrew install pkg-config"; exit 1; }
@command -v nasm >/dev/null 2>&1 || command -v yasm >/dev/null 2>&1 || { echo "❌ 需要 nasm 或 yasmbrew install nasm"; exit 1; }
@mkdir -p vendor/ffmpeg/macos build/ffmpeg-macos
@echo "==> 下載 ffmpeg source $(FFMPEG_VERSION)..."
curl -fL -o build/ffmpeg-macos/source.tar.gz "$(FFMPEG_SRC_URL)"
@echo "==> 驗證 source tarball sha256..."
@echo "$(FFMPEG_SRC_SHA256) build/ffmpeg-macos/source.tar.gz" | shasum -a 256 -c || { \
echo "❌ sha256 不符,請更新 Makefile 中的 FFMPEG_SRC_SHA256 或檢查來源"; \
echo " 實際 sha256$$(shasum -a 256 build/ffmpeg-macos/source.tar.gz | awk '{print $$1}')"; \
exit 1; }
@rm -rf build/ffmpeg-macos/src build/ffmpeg-macos/install
@mkdir -p build/ffmpeg-macos/src
tar xzf build/ffmpeg-macos/source.tar.gz -C build/ffmpeg-macos/src --strip-components=1
@echo "==> configuredecoder-only LGPL v3..."
cd build/ffmpeg-macos/src && ./configure \
--prefix="$$(pwd)/../install" \
--enable-version3 \
--disable-debug \
--disable-doc \
--disable-ffplay \
--disable-network \
--disable-autodetect \
--disable-shared \
--enable-static \
--disable-everything \
--enable-small \
--enable-protocol=file,pipe \
--enable-demuxer=mov,avi,mpegps,mpegts,matroska,image2 \
--enable-decoder=h264,hevc,mpeg1video,mpeg2video,mpeg4,mjpeg,prores,vp8,vp9,aac,mp2,mp3,pcm_s16le,pcm_s16be \
--enable-parser=h264,hevc,mpeg4video,mpegaudio,aac \
--enable-filter=scale,format,fps,null,anull \
--enable-muxer=image2pipe,image2,null \
--enable-encoder=mjpeg \
--enable-swscale \
--enable-swresample \
--extra-cflags="-arch x86_64 -mmacosx-version-min=10.15" \
--extra-ldflags="-arch x86_64 -mmacosx-version-min=10.15 -Wl,-search_paths_first" \
--arch=x86_64 \
--target-os=darwin \
--cc="clang -arch x86_64"
cd build/ffmpeg-macos/src && make -j$$(sysctl -n hw.ncpu)
cd build/ffmpeg-macos/src && make install
@echo "==> 複製 ffmpeg + ffprobe 到 vendor/ffmpeg/macos/..."
cp build/ffmpeg-macos/install/bin/ffmpeg vendor/ffmpeg/macos/ffmpeg
cp build/ffmpeg-macos/install/bin/ffprobe vendor/ffmpeg/macos/ffprobe
@strip -S -x vendor/ffmpeg/macos/ffmpeg
@strip -S -x vendor/ffmpeg/macos/ffprobe
@chmod +x vendor/ffmpeg/macos/ffmpeg vendor/ffmpeg/macos/ffprobe
@echo "==> ad-hoc 簽章..."
codesign --force --sign - vendor/ffmpeg/macos/ffmpeg
codesign --force --sign - vendor/ffmpeg/macos/ffprobe
@echo "==> 驗證授權configuration line 不含 --enable-gpl / libx264 / libx265..."
@if vendor/ffmpeg/macos/ffmpeg -version 2>&1 | grep -E -- '--enable-gpl|libx264|libx265'; then \
echo "❌ LGPL 驗證失敗build 不該出現 gpl / x264 / x265"; exit 1; \
fi
@echo "==> 複製 COPYING.LGPLv3 到 vendor/ffmpeg/macos/..."
cp build/ffmpeg-macos/src/COPYING.LGPLv3 vendor/ffmpeg/macos/COPYING.LGPLv3
@echo "==> ffmpeg 大小:$$(du -h vendor/ffmpeg/macos/ffmpeg | cut -f1)"
@echo "==> ffprobe 大小:$$(du -h vendor/ffmpeg/macos/ffprobe | cut -f1)"
@echo ""
@echo "✅ macOS LGPL ffmpeg build 完成。接下來請:"
@echo " 1) 更新 vendor/ffmpeg/macos/BUILD.md 的 Binary sha256 區塊"
@echo " 2) git add vendor/ffmpeg/macos/{ffmpeg,ffprobe,COPYING.LGPLv3,BUILD.md}"
# ── Build單元 ──────────────────────────────────────────────────
server: build-server ## alias for build-server
build-server: build-embed ## build Go server binary → dist/visiona-agent-server會先 build frontend + embed
@mkdir -p $(DIST)
cd server && go build -o ../$(DIST)/visiona-agent-server .
@echo "built: $(DIST)/visiona-agent-server"
build-server-windows: build-embed ## 交叉/原生 build Windows server → payload/windows/bin/visiona-agent-server.exe
@mkdir -p payload/windows/bin
cd server && GOOS=windows GOARCH=amd64 go build -o ../payload/windows/bin/visiona-agent-server.exe .
@echo "built: payload/windows/bin/visiona-agent-server.exe"
build-server-linux: build-embed ## 交叉/原生 build Linux server → payload/linux/bin/visiona-agent-server
@mkdir -p payload/linux/bin
cd server && GOOS=linux GOARCH=amd64 go build -o ../payload/linux/bin/visiona-agent-server .
@echo "built: payload/linux/bin/visiona-agent-server"
frontend: build-frontend ## alias for build-frontend
build-frontend: ## pnpm build → frontend/out/
@echo "==> pnpm build frontend..."
cd frontend && pnpm install --frozen-lockfile && pnpm build
@echo "==> frontend/out 完成:"
@du -sh frontend/out
build-embed: build-frontend ## 同步 frontend/out → server/web/out 供 go:embed
@echo "==> 同步 frontend/out → server/web/out..."
rm -rf server/web/out
mkdir -p server/web/out
cp -R frontend/out/. server/web/out/
@du -sh server/web/out
# ── Payload 準備 ───────────────────────────────────────────────────
payload: payload-$(OS) ## 依當前 OS 準備 payload
payload-macos: build-server vendor-python vendor-wheels vendor-ffmpeg ## 準備 macOS payload → payload/darwin/(含 python runtime + wheels + ffmpeg
@echo "==> 建立 macOS payload (binary + models + scripts + python + wheels + ffmpeg + ffprobe)..."
rm -rf payload/darwin
mkdir -p payload/darwin/bin payload/darwin/data payload/darwin/scripts payload/darwin/python payload/darwin/wheels
cp dist/visiona-agent-server payload/darwin/bin/
cp vendor/ffmpeg/macos/ffmpeg payload/darwin/bin/
cp vendor/ffmpeg/macos/ffprobe payload/darwin/bin/
cp vendor/ffmpeg/macos/COPYING.LGPLv3 payload/darwin/bin/ffmpeg-COPYING.LGPLv3
chmod +x payload/darwin/bin/ffmpeg payload/darwin/bin/ffprobe
cp -R server/data/* payload/darwin/data/
cp -R server/scripts/* payload/darwin/scripts/
cp vendor/python/darwin/python.tar.gz payload/darwin/python/
@cp vendor/wheels/darwin/*.whl payload/darwin/wheels/ 2>/dev/null || true
@echo "==> macOS payload 完成:"
@du -sh payload/darwin
@echo ""
@echo "payload/darwin 結構:"
@find payload/darwin -maxdepth 2 -type d | sed 's|^| |'
@echo ""
@echo "payload/darwin/python"
@ls -lh payload/darwin/python
@echo "payload/darwin/wheels"
@ls -1 payload/darwin/wheels | head -20
stage-macos: payload-macos ## 將 payload/darwin/ 放到 Wails build/darwin/Resources/
@echo "==> 放置 payload 到 Wails build/darwin/Resources..."
rm -rf visiona-agent/build/darwin/Resources
mkdir -p visiona-agent/build/darwin/Resources
cp -R payload/darwin/. visiona-agent/build/darwin/Resources/
@echo "==> stage 完成:"
@du -sh visiona-agent/build/darwin/Resources
# ── M4Windows vendor + payload + wails + exe ─────────────────────
# 注意wails-windows 與 exe 必須在 Windows runner 上跑;在 macOS 上會明確 fail。
# payload-windows / vendor-*-windows 是 curl 下載跨平台可跑server.exe 步驟除外)。
PBS_URL_WINDOWS := https://github.com/astral-sh/python-build-standalone/releases/download/$(PBS_RELEASE)/cpython-$(PYTHON_VERSION)+$(PBS_RELEASE)-x86_64-pc-windows-msvc-install_only.tar.gz
# LGPL v3 buildBtbN n7.1 穩定分支,含 LGPL-safe extra libs— v2 TDD §3
FFMPEG_URL_WINDOWS := https://github.com/BtbN/FFmpeg-Builds/releases/latest/download/ffmpeg-n7.1-latest-win64-lgpl-7.1.zip
vendor-python-windows: ## 下載 python-build-standalone Windows x86_64 → vendor/python/windows/
@mkdir -p vendor/python/windows
@if [ ! -f vendor/python/windows/python.tar.gz ]; then \
echo "==> 下載 python-build-standalone $(PYTHON_VERSION)+$(PBS_RELEASE) (Windows x86_64 install_only)..."; \
curl -fL -o vendor/python/windows/python.tar.gz "$(PBS_URL_WINDOWS)"; \
echo "==> tarball 大小:$$(du -sh vendor/python/windows/python.tar.gz | cut -f1)"; \
else \
echo "==> python (Windows) tarball 已存在,跳過 ($$(du -sh vendor/python/windows/python.tar.gz | cut -f1))"; \
fi
vendor-wheels-windows: ## 同步 Windows wheels → vendor/wheels/windows/
@mkdir -p vendor/wheels/windows
@echo "==> 同步內部 wheels (Windows, KneronPLUS 等)..."
@if [ -d visiona-agent/wheels/windows ]; then \
cp visiona-agent/wheels/windows/*.whl vendor/wheels/windows/ 2>/dev/null || true; \
fi
@echo "==> 從 PyPI 下載公開相依 wheels (cp312, win_amd64)..."
@PY=""; \
for candidate in "$$VISIONA_PYTHON" "py -3" python3 python; do \
[ -z "$$candidate" ] && continue; \
resolved=$$(command -v $${candidate%% *} 2>/dev/null || true); \
case "$$resolved" in *WindowsApps*) continue ;; esac; \
if $$candidate --version >/dev/null 2>&1; then PY="$$candidate"; break; fi; \
done; \
if [ -z "$$PY" ]; then \
echo "WARN: 找不到真實 pythonWindowsApps stub 不算),跳過 PyPI 下載(僅使用內部 wheels"; \
else \
echo "==> 使用 $$PY -m pip"; \
$$PY -m pip download \
--only-binary=:all: \
--platform win_amd64 \
--python-version 3.12 \
--implementation cp \
--dest vendor/wheels/windows \
numpy opencv-python-headless pyusb requests || echo "WARN: pip download 部分失敗(詳見上方訊息)"; \
fi
@echo "==> Windows wheels 總覽:"
@ls -1 vendor/wheels/windows/*.whl 2>/dev/null | wc -l | xargs -I{} echo " 共 {} 個 wheel"
@du -sh vendor/wheels/windows 2>/dev/null || true
vendor-ffmpeg-windows: ## 下載 ffmpeg Windows LGPL v3 build (n7.1) → vendor/ffmpeg/windows/
@mkdir -p vendor/ffmpeg/windows
@if [ -f vendor/ffmpeg/windows/ffmpeg.exe ] && [ -f vendor/ffmpeg/windows/ffprobe.exe ]; then \
echo "==> ffmpeg.exe + ffprobe.exe 已存在,跳過"; \
else \
echo "==> 下載 BtbN LGPL ffmpeg (Windows, n7.1)..."; \
curl -fL -o vendor/ffmpeg/windows/ffmpeg-win.zip "$(FFMPEG_URL_WINDOWS)"; \
PY=""; \
for candidate in "$$VISIONA_PYTHON" "py -3" python3 python; do \
[ -z "$$candidate" ] && continue; \
resolved=$$(command -v $${candidate%% *} 2>/dev/null || true); \
case "$$resolved" in *WindowsApps*) continue ;; esac; \
if $$candidate --version >/dev/null 2>&1; then PY="$$candidate"; break; fi; \
done; \
if [ -z "$$PY" ]; then echo "ERROR: 需要真實 python 來解壓 zipWindowsApps stub 無法使用)"; exit 1; fi; \
echo "==> 使用 $$PY 解壓 ffmpeg zip取出 ffmpeg.exe / ffprobe.exe / LICENSE.txt"; \
$$PY -c "import zipfile, os; z=zipfile.ZipFile('vendor/ffmpeg/windows/ffmpeg-win.zip'); \
wanted=['/bin/ffmpeg.exe','/bin/ffprobe.exe','/LICENSE.txt','/COPYING.LGPLv3']; \
members=[n for n in z.namelist() if any(n.endswith(w) for w in wanted)]; \
assert any(n.endswith('/bin/ffmpeg.exe') for n in members), 'ffmpeg.exe not found in zip'; \
assert any(n.endswith('/bin/ffprobe.exe') for n in members), 'ffprobe.exe not found in zip'; \
os.makedirs('vendor/ffmpeg/windows', exist_ok=True); \
[open('vendor/ffmpeg/windows/'+m.rsplit('/',1)[1],'wb').write(z.read(m)) for m in members]; \
print('extracted:', [m.rsplit('/',1)[1] for m in members])" || { echo "ERROR: python 解壓失敗"; exit 1; }; \
rm -f vendor/ffmpeg/windows/ffmpeg-win.zip; \
[ -f vendor/ffmpeg/windows/ffmpeg.exe ] || { echo "ERROR: ffmpeg.exe 沒被寫出"; exit 1; }; \
[ -f vendor/ffmpeg/windows/ffprobe.exe ] || { echo "ERROR: ffprobe.exe 沒被寫出"; exit 1; }; \
echo "==> ffmpeg.exe 大小:$$(du -h vendor/ffmpeg/windows/ffmpeg.exe | cut -f1)"; \
echo "==> ffprobe.exe 大小:$$(du -h vendor/ffmpeg/windows/ffprobe.exe | cut -f1)"; \
fi
payload-windows: build-server-windows vendor-python-windows vendor-wheels-windows vendor-ffmpeg-windows ## 準備 Windows payload → payload/windows/
@echo "==> 建立 Windows payload (binary + models + scripts + python + wheels + ffmpeg)..."
@# 注意:不 rm -rf payload/windows因為 build-server-windows 已先把 .exe 放進去
mkdir -p payload/windows/bin payload/windows/data payload/windows/scripts payload/windows/python payload/windows/wheels
@if [ ! -f payload/windows/bin/visiona-agent-server.exe ]; then \
echo "!! ERROR: payload/windows/bin/visiona-agent-server.exe 不存在build-server-windows 可能失敗 !!"; \
exit 1; \
fi
cp vendor/ffmpeg/windows/ffmpeg.exe payload/windows/bin/
cp vendor/ffmpeg/windows/ffprobe.exe payload/windows/bin/
@# LGPL 授權條款BtbN build 自帶 LICENSE.txtCOPYING.LGPLv3 不一定在壓縮檔內,失敗不致命)
@cp vendor/ffmpeg/windows/LICENSE.txt payload/windows/bin/ffmpeg-LICENSE.txt 2>/dev/null || true
@cp vendor/ffmpeg/windows/COPYING.LGPLv3 payload/windows/bin/ffmpeg-COPYING.LGPLv3 2>/dev/null || true
cp -R server/data/. payload/windows/data/
cp -R server/scripts/. payload/windows/scripts/
cp vendor/python/windows/python.tar.gz payload/windows/python/
@cp vendor/wheels/windows/*.whl payload/windows/wheels/ 2>/dev/null || true
@echo "==> Windows payload 完成:"
@du -sh payload/windows
@echo ""
@echo "payload/windows 結構:"
@find payload/windows -maxdepth 2 -type d | sed 's|^| |'
stage-windows: payload-windows ## 將 payload/windows/ 放到 Wails build/windows/Resources/
@echo "==> 放置 payload 到 Wails build/windows/Resources..."
rm -rf visiona-agent/build/windows/Resources
mkdir -p visiona-agent/build/windows/Resources
cp -R payload/windows/. visiona-agent/build/windows/Resources/
@echo "==> stage 完成:"
@du -sh visiona-agent/build/windows/Resources
# ── M5Linux vendor + payload + wails + AppImage ──────────────────
# 注意wails-linux 與 appimage 必須在 Linux runner 上跑;在 macOS 上會明確 fail。
# payload-linux / vendor-*-linux 是 curl 下載跨平台可跑server 步驟除外)。
PBS_URL_LINUX := https://github.com/astral-sh/python-build-standalone/releases/download/$(PBS_RELEASE)/cpython-$(PYTHON_VERSION)+$(PBS_RELEASE)-x86_64-unknown-linux-gnu-install_only.tar.gz
# LGPL v3 buildBtbN n7.1 穩定分支)— v2 TDD §4
FFMPEG_URL_LINUX := https://github.com/BtbN/FFmpeg-Builds/releases/latest/download/ffmpeg-n7.1-latest-linux64-lgpl-7.1.tar.xz
vendor-python-linux: ## 下載 python-build-standalone Linux x86_64 → vendor/python/linux/
@mkdir -p vendor/python/linux
@if [ ! -f vendor/python/linux/python.tar.gz ]; then \
echo "==> 下載 python-build-standalone $(PYTHON_VERSION)+$(PBS_RELEASE) (Linux x86_64 install_only)..."; \
curl -fL -o vendor/python/linux/python.tar.gz "$(PBS_URL_LINUX)"; \
echo "==> tarball 大小:$$(du -sh vendor/python/linux/python.tar.gz | cut -f1)"; \
else \
echo "==> python (Linux) tarball 已存在,跳過 ($$(du -sh vendor/python/linux/python.tar.gz | cut -f1))"; \
fi
vendor-wheels-linux: ## 同步 Linux wheels → vendor/wheels/linux/
@mkdir -p vendor/wheels/linux
@echo "==> 同步內部 wheels (Linux, KneronPLUS 等)..."
@if [ -d visiona-agent/wheels/linux ]; then \
cp visiona-agent/wheels/linux/*.whl vendor/wheels/linux/ 2>/dev/null || true; \
fi
@echo "==> 從 PyPI 下載公開相依 wheels (cp312, manylinux2014_x86_64)..."
@pip3 download \
--only-binary=:all: \
--platform manylinux2014_x86_64 \
--python-version 3.12 \
--implementation cp \
--dest vendor/wheels/linux \
numpy opencv-python-headless pyusb requests || echo "WARN: pip download 部分失敗(詳見上方訊息)"
@echo "==> Linux wheels 總覽:"
@ls -1 vendor/wheels/linux/*.whl 2>/dev/null | wc -l | xargs -I{} echo " 共 {} 個 wheel"
@du -sh vendor/wheels/linux 2>/dev/null || true
vendor-ffmpeg-linux: ## 下載 ffmpeg Linux LGPL v3 build (n7.1) → vendor/ffmpeg/linux/
@mkdir -p vendor/ffmpeg/linux
@if [ -f vendor/ffmpeg/linux/ffmpeg ] && [ -f vendor/ffmpeg/linux/ffprobe ]; then \
echo "==> ffmpeg + ffprobe (Linux) 已存在,跳過"; \
else \
echo "==> 下載 BtbN LGPL ffmpeg (Linux, n7.1)..."; \
curl -fL -o /tmp/ffmpeg-linux.tar.xz "$(FFMPEG_URL_LINUX)"; \
rm -rf /tmp/ffmpeg-linux-extract; \
mkdir -p /tmp/ffmpeg-linux-extract; \
tar xf /tmp/ffmpeg-linux.tar.xz -C /tmp/ffmpeg-linux-extract --strip-components=1; \
cp /tmp/ffmpeg-linux-extract/bin/ffmpeg vendor/ffmpeg/linux/; \
cp /tmp/ffmpeg-linux-extract/bin/ffprobe vendor/ffmpeg/linux/; \
cp /tmp/ffmpeg-linux-extract/LICENSE.txt vendor/ffmpeg/linux/LICENSE.txt 2>/dev/null || true; \
chmod +x vendor/ffmpeg/linux/ffmpeg vendor/ffmpeg/linux/ffprobe; \
rm -rf /tmp/ffmpeg-linux* ; \
echo "==> ffmpeg (Linux) 大小:$$(du -h vendor/ffmpeg/linux/ffmpeg | cut -f1)"; \
echo "==> ffprobe (Linux) 大小:$$(du -h vendor/ffmpeg/linux/ffprobe | cut -f1)"; \
fi
payload-linux: build-server-linux vendor-python-linux vendor-wheels-linux vendor-ffmpeg-linux ## 準備 Linux payload → payload/linux/
@echo "==> 建立 Linux payload (binary + models + scripts + python + wheels + ffmpeg)..."
mkdir -p payload/linux/bin payload/linux/data payload/linux/scripts payload/linux/python payload/linux/wheels
@if [ ! -f payload/linux/bin/visiona-agent-server ]; then \
echo "!! ERROR: payload/linux/bin/visiona-agent-server 不存在build-server-linux 可能失敗 !!"; \
exit 1; \
fi
@chmod +x payload/linux/bin/visiona-agent-server
@cp vendor/ffmpeg/linux/ffmpeg payload/linux/bin/ 2>/dev/null && chmod +x payload/linux/bin/ffmpeg || echo "!! WARN: ffmpeg 缺失"
@cp vendor/ffmpeg/linux/ffprobe payload/linux/bin/ 2>/dev/null && chmod +x payload/linux/bin/ffprobe || echo "!! WARN: ffprobe 缺失"
@cp vendor/ffmpeg/linux/LICENSE.txt payload/linux/bin/ffmpeg-LICENSE.txt 2>/dev/null || true
@if [ -d server/data ]; then cp -R server/data/. payload/linux/data/; fi
@if [ -d server/scripts ]; then cp -R server/scripts/. payload/linux/scripts/; fi
@if [ ! -f vendor/python/linux/python.tar.gz ]; then \
echo "!! ERROR: vendor/python/linux/python.tar.gz 不存在vendor-python-linux 應該已先跑過 !!"; \
exit 1; \
fi
@cp vendor/python/linux/python.tar.gz payload/linux/python/
@cp vendor/wheels/linux/*.whl payload/linux/wheels/ 2>/dev/null || true
@wheel_count=$$(ls -1 payload/linux/wheels/*.whl 2>/dev/null | wc -l); \
if [ "$$wheel_count" -lt 4 ]; then \
echo "!! ERROR: payload/linux/wheels 只找到 $$wheel_count 個 wheel預期至少 4 個numpy / opencv / pyusb / kp"; \
echo " 請檢查 vendor/wheels/linux/ 並重跑 make vendor-wheels-linux"; \
exit 1; \
fi
@echo "==> Linux payload 完成:包含 $$(ls payload/linux/wheels/*.whl 2>/dev/null | wc -l) 個 wheel + python tarball"
@du -sh payload/linux 2>/dev/null || true
@echo ""
@echo "payload/linux 結構:"
@find payload/linux -maxdepth 2 -type d 2>/dev/null | sed 's|^| |'
# ── Wails build ────────────────────────────────────────────────────
#
# AF6Wails 使用 visiona-agent/main.go 的 `//go:embed all:frontend` 嵌入前端產物。
# 新的 Next.js 靜態匯出在 `frontend/out/`,需要在 wails build 前同步到 `visiona-agent/frontend/`
# 否則打包後的 app 會仍嵌入舊的 local-tool 遺留控制台 JS。
#
# 選擇「Makefile 同步」而非改 Go embed 路徑的理由:
# 1. `//go:embed` 不允許走出 module rootvisiona-agent/);相對路徑 ../frontend/out 不合法
# 2. 保持 Go 原始碼穩定,避免為了 build pipeline 去碰語意性的 embed 宣告
# 3. 同步腳本單純rm + cp失敗時容易 debug
wails-sync-frontend: build-frontend ## 同步 frontend/out/ → visiona-agent/frontend/AF6取代舊的 local-tool JS 控制台)
@echo "==> 同步 frontend/out → visiona-agent/frontend (Wails go:embed 嵌入來源) ..."
@# 保留 wailsjs/ 目錄Wails CLI 產出的 TS bindings不應被 frontend/out 覆蓋)
@find visiona-agent/frontend -mindepth 1 -maxdepth 1 ! -name 'wailsjs' -exec rm -rf {} +
cp -R frontend/out/. visiona-agent/frontend/
@du -sh visiona-agent/frontend
wails-macos: stage-macos wails-sync-frontend ## wails build darwin/amd64 → visiona-agent/build/bin/visiona-agent.app
@# -s跳過 Wails 內建 frontend install/build我們已在 wails-sync-frontend 處理好,
@# visiona-agent/frontend 只放靜態產物,沒有 package.jsonWails 若自己跑 pnpm 會失敗)
cd visiona-agent && wails build -platform darwin/amd64 -clean -s
cp -R visiona-agent/build/darwin/Resources/bin \
visiona-agent/build/darwin/Resources/data \
visiona-agent/build/darwin/Resources/scripts \
visiona-agent/build/darwin/Resources/python \
visiona-agent/build/darwin/Resources/wheels \
visiona-agent/build/bin/visiona-agent.app/Contents/Resources/
codesign --force --deep --sign - visiona-agent/build/bin/visiona-agent.app
codesign --verify --verbose visiona-agent/build/bin/visiona-agent.app
@du -sh visiona-agent/build/bin/visiona-agent.app
wails-windows: stage-windows wails-sync-frontend ## ⚠️ 必須在 Windows runner 上執行wails build -platform windows/amd64
@case "$$(uname -s 2>/dev/null)" in \
MINGW*|CYGWIN*|MSYS*) : ;; \
*) \
echo ""; \
echo "❌ wails-windows 只能在 Windows runner 上 build偵測到 $$(uname -s)"; \
echo " 請在 Windows 機器上執行此 target或用 CI 的 Windows runner"; \
echo " 若只是要檢查前置步驟,可單獨跑 make stage-windows"; \
echo ""; \
exit 1 ;; \
esac
@# -s跳過 Wails 內建 frontend install/build見 wails-macos 的說明)
cd visiona-agent && wails build -platform windows/amd64 -clean -s
@du -sh visiona-agent/build/bin/visiona-agent.exe
wails-linux: payload-linux wails-sync-frontend ## ⚠️ 必須在 Linux runner 上執行wails build -platform linux/amd64
@if [ "$$(uname -s)" != "Linux" ]; then \
echo ""; \
echo "❌ wails-linux 只能在 Linux 上 build偵測到 $$(uname -s)"; \
echo " 請在 Linux x86_64 runnerGitHub Actions ubuntu-latest 即可)執行"; \
echo " 若只是要檢查前置步驟,可單獨跑 make payload-linux"; \
echo ""; \
exit 1; \
fi
# webkit2gtk-4.0 從 Ubuntu 22.10+ / Debian 12+ 起被 webkit2gtk-4.1 取代。
# 偵測 pkg-config 哪個存在:優先用 4.1(加 -tags webkit2_414.0 則
# 不加 tagWails 預設)。
@# -s跳過 Wails 內建 frontend install/build見 wails-macos 的說明)
@if pkg-config --exists webkit2gtk-4.1 2>/dev/null; then \
echo "==> webkit2gtk-4.1 detected → wails build with -tags webkit2_41"; \
cd visiona-agent && wails build -platform linux/amd64 -clean -s -tags webkit2_41; \
elif pkg-config --exists webkit2gtk-4.0 2>/dev/null; then \
echo "==> webkit2gtk-4.0 detected → wails build (default)"; \
cd visiona-agent && wails build -platform linux/amd64 -clean -s; \
else \
echo ""; \
echo "❌ 找不到 webkit2gtk-4.0 或 webkit2gtk-4.1 dev header"; \
echo " 請執行sudo apt install libwebkit2gtk-4.1-dev libgtk-3-dev"; \
echo " (舊版 Ubuntu 20.04 可改用 libwebkit2gtk-4.0-dev"; \
echo ""; \
exit 1; \
fi
@du -sh visiona-agent/build/bin/visiona-agent
# ── 安裝檔打包 ─────────────────────────────────────────────────────
dmg: wails-macos ## 美化 DMGcreate-dmg 有裝)或 plain DMGfallback→ dist/visiona-agent.dmg
@mkdir -p $(DIST)
@rm -f $(DIST)/visiona-agent.dmg
@if command -v create-dmg > /dev/null 2>&1; then \
$(MAKE) --no-print-directory dmg-fancy; \
else \
echo "⚠️ create-dmg 未安裝,使用 plain DMGhdiutil UDZO"; \
echo " 想要美化版本請執行brew install create-dmg"; \
$(MAKE) --no-print-directory dmg-plain; \
fi
@du -sh $(DIST)/visiona-agent.dmg
@file $(DIST)/visiona-agent.dmg
dmg-plain: ## hdiutil UDZO → dist/visiona-agent.dmg無背景圖CI / fallback 用)
@mkdir -p $(DIST)
rm -f $(DIST)/visiona-agent.dmg
hdiutil create -volname "visionA Agent" \
-srcfolder visiona-agent/build/bin/visiona-agent.app \
-ov -format UDZO \
$(DIST)/visiona-agent.dmg
dmg-fancy: ## create-dmg 美化版 → dist/visiona-agent.dmg需 brew install create-dmg
@if [ ! -d visiona-agent/build/bin/visiona-agent.app ]; then \
echo "❌ visiona-agent/build/bin/visiona-agent.app 不存在,請先跑 make wails-macos"; exit 1; \
fi
@if ! command -v create-dmg > /dev/null 2>&1; then \
echo "❌ create-dmg 未安裝請執行brew install create-dmg"; exit 1; \
fi
@mkdir -p $(DIST)
rm -f $(DIST)/visiona-agent.dmg
create-dmg \
--volname "visionA Agent" \
--background installer/macos/background.png \
--window-pos 200 120 \
--window-size 640 400 \
--icon-size 128 \
--icon "visiona-agent.app" 180 200 \
--app-drop-link 460 200 \
--hide-extension "visiona-agent.app" \
--no-internet-enable \
$(DIST)/visiona-agent.dmg \
visiona-agent/build/bin/visiona-agent.app
exe-only: ## 只跑 iscc 打包 installer前置產物必須已存在不重 build wails/payload
@if [ ! -f visiona-agent/build/bin/visiona-agent.exe ]; then \
echo "❌ visiona-agent/build/bin/visiona-agent.exe 不存在,請先跑 make wails-windows"; exit 1; \
fi
@if [ ! -f payload/windows/bin/visiona-agent-server.exe ]; then \
echo "❌ payload/windows/bin/visiona-agent-server.exe 不存在,請先跑 make payload-windows"; exit 1; \
fi
@$(MAKE) --no-print-directory _run-iscc
exe: wails-windows _run-iscc ## ⚠️ 必須在 Windows 上跑Inno Setup → dist/visiona-agent-*-windows-x64.exe
_run-iscc:
@echo "DEBUG _run-iscc: ISCC='$$ISCC'"
@echo "DEBUG _run-iscc: PATH first entries: $$(echo $$PATH | tr ':' '\n' | head -5)"
@ISCC_BIN="$$ISCC"; \
if [ -z "$$ISCC_BIN" ]; then \
if command -v iscc > /dev/null 2>&1; then ISCC_BIN=iscc; \
elif command -v iscc.exe > /dev/null 2>&1; then ISCC_BIN=iscc.exe; \
else \
for p in "/c/Program Files (x86)/Inno Setup 6/ISCC.exe" \
"/c/Program Files/Inno Setup 6/ISCC.exe" \
"$$LOCALAPPDATA/Programs/Inno Setup 6/ISCC.exe" \
"$$USERPROFILE/AppData/Local/Programs/Inno Setup 6/ISCC.exe" \
"/c/Program Files (x86)/Inno Setup 5/ISCC.exe"; do \
if [ -f "$$p" ]; then ISCC_BIN="$$p"; break; fi; \
done; \
fi; \
fi; \
ISCC_OK=0; \
if [ -n "$$ISCC_BIN" ]; then \
if [ -e "$$ISCC_BIN" ] || command -v "$$ISCC_BIN" > /dev/null 2>&1; then ISCC_OK=1; fi; \
fi; \
if [ "$$ISCC_OK" = "0" ]; then \
echo ""; \
echo "❌ Inno Setup Compiler (iscc) 未安裝 / 找不到"; \
echo " 已嘗試偵測的路徑:"; \
echo " - \$$ISCC 環境變數"; \
echo " - PATH 上的 iscc / iscc.exe"; \
echo " - /c/Program Files (x86)/Inno Setup 6/ISCC.exe"; \
echo " - /c/Program Files/Inno Setup 6/ISCC.exe"; \
echo " 請從 https://jrsoftware.org/isdl.php 下載並安裝 Inno Setup 6"; \
echo " 或設定 ISCC 環境變數指向 ISCC.exe 絕對路徑"; \
echo ""; \
exit 1; \
fi; \
echo "==> 使用 ISCC: $$ISCC_BIN"; \
mkdir -p $(DIST); \
echo "==> 執行 iscccwd: $$(pwd)..."; \
"$$ISCC_BIN" installer/windows/visiona-agent.iss; \
ISCC_RC=$$?; \
echo "==> iscc exit code: $$ISCC_RC"; \
if [ $$ISCC_RC -ne 0 ]; then exit $$ISCC_RC; fi
@echo "==> 產出:"
@ls -lh $(DIST)/ 2>/dev/null || echo "dist 目錄不存在"
@ls -lh $(DIST)/visiona-agent-*-windows-x64.exe 2>/dev/null || echo "未找到 .exe 產出檔"
appimage: wails-linux ## ⚠️ 必須在 Linux 上跑build-appimage.sh → dist/visiona-agent-*-linux-x64.AppImage
@if [ "$$(uname -s)" != "Linux" ]; then \
echo ""; \
echo "❌ appimage 只能在 Linux 上 build偵測到 $$(uname -s)"; \
echo " 需要 appimagetool請在 Linux x86_64 runner 執行"; \
echo ""; \
exit 1; \
fi
@mkdir -p $(DIST)
VERSION=$(VERSION) bash installer/linux/build-appimage.sh
@echo "==> 產出:"
@ls -lh $(DIST)/visiona-agent-*-linux-x64.AppImage 2>/dev/null || echo "未找到產出檔"
# ── 開發 ───────────────────────────────────────────────────────────
dev-agent: ## AB6build server binary + 以 go run 啟動 visiona-agentskip Wails GUI方便 CLI 迭代)
@echo "==> building server binary → $(DIST)/visiona-agent-server ..."
cd server && go build -o ../$(DIST)/visiona-agent-server .
@echo "==> starting visiona-agent (ctrl-c 停止)..."
@echo " tunnel 會等 VISIONA_RELAY_URL 設定後才啟動;"
@echo " 設 VISIONA_SESSION_TOKEN 自動跳過 Pair。"
cd visiona-agent && go run .
dev: dev-agent ## alias for dev-agent
test: ## go test -racefull agent module + server module
cd visiona-agent && go test -race -count=1 ./...
cd server && go test -race -count=1 ./...
test-tunnel: ## 只跑 tunnel 整合測試AB6
cd visiona-agent && go test -race -count=1 -v ./internal/tunnel/
lint: ## go vet + (optional) pnpm lint
cd visiona-agent && go vet ./...
cd server && go vet ./...
@echo "TODO: pnpm lint"
fmt: ## go fmt
cd visiona-agent && go fmt ./...
cd server && go fmt ./...
# ── 清理 ───────────────────────────────────────────────────────────
clean: ## 清除 dist/ 與 payload/ 產物
@echo "Cleaning dist/ and payload artifacts..."
rm -rf $(DIST)
rm -rf $(PAYLOAD)
@mkdir -p $(DIST) $(PAYLOAD)
@touch $(DIST)/.gitkeep $(PAYLOAD)/.gitkeep
@echo "Done."
clean-all: clean ## 完整清除dist/ payload/ wails build/ frontend build/ server embed
@echo "==> 清除 Wails build 產物..."
rm -rf visiona-agent/build/bin
rm -rf visiona-agent/build/darwin/Resources
rm -rf visiona-agent/build/windows/Resources
@echo "==> 清除 frontend build 產物..."
rm -rf frontend/out
rm -rf frontend/.next
@echo "==> 清除 server 內嵌前端..."
rm -rf server/web/out
@echo "==> clean-all 完成"
clean-build-exe: clean-all exe ## Windows完整 clean + 從頭 build installer .exe最乾淨的 build
clean-build-dmg: clean-all dmg ## macOS完整 clean + 從頭 build installer .dmg
clean-build-appimage: clean-all appimage ## Linux完整 clean + 從頭 build installer .AppImage

104
local-agent/README.md Normal file
View File

@ -0,0 +1,104 @@
# visionA Agent
> **Phase 0 雛形 — 正在建置中**
>
> 此專案於 2026-04-22 從 `local-tool/` forkbaseline commit`b71ff4cd3c72e879435f773ae15b23bf8b70841e`
>
> **fork 後獨立演進,不主動與 local-tool 同步。** 需要時手動 cherry-pick bug fix。
## 產品定位
visionA Agent 是 visionA 雲端版的 local 端代理程式:
**它是什麼**
- 一個完整的 local server沿用 local-tool 的 KneronPLUS / camera / inference / device / model / Python runtime / ffmpeg 邏輯)
- 加上 **tunnel client**,把自己 reverse-tunnel 到雲端 `visionA-backend`,讓雲端 web UI 可以透過 tunnel 操作使用者桌機上的 Kneron 裝置
- 加上 **3 頁極簡配置 UI**(狀態 / 配對 / 設定給使用者看「Agent 是否在線」和「配對雲端帳號」用
**它不是什麼**
- 不是 local-tool 的修改版local-tool 不動visionA Agent fork 後獨立演進)
- 不保留 local-tool 原本的裝置 / 模型 / 推論 UI這些由雲端 web UI 負責;本機 UI 只管配對/設定)
## 目前進度Phase 0 雛形)
| 任務 | 狀態 | 說明 |
|------|------|------|
| AB1 | ✅ | fork + 改名visiona-local → visiona-agent / bundle ID / Makefile 變數 78 處替換) |
| AB2+AB3 | ✅ | 裁剪 Wails 業務 UI binding、刪除舊資料目錄遷移新增 pairing / connection / settings 的 stub binding回 ErrNotImplemented為 AB4-AB10 預留擴點 |
| AB4 | ⏳ | 複製 tunnel client從 POC`internal/tunnel/` |
| AB5 | ⏳ | Agent config (YAML) 讀寫 |
| AB6 | ⏳ | Internal HTTP serverwrapper 綁 127.0.0.1:0 |
| AB7 | ⏳ | Pairing Exchanger呼叫雲端 `/api/pairing/exchange` |
| AB8 | ⏳ | connstate broadcaster + Wails event 推送 |
| AB9 | ⏳ | autostart 三平台實作 |
| AB10 | ⏳ | logexport壓縮最近 7 天 log |
| AB11-12 | ⏳ | Wails 整合 + 整合測試 |
## 架構圖(簡版)
```
[ Browser ] ── HTTPS ──► [ visionA-backend ]
│ (內部 HTTP forward
[ remote-proxy ]
WSS + yamux 出站長連線)
┌───────────────────────────────────────────────────────┐
│ visionA Agent使用者桌機Wails app
│ │
│ [Tunnel client] [3 頁配置 UI] │
│ │ │ Wails bindings │
│ ▼ ▼ │
│ [Internal HTTP server 127.0.0.1:<random port>] │
│ (沿用 local-tool 的 Gin handler裝置/模型/推論/...
│ │ │
│ ▼ │
│ [Python runtime + KneronPLUS SDK + ffmpeg] │
└───────────────────────────────────────────────────────┘
Kneron USB 裝置
```
## 文件
- 架構 TDD[`.autoflow/04-architecture/visiona-agent-tdd.md`](./.autoflow/04-architecture/visiona-agent-tdd.md)
- UI 規格:[`.autoflow/03-design/visiona-agent-spec.md`](./.autoflow/03-design/visiona-agent-spec.md)
- ADR[`.autoflow/04-architecture/adr/adr-007-*.md`](./.autoflow/04-architecture/adr/)(架構)、`adr-008-*.md`tunnel 複用)、`adr-009-*.md`token 儲存)
- 開發進度:[`.autoflow/progress.md`](./.autoflow/progress.md)
- Build / 共存驗證:[`docs/BUILD-VERIFICATION.md`](./docs/BUILD-VERIFICATION.md)
## fork baseline
本專案於 2026-04-22 從 `local-tool/`commit `b71ff4cd3c72e879435f773ae15b23bf8b70841e`)一次性 fork 後獨立演進,**不主動 sync**ADR-007
local-tool 原始 README 已封存於 [`docs/legacy-local-tool-readme.md`](./docs/legacy-local-tool-readme.md),僅作 cherry-pick bug fix 時的歷史對照,不再代表 visionA Agent 的產品定位。
## 開發指令
```bash
# 下載第三方依賴到 vendor/
make vendor-sync
# 本機 build + 產出 macOS DMG
make dmg
# 列出所有 targets
make help
```
主要 targets沿用 local-tool 結構,但所有產物名稱已替換為 `visiona-agent`
| Target | 作用 |
|--------|------|
| `vendor-sync` | 下載 python-build-standalone / wheels / ffmpeg |
| `build-frontend` | pnpm build Next.js 靜態產物 → `frontend/out/` |
| `wails-sync-frontend` | 同步 `frontend/out/``visiona-agent/frontend/`Wails embed |
| `wails-macos` / `wails-windows` / `wails-linux` | Wails build + ad-hoc codesignmacOS |
| `dmg` | 產出 `dist/visiona-agent.dmg`(缺 create-dmg 時 fallback hdiutil |
| `exe` | Inno Setup → `dist/visiona-agent-*-windows-x64.exe`(需 Windows runner |
| `appimage` | `dist/visiona-agent-*-x86_64.AppImage`(需 Linux runner |
| `dev-agent` | Wails dev mode |
| `test-tunnel` | tunnel client 整合測試 |
三平台 build 細節與 local-tool 共存對照表:[`docs/BUILD-VERIFICATION.md`](./docs/BUILD-VERIFICATION.md)。

View File

@ -0,0 +1,61 @@
# visionA Agent — Branding Assets
此目錄存放 visionA Agent 的品牌視覺資產。
## 檔案
| 檔案 | 用途 |
|------|------|
| `logo.svg` | 向量原始設計稿1024×1024 |
| `icon-1024.png` | Wails build 的 appicon會被 Wails 複製到 `visiona-agent/build/appicon.png` |
| `icon-512.png` / `icon-256.png` / `icon-128.png` | 各尺寸 PNG 備用 |
| `icon.ico` | Windows 多解析度 ICO16/24/32/48/64/96/128/256PNG-in-ICO 格式) |
| `icon.icns` | macOS .app bundle icon |
## 如何更新 logo
1. 改 `logo.svg` 或改 `gen_icon.go` 的繪圖函數
2. 跑 `go run gen_icon.go <output-dir>` 產出各尺寸 PNG
3. 跑 `go run gen_ico.go icon.ico <png-dir> 16,24,32,48,64,96,128,256` 產 Windows ICO
4. macOS icns 用 `iconutil`
```bash
mkdir icon.iconset
cp icon-16.png icon.iconset/icon_16x16.png
cp icon-32.png icon.iconset/icon_16x16@2x.png
cp icon-32.png icon.iconset/icon_32x32.png
cp icon-64.png icon.iconset/icon_32x32@2x.png
cp icon-128.png icon.iconset/icon_128x128.png
cp icon-256.png icon.iconset/icon_128x128@2x.png
cp icon-256.png icon.iconset/icon_256x256.png
cp icon-512.png icon.iconset/icon_256x256@2x.png
cp icon-512.png icon.iconset/icon_512x512.png
cp icon-1024.png icon.iconset/icon_512x512@2x.png
iconutil -c icns icon.iconset -o icon.icns
```
5. 部署:
- `cp icon-1024.png ../visiona-agent/build/appicon.png` — Wails build
- `cp icon-256.png ../visiona-agent/frontend/icon.png` — splash page
- `cp icon.ico ../frontend/src/app/favicon.ico` — Next.js favicon
- `cp icon-256.png ../frontend/src/app/icon.png` — Next.js App Router icon
6. 重 build`make clean-all && make exe`Windows`make clean-all && make dmg`macOS
## 設計理念
- 圓角方形背景(符合現代 app icon 容器標準)
- 深藍漸層底(`#1A1F36``#0E1222`)傳達專業、科技感
- 雙層同心圓環 = 相機鏡頭 / 視覺感測器隱喻
- 中央「V」字形 = vision 首字母
- 三個 pixel 點 + 右上 active indicator = Edge AI / pixel-level 運算的視覺語彙
- 主色 `#4F7EFF`(電子藍)搭配 `#6EF3C5`mint點綴避免純藍的冰冷
## 色票
| 用途 | HEX |
|------|-----|
| 主色 | `#4F7EFF` |
| 主色亮色 | `#6EA8FF` |
| 點綴 | `#6EF3C5` |
| 深色背景頂 | `#1A1F36` |
| 深色背景底 | `#0E1222` |
| 警示 | `#FF6B6B` |
| 中性灰 | `#8890B0` |

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

View File

@ -0,0 +1,54 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" width="1024" height="1024">
<!-- visionA Local — Logo -->
<!--
設計理念:
- 外層 rounded square 作為 app icon 的標準容器
- 深色背景 (#1A1F36) 襯托鏡頭感
- 同心圓 = 相機鏡頭 / 視覺感測器
- 中央幾何 "V" (vision 的縮寫) 用電子藍點陣強調「AI / edge」感
- 一點 mint 點綴用來打破純藍的冰冷
-->
<defs>
<linearGradient id="bgGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#1A1F36"/>
<stop offset="100%" stop-color="#0E1222"/>
</linearGradient>
<linearGradient id="lensGrad" x1="30%" y1="20%" x2="70%" y2="80%">
<stop offset="0%" stop-color="#6EA8FF"/>
<stop offset="100%" stop-color="#4F7EFF"/>
</linearGradient>
<radialGradient id="centerGrad" cx="50%" cy="45%" r="60%">
<stop offset="0%" stop-color="#6EF3C5" stop-opacity="0.9"/>
<stop offset="60%" stop-color="#4F7EFF" stop-opacity="0.2"/>
<stop offset="100%" stop-color="#4F7EFF" stop-opacity="0"/>
</radialGradient>
</defs>
<!-- 背景圓角方框 -->
<rect x="0" y="0" width="1024" height="1024" rx="220" ry="220" fill="url(#bgGrad)"/>
<!-- 外層鏡頭圓環(最粗) -->
<circle cx="512" cy="512" r="360" fill="none" stroke="url(#lensGrad)" stroke-width="36"/>
<!-- 中層鏡頭圓環(細) -->
<circle cx="512" cy="512" r="296" fill="none" stroke="#4F7EFF" stroke-width="14" stroke-opacity="0.6"/>
<!-- 內層光暈(中央感測器意象) -->
<circle cx="512" cy="512" r="240" fill="url(#centerGrad)"/>
<!-- 中央 "V" 幾何標誌vision -->
<!-- 用兩條粗線組成,線條頂端有 pixel dot底部交會 -->
<g stroke="#FFFFFF" stroke-width="44" stroke-linecap="round" stroke-linejoin="round" fill="none">
<path d="M 360 360 L 512 640"/>
<path d="M 664 360 L 512 640"/>
</g>
<!-- Pixel accent dots三個像素點強化 edge AI 的概念) -->
<circle cx="360" cy="360" r="22" fill="#6EF3C5"/>
<circle cx="664" cy="360" r="22" fill="#6EA8FF"/>
<circle cx="512" cy="640" r="26" fill="#FFFFFF"/>
<!-- 右上角 active indicator科技感點綴 -->
<circle cx="760" cy="264" r="14" fill="#6EF3C5" opacity="0.85"/>
<circle cx="760" cy="264" r="24" fill="none" stroke="#6EF3C5" stroke-width="3" opacity="0.4"/>
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

@ -0,0 +1,95 @@
// 把多張 PNG 打包成 Windows .icoPNG-in-ICO 格式Vista+ 支援)
// 用法go run gen_ico.go <output.ico> <png-dir> <sizes-csv>
// 範例go run gen_ico.go icon.ico ../ 16,24,32,48,64,96,128,256
//go:build ignore
package main
import (
"bytes"
"encoding/binary"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
)
type iconDir struct {
Reserved uint16
Type uint16
Count uint16
}
type iconDirEntry struct {
Width uint8
Height uint8
ColorCount uint8
Reserved uint8
Planes uint16
BitCount uint16
SizeBytes uint32
Offset uint32
}
func main() {
if len(os.Args) < 4 {
fmt.Fprintln(os.Stderr, "usage: gen_ico <output.ico> <png-dir> <sizes-csv>")
os.Exit(1)
}
outPath := os.Args[1]
pngDir := os.Args[2]
sizesCSV := os.Args[3]
var sizes []int
for _, s := range strings.Split(sizesCSV, ",") {
n, err := strconv.Atoi(strings.TrimSpace(s))
if err != nil {
fmt.Fprintln(os.Stderr, "bad size:", s)
os.Exit(1)
}
sizes = append(sizes, n)
}
pngs := make([][]byte, len(sizes))
for i, s := range sizes {
data, err := os.ReadFile(filepath.Join(pngDir, "icon-"+strconv.Itoa(s)+".png"))
if err != nil {
fmt.Fprintln(os.Stderr, "read", s, err)
os.Exit(1)
}
pngs[i] = data
}
var buf bytes.Buffer
binary.Write(&buf, binary.LittleEndian, iconDir{Reserved: 0, Type: 1, Count: uint16(len(sizes))})
headerSize := 6 + 16*len(sizes)
offset := uint32(headerSize)
for i, s := range sizes {
w := uint8(s)
h := uint8(s)
if s >= 256 {
w = 0
h = 0
}
entry := iconDirEntry{
Width: w,
Height: h,
Planes: 1,
BitCount: 32,
SizeBytes: uint32(len(pngs[i])),
Offset: offset,
}
binary.Write(&buf, binary.LittleEndian, entry)
offset += uint32(len(pngs[i]))
}
for _, p := range pngs {
buf.Write(p)
}
if err := os.WriteFile(outPath, buf.Bytes(), 0644); err != nil {
fmt.Fprintln(os.Stderr, "write", err)
os.Exit(1)
}
fmt.Println("wrote", outPath, "containing", len(sizes), "images")
}

View File

@ -0,0 +1,289 @@
// 一次性 icon 生成工具 — 產 visionA Agent logo 的多種解析度 PNG
// 用法go run gen_icon.go <output-dir>
//
// 此檔案為 standalone 工具,不屬於 visiona-agent app。
// 用 build tag 避免被一般 go build 抓到(只有明確 go run 才執行)。
//go:build ignore
package main
import (
"fmt"
"image"
"image/color"
"image/png"
"math"
"os"
"path/filepath"
"strconv"
)
var (
bgTop = color.RGBA{0x1A, 0x1F, 0x36, 0xFF}
bgBottom = color.RGBA{0x0E, 0x12, 0x22, 0xFF}
lensTop = color.RGBA{0x6E, 0xA8, 0xFF, 0xFF}
lensBot = color.RGBA{0x4F, 0x7E, 0xFF, 0xFF}
lensMid = color.RGBA{0x4F, 0x7E, 0xFF, 0x99}
centerGlow = color.RGBA{0x6E, 0xF3, 0xC5, 0xE0}
white = color.RGBA{0xFF, 0xFF, 0xFF, 0xFF}
mint = color.RGBA{0x6E, 0xF3, 0xC5, 0xFF}
lightBlue = color.RGBA{0x6E, 0xA8, 0xFF, 0xFF}
)
func lerpColor(a, b color.RGBA, t float64) color.RGBA {
if t < 0 {
t = 0
}
if t > 1 {
t = 1
}
return color.RGBA{
R: uint8(float64(a.R)*(1-t) + float64(b.R)*t),
G: uint8(float64(a.G)*(1-t) + float64(b.G)*t),
B: uint8(float64(a.B)*(1-t) + float64(b.B)*t),
A: uint8(float64(a.A)*(1-t) + float64(b.A)*t),
}
}
func blendAlpha(dst, src color.RGBA) color.RGBA {
if src.A == 0 {
return dst
}
if src.A == 0xFF {
return src
}
sa := float64(src.A) / 255.0
da := float64(dst.A) / 255.0
out := 1.0 - (1.0-sa)*(1.0-da)
if out == 0 {
return dst
}
return color.RGBA{
R: uint8((float64(src.R)*sa + float64(dst.R)*da*(1-sa)) / out),
G: uint8((float64(src.G)*sa + float64(dst.G)*da*(1-sa)) / out),
B: uint8((float64(src.B)*sa + float64(dst.B)*da*(1-sa)) / out),
A: uint8(out * 255),
}
}
func rgba(c color.Color) color.RGBA {
if r, ok := c.(color.RGBA); ok {
return r
}
r, g, b, a := c.RGBA()
return color.RGBA{R: uint8(r >> 8), G: uint8(g >> 8), B: uint8(b >> 8), A: uint8(a >> 8)}
}
func drawRoundedRect(img *image.RGBA, size int, radius float64, colorAt func(y int) color.RGBA) {
cx, cy := float64(size)/2, float64(size)/2
for y := 0; y < size; y++ {
c := colorAt(y)
for x := 0; x < size; x++ {
dx := math.Max(0, math.Abs(float64(x)-cx+0.5)-(float64(size)/2-radius))
dy := math.Max(0, math.Abs(float64(y)-cy+0.5)-(float64(size)/2-radius))
d := math.Hypot(dx, dy)
if d <= radius {
a := 1.0
if d > radius-1 {
a = radius - d
}
if a < 0 {
continue
}
if a > 1 {
a = 1
}
cc := c
cc.A = uint8(float64(cc.A) * a)
img.Set(x, y, blendAlpha(rgba(img.At(x, y)), cc))
}
}
}
}
func drawCircleRing(img *image.RGBA, cx, cy, rOuter, rInner float64, c color.RGBA) {
size := img.Bounds().Dx()
for y := int(cy-rOuter-1) - 1; y <= int(cy+rOuter+1)+1; y++ {
for x := int(cx-rOuter-1) - 1; x <= int(cx+rOuter+1)+1; x++ {
if x < 0 || y < 0 || x >= size || y >= size {
continue
}
d := math.Hypot(float64(x)-cx+0.5, float64(y)-cy+0.5)
if d <= rOuter && d >= rInner {
a := 1.0
if d > rOuter-1 {
a = rOuter - d
} else if d < rInner+1 {
a = d - rInner
}
if a < 0 {
continue
}
if a > 1 {
a = 1
}
cc := c
cc.A = uint8(float64(cc.A) * a)
img.Set(x, y, blendAlpha(rgba(img.At(x, y)), cc))
}
}
}
}
func drawFilledCircle(img *image.RGBA, cx, cy, r float64, c color.RGBA) {
size := img.Bounds().Dx()
for y := int(cy-r-1) - 1; y <= int(cy+r+1)+1; y++ {
for x := int(cx-r-1) - 1; x <= int(cx+r+1)+1; x++ {
if x < 0 || y < 0 || x >= size || y >= size {
continue
}
d := math.Hypot(float64(x)-cx+0.5, float64(y)-cy+0.5)
if d <= r {
a := 1.0
if d > r-1 {
a = r - d
}
if a < 0 {
continue
}
cc := c
cc.A = uint8(float64(cc.A) * a)
img.Set(x, y, blendAlpha(rgba(img.At(x, y)), cc))
}
}
}
}
func drawRadialGlow(img *image.RGBA, cx, cy, r float64, c color.RGBA) {
size := img.Bounds().Dx()
for y := int(cy-r-1) - 1; y <= int(cy+r+1)+1; y++ {
for x := int(cx-r-1) - 1; x <= int(cx+r+1)+1; x++ {
if x < 0 || y < 0 || x >= size || y >= size {
continue
}
d := math.Hypot(float64(x)-cx+0.5, float64(y)-cy+0.5)
if d <= r {
t := 1.0 - d/r
t = t * t
cc := c
cc.A = uint8(float64(cc.A) * t)
img.Set(x, y, blendAlpha(rgba(img.At(x, y)), cc))
}
}
}
}
func drawLine(img *image.RGBA, x1, y1, x2, y2, width float64, c color.RGBA) {
size := img.Bounds().Dx()
r := width / 2
minX := int(math.Min(x1, x2) - r - 1)
maxX := int(math.Max(x1, x2) + r + 1)
minY := int(math.Min(y1, y2) - r - 1)
maxY := int(math.Max(y1, y2) + r + 1)
dx := x2 - x1
dy := y2 - y1
lenSq := dx*dx + dy*dy
for y := minY; y <= maxY; y++ {
for x := minX; x <= maxX; x++ {
if x < 0 || y < 0 || x >= size || y >= size {
continue
}
px := float64(x) + 0.5
py := float64(y) + 0.5
var t float64
if lenSq > 0 {
t = ((px-x1)*dx + (py-y1)*dy) / lenSq
if t < 0 {
t = 0
}
if t > 1 {
t = 1
}
}
cx := x1 + t*dx
cy := y1 + t*dy
d := math.Hypot(px-cx, py-cy)
if d <= r {
a := 1.0
if d > r-1 {
a = r - d
}
if a < 0 {
continue
}
cc := c
cc.A = uint8(float64(cc.A) * a)
img.Set(x, y, blendAlpha(rgba(img.At(x, y)), cc))
}
}
}
}
func renderLogo(size int) *image.RGBA {
img := image.NewRGBA(image.Rect(0, 0, size, size))
s := float64(size)
k := s / 1024.0
radius := 220.0 * k
drawRoundedRect(img, size, radius, func(y int) color.RGBA {
t := float64(y) / s
return lerpColor(bgTop, bgBottom, t)
})
cx := 512.0 * k
cy := 512.0 * k
outerR := 360.0 * k
outerW := 36.0 * k
drawCircleRing(img, cx, cy, outerR, outerR-outerW, lensTop)
drawCircleRing(img, cx, cy, outerR, outerR-outerW/2, lerpColor(lensTop, lensBot, 0.6))
midR := 296.0 * k
midW := 14.0 * k
drawCircleRing(img, cx, cy, midR, midR-midW, lensMid)
glowR := 240.0 * k
drawRadialGlow(img, cx, cy, glowR, centerGlow)
lineW := 44.0 * k
drawLine(img, 360*k, 360*k, 512*k, 640*k, lineW, white)
drawLine(img, 664*k, 360*k, 512*k, 640*k, lineW, white)
drawFilledCircle(img, 360*k, 360*k, 22*k, mint)
drawFilledCircle(img, 664*k, 360*k, 22*k, lightBlue)
drawFilledCircle(img, 512*k, 640*k, 26*k, white)
drawFilledCircle(img, 760*k, 264*k, 14*k, mint)
drawCircleRing(img, 760*k, 264*k, 24*k, 21*k, color.RGBA{0x6E, 0xF3, 0xC5, 0x66})
return img
}
func savePNG(img *image.RGBA, path string) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
f, err := os.Create(path)
if err != nil {
return err
}
defer f.Close()
return png.Encode(f, img)
}
func main() {
if len(os.Args) < 2 {
fmt.Fprintln(os.Stderr, "usage: go run gen_icon.go <output-dir>")
os.Exit(1)
}
outDir := os.Args[1]
sizes := []int{16, 24, 32, 48, 64, 96, 128, 256, 512, 1024}
for _, s := range sizes {
img := renderLogo(s)
path := filepath.Join(outDir, "icon-"+strconv.Itoa(s)+".png")
if err := savePNG(img, path); err != nil {
fmt.Fprintln(os.Stderr, "save", path, err)
os.Exit(1)
}
fmt.Println("wrote", path)
}
}

View File

@ -0,0 +1,148 @@
# visionA Agent — Build Pipeline 驗證報告
> AB12 產出。驗證三平台 build pipeline 設定是否正確,以及 macOS 實際 build 結果。
> 最後驗證日期2026-04-22jimchen 的 macOS 13 開發機)
## 三平台預期輸出
| 平台 | 產出路徑 | 格式 | 參考大小 | 備註 |
|------|---------|------|---------|------|
| macOS | `dist/visiona-agent.dmg` | zlib-compressed UDZO DMG | ~160 MB | x86_64 IntelLSMinimumSystemVersion 10.13 |
| Windows | `dist/visiona-agent-<ver>-windows-x64.exe` | Inno Setup self-extracting EXE | ~180 MB | MinVersion 10.0.17763Win10 1809+ |
| Linux | `dist/visiona-agent-<ver>-linux-x64.AppImage` | AppImageFUSE 可執行) | ~180 MB | glibc 2.35+Ubuntu 22.04 build |
## 環境需求
### 共通
- Go 1.26+
- Node 22.x + pnpm 9或 10
- Wails CLI v2.12+`go install github.com/wailsapp/wails/v2/cmd/wails@latest`
- curl
### macOS 專屬
- Xcode Command Line Toolsclang、codesign
- `brew install create-dmg`(美化 DMG未裝則 fallback 到 plain `hdiutil` DMG
- (只有升級 ffmpeg 時)`brew install pkg-config nasm`
### Windows 專屬(只在 Windows runner 上跑)
- Inno Setup 6+`choco install innosetup`
- Git Bash / MSYS用 bash 跑 Makefile
- 真實 Python非 WindowsApps stub— 給 `pip download` 公開 wheels 用
### Linux 專屬(只在 Linux runner 上跑)
- `libgtk-3-dev libwebkit2gtk-4.1-dev libusb-1.0-0-dev`
- `fuse libfuse2`AppImage 執行需要)
- `desktop-file-utils`
- `appimagetool`(從 AppImage releases 下載)
## 已驗證項目
| 項目 | 狀態 | 驗證方式 |
|------|------|---------|
| macOS DMG 本機 build | ✅ 通過 | 本地 `make dmg` 跑過160 MB zlib UDZOhdiutil attach OK |
| macOS Info.plist Bundle ID | ✅ 通過 | `com.innovedus.visiona-agent`(與 local-tool 不衝突) |
| macOS App 內嵌 payload 完整 | ✅ 通過 | Contents/Resources/ 下 bin / data / scripts / python / wheels 齊備 |
| macOS App ad-hoc codesign | ✅ 通過 | `codesign --verify` 通過 |
| Windows .iss 語法 + 路徑 | ✅ 審閱通過 | 檔案存在,`visiona-local` 字串已清除AppId GUID 固定 |
| Linux build-appimage.sh | ✅ 審閱通過 | 腳本存在,`visiona-local` 字串已清除 |
| CI workflow `build.yml` | ✅ 審閱通過 | 三 jobmacos-13 / windows-2022 / ubuntu-22.04)設定齊備 |
| CI workflow cache key | ✅ 通過 | 使用 `visiona-agent` 路徑與 artifact 名稱 |
| Windows 實 build | ⏳ 待 CI | macOS 無法交叉 build WebView2 + iscc依賴 `.github/workflows/build.yml` Windows runner |
| Linux 實 build | ⏳ 待 CI | macOS 無法 build webkit2gtk + appimagetool依賴 `.github/workflows/build.yml` Linux runner |
| 三平台安裝到乾淨環境 | ⏳ 待人工 | 需要乾淨 macOS / Windows / Linux VM 或實機 |
## 與 local-tool 共存對照表
兩個工具可同時安裝在同一台機器上,互不干擾。
| 項目 | local-tool | visionA Agent | 結論 |
|------|-----------|---------------|------|
| App Bundle IdentifiermacOS | `com.innovedus.visiona-local` | `com.innovedus.visiona-agent` | ✅ 不衝突 |
| App Bundle NamemacOS | `visionA-local` | `visionA Agent` | ✅ 不衝突 |
| App 安裝位置macOS | `/Applications/visiona-local.app` | `/Applications/visiona-agent.app` | ✅ 不衝突 |
| App 安裝位置Windows | `Program Files\visiona-local` | `Program Files\visiona-agent``DefaultDirName={autopf}\visiona-agent` | ✅ 不衝突 |
| Inno Setup AppId GUIDWindows | local-tool 自己的 | `{8671343F-815C-4AA5-891F-1C453CE14E82}` | ✅ 不同 GUID |
| Data dirmacOS | `~/Library/Application Support/visiona-local` | `~/Library/Application Support/visiona-agent``platform_darwin.go` + `appName="visiona-agent"` | ✅ 不衝突 |
| Data dirLinux | `~/.local/share/visiona-local` | `~/.local/share/visiona-agent` | ✅ 不衝突 |
| Single-instance lock 檔名 | `visiona-local.lock` | `visiona-agent.lock``app.go:1580` | ✅ 不衝突 |
| IPC port sentinel 檔名 | `visiona-local.ipc-port` | `visiona-agent.ipc-port``app.go:1539` | ✅ 不衝突 |
| 對外網路 port | 3721HTTP UI | 無server 只 bind 127.0.0.1:random | ✅ 無衝突 |
| Tray icon 狀態列圖示 | 獨立 | 獨立 | ✅ 可同時常駐 |
**macOS 雙安裝測試**:本機已有 `/Applications/visiona-local.app`Bundle ID `com.innovedus.visiona-local`),本次 build 的 DMG 掛載後顯示 `visiona-agent.app`Bundle ID `com.innovedus.visiona-agent`LaunchServices 視為不同應用,可並存。
## 遇到的問題與處理
### 1. Wails build 因 `visiona-agent/frontend` 缺 package.json 而失敗
**症狀**:首次跑 `make wails-macos`Wails 偵測 `assetdir: ./frontend` 後自動執行 `frontend:install`,在 `visiona-agent/frontend/``pnpm install`,但該目錄只放 `//go:embed` 用的靜態產物,沒有 `package.json`,報錯:
```
ERR_PNPM_NO_IMPORTER_MANIFEST_FOUND No package.json... was found in "visiona-agent/frontend".
```
**修復**`Makefile``wails-macos` / `wails-windows` / `wails-linux` 三個 target 的 `wails build` 命令都加 `-s`Skip Frontend。理由前端產物已由 `wails-sync-frontend` target 從 `frontend/out/` 同步過來Wails 不需要自己再跑 pnpm。
### 2. 殘留 `visiona-local` / `visionA-local` 字串
**找到 3 處**,已全部修正:
| 檔案 | 行數 | 原字串 | 修正後 |
|------|------|--------|--------|
| `installer/linux/99-kneron.rules` | 1 | `# Kneron USB devices — visionA-local` | `# Kneron USB devices — visionA Agent` |
| `installer/macos/make-dmg-background.py` | 49 | `title = "Drag visionA-local to Applications"` | `title = "Drag visionA Agent to Applications"` |
| `.github/workflows/release.yml` | 55 | `name: visionA-local ${{ github.ref_name }}` | `name: visionA Agent ${{ github.ref_name }}` |
`installer/windows/visiona-agent.iss:117``edge-ai-platform` 字串是註解(「未來若要偵測舊版 edge-ai-platform...」),保留。
### 3. go.mod Wails 版本警告(非致命)
Wails CLI 提示:
```
Warning: go.mod is using Wails '2.11.0' but the CLI is 'v2.12.0'.
```
Build 本身不受影響。可未來升級(`wails build -u` 或手動改 `go.mod`AB12 不處理。
## Makefile 變更摘要AB12
| Target | 變更 |
|--------|------|
| `wails-macos` | `wails build``-s`並加註解說明為何frontend 靜態產物由 wails-sync-frontend 處理) |
| `wails-windows` | 同上 |
| `wails-linux` | 同上(兩個分支都加) |
## 完整 build 流程macOS 本機)
```bash
# 一次性安裝
brew install create-dmg
go install github.com/wailsapp/wails/v2/cmd/wails@latest
export PATH="$HOME/go/bin:$PATH"
# vendor第一次約 5-10 分鐘,之後吃 cache
make vendor-sync
# build pipeline
make build-frontend # → frontend/out/ (~1 MB)
make build-server # → dist/visiona-agent-server (~31 MB)
make payload-macos # → payload/darwin/ (~203 MB)
make wails-macos # → visiona-agent/build/bin/visiona-agent.app (~216 MB)
make dmg # → dist/visiona-agent.dmg (~160 MB)
# 或一步到位
make clean-build-dmg
```
## 下一步建議
1. **推到 GitHub 觸發 CI**`.github/workflows/build.yml` 會在 Windows 與 Linux runner 上完整 build驗證 pipeline 在另兩個平台也能跑通。
2. **乾淨環境安裝測試**
- macOS下載 `dist/visiona-agent.dmg`,在沒裝過 visionA Agent 的 Mac 上 drag-to-Applications首次啟動確認 tray icon 出現、Pair 流程可用。
- WindowsCI 產出的 `.exe` 在 Win10/11 乾淨 VM 上跑,確認 Inno Setup 安裝流程、WinUSB driver 首次啟動提權。
- LinuxCI 產出的 `.AppImage` 在 Ubuntu 22.04 上 `chmod +x && ./visiona-agent-*.AppImage`,確認 udev rule 提示可用。
3. **Code signing / notarization**Phase 2暫不做
- macOSDeveloper ID + notarize否則使用者首次打開要在設定 > 安全性手動允許)
- WindowsEV Code Signing 憑證(避免 SmartScreen 警告)
4. **安裝與 local-tool 同機共存**驗證:在已安裝 local-tool 的機器上安裝 Agent確認兩個 tray icon 同時出現、兩套 data dir 獨立、兩個 server 可同時運作。

View File

@ -0,0 +1,313 @@
# visionA Agent Tool — 功能規格文件
> 版本v0.1.0-dev | 最後更新2026-04-16 | 對應 commit`9793a2e`
## 概述
visionA Agent Tool 是 Kneron KL520 / KL720 邊緣 AI 推論硬體的**本機桌面應用**,讓使用者在完全離線的環境下,透過 USB 連接 Kneron 裝置,進行影像 / 影片 / 攝影機即時推論。
架構為 **Wails 桌面 AppGo + Vanilla JS 控制台)+ Go HTTP Server + Next.js Web UI瀏覽器**。Wails 控制台負責 server lifecycle 管理,瀏覽器 Web UI 負責裝置操作、模型管理、推論工作區。
### 三平台支援
| 平台 | 安裝格式 | Build target |
|------|---------|-------------|
| macOS (x86_64) | `.dmg` | `make dmg` |
| Windows (x64) | `.exe` (Inno Setup) | `make exe` |
| Linux (x86_64) | `.AppImage` | `make appimage` |
### 完全離線安裝
所有依賴Python runtime、wheels、ffmpeg、ffprobe、.nef 模型)全部 vendor 進 installer安裝和執行過程**不需要網路連線**。
---
## 1. Wails 控制台(桌面應用程式視窗)
### 1.1 啟動流程 — 5 階段進度面板
| 階段 | 名稱 | 做什麼 |
|------|------|--------|
| 1 | 初始化控制台 | 建立 dataDir、舊資料遷移、single-instance lock、IPC server、首次啟動 seed複製內建模型到 user dataDir |
| 2 | 檢查 Python 執行環境 | 偵測系統 Python → 或解壓內建 Python runtime首次啟動 ~1-3 分鐘)→ 建 venv → pip install wheelsnumpy/opencv/KneronPLUS 等 9 個套件) |
| 3 | 啟動本機伺服器 | spawn `visiona-agent-server` subprocess → 等 health check 通過(首次啟動 Windows Defender 掃描可能需 1-2 分鐘) |
| 4 | 偵測 Kneron 裝置 | 對 `/api/devices` 發一次 GET 確認 server 可 serve 業務 endpoint |
| 5 | 開啟瀏覽器 | 自動開啟系統預設瀏覽器到 `http://127.0.0.1:3721`(可在設定中關閉) |
- **全屏 splash**app 啟動最一開始顯示 logo + spinner 全屏 overlay收到第一個 stage event 後切換到 5 階段面板
- **每階段細步文案**Go 端在每個 sub-step emit `startup:stage-detail` event前端即時顯示當前正在做什麼例如「正在解壓 Python runtime」「等待伺服器健康檢查通過」
- **啟動完成自動收合**5 階段全部完成後面板收合成一行 summary「✓ 啟動完成 · 點此展開檢視」,使用者點擊可展開歷史紀錄
- **重啟 / 重試時重新展開**:按「重新啟動伺服器」或「重試」會重置面板並完整重跑 5 階段
- **前端 snapshot 補漏**:前端 init 完成後呼叫 `GetStartupSnapshot()` 補上 race window 中漏掉的 stage events避免順序亂跳
- **Web UI 連線指示燈**Stage 6「等待 Web UI 連線」從面板隱藏,改為 header 的 Web UI 連線指示燈(綠燈 = 已連線、黃燈 pulse = 等待中)
### 1.2 Timeout 機制
| 類型 | 時長 | 行為 |
|------|------|------|
| 每階段 soft timeout | 20 秒 | emit「正在重試...」提示,**不中斷流程** |
| 整體 hard timeout | 5 分鐘300 秒) | 超過則 fail 進 Error state |
| 首次 bootstrap pause | 無上限 | Stage 1 seed / Stage 2 Python bootstrap / Stage 3 waitHealthy 期間暫停 hard timeout 計時,不算進 5 分鐘 budget |
### 1.3 控制台功能
- **狀態列**:顯示 server 狀態(閒置 / 啟動中 / 執行中 / 停止中 / 已停止 / 錯誤、port、PID、uptime、Web UI 連線狀態
- **主要控制**:在瀏覽器開啟 / 啟動 / 管理(停止 / 重新啟動 / 開啟 log 資料夾)
- **Log 面板**server 即時 log 顯示、自動跟隨最新、關鍵字過濾、層級過濾All / INFO / WARN / ERROR、清空 / 複製 / 匯出 / 開啟 log 資料夾
- **Settings**啟動時自動開啟瀏覽器macOS/Windows 預設 ON、Linux 預設 OFF、語言切換繁體中文 / English / Auto、關於資訊
- **Shutdown modal**server 停止時顯示「正在停止伺服器…」overlay15 秒 watchdog 自動 hide可用 Esc / 點 backdrop 手動關閉
- **Error 處理**server 啟動失敗 → 紅 banner「伺服器無法啟動」+ 重試按鈕runtime crash → OS 原生通知 + Error state
- **i18n 雙語**:繁體中文 + English全 UI 元素(含 stage 細步文案、設定、錯誤訊息)
### 1.4 Single-instance 保護
- 用 `visiona-agent.lock` 檔案鎖 + PID 存活檢查
- 第二次啟動偵測到已有 instance → 嘗試 IPC raise 把現有視窗提到前景 → 自己 quietly exit
- Stale lockPID 已死)自動清理並取得新 lock
---
## 2. Web UI瀏覽器介面
### 2.1 頁面路由
| 路由 | 功能 |
|------|------|
| `/` | 儀表板 — 已連接裝置列表、活動時間軸、統計卡片 |
| `/devices` | 裝置列表 — 掃描 / 連接 / 斷開 Kneron USB 裝置 |
| `/devices/[id]` | 裝置詳細 — 狀態、健康度、韌體版本、連線日誌、設定 |
| `/models` | 模型庫 — 7 個預設 .nef 模型KL520 × 4 + KL720 × 3+ 上傳自訂模型 |
| `/models/[id]` | 模型詳細 — metadata、支援硬體、效能數據 |
| `/workspace` | 工作區 — 選裝置 → 選模型 → 選來源 → 推論 |
| `/workspace/[deviceId]` | 單一裝置工作區 — 推論操作主介面 |
| `/settings` | 設定 — 語言、主題、其他偏好 |
### 2.2 推論來源
| 來源 | 說明 |
|------|------|
| 攝影機 (Camera) | USB / IP Camera 即時串流推論 |
| 圖片 (Image) | 上傳單張 JPG/PNG 做單次推論 |
| 影片 (Video) | 上傳 MP4/AVI/MOV/MPEG/MPG 做逐幀推論ffmpeg 解碼) |
| 批次圖片 (Batch Images) | 上傳多張圖片逐張推論 |
### 2.3 推論結果顯示
- **MJPEG 即時串流**`/api/camera/stream` 提供多客戶端 multipart MJPEG 串流
- **Canvas overlay**bounding box + label + confidence 即時疊加
- **推論面板**FPS、平均延遲、信心度門檻調整、分類結果展示
- **影片進度**:目前幀 / 總幀、seek 跳轉
- **批次進度**:已處理 / 總張數、縮圖預覽
### 2.4 模型管理
- **7 個預設模型**NEF 格式、INT8 量化)
- **上傳自訂 .nef 模型**`POST /api/models/upload`
- **刪除已上傳模型**`DELETE /api/models/:id`
- **模型篩選**按任務類型object_detection / classification、硬體KL520 / KL720、關鍵字
- **模型對比工具**:並排比較兩個模型的規格
### 2.5 裝置管理
- **掃描 USB 裝置**:偵測已連接的 Kneron KL520 / KL720
- **連接 / 斷開**:連接時自動載入 firmwareKL520 USB Boot 流程)
- **韌體更新**flash .nef 到裝置 + 進度 WebSocket 即時回報
- **WinUSB 驅動安裝**Windows only首次啟動自動透過 KneronPLUS SDK libwdi 安裝,需 UAC 提權
### 2.6 離線覆蓋層
- Server 關閉 / 崩潰時,瀏覽器 tab 顯示 Offline Overlay
- `role=alertdialog` + focus trap + WebSocket 重連機制
- `boot-id` 偵測 server restart → 自動 reload含 reload loop guard
---
## 3. 預設模型
### KL5204 個)
| 模型 ID | 名稱 | 任務 | 輸入 |
|---------|------|------|------|
| kl520-yolov5-detection | YOLOv5 Detection | 物件偵測 | 640×640 |
| kl520-fcos-detection | FCOS Detection | 物件偵測 | 512×512 |
| kl520-ssd-face-detection | SSD Face Detection | 人臉偵測 | 320×240 |
| kl520-tiny-yolov3 | Tiny YOLOv3 | 物件偵測 | 416×416 |
### KL7203 個)
| 模型 ID | 名稱 | 任務 | 輸入 |
|---------|------|------|------|
| kl720-yolov5-detection | YOLOv5 Detection | 物件偵測 | 640×640 |
| kl720-resnet18-classification | ResNet18 Classification | 分類1000 類)| 224×224 |
| kl720-fcos-detection | FCOS Detection | 物件偵測 | 512×512 |
---
## 4. 內嵌依賴
| 依賴 | 版本 | 用途 | 授權 |
|------|------|------|------|
| Python | 3.12.9 (python-build-standalone) | Kneron Python bridge | PSF |
| numpy | 2.4.4 | 影像處理 | BSD |
| opencv-python-headless | 4.10.0 | 影像處理 | Apache-2.0 |
| KneronPLUS | 2.0.0 | Kneron SDK Python binding | Kneron |
| pyusb | 1.3.1 | USB 裝置存取 | BSD |
| requests | 2.33.1 | HTTP 工具 | Apache-2.0 |
| ffmpeg | 自建 LGPL v3 decoder-only | 影片解碼mp4/avi/mov/mpeg/mpg| LGPL v3 |
| ffprobe | 同上 | 影片 metadata 提取 | LGPL v3 |
### ffmpeg LGPL 合規
macOS 版為自行編譯的最小 decoder-only build~5.7MB),只啟用必要的 demuxer/decoder/parser/filter**無 --enable-gpl / libx264 / libx265**,符合 LGPL v3。Windows/Linux 使用 BtbN 官方 LGPL 預編譯 binary。
---
## 5. Server API 摘要
### 系統
| Method | Path | 說明 |
|--------|------|------|
| GET | `/api/system/health` | 健康檢查(高頻輪詢用,無 log |
| GET | `/api/system/info` | 版本、平台、uptime |
| GET | `/api/system/metrics` | Go runtime 記憶體統計 |
| GET | `/api/system/deps` | 外部依賴檢查python/ffmpeg/ffprobe |
| GET | `/api/system/boot-id` | server 啟動 ID偵測 restart 用) |
| POST | `/api/system/restart` | 重啟 server |
| POST | `/api/system/install-driver` | 安裝 Kneron USB 驅動Windows only |
| POST | `/api/system/shutdown-notify` | 廣播關機通知到 WebSocket clients |
### 模型
| Method | Path | 說明 |
|--------|------|------|
| GET | `/api/models` | 列出所有模型(支援 filter |
| GET | `/api/models/:id` | 取得單一模型詳細 |
| POST | `/api/models/upload` | 上傳自訂 .nef 模型 |
| DELETE | `/api/models/:id` | 刪除已上傳模型 |
### 裝置
| Method | Path | 說明 |
|--------|------|------|
| GET | `/api/devices` | 列出已偵測裝置 |
| POST | `/api/devices/scan` | 掃描 USB 裝置 |
| GET | `/api/devices/:id` | 取得裝置詳細 |
| POST | `/api/devices/:id/connect` | 連接裝置 |
| POST | `/api/devices/:id/disconnect` | 斷開裝置 |
| POST | `/api/devices/:id/flash` | 燒錄韌體 |
| POST | `/api/devices/:id/inference/start` | 啟動推論 |
| POST | `/api/devices/:id/inference/stop` | 停止推論 |
### 媒體
| Method | Path | 說明 |
|--------|------|------|
| GET | `/api/camera/list` | 列出可用攝影機 |
| POST | `/api/camera/start` | 啟動攝影機推論 pipeline |
| POST | `/api/camera/stop` | 停止 pipeline |
| GET | `/api/camera/stream` | MJPEG 即時串流 |
| POST | `/api/media/upload/image` | 上傳單張圖片推論 |
| POST | `/api/media/upload/video` | 上傳影片逐幀推論 |
| POST | `/api/media/upload/batch-images` | 批次上傳圖片推論 |
| GET | `/api/media/batch-images/:index` | 取得批次中特定幀 |
| POST | `/api/media/seek` | 影片 seek 跳轉 |
### WebSocket
| Path | 說明 |
|------|------|
| `/ws/devices/events` | 裝置連接 / 斷開事件 |
| `/ws/devices/:id/flash-progress` | 韌體更新進度 |
| `/ws/devices/:id/inference` | 推論結果即時串流 |
| `/ws/server-logs` | server log 即時廣播 |
| `/ws/system` | 系統事件(含 shutdown-imminent |
---
## 6. 資料目錄
| 平台 | 位置 |
|------|------|
| macOS | `~/Library/Application Support/visiona-agent/` |
| Windows | `%APPDATA%\visiona-agent\` |
| Linux | `~/.config/visiona-agent/``$XDG_DATA_HOME/visiona-agent/` |
### 目錄結構
```
visiona-agent/
├── visiona-agent.lock # single-instance 鎖
├── visiona-agent.ipc-port # server port供 raise 用)
├── visiona-agent.wails-ipc-port # Wails IPC port
├── preferences.json # 使用者偏好autoOpenBrowser / locale
├── models.json # 預設模型 catalog首次啟動從 bundle seed
├── nef/ # 預設 .nef 模型檔(首次啟動從 bundle seed
│ ├── kl520/
│ └── kl720/
├── custom-models/ # 使用者上傳的自訂模型
├── runtime/ # Python runtime首次啟動解壓
│ ├── python/
│ └── venv/
├── logs/
│ ├── server.stdout.log # server subprocess stdout
│ ├── server.stderr.log # server subprocess stderr
│ └── wails.log # Wails app (appLog) 啟動流程日誌
└── .first-ws-connected # WebSocket sentinel file啟動階段用
```
---
## 7. Installer Build 指引
### macOS
```bash
cd local-tool
make vendor-sync # 下載 Python + wheels + 驗證 ffmpeg LGPL
make payload-macos # 準備 payload/darwin/
make dmg # → dist/visiona-agent.dmg (~163MB)
```
### Windows需在 Windows 機器上)
```powershell
cd local-tool
.\scripts\bootstrap-windows.ps1 # 安裝 Go / Node / pnpm / Wails / Python / MSYS2
make exe # → dist/visiona-agent-*-windows-x64.exe
```
### LinuxUbuntu 22.04+
```bash
cd local-tool
bash scripts/bootstrap-linux.sh # 安裝所有依賴 + 自動 build
# 或手動:
make vendor-python-linux vendor-wheels-linux vendor-ffmpeg-linux
make payload-linux
make appimage # → dist/visiona-agent-*-linux-x64.AppImage
```
---
## 8. 除錯指引
### Windows 啟動問題
1. 檢查 `%APPDATA%\visiona-agent\logs\wails.log`
- 第一行應有 `fix marker: d946561+`(確認 build 版本)
- Stage 1-5 的細步文案會記錄每個 sub-step
2. 檢查 `server.stdout.log` / `server.stderr.log`
3. 如需更詳細的 Wails 端 log從命令列啟動
```
"C:\Program Files\visiona-agent\visiona-agent.exe" > %TEMP%\wails-debug.txt 2>&1
```
### 常見問題
| 症狀 | 可能原因 | 解法 |
|------|---------|------|
| 啟動 Stage 3 timeout | Windows Defender 首次掃 .exe | 等待,有 pause 機制不會 fail或加白名單 |
| 第二次啟動閃黑窗消失 | 前一次 crash 的 lock 殘留 | 刪 `visiona-agent.lock` |
| 瀏覽器看不到裝置 | USB 權限Linux/ WinUSB driver 沒裝 | Linux: `sudo bash install-udev.sh`Windows: 從 UI 點「安裝 Kneron USB 驅動程式」 |
| 模型庫是空的 | 首次啟動 seed 失敗 | 檢查 `wails.log` 的 seed 訊息;或手動複製 `models.json + nef/` 從 installer 到 user dataDir |

View File

@ -0,0 +1,209 @@
# Legacy local-tool README已封存
> **此檔僅作歷史保留。**
>
> 以下內容是 visionA Agent 於 2026-04-22 從 `local-tool/`baseline commit `b71ff4cd3c72e879435f773ae15b23bf8b70841e`fork 時的原始 README 後半段。
>
> 內容描述的是 **local-tool**(單機桌面 Kneron 工具)的產品定位、安裝流程、功能清單與不做的事,**並非 visionA Agent 的當前定位**。
>
> visionA Agent 的真實定位請見 `local-agent/README.md`
> - visionA Agent **就是** tunnel clientlocal-tool 明確排除「Tunnel」
> - visionA Agent 的 UI 只剩 3 頁配置(狀態 / 配對 / 設定),不保留 local-tool 的裝置 / 模型 / 推論 UI
> - visionA Agent 不會單獨上架,靠雲端 visionA-backend 驅動
>
> 保留此檔的目的:
> 1. 未來若需從 local-tool cherry-pick bug fix可比對原始 README 釐清 baseline 行為
> 2. 部分 build 流程Makefile target、三平台 installer仍延用 local-tool 結構,這份原文是歷史對照
>
> 若內容已不再有參考價值,可整檔刪除。
---
> **裝起來像一般 app離線也能跑接上 Kneron 就推論。**
> 把 `edge-ai-platform` 的 Kneron AI 邊緣推論能力,打包成單機桌面應用。
![macOS x86_64](https://img.shields.io/badge/macOS_x86__64-beta-brightgreen)
![Windows x86_64](https://img.shields.io/badge/Windows_x86__64-TBD-lightgrey)
![Linux x86_64](https://img.shields.io/badge/Linux_x86__64-TBD-lightgrey)
![License](https://img.shields.io/badge/license-TBD-orange)
<!-- TODO: docs/screenshots/logo.png -->
---
## 這是什麼
local-tool 是 `edge-ai-platform`(原本要部署到 EC2 + Docker 的 Kneron 邊緣推論平台)的**單機桌面衍生版本**。為「帶著筆電做 Kneron demo 的人」而生 —— 主要服務 Innovedus 內部 FAE 與外部 Kneron 開發者。
三個核心承諾:
- 🎒 **零依賴**Python runtime、KneronPLUS SDK、ffmpeg、預置 `.nef` 模型全部內嵌
- ✈️ **零網路**:下載一次後完全離線可用(適合客戶現場 IT 鎖得死緊的場景)
- 🖱️ **零學習成本**:雙擊安裝 → 開啟 → 插上 Kneron 裝置 30 秒內跑出第一幀推論
對標產品Docker Desktop、Ollama。
---
## 安裝(使用者)
### macOSx86_64beta
1. 從內部 Gitea Releases 下載 `visiona-local.dmg`
2. 雙擊開啟 dmg → 把 `visionA Local.app` 拖到 `Applications/`
3. **第一次啟動**因為未做程式碼簽章Gatekeeper 會警告「來自未識別開發者」
- 在 Finder 中**右鍵點 `visionA Local.app` → 選「開啟」**(不是雙擊)
- 對話框出現「仍要開啟」時點確認
- 往後直接雙擊即可
4. **首次啟動會花 3060 秒**解壓內嵌的 Python runtime 並離線安裝 wheels
這是預期行為,不是卡住。之後啟動只要幾秒
> 📁 資料目錄:`~/Library/Application Support/visiona-local/`
> 包含 log、lock、ipc-port、自上傳模型
### Windows / Linux
**Coming soon** — build script 已經寫好,等 CI runner 齊備後就會釋出。
- WindowsInno Setup `.exe` installer
- Linux`.AppImage` + udev rules需 root 裝 `99-kneron.rules`
---
## 系統需求
| 平台 | 最低版本 | 架構 |
|------|---------|------|
| macOS | 14 Sonoma | x86_64 ¹ |
| Windows | 10 1809 | x86_64 |
| Ubuntu | 22.04 | x86_64 |
¹ Apple Silicon 理論上可透過 Rosetta 2 執行,但**未經測試**。
**離線可用**:安裝後所有核心功能(包含 Python sidecar、推論、模型管理、攝影機、影片解碼完全不需要網路。
---
## 功能總覽
### ✅ 有的功能
- **裝置管理**USB 自動偵測 Kneron KL520 / KL72010 秒內連線
- **攝影機推論**MJPEG 串流 + 即時 overlay首次延遲 ≤ 250ms穩定後 ≤ 150ms
- **模型管理**8 個預置 `.nef` 模型(分類 / 偵測 / 臉辨)+ 自上傳切換
- **核心推論引擎**image classification、object detection、face recognition
- **媒體推論**支援圖片與影片檔本機上傳R5 決策後不支援 URL 推論)
- **中英雙語**,跟隨系統 Dark Mode
### ❌ 不做的事(明確排除)
> ⚠️ 注意:以下排除清單是 **local-tool** 的設計邊界,**不適用於 visionA Agent**。
> visionA Agent 的核心能力之一就是 Tunnel與此清單相反
為了聚焦「個人工具」,以下功能從 `edge-ai-platform` 全數砍掉:
- ❌ Cluster多裝置叢集
- ❌ Relay / Tunnel遠端連線、反向代理
- ❌ 韌體燒錄firmware flash
- ❌ 系統列 Tray 常駐
- ❌ Auto-update
- ❌ Telemetry / 崩潰回報
- ❌ License 啟用、憑證簽章
- ❌ Mac App Store / Microsoft Store / Snap Store 上架
---
## 開發者區local-tool 結構,僅供對照)
### 專案結構
```
local-tool/
├── .autoflow/ PRD / 設計 / 架構 / 進度文件
├── server/ Go 1.26 後端Gin + go:embed
├── frontend/ Next.js 16 + React 19 + shadcn
├── visiona-local/ Wails 應用殼installer
├── payload/ 打包暫存區
├── vendor/ 第三方依賴make vendor-sync 下載,不進 git
├── dist/ 最終安裝檔(.dmg / .exe / .AppImage
├── installer/ Inno Setup / AppImage script
├── scripts/ build 與維運腳本
└── Makefile
```
> visionA Agent 沿用大部分目錄結構,但 `visiona-local/``visiona-agent/`、Bundle ID / 安裝路徑 / lockfile 全部獨立。詳見 `local-agent/docs/BUILD-VERIFICATION.md`
### 開發流程
```bash
# 1. 下載全部第三方依賴到 vendor/
make vendor-sync
# 2. 本機 build 並產出 dmgmacOS
make dmg
# 查看所有可用 targets
make help
```
主要 make targets
| Target | 作用 |
|--------|------|
| `vendor-sync` | 下載 python-build-standalone、wheels、ffmpeg |
| `build-server` | 編譯 Go server binary先 build frontend + embed |
| `build-frontend` | pnpm build Next.js 靜態產物 |
| `payload-macos` | 準備 macOS payloadbinary + python + wheels + ffmpeg + 模型) |
| `wails-macos` | Wails build + ad-hoc codesign |
| `dmg` | 產出 `dist/visiona-local.dmg`local-tool/ `visiona-agent.dmg`visionA Agent |
| `exe` | Windows installer需在 Windows runner 執行) |
| `appimage` | Linux AppImage需在 Linux runner 執行) |
### 三方平台 build
| 平台 | 指令 | 執行環境 |
|------|------|---------|
| macOS | `make dmg` | 本機Intel Mac |
| Windows | `make exe` | Windows runner + Inno Setup 6 |
| Linux | `make appimage` | Ubuntu 22.04+ runner + appimagetool |
`vendor-*-windows` / `vendor-*-linux` 可在 macOS 上跑通(只有 `wails-*` 和最後一步 installer 需要對應平台)。
---
## 已知限制與 TODOlocal-tool 原文)
- 🟡 **Kneron 預置模型 re-distribution 授權**:開發階段假設可用,正式發佈前需與 Kneron 官方確認
- 🟡 **Windows / Linux 安裝檔**build script 就緒,等 CI runner 齊備
- 🟡 **Apple Silicon** 未經測試(理論上 Rosetta 2 可跑)
- 🟡 **Linux Kneron USB vendor ID**`installer/linux/99-kneron.rules` 需最終確認
- 🟡 程式碼簽章Developer ID / EV cert**不做**,使用者需手動繞過 Gatekeeper / SmartScreen
- 🟡 **無 auto-update**:新版需手動從 Gitea 下載
---
## 授權
**License: TBD**(內部工具 / MIT / proprietary 待定,發佈前確認)
### 第三方元件授權
| 元件 | 授權 | 備註 |
|------|------|------|
| ffmpeg | **LGPL v3**(方案 B 混合macOS 自 build decoder-only / Windows & Linux 用 BtbN n7.1 LGPL | v2 TDD §2.2 |
| KneronPLUS SDK | Kneron 商用條款 | 再次確認 re-distribution 權利 |
| python-build-standalone | MPL 2.0 / PSFL | — |
| Python 標準函式庫 | PSFL | — |
| shadcn/ui | MIT | — |
| Next.js / React | MIT | — |
| Wails | MIT | — |
| Gin | MIT | — |
完整第三方授權清單於 `.autoflow/02-prd/PRD.md` §4.8。
---
## 致謝 / 起源
local-tool 衍生自 Innovedus 內部專案 `edge-ai-platform`(原為部署於 EC2 + Docker 的多人共享平台)。本專案將其改造為單機桌面版本,聚焦「一個人帶一台筆電」的使用場景。
感謝 Kneron、python-build-standaloneastral-sh、shadcn 等開源社群。

44
local-agent/frontend/.gitignore vendored Normal file
View File

@ -0,0 +1,44 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
/dist/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files可依需要 opt-in 提交,例如 .env.example / .env.local.example
.env*
!.env.example
!.env.local.example
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

View File

View File

@ -0,0 +1,310 @@
# visionA Agent — Frontend
visionA Agent 是 visionA 雲端的 **local agent / tunnel bridge** — 一個桌面應用,把本機的 Kneron 邊緣裝置安全地連上 visionA 雲端。本目錄是它的前端實作純靜態匯出Next.js + React由 Wails Go runtime 透過 `go:embed` 嵌入可執行檔三平台macOS / Windows / Linux共用同一份程式碼。
> 定位:**「開了就忘掉它」的工具**。使用者配對一次後Agent 只要維持連線、不出問題就好。UI 極簡,沒有裝置面板、推論視窗、行銷素材 — 只有連線狀態、配對、設定三頁。
## 技術堆疊
| 層級 | 技術 | 版本 |
|------|------|------|
| 框架 | Next.jsApp Router`output: "export"` static | 16.1.x |
| 語言 | TypeScriptstrict | 5.x |
| UI | React | 19.2.x |
| 樣式 | Tailwind CSS | 4.x |
| 基礎元件 | Radix UI`radix-ui` 單一套件shadcn/ui 風格) | 1.4.x |
| 圖示 | lucide-react | 0.575.x |
| 主題 | next-themesLight / Dark / System | 0.4.x |
| Toast | sonner | 2.x |
| 動畫 | tw-animate-css | 1.4.x |
| 狀態管理 | zustand依賴保留雛形階段實際未用 | 5.x |
| 測試 | Vitest + @testing-library/react + jsdom | Vitest 4 / RTL 16 |
## 與其他前端專案的關係
| 專案 | 定位 | 是否為本專案的來源 |
|------|------|------------------|
| `visionA-frontend/`(雲端 Web | 使用者的主要操作介面 — 裝置管理、模型管理、推論 | **是**。Design Tokens (`globals.css`)、Theme Provider、i18n 結構、`utils.ts`、Radix UI 基礎元件複製自此 |
| `local-tool/frontend/`(本機 GUI | 開發者跑推論用的桌面工具 | **不是**。本專案**不是** local-tool 的 fork — 我們只借鑒其 Wails + Next.js `output:"export"` 的打包模式UI 和邏輯完全獨立 |
| `local-agent/frontend/`(本目錄) | 連線橋樑 UI狀態 / 配對 / 設定)| 獨立專案 |
**核心設計決策**:視覺上與 visionA-frontend 保持一致,讓同一位使用者在雲端 Web 與桌面 Agent 之間切換「能一眼認出是同家人」。
## 前置需求
- **Node.js** ≥ 20
- **pnpm** ≥ 9專案使用 pnpm**請勿** `npm install` / `yarn install`,會產生第二份 lockfile
- (整合模式才需要)**Go** ≥ 1.22 與 Wails CLI v2 — 詳見 `../README.md`local-agent 根目錄)
## 兩種開發模式
### 模式 A — 獨立前端開發(`pnpm dev`
純前端迭代(調色、調文案、元件樣式、互動邏輯)時使用,**不需要** Wails binding。
```bash
pnpm install
pnpm dev
# → http://localhost:3000支援 HMR
```
此模式下:
- `src/hooks/use-*` 的 Wails binding 呼叫會自動走 **mock 實作**(見 `src/lib/agent-api.ts` 的 runtime 偵測)
- 連線狀態、配對結果、設定值皆為假資料;可切 `notPaired` / `online` / `reconnecting` / `error` 等變體驗證 UI
- 可直接切換 Light / Dark、zh-Hant / en 驗證 Design Tokens 與 i18n
### 模式 B — 與 Wails 整合(`wails dev`
驗證與 Go backendtunnel client / pairing / connstate的真實互動時使用。
```bash
# 在 local-agent/ 根目錄
wails dev
# → Wails 會自動啟動 `pnpm dev` 並注入 Wails runtime前端呼叫 window.go.main.App.* 走真實 binding
```
此模式下:
- `isWailsRuntime()` 會回 `true``agent-api.ts` 改走真實 Wails `window.go.main.App.*`
- 事件(`connection:status` / `connection:log` / `settings:updated` / `pairing:result`)由 Go broadcaster 推送
- Token 會真的寫入 OS keychainmacOS Keychain / Windows Credential Manager / Linux Secret Service
## Build
```bash
pnpm build
```
產出到 `out/`Next.js `output: "export"` 的靜態匯出結果。Wails build 時透過 `wails.json``frontend:install` + `frontend:build` 呼叫此指令,然後透過 `assetdir: "./frontend/out"``//go:embed``out/` 整包嵌入最終可執行檔。
```bash
# 完整桌面應用 build在 local-agent/ 根目錄)
wails build
```
### 為什麼是 `output: "export"`
| 方案 | 是否可行 | 理由 |
|------|---------|------|
| Next.js server`standalone` | ❌ | Wails 不跑 Node server |
| Next.js SSR / SSG | ❌ | 同上,且 Wails 桌面環境無 HTTP server |
| **Next.js `output: "export"`** | ✅ | 純靜態,可 embed`local-tool` 已長期驗證此模式穩定 |
| Vite + SPA | ❓ | 可行但放棄 — 會引入第二套 build pipeline且 visionA-frontend 元件要改 import / `'use client'` directive |
詳見 `.autoflow/04-architecture/visiona-agent-tdd.md` §3.2。
## 可用腳本
| 指令 | 說明 |
|------|------|
| `pnpm dev` | 啟動開發伺服器mock bindingshttp://localhost:3000|
| `pnpm build` | 產出靜態檔到 `out/`(供 Wails `go:embed`|
| `pnpm lint` | ESLint 檢查(`eslint-config-next` + `react-hooks`|
| `pnpm test` | 執行所有 Vitest 測試one-shot|
| `pnpm test:watch` | Watch 模式 |
## 專案結構
```
frontend/
├── next.config.ts # output: "export" + trailingSlash + images.unoptimized
├── package.json # name: visiona-agent-frontend
├── tsconfig.json
├── postcss.config.mjs
├── eslint.config.mjs
├── vitest.config.ts
├── components.json # shadcn/ui 產生器設定(保留以利未來新增元件)
├── public/
│ └── visiona-logo.png
└── src/
├── app/
│ ├── globals.css # Design Tokens + Tailwind 層100% 從 visionA-frontend 複製)
│ ├── layout.tsx # Root layoutLocaleProvider / ThemeProvider / TooltipProvider / Toaster
│ └── page.tsx # AgentApp — 3 tabsRadix Tabs單頁切換入口
├── components/
│ ├── theme-provider.tsx
│ ├── layout/ # Agent 專屬 layoutAppShell / Header / TabNav / ConnectionStatusBadge
│ ├── agent/ # 狀態頁元件StatusHero / InfoCard / RecentLog配對頁元件TokenInput
│ └── ui/ # 24 個 Radix UI primitivesshadcn 風格封裝)+ EmptyState / Spinner / Sonner
├── views/ # 3 個 tab 對應的 view 元件(不走 Next.js routing以 useState 切換)
│ ├── status-view.tsx
│ ├── pair-view.tsx
│ └── settings-view.tsx
├── hooks/ # Wails bindings 的 React wrapper
│ ├── use-connection-status.ts # GetStatus() + connection:status event
│ ├── use-recent-logs.ts # GetRecentLog() + connection:log event
│ ├── use-pair.ts # Pair(token) + pairing:result event
│ ├── use-settings.ts # GetSettings() + UpdateSettings(patch) + settings:updated
│ ├── use-test-connection.ts # TestRelay(url)
│ └── use-export-log.ts # ExportLog()
├── lib/
│ ├── utils.ts # cn() — clsx + tailwind-merge
│ ├── agent-api.ts # Wails binding 抽象層(真實 binding + mock 雙實作自動切換)
│ └── i18n/
│ ├── context.tsx # LocaleProvider / useLocale / useT
│ ├── sync.tsx # LocaleSync — mount 後從 localStorage 拉偏好
│ ├── index.ts # dictionaries 匯出入口
│ ├── types.ts # Locale / Dictionary / SUPPORTED_LOCALES
│ └── dictionaries/
│ ├── zh-Hant.ts # 繁中字典(預設,約 93 keys
│ └── en.ts # English dictionarykey 集合與順序與 zh-Hant 完全一致)
├── types/ # 前端型別定義
│ ├── agent.ts # ConnectionState / ConnectionSnapshot / LogEntry / AgentSettings / PairError
│ └── api.ts # 與本地 server 互動的 envelope預留
└── tests/
└── setup.ts # @testing-library/jest-dom 全域 matcher
```
## 三個頁面對應
| Tab | 路徑 | 主要元件 | 對應 spec 章節 |
|-----|------|---------|---------------|
| 狀態(`status`,預設) | `views/status-view.tsx` | `StatusHero`80px 大狀態圓)+ `InfoCard`(帳號/Relay/Session+ `RecentLog`(最近 10 筆事件)| spec §4 |
| 配對(`pair` | `views/pair-view.tsx` | `TokenInput`(格式驗證 + 貼上 trim + Enter 送出)+ 錯誤 Alert + 底部安全提示 | spec §5 |
| 設定(`settings` | `views/settings-view.tsx` | 5 區塊:連線 / 行為 / Log / 關於 / 危險區域 | spec §6 |
Tab 切換走 **Radix Tabs**controlled `value`/`onValueChange`),不走 Next.js routing。跨 view 的程式化切換透過 `window` CustomEvent `agent:switch-tab` 實現(見 `status-view.tsx``page.tsx`)。
## Design Tokens
完全沿用 `visionA-frontend`:不動一行 `globals.css`
| Token 類別 | 來源 |
|-----------|------|
| 色彩(`--background` / `--primary` / `--muted-foreground` 等)| `globals.css``@layer base` 定義 light / dark 值)|
| 連線狀態色(`--status-online` / `--status-offline` / `--status-reconnecting` / `--status-idle` / `--status-error`| 同上visionA-frontend F2 定義)|
| 字型 | `font-sans`UI/ `font-mono`Token / Log / Relay URL|
| 圓角 / 陰影 / 間距 | Tailwind 4 預設 + 少量 `@theme` 擴充 |
**規則**:不允許在元件裡寫死 `#xxxxxx``rgb(...)`,一律透過 Tailwind utility class 引用 CSS 變數(例如 `bg-status-online``text-destructive`)。
## i18n
採用自製輕量 i18n不依賴 `next-intl`),以 React Context + `localStorage` 管理當前 locale。
- 預設 locale`zh-Hant`(繁中)
- 支援:`zh-Hant` / `en`
- 儲存 key`visionA.locale`
- Fallback找不到 key 時回傳 key 本身production 靜默dev 印 warning
- 測試保證:`i18n.test.ts` 驗證兩語系 key 集合完全一致,避免漏譯
字典採**扁平 key** 結構(例:`nav.status`),共分 8 個區塊:
| 區塊 | 說明 |
|------|------|
| `app.*` | 產品名稱、標語 |
| `common.*` | 通用按鈕 / 文案loading / cancel / save ...|
| `nav.*` | Tab 導航標籤 |
| `connection.*` | 連線狀態online / offline / reconnecting / notPaired / error |
| `header.*` | Header 工具列(切主題、切語言)|
| `status.*` | 狀態頁hero / info / action / confirm / log / empty|
| `pair.*` | 配對頁title / input / button / alert / error|
| `settings.*` | 設定頁section / relayUrl / behavior / log / about / reset|
新增 i18n key 時的 checklist
1. 兩個字典(`zh-Hant.ts` + `en.ts`**同步新增** — 否則 `i18n.test.ts` 會失敗
2. 保持兩邊區塊順序一致(便於 diff 與 review
3. 變數內插用 `{name}` 風格(例:`Attempt {n} of 5`),呼叫端以 `String.prototype.replace` 替換
4. 錯誤訊息要「說清楚發生什麼 + 使用者可以做什麼」
## 測試
```bash
pnpm test # one-shot 執行所有測試
pnpm test:watch # watch 模式
```
### 測試策略
| 層級 | 工具 | 範例 |
|------|------|------|
| 單元(元件) | Vitest + RTL | `button.test.tsx` / `token-input.test.tsx`(行為 + 格式驗證 regex|
| 單元hooks / utils | Vitest | `i18n.test.ts`(字典完整性 + key 集合一致性)|
| 整合view | Vitest + RTL | `app-shell.test.tsx`Header + Badge 組合)|
| E2E | — | 由 Testing Agent 負責,不在本 repo |
### 關鍵測試
- **`i18n.test.ts`** — 字典完整性守門員;兩語系 key 集合差異時立即失敗
- **`token-input.test.tsx`** — Pairing Token regex `^vAc_[a-f0-9]{32}$/i`(大小寫不敏感)+ 貼上清理 + Enter 送出
- **`status-hero.test.tsx`** — 5 種狀態變體online / offline / reconnecting / notPaired / error+ aria-label 組合 + icon `aria-hidden`
## 效能預算
本 Agent 不是 Web App不需要滿足 Core Web Vitals。但仍有一些自律規則
| 項目 | 目標 |
|------|------|
| Build 時間 | < 10sNext.js 16 Turbopack 目前約 3-4s |
| `out/` 總大小 | < 5 MBstatic export Wails embed 成本敏感 |
| 首次渲染到互動 | < 200msWails WebView 2 本機載入無網路延遲 |
| 冷啟動到可互動 | < 1s Wails process init + WebView init|
bundle 分析:`pnpm build` 時 Next.js 會印出每條 route 的大小。若單 route JS > 200KB需檢討是否有多餘依賴或未做 code splitting。
## 無障礙規範
嚴格遵守 WCAG 2.2 AA對齊 spec §9
- **不只靠顏色**:每個連線狀態同時有「圓點顏色 + icon + 文字標籤」
- **語義化 HTML**`<header>` / `<main>` / `<nav>` / `<section>` / `<dl><dt><dd>` / `<ol>`;避免 `<div>` 當按鈕
- **鍵盤導航**:所有操作 Tab 可達Enter 送出表單Esc 關閉 Dialog
- **ARIA**
- `role="status" aria-live="polite"` — 狀態變化主動朗讀StatusHero / ConnectionStatusBadge
- Session Token 遮蔽版:`aria-label="Session token, ending in e7f8"`(不念完整字串)
- Tab使用 Radix Tabs 原生 `role="tab"` / `role="tabpanel"` / `aria-selected`
- **焦點可見**:所有互動元件 `focus-visible:ring-[3px] ring-ring`
- **Reduce motion**`motion-safe:` prefix 讓 `prefers-reduced-motion: reduce` 使用者停用 pulse / spin
## 環境變數
| 變數 | 說明 | 預設值 | 必要 |
|------|------|--------|------|
| `NODE_ENV` | `development` / `production`(由 Next.js 自動設定)| — | ✅ |
| — | — | — | — |
**Agent 前端刻意不讀取環境變數** — 所有使用者偏好Relay URL、Log 等級、自啟動)由 Go backend 的 `agentconfig` 管理;前端只透過 Wails binding `GetSettings()` / `UpdateSettings(patch)` 讀寫。
## 安全
- **不儲存 Pairing Token**:使用者貼上的 token 經 `Pair(token)` 送到 Go backend前端 state 在成功 / 失敗後清空。長期 Session Token 由 Go 寫入 OS keychain**前端永遠拿不到完整 token**,只能看到遮蔽版 `sessionTokenPreview`(例如 `vAs_a1b2c3d4 ··· e7f8`)。
- **不 `dangerouslySetInnerHTML`**:本前端 0 處使用,已 grep 驗證。
- **外部 URL 開啟**:使用 `window.open(url, "_blank", "noopener,noreferrer")`AF6 會改用 Wails `BrowserOpenURL` 以觸發 OS 預設瀏覽器(而非 in-app WebView
- **不讀取使用者憑證 / 帳號 / API Key**Agent 的認證全靠 Pairing Token → Session Token 二段式換取,沒有使用者密碼。
## 路線圖 / 任務歷史
本 frontend 以 7 個任務逐步建置:
| Task | 範圍 | 狀態 |
|------|------|------|
| AF1 | 專案初始化Next.js + Tailwind + Radix UI + i18n / ThemeProvider 基礎層) | ✅ |
| AF2 | Tab navigation LayoutAppShell / Header / TabNav / ConnectionStatusBadge | ✅ |
| AF3 | 狀態頁StatusHero + InfoCard + RecentLog + EmptyState | ✅ |
| AF4 | 配對頁TokenInput + PairView + 7 種錯誤訊息對照) | ✅ |
| AF5 | 設定頁(連線 / 行為 / Log / 關於 / 危險區域 5 區塊) | ✅ |
| AF6 | Wails bindings 整合(`agent-api.ts` 抽象層 + 真實 binding 取代 hooks 的 mock | ✅(與 AF7 併發) |
| AF7 | i18n 字典審查 + README 完整版 + 前端收尾 | ✅ |
細節與原始 TDD 任務拆分詳見 `.autoflow/04-architecture/visiona-agent-tdd.md` §15.2。
## 參考文件
- `.autoflow/03-design/visiona-agent-spec.md` — UI 設計規格3 頁面 + 全域 Header + 所有互動行為 + i18n key 清單)
- `.autoflow/04-architecture/visiona-agent-tdd.md` — 技術設計文件
- §2 整體架構
- §3 專案結構§3.2 為何沿用 Next.js
- §6 三個 UI 頁面對接Wails bindings + events 規格)
- `.autoflow/04-architecture/adr/adr-008-tunnel-code-copy.md` — Tunnel client 複製策略
- `.autoflow/04-architecture/adr/adr-009-token-storage.md` — Token 儲存策略
## 給維護者的備忘
1. **不要**把 zustand store 加進來用於儲存狀態頁 / 配對頁的資料 — 連線狀態本質上是**後端主導的狀態**,前端以 Wails event 接收即可。若未來要做 UI-only 的複雜 state例如多步表單再引入 zustand。
2. **不要**在元件裡寫死 URL雲端 pair 頁、docs、github— 目前 `pair-view.tsx` / `settings-view.tsx` 的 URL 還是 placeholderPhase 1 應改為從 `agentconfig``wailsjs` bindings 取得。
3. **不要**為了「看起來好看」新增動畫 — spec §1.3 明列「不做動畫秀」。
4. **不要**引入 routing — 三 tab 切換就是切換Wails 環境下 URL 行為奇怪,避免之。
---
本 README 適用於任何接手 `local-agent/frontend/` 的工程師。有疑問請先讀 spec 與 TDD不清楚的決策請去查 ADR不確定的文案請去查 i18n 字典。

View File

@ -0,0 +1,23 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {}
}

View File

@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// 覆寫 eslint-config-next 預設 ignore加入 .next 各子目錄與測試產物
globalIgnores([
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
"coverage/**",
]),
]);
export default eslintConfig;

View File

@ -0,0 +1,34 @@
import type { NextConfig } from "next";
/**
* visionA Agent Next.js
*
* Wails macOS / Windows / Linux
* (static export)
*
* - output: "export"
* Next.js HTML / JS / CSS `out/`
* Wails build `assetdir: "./frontend/out"` `go:embed`
* TDD §2.2§3 ADRvisionA Agent Next.js + output: 'export'
*
* - trailingSlash: true
* route `index.html` Wails WebView sub-route 404
* local-tool local-tool Wails
*
* - images.unoptimized: true
* `output: "export"` next/image server-side optimizer
* build TDD §3.2
*
* 沿 visionA-frontend `output: "standalone"`
* Next.js server bundle Vercel / Docker Wails Node server
*/
const nextConfig: NextConfig = {
output: "export",
trailingSlash: true,
reactStrictMode: true,
images: {
unoptimized: true,
},
};
export default nextConfig;

View File

@ -0,0 +1,42 @@
{
"name": "visiona-agent-frontend",
"version": "0.1.0",
"private": true,
"description": "visionA Agent — Wails 桌面應用的前端Next.js static exportAF1 基礎層)",
"scripts": {
"dev": "next dev",
"build": "next build",
"lint": "eslint",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.575.0",
"next": "16.1.6",
"next-themes": "^0.4.6",
"radix-ui": "^1.4.3",
"react": "19.2.3",
"react-dom": "19.2.3",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tw-animate-css": "^1.4.0",
"zustand": "^5.0.11"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"@vitejs/plugin-react": "^5.1.4",
"eslint": "^9",
"eslint-config-next": "16.1.6",
"jsdom": "^28.1.0",
"tailwindcss": "^4",
"typescript": "^5",
"vitest": "^4.0.18"
}
}

6978
local-agent/frontend/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

View File

@ -0,0 +1,239 @@
/*
* visionA Cloud 全域樣式 / Design Tokens
*
* 本檔完整沿用 local-tool/frontend/src/app/globals.css Tokensshadcn + Tailwind 4
* 確保 F6 任務直接搬頁面時完全相容
*
* 架構分層參考 .autoflow/03-design/design-tokens.md
* 1. @theme inline CSS 變數映射到 Tailwind 4 的語意 utilitybg-background / text-foreground ...
* 2. :root Light Theme raw tokensshadcn 命名oklch 色彩空間
* 3. .dark Dark Theme 對應 tokens next-themes 透過 <html class="dark"> 切換
* 4. 狀態色 裝置狀態色--status-*為雲端版新增補齊 design-tokens.md §1.3
* 5. @layer base body / 元素預設border / outline / background / foreground
*
* local-tool 的差異
* - 保留 `@import "tw-animate-css"`F3 安裝提供 Dialog / Popover / DropdownMenu 等元件
* 所依賴的 `data-[state=open]:animate-in` animation utility
* - 移除 `@import "shadcn/tailwind.css"` shadcn CLI 的輔助樣式不需要
* - 移除 driver.js 主題覆寫 該元件屬 local-tool 專用功能雲端版不一定會搬
* - 新增 `--status-*` 裝置狀態色 tokens
*/
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
@theme inline {
/* --- 核心色彩映射 --- */
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
/* --- 圖表色 --- */
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
/* --- Sidebar --- */
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
/* --- 裝置狀態色雲端版新增---
* 補齊 design-tokens.md §1.3local-tool 原本散落在 device-status.tsx Tailwind 原生色
* 雲端版將其 token 方便未來視覺迭代與 Dark Mode 調整 */
--color-status-online: var(--status-online);
--color-status-offline: var(--status-offline);
--color-status-reconnecting: var(--status-reconnecting);
--color-status-error: var(--status-error);
--color-status-idle: var(--status-idle);
/* --- 警示warning tokensF7 新增---
* 用於離線 banner雛形 bannerPairing Token 倒數剩餘 10 分鐘等
* --destructive系做區分語意是提醒待處理而非致命錯誤
* subtle = 背景foreground = 對比文字base = icon / border 的強烈色
*/
--color-warning: var(--warning);
--color-warning-foreground: var(--warning-foreground);
--color-warning-subtle: var(--warning-subtle);
/* --- 字型 ---
* font-sans / font-mono 交由 next/font layout.tsx 注入 CSS 變數
* 此處只映射 Tailwind utility實際值由 @font-face / next/font 決定 */
--font-sans: var(--font-geist-sans, ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif);
--font-mono: var(--font-geist-mono, ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace);
/* --- 圓角shadcn 階梯式) --- */
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--radius-2xl: calc(var(--radius) + 8px);
--radius-3xl: calc(var(--radius) + 12px);
--radius-4xl: calc(var(--radius) + 16px);
}
/* ============================================
* Light Theme 預設
* ============================================ */
:root {
--radius: 0.625rem;
/* 核心表面 */
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
/* 品牌與互動 */
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
/* 邊框 / 輸入 / Focus */
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
/* 圖表 */
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
/* Sidebar */
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
/* 裝置狀態色 Light對齊 design-tokens.md §1.3 / §3.7
* online = green-500 : 裝置在線
* offline = gray-400 : 裝置掉線含上次心跳時間
* reconnecting = yellow-400 : 連線中 / 重連中可搭配 animate-pulse
* error = red-500 : 錯誤
* idle = gray-300 : 已偵測但未連線 */
--status-online: oklch(0.696 0.17 162.48);
--status-offline: oklch(0.708 0 0);
--status-reconnecting: oklch(0.852 0.17 91.31);
--status-error: oklch(0.637 0.237 25.33);
--status-idle: oklch(0.82 0 0);
/* 警示系Light — 接近 Tailwind amber-500/-900/-50 的等價 oklch */
--warning: oklch(0.704 0.155 80);
--warning-foreground: oklch(0.32 0.08 70);
--warning-subtle: oklch(0.97 0.04 85);
}
/* ============================================
* Dark Theme next-themes <html> 加上 .dark class 切換
* ============================================ */
.dark {
/* 核心表面 */
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
/* 品牌與互動 */
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
/* 邊框 / 輸入 / Focus */
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
/* 圖表 */
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
/* Sidebar */
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
/* 裝置狀態色 — Dark維持辨識度略微亮化以對應低對比背景 */
--status-online: oklch(0.72 0.17 162.48);
--status-offline: oklch(0.556 0 0);
--status-reconnecting: oklch(0.84 0.14 91);
--status-error: oklch(0.7 0.2 22);
--status-idle: oklch(0.4 0 0);
/* 警示系Dark — 背景轉深、前景轉淺,維持對比 */
--warning: oklch(0.78 0.15 82);
--warning-foreground: oklch(0.95 0.04 85);
--warning-subtle: oklch(0.3 0.06 75 / 30%);
}
/* ============================================
* Base Layer 元素預設繼承 Design Tokens
* ============================================ */
@layer base {
* {
@apply border-border outline-ring/50;
}
html,
body {
height: 100%;
}
body {
/* 使用 Tailwind utility + CSS 變數;不再寫死 system-ui修正 F1 review Minor #1。 */
@apply bg-background text-foreground font-sans antialiased;
}
}

View File

@ -0,0 +1,64 @@
import type { Metadata } from "next";
import "./globals.css";
import { ThemeProvider } from "@/components/theme-provider";
import { Toaster } from "@/components/ui/sonner";
import { TooltipProvider } from "@/components/ui/tooltip";
import { LocaleProvider } from "@/lib/i18n/context";
import { LocaleSync } from "@/lib/i18n/sync";
/**
* Root Layout visionA Agent
*
* AF1 Provider
* - LocaleProvider + LocaleSync i18n <html lang>
* - ThemeProvider Light / Dark / System next-themes
* - TooltipProvider Tooltip contextdelayDuration=0 Shadcn
* - Toaster Sonner toast portalplaced outside main Dialog
*
*
* - AF2 Tab navigation LayoutHeader / TabBar children
* - AF3-AF5 viewStatusView / PairView / SettingsView
* - AF6 Wails bindings hooks
* - AF7 agent i18n key + README
*
* visionA-frontend layout AF1
* - AppShell PrototypeBanner / Sidebar / Header / Breadcrumbagent tab
* - StoreHydrationagent zustand storeAF3+
*/
export const metadata: Metadata = {
title: "visionA Agent",
description:
"本機桌面代理local agent / tunnel bridge讓 Kneron 邊緣裝置安全連上 visionA 雲端。",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
// suppressHydrationWarningnext-themes 與 LocaleSync 於 client mount 後才套用使用者偏好,
// 此 hint 關閉 React 對 <html> class/lang 首次 render 差異的警告。
<html lang="zh-Hant" suppressHydrationWarning>
{/*
body globals.css `@layer base body`bg-background / text-foreground / font-sans / antialiased
`min-h-dvh` globals.css `height: 100%`滿
*/}
<body className="min-h-dvh">
<LocaleProvider>
<LocaleSync />
<ThemeProvider>
{/* TooltipProvider delayDuration=0Wails
hover Tooltip */}
<TooltipProvider delayDuration={0}>
{children}
{/* Sonner Toast Portal — 置於 children 之外以覆蓋 Dialog / Sheet */}
<Toaster richColors closeButton position="top-right" />
</TooltipProvider>
</ThemeProvider>
</LocaleProvider>
</body>
</html>
);
}

View File

@ -0,0 +1,74 @@
"use client";
/**
* AgentApp visionA Agent SPA tab in-place
*
* TDD §3 spec §3
* - 使 Next.js routing tab / / Radix Tabs
* client state utility
* - spec §3.2
*
* AF3-AF5
* - `defaultValue`uncontrolled `value`/`onValueChange`controlled
* StatusView / PairView CustomEvent `agent:switch-tab` tab
* pair-view status status-view CTA pair
* - `agent:switch-tab` CustomEvent stateview event page
*
* `page.tsx`Next.js App Router `AgentApp`
*/
import { useEffect, useState } from "react";
import { AppShell } from "@/components/layout/app-shell";
import { TabNav, type AgentTabValue } from "@/components/layout/tab-nav";
import { Tabs, TabsContent } from "@/components/ui/tabs";
import { PairView } from "@/views/pair-view";
import { SettingsView } from "@/views/settings-view";
import { AGENT_SWITCH_TAB_EVENT, StatusView } from "@/views/status-view";
export default function AgentApp() {
const [tab, setTab] = useState<AgentTabValue>("status");
useEffect(() => {
// 監聽 view 發出的程式化切 tab 請求pair-view 配對成功後 → 切 status
function handleSwitch(e: Event) {
const detail = (e as CustomEvent<{ value: string }>).detail;
if (!detail?.value) return;
// 收斂成 AgentTabValue避免外部事件傳入未知字串
if (detail.value === "status" || detail.value === "pair" || detail.value === "settings") {
setTab(detail.value);
}
}
window.addEventListener(AGENT_SWITCH_TAB_EVENT, handleSwitch);
return () => window.removeEventListener(AGENT_SWITCH_TAB_EVENT, handleSwitch);
}, []);
return (
<AppShell>
{/*
Tabs controlled
- value / onValueChange CustomEvent
- padding px-4 Header px-4 top padding Tab
- max-w-2xl + mx-auto spec §4.5 672px
*/}
<Tabs
value={tab}
onValueChange={(v) => setTab(v as AgentTabValue)}
className="mx-auto w-full max-w-2xl px-4 py-3"
data-testid="agent-tabs"
>
<TabNav />
<TabsContent value="status">
<StatusView />
</TabsContent>
<TabsContent value="pair">
<PairView />
</TabsContent>
<TabsContent value="settings">
<SettingsView />
</TabsContent>
</Tabs>
</AppShell>
);
}

View File

@ -0,0 +1,103 @@
"use client";
/**
* InfoCard spec §4.2 B
*
* 4 / Relay URL / / Session Token
* labeledtext-muted-foreground, w-[120px] valuefont-mono
*
* Session Token spec §4.2 B
* - `connection:status.sessionTokenPreview` "vAs_a1b2c3d4 ··· e7f8"
* - ``
* - token
*
*
* - 使 <dl>/<dt>/<dd> SR xxx
* - Session Token dd aria-labelSession token ending in {last4}
* SR
*/
import { useT } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import type { ConnectionSnapshot } from "@/types/agent";
interface InfoCardProps {
snapshot: ConnectionSnapshot;
className?: string;
}
/** 格式化「連線開始」欄位ISO → "2026-04-21 14:30"。 */
function formatConnectedSince(iso: string | undefined): string | null {
if (!iso) return null;
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return null;
const yyyy = d.getFullYear();
const mm = String(d.getMonth() + 1).padStart(2, "0");
const dd = String(d.getDate()).padStart(2, "0");
const hh = String(d.getHours()).padStart(2, "0");
const mi = String(d.getMinutes()).padStart(2, "0");
return `${yyyy}-${mm}-${dd} ${hh}:${mi}`;
}
/** 從遮蔽 token 擷取尾 4 字元(給 aria-label 用)。失敗回 null。 */
function extractLast4(preview: string | undefined): string | null {
if (!preview) return null;
const match = preview.match(/([a-zA-Z0-9]{2,})\s*$/);
return match ? match[1].slice(-4) : null;
}
export function InfoCard({ snapshot, className }: InfoCardProps) {
const t = useT();
const connectedSinceText = formatConnectedSince(snapshot.connectedSince);
const sessionLast4 = extractLast4(snapshot.sessionTokenPreview);
const rows: Array<{
label: string;
value: React.ReactNode;
valueClass?: string;
"aria-label"?: string;
}> = [
{
label: t("status.info.account"),
value: snapshot.account ?? "—",
},
{
label: t("status.info.relayUrl"),
value: snapshot.relayUrl,
},
{
label: t("status.info.connectedAt"),
value: connectedSinceText ?? "—",
},
{
label: t("status.info.sessionToken"),
value: snapshot.sessionTokenPreview ?? "—",
"aria-label": sessionLast4
? t("status.info.sessionToken.aria").replace("{last4}", sessionLast4)
: undefined,
},
];
return (
<dl
data-testid="info-card"
className={cn("grid gap-3 text-sm", className)}
>
{rows.map((row) => (
<div
key={row.label}
className="grid grid-cols-[120px_1fr] items-baseline gap-3"
>
<dt className="text-muted-foreground">{row.label}</dt>
<dd
className={cn("font-mono break-all", row.valueClass)}
aria-label={row["aria-label"]}
>
{row.value}
</dd>
</div>
))}
</dl>
);
}

View File

@ -0,0 +1,107 @@
"use client";
/**
* RecentLog spec §4.2 D
*
* 10 `HH:mm · icon · 事件文字`
* empty hint ScrollArea
*
* Icon spec §4.2 D
* connected / connecting / failed / started / stopped / settings
*
*
* - <section> + aria-labelledby SR
* - 使 <ol> order list
* - icon `aria-hidden`
*/
import {
AlertCircle,
CheckCircle2,
Power,
PowerOff,
RefreshCw,
Settings,
type LucideIcon,
} from "lucide-react";
import { ScrollArea } from "@/components/ui/scroll-area";
import { useT } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import type { LogEntry } from "@/types/agent";
/** ISO 時間字串 → `HH:mm`(本地時區)。解析失敗回 `--:--`。 */
function formatHHmm(iso: string): string {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "--:--";
const h = String(d.getHours()).padStart(2, "0");
const m = String(d.getMinutes()).padStart(2, "0");
return `${h}:${m}`;
}
/** LogEntry.kind → Icon / 前景色。 */
const KIND_META: Record<
LogEntry["kind"],
{ Icon: LucideIcon; colorClass: string }
> = {
connected: { Icon: CheckCircle2, colorClass: "text-status-online" },
connecting: { Icon: RefreshCw, colorClass: "text-status-reconnecting" },
failed: { Icon: AlertCircle, colorClass: "text-destructive" },
started: { Icon: Power, colorClass: "text-muted-foreground" },
stopped: { Icon: PowerOff, colorClass: "text-muted-foreground" },
settings: { Icon: Settings, colorClass: "text-muted-foreground" },
};
interface RecentLogProps {
logs: LogEntry[];
/** 最多顯示筆數(超出部分由呼叫端決定如何提示「查看完整 Log」。 */
max?: number;
className?: string;
}
export function RecentLog({ logs, max = 10, className }: RecentLogProps) {
const t = useT();
const display = logs.slice(0, max);
return (
<section
data-testid="recent-log"
aria-labelledby="recent-log-heading"
className={cn("space-y-2", className)}
>
<h3
id="recent-log-heading"
className="text-sm font-medium"
>
{t("status.log.title")}
</h3>
{display.length === 0 ? (
<p className="text-muted-foreground text-xs">{t("status.log.empty")}</p>
) : (
<ScrollArea className="h-40 rounded-md border">
<ol className="divide-border divide-y">
{display.map((entry, i) => {
const meta = KIND_META[entry.kind];
return (
<li
// ts 可能重複(理論上不會,但保險起見)— 加 index
key={`${entry.ts}-${i}`}
className="flex items-center gap-3 px-3 py-1.5 font-mono text-xs"
>
<span className="text-muted-foreground">
{formatHHmm(entry.ts)}
</span>
<meta.Icon
aria-hidden="true"
className={cn("size-3.5 shrink-0", meta.colorClass)}
/>
<span className="flex-1 truncate">{entry.text}</span>
</li>
);
})}
</ol>
</ScrollArea>
)}
</section>
);
}

View File

@ -0,0 +1,101 @@
/**
* StatusHero AF3
*
* spec §4.2 A
* 1. 5 online / offline / reconnecting / notPaired / error
* 2. `connecting` reconnecting
* 3. reconnecting attemptNo N/5
* 4. error errorMessage
* 5. role / aria-live / aria-label
*
*
* - Tailwind class FAANG
* - animate-pulse / animate-spin CSS jsdom
*/
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { LocaleProvider } from "@/lib/i18n/context";
import type { ConnectionState } from "@/types/agent";
import { StatusHero } from "./status-hero";
function renderHero(props: React.ComponentProps<typeof StatusHero>) {
return render(
<LocaleProvider>
<StatusHero {...props} />
</LocaleProvider>,
);
}
describe("<StatusHero />", () => {
it.each<[ConnectionState, string]>([
["online", "已連線"],
["offline", "離線"],
["reconnecting", "重新連線中…"],
["notPaired", "尚未配對"],
["error", "連線錯誤"],
])("狀態 %s 顯示主標「%s」並寫入 data-state", (state, expected) => {
renderHero({ state });
const hero = screen.getByTestId("status-hero");
// connecting 以外皆 1:1 對映
expect(hero).toHaveAttribute("data-state", state);
expect(hero).toHaveTextContent(expected);
// 無障礙屬性 — role + aria-live狀態變化會被 SR 朗讀)
expect(hero).toHaveAttribute("role", "status");
expect(hero).toHaveAttribute("aria-live", "polite");
});
it("connecting 狀態視為 reconnecting 變體(避免 spec 沒列到的狀態變成空白)", () => {
renderHero({ state: "connecting" });
const hero = screen.getByTestId("status-hero");
expect(hero).toHaveAttribute("data-state", "reconnecting");
expect(hero).toHaveTextContent("重新連線中…");
});
it("reconnecting + attemptNo 顯示「第 N/5 次嘗試」副標", () => {
renderHero({ state: "reconnecting", attemptNo: 3 });
const hero = screen.getByTestId("status-hero");
expect(hero).toHaveTextContent("第 3/5 次嘗試");
});
it("reconnecting 沒帶 attemptNo 時不渲染副標", () => {
renderHero({ state: "reconnecting" });
const hero = screen.getByTestId("status-hero");
expect(hero).not.toHaveTextContent("第");
expect(hero).not.toHaveTextContent("/5");
});
it("error 狀態顯示 errorMessage 副標", () => {
renderHero({ state: "error", errorMessage: "Pairing Token 已撤銷" });
const hero = screen.getByTestId("status-hero");
expect(hero).toHaveTextContent("連線錯誤");
expect(hero).toHaveTextContent("Pairing Token 已撤銷");
});
it("error 沒帶 errorMessage 時僅顯示主標", () => {
renderHero({ state: "error" });
const hero = screen.getByTestId("status-hero");
expect(hero).toHaveTextContent("連線錯誤");
// 僅主標沒有副標spec §4.2 A 規定 error 的副標為 errorMessage無則留空
});
it("aria-label 同時包含主標與副標(避免 SR 分開朗讀)", () => {
renderHero({ state: "reconnecting", attemptNo: 2 });
const hero = screen.getByTestId("status-hero");
// format: "{label}, {subLabel}"
const ariaLabel = hero.getAttribute("aria-label");
expect(ariaLabel).toContain("重新連線中…");
expect(ariaLabel).toContain("第 2/5 次嘗試");
});
it("所有 SVG icon 皆標註 aria-hidden純裝飾", () => {
const { container } = renderHero({ state: "online" });
const svgs = container.querySelectorAll("svg");
expect(svgs.length).toBeGreaterThan(0);
svgs.forEach((svg) => {
expect(svg).toHaveAttribute("aria-hidden", "true");
});
});
});

View File

@ -0,0 +1,183 @@
"use client";
/**
* StatusHero spec §4.2 A
*
* 80px 5 online / offline / reconnecting / notPaired / error
* + icon + WCAG 2.2 AA
*
* ConnectionStatusBadge
* - BadgeHeader pill
* - Heroicon + + / attemptNo
*
*
* - `role="status"` + `aria-live="polite"` SR
* - `aria-label` + SR
* - icon `aria-hidden`
* - reconnecting pulse / spin `motion-safe:` `prefers-reduced-motion`
*/
import {
AlertTriangle,
CheckCircle2,
PowerOff,
RefreshCw,
Unlink,
type LucideIcon,
} from "lucide-react";
import { useT } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import type { ConnectionState } from "@/types/agent";
/**
* ConnectionStateTDD Hero 5 variant
*
*
* connecting reconnecting = =
*
*/
type HeroVariant = "online" | "offline" | "reconnecting" | "notPaired" | "error";
function stateToVariant(state: ConnectionState): HeroVariant {
if (state === "connecting") return "reconnecting";
return state;
}
interface VariantMeta {
i18nKey: string;
Icon: LucideIcon;
/** 圓形背景色Design Token `--status-*`)。 */
dotClass: string;
/** 大圓本身是否 pulsereconnecting 用)。 */
dotAnimate: boolean;
/** Icon 是否旋轉reconnecting 用)。 */
iconSpin: boolean;
/** 文字顏色(大部分用 foregrounderror 用 destructive。 */
labelClass?: string;
}
const VARIANT_META: Record<HeroVariant, VariantMeta> = {
online: {
i18nKey: "status.hero.online",
Icon: CheckCircle2,
dotClass: "bg-status-online",
dotAnimate: false,
iconSpin: false,
},
offline: {
i18nKey: "status.hero.offline",
Icon: PowerOff,
dotClass: "bg-status-offline",
dotAnimate: false,
iconSpin: false,
},
reconnecting: {
i18nKey: "status.hero.reconnecting",
Icon: RefreshCw,
dotClass: "bg-status-reconnecting",
dotAnimate: true,
iconSpin: true,
},
notPaired: {
i18nKey: "status.hero.notPaired",
Icon: Unlink,
dotClass: "bg-status-idle",
dotAnimate: false,
iconSpin: false,
},
error: {
i18nKey: "status.hero.error",
Icon: AlertTriangle,
dotClass: "bg-status-error",
dotAnimate: false,
iconSpin: false,
labelClass: "text-destructive",
},
};
export interface StatusHeroProps {
state: ConnectionState;
/** reconnecting 時顯示「第 {n}/5 次嘗試」。 */
attemptNo?: number;
/** error 時顯示的錯誤訊息(已翻譯)。 */
errorMessage?: string;
className?: string;
}
export function StatusHero({
state,
attemptNo,
errorMessage,
className,
}: StatusHeroProps) {
const t = useT();
const variant = stateToVariant(state);
const meta = VARIANT_META[variant];
const label = t(meta.i18nKey);
/** 次行文字spec §4.2 A 各狀態變體規定)。 */
const subLabel: string | null = (() => {
if (variant === "reconnecting" && typeof attemptNo === "number") {
return t("status.hero.attemptNo").replace("{n}", String(attemptNo));
}
if (variant === "error" && errorMessage) {
return errorMessage;
}
return null;
})();
return (
<div
role="status"
aria-live="polite"
aria-label={subLabel ? `${label}, ${subLabel}` : label}
data-testid="status-hero"
data-state={variant}
className={cn(
"flex flex-col items-center gap-3 py-4",
className,
)}
>
{/* 大圓 — 80px 直徑,含 icon */}
<div
aria-hidden="true"
className={cn(
"flex size-20 items-center justify-center rounded-full text-white shadow-sm",
meta.dotClass,
meta.dotAnimate && "motion-safe:animate-pulse",
)}
>
<meta.Icon
className={cn(
"size-8",
meta.iconSpin && "motion-safe:animate-spin",
)}
/>
</div>
{/* 主標21px+ 次行12px */}
<div className="flex flex-col items-center gap-1 text-center">
<p
className={cn(
"text-xl font-semibold",
meta.labelClass,
)}
>
{label}
</p>
{subLabel && (
<p
className={cn(
"text-xs",
variant === "error"
? "text-destructive"
: "text-muted-foreground",
)}
>
{subLabel}
</p>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,184 @@
/**
* TokenInput AF4
*
* spec §5.2 B / §5.7
* 1. validateToken / sanitizeToken
* 2. hint lineempty / invalid / valid
* 3. aria-invalid data-validity
* 4. /
* 5. Enter valid onSubmit
*
*
* - DOM select on focus jsdom
* - Tailwind data-validity
*/
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { LocaleProvider } from "@/lib/i18n/context";
import {
PAIRING_TOKEN_REGEX,
TokenInput,
sanitizeToken,
validateToken,
} from "./token-input";
/* -------------------------------------------------------------------------- */
/* 純函式測試 */
/* -------------------------------------------------------------------------- */
describe("PAIRING_TOKEN_REGEX", () => {
it("接受 vAc_ 前綴 + 32 字元小寫 hex", () => {
expect(PAIRING_TOKEN_REGEX.test("vAc_a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6")).toBe(
true,
);
});
it("大小寫不敏感", () => {
expect(PAIRING_TOKEN_REGEX.test("VaC_A1B2C3D4E5F6A7B8C9D0E1F2A3B4C5D6")).toBe(
true,
);
});
it("拒絕 32 字元以下", () => {
expect(PAIRING_TOKEN_REGEX.test("vAc_a1b2c3d4")).toBe(false);
});
it("拒絕非 hex 字元", () => {
// 'g' 不是 hex
expect(PAIRING_TOKEN_REGEX.test("vAc_g1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6")).toBe(
false,
);
});
it("拒絕沒有 vAc_ 前綴的字串", () => {
expect(PAIRING_TOKEN_REGEX.test("a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6")).toBe(
false,
);
});
});
describe("sanitizeToken", () => {
it("去除所有空白(空格 / tab / 換行)", () => {
expect(sanitizeToken("vAc_ a1b2 c3d4\t\ne5f6")).toBe("vAc_a1b2c3d4e5f6");
});
it("無空白時不改變內容", () => {
const raw = "vAc_a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6";
expect(sanitizeToken(raw)).toBe(raw);
});
});
describe("validateToken", () => {
it("空字串為 empty", () => {
expect(validateToken("")).toBe("empty");
expect(validateToken(" ")).toBe("empty");
});
it("格式正確為 valid", () => {
expect(validateToken("vAc_a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6")).toBe("valid");
});
it("格式錯誤為 invalid", () => {
expect(validateToken("vAc_toolshort")).toBe("invalid");
expect(validateToken("randomstring")).toBe("invalid");
});
});
/* -------------------------------------------------------------------------- */
/* 元件整合測試 */
/* -------------------------------------------------------------------------- */
const VALID_TOKEN = "vAc_a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6";
function renderInput(props?: Partial<React.ComponentProps<typeof TokenInput>>) {
const onChange = props?.onChange ?? vi.fn();
const onSubmit = props?.onSubmit ?? vi.fn();
const utils = render(
<LocaleProvider>
<TokenInput value={props?.value ?? ""} onChange={onChange} onSubmit={onSubmit} />
</LocaleProvider>,
);
return { ...utils, onChange, onSubmit };
}
describe("<TokenInput />", () => {
it("預設(空值)顯示 empty hint 並 data-validity=empty", () => {
renderInput();
const input = screen.getByTestId("token-input");
expect(input).toHaveAttribute("data-validity", "empty");
expect(input).toHaveAttribute("aria-invalid", "false");
expect(screen.getByText("請貼上 token")).toBeInTheDocument();
});
it("輸入無效 token 顯示錯誤 hint + aria-invalid=true", () => {
renderInput({ value: "not-a-token" });
const input = screen.getByTestId("token-input");
expect(input).toHaveAttribute("data-validity", "invalid");
expect(input).toHaveAttribute("aria-invalid", "true");
expect(screen.getByText(/格式不正確/)).toBeInTheDocument();
});
it("輸入合法 token 顯示 valid hint + aria-invalid=false", () => {
renderInput({ value: VALID_TOKEN });
const input = screen.getByTestId("token-input");
expect(input).toHaveAttribute("data-validity", "valid");
expect(input).toHaveAttribute("aria-invalid", "false");
expect(screen.getByText(/格式正確/)).toBeInTheDocument();
});
it("onChange 收到的值會自動去除空白", () => {
const onChange = vi.fn();
renderInput({ onChange });
const input = screen.getByTestId("token-input");
// 模擬貼上含空格的 token雲端版顯示可能含空格
fireEvent.change(input, {
target: { value: "vAc_ a1b2 c3d4 e5f6 a7b8 c9d0 e1f2 a3b4 c5d6" },
});
// sanitize 後應無空格
expect(onChange).toHaveBeenCalledWith("vAc_a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6");
});
it("Enter 鍵於 valid 狀態觸發 onSubmit", () => {
const onSubmit = vi.fn();
renderInput({ value: VALID_TOKEN, onSubmit });
const input = screen.getByTestId("token-input");
fireEvent.keyDown(input, { key: "Enter" });
expect(onSubmit).toHaveBeenCalledTimes(1);
});
it("Enter 鍵於 invalid 狀態不觸發 onSubmit", () => {
const onSubmit = vi.fn();
renderInput({ value: "nope", onSubmit });
const input = screen.getByTestId("token-input");
fireEvent.keyDown(input, { key: "Enter" });
expect(onSubmit).not.toHaveBeenCalled();
});
it("Enter 鍵於 empty 狀態不觸發 onSubmit", () => {
const onSubmit = vi.fn();
renderInput({ value: "", onSubmit });
const input = screen.getByTestId("token-input");
fireEvent.keyDown(input, { key: "Enter" });
expect(onSubmit).not.toHaveBeenCalled();
});
it("disabled 時 input 為 disabled 狀態", () => {
renderInput({ value: VALID_TOKEN });
// 這個 case 先覆蓋預設 render 得到的 enabled 狀態,下方另開啟一次 disabled 版
render(
<LocaleProvider>
<TokenInput
value={VALID_TOKEN}
onChange={vi.fn()}
disabled
/>
</LocaleProvider>,
);
const inputs = screen.getAllByTestId("token-input");
// 最後 mount 的才是 disabled 版
expect(inputs[inputs.length - 1]).toBeDisabled();
});
});

View File

@ -0,0 +1,168 @@
"use client";
/**
* TokenInput Pairing Token spec §5.2 B
*
*
* 1. trim / `vAc_a1b2 c3d4 ...`
* 2. regex `/^vAc_[a-f0-9]{32}$/i`
* 3. hint lineempty / invalid / valid
* 4. Focus 便
*
*
* - + trim + pair-view
* - token
*
*
* - <label htmlFor> <input id>
* - `aria-invalid` + `aria-describedby` hint lineSR
* - valid describedby `role="status"` alert
*/
import * as React from "react";
import { AlertCircle, CheckCircle2 } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useT } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
/**
* Pairing Token regex
*
* spec §5.7`vAc_` + 32 hex
* token-input.test.tsx pair-view regex
*/
export const PAIRING_TOKEN_REGEX = /^vAc_[a-f0-9]{32}$/i;
/** Token 驗證三態。 */
export type TokenValidity = "empty" | "invalid" | "valid";
/** 判斷目前的 token 是否合法。 */
export function validateToken(raw: string): TokenValidity {
const trimmed = raw.trim();
if (trimmed.length === 0) return "empty";
return PAIRING_TOKEN_REGEX.test(trimmed) ? "valid" : "invalid";
}
/** 清洗 — 去掉所有空白與換行(使用者可能從雲端網頁複製含空格的字串)。 */
export function sanitizeToken(raw: string): string {
return raw.replace(/\s+/g, "");
}
export interface TokenInputProps {
id?: string;
value: string;
onChange: (value: string) => void;
/** Enter 鍵送出(若目前是 valid 才觸發)。 */
onSubmit?: () => void;
/** 整個表單送出中時 disable input。 */
disabled?: boolean;
autoFocus?: boolean;
}
export function TokenInput({
id = "pairing-token",
value,
onChange,
onSubmit,
disabled = false,
autoFocus = false,
}: TokenInputProps) {
const t = useT();
const validity = validateToken(value);
const hintId = `${id}-hint`;
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
// 貼上時含空白 / 換行會被自動清掉人工輸入仍然有效sanitize 對純字母數字 no-op
onChange(sanitizeToken(e.target.value));
}
function handleFocus(e: React.FocusEvent<HTMLInputElement>) {
// spec §5.5Focus 時自動全選,方便覆蓋貼上
e.currentTarget.select();
}
function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
if (e.key === "Enter" && validity === "valid" && onSubmit) {
e.preventDefault();
onSubmit();
}
}
/** 根據 validity 計算 Input 的視覺邊框色。 */
const inputStateClass =
validity === "invalid"
? "border-destructive focus-visible:ring-destructive/30"
: validity === "valid"
? "border-status-online focus-visible:ring-status-online/30"
: "";
return (
<div className="space-y-2">
<Label htmlFor={id}>{t("pair.input.label")}</Label>
<Input
id={id}
type="text"
value={value}
onChange={handleChange}
onFocus={handleFocus}
onKeyDown={handleKeyDown}
disabled={disabled}
// 使用 ref 方式 autoFocus 會在 SSR 時不一致;用 input 原生屬性即可(只在 client 端 mount 後觸發)
autoFocus={autoFocus}
spellCheck={false}
autoCapitalize="none"
autoComplete="off"
placeholder={t("pair.input.placeholder")}
aria-invalid={validity === "invalid"}
aria-describedby={hintId}
data-testid="token-input"
data-validity={validity}
className={cn("font-mono text-sm", inputStateClass)}
/>
{/* Hint line — 三態文字 + icon */}
<TokenHint id={hintId} validity={validity} />
</div>
);
}
/** 三態 Hint line — 獨立元件讓無障礙屬性更乾淨。 */
function TokenHint({ id, validity }: { id: string; validity: TokenValidity }) {
const t = useT();
if (validity === "empty") {
return (
<p id={id} className="text-muted-foreground flex items-center gap-1.5 text-xs">
{t("pair.input.hintEmpty")}
</p>
);
}
if (validity === "invalid") {
return (
<p
id={id}
// role="alert" 會打擾 SR這裡 token 格式是輸入過程中即時驗證的結果,
// 改用 aria-live="polite" 讓下一個閒置時朗讀
aria-live="polite"
className="text-destructive flex items-center gap-1.5 text-xs"
>
<AlertCircle aria-hidden="true" className="size-3.5 shrink-0" />
<span>{t("pair.input.errorFormat")}</span>
</p>
);
}
// valid
return (
<p
id={id}
aria-live="polite"
className="text-status-online flex items-center gap-1.5 text-xs"
>
<CheckCircle2 aria-hidden="true" className="size-3.5 shrink-0" />
<span>{t("pair.input.valid")}</span>
</p>
);
}

View File

@ -0,0 +1,79 @@
/**
* AppShell AF2
*
*
* 1. AppShell children
* 2. Header `banner` role
* 3. main children
* 4. ConnectionStatusBadge "not-paired"
*
* / Tailwind class FAANG 使
*/
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { TooltipProvider } from "@/components/ui/tooltip";
import { LocaleProvider } from "@/lib/i18n/context";
import { AppShell } from "./app-shell";
/**
* wrapper
*
* Providers
* - LocaleProvider useT()
* - TooltipProvider Header LocaleToggle / ThemeToggle 使 Tooltip
* Radix throw root layout TooltipProvider
*
* ThemeProvider
* - useTheme()next-themes Provider fallback undefined
* throwThemeToggle `resolvedTheme === "dark"` false
* Sun icon
*/
function renderWithProviders(ui: React.ReactNode) {
return render(
<LocaleProvider>
<TooltipProvider>{ui}</TooltipProvider>
</LocaleProvider>,
);
}
describe("<AppShell />", () => {
it("渲染 children 到 main 容器內", () => {
renderWithProviders(
<AppShell>
<p data-testid="child">Hello Agent</p>
</AppShell>,
);
const child = screen.getByTestId("child");
expect(child).toBeInTheDocument();
// main 應為 children 的 ancestor
const main = screen.getByRole("main");
expect(main).toContainElement(child);
});
it("顯示 Headerbanner role與產品名", () => {
renderWithProviders(
<AppShell>
<div />
</AppShell>,
);
// <header> 隱含 banner role
expect(screen.getByRole("banner")).toBeInTheDocument();
// 產品名 "visionA Agent" 來自 app.title
expect(screen.getByText("visionA Agent")).toBeInTheDocument();
});
it("顯示預設連線狀態為 not-paired", () => {
renderWithProviders(
<AppShell>
<div />
</AppShell>,
);
const badge = screen.getByTestId("connection-status-badge");
expect(badge).toHaveAttribute("data-status", "not-paired");
// 繁中預設 → 「尚未配對」
expect(badge).toHaveTextContent("尚未配對");
});
});

View File

@ -0,0 +1,50 @@
"use client";
/**
* AppShell visionA Agent
*
*
* - .autoflow/03-design/visiona-agent-spec.md §3.1 ASCII
*
*
*
* Header (h-14) bg-sidebar + border-b
*
* <main>
* {children} page.tsx <Tabs>
*
*
*
* visionA-frontend AppShell
* - PrototypeBannerspec §3.3
* - Sidebarspec §3.3
* - NetworkErrorBanneragent ConnectionStatusBadge
*
* Layout
* - `flex min-h-dvh flex-col` Header + main
* - main `flex-1 overflow-y-auto` 滿
* - `max-w-2xl mx-auto`spec §3.4 resizable
* `max-w-2xl` childrenpage.tsx <Tabs>
* AppShell
*/
import { Header } from "./header";
interface AppShellProps {
children: React.ReactNode;
}
export function AppShell({ children }: AppShellProps) {
return (
<div className="flex min-h-dvh flex-col" data-testid="agent-app-shell">
<Header />
<main
// min-w-0 避免內容過長撐爆 flex
// overflow-y-auto 讓 main 內部可捲動Header 保持固定在視覺頂部
className="flex-1 min-w-0 overflow-y-auto"
>
{children}
</main>
</div>
);
}

View File

@ -0,0 +1,67 @@
/**
* ConnectionStatusBadge AF2
*
*
* 1. `data-status`
* 2. role="status" / aria-live / aria-label
* 3. Icon + +
*
*
* - Tailwind class FAANG
* - i18n localei18n.test.ts key
*/
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { LocaleProvider } from "@/lib/i18n/context";
import {
ConnectionStatusBadge,
type ConnectionStatus,
} from "./connection-status-badge";
function renderBadge(status?: ConnectionStatus) {
return render(
<LocaleProvider>
<ConnectionStatusBadge status={status} />
</LocaleProvider>,
);
}
describe("<ConnectionStatusBadge />", () => {
it("預設為 not-paired 並顯示「尚未配對」", () => {
renderBadge();
const badge = screen.getByTestId("connection-status-badge");
expect(badge).toHaveAttribute("data-status", "not-paired");
expect(badge).toHaveTextContent("尚未配對");
});
it.each<[ConnectionStatus, string]>([
["online", "已連線"],
["offline", "離線"],
["reconnecting", "重新連線中"],
["not-paired", "尚未配對"],
["error", "連線錯誤"],
])("狀態 %s 顯示文字「%s」並同步寫入 data-status", (status, expected) => {
renderBadge(status);
const badge = screen.getByTestId("connection-status-badge");
expect(badge).toHaveAttribute("data-status", status);
expect(badge).toHaveTextContent(expected);
// role="status" + aria-live 讓 SR 朗讀狀態變化
expect(badge).toHaveAttribute("role", "status");
expect(badge).toHaveAttribute("aria-live", "polite");
// aria-label 與可見文字一致(避免 SR 朗讀重複)
expect(badge).toHaveAttribute("aria-label", expected);
});
it("SVG icon 標註 aria-hidden純裝飾語意由文字傳達", () => {
const { container } = renderBadge("online");
// badge 內部的所有 svglucide icon都應 aria-hidden
const svgs = container.querySelectorAll("svg");
expect(svgs.length).toBeGreaterThan(0);
svgs.forEach((svg) => {
expect(svg).toHaveAttribute("aria-hidden", "true");
});
});
});

View File

@ -0,0 +1,139 @@
"use client";
/**
* ConnectionStatusBadge Header
*
*
* - .autoflow/03-design/visiona-agent-spec.md §2.2
* - .autoflow/03-design/visiona-agent-spec.md §3.2Header badge
*
* `--status-*` token沿 visionA-frontend F2
* - online bg-status-online + CheckCircle2
* - offline bg-status-offline + PowerOff
* - reconnecting bg-status-reconnecting + RefreshCwanimate-spin
* - notPaired bg-status-idle + Unlink
* - error bg-status-error + AlertTriangle
*
*
* - + icon + WCAG 2.2 AA
* - `role="status" aria-live="polite"`
* - icon `aria-hidden="true"`
* - `prefers-reduced-motion`reconnecting spin/pulse
*
* AF2
* - `status` prop "not-paired"AF6 Wails `GetConnectionStatus()` binding
* - mock AF3 StatusView Layout
*/
import * as React from "react";
import { AlertTriangle, CheckCircle2, PowerOff, RefreshCw, Unlink } from "lucide-react";
import { useT } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
/**
* visiona-agent-spec §2.2
*
* 沿 spec camelCasenotPairedURL / data kebab-casenot-paired
*/
export type ConnectionStatus =
| "online"
| "offline"
| "reconnecting"
| "not-paired"
| "error";
interface ConnectionStatusBadgeProps {
/** 目前連線狀態AF2 階段呼叫端不傳,使用預設 "not-paired"。AF6 會從 Wails binding 灌入。 */
status?: ConnectionStatus;
className?: string;
}
/** 狀態 → i18n key / icon / dot 顏色 class 的查表 */
const STATUS_META: Record<
ConnectionStatus,
{
i18nKey: string;
/** Lucide icon 元件。固定 size-3.514px與 badge 文字對齊。 */
Icon: typeof CheckCircle2;
/** 圓點背景色(對齊 globals.css 的 --color-status-* tokens。 */
dotClass: string;
/** dot 是否要有動畫reconnecting 的 pulse。 */
animate?: boolean;
/** icon 是否要有動畫reconnecting 的 spin。 */
iconAnimate?: boolean;
}
> = {
online: {
i18nKey: "connection.online",
Icon: CheckCircle2,
dotClass: "bg-status-online",
},
offline: {
i18nKey: "connection.offline",
Icon: PowerOff,
dotClass: "bg-status-offline",
},
reconnecting: {
i18nKey: "connection.reconnecting",
Icon: RefreshCw,
// motion-safe 只在沒開「減少動態效果」時才 pulse / spinWCAG 2.3.3
dotClass: "bg-status-reconnecting motion-safe:animate-pulse",
animate: true,
iconAnimate: true,
},
"not-paired": {
i18nKey: "connection.notPaired",
Icon: Unlink,
// notPaired 視覺上使用「較淡的中性灰」,避免與 offline 混淆
dotClass: "bg-status-idle",
},
error: {
i18nKey: "connection.error",
Icon: AlertTriangle,
dotClass: "bg-status-error",
},
};
export function ConnectionStatusBadge({
// AF2 階段mock 寫死 not-paired符合「首次啟動尚未配對」的真實預設
// AF6 會改由 Wails binding + zustand store 注入 props移除此預設值。
status = "not-paired",
className,
}: ConnectionStatusBadgeProps) {
const t = useT();
const meta = STATUS_META[status];
const label = t(meta.i18nKey);
return (
<span
role="status"
aria-live="polite"
aria-label={label}
data-testid="connection-status-badge"
data-status={status}
className={cn(
// 小型 pill圓點 + icon + 文字
"inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium",
"border-border bg-card text-foreground/80",
// 圓點高度與 text 對齊,避免視覺錯位
className,
)}
>
{/* 圓點 — 只靠顏色辨識的輔助視覺icon + 文字才是主要語意 */}
<span
aria-hidden="true"
className={cn("inline-block size-2 shrink-0 rounded-full", meta.dotClass)}
/>
<meta.Icon
aria-hidden="true"
className={cn(
"size-3.5 shrink-0",
// reconnecting 的 icon 旋轉;其餘狀態為靜態
meta.iconAnimate && "motion-safe:animate-spin",
)}
/>
<span>{label}</span>
</span>
);
}

View File

@ -0,0 +1,150 @@
"use client";
/**
* Header visionA Agent
*
*
* - .autoflow/03-design/visiona-agent-spec.md §3.1 / §3.2Header h-14
* - §3.2 ConnectionStatusBadge
*
* visionA-frontend Header
* - Breadcrumbagent routing tab in-place
* - UserMenuagent Web
* - NotificationCenteragent
* - Logo + "visionA Agent"
* - ConnectionStatusBadge + LocaleToggle + ThemeToggle
*
* Tokens沿
* - h-1456px visionA-frontend
* - bg-sidebar + border-b border-sidebar-borderspec §3.2
* - Logo 沿 vA primary token
*
*
* - 使 <header> + `role="banner"` SR
* - Logo span agent home routing aria-label
* - aria-label + focus-visible ring
*/
import { Globe, Moon, Sun } from "lucide-react";
import { useTheme } from "next-themes";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useLocale, useT } from "@/lib/i18n/context";
import { SUPPORTED_LOCALES } from "@/lib/i18n/types";
import { cn } from "@/lib/utils";
import { ConnectionStatusBadge } from "./connection-status-badge";
export function Header() {
const t = useT();
return (
<header
data-slot="header"
className={cn(
// 對齊 visionA-frontend h-14但用 sidebar tokenspec 指定 bg-sidebar
"flex h-14 shrink-0 items-center gap-3 border-b px-4",
"bg-sidebar text-sidebar-foreground border-sidebar-border",
)}
>
{/* 左側Logo + 產品名agent 無 home route故用 div 而非 Link */}
<div className="flex items-center gap-2" aria-label={t("app.title")}>
<span
aria-hidden="true"
className="bg-primary text-primary-foreground grid size-7 place-items-center rounded-md text-xs font-bold"
>
vA
</span>
<span className="text-sm font-semibold">{t("app.title")}</span>
</div>
{/* 中間彈性空白 */}
<div className="flex-1" />
{/* 右側:連線狀態 + Locale + Theme */}
<div className="flex items-center gap-2">
<ConnectionStatusBadge />
<LocaleToggle />
<ThemeToggle />
</div>
</header>
);
}
/* -------------------------------------------------------------------------- */
/* LocaleToggle — cycle 於 SUPPORTED_LOCALES 間zh-Hant ↔ en */
/* -------------------------------------------------------------------------- */
function LocaleToggle() {
const { locale, setLocale } = useLocale();
const t = useT();
function handleToggle() {
// 支援未來擴充到 2 種以上 locale找出目前位置的下一個
const idx = SUPPORTED_LOCALES.indexOf(locale);
const next = SUPPORTED_LOCALES[(idx + 1) % SUPPORTED_LOCALES.length];
setLocale(next);
}
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
aria-label={t("header.toggleLocale")}
onClick={handleToggle}
data-testid="locale-toggle"
>
<Globe className="size-4" aria-hidden="true" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">
{t("header.toggleLocale")} · {locale}
</TooltipContent>
</Tooltip>
);
}
/* -------------------------------------------------------------------------- */
/* ThemeToggle — cycle 於 dark / lightsystem 由 ThemeProvider 預設管理) */
/* -------------------------------------------------------------------------- */
function ThemeToggle() {
const { theme, setTheme, resolvedTheme } = useTheme();
const t = useT();
function handleToggle() {
// 以 resolvedTheme實際視覺色判定目前深淺切到相反色
// 欲回 system 可透過未來的設定頁
if (resolvedTheme === "dark") {
setTheme("light");
} else {
setTheme("dark");
}
}
const isDark = theme === "dark" || resolvedTheme === "dark";
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
aria-label={t("header.toggleTheme")}
onClick={handleToggle}
data-testid="theme-toggle"
>
{isDark ? (
<Moon className="size-4" aria-hidden="true" />
) : (
<Sun className="size-4" aria-hidden="true" />
)}
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">{t("header.toggleTheme")}</TooltipContent>
</Tooltip>
);
}

View File

@ -0,0 +1,66 @@
"use client";
/**
* TabNav 3 Tab / /
*
*
* - .autoflow/03-design/visiona-agent-spec.md §3.2Tab
*
*
* - 使 Radix Tabs components/ui/tabs.tsx shadcn
* - **** Next.js routing tab client state URL
* agent route change TDD §3
* - AppShell TabNav <Tabs> TabsList
*
* (1) Radix Tabs `value`/`onValueChange` state
* (2) TabsContent TabsList `group-data-[variant]`
*
*
* - Radix Tabs `role="tablist"` / `role="tab"` / `role="tabpanel"`
* `value` `data-testid` E2E
*
* i18n `useT()` i18n.test.ts key
*/
import { TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useT } from "@/lib/i18n/context";
/** Agent 的 3 個 tab 值。與 `src/views/*-view.tsx` 對映。 */
export type AgentTabValue = "status" | "pair" | "settings";
/** 三個 tab 的靜態設定(順序 = 視覺順序spec §3.2 指定)。 */
export const AGENT_TABS: ReadonlyArray<{ value: AgentTabValue; i18nKey: string }> = [
{ value: "status", i18nKey: "nav.status" },
{ value: "pair", i18nKey: "nav.pair" },
{ value: "settings", i18nKey: "nav.settings" },
] as const;
/**
* TabNav TabsList 3 TabsTrigger <Tabs> 使
*
* 使
* ```tsx
* <Tabs defaultValue="status">
* <TabNav />
* <TabsContent value="status">...</TabsContent>
* ...
* </Tabs>
* ```
*/
export function TabNav() {
const t = useT();
return (
<TabsList data-testid="agent-tab-list" aria-label={t("nav.status")} className="w-full">
{AGENT_TABS.map(({ value, i18nKey }) => (
<TabsTrigger
key={value}
value={value}
data-testid={`agent-tab-trigger-${value}`}
>
{t(i18nKey)}
</TabsTrigger>
))}
</TabsList>
);
}

View File

@ -0,0 +1,29 @@
"use client";
/**
* ThemeProvider visionA Cloud
*
* next-themes ThemeProvider
* - attribute="class" <html> `.dark` globals.css `.dark { ... }`
* - defaultTheme="system"
* - enableSystem system
* - disableTransitionOnChange transition
*
* 使 root layout {children} F4 ThemeToggle UI
*/
import { ThemeProvider as NextThemesProvider, type ThemeProviderProps } from "next-themes";
export function ThemeProvider({ children, ...rest }: ThemeProviderProps) {
return (
<NextThemesProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
{...rest}
>
{children}
</NextThemesProvider>
);
}

View File

@ -0,0 +1,207 @@
"use client";
import * as React from "react";
import { AlertDialog as AlertDialogPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
/**
* AlertDialog Shadcn Radix AlertDialog
*
* local-tool/frontend/src/components/ui/alert-dialog.tsx100% 沿
*
* Dialog
* - ESC 使 Action Cancel
* - token
*
* tw-animate-css
*/
function AlertDialog({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />;
}
function AlertDialogTrigger({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
return (
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
);
}
function AlertDialogPortal({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
return (
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
);
}
function AlertDialogOverlay({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
return (
<AlertDialogPrimitive.Overlay
data-slot="alert-dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className,
)}
{...props}
/>
);
}
function AlertDialogContent({
className,
size = "default",
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Content> & {
size?: "default" | "sm";
}) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
data-slot="alert-dialog-content"
data-size={size}
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 group/alert-dialog-content fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-lg",
className,
)}
{...props}
/>
</AlertDialogPortal>
);
}
function AlertDialogHeader({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-header"
className={cn(
"grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-6 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]",
className,
)}
{...props}
/>
);
}
function AlertDialogFooter({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end",
className,
)}
{...props}
/>
);
}
function AlertDialogTitle({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn(
"text-lg font-semibold sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2",
className,
)}
{...props}
/>
);
}
function AlertDialogDescription({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
data-slot="alert-dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
function AlertDialogMedia({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-media"
className={cn(
"bg-muted mb-2 inline-flex size-16 items-center justify-center rounded-md sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-8",
className,
)}
{...props}
/>
);
}
function AlertDialogAction({
className,
variant = "default",
size = "default",
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Action> &
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
return (
<Button variant={variant} size={size} asChild>
<AlertDialogPrimitive.Action
data-slot="alert-dialog-action"
className={cn(className)}
{...props}
/>
</Button>
);
}
function AlertDialogCancel({
className,
variant = "outline",
size = "default",
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel> &
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
return (
<Button variant={variant} size={size} asChild>
<AlertDialogPrimitive.Cancel
data-slot="alert-dialog-cancel"
className={cn(className)}
{...props}
/>
</Button>
);
}
export {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogMedia,
AlertDialogOverlay,
AlertDialogPortal,
AlertDialogTitle,
AlertDialogTrigger,
};

View File

@ -0,0 +1,84 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
/**
* Alert Shadcn
*
* AF4 AF5
* shadcn/ui new-york Tokens 調
* - default muted
* - destructive / --destructive token
* - info spec §5.2 E💡 Token 15 alert
*
*
* - Alert role="alert"
* - AlertTitle font-medium
* - AlertDescription text-sm, muted
*/
const alertVariants = cva(
"relative grid w-full grid-cols-[0_1fr] items-start gap-y-0.5 rounded-lg border px-4 py-3 text-sm has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-[>svg]:gap-x-3 [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"text-destructive bg-card border-destructive/40 [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
info:
// 淡藍背景 + 邊框 + 前景色(對比 bg-background 在 light / dark 都達 ≥ 4.5:1
"border-blue-300/60 bg-blue-50 text-blue-900 dark:border-blue-500/30 dark:bg-blue-950/30 dark:text-blue-100 *:data-[slot=alert-description]:text-blue-800/90 dark:*:data-[slot=alert-description]:text-blue-200/80",
},
},
defaultVariants: {
variant: "default",
},
},
);
function Alert({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
data-variant={variant ?? "default"}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
);
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn(
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
className,
)}
{...props}
/>
);
}
function AlertDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
className,
)}
{...props}
/>
);
}
export { Alert, AlertTitle, AlertDescription };

View File

@ -0,0 +1,66 @@
"use client";
import * as React from "react";
import { Avatar as AvatarPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
/**
* Avatar Shadcn Radix Avatar
*
* Shadcn UI New York stylelocal-tool F4
* 使 `React.ComponentProps<typeof Primitive.X>` + data-slot ui/
*
*
* Avatar size-8 className
* AvatarImage Radix
* AvatarFallback / email
*
* 使UserMenu trigger
*/
function Avatar({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
className={cn(
"relative flex size-8 shrink-0 overflow-hidden rounded-full",
className,
)}
{...props}
/>
);
}
function AvatarImage({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn("aspect-square size-full", className)}
{...props}
/>
);
}
function AvatarFallback({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"bg-muted text-muted-foreground flex size-full items-center justify-center rounded-full text-xs font-medium",
className,
)}
{...props}
/>
);
}
export { Avatar, AvatarFallback, AvatarImage };

View File

@ -0,0 +1,55 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { Slot } from "radix-ui";
import { cn } from "@/lib/utils";
/**
* Badge Shadcn asChild <a>
*
* local-tool/frontend/src/components/ui/badge.tsx100% 沿
*
* variantsdefault / secondary / destructive / outline / ghost / link
*/
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-full border border-transparent px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
link: "text-primary underline-offset-4 [a&]:hover:underline",
},
},
defaultVariants: {
variant: "default",
},
},
);
function Badge({
className,
variant = "default",
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "span";
return (
<Comp
data-slot="badge"
data-variant={variant}
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
);
}
export { Badge, badgeVariants };

View File

@ -0,0 +1,55 @@
/**
* Button
*
* F3 Tailwind / CVA / radix-ui Slot / jest-dom matcher
*/
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { Button } from "./button";
describe("<Button />", () => {
it("能渲染 children 並標記為 button", () => {
render(<Button></Button>);
const btn = screen.getByRole("button", { name: "儲存" });
expect(btn).toBeInTheDocument();
expect(btn.tagName).toBe("BUTTON");
});
it("預設 variant=default / size=default透過 data-* 驗證,不綁 Tailwind class", () => {
render(<Button>Default</Button>);
const btn = screen.getByRole("button");
expect(btn).toHaveAttribute("data-variant", "default");
expect(btn).toHaveAttribute("data-size", "default");
});
it("variant 與 size prop 會反映到 data 屬性", () => {
render(
<Button variant="destructive" size="sm">
Delete
</Button>,
);
const btn = screen.getByRole("button");
expect(btn).toHaveAttribute("data-variant", "destructive");
expect(btn).toHaveAttribute("data-size", "sm");
});
it("disabled 時 pointer-events 與 aria 正確", () => {
render(<Button disabled>Disabled</Button>);
const btn = screen.getByRole("button");
expect(btn).toBeDisabled();
});
it("asChild 將樣式傳給子元素(此處用 <a>),而非外包一層 <button>", () => {
render(
<Button asChild>
<a href="/somewhere">Link</a>
</Button>,
);
// asChild 下 Slot 會把 className 與 data-* 合併到子元素;不應存在 <button>
expect(screen.queryByRole("button")).not.toBeInTheDocument();
const link = screen.getByRole("link", { name: "Link" });
expect(link).toHaveAttribute("data-slot", "button");
expect(link).toHaveAttribute("data-variant", "default");
});
});

View File

@ -0,0 +1,75 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { Slot } from "radix-ui";
import { cn } from "@/lib/utils";
/**
* Button Shadcn New York style
*
* local-tool/frontend/src/components/ui/button.tsx100% 沿
*
*
* - variantdefault / destructive / outline / secondary / ghost / link
* - sizedefault / xs / sm / lg / icon / icon-xs / icon-sm / icon-lg
*
* `asChild` radix-ui Slot <Button asChild><Link>...</Link></Button>
*/
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot.Root : "button";
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
}
export { Button, buttonVariants };

View File

@ -0,0 +1,75 @@
/**
* Card
*
* Card Header / Title / Description / Content / Footer / Action
* data-slot Card data-slot has-data
*/
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import {
Card,
CardAction,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "./card";
describe("<Card />", () => {
it("能渲染完整組合並正確標記 data-slot", () => {
render(
<Card data-testid="card-root">
<CardHeader>
<CardTitle>Pairing Token</CardTitle>
<CardDescription> local agent token</CardDescription>
<CardAction>
<button type="button"></button>
</CardAction>
</CardHeader>
<CardContent>vAc_abcdef...</CardContent>
<CardFooter> 14:52</CardFooter>
</Card>,
);
// 結構
expect(screen.getByTestId("card-root")).toHaveAttribute(
"data-slot",
"card",
);
expect(screen.getByText("Pairing Token")).toHaveAttribute(
"data-slot",
"card-title",
);
expect(screen.getByText("連上 local agent 的 token")).toHaveAttribute(
"data-slot",
"card-description",
);
expect(screen.getByText("vAc_abcdef...")).toHaveAttribute(
"data-slot",
"card-content",
);
expect(screen.getByText("剩餘 14:52")).toHaveAttribute(
"data-slot",
"card-footer",
);
// CardAction 可被 querySelector 以 data-slot 找到
const action = screen
.getByTestId("card-root")
.querySelector('[data-slot="card-action"]');
expect(action).not.toBeNull();
expect(action?.textContent).toBe("重新產生");
});
it("className 透過 cn() 合併tailwind-merge 解衝突)", () => {
render(
<Card className="border-4" data-testid="card-root">
content
</Card>,
);
const card = screen.getByTestId("card-root");
// cn() 會把 className 後綴合併;我們只確認 className 被套用
expect(card.className).toContain("border-4");
});
});

View File

@ -0,0 +1,100 @@
import * as React from "react";
import { cn } from "@/lib/utils";
/**
* Card Shadcn New York style
*
* local-tool/frontend/src/components/ui/card.tsx100% 沿
*
* Card / CardHeader / CardTitle / CardDescription / CardAction / CardContent / CardFooter
* CardAction `has-data-[slot=card-action]` Header
*/
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className,
)}
{...props}
/>
);
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className,
)}
{...props}
/>
);
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
);
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className,
)}
{...props}
/>
);
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
);
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
);
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
};

View File

@ -0,0 +1,40 @@
"use client";
import * as React from "react";
import { CheckIcon } from "lucide-react";
import { Checkbox as CheckboxPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
/**
* Checkbox Shadcn Radix Checkbox
*
* local-tool/frontend/src/components/ui/checkbox.tsx100% 沿
*
* default / checked / indeterminate / disabled
* Focus `focus-visible:ring-[3px]` WCAG AA
*/
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
);
}
export { Checkbox };

View File

@ -0,0 +1,174 @@
"use client";
import * as React from "react";
import { XIcon } from "lucide-react";
import { Dialog as DialogPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
/**
* Dialog Shadcn ModalRadix Dialog
*
* local-tool/frontend/src/components/ui/dialog.tsx100% 沿
*
*
* Dialog
* DialogTrigger
* DialogContent DialogPortal + DialogOverlay + Close X
* DialogHeader
* DialogTitle
* DialogDescription
* DialogFooter showCloseButton
*
* tw-animate-css`data-[state=open]:animate-in` utility
*/
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className,
)}
{...props}
/>
);
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean;
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg",
className,
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
);
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
);
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean;
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className,
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close asChild>
<Button variant="outline">Close</Button>
</DialogPrimitive.Close>
)}
</div>
);
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
);
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
};

View File

@ -0,0 +1,282 @@
"use client";
import * as React from "react";
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
/**
* DropdownMenu Shadcn Radix DropdownMenu
*
* Shadcn UI New York stylelocal-tool F4
* `ui/` 使 `React.ComponentProps<typeof Primitive.X>` + data-slot
*
*
* DropdownMenu
* DropdownMenuTrigger
* DropdownMenuContent
* DropdownMenuLabel
* DropdownMenuItem
* DropdownMenuCheckboxItem
* DropdownMenuRadioGroup / Item
* DropdownMenuSeparator
* DropdownMenuSub / SubTrigger / SubContent
*
* tw-animate-cssglobals.css import
*
* 使
* - UserMenuHeader avatar
* -
*/
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
);
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
);
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
);
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
);
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
variant?: "default" | "destructive";
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
);
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
);
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
);
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
);
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className,
)}
{...props}
/>
);
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
);
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className,
)}
{...props}
/>
);
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return (
<DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
);
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
className,
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
);
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className,
)}
{...props}
/>
);
}
export {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuPortal,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
};

View File

@ -0,0 +1,57 @@
import type { LucideIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
/**
* EmptyState
*
* local-tool/frontend/src/components/ui/empty-state.tsx100% 沿
*
* / icon + + + /
*/
interface EmptyStateAction {
label: string;
onClick: () => void;
}
interface EmptyStateProps {
icon: LucideIcon;
title: string;
description: string;
action?: EmptyStateAction;
secondaryAction?: EmptyStateAction;
}
export function EmptyState({
icon: Icon,
title,
description,
action,
secondaryAction,
}: EmptyStateProps) {
return (
<div className="flex h-64 flex-col items-center justify-center gap-4 text-center">
<div className="bg-muted rounded-full p-4">
<Icon className="text-muted-foreground h-8 w-8" />
</div>
<div className="space-y-1">
<h3 className="text-base font-medium">{title}</h3>
<p className="text-muted-foreground max-w-sm text-sm">{description}</p>
</div>
{(action || secondaryAction) && (
<div className="flex gap-2">
{action && (
<Button onClick={action.onClick} size="sm">
{action.label}
</Button>
)}
{secondaryAction && (
<Button onClick={secondaryAction.onClick} size="sm" variant="outline">
{secondaryAction.label}
</Button>
)}
</div>
)}
</div>
);
}

View File

@ -0,0 +1,30 @@
import * as React from "react";
import { cn } from "@/lib/utils";
/**
* Input Shadcn
*
* local-tool/frontend/src/components/ui/input.tsx100% 沿
*
* - h-9rounded-mdshadow-xs
* - `aria-invalid` `aria-invalid:border-destructive`
* - Focus `focus-visible:ring-[3px]` ring WCAG AA
*/
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className,
)}
{...props}
/>
);
}
export { Input };

View File

@ -0,0 +1,33 @@
"use client";
import * as React from "react";
import { Label as LabelPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
/**
* Label Shadcn Radix Label
*
* local-tool/frontend/src/components/ui/label.tsx100% 沿
*
*
* - peer-disabled / group-data-[disabled=true]
* - Input Radix Label htmlFor
*/
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className,
)}
{...props}
/>
);
}
export { Label };

View File

@ -0,0 +1,39 @@
"use client";
import * as React from "react";
import { Progress as ProgressPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
/**
* Progress Shadcn Radix Progress
*
* local-tool/frontend/src/components/ui/progress.tsx100% 沿
*
* 使PairingToken
* `translateX` reflow width transition
*/
function Progress({
className,
value,
...props
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
return (
<ProgressPrimitive.Root
data-slot="progress"
className={cn(
"bg-primary/20 relative h-2 w-full overflow-hidden rounded-full",
className,
)}
{...props}
>
<ProgressPrimitive.Indicator
data-slot="progress-indicator"
className="bg-primary h-full w-full flex-1 transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
);
}
export { Progress };

View File

@ -0,0 +1,72 @@
"use client";
import * as React from "react";
import { CircleIcon } from "lucide-react";
import { RadioGroup as RadioGroupPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
/**
* RadioGroup Shadcn Radix RadioGroup
*
* shadcn/ui new-york AF5
* 沿 Button / Checkbox focus-visible WCAG 2.2 AA
*
*
* - RadioGroup root value / onValueChange
* - RadioGroupItem disabled
*
* 使
* ```tsx
* <RadioGroup value={strategy} onValueChange={setStrategy}>
* <div className="flex items-center gap-2">
* <RadioGroupItem value="auto" id="auto" />
* <Label htmlFor="auto"></Label>
* </div>
* ...
* </RadioGroup>
* ```
*/
function RadioGroup({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
return (
<RadioGroupPrimitive.Root
data-slot="radio-group"
className={cn("grid gap-3", className)}
{...props}
/>
);
}
function RadioGroupItem({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
return (
<RadioGroupPrimitive.Item
data-slot="radio-group-item"
className={cn(
// 外觀size-4 圓形邊框,與 Checkbox 視覺量體對齊
"border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
"dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border shadow-xs outline-none",
"transition-[color,box-shadow] focus-visible:ring-[3px]",
"disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
>
<RadioGroupPrimitive.Indicator
data-slot="radio-group-indicator"
className="relative flex items-center justify-center"
>
{/* 內圓點(用 CircleIcon 填充色)— 只有 checked 時顯示 */}
<CircleIcon className="fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
);
}
export { RadioGroup, RadioGroupItem };

View File

@ -0,0 +1,66 @@
"use client";
import * as React from "react";
import { ScrollArea as ScrollAreaPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
/**
* ScrollArea Shadcn Radix ScrollArea
*
* local-tool/frontend/src/components/ui/scroll-area.tsx100% 沿
*
* Windows macOS
* vertical ScrollBar horizontal <ScrollBar orientation="horizontal" />
*/
function ScrollArea({
className,
children,
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
);
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
data-slot="scroll-area-scrollbar"
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent",
className,
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb
data-slot="scroll-area-thumb"
className="bg-border relative flex-1 rounded-full"
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
);
}
export { ScrollArea, ScrollBar };

View File

@ -0,0 +1,200 @@
"use client";
import * as React from "react";
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
import { Select as SelectPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
/**
* Select Shadcn Radix Select
*
* local-tool/frontend/src/components/ui/select.tsx100% 沿
*
* Select / SelectTrigger / SelectValue / SelectContent / SelectItem /
* SelectGroup / SelectLabel / SelectSeparator / SelectScrollUp/Down
*
* SelectTrigger size`sm` = h-8`default` = h-9
*/
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />;
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default";
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
);
}
function SelectContent({
className,
children,
position = "item-aligned",
align = "center",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className,
)}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1",
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
);
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...props}
/>
);
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className,
)}
{...props}
>
<span
data-slot="select-item-indicator"
className="absolute right-2 flex size-3.5 items-center justify-center"
>
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
);
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...props}
/>
);
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className,
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
);
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className,
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
);
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
};

View File

@ -0,0 +1,35 @@
"use client";
import * as React from "react";
import { Separator as SeparatorPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
/**
* Separator Shadcn Radix Separator
*
* local-tool/frontend/src/components/ui/separator.tsx100% 沿
*
* decorative=true role
*/
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className,
)}
{...props}
/>
);
}
export { Separator };

View File

@ -0,0 +1,23 @@
import * as React from "react";
import { cn } from "@/lib/utils";
/**
* Skeleton Loading
*
* local-tool shadcn
*
* CLSlayout shift Tailwind `animate-pulse`
* 使 className h-4 w-24 rounded
*/
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("bg-muted animate-pulse rounded-md", className)}
{...props}
/>
);
}
export { Skeleton };

View File

@ -0,0 +1,71 @@
"use client";
import * as React from "react";
import { Slider as SliderPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
/**
* Slider Shadcn Radix Slider
*
* local-tool/frontend/src/components/ui/slider.tsx100% 沿
*
* / range value Thumb
* 使inference confidence threshold0-1
*/
function Slider({
className,
defaultValue,
value,
min = 0,
max = 100,
...props
}: React.ComponentProps<typeof SliderPrimitive.Root>) {
const _values = React.useMemo(
() =>
Array.isArray(value)
? value
: Array.isArray(defaultValue)
? defaultValue
: [min, max],
[value, defaultValue, min, max],
);
return (
<SliderPrimitive.Root
data-slot="slider"
defaultValue={defaultValue}
value={value}
min={min}
max={max}
className={cn(
"relative flex w-full touch-none items-center select-none data-[disabled]:opacity-50 data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col",
className,
)}
{...props}
>
<SliderPrimitive.Track
data-slot="slider-track"
className={cn(
"bg-muted relative grow overflow-hidden rounded-full data-[orientation=horizontal]:h-1.5 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-1.5",
)}
>
<SliderPrimitive.Range
data-slot="slider-range"
className={cn(
"bg-primary absolute data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full",
)}
/>
</SliderPrimitive.Track>
{Array.from({ length: _values.length }, (_, index) => (
<SliderPrimitive.Thumb
data-slot="slider-thumb"
key={index}
className="border-primary ring-ring/50 block size-4 shrink-0 rounded-full border bg-white shadow-sm transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50"
/>
))}
</SliderPrimitive.Root>
);
}
export { Slider };

View File

@ -0,0 +1,52 @@
"use client";
import {
CircleCheckIcon,
InfoIcon,
Loader2Icon,
OctagonXIcon,
TriangleAlertIcon,
} from "lucide-react";
import { useTheme } from "next-themes";
import { Toaster as Sonner, type ToasterProps } from "sonner";
/**
* Toaster Sonner Shadcn
*
* local-tool/frontend/src/components/ui/sonner.tsx Zustand store next-themes
*
*
* - local-tool useSettingsStore theme settings-storetheme next-themes
* `useTheme()` theme 'system'sonner media query
* - icon / CSS 沿 local-tool
*
* 使 root layout <Toaster /> `toast()` / `toast.success()` API
*/
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme();
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
icons={{
success: <CircleCheckIcon className="size-4" />,
info: <InfoIcon className="size-4" />,
warning: <TriangleAlertIcon className="size-4" />,
error: <OctagonXIcon className="size-4" />,
loading: <Loader2Icon className="size-4 animate-spin" />,
}}
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
"--border-radius": "var(--radius)",
} as React.CSSProperties
}
{...props}
/>
);
};
export { Toaster };

View File

@ -0,0 +1,46 @@
import * as React from "react";
import { Loader2Icon } from "lucide-react";
import { cn } from "@/lib/utils";
/**
* Spinner Loading
*
* local-tool 使 <Loader2Icon className="animate-spin" /> sonner.tsx loading icon
* 便 Button loading / loading 使
*
* sizesmsize-4/ mdsize-5/ lgsize-8
*/
interface SpinnerProps extends Omit<React.ComponentProps<"span">, "children"> {
size?: "sm" | "md" | "lg";
/** 螢幕閱讀器朗讀文字預設「Loading」— 呼叫端若有語系請覆寫 */
label?: string;
}
const sizeClass: Record<NonNullable<SpinnerProps["size"]>, string> = {
sm: "size-4",
md: "size-5",
lg: "size-8",
};
function Spinner({
size = "md",
label = "Loading",
className,
...props
}: SpinnerProps) {
return (
<span
role="status"
aria-live="polite"
data-slot="spinner"
className={cn("inline-flex items-center justify-center", className)}
{...props}
>
<Loader2Icon className={cn("animate-spin", sizeClass[size])} aria-hidden />
<span className="sr-only">{label}</span>
</span>
);
}
export { Spinner };

View File

@ -0,0 +1,100 @@
"use client";
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { Tabs as TabsPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
/**
* Tabs Shadcn Radix Tabs
*
* local-tool/frontend/src/components/ui/tabs.tsx100% 沿
*
* variant
* - defaultTabsList `bg-muted`active
* - lineTabsList active `after:*` utility
*/
function Tabs({
className,
orientation = "horizontal",
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
data-orientation={orientation}
orientation={orientation}
className={cn(
"group/tabs flex gap-2 data-[orientation=horizontal]:flex-col",
className,
)}
{...props}
/>
);
}
const tabsListVariants = cva(
"rounded-lg p-[3px] group-data-[orientation=horizontal]/tabs:h-9 data-[variant=line]:rounded-none group/tabs-list text-muted-foreground inline-flex w-fit items-center justify-center group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col",
{
variants: {
variant: {
default: "bg-muted",
line: "gap-1 bg-transparent",
},
},
defaultVariants: {
variant: "default",
},
},
);
function TabsList({
className,
variant = "default",
...props
}: React.ComponentProps<typeof TabsPrimitive.List> &
VariantProps<typeof tabsListVariants>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
data-variant={variant}
className={cn(tabsListVariants({ variant }), className)}
{...props}
/>
);
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring text-foreground/60 hover:text-foreground dark:text-muted-foreground dark:hover:text-foreground relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 group-data-[variant=default]/tabs-list:data-[state=active]:shadow-sm group-data-[variant=line]/tabs-list:data-[state=active]:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:border-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent",
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 data-[state=active]:text-foreground",
"after:bg-foreground after:absolute after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-[state=active]:after:opacity-100",
className,
)}
{...props}
/>
);
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 outline-none", className)}
{...props}
/>
);
}
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants };

View File

@ -0,0 +1,76 @@
"use client";
import * as React from "react";
import { Tooltip as TooltipPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
/**
* Tooltip Shadcn Radix Tooltip
*
* Shadcn UI New York stylelocal-tool F4
*
* 使
* <TooltipProvider> root layoutApp
* <Tooltip>
* <TooltipTrigger>...</TooltipTrigger>
* <TooltipContent></TooltipContent>
* </Tooltip>
*
* 使
* - Header Tunnel RTT
* -
* - Sidebar icon
*
* tw-animate-css
*/
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
);
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />;
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
className,
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="bg-primary fill-primary z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
);
}
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger };

View File

@ -0,0 +1,100 @@
"use client";
/**
* useConnectionStatus Wails `GetConnectionStatus()` + `connection:status` event
*
* TDD §6.1 / §6.3 binding
* - `agentAPI.getConnectionStatus()` snapshot
* - `onConnectionStatus(handler)` Wails runtime
* - useEffect cleanup memory leak
*
* AF6
* - mock `agentAPI`
* - dev `pnpm dev` `window.go` `agentAPI` mock
* - `refresh()` mock no-op
*
*
* - setErrorUI error state StatusView check loading
* AF6 silenceerror log UIfallback notPaired
*
*
* - snapshotnull fallback
* - loading snapshot
* - errornull
* - refresh()
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { agentAPI, onConnectionStatus } from "@/lib/agent-api";
import type { ConnectionSnapshot } from "@/types/agent";
export interface UseConnectionStatusResult {
snapshot: ConnectionSnapshot | null;
loading: boolean;
error: string | null;
/** 手動重拉;回傳 Promise 供呼叫端 awaitUI 通常不 await。 */
refresh: () => Promise<void>;
}
export function useConnectionStatus(): UseConnectionStatusResult {
const [snapshot, setSnapshot] = useState<ConnectionSnapshot | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
/**
* ref mounted async callback
* setState React warning
*/
const mountedRef = useRef(true);
/**
* Promise chain async/await setState
* Promise callback `react-hooks/set-state-in-effect`
* effect body setStatePromise handler async boundary
*
*/
const refresh = useCallback((): Promise<void> => {
return agentAPI
.getConnectionStatus()
.then((snap) => {
if (mountedRef.current) {
setSnapshot(snap);
setError(null);
}
})
.catch((err: unknown) => {
if (mountedRef.current) {
setError(err instanceof Error ? err.message : String(err));
}
})
.finally(() => {
if (mountedRef.current) {
setLoading(false);
}
});
}, []);
useEffect(() => {
mountedRef.current = true;
// 1. 初次載入:拉當前 snapshotPromise chain 讓 setState 落在 callback 內)
void refresh();
// 2. 訂閱 Wails event — Go 端狀態變化會即時推送
const unsubscribe = onConnectionStatus((next) => {
if (mountedRef.current) {
setSnapshot(next);
// 收到 event 代表 backend 有回應;清 error若先前初次拉取失敗
setError(null);
}
});
return () => {
mountedRef.current = false;
unsubscribe();
};
}, [refresh]);
return { snapshot, loading, error, refresh };
}

View File

@ -0,0 +1,53 @@
"use client";
/**
* useExportLog Wails `ExportLog()` binding
*
* spec §6.2.3 LogGo ring buffer + logs/ zip
* OS temp dir
*
* AF6
* - mock `agentAPI.exportLog()`
* - Go zip throw error null UI toast
* - dev mock
*
*
* - exportLog() resolve path null / 使
* - exporting loading state
* - lastError null
*
* Go SaveFileDialog zip temp dir
* UI {path}使Phase 1 dialog
*/
import { useCallback, useState } from "react";
import { agentAPI } from "@/lib/agent-api";
export interface UseExportLogResult {
/** 觸發匯出resolve 成匯出後的檔案路徑(失敗 / 取消時回 null。 */
exportLog: () => Promise<string | null>;
exporting: boolean;
lastError: string | null;
}
export function useExportLog(): UseExportLogResult {
const [exporting, setExporting] = useState(false);
const [lastError, setLastError] = useState<string | null>(null);
const exportLog = useCallback(async (): Promise<string | null> => {
setExporting(true);
setLastError(null);
try {
const path = await agentAPI.exportLog();
return path || null;
} catch (err) {
setLastError(err instanceof Error ? err.message : String(err));
return null;
} finally {
setExporting(false);
}
}, []);
return { exportLog, exporting, lastError };
}

View File

@ -0,0 +1,99 @@
/**
* usePair AF6
*
*
* 1. resolvesubmitting toggle lastError null
* 2. reject PairErrorsubmitting lastError
* 3. reset() lastError
*
*
* - Wails binding agent-api.test.ts
* - pair-view UI pair-view.test.tsx
*/
import { act, renderHook, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { usePair } from "./use-pair";
/* agent-api mock — 讓 hook 不依賴真實實作;每個 test 依需求改 spy 行為 */
const pairMock = vi.fn<(token: string) => Promise<void>>();
vi.mock("@/lib/agent-api", () => ({
agentAPI: {
pair: (token: string) => pairMock(token),
},
}));
beforeEach(() => {
pairMock.mockReset();
});
describe("usePair", () => {
it("成功路徑resolvelastError 保持 null", async () => {
pairMock.mockResolvedValueOnce(undefined);
const { result } = renderHook(() => usePair());
await act(async () => {
await result.current.pair("vAc_" + "a".repeat(32));
});
expect(pairMock).toHaveBeenCalledWith("vAc_" + "a".repeat(32));
expect(result.current.submitting).toBe(false);
expect(result.current.lastError).toBeNull();
});
it("失敗路徑PairError 被保留submitting 歸位", async () => {
pairMock.mockRejectedValueOnce({ code: "token_expired" });
const { result } = renderHook(() => usePair());
await act(async () => {
try {
await result.current.pair("vAc_" + "b".repeat(32));
} catch {
// 預期 reject吞掉讓 assertion 繼續
}
});
await waitFor(() => {
expect(result.current.lastError).toEqual({ code: "token_expired" });
});
expect(result.current.submitting).toBe(false);
});
it("非 PairError 物件的錯誤被轉成 unknown code", async () => {
pairMock.mockRejectedValueOnce(new Error("random failure"));
const { result } = renderHook(() => usePair());
await act(async () => {
try {
await result.current.pair("vAc_xxx");
} catch {
// ignore
}
});
expect(result.current.lastError).toEqual({ code: "unknown" });
});
it("reset() 清除 lastError", async () => {
pairMock.mockRejectedValueOnce({ code: "token_used" });
const { result } = renderHook(() => usePair());
await act(async () => {
try {
await result.current.pair("vAc_yyy");
} catch {
/* ignore */
}
});
expect(result.current.lastError).toEqual({ code: "token_used" });
act(() => {
result.current.reset();
});
expect(result.current.lastError).toBeNull();
});
});

View File

@ -0,0 +1,67 @@
"use client";
/**
* usePair Wails `Pair(token)` binding
*
* TDD §6.3
* 1. token `/^vAc_[a-f0-9]{32}$/i` pair-view
* 2. `agentAPI.pair(token)` Go token tunnel dial Session persist
* 3. resolve() reject(PairError)
*
* AF6
* - mock `agentAPI.pair()`
* - Go error PairError `agent-api.ts` `parsePairError`
* - dev `pnpm dev` mock resolve
*/
import { useCallback, useState } from "react";
import { agentAPI } from "@/lib/agent-api";
import type { PairError } from "@/types/agent";
/** PairError 的 type guard — 確認 throw 出來的物件是結構化錯誤而非隨便的 Error。 */
function isPairError(v: unknown): v is PairError {
return (
typeof v === "object" &&
v !== null &&
"code" in v &&
typeof (v as { code: unknown }).code === "string"
);
}
export interface UsePairResult {
/** 送出配對請求;成功 resolve、失敗 reject(PairError)。 */
pair: (token: string) => Promise<void>;
/** 當前是否正在配對Button loading state / input disabled 用)。 */
submitting: boolean;
/** 最近一次錯誤(呼叫 pair() 或 reset() 會重置)。 */
lastError: PairError | null;
/** 重置錯誤(使用者修改 token 後清錯誤訊息)。 */
reset: () => void;
}
export function usePair(): UsePairResult {
const [submitting, setSubmitting] = useState(false);
const [lastError, setLastError] = useState<PairError | null>(null);
const pair = useCallback(async (token: string): Promise<void> => {
setSubmitting(true);
setLastError(null);
try {
await agentAPI.pair(token);
} catch (err) {
const pairErr: PairError = isPairError(err)
? err
: { code: "unknown" };
setLastError(pairErr);
throw pairErr;
} finally {
setSubmitting(false);
}
}, []);
const reset = useCallback(() => setLastError(null), []);
return { pair, submitting, lastError, reset };
}

View File

@ -0,0 +1,68 @@
"use client";
/**
* useRecentLogs Wails `GetRecentLogs()` + `connection:log` event
*
* TDD §6.2 / spec §4.2 D
* - `agentAPI.getRecentLogs(max)` N
* - `onConnectionLog()` append Go emit
* - spec `max` 10
*
* AF6
* - mock `agentAPI`
* - `connection:log` eventGo AB append
* - Phase 0real binding `getRecentLogs()` server stdout/stderr
* spec-shape AB connection:log emit
*
* logs max append trim
*/
import { useEffect, useRef, useState } from "react";
import { agentAPI, onConnectionLog } from "@/lib/agent-api";
import type { LogEntry } from "@/types/agent";
export interface UseRecentLogsResult {
logs: LogEntry[];
loading: boolean;
}
export function useRecentLogs(max: number = 10): UseRecentLogsResult {
const [logs, setLogs] = useState<LogEntry[]>([]);
const [loading, setLoading] = useState(true);
const mountedRef = useRef(true);
useEffect(() => {
mountedRef.current = true;
// 1. 初次載入Promise chain 讓 setState 落在 callback避免 react-hooks/set-state-in-effect
agentAPI
.getRecentLogs(max)
.then((initial) => {
if (mountedRef.current) {
setLogs(initial.slice(-max));
}
})
.finally(() => {
if (mountedRef.current) {
setLoading(false);
}
});
// 2. 訂閱新事件 — 新 LogEntry 來時 appendtrim 成 max
const unsubscribe = onConnectionLog((entry) => {
if (!mountedRef.current) return;
setLogs((prev) => {
const next = [...prev, entry];
return next.length > max ? next.slice(-max) : next;
});
});
return () => {
mountedRef.current = false;
unsubscribe();
};
}, [max]);
return { logs, loading };
}

View File

@ -0,0 +1,116 @@
"use client";
/**
* useSettings Wails `GetAgentSettings()` / `SaveAgentSettings()` / `ResetAllSettings()` binding
*
* TDD §6.1 / §6.3 binding
*
* AF6
* - mock `agentAPI`
* - `save(patch)` Go setError UI toast
* - `resetAll()` `ResetAllSettings`Go config + Session Token
* - dev mockin-memory
*
*
* - save / resetAll UI settings optimistic update
* `lastError` UI UI toast
* - UI lastError
*
*
* - `settings` / `save(patch)` / `resetAll()` settings-view
* - `loading` `lastError`使
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { agentAPI } from "@/lib/agent-api";
import type { AgentSettings } from "@/types/agent";
/** 預設值 — 對齊 spec §6 各區塊預設。在 GetAgentSettings 回來前用這個顯示。 */
const DEFAULT_SETTINGS: AgentSettings = {
relayUrl: "wss://relay.visionA.cloud",
autoStart: false,
reconnectStrategy: "auto",
logLevel: "info",
};
export interface UseSettingsResult {
settings: AgentSettings;
/** 初次從 backend 拉設定是否完成。`true` 時 `settings` 仍是 DEFAULT_SETTINGS 作為 fallback。 */
loading: boolean;
/** 最近一次 save / resetAll 失敗的錯誤訊息;成功操作會清成 null。 */
lastError: string | null;
/** 更新單/多個欄位patch 合併)— optimistic update立即更新 UI再 sync backend。 */
save: (patch: Partial<AgentSettings>) => Promise<void>;
/** 重置所有設定spec §6.2.5 危險區域)— 會清 Session Token。 */
resetAll: () => Promise<void>;
}
export function useSettings(): UseSettingsResult {
const [settings, setSettings] = useState<AgentSettings>(DEFAULT_SETTINGS);
const [loading, setLoading] = useState(true);
const [lastError, setLastError] = useState<string | null>(null);
const mountedRef = useRef(true);
// 1. 初次載入Promise chain 讓 setState 落在 callback避免 react-hooks/set-state-in-effect
useEffect(() => {
mountedRef.current = true;
agentAPI
.getAgentSettings()
.then((initial) => {
if (mountedRef.current) {
setSettings(initial);
setLastError(null);
}
})
.catch((err: unknown) => {
if (mountedRef.current) {
setLastError(err instanceof Error ? err.message : String(err));
}
})
.finally(() => {
if (mountedRef.current) {
setLoading(false);
}
});
return () => {
mountedRef.current = false;
};
}, []);
/**
* Optimistic update UI state sync backend
* setLastError revert UI throw fire-and-forget
* Checkbox onCheckedChange unhandled promise rejection
* `lastError`
*/
const save = useCallback(
async (patch: Partial<AgentSettings>): Promise<void> => {
const next: AgentSettings = { ...settings, ...patch };
setSettings(next);
try {
await agentAPI.saveAgentSettings(next);
setLastError(null);
} catch (err) {
setLastError(err instanceof Error ? err.message : String(err));
}
},
[settings],
);
/**
* + Session Token
* save throw lastError
*/
const resetAll = useCallback(async (): Promise<void> => {
try {
await agentAPI.resetAllSettings();
setSettings(DEFAULT_SETTINGS);
setLastError(null);
} catch (err) {
setLastError(err instanceof Error ? err.message : String(err));
}
}, []);
return { settings, loading, lastError, save, resetAll };
}

View File

@ -0,0 +1,46 @@
"use client";
/**
* useTestConnection Wails `TestConnection(url)` binding
*
* spec §6.2.1 WS handshake token reachability
*
* AF6
* - mock `agentAPI.testConnection()`
* - Go WebSocket dial5 timeout
* - `test()` resolve reject result.ok=false + reason
* - dev mock
*/
import { useCallback, useState } from "react";
import { agentAPI } from "@/lib/agent-api";
import type { TestRelayResult } from "@/types/agent";
export interface UseTestConnectionResult {
/** 發起測試;回傳結果(永遠不 reject — 失敗資訊在 result.ok=false + reason。 */
test: (url: string) => Promise<TestRelayResult>;
testing: boolean;
}
export function useTestConnection(): UseTestConnectionResult {
const [testing, setTesting] = useState(false);
const test = useCallback(async (url: string): Promise<TestRelayResult> => {
setTesting(true);
try {
return await agentAPI.testConnection(url);
} catch (err) {
// 理論上 agentAPI.testConnection 不會 throwGo 端回 TestResult 帶 reason
// 但萬一底層出事Wails binding 不存在),包成 ok=false 讓 UI 能顯示
return {
ok: false,
reason: err instanceof Error ? err.message : String(err),
};
} finally {
setTesting(false);
}
}, []);
return { test, testing };
}

View File

@ -0,0 +1,314 @@
/**
* agent-api AF6
*
*
* 1. Wails runtime window.go mock real binding
* 2. Go DTO TS int64 ms ISO string
* 3. Go error PairErrorCode
* 4. Event wails EventsOn mock no-op
*
*
* - Wails Wails runtime
* - handshake Go
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ConnectionSnapshot } from "@/types/agent";
import {
__isWailsRuntimeForTest,
__resetMockForTest,
agentAPI,
onConnectionStatus,
} from "./agent-api";
/* -------------------------------------------------------------------------- */
/* 測試工具:安裝假的 window.go / window.runtime */
/* -------------------------------------------------------------------------- */
interface FakeBinding {
GetConnectionStatus: ReturnType<typeof vi.fn>;
Pair: ReturnType<typeof vi.fn>;
Unpair: ReturnType<typeof vi.fn>;
Reconnect: ReturnType<typeof vi.fn>;
Disconnect: ReturnType<typeof vi.fn>;
GetAgentSettings: ReturnType<typeof vi.fn>;
SaveAgentSettings: ReturnType<typeof vi.fn>;
TestConnection: ReturnType<typeof vi.fn>;
ResetAllSettings: ReturnType<typeof vi.fn>;
GetRecentLogs: ReturnType<typeof vi.fn>;
ExportLog: ReturnType<typeof vi.fn>;
}
function installFakeWails(): {
binding: FakeBinding;
eventsOn: ReturnType<typeof vi.fn>;
eventsOff: ReturnType<typeof vi.fn>;
browserOpenURL: ReturnType<typeof vi.fn>;
} {
const binding: FakeBinding = {
GetConnectionStatus: vi.fn(),
Pair: vi.fn(),
Unpair: vi.fn(),
Reconnect: vi.fn(),
Disconnect: vi.fn(),
GetAgentSettings: vi.fn(),
SaveAgentSettings: vi.fn(),
TestConnection: vi.fn(),
ResetAllSettings: vi.fn(),
GetRecentLogs: vi.fn(),
ExportLog: vi.fn(),
};
const eventsOn = vi.fn(() => vi.fn()); // EventsOn 回傳取消訂閱函式
const eventsOff = vi.fn();
const browserOpenURL = vi.fn();
// jsdom 環境:把假的 go / runtime 直接塞到 window
(window as unknown as { go: unknown }).go = { main: { App: binding } };
(window as unknown as { runtime: unknown }).runtime = {
EventsOn: eventsOn,
EventsOff: eventsOff,
BrowserOpenURL: browserOpenURL,
};
return { binding, eventsOn, eventsOff, browserOpenURL };
}
function uninstallFakeWails() {
delete (window as unknown as { go?: unknown }).go;
delete (window as unknown as { runtime?: unknown }).runtime;
}
/* -------------------------------------------------------------------------- */
/* 測試Mock 路徑window.go 不存在) */
/* -------------------------------------------------------------------------- */
describe("agentAPI — Mock 路徑(瀏覽器 dev 模式)", () => {
beforeEach(() => {
uninstallFakeWails();
__resetMockForTest();
});
it("偵測到無 Wails runtime", () => {
expect(__isWailsRuntimeForTest()).toBe(false);
});
it("getConnectionStatus 回 notPaired 預設值", async () => {
const snap = await agentAPI.getConnectionStatus();
expect(snap.state).toBe("notPaired");
expect(snap.relayUrl).toContain("wss://");
});
it("pair 接受合法 token 後 resolve", async () => {
const validToken = "vAc_" + "a".repeat(32);
await expect(agentAPI.pair(validToken)).resolves.toBeUndefined();
});
it("pair 收到格式錯誤的 token 時 reject 帶 PairError", async () => {
await expect(agentAPI.pair("not-a-valid-token")).rejects.toMatchObject({
code: "token_invalid",
});
});
it("testConnection 驗證 URL 協議", async () => {
const ok = await agentAPI.testConnection("wss://relay.example.com");
expect(ok.ok).toBe(true);
expect(ok.latencyMs).toBeTypeOf("number");
const bad = await agentAPI.testConnection("http://not-ws");
expect(bad.ok).toBe(false);
expect(bad.reason).toContain("ws://");
});
it("saveAgentSettings / getAgentSettings 能來回讀寫", async () => {
const patched = {
relayUrl: "wss://custom.test",
autoStart: true,
reconnectStrategy: "manual" as const,
logLevel: "debug" as const,
};
await agentAPI.saveAgentSettings(patched);
const read = await agentAPI.getAgentSettings();
expect(read).toEqual(patched);
});
it("getRecentLogs 回空陣列Phase 0 mock", async () => {
const logs = await agentAPI.getRecentLogs(10);
expect(logs).toEqual([]);
});
it("exportLog 回 mock path", async () => {
const path = await agentAPI.exportLog();
expect(path).toMatch(/\.zip$/);
});
it("openURLmock 環境)退到 window.opennoopener noreferrer", () => {
// Fix-F3mock 環境沒 Wails runtime → fallback window.open
const spy = vi.spyOn(window, "open").mockImplementation(() => null);
agentAPI.openURL("https://example.com");
expect(spy).toHaveBeenCalledWith(
"https://example.com",
"_blank",
"noopener,noreferrer",
);
spy.mockRestore();
});
it("onConnectionStatus 回 no-op unsubscribecallback 永遠不被呼叫", () => {
const cb = vi.fn();
const off = onConnectionStatus(cb);
expect(typeof off).toBe("function");
expect(() => off()).not.toThrow();
expect(cb).not.toHaveBeenCalled();
});
});
/* -------------------------------------------------------------------------- */
/* 測試Real 路徑window.go 存在) */
/* -------------------------------------------------------------------------- */
describe("agentAPI — Real 路徑Wails runtime 模擬)", () => {
let binding: FakeBinding;
let eventsOn: ReturnType<typeof vi.fn>;
let browserOpenURL: ReturnType<typeof vi.fn>;
beforeEach(() => {
const fake = installFakeWails();
binding = fake.binding;
eventsOn = fake.eventsOn;
browserOpenURL = fake.browserOpenURL;
});
afterEach(() => {
uninstallFakeWails();
});
it("偵測到 Wails runtime", () => {
expect(__isWailsRuntimeForTest()).toBe(true);
});
it("getConnectionStatus 呼叫 Go binding 並轉 ms → ISO", async () => {
const goMs = 1_700_000_000_000;
binding.GetConnectionStatus.mockResolvedValue({
state: "online",
relayUrl: "wss://relay.test",
connectedSince: goMs,
sessionTokenPreview: "vAs_abc ··· def",
});
const snap: ConnectionSnapshot = await agentAPI.getConnectionStatus();
expect(binding.GetConnectionStatus).toHaveBeenCalledOnce();
expect(snap.state).toBe("online");
expect(snap.connectedSince).toBe(new Date(goMs).toISOString());
expect(snap.sessionTokenPreview).toBe("vAs_abc ··· def");
});
it("getConnectionStatus 把未知 state 收斂到 error", async () => {
binding.GetConnectionStatus.mockResolvedValue({
state: "weird-unknown-value",
relayUrl: "wss://relay.test",
});
const snap = await agentAPI.getConnectionStatus();
expect(snap.state).toBe("error");
});
it("getConnectionStatus 不帶 connectedSince 時欄位省略", async () => {
binding.GetConnectionStatus.mockResolvedValue({
state: "notPaired",
relayUrl: "wss://relay.test",
});
const snap = await agentAPI.getConnectionStatus();
expect(snap.connectedSince).toBeUndefined();
});
it("pair 成功時 resolve", async () => {
binding.Pair.mockResolvedValue(undefined);
await expect(agentAPI.pair("vAc_abc")).resolves.toBeUndefined();
expect(binding.Pair).toHaveBeenCalledWith("vAc_abc");
});
it.each([
["pairing token expired", "token_expired"],
["pairing token already used", "token_used"],
["pairing token revoked", "token_revoked"],
["pairing token invalid", "token_invalid"],
["invalid pairing token format (expected vAc_ + 32 hex)", "token_invalid"],
["websocket dial: connection refused", "relay_unreachable"],
["network timeout", "network_error"],
["something else entirely", "unknown"],
])("pair Go error %q → code %q", async (goMsg, expectedCode) => {
binding.Pair.mockRejectedValue(new Error(goMsg));
try {
await agentAPI.pair("vAc_xxx");
throw new Error("should have rejected");
} catch (err) {
expect(err).toMatchObject({ code: expectedCode });
}
});
it("testConnection 透傳 Go 結果", async () => {
binding.TestConnection.mockResolvedValue({ ok: true, latencyMs: 30 });
const r = await agentAPI.testConnection("wss://test");
expect(r).toEqual({ ok: true, latencyMs: 30, reason: undefined });
expect(binding.TestConnection).toHaveBeenCalledWith("wss://test");
});
it("saveAgentSettings 透傳整包 settings 給 Go", async () => {
binding.SaveAgentSettings.mockResolvedValue(undefined);
const patch = {
relayUrl: "wss://new",
autoStart: true,
reconnectStrategy: "manual" as const,
logLevel: "debug" as const,
};
await agentAPI.saveAgentSettings(patch);
expect(binding.SaveAgentSettings).toHaveBeenCalledWith(patch);
});
it("exportLog 透傳 Go 回的 path", async () => {
binding.ExportLog.mockResolvedValue("/tmp/export.zip");
const path = await agentAPI.exportLog();
expect(path).toBe("/tmp/export.zip");
});
it("openURLWails 環境)呼叫 runtime.BrowserOpenURL", () => {
// Fix-F3Wails 環境下走 BrowserOpenURL不該打到 window.open
const winOpenSpy = vi.spyOn(window, "open").mockImplementation(() => null);
agentAPI.openURL("https://docs.visionA.cloud/agent");
expect(browserOpenURL).toHaveBeenCalledWith("https://docs.visionA.cloud/agent");
expect(winOpenSpy).not.toHaveBeenCalled();
winOpenSpy.mockRestore();
});
it("onConnectionStatus 訂閱 Wails event", () => {
const cb = vi.fn();
onConnectionStatus(cb);
expect(eventsOn).toHaveBeenCalledWith(
"connection:status",
expect.any(Function),
);
});
it("onConnectionStatus 收到 Go payload 會轉成 TS snapshot 後呼 callback", () => {
const cb = vi.fn();
onConnectionStatus(cb);
const wailsHandler = eventsOn.mock.calls[0][1] as (
...data: unknown[]
) => void;
// 模擬 Wails 推一個 payloadspread 參數進來)
wailsHandler({
state: "online",
relayUrl: "wss://r",
connectedSince: 1_700_000_000_000,
});
expect(cb).toHaveBeenCalledOnce();
const snap = cb.mock.calls[0][0] as ConnectionSnapshot;
expect(snap.state).toBe("online");
expect(snap.connectedSince).toBe(
new Date(1_700_000_000_000).toISOString(),
);
});
});

View File

@ -0,0 +1,519 @@
"use client";
/**
* agent-api Wails binding AF6
*
* Go backend
*
* 1. Wails runtimewails dev / app
* - `window.go.main.App.<Method>()` Wails
* - `window.runtime.EventsOn / EventsOff` Wails
* - visiona-agent/agent_bindings.go
*
* 2. dev`pnpm dev` Next.js vitest jsdom
* - `window.go` mock
* - Mock UI
*
*
* - Go DTO TS int64 unix ms ISO string
* - Go error PairError `pair()`
* - Hook TS `ConnectionSnapshot` / `AgentSettings` / ...
*
* `visiona-agent/agent_bindings.go`Go struct json tag
* Go json tag
*/
import type {
AgentSettings,
ConnectionSnapshot,
ConnectionState,
LogEntry,
PairError,
PairErrorCode,
TestRelayResult,
} from "@/types/agent";
/* -------------------------------------------------------------------------- */
/* Wails runtime 偵測 */
/* -------------------------------------------------------------------------- */
/**
* Wails runtime
*
*
* - window SSR / Next.js build time undefined
* - window.go.main.App Wails Go binding namespace
*
* window.go Wails
* effect / callback
*/
function isWailsRuntime(): boolean {
if (typeof window === "undefined") return false;
const w = window as unknown as WailsWindow;
return Boolean(w.go?.main?.App);
}
/** Wails runtime 事件取消訂閱函式的空實作mock 環境用)。 */
const NOOP_UNSUBSCRIBE = () => {};
/* -------------------------------------------------------------------------- */
/* Wails binding 的型別宣告window.go.main.App 的最小 shape */
/* -------------------------------------------------------------------------- */
/** Go ConnectionStatus DTO 的原始形狀(見 agent_bindings.go。 */
interface GoConnectionStatus {
state: string;
error?: string;
attemptNo?: number;
relayUrl: string;
account?: string;
/** Unix millis0 / undefined 代表尚未連線 */
connectedSince?: number;
/** Unix millis最後一次 pong 時間 */
lastSeenAt?: number;
sessionTokenPreview?: string;
}
/** Go AgentSettings DTO與 TS AgentSettings 同 shape直接傳。 */
interface GoAgentSettings {
relayUrl: string;
autoStart: boolean;
reconnectStrategy: "auto" | "manual";
logLevel: "debug" | "info" | "warn" | "error";
}
/** Go TestResult DTO與 TS TestRelayResult 同 shape。 */
interface GoTestResult {
ok: boolean;
latencyMs?: number;
reason?: string;
}
/** Go LogLine DTO — 原始 server stdout/stderr 行(非 spec 定義的 connection:log。 */
interface GoLogLine {
ts: number;
stream: string;
line: string;
level?: string;
}
/** visiona-agent/agent_bindings.go 綁定的最小介面。 */
interface AgentBindings {
GetConnectionStatus: () => Promise<GoConnectionStatus>;
Pair: (token: string) => Promise<void>;
Unpair: () => Promise<void>;
Reconnect: () => Promise<void>;
Disconnect: () => Promise<void>;
GetAgentSettings: () => Promise<GoAgentSettings>;
SaveAgentSettings: (settings: GoAgentSettings) => Promise<void>;
TestConnection: (relayURL: string) => Promise<GoTestResult>;
ResetAllSettings: () => Promise<void>;
GetRecentLogs: (n: number) => Promise<GoLogLine[]>;
ExportLog: () => Promise<string>;
}
/** Wails 注入到 window 的全域型別(內部使用;不對外 export。 */
interface WailsWindow {
go?: {
main?: {
App?: AgentBindings;
};
};
runtime?: {
EventsOn: (
eventName: string,
callback: (...data: unknown[]) => void,
) => () => void;
EventsOff: (eventName: string, ...more: string[]) => void;
/**
* Wails URL OS hijack webview
* spec §13.2 / TDD §11.3 API
*/
BrowserOpenURL?: (url: string) => void;
};
}
/** 取得 Wails App binding必在 isWailsRuntime() === true 時呼叫)。 */
function goApp(): AgentBindings {
const w = window as unknown as WailsWindow;
const app = w.go?.main?.App;
if (!app) {
throw new Error("agent-api: Wails binding not available (window.go.main.App missing)");
}
return app;
}
/* -------------------------------------------------------------------------- */
/* Go DTO → TS 介面的轉換 */
/* -------------------------------------------------------------------------- */
/** 安全把 Go state 字串轉成 ConnectionState未知值 fallback 到 "error")。 */
function normalizeState(s: string | undefined): ConnectionState {
switch (s) {
case "notPaired":
case "connecting":
case "online":
case "reconnecting":
case "offline":
case "error":
return s;
default:
return "error";
}
}
/** Go ConnectionStatusms 時間)→ TS ConnectionSnapshotISO 時間)。 */
function toSnapshot(go: GoConnectionStatus): ConnectionSnapshot {
const snapshot: ConnectionSnapshot = {
state: normalizeState(go.state),
relayUrl: go.relayUrl ?? "",
};
if (go.error) snapshot.error = go.error;
if (go.attemptNo) snapshot.attemptNo = go.attemptNo;
if (go.account) snapshot.account = go.account;
if (go.connectedSince && go.connectedSince > 0) {
snapshot.connectedSince = new Date(go.connectedSince).toISOString();
}
if (go.sessionTokenPreview) {
snapshot.sessionTokenPreview = go.sessionTokenPreview;
}
return snapshot;
}
/**
* Go error PairErrorCode
*
* Wails Go `error` `.Error()` JS `Error.message`
* `visiona-agent/internal/tunnel/pairing.go` sentinel error
*
* ErrInvalidTokenFormat "invalid pairing token format..."
* ErrTokenInvalid "pairing token invalid"
* ErrTokenExpired "pairing token expired"
* ErrTokenUsed "pairing token already used"
* ErrTokenRevoked "pairing token revoked"
*
* exchange endpoint not found / network error
* fallback `network_error` `unknown`
*/
function parsePairError(err: unknown): PairError {
const message = err instanceof Error ? err.message : String(err);
const lower = message.toLowerCase();
let code: PairErrorCode;
if (lower.includes("token") && lower.includes("format")) {
code = "token_invalid";
} else if (lower.includes("token") && lower.includes("expired")) {
code = "token_expired";
} else if (lower.includes("token") && lower.includes("already used")) {
code = "token_used";
} else if (lower.includes("token") && lower.includes("revoked")) {
code = "token_revoked";
} else if (lower.includes("token") && lower.includes("invalid")) {
code = "token_invalid";
} else if (
lower.includes("relay") ||
lower.includes("websocket") ||
lower.includes("dial") ||
lower.includes("connection refused")
) {
code = "relay_unreachable";
} else if (
lower.includes("network") ||
lower.includes("timeout") ||
lower.includes("no such host")
) {
code = "network_error";
} else {
code = "unknown";
}
return { code };
}
/* -------------------------------------------------------------------------- */
/* Mock 實作(`pnpm dev` / vitest 環境用) */
/* -------------------------------------------------------------------------- */
/**
* Mock `notPaired` spec §4.3
* hook override
*/
const MOCK_INITIAL_SNAPSHOT: ConnectionSnapshot = {
state: "notPaired",
relayUrl: "wss://relay.visionA.cloud",
};
const MOCK_DEFAULT_SETTINGS: AgentSettings = {
relayUrl: "wss://relay.visionA.cloud",
autoStart: false,
reconnectStrategy: "auto",
logLevel: "info",
};
/** Mock 記憶體內的設定(讓 `pnpm dev` 測 UI 流程時能看到 save 效果)。 */
let mockSettings: AgentSettings = { ...MOCK_DEFAULT_SETTINGS };
const mockImpl = {
async getConnectionStatus(): Promise<ConnectionSnapshot> {
return { ...MOCK_INITIAL_SNAPSHOT };
},
async pair(token: string): Promise<void> {
// 模擬 1 秒延遲 + 格式驗證
await sleep(800);
if (!/^vAc_[a-f0-9]{32}$/i.test(token)) {
const err: PairError = { code: "token_invalid" };
throw err;
}
// 成功路徑 — 呼叫端會依 pair:result event 切 tabmock 環境沒 event靠 usePair promise resolve 就夠)
},
async unpair(): Promise<void> {
mockSettings = { ...MOCK_DEFAULT_SETTINGS };
},
async reconnect(): Promise<void> {
// no-op
},
async disconnect(): Promise<void> {
// no-op
},
async getAgentSettings(): Promise<AgentSettings> {
return { ...mockSettings };
},
async saveAgentSettings(settings: AgentSettings): Promise<void> {
mockSettings = { ...settings };
},
async testConnection(relayURL: string): Promise<TestRelayResult> {
await sleep(500);
if (!/^wss?:\/\//i.test(relayURL)) {
return { ok: false, reason: "URL must start with ws:// or wss://" };
}
return { ok: true, latencyMs: 42 };
},
async resetAllSettings(): Promise<void> {
mockSettings = { ...MOCK_DEFAULT_SETTINGS };
},
async getRecentLogs(): Promise<LogEntry[]> {
// Phase 0空陣列RecentLog 元件會渲染 empty state
return [];
},
async exportLog(): Promise<string> {
await sleep(300);
return "~/Downloads/visionA-agent-log-mock.zip";
},
};
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/* -------------------------------------------------------------------------- */
/* 統一 API */
/* -------------------------------------------------------------------------- */
/**
* Agent API method real vs mock
* hooks
*
* method
* - `pair()` reject `PairError``{ code, relayUrl? }`
* - method reject `Error` Go
* - 使 try/catch pair toast
*/
export const agentAPI = {
/** 讀取當前連線狀態快照(狀態頁初始化用)。 */
async getConnectionStatus(): Promise<ConnectionSnapshot> {
if (!isWailsRuntime()) return mockImpl.getConnectionStatus();
const raw = await goApp().GetConnectionStatus();
return toSnapshot(raw);
},
/**
* Pairing Token
* reject `PairError` `Error` `.code`
*/
async pair(token: string): Promise<void> {
if (!isWailsRuntime()) return mockImpl.pair(token);
try {
await goApp().Pair(token);
} catch (err) {
throw parsePairError(err);
}
},
/** 清除本地 Session Token 並斷開 tunnel。 */
async unpair(): Promise<void> {
if (!isWailsRuntime()) return mockImpl.unpair();
return goApp().Unpair();
},
/** 觸發手動重連(狀態頁「立即重試」按鈕)。 */
async reconnect(): Promise<void> {
if (!isWailsRuntime()) return mockImpl.reconnect();
return goApp().Reconnect();
},
/** 主動斷開 tunnel保留 Session Token。 */
async disconnect(): Promise<void> {
if (!isWailsRuntime()) return mockImpl.disconnect();
return goApp().Disconnect();
},
/** 讀取 Agent 設定(設定頁初始化用)。 */
async getAgentSettings(): Promise<AgentSettings> {
if (!isWailsRuntime()) return mockImpl.getAgentSettings();
const raw = await goApp().GetAgentSettings();
// Go DTO 與 TS 介面同 shape直接透傳只是加一層 copy 避免 Wails 內部 object 洩漏)
return {
relayUrl: raw.relayUrl,
autoStart: raw.autoStart,
reconnectStrategy: raw.reconnectStrategy,
logLevel: raw.logLevel,
};
},
/** 儲存 Agent 設定(設定頁各欄位變更時呼叫)。 */
async saveAgentSettings(settings: AgentSettings): Promise<void> {
if (!isWailsRuntime()) return mockImpl.saveAgentSettings(settings);
return goApp().SaveAgentSettings({
relayUrl: settings.relayUrl,
autoStart: settings.autoStart,
reconnectStrategy: settings.reconnectStrategy,
logLevel: settings.logLevel,
});
},
/** 測試 Relay URL 的 reachability設定頁「測試連線」按鈕。 */
async testConnection(relayURL: string): Promise<TestRelayResult> {
if (!isWailsRuntime()) return mockImpl.testConnection(relayURL);
const raw = await goApp().TestConnection(relayURL);
return {
ok: raw.ok,
latencyMs: raw.latencyMs,
reason: raw.reason,
};
},
/** 重置所有設定 + 清除 Session Token設定頁危險區域。 */
async resetAllSettings(): Promise<void> {
if (!isWailsRuntime()) return mockImpl.resetAllSettings();
return goApp().ResetAllSettings();
},
/**
* N log RecentLog
*
* Phase 0real binding `GoLogLine[]` server stdout/stderr
* spec §4.2 D `{icon, text}` AB
* `connection:log` event emission UI RecentLog empty state
*
*/
async getRecentLogs(max: number): Promise<LogEntry[]> {
if (!isWailsRuntime()) return mockImpl.getRecentLogs();
// TODO(AB): Go 側尚未 emit spec-shape 的 connection:log目前 binding 只能
// 拿到 server stdout/stderr。Phase 0 一律回空,真實事件靠 onConnectionLog 推。
// `max` 參數保留介面Go 版支援讀取最近 N 筆);未來補 LogLine→LogEntry 映射時會用到。
void max;
return [];
},
/** 觸發匯出 Log回傳 zip 絕對路徑;失敗 reject。 */
async exportLog(): Promise<string> {
if (!isWailsRuntime()) return mockImpl.exportLog();
return goApp().ExportLog();
},
/**
* OS URLspec §13.2 / TDD §11.3 / Fix-F3
*
* `window.open`
* - Wails webview `window.open` macOS WKWebView
* hijack webviewWindows
* - `BrowserOpenURL` OS
*
*
* - Wails runtime `window.runtime.BrowserOpenURL(url)`
* - Mock pnpm dev / vitestfallback `window.open(url, '_blank', 'noopener,noreferrer')`
*
*
* Promise Wails BrowserOpenURL fire-and-forget
*/
openURL(url: string): void {
if (typeof window === "undefined") return;
if (isWailsRuntime()) {
const w = window as unknown as WailsWindow;
const open = w.runtime?.BrowserOpenURL;
if (open) {
open(url);
return;
}
// Wails runtime 存在但 BrowserOpenURL 未注入(極罕見,例如舊版 Wails— 退到 window.open
}
window.open(url, "_blank", "noopener,noreferrer");
},
};
/* -------------------------------------------------------------------------- */
/* 事件訂閱Wails runtime 專屬mock 環境為 no-op */
/* -------------------------------------------------------------------------- */
/**
* connection:status eventWails runtime Go emit
*
*
* useEffect(() => {
* const off = onConnectionStatus((snap) => setSnapshot(snap));
* return off;
* }, []);
*
* Mock pnpm dev / vitestevent no-op unsubscribe
* useEffect cleanup便 Wails runtime
*/
export function onConnectionStatus(
callback: (snapshot: ConnectionSnapshot) => void,
): () => void {
if (!isWailsRuntime()) return NOOP_UNSUBSCRIBE;
const w = window as unknown as WailsWindow;
const runtime = w.runtime;
if (!runtime) return NOOP_UNSUBSCRIBE;
return runtime.EventsOn("connection:status", (...data: unknown[]) => {
const payload = data[0] as GoConnectionStatus | undefined;
if (!payload) return;
callback(toSnapshot(payload));
});
}
/**
* connection:log event Go emit AB
*
* hook
*/
export function onConnectionLog(
callback: (entry: LogEntry) => void,
): () => void {
if (!isWailsRuntime()) return NOOP_UNSUBSCRIBE;
const w = window as unknown as WailsWindow;
const runtime = w.runtime;
if (!runtime) return NOOP_UNSUBSCRIBE;
return runtime.EventsOn("connection:log", (...data: unknown[]) => {
const payload = data[0] as LogEntry | undefined;
if (!payload) return;
callback(payload);
});
}
/* -------------------------------------------------------------------------- */
/* Test helpersvitest 專用,不走 production bundle */
/* -------------------------------------------------------------------------- */
/**
* mock test
*
* mock
*/
export function __resetMockForTest(): void {
mockSettings = { ...MOCK_DEFAULT_SETTINGS };
}
/** 測試用 — 檢查是否在 Wails runtime單元測試驗證 isWailsRuntime 判斷正確)。 */
export function __isWailsRuntimeForTest(): boolean {
return isWailsRuntime();
}

View File

@ -0,0 +1,92 @@
/**
* i18n Context / useT() visionA Agent
*
*
* 1. LocaleProvider useT()
* 2. locale useT()
* 3. key fallback key console.warn
*
* AF1 app.* / common.* `app.tagline`
* locale
*/
import { act, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { LocaleProvider, useLocale, useT } from "./context";
function Probe() {
const t = useT();
const { locale, setLocale } = useLocale();
return (
<div>
<span data-testid="locale">{locale}</span>
<span data-testid="title">{t("app.title")}</span>
{/* tagline locale
app.title "visionA Agent" */}
<span data-testid="tagline">{t("app.tagline")}</span>
<span data-testid="missing">{t("this.key.does.not.exist")}</span>
<button type="button" onClick={() => setLocale("en")} data-testid="to-en">
en
</button>
<button type="button" onClick={() => setLocale("zh-Hant")} data-testid="to-zh">
zh-Hant
</button>
</div>
);
}
describe("LocaleProvider + useT", () => {
let warnSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
// 攔截 console.warn非 production 模式下 useT 會 warn 缺漏的 key
warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
});
afterEach(() => {
warnSpy.mockRestore();
});
it("預設以 zh-Hant 渲染", () => {
render(
<LocaleProvider>
<Probe />
</LocaleProvider>,
);
expect(screen.getByTestId("locale").textContent).toBe("zh-Hant");
expect(screen.getByTestId("title").textContent).toBe("visionA Agent");
});
it("切換到 en 後 useT() 回傳英文字串", () => {
render(
<LocaleProvider>
<Probe />
</LocaleProvider>,
);
// 切換前先驗預設 zh-Hant tagline
expect(screen.getByTestId("tagline").textContent).toBe(
"將你的 Kneron 裝置連上 visionA 雲端",
);
act(() => {
screen.getByTestId("to-en").click();
});
expect(screen.getByTestId("locale").textContent).toBe("en");
expect(screen.getByTestId("tagline").textContent).toBe(
"Connect your Kneron devices to the visionA cloud",
);
});
it("找不到 key 時回傳 key 本身並發出 warning", () => {
render(
<LocaleProvider>
<Probe />
</LocaleProvider>,
);
expect(screen.getByTestId("missing").textContent).toBe("this.key.does.not.exist");
expect(warnSpy).toHaveBeenCalled();
});
});

View File

@ -0,0 +1,92 @@
"use client";
/**
* i18n React Context visionA Cloud
*
*
* - <LocaleProvider> app root layout locale
* - useLocale() locale
* - useT() `t(key)` locale re-render
*
*
* 1. DEFAULT_LOCALE SSR Client render hydration mismatch
* 2. localStorage <LocaleSync /> mount sync.tsx
* 3. `t()` key key console.warn
*/
import { createContext, useCallback, useContext, useMemo, useState } from "react";
import { dictionaries } from "./index";
import { DEFAULT_LOCALE, LOCALE_STORAGE_KEY, type Locale } from "./types";
/** Context 內容型別:當前 locale + setter。 */
interface LocaleContextValue {
locale: Locale;
setLocale: (next: Locale) => void;
}
const LocaleContext = createContext<LocaleContextValue | null>(null);
interface LocaleProviderProps {
/** 初始 localeSSR 情境下建議使用 DEFAULT_LOCALE 避免 hydration mismatch。 */
initialLocale?: Locale;
children: React.ReactNode;
}
/**
* locale provider root layout ThemeProvider
*/
export function LocaleProvider({ initialLocale = DEFAULT_LOCALE, children }: LocaleProviderProps) {
const [locale, setLocaleState] = useState<Locale>(initialLocale);
/** 切換 locale 同時寫入 localStorage下次造訪自動恢復。 */
const setLocale = useCallback((next: Locale) => {
setLocaleState(next);
if (typeof window !== "undefined") {
try {
window.localStorage.setItem(LOCALE_STORAGE_KEY, next);
} catch {
// localStorage 可能被使用者停用Safari 隱私模式),靜默忽略即可
}
}
}, []);
const value = useMemo<LocaleContextValue>(() => ({ locale, setLocale }), [locale, setLocale]);
return <LocaleContext.Provider value={value}>{children}</LocaleContext.Provider>;
}
/**
* locale setter
* <LocaleProvider> 使使
*/
export function useLocale(): LocaleContextValue {
const ctx = useContext(LocaleContext);
if (!ctx) {
throw new Error("useLocale 必須在 <LocaleProvider> 內使用");
}
return ctx;
}
/** `t()` 函式型別:輸入 key 回傳翻譯後字串。 */
export type TranslateFn = (key: string) => string;
/**
* locale `t()`
* - key key warningproduction log
* - locale useMemo `t()` re-render
*/
export function useT(): TranslateFn {
const { locale } = useLocale();
return useMemo<TranslateFn>(() => {
const dict = dictionaries[locale];
return (key: string) => {
const value = dict[key];
if (typeof value === "string") return value;
if (process.env.NODE_ENV !== "production") {
// 漏譯是開發時必須注意的警示production 模式靜默
console.warn(`[i18n] 缺少翻譯 key${key}」(locale=${locale})`);
}
return key;
};
}, [locale]);
}

View File

@ -0,0 +1,175 @@
/**
* English dictionary visionA Agent
*
* Phased expansion:
* - AF1: `app.*` / `common.*` (minimum base layer)
* - AF2: Added `nav.*` (tab navigation), `connection.*` (status badge), `header.*` (header toolbar),
* and `view.*` (placeholders for the three views)
* - AF3-AF5: Added `status.*` / `pair.*` / `settings.*` business keys;
* removed consumed `view.*` placeholders
* - AF7: Dictionary review / cleanup; key sets and section order aligned with zh-Hant.ts;
* every key referenced by the UI is defined.
*
* Section order (must stay in sync with zh-Hant.ts):
* app / common / nav / connection / header / status / pair / settings
*
* Keep in sync with zh-Hant.ts `i18n.test.ts` enforces identical key sets
* across both locales. Placeholders use `{name}` tokens (e.g. `{n}`, `{url}`)
* that callers replace via `String.prototype.replace`.
*
* Reserved but currently unused keys (do not delete they will be activated
* when Phase 1 / AF6 brings back the corresponding UI):
* - `common.close` / `common.confirm` / `common.error` / `common.retry` / `common.save`
* generic button / copy pool
*
* Activated by Fix-F1 / Fix-F4:
* - `status.action.repair` StatusView secondary button for error / offline
* - `settings.unsavedChanges` SettingsView title-row "unsaved" badge
*/
import type { Dictionary } from "../types";
export const en: Dictionary = {
// ── Application ──
"app.title": "visionA Agent",
"app.tagline": "Connect your Kneron devices to the visionA cloud",
// ── Common ──
"common.loading": "Loading…",
"common.error": "Something went wrong",
"common.cancel": "Cancel",
"common.confirm": "Confirm",
"common.save": "Save",
"common.close": "Close",
"common.retry": "Retry",
// ── Tab navigation ──
"nav.status": "Status",
"nav.pair": "Pair",
"nav.settings": "Settings",
// ── Connection status (shared by Header badge / StatusHero) ──
"connection.online": "Connected",
"connection.offline": "Offline",
"connection.reconnecting": "Reconnecting",
"connection.notPaired": "Not Paired",
"connection.error": "Connection Error",
// ── Header toolbar ──
"header.toggleTheme": "Toggle Theme",
"header.toggleLocale": "Toggle Language",
// ── Status view — StatusHero (spec §4.2 A) ──
"status.hero.online": "Connected",
"status.hero.offline": "Offline",
"status.hero.reconnecting": "Reconnecting…",
"status.hero.notPaired": "Not paired",
"status.hero.error": "Connection error",
"status.hero.attemptNo": "Attempt {n} of 5",
// ── Status view — InfoCard (spec §4.2 B) ──
"status.info.account": "Account",
"status.info.relayUrl": "Relay",
"status.info.connectedAt": "Connected at",
"status.info.sessionToken": "Session",
"status.info.sessionToken.aria": "Session token, ending in {last4}",
// ── Status view — main buttons (spec §4.2 C) ──
"status.action.disconnect": "Disconnect",
"status.action.reconnect": "Reconnect",
"status.action.goPair": "Go to pairing",
"status.action.retry": "Retry",
"status.action.repair": "Re-pair",
// ── Status view — Disconnect / Repair AlertDialog ──
"status.confirm.disconnect.title": "Disconnect from the cloud?",
"status.confirm.disconnect.description":
"Once disconnected, this device will be unavailable remotely. The session token stays valid — you can reconnect any time.",
// ── Status view — Log block (spec §4.2 D) ──
"status.log.title": "Recent activity",
"status.log.empty": "No recent activity",
// ── Status view — empty state (not paired, spec §4.3) ──
"status.empty.title": "No device paired yet",
"status.empty.description":
"Generate a pairing token on the visionA cloud, then paste it in the Pair tab to get started.",
// ── Pair view ──
"pair.title": "Pair with visionA Cloud",
"pair.description":
"Paste the pairing token so this device shows up on your cloud dashboard.",
"pair.input.label": "Pairing Token",
"pair.input.placeholder": "Paste a token starting with vAc_",
"pair.input.hintEmpty": "Paste your token above",
"pair.input.errorFormat":
"Invalid format — expected vAc_ prefix + 32 hex characters",
"pair.input.valid": "Looks good (36 characters)",
"pair.linkCloud": "Don't have a token? Generate one on the cloud",
"pair.button.submit": "Pair",
"pair.button.submitting": "Connecting to cloud…",
"pair.button.cancel": "Cancel",
"pair.alert.security":
"Pairing tokens expire after 15 minutes and are single-use. The Agent swaps it for a long-lived session automatically — you won't need to remember or paste it again.",
"pair.success": "Successfully paired",
// ── Pair view errors (spec §5.4) ──
"pair.error.tokenExpired": "This token has expired (15 min lifetime)",
"pair.error.tokenUsed": "This token has already been used by another device",
"pair.error.tokenInvalid": "Invalid or malformed token",
"pair.error.tokenRevoked": "This token has been revoked",
"pair.error.network":
"Unable to reach the cloud — please check your network",
"pair.error.relayUnreachable":
"Cannot connect to relay {url}. Please verify the setting.",
"pair.error.unknown": "Pairing failed — please check the log",
// ── Settings — connection (spec §6.2.1) ──
"settings.section.connection": "Connection",
"settings.relayUrl.label": "Relay URL",
"settings.relayUrl.hint": "Changes apply on the next connection attempt",
"settings.relayUrl.invalidProtocol": "Must start with ws:// or wss://",
"settings.testConnection.button": "Test connection",
"settings.testConnection.testing": "Testing…",
"settings.testConnection.success": "Relay reachable ({latency}ms)",
"settings.testConnection.failed": "Cannot reach {url}: {reason}",
// ── Settings — behavior (spec §6.2.2) ──
"settings.section.behavior": "Behavior",
"settings.autoStart.label": "Launch visionA Agent at startup",
"settings.reconnect.label": "Reconnection strategy",
"settings.reconnect.auto": "Retry automatically (exponential backoff, up to 5 attempts)",
"settings.reconnect.manual": "Reconnect manually",
// ── Settings — Log (spec §6.2.3) ──
"settings.section.log": "Log",
"settings.log.level": "Log level",
"settings.log.level.debug": "Debug",
"settings.log.level.info": "Info",
"settings.log.level.warn": "Warn",
"settings.log.level.error": "Error",
"settings.log.location": "Log location",
"settings.log.export": "Export log…",
"settings.log.exporting": "Exporting…",
"settings.log.exported": "Exported to {path}",
// ── Settings — About (spec §6.2.4) ──
"settings.section.about": "About",
"settings.about.version": "Version",
"settings.about.docs": "Open docs",
"settings.about.github": "GitHub",
"settings.about.checkUpdate": "Check for updates",
"settings.about.checkUpdate.phase1": "Available in Phase 1",
// ── Settings — danger zone (spec §6.2.5) ──
"settings.section.danger": "Danger zone",
"settings.reset.button": "Reset all settings",
"settings.reset.confirm.title": "Reset all settings?",
"settings.reset.confirm.description":
"This will clear: relay URL, autostart, log level, and your paired session token. You'll need to re-pair before using the device. This cannot be undone.",
"settings.reset.confirm.ok": "Confirm reset",
"settings.reset.done": "All settings reset",
// ── Settings — cross-field (Fix-F4 / spec §6.2.1) ──
"settings.unsavedChanges": "Unsaved changes",
};

View File

@ -0,0 +1,170 @@
/**
* visionA Agent
*
*
* - AF1`app.*` / `common.*`
* - AF2 `nav.*`Tab `connection.*` badge`header.*`Header
* `view.*` placeholder
* - AF3-AF5 `status.*` / `pair.*` / `settings.*` key `view.*` placeholder
* - AF7 key 使 key
*
*
* app / common / nav / connection / header / status / pair / settings
*
*
* - key`nav.status` visionA-frontend i18n loader
* - + 使
* - i18n.test.ts en.ts key
* - `{placeholder}` `第 {n}/5 次嘗試` string.replace
*
* 使 keyPhase 1 / AF6 UI
* - `common.close` / `common.confirm` / `common.error` / `common.retry` / `common.save`
* /
*
* Fix-F1 / Fix-F4
* - `status.action.repair` StatusView error / offline
* - `settings.unsavedChanges` SettingsView badge
*/
import type { Dictionary } from "../types";
export const zhHant: Dictionary = {
// ── 應用 ──
"app.title": "visionA Agent",
"app.tagline": "將你的 Kneron 裝置連上 visionA 雲端",
// ── 通用 ──
"common.loading": "載入中…",
"common.error": "發生錯誤",
"common.cancel": "取消",
"common.confirm": "確認",
"common.save": "儲存",
"common.close": "關閉",
"common.retry": "重試",
// ── Tab 導航 ──
"nav.status": "狀態",
"nav.pair": "配對",
"nav.settings": "設定",
// ── 連線狀態Header badge / StatusHero 共用)──
"connection.online": "已連線",
"connection.offline": "離線",
"connection.reconnecting": "重新連線中",
"connection.notPaired": "尚未配對",
"connection.error": "連線錯誤",
// ── Header 工具列 ──
"header.toggleTheme": "切換主題",
"header.toggleLocale": "切換語言",
// ── 狀態頁 — StatusHerospec §4.2 A──
"status.hero.online": "已連線",
"status.hero.offline": "離線",
"status.hero.reconnecting": "重新連線中…",
"status.hero.notPaired": "尚未配對",
"status.hero.error": "連線錯誤",
"status.hero.attemptNo": "第 {n}/5 次嘗試",
// ── 狀態頁 — InfoCardspec §4.2 B──
"status.info.account": "帳號",
"status.info.relayUrl": "Relay",
"status.info.connectedAt": "連線開始",
"status.info.sessionToken": "Session",
"status.info.sessionToken.aria": "Session token結尾為 {last4}",
// ── 狀態頁 — 主按鈕spec §4.2 C──
"status.action.disconnect": "斷開連線",
"status.action.reconnect": "重新連線",
"status.action.goPair": "前往配對",
"status.action.retry": "重試",
"status.action.repair": "重新配對",
// ── 狀態頁 — Disconnect / Repair AlertDialog ──
"status.confirm.disconnect.title": "確定要斷開連線嗎?",
"status.confirm.disconnect.description":
"斷開後遠端將無法使用此裝置。Session Token 不會被撤銷,下次可直接重連。",
// ── 狀態頁 — Log 區塊spec §4.2 D──
"status.log.title": "最近連線紀錄",
"status.log.empty": "尚無連線紀錄",
// ── 狀態頁 — 空狀態未配對spec §4.3)──
"status.empty.title": "尚未配對裝置",
"status.empty.description":
"請到雲端 Web 產生 Pairing Token然後在「配對」分頁貼上即可開始",
// ── 配對頁(pair)──
"pair.title": "配對到 visionA 雲端",
"pair.description": "貼上 Pairing Token讓你的裝置出現在雲端 Web",
"pair.input.label": "Pairing Token",
"pair.input.placeholder": "貼上 vAc_... 格式的 token",
"pair.input.hintEmpty": "請貼上 token",
"pair.input.errorFormat": "格式不正確 — token 應為 vAc_ 開頭 + 32 字元 hex",
"pair.input.valid": "格式正確36 字元)",
"pair.linkCloud": "還沒有 token到雲端網頁產生",
"pair.button.submit": "配對",
"pair.button.submitting": "正在連線到雲端…",
"pair.button.cancel": "取消",
"pair.alert.security":
"Token 15 分鐘內有效一次性使用。Agent 會自動換取長期 Session你不需要記住或重複貼上。",
"pair.success": "配對成功",
// ── 配對頁錯誤訊息spec §5.4)──
"pair.error.tokenExpired": "此 Token 已過期15 分鐘有效)",
"pair.error.tokenUsed": "此 Token 已被其他裝置使用",
"pair.error.tokenInvalid": "Token 無效或格式錯誤",
"pair.error.tokenRevoked": "此 Token 已被撤銷",
"pair.error.network": "無法連上雲端服務,請檢查網路",
"pair.error.relayUnreachable": "Relay {url} 無法連線,請確認設定",
"pair.error.unknown": "配對失敗,請查看 log",
// ── 設定頁 — 連線spec §6.2.1)──
"settings.section.connection": "連線",
"settings.relayUrl.label": "Relay URL",
"settings.relayUrl.hint": "當前設定將於下次連線時生效",
"settings.relayUrl.invalidProtocol": "必須以 ws:// 或 wss:// 開頭",
"settings.testConnection.button": "測試連線",
"settings.testConnection.testing": "測試中…",
"settings.testConnection.success": "Relay 可達({latency}ms",
"settings.testConnection.failed": "無法連上 {url}{reason}",
// ── 設定頁 — 行為spec §6.2.2)──
"settings.section.behavior": "行為",
"settings.autoStart.label": "開機自動啟動 visionA Agent",
"settings.reconnect.label": "重連策略",
"settings.reconnect.auto": "自動重試(指數退避,最多 5 次)",
"settings.reconnect.manual": "手動重連",
// ── 設定頁 — Logspec §6.2.3)──
"settings.section.log": "Log",
"settings.log.level": "Log 等級",
"settings.log.level.debug": "Debug",
"settings.log.level.info": "Info",
"settings.log.level.warn": "Warn",
"settings.log.level.error": "Error",
"settings.log.location": "Log 位置",
"settings.log.export": "匯出 Log…",
"settings.log.exporting": "匯出中…",
"settings.log.exported": "已匯出到 {path}",
// ── 設定頁 — 關於spec §6.2.4)──
"settings.section.about": "關於",
"settings.about.version": "版本",
"settings.about.docs": "開啟文件",
"settings.about.github": "GitHub",
"settings.about.checkUpdate": "檢查更新",
"settings.about.checkUpdate.phase1": "Phase 1 才支援",
// ── 設定頁 — 危險區域spec §6.2.5)──
"settings.section.danger": "危險區域",
"settings.reset.button": "重置所有設定",
"settings.reset.confirm.title": "確定要重置所有設定嗎?",
"settings.reset.confirm.description":
"這將清除Relay URL、開機自啟、Log 等級,以及已配對的 Session Token。你將需要重新配對才能使用。此操作無法復原。",
"settings.reset.confirm.ok": "確定重置",
"settings.reset.done": "已重置所有設定",
// ── 設定頁 — 跨欄位Fix-F4 / spec §6.2.1)──
"settings.unsavedChanges": "有未儲存的變更",
};

View File

@ -0,0 +1,72 @@
/**
* i18n + fallback visionA Agent
*
*
* 1. zh-Hant en key
* 2. dictionaries Locale Dictionary
* 3. isLocale /
* 4. key
*
* AF1 `app.*` `common.*`AF7 agent key
* key
*/
import { describe, expect, it } from "vitest";
import { dictionaries, DEFAULT_LOCALE, SUPPORTED_LOCALES, isLocale } from "./index";
describe("i18n — 字典完整性", () => {
it("SUPPORTED_LOCALES 應包含 zh-Hant 與 en", () => {
expect(SUPPORTED_LOCALES).toContain("zh-Hant");
expect(SUPPORTED_LOCALES).toContain("en");
});
it("DEFAULT_LOCALE 預設為 zh-Hant", () => {
expect(DEFAULT_LOCALE).toBe("zh-Hant");
});
it("zh-Hant 與 en 必須擁有相同的 key 集合(避免漏譯)", () => {
const zhKeys = Object.keys(dictionaries["zh-Hant"]).sort();
const enKeys = Object.keys(dictionaries.en).sort();
expect(zhKeys).toEqual(enKeys);
});
it("所有 key 的 value 必須為非空字串", () => {
for (const locale of SUPPORTED_LOCALES) {
const dict = dictionaries[locale];
for (const [key, value] of Object.entries(dict)) {
expect(typeof value, `[${locale}] ${key} 應為 string`).toBe("string");
expect(value.length, `[${locale}] ${key} 不應為空字串`).toBeGreaterThan(0);
}
}
});
});
describe("i18n — 字串查詢", () => {
it("zh-Hant 能取得 app.title", () => {
expect(dictionaries["zh-Hant"]["app.title"]).toBe("visionA Agent");
});
it("en 能取得 common.cancel", () => {
expect(dictionaries.en["common.cancel"]).toBe("Cancel");
});
it("找不到 key 時字典回傳 undefined由 t() 負責 fallback 到 key", () => {
expect(dictionaries["zh-Hant"]["non.existent.key"]).toBeUndefined();
});
});
describe("i18n — isLocale 型別守衛", () => {
it("合法 locale 回傳 true", () => {
expect(isLocale("zh-Hant")).toBe(true);
expect(isLocale("en")).toBe(true);
});
it("非法輸入回傳 false", () => {
expect(isLocale("zh-CN")).toBe(false);
expect(isLocale("")).toBe(false);
expect(isLocale(null)).toBe(false);
expect(isLocale(undefined)).toBe(false);
expect(isLocale(123)).toBe(false);
});
});

View File

@ -0,0 +1,22 @@
/**
* i18n visionA Cloud
*
*
* import { dictionaries, DEFAULT_LOCALE, type Locale } from "@/lib/i18n";
*/
import { en } from "./dictionaries/en";
import { zhHant } from "./dictionaries/zh-Hant";
import type { Dictionary, Locale } from "./types";
export { DEFAULT_LOCALE, LOCALE_STORAGE_KEY, SUPPORTED_LOCALES, isLocale } from "./types";
export type { Dictionary, Locale } from "./types";
/**
*
* Client Context Provider locale `t()` key
*/
export const dictionaries: Record<Locale, Dictionary> = {
"zh-Hant": zhHant,
en,
};

View File

@ -0,0 +1,44 @@
"use client";
/**
* LocaleSync visionA Cloud
*
* mount localStorage `visionA.locale` LocaleProvider
* Provider useEffect
* 1. SSR hydration mismatchProvider render DEFAULT_LOCALE
* 2. <ThemeProvider> next-themes mount 使
* 3. <html lang="..."> SEO
*/
import { useEffect } from "react";
import { useLocale } from "./context";
import { LOCALE_STORAGE_KEY, isLocale } from "./types";
/**
* <LocaleProvider> root layout null DOM
*/
export function LocaleSync() {
const { locale, setLocale } = useLocale();
// 首次 mount從 localStorage 讀回使用者偏好
useEffect(() => {
if (typeof window === "undefined") return;
try {
const stored = window.localStorage.getItem(LOCALE_STORAGE_KEY);
if (stored && isLocale(stored) && stored !== locale) {
setLocale(stored);
}
} catch {
// localStorage 被禁用時靜默忽略
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- 僅需 mount 時執行一次
}, []);
// locale 變動時同步 <html lang>,對螢幕閱讀器與 SEO 友善
useEffect(() => {
if (typeof document === "undefined") return;
document.documentElement.lang = locale;
}, [locale]);
return null;
}

View File

@ -0,0 +1,36 @@
/**
* i18n visionA Cloud
*
* i18n next-intl Context + localStorage locale
* key`nav.dashboard` local-tool object
*
* 1. key Phase 0
* 2. TypeScript `keyof Dictionary` Paths<T>
* 3. F6 local-tool
*/
/** 支援的語系 — 繁體中文(預設)與英文。 */
export type Locale = "zh-Hant" | "en";
/** 全部支援語系清單(供 UI 產生語言切換選單)。 */
export const SUPPORTED_LOCALES: readonly Locale[] = ["zh-Hant", "en"] as const;
/** 預設語系 — 繁體中文。 */
export const DEFAULT_LOCALE: Locale = "zh-Hant";
/** localStorage 儲存 key統一前綴 visionA.* 避免與其他站點衝突)。 */
export const LOCALE_STORAGE_KEY = "visionA.locale";
/**
* 使 Record<string, string>
* key `dictionaries/zh-Hant.ts` `dictionaries/en.ts`
*/
export type Dictionary = Record<string, string>;
/**
* Locale
* localStorage / URL
*/
export function isLocale(value: unknown): value is Locale {
return typeof value === "string" && (SUPPORTED_LOCALES as readonly string[]).includes(value);
}

View File

@ -0,0 +1,12 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
/**
* shadcn className helper
*
* - clsx class
* - tailwind-merge Tailwind `p-2 p-4` `p-4`
*/
export function cn(...inputs: ClassValue[]): string {
return twMerge(clsx(inputs));
}

View File

@ -0,0 +1,92 @@
/**
* settings-draft-store Fix-F4
*
*
* 1. setDraft
* 2. clearAll draft
* 3. hasUnsaved
* a. draft false
* b. draft saved true
* c. draft saved false badge
*/
import { beforeEach, describe, expect, it } from "vitest";
import type { AgentSettings } from "@/types/agent";
import {
__resetSettingsDraftStoreForTest,
useSettingsDraftStore,
} from "./settings-draft-store";
const SAVED: AgentSettings = {
relayUrl: "wss://relay.visionA.cloud",
autoStart: false,
reconnectStrategy: "auto",
logLevel: "info",
};
beforeEach(() => {
__resetSettingsDraftStoreForTest();
});
describe("settings-draft-store", () => {
it("初始狀態所有 draft 欄位為 undefined", () => {
const s = useSettingsDraftStore.getState();
expect(s.draftRelayUrl).toBeUndefined();
expect(s.draftAutoStart).toBeUndefined();
expect(s.draftReconnectStrategy).toBeUndefined();
expect(s.draftLogLevel).toBeUndefined();
});
it("setDraft 寫入單一欄位,其他欄位不受影響", () => {
useSettingsDraftStore.getState().setDraft("draftRelayUrl", "wss://new.test");
const s = useSettingsDraftStore.getState();
expect(s.draftRelayUrl).toBe("wss://new.test");
expect(s.draftAutoStart).toBeUndefined();
expect(s.draftReconnectStrategy).toBeUndefined();
expect(s.draftLogLevel).toBeUndefined();
});
it("setDraft 傳 undefined 等同清除該欄位", () => {
const { setDraft } = useSettingsDraftStore.getState();
setDraft("draftRelayUrl", "wss://temp");
expect(useSettingsDraftStore.getState().draftRelayUrl).toBe("wss://temp");
setDraft("draftRelayUrl", undefined);
expect(useSettingsDraftStore.getState().draftRelayUrl).toBeUndefined();
});
it("clearAll 一次清掉所有 draft", () => {
const { setDraft, clearAll } = useSettingsDraftStore.getState();
setDraft("draftRelayUrl", "wss://x");
setDraft("draftAutoStart", true);
setDraft("draftLogLevel", "debug");
clearAll();
const s = useSettingsDraftStore.getState();
expect(s.draftRelayUrl).toBeUndefined();
expect(s.draftAutoStart).toBeUndefined();
expect(s.draftLogLevel).toBeUndefined();
});
describe("hasUnsaved", () => {
it("沒任何 draft → false", () => {
expect(useSettingsDraftStore.getState().hasUnsaved(SAVED)).toBe(false);
});
it("draft 與 saved 不同 → true", () => {
useSettingsDraftStore.getState().setDraft("draftRelayUrl", "wss://different");
expect(useSettingsDraftStore.getState().hasUnsaved(SAVED)).toBe(true);
});
it("draft 與 saved 一致 → false", () => {
// 使用者改了又改回原值,不應該繼續顯示 badge
useSettingsDraftStore.getState().setDraft("draftRelayUrl", SAVED.relayUrl);
expect(useSettingsDraftStore.getState().hasUnsaved(SAVED)).toBe(false);
});
it("autoStart draft true vs saved false → true", () => {
useSettingsDraftStore.getState().setDraft("draftAutoStart", true);
expect(useSettingsDraftStore.getState().hasUnsaved(SAVED)).toBe(true);
});
});
});

View File

@ -0,0 +1,108 @@
"use client";
/**
* Settings Draft Store visionA AgentFix-F4
*
*
* SettingsView 使 Relay URL tabdraft
* SectionConnection unmount draft in-memory store
* tab
*
* zustand sessionStorage
* - reload agent 使
* - zustand sessionStorage SSR / hydration no JSON parse
* - settings autoStart / reconnectStrategy / logLevel
* onChange save draft relayUrl onBlur
* draft store
*
*
* - 使 setDraft store
* - 使 onBlur save store reset undefined
* - undefined draftUI saved settings
*
* zustand/middleware/persistagent
*/
import { create } from "zustand";
import type {
AgentSettings,
LogLevel,
ReconnectStrategy,
} from "@/types/agent";
/**
* Draft optional`undefined` draftUI fallback saved settings
* shape relayUrl
*/
export interface SettingsDraft {
draftRelayUrl?: string;
draftAutoStart?: boolean;
draftReconnectStrategy?: ReconnectStrategy;
draftLogLevel?: LogLevel;
}
interface SettingsDraftStore extends SettingsDraft {
/** 設定單一欄位的 draft傳 undefined 等同清除該欄位。 */
setDraft: <K extends keyof SettingsDraft>(
key: K,
value: SettingsDraft[K],
) => void;
/** 一次清除所有 draft按儲存後呼叫。 */
clearAll: () => void;
/**
* draft vs saved settings
*
* draft undefined saved
* saved settings store saved
*hook + zustand store hook
*/
hasUnsaved: (saved: AgentSettings) => boolean;
}
/**
* settings draft store
*
*
* const draftRelayUrl = useSettingsDraftStore((s) => s.draftRelayUrl);
* const setDraft = useSettingsDraftStore((s) => s.setDraft);
*/
export const useSettingsDraftStore = create<SettingsDraftStore>((set, get) => ({
draftRelayUrl: undefined,
draftAutoStart: undefined,
draftReconnectStrategy: undefined,
draftLogLevel: undefined,
setDraft: (key, value) => set({ [key]: value } as Partial<SettingsDraft>),
clearAll: () =>
set({
draftRelayUrl: undefined,
draftAutoStart: undefined,
draftReconnectStrategy: undefined,
draftLogLevel: undefined,
}),
hasUnsaved: (saved) => {
const s = get();
if (s.draftRelayUrl !== undefined && s.draftRelayUrl !== saved.relayUrl) return true;
if (s.draftAutoStart !== undefined && s.draftAutoStart !== saved.autoStart) return true;
if (
s.draftReconnectStrategy !== undefined &&
s.draftReconnectStrategy !== saved.reconnectStrategy
)
return true;
if (s.draftLogLevel !== undefined && s.draftLogLevel !== saved.logLevel) return true;
return false;
},
}));
/** 測試用 — 直接重置 store 狀態(避免 test 之間互相污染)。 */
export function __resetSettingsDraftStoreForTest(): void {
useSettingsDraftStore.setState({
draftRelayUrl: undefined,
draftAutoStart: undefined,
draftReconnectStrategy: undefined,
draftLogLevel: undefined,
});
}

View File

@ -0,0 +1,8 @@
/**
* Vitest setup
*
* - expect() @testing-library/jest-dom matcher
* toBeInTheDocument / toHaveClass / toHaveAttribute
* - F3
*/
import "@testing-library/jest-dom/vitest";

View File

@ -0,0 +1,124 @@
/**
* Agent `.autoflow/04-architecture/visiona-agent-tdd.md` §6.1 / §6.4
*
* Wails bindings Go structsnake_case JSON tag Wails camelCase
* TS Go snapshot schemaspec §6.4 / hook
*
* 使
* - hooks/use-connection-status.ts Snapshot
* - hooks/use-recent-logs.ts LogEntry[]
* - hooks/use-settings.ts AgentSettings
* - view Props /
*
* AF6 Wails binding
* Go struct TypeScript
*/
/* -------------------------------------------------------------------------- */
/* 連線狀態status-view 使用) */
/* -------------------------------------------------------------------------- */
/**
*
*
*
* - TDD §6.4 Go state `notPaired`camelCase
* - `components/layout/connection-status-badge.tsx` `ConnectionStatus` `not-paired`kebab-case
* `data-status` 使
* camelCase Wails bindingview
*/
export type ConnectionState =
| "notPaired"
| "connecting"
| "online"
| "reconnecting"
| "offline"
| "error";
/**
* TDD §6.4 Snapshot
* AF6 Wails binding `GetStatus()` / `connection:status` event
*/
export interface ConnectionSnapshot {
state: ConnectionState;
/** 錯誤訊息state === "error" 時有值)。 */
error?: string;
/** 第幾次重試state === "reconnecting" 時有值1..5)。 */
attemptNo?: number;
/** 當前連線的 Relay URL無論 online/offline 都會帶,供 InfoCard 顯示)。 */
relayUrl: string;
/** 雲端帳號 email配對後有值。 */
account?: string;
/** 連線建立時間ISO 字串online / 剛離線時有值)。 */
connectedSince?: string;
/** Session Token 遮蔽字串(例:"vAs_a1b2c3d4 ··· e7f8")。完整 token 絕不送上前端。 */
sessionTokenPreview?: string;
}
/* -------------------------------------------------------------------------- */
/* 最近連線 Log */
/* -------------------------------------------------------------------------- */
/** RecentLog 單筆紀錄(對齊 TDD §6.4 LogBuffer 輸出結構)。 */
export interface LogEntry {
/** ISO 時間字串(元件顯示時轉成 HH:mm。 */
ts: string;
/**
* spec §4.2 D icon
* connected / connecting / failed / started / stopped / settings
*/
kind: "connected" | "connecting" | "failed" | "started" | "stopped" | "settings";
/** 已翻譯好的事件文字(目前後端直接塞使用者看得懂的字串)。 */
text: string;
}
/* -------------------------------------------------------------------------- */
/* 設定settings-view 使用) */
/* -------------------------------------------------------------------------- */
/** Log 等級(對齊 spec §6.2.3 Select 選項)。 */
export type LogLevel = "debug" | "info" | "warn" | "error";
/** 重連策略(對齊 spec §6.2.2 RadioGroup 選項)。 */
export type ReconnectStrategy = "auto" | "manual";
/** Agent 設定 — `GetSettings()` 回傳 / `UpdateSettings(patch)` 接收的 shape。 */
export interface AgentSettings {
relayUrl: string;
autoStart: boolean;
reconnectStrategy: ReconnectStrategy;
logLevel: LogLevel;
}
/** 「測試連線」按鈕的回傳spec §6.2.1)。 */
export interface TestRelayResult {
ok: boolean;
/** 成功時的 latency毫秒。 */
latencyMs?: number;
/** 失敗時的 reason已本地化可直接顯示。 */
reason?: string;
}
/* -------------------------------------------------------------------------- */
/* 配對錯誤代碼pair-view 使用) */
/* -------------------------------------------------------------------------- */
/**
* spec §5.4
* AF6 `pairing:result.error` Phase 0 mock
*/
export type PairErrorCode =
| "token_expired"
| "token_used"
| "token_invalid"
| "token_revoked"
| "network_error"
| "relay_unreachable"
| "unknown";
/** 配對失敗時的結構化錯誤(`usePair()` reject 的 value。 */
export interface PairError {
code: PairErrorCode;
/** 若為 `relay_unreachable`,帶出當前 Relay URL 以供錯誤訊息內插。 */
relayUrl?: string;
}

View File

@ -0,0 +1,48 @@
/**
* API visionA Cloud
*
* `.autoflow/04-architecture/api/api-spec.md`
*
* { "success": true, "data": {...} }
* { "success": false, "error": { "code": "ERR_CODE", "message": "..." } }
*/
export interface ApiErrorShape {
/** 機器可讀的錯誤代號(見 api-spec.md §11 錯誤碼清單) */
code: string;
/** 人類可讀的錯誤訊息(可顯示給使用者) */
message: string;
/** 附加資訊(驗證錯誤的 field 詳細、rate limit 的重試時間等) */
details?: unknown;
}
/** 後端統一成功回應。 */
export interface ApiSuccessEnvelope<T> {
success: true;
data: T;
}
/** 後端統一失敗回應。 */
export interface ApiErrorEnvelope {
success: false;
error: ApiErrorShape;
}
export type ApiEnvelope<T> = ApiSuccessEnvelope<T> | ApiErrorEnvelope;
/** api-spec.md §11 錯誤碼字串聯集(供前端 switch / 比對用)。 */
export type KnownErrorCode =
| "UNAUTHORIZED"
| "FORBIDDEN"
| "NOT_FOUND"
| "VALIDATION_FAILED"
| "TUNNEL_DISCONNECTED"
| "TUNNEL_ERROR"
| "NOT_IMPLEMENTED"
| "RATE_LIMITED"
| "INTERNAL_ERROR"
// 前端自建,表示「非後端回傳」的錯誤
| "NETWORK_ERROR"
| "TIMEOUT"
| "ABORTED"
| "PARSE_ERROR";

View File

@ -0,0 +1,140 @@
/**
* PairView Fix-F2 / Fix-F3
*
*
* 1. Fix-F2Esc dispatch agent:switch-tab status
* 2. Fix-F2submitting=true Esc
* 3. Fix-F2unmount keydown memory leak / view
* 4. Fix-F3 agentAPI.openURL window.open
*
*
* - usePair submitting / lastError hook
* - TokenInput token-input.test.tsx
*/
import { act, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { LocaleProvider } from "@/lib/i18n/context";
// usePair 用 ref 控制:每個 test 改 currentSubmitting 即可影響 hook 回傳
let currentSubmitting = false;
const pairFn = vi.fn().mockResolvedValue(undefined);
const resetFn = vi.fn();
vi.mock("@/hooks/use-pair", () => ({
usePair: () => ({
pair: pairFn,
submitting: currentSubmitting,
lastError: null,
reset: resetFn,
}),
}));
const openURLMock = vi.fn();
vi.mock("@/lib/agent-api", () => ({
agentAPI: {
openURL: (url: string) => openURLMock(url),
},
}));
import { AGENT_SWITCH_TAB_EVENT } from "./status-view";
import { PairView } from "./pair-view";
function renderPair() {
return render(
<LocaleProvider>
<PairView />
</LocaleProvider>,
);
}
describe("<PairView /> — Esc 取消Fix-F2", () => {
let dispatchSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
currentSubmitting = false;
pairFn.mockClear();
resetFn.mockClear();
openURLMock.mockClear();
dispatchSpy = vi.spyOn(window, "dispatchEvent");
});
afterEach(() => {
dispatchSpy.mockRestore();
});
it("按 Esc 會 dispatch agent:switch-tab → status", () => {
renderPair();
act(() => {
window.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
});
const switchCall = dispatchSpy.mock.calls.find(([ev]) => {
return ev instanceof CustomEvent && ev.type === AGENT_SWITCH_TAB_EVENT;
});
expect(switchCall).toBeDefined();
const [event] = switchCall as [CustomEvent<{ value: string }>];
expect(event.detail.value).toBe("status");
});
it("submitting=true 時按 Esc 不 dispatch避免打斷送出", () => {
currentSubmitting = true;
renderPair();
act(() => {
window.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
});
const switchCall = dispatchSpy.mock.calls.find(([ev]) => {
return ev instanceof CustomEvent && ev.type === AGENT_SWITCH_TAB_EVENT;
});
expect(switchCall).toBeUndefined();
});
it("unmount 後 Esc 不再響應(解綁 listener", () => {
const { unmount } = renderPair();
unmount();
dispatchSpy.mockClear();
act(() => {
window.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
});
const switchCall = dispatchSpy.mock.calls.find(([ev]) => {
return ev instanceof CustomEvent && ev.type === AGENT_SWITCH_TAB_EVENT;
});
expect(switchCall).toBeUndefined();
});
it("非 Escape 鍵不觸發切 tab", () => {
renderPair();
act(() => {
window.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter" }));
window.dispatchEvent(new KeyboardEvent("keydown", { key: "Tab" }));
});
const switchCall = dispatchSpy.mock.calls.find(([ev]) => {
return ev instanceof CustomEvent && ev.type === AGENT_SWITCH_TAB_EVENT;
});
expect(switchCall).toBeUndefined();
});
});
describe("<PairView /> — 雲端連結改用 agentAPI.openURLFix-F3", () => {
beforeEach(() => {
currentSubmitting = false;
openURLMock.mockClear();
});
it("點擊「前往雲端」連結會呼叫 agentAPI.openURL", () => {
renderPair();
fireEvent.click(screen.getByTestId("pair-link-cloud"));
expect(openURLMock).toHaveBeenCalledOnce();
expect(openURLMock).toHaveBeenCalledWith(
expect.stringMatching(/^https:\/\/visionA\.cloud\/devices\/pair$/i),
);
});
});

View File

@ -0,0 +1,224 @@
"use client";
/**
* PairView AF4
*
* spec §5
* - + description
* - Token 使 TokenInput + trim + Enter
* - Phase 0 URL placeholderspec §5.2 C
* - / loading state
* - Alert Token 15
* - 7 PairErrorCode
* - 0.5s + toastD2 spec §5.3
*
*
* - usePair() pair(token) Promise; submitting; lastError; reset()
*/
import { useEffect, useRef, useState } from "react";
import { ArrowUpRight } from "lucide-react";
import { toast } from "sonner";
import { TokenInput, validateToken } from "@/components/agent/token-input";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Spinner } from "@/components/ui/spinner";
import { usePair } from "@/hooks/use-pair";
import { agentAPI } from "@/lib/agent-api";
import { useT } from "@/lib/i18n/context";
import type { PairError, PairErrorCode } from "@/types/agent";
import { AGENT_SWITCH_TAB_EVENT } from "./status-view";
/** 雲端產生 token 的 URLPhase 0 placeholder正式上線改為環境變數驅動。 */
const CLOUD_PAIR_URL = "https://visionA.cloud/devices/pair";
/** 成功後切回狀態頁的延遲spec §5.3「0.5 秒後自動切回」)。 */
const PAIR_SUCCESS_REDIRECT_MS = 500;
/** PairErrorCode → i18n key 對照spec §5.4)。 */
const ERROR_I18N_KEY: Record<PairErrorCode, string> = {
token_expired: "pair.error.tokenExpired",
token_used: "pair.error.tokenUsed",
token_invalid: "pair.error.tokenInvalid",
token_revoked: "pair.error.tokenRevoked",
network_error: "pair.error.network",
relay_unreachable: "pair.error.relayUnreachable",
unknown: "pair.error.unknown",
};
/** 切回狀態頁(透過 StatusView 定義的 CustomEvent。 */
function goBackToStatus() {
if (typeof window === "undefined") return;
window.dispatchEvent(
new CustomEvent<{ value: string }>(AGENT_SWITCH_TAB_EVENT, {
detail: { value: "status" },
}),
);
}
export function PairView() {
const t = useT();
const { pair, submitting, lastError, reset } = usePair();
const [token, setToken] = useState("");
const validity = validateToken(token);
const canSubmit = validity === "valid" && !submitting;
/**
* spec §5.5 / Fix-F2Esc cancel
*
* - ref submitting listener stale closure listener
* - submitting=true Esc 使
* - mount unmount memory leak / view
* - spec
*/
const submittingRef = useRef(submitting);
useEffect(() => {
submittingRef.current = submitting;
}, [submitting]);
useEffect(() => {
function onKeyDown(e: KeyboardEvent) {
if (e.key !== "Escape") return;
if (submittingRef.current) return;
e.preventDefault();
// 不呼叫 handleCancel會碰到 stale closure 的 reset/setToken
// 這裡直接切 tab 即可token state 隨 unmount 一起釋放)
goBackToStatus();
}
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, []);
/** 將 PairError 轉為使用者可見的字串(處理變數內插)。 */
function formatError(err: PairError): string {
const raw = t(ERROR_I18N_KEY[err.code]);
if (err.code === "relay_unreachable" && err.relayUrl) {
return raw.replace("{url}", err.relayUrl);
}
return raw;
}
async function handleSubmit() {
if (!canSubmit) return;
try {
await pair(token);
toast.success(t("pair.success"));
// D2 決議0.5 秒後自動切回狀態頁
window.setTimeout(goBackToStatus, PAIR_SUCCESS_REDIRECT_MS);
} catch {
// lastError 會由 hook 自動設定;此處不做額外處理
// toast 不顯示;錯誤由頁內 Alert 呈現,避免雙重通知)
}
}
function handleCancel() {
setToken("");
reset();
goBackToStatus();
}
function handleChange(next: string) {
setToken(next);
// 使用者修改 token → 清掉上次錯誤(避免舊錯誤誤導)
if (lastError) reset();
}
function handleOpenCloud() {
// Fix-F3統一走 agentAPI.openURLWails runtime 會用 BrowserOpenURL
// mock 環境 fallback 到 window.open
agentAPI.openURL(CLOUD_PAIR_URL);
}
return (
<section
data-testid="pair-view"
aria-labelledby="pair-view-heading"
className="flex flex-col gap-6 py-4"
>
{/* 標題區spec §5.2 A */}
<div className="space-y-1 text-center">
<h2
id="pair-view-heading"
className="text-2xl font-semibold"
>
{t("pair.title")}
</h2>
<p className="text-muted-foreground text-sm">{t("pair.description")}</p>
</div>
{/* Token 輸入卡spec §5.2 B-D */}
<Card>
<CardContent className="space-y-4">
<TokenInput
value={token}
onChange={handleChange}
onSubmit={handleSubmit}
disabled={submitting}
autoFocus
/>
{/* 錯誤訊息(僅在配對失敗時顯示) */}
{lastError && (
<Alert variant="destructive" data-testid="pair-error">
<AlertDescription>{formatError(lastError)}</AlertDescription>
</Alert>
)}
{/* 輔助連結spec §5.2 C */}
<div className="flex items-center justify-between gap-3">
<Button
type="button"
variant="link"
size="sm"
className="h-auto p-0 text-xs"
onClick={handleOpenCloud}
data-testid="pair-link-cloud"
>
{t("pair.linkCloud")}
<ArrowUpRight aria-hidden="true" className="ml-0.5 size-3" />
</Button>
{/* 按鈕列 — 取消 / 配對 */}
<div className="flex items-center gap-2">
<Button
type="button"
variant="ghost"
onClick={handleCancel}
disabled={submitting}
data-testid="pair-button-cancel"
>
{t("pair.button.cancel")}
</Button>
<Button
type="button"
variant="default"
onClick={handleSubmit}
disabled={!canSubmit}
aria-busy={submitting}
data-testid="pair-button-submit"
>
{submitting ? (
<>
<Spinner size="sm" label={t("pair.button.submitting")} />
<span>{t("pair.button.submitting")}</span>
</>
) : (
t("pair.button.submit")
)}
</Button>
</div>
</div>
</CardContent>
</Card>
{/* 底部安全提示spec §5.2 E */}
<Alert variant="info" data-testid="pair-alert-security">
<AlertDescription>{t("pair.alert.security")}</AlertDescription>
</Alert>
</section>
);
}

View File

@ -0,0 +1,175 @@
/**
* SettingsView Fix-F4 draft + badge
*
*
* 1. Relay URL unmount remountdraft mock tab
* 2. draft badge
* 3. onBlur valid draft badge
*
*
* - useSettings mock settings / save
* - useTestConnection / useExportLog mock
* - section autoStart / logLevel / reset
*/
import { fireEvent, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { LocaleProvider } from "@/lib/i18n/context";
import {
__resetSettingsDraftStoreForTest,
useSettingsDraftStore,
} from "@/stores/settings-draft-store";
import type { AgentSettings } from "@/types/agent";
// useSettings mock — 用模組區域變數讓 test 可改 saved settings
let savedSettings: AgentSettings = {
relayUrl: "wss://relay.visionA.cloud",
autoStart: false,
reconnectStrategy: "auto",
logLevel: "info",
};
const saveMock = vi.fn(async (patch: Partial<AgentSettings>) => {
savedSettings = { ...savedSettings, ...patch };
});
const resetAllMock = vi.fn(async () => {});
vi.mock("@/hooks/use-settings", () => ({
useSettings: () => ({
settings: savedSettings,
loading: false,
lastError: null,
save: saveMock,
resetAll: resetAllMock,
}),
}));
vi.mock("@/hooks/use-test-connection", () => ({
useTestConnection: () => ({ test: vi.fn(), testing: false }),
}));
vi.mock("@/hooks/use-export-log", () => ({
useExportLog: () => ({ exportLog: vi.fn(), exporting: false }),
}));
vi.mock("@/lib/agent-api", () => ({
agentAPI: {
openURL: vi.fn(),
},
}));
import { SettingsView } from "./settings-view";
function renderSettings() {
return render(
<LocaleProvider>
<SettingsView />
</LocaleProvider>,
);
}
beforeEach(() => {
savedSettings = {
relayUrl: "wss://relay.visionA.cloud",
autoStart: false,
reconnectStrategy: "auto",
logLevel: "info",
};
saveMock.mockClear();
resetAllMock.mockClear();
__resetSettingsDraftStoreForTest();
});
afterEach(() => {
__resetSettingsDraftStoreForTest();
});
describe("<SettingsView /> — draft 持久Fix-F4", () => {
it("初次 render 沒 draft → 不顯示「未儲存」badge", () => {
renderSettings();
expect(
screen.queryByTestId("settings-unsaved-badge"),
).not.toBeInTheDocument();
});
it("改 Relay URL未 blur→ 顯示「未儲存」badge", () => {
renderSettings();
const input = screen.getByTestId("settings-relay-url");
fireEvent.change(input, { target: { value: "wss://draft.test" } });
expect(screen.getByTestId("settings-unsaved-badge")).toBeInTheDocument();
expect(screen.getByTestId("settings-unsaved-badge")).toHaveTextContent(
"有未儲存的變更",
);
});
it("改 Relay URL → unmount → remountdraft 還在(模擬切 tab 又回來)", () => {
const { unmount } = renderSettings();
const input = screen.getByTestId("settings-relay-url");
fireEvent.change(input, { target: { value: "wss://persisted.test" } });
// 模擬切到別的 tabunmountstore 應該保留 draft
unmount();
expect(useSettingsDraftStore.getState().draftRelayUrl).toBe(
"wss://persisted.test",
);
// 切回設定 tabremount— input 應該顯示 store 裡的 draft 而非 saved 值
renderSettings();
const inputAgain = screen.getByTestId(
"settings-relay-url",
) as HTMLInputElement;
expect(inputAgain.value).toBe("wss://persisted.test");
expect(screen.getByTestId("settings-unsaved-badge")).toBeInTheDocument();
});
it("blur valid URL 觸發 save → badge 消失draft 清空", async () => {
renderSettings();
const input = screen.getByTestId("settings-relay-url");
fireEvent.change(input, { target: { value: "wss://saved.test" } });
expect(screen.getByTestId("settings-unsaved-badge")).toBeInTheDocument();
// blur → handleBlur 觸發 save + clearAllDraft
fireEvent.blur(input);
// save 是 sync 呼叫(會 await Promise但 expect call 不需要等)
await Promise.resolve();
expect(saveMock).toHaveBeenCalledWith({ relayUrl: "wss://saved.test" });
// saved 變了draft 也清了badge 應該消失
expect(useSettingsDraftStore.getState().draftRelayUrl).toBeUndefined();
expect(
screen.queryByTestId("settings-unsaved-badge"),
).not.toBeInTheDocument();
});
it("改回與 saved 相同的值 → badge 消失(避免「改回去還顯示」的怪事)", () => {
renderSettings();
const input = screen.getByTestId("settings-relay-url");
fireEvent.change(input, { target: { value: "wss://temp.test" } });
expect(screen.getByTestId("settings-unsaved-badge")).toBeInTheDocument();
// 改回與 saved 相同
fireEvent.change(input, { target: { value: savedSettings.relayUrl } });
expect(
screen.queryByTestId("settings-unsaved-badge"),
).not.toBeInTheDocument();
});
it("invalid URL → 不 savebadge 仍顯示", () => {
renderSettings();
const input = screen.getByTestId("settings-relay-url");
fireEvent.change(input, { target: { value: "http://not-ws" } });
fireEvent.blur(input);
// 格式不正確 → 不該觸發 save
expect(saveMock).not.toHaveBeenCalled();
// draft 仍在 → badge 仍在
expect(screen.getByTestId("settings-unsaved-badge")).toBeInTheDocument();
});
});

View File

@ -0,0 +1,489 @@
"use client";
/**
* SettingsView AF5
*
* spec §6
* - 6.2.1 Relay URL +
* - 6.2.2 Checkbox + RadioGroup
* - 6.2.3 Log Select + platform +
* - 6.2.4 + Phase 0 disabled + tooltipPhase 1
* - 6.2.5 AlertDialog
*
*
* - useSettings() settings + save(patch) + resetAll()
* - useTestConnection() test(url) { ok, latencyMs, reason }
* - useExportLog() exportLog() path
*
* 4 + 1 Separator
* Section*
*/
import { useState } from "react";
import { toast } from "sonner";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { Spinner } from "@/components/ui/spinner";
import { useExportLog } from "@/hooks/use-export-log";
import { useSettings } from "@/hooks/use-settings";
import { useTestConnection } from "@/hooks/use-test-connection";
import { agentAPI } from "@/lib/agent-api";
import { useT, type TranslateFn } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import type { LogLevel, ReconnectStrategy } from "@/types/agent";
import { useSettingsDraftStore } from "@/stores/settings-draft-store";
/** Phase 0 的版本號AF6 會改為從 Wails binding `GetVersion()` 讀取(或 Next build-time injected。 */
const APP_VERSION = "0.1.0";
/** Log 位置(依 OS— spec §6.2.3。 */
const LOG_LOCATIONS: Record<"macos" | "windows" | "linux", string> = {
macos: "~/Library/Application Support/visionA Agent/logs/",
windows: "%APPDATA%\\visionA Agent\\logs\\",
linux: "~/.config/visionA-agent/logs/",
};
/** 偵測當前 OSclient-side only— Phase 0 用 navigator.platform。 */
function detectOS(): "macos" | "windows" | "linux" {
if (typeof navigator === "undefined") return "macos";
const p = navigator.platform.toLowerCase();
if (p.includes("mac")) return "macos";
if (p.includes("win")) return "windows";
return "linux";
}
/** Relay URL 格式驗證(必須 ws:// 或 wss://)— spec §6.2.1。 */
function isValidRelayUrl(url: string): boolean {
return /^wss?:\/\/.+/i.test(url);
}
export function SettingsView() {
const t = useT();
const { settings } = useSettings();
// Fix-F4「未儲存」badge — 任何 draft 欄位 ≠ saved 即顯示
const hasUnsaved = useSettingsDraftStore((s) => s.hasUnsaved(settings));
return (
<section
data-testid="settings-view"
aria-labelledby="settings-view-heading"
className="flex flex-col gap-8 py-4"
>
<div className="flex items-center justify-between">
<h2 id="settings-view-heading" className="sr-only">
{t("nav.settings")}
</h2>
{hasUnsaved && (
<span
data-testid="settings-unsaved-badge"
// role="status" + aria-live="polite"badge 出現時 SR 會在閒置時朗讀
role="status"
aria-live="polite"
className="text-amber-600 dark:text-amber-400 ml-auto inline-flex items-center gap-1 rounded-full border border-amber-300 bg-amber-50 px-2 py-0.5 text-xs font-medium dark:border-amber-700/40 dark:bg-amber-950/30"
>
{t("settings.unsavedChanges")}
</span>
)}
</div>
<SectionConnection />
<Separator />
<SectionBehavior />
<Separator />
<SectionLog />
<Separator />
<SectionAbout />
<Separator />
<SectionDanger />
</section>
);
}
/* -------------------------------------------------------------------------- */
/* Section: 連線spec §6.2.1 */
/* -------------------------------------------------------------------------- */
function SectionConnection() {
const t = useT();
const { settings, save } = useSettings();
const { test, testing } = useTestConnection();
// Fix-F4draft 從 store 讀;若 store 沒值(沒未儲存的變更)就 fallback 到 saved settings。
// 切到別的 tab 後 SectionConnection unmountstore 仍保留 draft回來 mount 時這行
// 會把 draft 拿回來,達到「跨 tab 持久」效果。
const draftStored = useSettingsDraftStore((s) => s.draftRelayUrl);
const setStoredDraft = useSettingsDraftStore((s) => s.setDraft);
const clearAllDraft = useSettingsDraftStore((s) => s.clearAll);
const draftUrl = draftStored ?? settings.relayUrl;
const urlValid = isValidRelayUrl(draftUrl);
const hasChanges = draftUrl !== settings.relayUrl;
async function handleTest() {
const result = await test(draftUrl);
if (result.ok) {
toast.success(
t("settings.testConnection.success").replace(
"{latency}",
String(result.latencyMs ?? 0),
),
);
} else {
toast.error(
t("settings.testConnection.failed")
.replace("{url}", draftUrl)
.replace("{reason}", result.reason ?? ""),
);
}
}
function handleChange(next: string) {
// 寫到 store若改回與 saved 完全一致就清掉 draftbadge 自動消失)
if (next === settings.relayUrl) {
setStoredDraft("draftRelayUrl", undefined);
} else {
setStoredDraft("draftRelayUrl", next);
}
}
function handleBlur() {
// 離開 input 時自動 persist 草稿(格式正確才 save
if (hasChanges && urlValid) {
save({ relayUrl: draftUrl });
// 儲存後一律清除整包 draft避免殘留欄位影響 hasUnsaved 判斷
clearAllDraft();
}
}
return (
<SectionFrame titleKey="settings.section.connection" t={t}>
<div className="space-y-2">
<Label htmlFor="settings-relay-url">
{t("settings.relayUrl.label")}
</Label>
<div className="flex gap-2">
<Input
id="settings-relay-url"
type="text"
value={draftUrl}
onChange={(e) => handleChange(e.target.value)}
onBlur={handleBlur}
aria-invalid={!urlValid}
data-testid="settings-relay-url"
className="font-mono text-sm"
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleTest}
disabled={!urlValid || testing}
data-testid="settings-test-connection"
>
{testing ? (
<>
<Spinner size="sm" label={t("common.loading")} />
<span>{t("settings.testConnection.testing")}</span>
</>
) : (
t("settings.testConnection.button")
)}
</Button>
</div>
<p
className={cn(
"text-xs",
urlValid ? "text-muted-foreground" : "text-destructive",
)}
>
{urlValid
? t("settings.relayUrl.hint")
: t("settings.relayUrl.invalidProtocol")}
</p>
</div>
</SectionFrame>
);
}
/* -------------------------------------------------------------------------- */
/* Section: 行為spec §6.2.2 */
/* -------------------------------------------------------------------------- */
function SectionBehavior() {
const t = useT();
const { settings, save } = useSettings();
return (
<SectionFrame titleKey="settings.section.behavior" t={t}>
{/* 開機自啟 Checkbox */}
<label className="flex items-center gap-2 text-sm">
<Checkbox
checked={settings.autoStart}
onCheckedChange={(c) => save({ autoStart: c === true })}
data-testid="settings-autostart"
/>
<span>{t("settings.autoStart.label")}</span>
</label>
{/* 重連策略 RadioGroup */}
<div className="space-y-2">
<Label>{t("settings.reconnect.label")}</Label>
<RadioGroup
value={settings.reconnectStrategy}
onValueChange={(v) =>
save({ reconnectStrategy: v as ReconnectStrategy })
}
data-testid="settings-reconnect-strategy"
>
<div className="flex items-center gap-2">
<RadioGroupItem value="auto" id="reconnect-auto" />
<Label htmlFor="reconnect-auto" className="font-normal">
{t("settings.reconnect.auto")}
</Label>
</div>
<div className="flex items-center gap-2">
<RadioGroupItem value="manual" id="reconnect-manual" />
<Label htmlFor="reconnect-manual" className="font-normal">
{t("settings.reconnect.manual")}
</Label>
</div>
</RadioGroup>
</div>
</SectionFrame>
);
}
/* -------------------------------------------------------------------------- */
/* Section: Logspec §6.2.3 */
/* -------------------------------------------------------------------------- */
function SectionLog() {
const t = useT();
const { settings, save } = useSettings();
const { exportLog, exporting } = useExportLog();
// 使用 useState 讓 OS 偵測只在 client 端執行SSR 會用預設 macos
const [os] = useState(() => detectOS());
async function handleExport() {
const path = await exportLog();
if (path) {
toast.success(t("settings.log.exported").replace("{path}", path));
}
}
return (
<SectionFrame titleKey="settings.section.log" t={t}>
{/* Log 等級 */}
<div className="space-y-2">
<Label htmlFor="settings-log-level">{t("settings.log.level")}</Label>
<Select
value={settings.logLevel}
onValueChange={(v) => save({ logLevel: v as LogLevel })}
>
<SelectTrigger
id="settings-log-level"
data-testid="settings-log-level"
className="w-40"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="debug">{t("settings.log.level.debug")}</SelectItem>
<SelectItem value="info">{t("settings.log.level.info")}</SelectItem>
<SelectItem value="warn">{t("settings.log.level.warn")}</SelectItem>
<SelectItem value="error">{t("settings.log.level.error")}</SelectItem>
</SelectContent>
</Select>
</div>
{/* Log 位置 */}
<div className="space-y-2">
<Label>{t("settings.log.location")}</Label>
<p
className="text-muted-foreground break-all font-mono text-xs"
data-testid="settings-log-location"
>
{LOG_LOCATIONS[os]}
</p>
</div>
{/* 匯出 Log */}
<div>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleExport}
disabled={exporting}
data-testid="settings-export-log"
>
{exporting ? (
<>
<Spinner size="sm" label={t("common.loading")} />
<span>{t("settings.log.exporting")}</span>
</>
) : (
t("settings.log.export")
)}
</Button>
</div>
</SectionFrame>
);
}
/* -------------------------------------------------------------------------- */
/* Section: 關於spec §6.2.4 */
/* -------------------------------------------------------------------------- */
function SectionAbout() {
const t = useT();
// Fix-F3統一走 agentAPI.openURLWails 用 BrowserOpenURLmock 退到 window.open
function openUrl(url: string) {
agentAPI.openURL(url);
}
return (
<SectionFrame titleKey="settings.section.about" t={t}>
<div className="grid grid-cols-[120px_1fr] items-center gap-3 text-sm">
<span className="text-muted-foreground">
{t("settings.about.version")}
</span>
<span className="font-mono" data-testid="settings-version">
visionA Agent {APP_VERSION}
</span>
</div>
<div className="flex flex-wrap gap-2">
{/* 檢查更新 — Phase 0 disabledD3 決議) */}
<Button
type="button"
variant="outline"
size="sm"
disabled
title={t("settings.about.checkUpdate.phase1")}
aria-describedby="check-update-phase-hint"
data-testid="settings-check-update"
>
{t("settings.about.checkUpdate")}
</Button>
<span id="check-update-phase-hint" className="sr-only">
{t("settings.about.checkUpdate.phase1")}
</span>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => openUrl("https://docs.visionA.cloud/agent")}
>
{t("settings.about.docs")}
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => openUrl("https://github.com/innovedus/visionA-agent")}
>
{t("settings.about.github")}
</Button>
</div>
</SectionFrame>
);
}
/* -------------------------------------------------------------------------- */
/* Section: 危險區域spec §6.2.5 */
/* -------------------------------------------------------------------------- */
function SectionDanger() {
const t = useT();
const { resetAll } = useSettings();
function handleConfirmReset() {
resetAll();
toast.success(t("settings.reset.done"));
// AF6await window.go.main.App.ResetAll() — 清 config + session token 後切回配對頁
}
return (
<div className="space-y-3">
<h3 className="text-destructive text-sm font-medium">
{t("settings.section.danger")}
</h3>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
type="button"
variant="destructive"
size="sm"
data-testid="settings-reset"
>
{t("settings.reset.button")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("settings.reset.confirm.title")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("settings.reset.confirm.description")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={handleConfirmReset}
data-testid="settings-reset-confirm"
>
{t("settings.reset.confirm.ok")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
/* -------------------------------------------------------------------------- */
/* 共用SectionFrame — 每個區塊的小標 + 內容殼 */
/* -------------------------------------------------------------------------- */
interface SectionFrameProps {
titleKey: string;
t: TranslateFn;
children: React.ReactNode;
}
function SectionFrame({ titleKey, t, children }: SectionFrameProps) {
return (
<div className="space-y-3">
<h3 className="text-sm font-medium">{t(titleKey)}</h3>
<div className="space-y-4">{children}</div>
</div>
);
}

View File

@ -0,0 +1,125 @@
/**
* StatusView Fix-F1
*
*
* 1. error / offline
* 2. online / reconnecting / connecting / notPaired
* 3. dispatch `agent:switch-tab` payload `'pair'`
*
*
* - reconnect / disconnect / retry
* - useConnectionStatus / useRecentLogs mock
*/
import { fireEvent, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { LocaleProvider } from "@/lib/i18n/context";
import type { ConnectionSnapshot, ConnectionState } from "@/types/agent";
// 預設 snapshot 給 mock每個 test 透過 setSnapshot 改
let currentSnapshot: ConnectionSnapshot = {
state: "online",
relayUrl: "wss://relay.test",
account: "alice@example.com",
connectedSince: new Date("2024-01-01T00:00:00Z").toISOString(),
sessionTokenPreview: "vAs_abc ··· d3f4",
};
vi.mock("@/hooks/use-connection-status", () => ({
useConnectionStatus: () => ({ snapshot: currentSnapshot, loading: false }),
}));
vi.mock("@/hooks/use-recent-logs", () => ({
useRecentLogs: () => ({ logs: [] }),
}));
// agentAPI 的 reconnect / disconnect 是 fire-and-forgetmock 成功即可,本測試不驗主按鈕
vi.mock("@/lib/agent-api", () => ({
agentAPI: {
reconnect: vi.fn().mockResolvedValue(undefined),
disconnect: vi.fn().mockResolvedValue(undefined),
},
}));
import { AGENT_SWITCH_TAB_EVENT, StatusView } from "./status-view";
function renderStatus(state: ConnectionState, extras?: Partial<ConnectionSnapshot>) {
currentSnapshot = {
state,
relayUrl: "wss://relay.test",
account: "alice@example.com",
connectedSince: new Date("2024-01-01T00:00:00Z").toISOString(),
sessionTokenPreview: "vAs_abc ··· d3f4",
...extras,
};
return render(
<LocaleProvider>
<StatusView />
</LocaleProvider>,
);
}
describe("<StatusView /> — 重新配對次按鈕Fix-F1", () => {
let dispatchSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
dispatchSpy = vi.spyOn(window, "dispatchEvent");
});
afterEach(() => {
dispatchSpy.mockRestore();
});
it("error 狀態顯示「重新配對」次按鈕", () => {
renderStatus("error", { error: "Pairing token revoked" });
expect(screen.getByTestId("status-action-repair")).toBeInTheDocument();
});
it("offline 狀態顯示「重新配對」次按鈕", () => {
renderStatus("offline");
expect(screen.getByTestId("status-action-repair")).toBeInTheDocument();
});
it("online 狀態不顯示次按鈕", () => {
renderStatus("online");
expect(screen.queryByTestId("status-action-repair")).not.toBeInTheDocument();
});
it("reconnecting 狀態不顯示次按鈕", () => {
renderStatus("reconnecting", { attemptNo: 2 });
expect(screen.queryByTestId("status-action-repair")).not.toBeInTheDocument();
});
it("connecting 狀態不顯示次按鈕", () => {
renderStatus("connecting");
expect(screen.queryByTestId("status-action-repair")).not.toBeInTheDocument();
});
it("notPaired 狀態不渲染主按鈕區(顯示 EmptyState", () => {
renderStatus("notPaired");
// EmptyState 的「前往配對」CTA 可被找到,但 ActionButtons 不存在
expect(screen.queryByTestId("status-action-repair")).not.toBeInTheDocument();
});
it("點擊次按鈕會 dispatch agent:switch-tab → pair", () => {
renderStatus("error", { error: "Connection refused" });
fireEvent.click(screen.getByTestId("status-action-repair"));
// 找到 dispatchEvent 被呼叫的那個 CustomEvent型別 narrow + 檢查 detail
const switchCall = dispatchSpy.mock.calls.find(([ev]) => {
return ev instanceof CustomEvent && ev.type === AGENT_SWITCH_TAB_EVENT;
});
expect(switchCall).toBeDefined();
const [event] = switchCall as [CustomEvent<{ value: string }>];
expect(event.detail.value).toBe("pair");
});
it("次按鈕有 aria-label 與 title無障礙 / hover hint", () => {
renderStatus("offline");
const btn = screen.getByTestId("status-action-repair");
expect(btn).toHaveAttribute("aria-label", "重新配對");
expect(btn).toHaveAttribute("title", "重新配對");
});
});

View File

@ -0,0 +1,293 @@
"use client";
/**
* StatusView AF3
*
* spec §4
* - StatusHero5
* - InfoCard / Relay / / Session Token
* -
* - RecentLog 10
* - EmptyState + CTA
*
* AF3 mockAF6 Wails binding
* - useConnectionStatus() snapshot
* - useRecentLogs(10) logs
*
* Tab
* - Radix Tabs `TabsContext` setValue defaultValue
* dispatch CustomEvent `agent:switch-tab` page.tsx
* prop-drilling view app
* StatusView
*/
import { Unlink } from "lucide-react";
import { toast } from "sonner";
import { InfoCard } from "@/components/agent/info-card";
import { RecentLog } from "@/components/agent/recent-log";
import { StatusHero } from "@/components/agent/status-hero";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { EmptyState } from "@/components/ui/empty-state";
import { Spinner } from "@/components/ui/spinner";
import { useConnectionStatus } from "@/hooks/use-connection-status";
import { useRecentLogs } from "@/hooks/use-recent-logs";
import { agentAPI } from "@/lib/agent-api";
import { useT } from "@/lib/i18n/context";
/** 觸發切換到配對 tab 的自訂事件名page.tsx 監聽)。 */
export const AGENT_SWITCH_TAB_EVENT = "agent:switch-tab";
/** 發送 CustomEvent 切換 tabexport 給其他 view 共用,避免重複定義 helper。 */
export function switchAgentTab(value: "status" | "pair" | "settings") {
if (typeof window === "undefined") return;
window.dispatchEvent(
new CustomEvent<{ value: string }>(AGENT_SWITCH_TAB_EVENT, {
detail: { value },
}),
);
}
/** 內部別名(保留舊名以利 view 內部讀起來語意清楚)。 */
const switchTab = switchAgentTab;
export function StatusView() {
const t = useT();
const { snapshot, loading } = useConnectionStatus();
const { logs } = useRecentLogs(10);
/* ---------- Loading初次拉 snapshot---------- */
if (loading || !snapshot) {
return (
<section
data-testid="status-view"
aria-labelledby="status-view-heading"
className="flex flex-col items-center gap-3 py-12"
>
<h2 id="status-view-heading" className="sr-only">
{t("nav.status")}
</h2>
<Spinner size="lg" label={t("common.loading")} />
</section>
);
}
/* ---------- 空狀態尚未配對spec §4.3---------- */
if (snapshot.state === "notPaired") {
return (
<section
data-testid="status-view"
aria-labelledby="status-view-heading"
className="py-6"
>
<h2 id="status-view-heading" className="sr-only">
{t("nav.status")}
</h2>
<EmptyState
icon={Unlink}
title={t("status.empty.title")}
description={t("status.empty.description")}
action={{
label: t("status.action.goPair"),
onClick: () => switchTab("pair"),
}}
/>
</section>
);
}
/* ---------- 正常狀態online / offline / reconnecting / error---------- */
return (
<section
data-testid="status-view"
aria-labelledby="status-view-heading"
className="flex flex-col gap-6 py-4"
>
<h2 id="status-view-heading" className="sr-only">
{t("nav.status")}
</h2>
<StatusHero
state={snapshot.state}
attemptNo={snapshot.attemptNo}
errorMessage={snapshot.error}
/>
<Card>
<CardContent className="space-y-4">
<InfoCard snapshot={snapshot} />
<ActionButtons state={snapshot.state} />
</CardContent>
</Card>
<RecentLog logs={logs} max={10} />
</section>
);
}
/* -------------------------------------------------------------------------- */
/* ActionButtons — 主按鈕列(依狀態 5 套) */
/* -------------------------------------------------------------------------- */
interface ActionButtonsProps {
state: "online" | "offline" | "connecting" | "reconnecting" | "error" | "notPaired";
}
function ActionButtons({ state }: ActionButtonsProps) {
const t = useT();
// online 與 offline / reconnecting 的主按鈕不同,用 switch 明確分岔
switch (state) {
case "online":
return (
<div className="flex flex-wrap gap-2">
<DisconnectButton />
</div>
);
case "offline":
return (
<div className="flex flex-wrap gap-2">
<Button
data-testid="status-action-reconnect"
variant="default"
onClick={() => {
// AF6手動重連失敗時 toast成功由 connection:status event 更新 UI
agentAPI.reconnect().catch((err) => {
toast.error(
err instanceof Error ? err.message : String(err),
);
});
}}
>
{t("status.action.reconnect")}
</Button>
<RepairButton />
</div>
);
case "reconnecting":
case "connecting":
return (
<div className="flex flex-wrap items-center gap-2">
<Button
data-testid="status-action-reconnect"
variant="outline"
disabled
>
<Spinner size="sm" label={t("common.loading")} />
<span>{t("connection.reconnecting")}</span>
</Button>
</div>
);
case "error":
return (
<div className="flex flex-wrap gap-2">
<Button
data-testid="status-action-retry"
variant="default"
onClick={() => {
// AF6錯誤狀態下的重試 = 手動重連
agentAPI.reconnect().catch((err) => {
toast.error(
err instanceof Error ? err.message : String(err),
);
});
}}
>
{t("status.action.retry")}
</Button>
<RepairButton />
</div>
);
default:
// notPaired 不會到這裡(外層 early-return 了),保留 fallback 讓 switch 完整
return null;
}
}
/**
* spec §4.2 C / Fix-F1
*
* error / offline ActionButtons render
*
* tab unpairspec §4.2 C unpair
* 使 Session
*
* variant="outline"
*/
function RepairButton() {
const t = useT();
return (
<Button
type="button"
variant="outline"
data-testid="status-action-repair"
aria-label={t("status.action.repair")}
title={t("status.action.repair")}
onClick={() => switchTab("pair")}
>
{t("status.action.repair")}
</Button>
);
}
/** Disconnect 按鈕 — 透過 AlertDialog 確認。 */
function DisconnectButton() {
const t = useT();
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="destructive"
data-testid="status-action-disconnect"
>
{t("status.action.disconnect")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("status.confirm.disconnect.title")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("status.confirm.disconnect.description")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
data-testid="status-action-disconnect-confirm"
onClick={() => {
// AF6呼叫 Wails Disconnect binding
// 成功connection:status event 會推 "offline" 更新 UI失敗toast
agentAPI.disconnect().catch((err) => {
toast.error(
err instanceof Error ? err.message : String(err),
);
});
}}
>
{t("status.action.disconnect")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}

View File

@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}

View File

@ -0,0 +1,25 @@
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
import path from "path";
/**
* Vitest visionA Cloud
*
* - environment: jsdom
* - setupFiles: 預留給 @testing-library/jest-dom matcherF3
* - alias "@" tsconfig沿 import "@/..."
*/
export default defineConfig({
plugins: [react()],
test: {
environment: "jsdom",
globals: true,
setupFiles: ["./src/tests/setup.ts"],
include: ["src/**/*.{test,spec}.{ts,tsx}"],
},
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
});

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