179 lines
6.2 KiB
JavaScript
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>
|
|
)
|
|
}
|