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>
258
local-agent/.github/workflows/build.yml
vendored
Normal 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.04(glibc 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
|
||||
61
local-agent/.github/workflows/release.yml
vendored
Normal 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
@ -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-6b:macOS 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*
|
||||
|
||||
# ── Python(dev 時可能出現的 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
@ -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 .dmg(dmgbuild)"
|
||||
@echo " exe Windows .exe(Inno 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
|
||||
|
||||
# ── ffmpeg(LGPL v3,方案 B 混合)──
|
||||
# v2 TDD §2.2:macOS 自 build decoder-only(~20 MB,commit 到 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 "==> 同步內部 wheels(KneronPLUS 等)..."
|
||||
@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: ## macOS:LGPL 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 版本時才跑此 target;binary 產出後 commit 到 git(v2 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-config(brew install pkg-config)"; exit 1; }
|
||||
@command -v nasm >/dev/null 2>&1 || command -v yasm >/dev/null 2>&1 || { echo "❌ 需要 nasm 或 yasm(brew 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 "==> configure(decoder-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
|
||||
|
||||
# ── M4:Windows 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 build(BtbN 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: 找不到真實 python(WindowsApps 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 來解壓 zip(WindowsApps 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.txt;COPYING.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
|
||||
|
||||
# ── M5:Linux 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 build(BtbN 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 ────────────────────────────────────────────────────
|
||||
#
|
||||
# AF6:Wails 使用 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 root(visiona-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.json,Wails 若自己跑 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 runner(GitHub 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_41),4.0 則
|
||||
# 不加 tag(Wails 預設)。
|
||||
@# -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 ## 美化 DMG(create-dmg 有裝)或 plain DMG(fallback)→ 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 DMG(hdiutil 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 "==> 執行 iscc(cwd: $$(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: ## AB6:build server binary + 以 go run 啟動 visiona-agent(skip 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 -race(full 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
@ -0,0 +1,104 @@
|
||||
# visionA Agent
|
||||
|
||||
> **Phase 0 雛形 — 正在建置中**
|
||||
>
|
||||
> 此專案於 2026-04-22 從 `local-tool/` fork,baseline 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 server(wrapper 綁 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 codesign(macOS) |
|
||||
| `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)。
|
||||
61
local-agent/branding/README.md
Normal 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 多解析度 ICO(16/24/32/48/64/96/128/256,PNG-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` |
|
||||
BIN
local-agent/branding/icon-1024.png
Normal file
|
After Width: | Height: | Size: 120 KiB |
BIN
local-agent/branding/icon-128.png
Normal file
|
After Width: | Height: | Size: 9.8 KiB |
BIN
local-agent/branding/icon-256.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
local-agent/branding/icon-512.png
Normal file
|
After Width: | Height: | Size: 53 KiB |
BIN
local-agent/branding/icon.icns
Normal file
BIN
local-agent/branding/icon.ico
Normal file
|
After Width: | Height: | Size: 50 KiB |
54
local-agent/branding/logo.svg
Normal 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 |
95
local-agent/branding/tools/gen_ico.go
Normal file
@ -0,0 +1,95 @@
|
||||
// 把多張 PNG 打包成 Windows .ico(PNG-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")
|
||||
}
|
||||
289
local-agent/branding/tools/gen_icon.go
Normal 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)
|
||||
}
|
||||
}
|
||||
148
local-agent/docs/BUILD-VERIFICATION.md
Normal file
@ -0,0 +1,148 @@
|
||||
# visionA Agent — Build Pipeline 驗證報告
|
||||
|
||||
> AB12 產出。驗證三平台 build pipeline 設定是否正確,以及 macOS 實際 build 結果。
|
||||
> 最後驗證日期:2026-04-22(jimchen 的 macOS 13 開發機)
|
||||
|
||||
## 三平台預期輸出
|
||||
|
||||
| 平台 | 產出路徑 | 格式 | 參考大小 | 備註 |
|
||||
|------|---------|------|---------|------|
|
||||
| macOS | `dist/visiona-agent.dmg` | zlib-compressed UDZO DMG | ~160 MB | x86_64 Intel,LSMinimumSystemVersion 10.13 |
|
||||
| Windows | `dist/visiona-agent-<ver>-windows-x64.exe` | Inno Setup self-extracting EXE | ~180 MB(估) | MinVersion 10.0.17763(Win10 1809+) |
|
||||
| Linux | `dist/visiona-agent-<ver>-linux-x64.AppImage` | AppImage(FUSE 可執行) | ~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 Tools(clang、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 UDZO,hdiutil 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` | ✅ 審閱通過 | 三 job(macos-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 Identifier(macOS) | `com.innovedus.visiona-local` | `com.innovedus.visiona-agent` | ✅ 不衝突 |
|
||||
| App Bundle Name(macOS) | `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 GUID(Windows) | local-tool 自己的 | `{8671343F-815C-4AA5-891F-1C453CE14E82}` | ✅ 不同 GUID |
|
||||
| Data dir(macOS) | `~/Library/Application Support/visiona-local` | `~/Library/Application Support/visiona-agent`(`platform_darwin.go` + `appName="visiona-agent"`) | ✅ 不衝突 |
|
||||
| Data dir(Linux) | `~/.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 | 3721(HTTP 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 流程可用。
|
||||
- Windows:CI 產出的 `.exe` 在 Win10/11 乾淨 VM 上跑,確認 Inno Setup 安裝流程、WinUSB driver 首次啟動提權。
|
||||
- Linux:CI 產出的 `.AppImage` 在 Ubuntu 22.04 上 `chmod +x && ./visiona-agent-*.AppImage`,確認 udev rule 提示可用。
|
||||
3. **Code signing / notarization**(Phase 2,暫不做):
|
||||
- macOS:Developer ID + notarize(否則使用者首次打開要在設定 > 安全性手動允許)
|
||||
- Windows:EV Code Signing 憑證(避免 SmartScreen 警告)
|
||||
4. **安裝與 local-tool 同機共存**驗證:在已安裝 local-tool 的機器上安裝 Agent,確認兩個 tray icon 同時出現、兩套 data dir 獨立、兩個 server 可同時運作。
|
||||
313
local-agent/docs/LOCAL-TOOL-SPEC.md
Normal 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 桌面 App(Go + 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 wheels(numpy/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 停止時顯示「正在停止伺服器…」overlay,15 秒 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 lock(PID 已死)自動清理並取得新 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
|
||||
- **連接 / 斷開**:連接時自動載入 firmware(KL520 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. 預設模型
|
||||
|
||||
### KL520(4 個)
|
||||
|
||||
| 模型 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 |
|
||||
|
||||
### KL720(3 個)
|
||||
|
||||
| 模型 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
|
||||
```
|
||||
|
||||
### Linux(Ubuntu 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 |
|
||||
209
local-agent/docs/legacy-local-tool-readme.md
Normal 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 client(local-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 邊緣推論能力,打包成單機桌面應用。
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
<!-- 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。
|
||||
|
||||
---
|
||||
|
||||
## 安裝(使用者)
|
||||
|
||||
### macOS(x86_64,beta)
|
||||
|
||||
1. 從內部 Gitea Releases 下載 `visiona-local.dmg`
|
||||
2. 雙擊開啟 dmg → 把 `visionA Local.app` 拖到 `Applications/`
|
||||
3. **第一次啟動**:因為未做程式碼簽章,Gatekeeper 會警告「來自未識別開發者」
|
||||
- 在 Finder 中**右鍵點 `visionA Local.app` → 選「開啟」**(不是雙擊)
|
||||
- 對話框出現「仍要開啟」時點確認
|
||||
- 往後直接雙擊即可
|
||||
4. **首次啟動會花 30–60 秒**解壓內嵌的 Python runtime 並離線安裝 wheels
|
||||
這是預期行為,不是卡住。之後啟動只要幾秒
|
||||
|
||||
> 📁 資料目錄:`~/Library/Application Support/visiona-local/`
|
||||
> 包含 log、lock、ipc-port、自上傳模型
|
||||
|
||||
### Windows / Linux
|
||||
|
||||
**Coming soon** — build script 已經寫好,等 CI runner 齊備後就會釋出。
|
||||
- Windows:Inno 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 / KL720,10 秒內連線
|
||||
- **攝影機推論**: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 並產出 dmg(macOS)
|
||||
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 payload(binary + 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 需要對應平台)。
|
||||
|
||||
---
|
||||
|
||||
## 已知限制與 TODO(local-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-standalone(astral-sh)、shadcn 等開源社群。
|
||||
44
local-agent/frontend/.gitignore
vendored
Normal 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
|
||||
0
local-agent/frontend/.gitkeep
Normal file
310
local-agent/frontend/README.md
Normal 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.js(App Router,`output: "export"` static) | 16.1.x |
|
||||
| 語言 | TypeScript(strict) | 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-themes(Light / 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 backend(tunnel 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 keychain(macOS 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 bindings,http://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 layout(LocaleProvider / ThemeProvider / TooltipProvider / Toaster)
|
||||
│ └── page.tsx # AgentApp — 3 tabs(Radix Tabs)單頁切換入口
|
||||
├── components/
|
||||
│ ├── theme-provider.tsx
|
||||
│ ├── layout/ # Agent 專屬 layout:AppShell / Header / TabNav / ConnectionStatusBadge
|
||||
│ ├── agent/ # 狀態頁元件:StatusHero / InfoCard / RecentLog;配對頁元件:TokenInput
|
||||
│ └── ui/ # 24 個 Radix UI primitives(shadcn 風格封裝)+ 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 dictionary(key 集合與順序與 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 時間 | < 10s(Next.js 16 Turbopack 目前約 3-4s) |
|
||||
| `out/` 總大小 | < 5 MB(static export 後,Wails embed 成本敏感) |
|
||||
| 首次渲染到互動 | < 200ms(Wails 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 Layout(AppShell / 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 還是 placeholder,Phase 1 應改為從 `agentconfig` 或 `wailsjs` bindings 取得。
|
||||
3. **不要**為了「看起來好看」新增動畫 — spec §1.3 明列「不做動畫秀」。
|
||||
4. **不要**引入 routing — 三 tab 切換就是切換,Wails 環境下 URL 行為奇怪,避免之。
|
||||
|
||||
---
|
||||
|
||||
本 README 適用於任何接手 `local-agent/frontend/` 的工程師。有疑問請先讀 spec 與 TDD;不清楚的決策請去查 ADR;不確定的文案請去查 i18n 字典。
|
||||
23
local-agent/frontend/components.json
Normal 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": {}
|
||||
}
|
||||
18
local-agent/frontend/eslint.config.mjs
Normal 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;
|
||||
34
local-agent/frontend/next.config.ts
Normal 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 與 ADR:「visionA 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;
|
||||
42
local-agent/frontend/package.json
Normal file
@ -0,0 +1,42 @@
|
||||
{
|
||||
"name": "visiona-agent-frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "visionA Agent — Wails 桌面應用的前端(Next.js static export,AF1 基礎層)",
|
||||
"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
7
local-agent/frontend/postcss.config.mjs
Normal file
@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
BIN
local-agent/frontend/public/visiona-logo.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
239
local-agent/frontend/src/app/globals.css
Normal file
@ -0,0 +1,239 @@
|
||||
/*
|
||||
* visionA Cloud — 全域樣式 / Design Tokens
|
||||
*
|
||||
* 本檔完整沿用 local-tool/frontend/src/app/globals.css 的 Tokens(shadcn + Tailwind 4),
|
||||
* 確保 F6 任務直接搬頁面時完全相容。
|
||||
*
|
||||
* 架構分層(參考 .autoflow/03-design/design-tokens.md):
|
||||
* 1. @theme inline ─ 將 CSS 變數映射到 Tailwind 4 的語意 utility(bg-background / text-foreground ...)
|
||||
* 2. :root ─ Light Theme raw tokens(shadcn 命名,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.3:local-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)系 tokens(F7 新增)---
|
||||
* 用於:離線 banner、雛形 banner、Pairing 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;
|
||||
}
|
||||
}
|
||||
64
local-agent/frontend/src/app/layout.tsx
Normal 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 context(delayDuration=0 對齊 Shadcn 慣例)
|
||||
* - Toaster — Sonner 全域 toast portal(placed outside main 內容以覆蓋 Dialog)
|
||||
*
|
||||
* 後續任務:
|
||||
* - AF2 — Tab navigation Layout(Header / TabBar)會包在 children 之內
|
||||
* - AF3-AF5 — 三頁 view(StatusView / PairView / SettingsView)
|
||||
* - AF6 — Wails bindings 整合 hooks
|
||||
* - AF7 — 補齊 agent 業務 i18n key + README 收尾
|
||||
*
|
||||
* 與 visionA-frontend layout 的差異(AF1 刻意移除):
|
||||
* - AppShell(雲端版有 PrototypeBanner / Sidebar / Header / Breadcrumb;agent 用更簡單的 tab 切換)
|
||||
* - StoreHydration(agent 還沒有 zustand store,AF3+ 會新增時再引入對應元件)
|
||||
*/
|
||||
export const metadata: Metadata = {
|
||||
title: "visionA Agent",
|
||||
description:
|
||||
"本機桌面代理(local agent / tunnel bridge),讓 Kneron 邊緣裝置安全連上 visionA 雲端。",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
// suppressHydrationWarning:next-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=0:Wails 桌面應用體驗偏向「即時回饋」,
|
||||
hover 即顯示 Tooltip 比延遲顯示更符合本機應用慣例。 */}
|
||||
<TooltipProvider delayDuration={0}>
|
||||
{children}
|
||||
{/* Sonner Toast Portal — 置於 children 之外以覆蓋 Dialog / Sheet */}
|
||||
<Toaster richColors closeButton position="top-right" />
|
||||
</TooltipProvider>
|
||||
</ThemeProvider>
|
||||
</LocaleProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
74
local-agent/frontend/src/app/page.tsx
Normal 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,收到時更新 state(單向資料流:view → 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>
|
||||
);
|
||||
}
|
||||
103
local-agent/frontend/src/components/agent/info-card.tsx
Normal file
@ -0,0 +1,103 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* InfoCard — 狀態頁的連線資訊卡(spec §4.2 B)
|
||||
*
|
||||
* 顯示 4 欄:帳號 / Relay URL / 連線開始時間 / Session Token(遮蔽版)。
|
||||
* 每一行:左欄 labeled(text-muted-foreground, w-[120px]),右欄 value(font-mono)。
|
||||
*
|
||||
* Session Token 遮蔽規則(spec §4.2 B):
|
||||
* - 後端 `connection:status.sessionTokenPreview` 已是遮蔽字串(例:"vAs_a1b2c3d4 ··· e7f8")
|
||||
* - 前端直接顯示;若未帶值,顯示 `—`
|
||||
* - 完整 token 永不顯示、不可複製
|
||||
*
|
||||
* 無障礙:
|
||||
* - 使用 <dl>/<dt>/<dd> 語義化標籤,讓 SR 正確讀出「帳號:xxx」
|
||||
* - Session Token 的 dd 加上 aria-label「Session 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>
|
||||
);
|
||||
}
|
||||
107
local-agent/frontend/src/components/agent/recent-log.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
101
local-agent/frontend/src/components/agent/status-hero.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
183
local-agent/frontend/src/components/agent/status-hero.tsx
Normal 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 的差異:
|
||||
* - Badge(Header 右上角):小型 pill,文字為主,純唯讀
|
||||
* - Hero(狀態頁主視覺):大圓形,icon + 文字 + 次行(錯誤訊息 / 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";
|
||||
|
||||
/**
|
||||
* 將 ConnectionState(TDD 的連線狀態)縮減為 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;
|
||||
/** 大圓本身是否 pulse(reconnecting 用)。 */
|
||||
dotAnimate: boolean;
|
||||
/** Icon 是否旋轉(reconnecting 用)。 */
|
||||
iconSpin: boolean;
|
||||
/** 文字顏色(大部分用 foreground,error 用 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>
|
||||
);
|
||||
}
|
||||
184
local-agent/frontend/src/components/agent/token-input.test.tsx
Normal file
@ -0,0 +1,184 @@
|
||||
/**
|
||||
* TokenInput 單元測試(AF4)
|
||||
*
|
||||
* 驗證重點(對齊 spec §5.2 B / §5.7):
|
||||
* 1. validateToken / sanitizeToken 純函式行為
|
||||
* 2. 三態 hint line(empty / 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();
|
||||
});
|
||||
});
|
||||
168
local-agent/frontend/src/components/agent/token-input.tsx
Normal 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 line:empty / invalid / valid
|
||||
* 4. Focus 時自動全選(方便覆蓋貼上)
|
||||
*
|
||||
* 抽出成獨立元件的原因:
|
||||
* - 邏輯複雜(驗證 + trim + 三態顯示),從 pair-view 分離後單測覆蓋容易
|
||||
* - 未來其他地方可能會有類似「帶格式的一次性 token 輸入」需求
|
||||
*
|
||||
* 無障礙:
|
||||
* - <label htmlFor> 與 <input id> 正確對應
|
||||
* - 錯誤狀態 `aria-invalid` + `aria-describedby` 指向 hint line,SR 會朗讀
|
||||
* - 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.5:Focus 時自動全選,方便覆蓋貼上
|
||||
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>
|
||||
);
|
||||
}
|
||||
@ -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,
|
||||
* 不會 throw。ThemeToggle 的 `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("顯示 Header(banner 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("尚未配對");
|
||||
});
|
||||
});
|
||||
50
local-agent/frontend/src/components/layout/app-shell.tsx
Normal 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 的差異(刻意移除):
|
||||
* - ❌ 不做 PrototypeBanner(spec §3.3 明列「沒有」)
|
||||
* - ❌ 不做 Sidebar(spec §3.3「視窗太小、功能太少」)
|
||||
* - ❌ 不做 NetworkErrorBanner(agent 的連線狀態由 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` 由 children(page.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>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,67 @@
|
||||
/**
|
||||
* ConnectionStatusBadge 單元測試(AF2)
|
||||
*
|
||||
* 驗證重點:
|
||||
* 1. 五種狀態的文字與 `data-status` 對映正確
|
||||
* 2. 無障礙屬性(role="status" / aria-live / aria-label)齊全
|
||||
* 3. Icon + 圓點 + 文字同時呈現(不只靠顏色)
|
||||
*
|
||||
* 不測試:
|
||||
* - Tailwind class 完整字串(實作細節;FAANG 指南禁止)
|
||||
* - i18n 的其他 locale(i18n.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 內部的所有 svg(lucide icon)都應 aria-hidden
|
||||
const svgs = container.querySelectorAll("svg");
|
||||
expect(svgs.length).toBeGreaterThan(0);
|
||||
svgs.forEach((svg) => {
|
||||
expect(svg).toHaveAttribute("aria-hidden", "true");
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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.2(Header 右側 badge)
|
||||
*
|
||||
* 五種狀態對應 `--status-*` token(沿用 visionA-frontend F2 定義):
|
||||
* - online → bg-status-online + CheckCircle2
|
||||
* - offline → bg-status-offline + PowerOff
|
||||
* - reconnecting → bg-status-reconnecting + RefreshCw(animate-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 的 camelCase(notPaired),URL / data 屬性另取 kebab-case(not-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.5(14px)與 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 / spin(WCAG 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>
|
||||
);
|
||||
}
|
||||
150
local-agent/frontend/src/components/layout/header.tsx
Normal file
@ -0,0 +1,150 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Header — visionA Agent 全域頂部列
|
||||
*
|
||||
* 規格來源:
|
||||
* - .autoflow/03-design/visiona-agent-spec.md §3.1 / §3.2(Header 版型、高度 h-14)
|
||||
* - §3.2 右上角 ConnectionStatusBadge(連線狀態)
|
||||
*
|
||||
* 與 visionA-frontend Header 的差異(刻意精簡):
|
||||
* - ❌ 不做 Breadcrumb(agent 不用 routing,三 tab 切換 in-place)
|
||||
* - ❌ 不做 UserMenu(agent 沒有雲端帳號概念,登入在雲端 Web 完成)
|
||||
* - ❌ 不做 NotificationCenter(agent 只關心連線,無通知)
|
||||
* - ✅ 左側:Logo + "visionA Agent" 產品名
|
||||
* - ✅ 右側:ConnectionStatusBadge + LocaleToggle + ThemeToggle
|
||||
*
|
||||
* 設計 Tokens(沿用雲端版):
|
||||
* - 高度 h-14(56px)— 與 visionA-frontend 一致
|
||||
* - 背景 bg-sidebar + border-b border-sidebar-border(spec §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 token(spec 指定 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 / light(system 由 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>
|
||||
);
|
||||
}
|
||||
66
local-agent/frontend/src/components/layout/tab-nav.tsx
Normal file
@ -0,0 +1,66 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* TabNav — 3 個 Tab 的導航列(狀態 / 配對 / 設定)
|
||||
*
|
||||
* 規格來源:
|
||||
* - .autoflow/03-design/visiona-agent-spec.md §3.2(Tab 列)
|
||||
*
|
||||
* 設計決策:
|
||||
* - 使用 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>
|
||||
);
|
||||
}
|
||||
29
local-agent/frontend/src/components/theme-provider.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
207
local-agent/frontend/src/components/ui/alert-dialog.tsx
Normal 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.tsx(100% 沿用)
|
||||
*
|
||||
* 與 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,
|
||||
};
|
||||
84
local-agent/frontend/src/components/ui/alert.tsx
Normal 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 };
|
||||
66
local-agent/frontend/src/components/ui/avatar.tsx
Normal 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 style),local-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 };
|
||||
55
local-agent/frontend/src/components/ui/badge.tsx
Normal 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.tsx(100% 沿用)
|
||||
*
|
||||
* variants:default / 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 };
|
||||
55
local-agent/frontend/src/components/ui/button.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
75
local-agent/frontend/src/components/ui/button.tsx
Normal 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.tsx(100% 沿用)
|
||||
*
|
||||
* 變體:
|
||||
* - variant:default / destructive / outline / secondary / ghost / link
|
||||
* - size:default / 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 };
|
||||
75
local-agent/frontend/src/components/ui/card.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
100
local-agent/frontend/src/components/ui/card.tsx
Normal 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.tsx(100% 沿用)
|
||||
*
|
||||
* 子元件: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,
|
||||
};
|
||||
40
local-agent/frontend/src/components/ui/checkbox.tsx
Normal 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.tsx(100% 沿用)
|
||||
*
|
||||
* 狀態: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 };
|
||||
174
local-agent/frontend/src/components/ui/dialog.tsx
Normal 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 風 Modal(Radix Dialog 封裝)
|
||||
*
|
||||
* 來源:local-tool/frontend/src/components/ui/dialog.tsx(100% 沿用)
|
||||
*
|
||||
* 元件樹:
|
||||
* 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,
|
||||
};
|
||||
282
local-agent/frontend/src/components/ui/dropdown-menu.tsx
Normal 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 style),local-tool 未提供此元件,F4 任務新增。
|
||||
* 與其他 `ui/` 元件一致使用 `React.ComponentProps<typeof Primitive.X>` 風格 + data-slot。
|
||||
*
|
||||
* 元件樹(常用子集):
|
||||
* DropdownMenu
|
||||
* ├── DropdownMenuTrigger
|
||||
* └── DropdownMenuContent
|
||||
* ├── DropdownMenuLabel
|
||||
* ├── DropdownMenuItem ← 最常用
|
||||
* ├── DropdownMenuCheckboxItem
|
||||
* ├── DropdownMenuRadioGroup / Item
|
||||
* ├── DropdownMenuSeparator
|
||||
* └── DropdownMenuSub / SubTrigger / SubContent
|
||||
*
|
||||
* 動畫依賴 tw-animate-css(globals.css 已 import)。
|
||||
*
|
||||
* 雲端版主要使用情境:
|
||||
* - UserMenu(Header 右側 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,
|
||||
};
|
||||
57
local-agent/frontend/src/components/ui/empty-state.tsx
Normal 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.tsx(100% 沿用)
|
||||
*
|
||||
* 用於列表 / 頁面沒有資料時的引導:圓形 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>
|
||||
);
|
||||
}
|
||||
30
local-agent/frontend/src/components/ui/input.tsx
Normal file
@ -0,0 +1,30 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Input — Shadcn 風單行文字輸入
|
||||
*
|
||||
* 來源:local-tool/frontend/src/components/ui/input.tsx(100% 沿用)
|
||||
*
|
||||
* - h-9、rounded-md、shadow-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 };
|
||||
33
local-agent/frontend/src/components/ui/label.tsx
Normal 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.tsx(100% 沿用)
|
||||
*
|
||||
* 支援:
|
||||
* - 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 };
|
||||
39
local-agent/frontend/src/components/ui/progress.tsx
Normal 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.tsx(100% 沿用)
|
||||
*
|
||||
* 主要使用場景:檔案上傳、推論進度、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 };
|
||||
72
local-agent/frontend/src/components/ui/radio-group.tsx
Normal 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 };
|
||||
66
local-agent/frontend/src/components/ui/scroll-area.tsx
Normal 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.tsx(100% 沿用)
|
||||
*
|
||||
* 用於跨平台一致的滾動條外觀(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 };
|
||||
200
local-agent/frontend/src/components/ui/select.tsx
Normal 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.tsx(100% 沿用)
|
||||
*
|
||||
* 子元件: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,
|
||||
};
|
||||
35
local-agent/frontend/src/components/ui/separator.tsx
Normal 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.tsx(100% 沿用)
|
||||
*
|
||||
* 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 };
|
||||
23
local-agent/frontend/src/components/ui/skeleton.tsx
Normal file
@ -0,0 +1,23 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Skeleton — Loading 骨架
|
||||
*
|
||||
* local-tool 未提供此元件,雲端版提前補齊(shadcn 標準實作,極輕量)。
|
||||
*
|
||||
* 用於資料載入中的占位效果,避免 CLS(layout 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 };
|
||||
71
local-agent/frontend/src/components/ui/slider.tsx
Normal 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.tsx(100% 沿用)
|
||||
*
|
||||
* 支援:單值 / 雙值(range),自動根據 value 產生對應數量的 Thumb。
|
||||
* 主要使用場景:inference confidence threshold(0-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 };
|
||||
52
local-agent/frontend/src/components/ui/sonner.tsx
Normal 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-store(theme 由 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 };
|
||||
46
local-agent/frontend/src/components/ui/spinner.tsx
Normal 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 重複使用。
|
||||
*
|
||||
* size:sm(size-4)/ md(size-5)/ lg(size-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 };
|
||||
100
local-agent/frontend/src/components/ui/tabs.tsx
Normal 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.tsx(100% 沿用)
|
||||
*
|
||||
* variant:
|
||||
* - default:TabsList 帶背景(`bg-muted`),active 項顯示白底陰影
|
||||
* - line:TabsList 無背景,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 };
|
||||
76
local-agent/frontend/src/components/ui/tooltip.tsx
Normal 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 style),local-tool 未提供此元件,F4 任務新增。
|
||||
*
|
||||
* 使用方式:
|
||||
* <TooltipProvider>(通常放在 root layout,App 層級包一次即可)
|
||||
* <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 };
|
||||
100
local-agent/frontend/src/hooks/use-connection-status.ts
Normal 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)
|
||||
*
|
||||
* 錯誤處理:
|
||||
* - 初次拉取失敗時 setError,UI 層應顯示 error state(但目前 StatusView 只 check loading,
|
||||
* AF6 階段先保持 silence:error 會被 log 但不阻塞 UI,fallback 到 notPaired 視覺)
|
||||
*
|
||||
* 回傳:
|
||||
* - snapshot:當前狀態快照(null 表示尚未初始化或取錯了 fallback 用)
|
||||
* - loading:是否還在取初始 snapshot
|
||||
* - error:初始化失敗時的錯誤訊息(null 表示成功或尚未開始)
|
||||
* - 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 供呼叫端 await(UI 通常不 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 不能同步呼叫 setState;Promise 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. 初次載入:拉當前 snapshot(Promise 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 };
|
||||
}
|
||||
53
local-agent/frontend/src/hooks/use-export-log.ts
Normal file
@ -0,0 +1,53 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* useExportLog — 封裝 Wails `ExportLog()` binding
|
||||
*
|
||||
* spec §6.2.3「匯出 Log」按鈕:Go 端把 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 };
|
||||
}
|
||||
99
local-agent/frontend/src/hooks/use-pair.test.ts
Normal file
@ -0,0 +1,99 @@
|
||||
/**
|
||||
* usePair 單元測試(AF6)
|
||||
*
|
||||
* 驗證重點:
|
||||
* 1. 成功路徑:resolve,submitting toggle 正確,lastError 保持 null
|
||||
* 2. 失敗路徑:reject 帶 PairError,submitting 歸位,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("成功路徑:resolve,lastError 保持 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();
|
||||
});
|
||||
});
|
||||
67
local-agent/frontend/src/hooks/use-pair.ts
Normal 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 };
|
||||
}
|
||||
68
local-agent/frontend/src/hooks/use-recent-logs.ts
Normal 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` event,Go 端 AB 系列完成後即時 append
|
||||
* - Phase 0:real 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 來時 append,trim 成 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 };
|
||||
}
|
||||
116
local-agent/frontend/src/hooks/use-settings.ts
Normal 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 模式下走 mock(in-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 };
|
||||
}
|
||||
46
local-agent/frontend/src/hooks/use-test-connection.ts
Normal 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 dial(5 秒 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 不會 throw(Go 端回 TestResult 帶 reason);
|
||||
// 但萬一底層出事(例:Wails binding 不存在),包成 ok=false 讓 UI 能顯示
|
||||
return {
|
||||
ok: false,
|
||||
reason: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { test, testing };
|
||||
}
|
||||
314
local-agent/frontend/src/lib/agent-api.test.ts
Normal 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("openURL(mock 環境)退到 window.open,noopener noreferrer", () => {
|
||||
// Fix-F3:mock 環境沒 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 unsubscribe,callback 永遠不被呼叫", () => {
|
||||
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("openURL(Wails 環境)呼叫 runtime.BrowserOpenURL", () => {
|
||||
// Fix-F3:Wails 環境下走 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 推一個 payload(spread 參數進來)
|
||||
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(),
|
||||
);
|
||||
});
|
||||
});
|
||||
519
local-agent/frontend/src/lib/agent-api.ts
Normal file
@ -0,0 +1,519 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* agent-api — Wails binding 統一封裝(AF6)
|
||||
*
|
||||
* 本檔是前端呼叫 Go backend 的唯一閘口。兩種執行環境:
|
||||
*
|
||||
* 1. Wails runtime(wails 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 millis;0 / 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 ConnectionStatus(ms 時間)→ TS ConnectionSnapshot(ISO 時間)。 */
|
||||
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 切 tab(mock 環境沒 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 0:real 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 預設瀏覽器開外部 URL(spec §13.2 / TDD §11.3 / Fix-F3)。
|
||||
*
|
||||
* 為什麼不直接 `window.open`:
|
||||
* - Wails webview 對 `window.open` 的處理是平台相依的(macOS WKWebView
|
||||
* 可能會 hijack 到內建 webview,Windows 可能彈出新視窗),體驗不一致
|
||||
* - `BrowserOpenURL` 一律交給 OS 預設瀏覽器,行為穩定
|
||||
*
|
||||
* 行為:
|
||||
* - Wails runtime:呼叫 `window.runtime.BrowserOpenURL(url)`
|
||||
* - Mock 環境(pnpm dev / vitest):fallback 到 `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 event(Wails runtime 由 Go 端 emit)。
|
||||
*
|
||||
* 用法:
|
||||
* useEffect(() => {
|
||||
* const off = onConnectionStatus((snap) => setSnapshot(snap));
|
||||
* return off;
|
||||
* }, []);
|
||||
*
|
||||
* Mock 環境(pnpm dev / vitest):event 永遠不會觸發,回傳 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 helpers(vitest 專用,不走 production bundle) */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* 測試用 — 重置 mock 狀態(讓每個 test 從乾淨狀態開始)。
|
||||
*
|
||||
* ⚠️ 正式環境不該呼叫此函式;它只修改 mock 模組內的變數。
|
||||
*/
|
||||
export function __resetMockForTest(): void {
|
||||
mockSettings = { ...MOCK_DEFAULT_SETTINGS };
|
||||
}
|
||||
|
||||
/** 測試用 — 檢查是否在 Wails runtime(單元測試驗證 isWailsRuntime 判斷正確)。 */
|
||||
export function __isWailsRuntimeForTest(): boolean {
|
||||
return isWailsRuntime();
|
||||
}
|
||||
92
local-agent/frontend/src/lib/i18n/context.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
92
local-agent/frontend/src/lib/i18n/context.tsx
Normal 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 {
|
||||
/** 初始 locale;SSR 情境下建議使用 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 本身,並在開發環境發出 warning(production 靜默以避免污染 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]);
|
||||
}
|
||||
175
local-agent/frontend/src/lib/i18n/dictionaries/en.ts
Normal 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",
|
||||
};
|
||||
170
local-agent/frontend/src/lib/i18n/dictionaries/zh-Hant.ts
Normal 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 替換
|
||||
*
|
||||
* 預留但尚未使用的 key(不可刪除,Phase 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": "切換語言",
|
||||
|
||||
// ── 狀態頁 — StatusHero(spec §4.2 A)──
|
||||
"status.hero.online": "已連線",
|
||||
"status.hero.offline": "離線",
|
||||
"status.hero.reconnecting": "重新連線中…",
|
||||
"status.hero.notPaired": "尚未配對",
|
||||
"status.hero.error": "連線錯誤",
|
||||
"status.hero.attemptNo": "第 {n}/5 次嘗試",
|
||||
|
||||
// ── 狀態頁 — InfoCard(spec §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": "手動重連",
|
||||
|
||||
// ── 設定頁 — Log(spec §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": "有未儲存的變更",
|
||||
};
|
||||
72
local-agent/frontend/src/lib/i18n/i18n.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
22
local-agent/frontend/src/lib/i18n/index.ts
Normal 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,
|
||||
};
|
||||
44
local-agent/frontend/src/lib/i18n/sync.tsx
Normal file
@ -0,0 +1,44 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* LocaleSync — visionA Cloud
|
||||
*
|
||||
* 在客戶端 mount 後讀取 localStorage 的 `visionA.locale`,同步到 LocaleProvider。
|
||||
* 採用獨立元件(而非 Provider 內 useEffect)的原因:
|
||||
* 1. 避免 SSR 與 hydration mismatch:Provider 首次 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;
|
||||
}
|
||||
36
local-agent/frontend/src/lib/i18n/types.ts
Normal 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);
|
||||
}
|
||||
12
local-agent/frontend/src/lib/utils.ts
Normal 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));
|
||||
}
|
||||
92
local-agent/frontend/src/stores/settings-draft-store.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
108
local-agent/frontend/src/stores/settings-draft-store.ts
Normal file
@ -0,0 +1,108 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Settings Draft Store — visionA Agent(Fix-F4)
|
||||
*
|
||||
* 為什麼存在:
|
||||
* SettingsView 在使用者改 Relay URL 但沒按儲存時切到別的 tab,draft 會
|
||||
* 隨 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 代表「沒有未套用的 draft」,UI 應顯示 saved settings 的值
|
||||
*
|
||||
* 不持久(沒有 zustand/middleware/persist):agent 重啟即清。
|
||||
*/
|
||||
|
||||
import { create } from "zustand";
|
||||
|
||||
import type {
|
||||
AgentSettings,
|
||||
LogLevel,
|
||||
ReconnectStrategy,
|
||||
} from "@/types/agent";
|
||||
|
||||
/**
|
||||
* Draft 狀態 — 每個欄位都是 optional,`undefined` 代表「沒有 draft」(UI 應 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,
|
||||
});
|
||||
}
|
||||
8
local-agent/frontend/src/tests/setup.ts
Normal file
@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Vitest 全域 setup
|
||||
*
|
||||
* - 擴充 expect() 以支援 @testing-library/jest-dom 的 matcher
|
||||
* (toBeInTheDocument / toHaveClass / toHaveAttribute 等)
|
||||
* - F3 任務補齊(元件測試需要)
|
||||
*/
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
124
local-agent/frontend/src/types/agent.ts
Normal file
@ -0,0 +1,124 @@
|
||||
/**
|
||||
* Agent 前端專用型別 — 對齊 `.autoflow/04-architecture/visiona-agent-tdd.md` §6.1 / §6.4
|
||||
*
|
||||
* Wails bindings 的 Go 端 struct(snake_case JSON tag)會被 Wails 序列化成 camelCase 送到前端,
|
||||
* 這裡的 TS 介面命名對應 Go snapshot schema(spec §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 binding),view 層依需要轉換。
|
||||
*/
|
||||
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;
|
||||
}
|
||||
48
local-agent/frontend/src/types/api.ts
Normal 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";
|
||||
140
local-agent/frontend/src/views/pair-view.test.tsx
Normal file
@ -0,0 +1,140 @@
|
||||
/**
|
||||
* PairView 單元測試(Fix-F2 / Fix-F3 涵蓋)
|
||||
*
|
||||
* 驗證重點:
|
||||
* 1. Fix-F2:Esc 鍵 dispatch agent:switch-tab → status
|
||||
* 2. Fix-F2:submitting=true 時 Esc 不觸發
|
||||
* 3. Fix-F2:unmount 後不再響應 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.openURL(Fix-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),
|
||||
);
|
||||
});
|
||||
});
|
||||
224
local-agent/frontend/src/views/pair-view.tsx
Normal file
@ -0,0 +1,224 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* PairView — 配對頁(AF4 實作)
|
||||
*
|
||||
* 對齊 spec §5:
|
||||
* - 標題 + description
|
||||
* - Token 輸入卡(使用抽出的 TokenInput 元件:格式驗證 + trim + Enter 送出)
|
||||
* - 輔助連結(到雲端網頁產生)— Phase 0 URL 寫死 placeholder(spec §5.2 C 明列)
|
||||
* - 按鈕列(取消 / 配對,含 loading state)
|
||||
* - Alert 底部提示(Token 15 分鐘有效)
|
||||
* - 錯誤訊息對照(7 種,對應 PairErrorCode)
|
||||
* - 成功 → 0.5s 後自動切到狀態頁 + toast「配對成功」(D2 決議,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 的 URL(Phase 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-F2:Esc 鍵 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.openURL;Wails 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>
|
||||
);
|
||||
}
|
||||
175
local-agent/frontend/src/views/settings-view.test.tsx
Normal file
@ -0,0 +1,175 @@
|
||||
/**
|
||||
* SettingsView 單元測試(Fix-F4 涵蓋 — draft 持久 + 未儲存 badge)
|
||||
*
|
||||
* 驗證重點:
|
||||
* 1. 改 Relay URL → unmount → remount,draft 還在(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 → remount,draft 還在(模擬切 tab 又回來)", () => {
|
||||
const { unmount } = renderSettings();
|
||||
|
||||
const input = screen.getByTestId("settings-relay-url");
|
||||
fireEvent.change(input, { target: { value: "wss://persisted.test" } });
|
||||
|
||||
// 模擬切到別的 tab(unmount),store 應該保留 draft
|
||||
unmount();
|
||||
expect(useSettingsDraftStore.getState().draftRelayUrl).toBe(
|
||||
"wss://persisted.test",
|
||||
);
|
||||
|
||||
// 切回設定 tab(remount)— 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 → 不 save,badge 仍顯示", () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
489
local-agent/frontend/src/views/settings-view.tsx
Normal 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 + tooltip「Phase 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/",
|
||||
};
|
||||
|
||||
/** 偵測當前 OS(client-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-F4:draft 從 store 讀;若 store 沒值(沒未儲存的變更)就 fallback 到 saved settings。
|
||||
// 切到別的 tab 後 SectionConnection unmount,store 仍保留 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 完全一致就清掉 draft(badge 自動消失)
|
||||
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: Log(spec §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.openURL(Wails 用 BrowserOpenURL;mock 退到 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 disabled(D3 決議) */}
|
||||
<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"));
|
||||
// AF6:await 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>
|
||||
);
|
||||
}
|
||||
125
local-agent/frontend/src/views/status-view.test.tsx
Normal 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-forget;mock 成功即可,本測試不驗主按鈕
|
||||
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", "重新配對");
|
||||
});
|
||||
});
|
||||
293
local-agent/frontend/src/views/status-view.tsx
Normal file
@ -0,0 +1,293 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* StatusView — 狀態頁(AF3 實作)
|
||||
*
|
||||
* 對齊 spec §4:
|
||||
* - StatusHero(大狀態指示器,5 變體)
|
||||
* - InfoCard(帳號 / Relay / 連線開始 / Session Token 遮蔽版)
|
||||
* - 主按鈕列(依狀態五套)
|
||||
* - RecentLog(最近 10 筆事件)
|
||||
* - 未配對時顯示 EmptyState + 「前往配對」CTA
|
||||
*
|
||||
* 資料來源(AF3 階段 mock;AF6 會換成真 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 切換 tab(export 給其他 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(不直接呼叫 unpair;spec §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>
|
||||
);
|
||||
}
|
||||
34
local-agent/frontend/tsconfig.json
Normal 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"]
|
||||
}
|
||||
25
local-agent/frontend/vitest.config.ts
Normal 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 等 matcher(F3 任務才會加)
|
||||
* - 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"),
|
||||
},
|
||||
},
|
||||
});
|
||||