jim800121chen 3f0175f1a9 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>
2026-05-01 11:22:01 +08:00

225 lines
7.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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