"use client"; import { useCallback, useEffect, useRef, useState } from "react"; import { api } from "@/lib/api/client"; import { useToast } from "@/components/Toast"; import { useJobPoll } from "@/lib/jobs/useJobPoll"; import { authOpenUrl, connectPhase, toConnectionStatus, toDeviceDisplay, type ConnectPhase, type DeviceDisplay, } from "./kimiOauth"; const START = "/settings/providers/kimi-code/oauth/start"; const DISCONNECT = "/settings/providers/kimi-code/oauth/disconnect"; export interface UseKimiOauth { // 派生连接阶段(idle/awaiting/connected/error),驱动 UI。 phase: ConnectPhase; // device 启动后展示给用户的信息(user_code + 验证 URL);未启动→null。 device: DeviceDisplay | null; // 已连接时的 access token 过期时刻(ISO8601;可能为 null)。 expiresAt: string | null; // 任一在途请求(start/disconnect)或正在轮询。 busy: boolean; // 错误文案(device 启动失败 / 轮询失败 / 过期 / 拒绝)。 error: string | null; // 发起连接:POST start → 拿 user_code + job_id → 开浏览器 + 轮询 job。 connect: () => Promise; // 断开:POST disconnect → 复位为未连接。 disconnect: () => Promise; } // 在浏览器打开授权页(device complete URL 优先)。SSR/无 window 时静默跳过。 function openVerification(device: DeviceDisplay): void { if (typeof window === "undefined") return; window.open(authOpenUrl(device), "_blank", "noopener,noreferrer"); } // Kimi Code OAuth device-flow 连接编排(K1.4)。 // connect:POST .../oauth/start(202)→ 展示 user_code + 开浏览器 → 轮询 job 到 done/failed。 // status 进页传入 initialConnected/initialExpiresAt(Server Component 取)。 export function useKimiOauth(args: { initialConnected: boolean; initialExpiresAt: string | null; }): UseKimiOauth { const [connected, setConnected] = useState(args.initialConnected); const [expiresAt, setExpiresAt] = useState( args.initialExpiresAt, ); const [device, setDevice] = useState(null); const [started, setStarted] = useState(false); const [inFlight, setInFlight] = useState(false); const poll = useJobPoll(); const toast = useToast(); const startedRef = useRef(false); const phase = connectPhase({ started, connected, poll }); // 轮询终态:done 且 job.connected → 已连接(刷新状态);error → 提示。 useEffect(() => { if (!startedRef.current) return; if (poll.status === "done") { void refreshStatus().then((ok) => { if (ok) { setConnected(true); toast("已连接 Kimi Code。", "success"); } }); } if (poll.status === "error") { toast(`连接失败:${poll.error ?? "授权未完成或已过期"}`, "error"); } // 仅在 poll.status 变化时反应。 // eslint-disable-next-line react-hooks/exhaustive-deps }, [poll.status]); const refreshStatus = useCallback(async (): Promise => { const { data, error } = await api.GET( "/settings/providers/kimi-code/oauth/status", ); if (error || !data) return false; const status = toConnectionStatus(data); setExpiresAt(status.expiresAt); return status.connected; }, []); const connect = useCallback(async () => { setInFlight(true); try { const { data, error } = await api.POST(START, {}); if (error || !data) { toast("发起 Kimi Code 连接失败,请稍后重试。", "error"); return; } const dev = toDeviceDisplay(data); setDevice(dev); setStarted(true); startedRef.current = true; openVerification(dev); poll.poll(data.job_id); } finally { setInFlight(false); } }, [poll, toast]); const disconnect = useCallback(async () => { setInFlight(true); try { const { data, error } = await api.POST(DISCONNECT, {}); if (error || !data) { toast("断开 Kimi Code 失败,请稍后重试。", "error"); return; } poll.reset(); startedRef.current = false; setStarted(false); setDevice(null); setConnected(false); setExpiresAt(null); toast("已断开 Kimi Code。", "success"); } finally { setInFlight(false); } }, [poll, toast]); return { phase, device, expiresAt, busy: inFlight || (startedRef.current && poll.status === "polling"), error: poll.status === "error" ? poll.error : null, connect, disconnect, }; }