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>
102 lines
3.6 KiB
TypeScript
102 lines
3.6 KiB
TypeScript
// 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",
|
||
});
|
||
}
|