Files
writer-work-flow/apps/web/lib/settings/useKimiOauth.ts
Yaojia Wang 765dbdfbd4 feat: M4 文风 + M5 生成/多provider/Skill + Kimi Code 订阅接入 + 本地联调修复
M4(文风): style-auditor 双轨(提取指纹/漂移第四审)+ jobs 长任务框架(zombie reaper) + 回炉 refine + GET /style read-back。
M5(生成+扩展): worldbuilder/character-gen(入库 continuity 409 gate + partition_writes 白名单 + schema→JSONB 形变);
  网关多 provider 回退链/熔断/能力降级(Anthropic/Gemini 适配器);Skill registry + 表权限沙箱 + 规则;
  前端 角色生成器/世界观/Codex/规则页/技能库/⌘K 命令面板。
K1(Kimi Code 订阅接入): OAuth device-flow(kimi-code)+ 静态 Console key(kimi-code-key)两路径;
  coding 端点 KimiCLI 伪造头(实测 UA allow-list 门禁,缺则 403)+ JSON 模式结构化(thinking ⊥ tool_choice)。
本地联调修复: CORS 中间件;assemble 注入 premise+「写第N章」指令(修空 prompt 400);
  GET /outline·/draft read-back + 大纲/工作台/审稿页重载;写页 client/server 常量边界 + notFound 健壮化;
  字数 toLocaleString locale 水合;审稿页终稿从已存草稿 seed(修 accept 422)。
门禁: backend ruff/mypy(157)/alembic 无漂移/pytest 451 · frontend lint/tsc/vitest/build。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 10:39:58 +02:00

140 lines
4.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";
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<void>;
// 断开POST disconnect → 复位为未连接。
disconnect: () => Promise<void>;
}
// 在浏览器打开授权页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)。
// connectPOST .../oauth/start202→ 展示 user_code + 开浏览器 → 轮询 job 到 done/failed。
// status 进页传入 initialConnected/initialExpiresAtServer Component 取)。
export function useKimiOauth(args: {
initialConnected: boolean;
initialExpiresAt: string | null;
}): UseKimiOauth {
const [connected, setConnected] = useState(args.initialConnected);
const [expiresAt, setExpiresAt] = useState<string | null>(
args.initialExpiresAt,
);
const [device, setDevice] = useState<DeviceDisplay | null>(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<boolean> => {
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<UseKimiOauth["connect"]>(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<UseKimiOauth["disconnect"]>(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,
};
}