// Kimi Code OAuth device-flow 连接态纯逻辑(C3 扩 K1.3,K1.4 前端)。 // 把「连接状态查询」「device 启动响应」「job 轮询结果」收窄/归一成一个 // 可渲染的连接阶段(connect phase),组件只读结果、不含分支逻辑。 // 纯函数 + node-env 单测;token 永不出现(job 结果只 {connected, provider})。 import type { JobView, PollState } from "@/lib/jobs/job"; import type { OAuthStartResponse, OAuthStatusResponse, } from "@/lib/api/types"; // Kimi Code 是 OAuth 订阅 plan 提供商(与 API-key provider `kimi` 分离)。 export const KIMI_CODE_PROVIDER = "kimi-code"; export const KIMI_CODE_MODEL = "kimi-for-coding"; // 连接流阶段: // - idle:未发起且未连接(展示「连接」按钮)。 // - connected:状态查询/job 完成显示已连接(展示「断开」)。 // - awaiting:已拿 device code,正在等待用户在浏览器授权 + 轮询 job。 // - error:device 启动失败 / 轮询失败 / 过期 / 拒绝(展示错误 + 允许重试)。 export type ConnectPhase = "idle" | "awaiting" | "connected" | "error"; // device 启动后用户面要展示的信息(无 token)。 export interface DeviceDisplay { userCode: string; verificationUri: string; verificationUriComplete: string | null; expiresIn: number; interval: number; } // 把 OAuthStartResponse 收窄成展示用 DeviceDisplay(缺字段给安全默认)。 export function toDeviceDisplay(res: OAuthStartResponse): DeviceDisplay { return { userCode: res.user_code, verificationUri: res.verification_uri, verificationUriComplete: res.verification_uri_complete ?? null, expiresIn: typeof res.expires_in === "number" ? res.expires_in : 0, interval: typeof res.interval === "number" ? res.interval : 5, }; } // 优先打开的授权 URL:有 complete(带 user_code 预填)就用它,否则裸 verification_uri。 export function authOpenUrl(device: DeviceDisplay): string { return device.verificationUriComplete ?? device.verificationUri; } // 连接状态(GET .../oauth/status)收窄。 export interface ConnectionStatus { connected: boolean; expiresAt: string | null; } export function toConnectionStatus( res: OAuthStatusResponse, ): ConnectionStatus { return { connected: res.connected === true, expiresAt: typeof res.expires_at === "string" ? res.expires_at : null, }; } // kimi_oauth job 完成态的 result:{connected, provider}(**绝无 token**)。 export function jobConnected(job: JobView): boolean { return job.result?.["connected"] === true; } // 把「是否已发起连接 + 轮询状态」映射成连接阶段。 // - 未发起(started=false):connected ? connected : idle。 // - 已发起:done 且 job.connected → connected;error → error;否则 awaiting。 export function connectPhase(args: { started: boolean; connected: boolean; poll: PollState; }): ConnectPhase { const { started, connected, poll } = args; if (!started) { return connected ? "connected" : "idle"; } if (poll.status === "done") { return poll.job !== null && jobConnected(poll.job) ? "connected" : "error"; } if (poll.status === "error") { return "error"; } return "awaiting"; } // 把 ISO8601 过期时刻格式化成可读文案(无效/缺省→null)。 export function formatExpiresAt(iso: string | null): string | null { if (!iso) return null; const ms = Date.parse(iso); if (Number.isNaN(ms)) return null; return new Date(ms).toLocaleString("zh-CN", { year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", }); }