154 lines
6.3 KiB
TypeScript
154 lines
6.3 KiB
TypeScript
import React, { useEffect, useRef, useState } from "react";
|
|
|
|
import AutoFixHighIcon from "@mui/icons-material/AutoFixHigh";
|
|
import { Alert, Box, Button, MenuItem, Paper, Stack, TextField, Typography } from "@mui/material";
|
|
|
|
import { getApiErrorMessage } from "../../api";
|
|
import { useAccountPlan } from "../../accountPlan";
|
|
import { AI_ACTIONS, CvAiCallbacks, CvAiProposal, normalizeCvLanguage } from "../../cvBuilder";
|
|
import { useToast } from "../../toast";
|
|
import ProFeatureNotice from "../ProFeatureNotice";
|
|
import { useI18n } from "../../i18n/I18nProvider";
|
|
|
|
type Props = CvAiCallbacks & {
|
|
sectionName: string;
|
|
text: string;
|
|
context?: string;
|
|
documentLanguage?: string | null;
|
|
targetId: string;
|
|
};
|
|
|
|
const SECTION_ACTIONS = new Set(["improve", "professional", "shorten", "grammar", "impact", "bullets"]);
|
|
|
|
export default function AiSectionAssistant({ sectionName, text, context, documentLanguage, targetId, onGenerate, onAccept }: Props) {
|
|
const { t } = useI18n();
|
|
const { toast } = useToast();
|
|
const { canUseAi } = useAccountPlan();
|
|
const [open, setOpen] = useState(false);
|
|
const [action, setAction] = useState("improve");
|
|
const [suggestion, setSuggestion] = useState("");
|
|
const [proposal, setProposal] = useState<CvAiProposal | null>(null);
|
|
const [busy, setBusy] = useState(false);
|
|
const [applying, setApplying] = useState(false);
|
|
const requestId = useRef(0);
|
|
const normalizedLanguage = normalizeCvLanguage(documentLanguage);
|
|
const [targetLanguage, setTargetLanguage] = useState(normalizedLanguage);
|
|
|
|
useEffect(() => setTargetLanguage(normalizedLanguage), [normalizedLanguage]);
|
|
useEffect(() => {
|
|
requestId.current += 1;
|
|
setProposal(null);
|
|
setSuggestion("");
|
|
setBusy(false);
|
|
setApplying(false);
|
|
return () => { requestId.current += 1; };
|
|
}, [text, targetId, normalizedLanguage, canUseAi]);
|
|
|
|
const discard = () => {
|
|
requestId.current += 1;
|
|
setProposal(null);
|
|
setSuggestion("");
|
|
setBusy(false);
|
|
setApplying(false);
|
|
};
|
|
|
|
const run = async () => {
|
|
if (!text.trim() || !canUseAi || busy) return;
|
|
const request = ++requestId.current;
|
|
setBusy(true);
|
|
try {
|
|
const response = await onGenerate({
|
|
action,
|
|
text,
|
|
targetId,
|
|
language: targetLanguage,
|
|
context: [`Section: ${sectionName}. Preserve facts and do not invent achievements.`, context].filter(Boolean).join("\n"),
|
|
});
|
|
if (request !== requestId.current) return;
|
|
setProposal(response);
|
|
setSuggestion(response.result);
|
|
} catch (error) {
|
|
if (request === requestId.current) toast(getApiErrorMessage(error, t("cvAiFailed")), "error");
|
|
} finally {
|
|
if (request === requestId.current) { setBusy(false); setApplying(false); }
|
|
}
|
|
};
|
|
|
|
const accept = async () => {
|
|
if (!proposal || busy) return;
|
|
const request = ++requestId.current;
|
|
setBusy(true);
|
|
setApplying(true);
|
|
try {
|
|
await onAccept(proposal, suggestion);
|
|
if (request !== requestId.current) return;
|
|
discard();
|
|
setOpen(false);
|
|
toast(t("cvAiApplied", { section: sectionName }), "success");
|
|
} catch (error) {
|
|
if (request === requestId.current) toast(getApiErrorMessage(error, t("cvAiFailed")), "error");
|
|
} finally {
|
|
if (request === requestId.current) { setBusy(false); setApplying(false); }
|
|
}
|
|
};
|
|
|
|
if (!open) {
|
|
return (
|
|
<Button size="small" variant="outlined" startIcon={<AutoFixHighIcon />} disabled={!text.trim()} onClick={() => setOpen(true)}>
|
|
{t("cvAiSuggestions")}
|
|
</Button>
|
|
);
|
|
}
|
|
|
|
if (!canUseAi) {
|
|
return (
|
|
<Stack spacing={1}>
|
|
<ProFeatureNotice featureKey="cv-writing-ai" title={t("cvAiProTitle")}>
|
|
{t("cvAiProBody")}
|
|
</ProFeatureNotice>
|
|
<Button size="small" onClick={() => setOpen(false)}>{t("cvAiClose")}</Button>
|
|
</Stack>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Paper variant="outlined" sx={{ p: 1.25, borderColor: "primary.main", bgcolor: "action.hover" }}>
|
|
<Stack spacing={1}>
|
|
<Stack direction={{ xs: "column", sm: "row" }} spacing={1} alignItems={{ sm: "center" }}>
|
|
<TextField select size="small" disabled={busy} label={t("cvAiAction")} value={action} onChange={(event) => setAction(event.target.value)} sx={{ minWidth: 190 }}>
|
|
{AI_ACTIONS.filter((item) => SECTION_ACTIONS.has(item.key)).map((item) => <MenuItem key={item.key} value={item.key}>{actionLabel(item.key, item.label, t)}</MenuItem>)}
|
|
</TextField>
|
|
<TextField select size="small" disabled={busy} label={t("cvAiLanguage")} value={targetLanguage} onChange={(event) => setTargetLanguage(event.target.value)} sx={{ minWidth: 150 }}>
|
|
<MenuItem value="en">{t("languageEnglish")}</MenuItem>
|
|
<MenuItem value="nb-NO">{t("languageNorwegianBokmal")}</MenuItem>
|
|
</TextField>
|
|
<Button size="small" variant="contained" disabled={busy || !text.trim()} onClick={() => void run()}>{busy ? t("cvAiWorking") : t("cvAiSuggest")}</Button>
|
|
<Button size="small" disabled={applying} onClick={() => { discard(); setOpen(false); }}>{t("cvAiClose")}</Button>
|
|
</Stack>
|
|
{suggestion && <>
|
|
<Box>
|
|
<Typography variant="overline" color="text.secondary">{t("cvAiCurrent")}</Typography>
|
|
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap" }}>{proposal?.original ?? text}</Typography>
|
|
</Box>
|
|
<TextField label={t("cvAiSuggested")} multiline minRows={3} fullWidth size="small" disabled={busy} value={suggestion} onChange={(event) => setSuggestion(event.target.value)} />
|
|
<Alert severity="info" sx={{ py: 0 }}>{t("cvAiCheckFacts")}</Alert>
|
|
<Stack direction="row" spacing={1}>
|
|
<Button size="small" variant="contained" disabled={busy || !proposal || !suggestion.trim()} onClick={() => void accept()}>{t("cvAiApply")}</Button>
|
|
<Button size="small" disabled={applying} onClick={discard}>{t("cvAiReject")}</Button>
|
|
</Stack>
|
|
</>}
|
|
</Stack>
|
|
</Paper>
|
|
);
|
|
}
|
|
|
|
type Translate = ReturnType<typeof useI18n>["t"];
|
|
|
|
function actionLabel(key: string, fallback: string, t: Translate) {
|
|
const labels: Record<string, string> = {
|
|
improve: t("cvAiImprove"), professional: t("cvAiProfessional"), shorten: t("cvAiShorten"),
|
|
grammar: t("cvAiGrammar"), impact: t("cvAiImpact"), bullets: t("cvAiBullets"),
|
|
};
|
|
return labels[key] ?? fallback;
|
|
}
|