fix(cv): gate AI proposals on grounding and source freshness
This commit is contained in:
@@ -34,7 +34,7 @@ import CvCustomizeTab from "../components/cv/CvCustomizeTab";
|
||||
import ProFeatureNotice from "../components/ProFeatureNotice";
|
||||
import { useDragReorder } from "../hooks/useDragReorder";
|
||||
import {
|
||||
CUSTOM_SECTION_PRESETS, CvCustomSectionSetting, CvItemOverride, CvOutline, CvOutlineSection, CvSectionSetting,
|
||||
CUSTOM_SECTION_PRESETS, CvAiCallbacks, CvAiInput, CvAiProposal, CvCustomSectionSetting, CvItemOverride, CvOutline, CvOutlineSection, CvSectionSetting,
|
||||
CvTheme, CvVariant, CvVariantSettings, CvVariantVersionInfo, DEFAULT_SECTION_ORDER, SECTION_LABELS,
|
||||
cvBuilderApi, getCvPageMetrics, getCvPreviewFitZoom, moveItem, normalizeCvLanguage,
|
||||
} from "../cvBuilder";
|
||||
@@ -81,6 +81,7 @@ export default function CvBuilderEditor() {
|
||||
|
||||
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const saveRevision = useRef(0);
|
||||
const persistedRevision = useRef(0);
|
||||
const saveQueue = useRef<Promise<boolean>>(Promise.resolve(true));
|
||||
const serverVersion = useRef(0);
|
||||
const editorEpoch = useRef(0);
|
||||
@@ -119,6 +120,7 @@ export default function CvBuilderEditor() {
|
||||
})();
|
||||
return () => {
|
||||
alive = false;
|
||||
editorEpoch.current += 1;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [variantId]);
|
||||
@@ -126,6 +128,7 @@ export default function CvBuilderEditor() {
|
||||
const applyVariant = (variant: CvVariant) => {
|
||||
if (saveTimer.current) clearTimeout(saveTimer.current);
|
||||
saveRevision.current += 1;
|
||||
persistedRevision.current = saveRevision.current;
|
||||
editorEpoch.current += 1;
|
||||
serverVersion.current = variant.version;
|
||||
revisionConflict.current = false;
|
||||
@@ -175,6 +178,7 @@ export default function CvBuilderEditor() {
|
||||
const saved = await cvBuilderApi.save(variantId, { name: nextName, settings: next, source: "autosave", expectedVersion: serverVersion.current });
|
||||
if (epoch !== editorEpoch.current) return false;
|
||||
serverVersion.current = saved.version;
|
||||
persistedRevision.current = revision;
|
||||
if (saveRevision.current === revision) setSaveState("saved");
|
||||
return true;
|
||||
} catch (error) {
|
||||
@@ -201,6 +205,7 @@ export default function CvBuilderEditor() {
|
||||
if (saveTimer.current) clearTimeout(saveTimer.current);
|
||||
if (!nextName.trim() || revisionConflict.current) return;
|
||||
saveTimer.current = setTimeout(async () => {
|
||||
saveTimer.current = null;
|
||||
await performSave(next, nextName, revision);
|
||||
}, 800);
|
||||
},
|
||||
@@ -214,6 +219,70 @@ export default function CvBuilderEditor() {
|
||||
return performSave(latestSettings.current, latestName.current, revision);
|
||||
}, [performSave]);
|
||||
|
||||
const flushAiSource = async () => {
|
||||
const epoch = editorEpoch.current;
|
||||
if (saveTimer.current) clearTimeout(saveTimer.current);
|
||||
saveTimer.current = null;
|
||||
await saveQueue.current;
|
||||
if (epoch !== editorEpoch.current || revisionConflict.current || !latestSettings.current || !latestName.current.trim()) {
|
||||
throw new Error(t("cvAiFailed"));
|
||||
}
|
||||
const revision = saveRevision.current;
|
||||
if (persistedRevision.current !== revision && !(await performSave(latestSettings.current, latestName.current, revision))) {
|
||||
throw new Error(t("cvAiFailed"));
|
||||
}
|
||||
if (epoch !== editorEpoch.current || revision !== saveRevision.current) throw new Error(t("cvAiFailed"));
|
||||
return { epoch, revision };
|
||||
};
|
||||
|
||||
const generateAi = async (input: CvAiInput) => {
|
||||
const { epoch, revision } = await flushAiSource();
|
||||
const proposal = await cvBuilderApi.aiAssist({ ...input, variantId, expectedVersion: serverVersion.current });
|
||||
if (epoch !== editorEpoch.current || revision !== saveRevision.current) throw new Error(t("cvAiFailed"));
|
||||
return proposal;
|
||||
};
|
||||
|
||||
const acceptAi = async (proposal: CvAiProposal, result?: string) => {
|
||||
const { epoch, revision } = await flushAiSource();
|
||||
const previous = latestSettings.current!;
|
||||
const apply = async () => {
|
||||
if (epoch !== editorEpoch.current || revision !== saveRevision.current || revisionConflict.current) throw new Error(t("cvAiFailed"));
|
||||
try {
|
||||
const saved = await cvBuilderApi.applyAi(variantId, { proposalToken: proposal.proposalToken, ...(result !== undefined ? { result } : {}) });
|
||||
if (epoch !== editorEpoch.current) throw new Error(t("cvAiFailed"));
|
||||
serverVersion.current = saved.version;
|
||||
if (revision !== saveRevision.current) {
|
||||
revisionConflict.current = true;
|
||||
setSaveState("conflict");
|
||||
throw new Error(t("cvAiFailed"));
|
||||
}
|
||||
undoStack.current = [...undoStack.current.slice(-59), previous];
|
||||
redoStack.current = [];
|
||||
saveRevision.current += 1;
|
||||
persistedRevision.current = saveRevision.current;
|
||||
latestSettings.current = saved.settings;
|
||||
latestName.current = saved.name;
|
||||
setSettings(saved.settings);
|
||||
setName(saved.name);
|
||||
setIsPublic(saved.isPublic);
|
||||
setPublicSlug(saved.publicSlug);
|
||||
setJobApplicationId(saved.jobApplicationId ?? null);
|
||||
setSaveState("saved");
|
||||
setHistoryRevision((value) => value + 1);
|
||||
} catch (error) {
|
||||
const code = (error as { response?: { data?: { code?: string } } })?.response?.data?.code;
|
||||
if (epoch === editorEpoch.current && (code === "cv_revision_conflict" || code === "cv_revision_required")) {
|
||||
revisionConflict.current = true;
|
||||
setSaveState("conflict");
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
const queued = saveQueue.current.then(apply, apply);
|
||||
saveQueue.current = queued.then(() => true, () => false);
|
||||
await queued;
|
||||
};
|
||||
|
||||
const update = (patch: Partial<CvVariantSettings>) => {
|
||||
setSettings((prev) => {
|
||||
if (!prev) return prev;
|
||||
@@ -514,7 +583,7 @@ export default function CvBuilderEditor() {
|
||||
</Tabs>
|
||||
<Box sx={{ p: { xs: 1, sm: 1.5 } }}>
|
||||
<Box role="tabpanel" id="cv-studio-panel-1" aria-labelledby="cv-studio-tab-1" hidden={tab !== 1}>
|
||||
<ContentTab settings={settings} update={update} outline={outline} showPreviewEntry={!compactEditor} onFocusPreview={() => {
|
||||
<ContentTab settings={settings} update={update} outline={outline} onGenerate={generateAi} onAccept={acceptAi} showPreviewEntry={!compactEditor} onFocusPreview={() => {
|
||||
scrollRef.current?.focus({ preventScroll: true });
|
||||
scrollRef.current?.scrollIntoView({ block: "nearest", inline: "nearest" });
|
||||
}} />
|
||||
@@ -523,7 +592,7 @@ export default function CvBuilderEditor() {
|
||||
{tab === value && <>
|
||||
{value === 0 && <CvCustomizeTab mode="template" settings={settings} update={update} themes={themes} sectionLabel={(key, fallback) => sectionName(key, fallback, t)} />}
|
||||
{value === 2 && <CvCustomizeTab mode="customize" settings={settings} update={update} themes={themes} sectionLabel={(key, fallback) => sectionName(key, fallback, t)} />}
|
||||
{value === 3 && <DocumentAiTab settings={settings} outline={outline} update={update} />}
|
||||
{value === 3 && <DocumentAiTab settings={settings} outline={outline} onGenerate={generateAi} onAccept={acceptAi} />}
|
||||
{value === 4 && <HistoryTab versions={versions} onRestore={restore} />}
|
||||
</>}
|
||||
</Box>)}
|
||||
@@ -660,7 +729,7 @@ function documentAiBlocks(settings: CvVariantSettings, outline: CvOutline | null
|
||||
}
|
||||
}
|
||||
for (const section of settings.customSections ?? []) {
|
||||
if (!section.hidden) blocks.push({ id: `custom:${section.key}`, title: section.title, items: section.items });
|
||||
if (!(configured.get(`custom:${section.key}`)?.hidden ?? section.hidden)) blocks.push({ id: `custom:${section.key}`, title: section.title, items: section.items });
|
||||
}
|
||||
return blocks.filter((block) => block.title?.trim() || block.subtitle?.trim() || block.items?.some((item) => item.trim()));
|
||||
}
|
||||
@@ -688,36 +757,9 @@ function parseDocumentAiBlocks(value: string, originals: DocumentAiBlock[]): Doc
|
||||
}
|
||||
}
|
||||
|
||||
function applyDocumentAiBlocks(settings: CvVariantSettings, blocks: DocumentAiBlock[]): CvVariantSettings {
|
||||
const byId = new Map(blocks.map((block) => [block.id, block]));
|
||||
const headline = byId.get("headline")?.items?.[0];
|
||||
const sections = settings.sections.map((section) => {
|
||||
const block = byId.get(`section:${section.key}`);
|
||||
return block ? { ...section, title: block.title, items: block.items } : section;
|
||||
});
|
||||
const configuredKeys = new Set(sections.map((section) => section.key));
|
||||
for (const block of blocks) {
|
||||
if (!block.id.startsWith("section:")) continue;
|
||||
const key = block.id.slice("section:".length);
|
||||
if (!configuredKeys.has(key)) sections.push({ key, title: block.title, items: block.items });
|
||||
}
|
||||
const overrides = { ...settings.overrides };
|
||||
for (const block of blocks) {
|
||||
if (!block.id.startsWith("entry:")) continue;
|
||||
const key = block.id.split(":").slice(2).join(":");
|
||||
overrides[key] = { ...overrides[key], title: block.title, subtitle: block.subtitle, bullets: block.items };
|
||||
}
|
||||
const customSections = settings.customSections.map((section) => {
|
||||
const block = byId.get(`custom:${section.key}`);
|
||||
return block ? { ...section, title: block.title, items: block.items ?? [] } : section;
|
||||
});
|
||||
return { ...settings, ...(headline !== undefined ? { headline } : {}), sections, overrides, customSections };
|
||||
}
|
||||
|
||||
function DocumentAiTab({ settings, outline, update }: {
|
||||
function DocumentAiTab({ settings, outline, onGenerate, onAccept }: CvAiCallbacks & {
|
||||
settings: CvVariantSettings;
|
||||
outline: CvOutline | null;
|
||||
update: (patch: Partial<CvVariantSettings>) => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const { toast } = useToast();
|
||||
@@ -726,27 +768,66 @@ function DocumentAiTab({ settings, outline, update }: {
|
||||
const [targetLanguage, setTargetLanguage] = useState(normalizeCvLanguage(settings.language));
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [suggestion, setSuggestion] = useState<DocumentAiBlock[] | null>(null);
|
||||
const [originals, setOriginals] = useState<DocumentAiBlock[]>([]);
|
||||
const [proposal, setProposal] = useState<CvAiProposal | null>(null);
|
||||
const requestId = useRef(0);
|
||||
const sourceKey = JSON.stringify(blocks);
|
||||
|
||||
useEffect(() => setTargetLanguage(normalizeCvLanguage(settings.language)), [settings.language]);
|
||||
useEffect(() => {
|
||||
requestId.current += 1;
|
||||
setSuggestion(null);
|
||||
setProposal(null);
|
||||
setBusy(null);
|
||||
return () => { requestId.current += 1; };
|
||||
}, [sourceKey, settings.language, canUseAi]);
|
||||
|
||||
const discard = () => {
|
||||
requestId.current += 1;
|
||||
setSuggestion(null);
|
||||
setProposal(null);
|
||||
setBusy(null);
|
||||
};
|
||||
|
||||
const run = async (action: string) => {
|
||||
if (!blocks.length) return;
|
||||
if (!blocks.length || !canUseAi || busy) return;
|
||||
const request = ++requestId.current;
|
||||
setBusy(action);
|
||||
setSuggestion(null);
|
||||
try {
|
||||
const response = await cvBuilderApi.aiAssist({
|
||||
const response = await onGenerate({
|
||||
action,
|
||||
text: JSON.stringify(blocks),
|
||||
language: targetLanguage,
|
||||
context: "This is a complete CV represented as JSON blocks. Return only a JSON array with every original id exactly once and the same title, subtitle and items fields. Preserve all facts, employers, dates, qualifications and technologies. Never add unsupported claims.",
|
||||
});
|
||||
const parsed = parseDocumentAiBlocks(response.result, blocks);
|
||||
if (request !== requestId.current) return;
|
||||
const source: DocumentAiBlock[] = JSON.parse(response.original);
|
||||
if (!Array.isArray(source) || !source.length || source.some((block) => !block || typeof block.id !== "string") || new Set(source.map((block) => block.id)).size !== source.length) throw new Error(t("cvDocumentAiInvalid"));
|
||||
const parsed = parseDocumentAiBlocks(response.result, source);
|
||||
if (!parsed) throw new Error(t("cvDocumentAiInvalid"));
|
||||
setOriginals(source);
|
||||
setProposal(response);
|
||||
setSuggestion(parsed);
|
||||
} catch (error) {
|
||||
toast(getApiErrorMessage(error, t("cvAiFailed")), "error");
|
||||
if (request === requestId.current) toast(getApiErrorMessage(error, t("cvAiFailed")), "error");
|
||||
} finally {
|
||||
setBusy(null);
|
||||
if (request === requestId.current) setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const accept = async () => {
|
||||
if (!proposal || busy) return;
|
||||
const request = ++requestId.current;
|
||||
setBusy("apply");
|
||||
try {
|
||||
await onAccept(proposal);
|
||||
if (request !== requestId.current) return;
|
||||
discard();
|
||||
toast(t("cvDocumentAiApplied"), "success");
|
||||
} catch (error) {
|
||||
if (request === requestId.current) toast(getApiErrorMessage(error, t("cvAiFailed")), "error");
|
||||
} finally {
|
||||
if (request === requestId.current) setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -755,7 +836,7 @@ function DocumentAiTab({ settings, outline, update }: {
|
||||
{ key: "document-improve", title: t("cvDocumentAiImprove"), description: t("cvDocumentAiImproveHelp"), icon: <AutoFixHighIcon /> },
|
||||
{ key: "document-grammar", title: t("cvDocumentAiGrammar"), description: t("cvDocumentAiGrammarHelp"), icon: <SpellcheckIcon /> },
|
||||
];
|
||||
const changed = suggestion?.filter((block) => JSON.stringify(block) !== JSON.stringify(blocks.find((original) => original.id === block.id))) ?? [];
|
||||
const changed = suggestion?.filter((block) => JSON.stringify(block) !== JSON.stringify(originals.find((original) => original.id === block.id))) ?? [];
|
||||
|
||||
return (
|
||||
<Stack spacing={2}>
|
||||
@@ -764,7 +845,7 @@ function DocumentAiTab({ settings, outline, update }: {
|
||||
<Typography variant="body2" color="text.secondary">{t("cvDocumentAiHelp")}</Typography>
|
||||
</Box>
|
||||
{!canUseAi && <ProFeatureNotice featureKey="cv-writing-ai" title={t("cvDocumentAiProTitle")}>{t("cvAiProBody")}</ProFeatureNotice>}
|
||||
<TextField select size="small" label={t("cvAiLanguage")} value={targetLanguage} onChange={(event) => setTargetLanguage(event.target.value)} sx={{ maxWidth: 220 }}>
|
||||
<TextField select size="small" disabled={!!busy} label={t("cvAiLanguage")} value={targetLanguage} onChange={(event) => setTargetLanguage(event.target.value)} sx={{ maxWidth: 220 }}>
|
||||
<MenuItem value="en">{t("languageEnglish")}</MenuItem>
|
||||
<MenuItem value="nb-NO">{t("languageNorwegianBokmal")}</MenuItem>
|
||||
</TextField>
|
||||
@@ -791,15 +872,15 @@ function DocumentAiTab({ settings, outline, update }: {
|
||||
{changed.length === 0 ? <Alert severity="info">{t("cvDocumentAiNoChanges")}</Alert> : (
|
||||
<Stack spacing={1} sx={{ maxHeight: 360, overflowY: "auto" }}>
|
||||
{changed.map((block) => {
|
||||
const original = blocks.find((item) => item.id === block.id);
|
||||
const original = originals.find((item) => item.id === block.id);
|
||||
return <Paper key={block.id} variant="outlined" sx={{ p: 1 }}><Typography variant="caption" sx={{ fontWeight: 800 }}>{block.title ?? original?.title ?? block.id}</Typography><Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" }, gap: 1, mt: 0.5 }}><Typography variant="body2" sx={{ whiteSpace: "pre-wrap", color: "text.secondary" }}>{[original?.subtitle, ...(original?.items ?? [])].filter(Boolean).join("\n")}</Typography><Typography variant="body2" sx={{ whiteSpace: "pre-wrap" }}>{[block.subtitle, ...(block.items ?? [])].filter(Boolean).join("\n")}</Typography></Box></Paper>;
|
||||
})}
|
||||
</Stack>
|
||||
)}
|
||||
<Alert severity="info" sx={{ mt: 1.5 }}>{t("cvAiCheckFacts")}</Alert>
|
||||
<Stack direction="row" spacing={1} sx={{ mt: 1.5 }}>
|
||||
<Button variant="contained" disabled={!changed.length} onClick={() => { const next = applyDocumentAiBlocks(settings, suggestion); update(next); setSuggestion(null); toast(t("cvDocumentAiApplied"), "success"); }}>{t("cvAiApply")}</Button>
|
||||
<Button onClick={() => setSuggestion(null)}>{t("cvAiReject")}</Button>
|
||||
<Button variant="contained" disabled={!!busy || !changed.length} onClick={() => void accept()}>{t("cvAiApply")}</Button>
|
||||
<Button disabled={busy === "apply"} onClick={discard}>{t("cvAiReject")}</Button>
|
||||
</Stack>
|
||||
</Paper>
|
||||
)}
|
||||
@@ -829,7 +910,7 @@ function presetLabel(key: string, fallback: string, t: Translate) {
|
||||
return labels[key] ?? fallback;
|
||||
}
|
||||
|
||||
function ContentTab({ settings, update, outline, showPreviewEntry, onFocusPreview }: {
|
||||
function ContentTab({ settings, update, outline, showPreviewEntry, onFocusPreview, onGenerate, onAccept }: CvAiCallbacks & {
|
||||
settings: CvVariantSettings;
|
||||
update: (p: Partial<CvVariantSettings>) => void;
|
||||
outline: CvOutline | null;
|
||||
@@ -982,6 +1063,8 @@ function ContentTab({ settings, update, outline, showPreviewEntry, onFocusPrevie
|
||||
if (custom) void removeCustom(custom);
|
||||
}}
|
||||
onUpdateSettings={update}
|
||||
onGenerate={onGenerate}
|
||||
onAccept={onAccept}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
@@ -1027,8 +1110,8 @@ function AddContentCard({ title, description, added, onAdd }: { title: string; d
|
||||
}
|
||||
|
||||
function SectionRow({
|
||||
row, index, total, dragProps, dragging, over, outlineSection, customSection, settings, expanded, onToggle, onSectionFocus, toggleRef, onMove, onPatch, onRenameCustom, onUpdateCustom, onRemoveCustom, onUpdateSettings,
|
||||
}: {
|
||||
row, index, total, dragProps, dragging, over, outlineSection, customSection, settings, expanded, onToggle, onSectionFocus, toggleRef, onMove, onPatch, onRenameCustom, onUpdateCustom, onRemoveCustom, onUpdateSettings, onGenerate, onAccept,
|
||||
}: CvAiCallbacks & {
|
||||
row: CvSectionSetting;
|
||||
index: number;
|
||||
total: number;
|
||||
@@ -1136,10 +1219,10 @@ function SectionRow({
|
||||
<Button ref={addEntryButton} size="small" startIcon={<AddIcon />} sx={{ alignSelf: "flex-start" }} onClick={() => { onUpdateCustom({ items: [...customSection.items, ""] }); focusCustomEntry(customSection.items.length); }}>{t("cvEditorAddEntry")}</Button>
|
||||
</Stack> : <RichTextField label={customSection.contentType === "paragraphs" ? t("cvEditorParagraphsHelp") : t("cvEditorBulletsHelp")} minRows={4} value={customSection.contentType === "paragraphs" ? customText : customSection.items.join("\n")}
|
||||
onChange={(value) => onUpdateCustom({ items: customSection.contentType === "paragraphs" ? value.split(/\n\s*\n/) : value.split("\n") })} />}
|
||||
<AiSectionAssistant sectionName={sectionLabel} text={customText} documentLanguage={settings.language} context="This is a custom CV section." onApply={(value) => onUpdateCustom({ items: customSection.contentType === "paragraphs" ? value.split(/\n\s*\n/) : value.split("\n") })} />
|
||||
<AiSectionAssistant sectionName={sectionLabel} targetId={`custom:${customSection.key}`} text={customText} documentLanguage={settings.language} context="This is a custom CV section." onGenerate={onGenerate} onAccept={onAccept} />
|
||||
</Stack>
|
||||
) : outlineSection?.kind === "entries" ? (
|
||||
<EntryEditor section={outlineSection} row={row} settings={settings} onPatch={onPatch} onUpdateSettings={onUpdateSettings} />
|
||||
<EntryEditor section={outlineSection} row={row} settings={settings} onPatch={onPatch} onUpdateSettings={onUpdateSettings} onGenerate={onGenerate} onAccept={onAccept} />
|
||||
) : (
|
||||
<Stack spacing={1}>
|
||||
<RichTextField label={outlineSection?.kind === "tags" ? t("cvEditorItemsHelp") : t("cvEditorContentItemsHelp")} helperText={t(row.items != null ? "cvEditorCustomized" : "cvEditorUsingSource")} minRows={3} value={editableItems.join("\n")} onChange={(value) => onPatch({ items: value.split("\n") })} />
|
||||
@@ -1150,7 +1233,7 @@ function SectionRow({
|
||||
onPatch({ items: undefined }); requestAnimationFrame(() => field?.focus());
|
||||
}}>{t("cvEditorResetMasterProfile")}</Button>
|
||||
</Stack>}
|
||||
<AiSectionAssistant sectionName={sectionLabel} text={editableItems.join("\n")} documentLanguage={settings.language} context="Return one item per line when suggesting a list." onApply={(value) => onPatch({ items: value.split("\n") })} />
|
||||
<AiSectionAssistant sectionName={sectionLabel} targetId={`section:${row.key}`} text={editableItems.join("\n")} documentLanguage={settings.language} context="Return one item per line when suggesting a list." onGenerate={onGenerate} onAccept={onAccept} />
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
@@ -1159,7 +1242,7 @@ function SectionRow({
|
||||
);
|
||||
}
|
||||
|
||||
function EntryEditor({ section, row, settings, onPatch, onUpdateSettings }: {
|
||||
function EntryEditor({ section, row, settings, onPatch, onUpdateSettings, onGenerate, onAccept }: CvAiCallbacks & {
|
||||
section: CvOutlineSection;
|
||||
row: CvSectionSetting;
|
||||
settings: CvVariantSettings;
|
||||
@@ -1244,7 +1327,7 @@ function EntryEditor({ section, row, settings, onPatch, onUpdateSettings }: {
|
||||
</Box>
|
||||
{ov.bullets != null && <Button size="small" sx={{ alignSelf: "flex-start" }} onClick={() => resetField(key, "bullets")}>{t("cvEditorResetField", { field: t("cvEditorBulletsHelp") })}</Button>}
|
||||
{(ov.title != null || ov.subtitle != null || ov.bullets != null) && <Typography variant="caption" color="text.secondary">{t("cvEditorResetFieldHelp")}</Typography>}
|
||||
<AiSectionAssistant sectionName={`${section.title}: ${entryLabel}`} text={(ov.bullets ?? entry.bullets).join("\n")} documentLanguage={settings.language} context={`Entry title: ${ov.title ?? entry.title ?? ""}. Subtitle: ${ov.subtitle ?? entry.subtitle ?? ""}. Return one bullet per line.`} onApply={(value) => setOverride(key, { bullets: value.split("\n") })} />
|
||||
<AiSectionAssistant sectionName={`${section.title}: ${entryLabel}`} targetId={`entry:${section.key}:${key}`} text={(ov.bullets ?? entry.bullets).join("\n")} documentLanguage={settings.language} context={`Entry title: ${ov.title ?? entry.title ?? ""}. Subtitle: ${ov.subtitle ?? entry.subtitle ?? ""}. Return one bullet per line.`} onGenerate={onGenerate} onAccept={onAccept} />
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user