HR-ATS-Portal/frontend/src/ui/AiFieldAssist.jsx

179 lines
6.2 KiB
JavaScript

/* ============================================================
AiFieldAssist — per-field ✨ assist for form inputs.
A small sparkle button beside a field label opening a panel with two
actions: "Fix mistakes" (cleans up what the recruiter typed) and
"Suggest content" (drafts the field from the rest of the form). The
result is previewed and only written into the field on Apply — the AI
never overwrites the recruiter's text silently.
Generic on purpose: drop it next to any input/textarea and pass
field/value/getContext/onApply. Backend: POST /job/assist-field via
api/jobAssist.js.
============================================================ */
import { useEffect, useRef, useState } from 'react'
import { Icon } from './primitives'
import { assistField } from '../api/jobAssist'
import { friendlyAuthError } from '../lib/errors'
export default function AiFieldAssist({
field, value, getContext, onApply, disabled = false, multiline = false, suggestHint = '',
}) {
const [open, setOpen] = useState(false)
const [phase, setPhase] = useState('menu') // menu | loading | preview | error
const [action, setAction] = useState(null)
const [suggestion, setSuggestion] = useState('')
const [error, setError] = useState('')
const ref = useRef(null)
const abortRef = useRef(null)
const close = () => {
abortRef.current?.abort()
setOpen(false)
setPhase('menu')
setSuggestion('')
setError('')
}
useEffect(() => {
if (!open) return undefined
// mousedown, not click: on click React has already swapped the panel's
// phase and detached the pressed button, so contains() would report an
// inside press as outside and close the panel mid-request.
const onDocDown = (e) => {
if (!e.target?.isConnected) return
if (!ref.current?.contains(e.target)) close()
}
const onKey = (e) => { if (e.key === 'Escape') close() }
document.addEventListener('mousedown', onDocDown)
document.addEventListener('keydown', onKey)
return () => {
document.removeEventListener('mousedown', onDocDown)
document.removeEventListener('keydown', onKey)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open])
// Abort any in-flight call when the form unmounts mid-request.
useEffect(() => () => abortRef.current?.abort(), [])
async function run(nextAction) {
abortRef.current?.abort()
const controller = new AbortController()
abortRef.current = controller
setAction(nextAction)
setPhase('loading')
setError('')
try {
const res = await assistField(
{ field, action: nextAction, text: value || '', context: getContext?.() ?? {} },
{ signal: controller.signal },
)
const text = String(res?.data?.suggestion ?? '').trim()
if (!text) throw new Error('empty suggestion')
setSuggestion(text)
setPhase('preview')
} catch (err) {
if (controller.signal.aborted) return
setError(friendlyAuthError(err, 'AI assist is unavailable right now.'))
setPhase('error')
}
}
return (
<span className="ai-assist" ref={ref}>
<button
type="button"
className={`ai-assist-btn ${open ? 'active' : ''}`}
data-tip="AI assist"
aria-label="AI assist"
disabled={disabled}
onClick={() => (open ? close() : setOpen(true))}
>
<Icon name="sparkles" />
</button>
{open && (
<div className="ai-assist-panel">
{phase === 'menu' && (
<>
<button
type="button"
className="dropdown-link"
disabled={!String(value || '').trim()}
onClick={() => run('fix')}
>
<Icon name="check" /> Fix mistakes
</button>
<button
type="button"
className="dropdown-link"
disabled={Boolean(suggestHint)}
onClick={() => run('suggest')}
>
<Icon name="sparkles" /> Suggest content
</button>
{suggestHint && <div className="ai-assist-hint">{suggestHint}</div>}
</>
)}
{phase === 'loading' && (
<div className="ai-assist-status">
<span className="ai-assist-spinner" />
{action === 'fix' ? 'Fixing…' : 'Writing a suggestion…'}
</div>
)}
{phase === 'error' && (
<>
<div className="ai-assist-error">{error}</div>
<div className="ai-assist-actions">
<button type="button" className="btn btn-secondary btn-sm" onClick={() => setPhase('menu')}>
Back
</button>
<button type="button" className="btn btn-primary btn-sm" onClick={() => run(action)}>
Retry
</button>
</div>
</>
)}
{phase === 'preview' && (() => {
const unchanged = action === 'fix' && suggestion === String(value || '').trim()
return (
<>
{unchanged ? (
<div className="ai-assist-status">
<Icon name="check" /> Nothing to fix the text already looks good.
</div>
) : (
<div className={`ai-assist-preview ${multiline ? 'multiline' : ''}`}>{suggestion}</div>
)}
<div className="ai-assist-actions">
<button type="button" className="btn btn-secondary btn-sm" onClick={close}>
Dismiss
</button>
<button type="button" className="btn btn-secondary btn-sm" onClick={() => run(action)}>
Redo
</button>
{!unchanged && (
<button
type="button"
className="btn btn-primary btn-sm"
onClick={() => { onApply(suggestion); close() }}
>
<Icon name="check" /> Apply
</button>
)}
</div>
</>
)
})()}
</div>
)}
</span>
)
}