120 lines
4.0 KiB
TypeScript
120 lines
4.0 KiB
TypeScript
"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 { styleLearnResult } from "@/lib/jobs/job";
|
||
import { errorCode } from "@/lib/generation/cards";
|
||
import {
|
||
buildLearnRequest,
|
||
normalizeFingerprint,
|
||
type Fingerprint,
|
||
type StyleLearnMode,
|
||
} from "./style";
|
||
|
||
export interface UseStyleLearn {
|
||
// 提交中(POST /style 受理)或轮询中。
|
||
busy: boolean;
|
||
pollStatus: ReturnType<typeof useJobPoll>["status"] | "idle";
|
||
progress: number;
|
||
fingerprint: Fingerprint | null;
|
||
// 学文风:POST /style → 拿 job_id → 轮询 → done 后拉最新指纹。
|
||
learn: (
|
||
projectId: string,
|
||
samples: string[],
|
||
mode: StyleLearnMode,
|
||
) => Promise<boolean>;
|
||
}
|
||
|
||
// 学文风编排:受理(202 job_id)→ useJobPoll 轮询 → done 拉 GET /style 展示指纹。
|
||
export function useStyleLearn(initial: Fingerprint | null): UseStyleLearn {
|
||
const [fingerprint, setFingerprint] = useState<Fingerprint | null>(initial);
|
||
const [submitting, setSubmitting] = useState(false);
|
||
const [pollStatus, setPollStatus] = useState<
|
||
ReturnType<typeof useJobPoll>["status"] | "idle"
|
||
>("idle");
|
||
// projectId 用 ref 而非 state:done 边沿的 effect 须读到「最近一次 learn 传入的」
|
||
// 而非闭包捕获的旧值(修 stale-closure)。learn 同步写 ref,effect 同步读。
|
||
const projectIdRef = useRef<string | null>(null);
|
||
const poll = useJobPoll();
|
||
const toast = useToast();
|
||
// 仅在提交过一次学文风后才反映轮询状态(initialPollState.status 默认 "polling")。
|
||
const startedRef = useRef(false);
|
||
|
||
// 轮询完成 → 拉最新指纹;失败 → toast。
|
||
// 依赖 poll.status / poll.error / toast(均稳定或随状态变化),projectId 经 ref 取最新。
|
||
useEffect(() => {
|
||
if (!startedRef.current) return;
|
||
setPollStatus(poll.status);
|
||
const projectId = projectIdRef.current;
|
||
if (poll.status === "done" && projectId) {
|
||
void refetchFingerprint(projectId).then((fp) => {
|
||
if (fp) setFingerprint(fp);
|
||
toast("文风指纹已更新。", "success");
|
||
});
|
||
}
|
||
if (poll.status === "error") {
|
||
toast(`学文风失败:${poll.error ?? "未知原因"}`, "error");
|
||
}
|
||
}, [poll.status, poll.error, toast]);
|
||
|
||
const learn = useCallback<UseStyleLearn["learn"]>(
|
||
async (pid, samples, mode) => {
|
||
setSubmitting(true);
|
||
projectIdRef.current = pid;
|
||
try {
|
||
const { data, error } = await api.POST("/projects/{project_id}/style", {
|
||
params: { path: { project_id: pid } },
|
||
body: buildLearnRequest(samples, mode),
|
||
});
|
||
if (error || !data) {
|
||
const code = errorCode(error);
|
||
toast(
|
||
code === "LLM_UNAVAILABLE"
|
||
? "未配置提供商,请先去设置页连一家。"
|
||
: "学文风受理失败,请稍后重试。",
|
||
"error",
|
||
);
|
||
return false;
|
||
}
|
||
startedRef.current = true;
|
||
poll.poll(data.job_id);
|
||
setPollStatus("polling");
|
||
return true;
|
||
} finally {
|
||
setSubmitting(false);
|
||
}
|
||
},
|
||
[poll, toast],
|
||
);
|
||
|
||
return {
|
||
busy: submitting || pollStatus === "polling",
|
||
pollStatus,
|
||
progress: poll.progress,
|
||
fingerprint,
|
||
learn,
|
||
};
|
||
}
|
||
|
||
// 客户端拉最新指纹(done 后刷新展示);404/失败 → null。
|
||
async function refetchFingerprint(
|
||
projectId: string,
|
||
): Promise<Fingerprint | null> {
|
||
const { data, error } = await api.GET("/projects/{project_id}/style", {
|
||
params: { path: { project_id: projectId } },
|
||
});
|
||
if (error || !data) return null;
|
||
return normalizeFingerprint(data);
|
||
}
|
||
|
||
// 仅供测试/复用:保证 job done result 的版本回显(不阻断 UI)。
|
||
export function learnSummary(
|
||
poll: ReturnType<typeof useJobPoll>,
|
||
): { version: number | null; dimsCount: number | null } | null {
|
||
if (poll.status !== "done" || !poll.job) return null;
|
||
return styleLearnResult(poll.job);
|
||
}
|