Prompt queue: pre-fetch 5, instant local fallback, emoji source icon
- Refill a queue of 5 AI prompts in the background on page load - Track in-flight fetches with pendingFetches ref so we never over-request - fetchPrompt() is now synchronous: pops from queue or instantly returns a shuffled built-in (LOCAL_PROMPTS, 12 entries, no-repeat cycling) - Source badge replaced with a small emoji in the top-right corner of the popover: 🤖 for AI prompts, 📚 for built-ins - Removed promptLoading state and spinner entirely Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
c5b7d7f774
commit
48d3533bfc
@ -14,6 +14,33 @@ import NotesPanel from '../components/NotesPanel'
|
||||
const MIN_FONT = 13
|
||||
const MAX_FONT = 26
|
||||
|
||||
// Small client-side fallback pool — used instantly when the AI queue is empty.
|
||||
// Shuffled so the same prompt doesn't appear twice before cycling through all.
|
||||
const LOCAL_PROMPTS = (() => {
|
||||
const list = [
|
||||
"A door appears in your bedroom wall overnight. It wasn't there when you went to sleep.",
|
||||
"You find a letter in your pocket written in your own handwriting — dated next week.",
|
||||
"The last dragon in the world lands in your garden. It is very small and very scared.",
|
||||
"Your shadow starts leaving notes under your pillow.",
|
||||
"Every story you write comes true — but always with one small, wrong detail.",
|
||||
"A train arrives at your station that isn't on any timetable. The conductor says you have been expected.",
|
||||
"You discover you have one magical power: you can always find things that are lost. Not everything that is lost wants to be found.",
|
||||
"The stars rearrange themselves every night. Last night you finally worked out what language they're writing in.",
|
||||
"A bookshop appears on your street that wasn't there yesterday. Inside are books about your life — including things that haven't happened yet.",
|
||||
"Everyone in your town wakes up speaking a different language. Everyone except you.",
|
||||
"The person chosen to save the world is seventy-three years old, very tired, and was just about to have a cup of tea.",
|
||||
"You are the only one who can hear the sea talking. It is furious about something.",
|
||||
]
|
||||
let queue = []
|
||||
const shuffle = arr => [...arr].sort(() => Math.random() - 0.5)
|
||||
return {
|
||||
next() {
|
||||
if (!queue.length) queue = shuffle(list)
|
||||
return { prompt: queue.pop(), source: 'local', model: null }
|
||||
},
|
||||
}
|
||||
})()
|
||||
|
||||
export default function EditorPage() {
|
||||
const { id } = useParams()
|
||||
const navigate = useNavigate()
|
||||
@ -33,7 +60,6 @@ export default function EditorPage() {
|
||||
const [promptText, setPromptText] = useState(null)
|
||||
const [promptSource, setPromptSource] = useState(null) // 'ollama' | 'local'
|
||||
const [promptModel, setPromptModel] = useState(null)
|
||||
const [promptLoading, setPromptLoading] = useState(false)
|
||||
const [fontSize, setFontSize] = useState(
|
||||
() => Math.max(MIN_FONT, Math.min(MAX_FONT, parseInt(localStorage.getItem('sw-fontsize')) || 17))
|
||||
)
|
||||
@ -43,9 +69,10 @@ export default function EditorPage() {
|
||||
const latestContent = useRef({})
|
||||
const prevWordCount = useRef(0)
|
||||
const coverRef = useRef()
|
||||
// Holds the in-flight prompt request so the click can await it rather than
|
||||
// starting a brand-new fetch (which would have to wait all over again).
|
||||
const pendingPrompt = useRef(null)
|
||||
// Prompt queue — up to 5 AI prompts pre-fetched and ready to go.
|
||||
// pendingFetches tracks in-flight requests so we know how many more to start.
|
||||
const promptQueue = useRef([])
|
||||
const pendingFetches = useRef([])
|
||||
const editorRef = useRef()
|
||||
const saveRef = useRef(null)
|
||||
|
||||
@ -62,9 +89,8 @@ export default function EditorPage() {
|
||||
.catch(() => setLoadError(true))
|
||||
}, [id])
|
||||
|
||||
// Kick off a prompt fetch in the background as soon as the page loads so
|
||||
// Ollama has the full response time before the user ever clicks the button.
|
||||
useEffect(() => { kickPromptFetch() }, []) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
// Start filling the prompt queue as soon as the page loads.
|
||||
useEffect(() => { refillPrompts() }, []) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('sw-fontsize', fontSize)
|
||||
@ -167,32 +193,28 @@ export default function EditorPage() {
|
||||
await api.updateStory(id, { cover_image: null })
|
||||
}
|
||||
|
||||
// Returns the in-flight prompt Promise, or starts a fresh one.
|
||||
// Calling this twice before the first resolves hands back the same Promise.
|
||||
function kickPromptFetch() {
|
||||
if (!pendingPrompt.current) {
|
||||
pendingPrompt.current = api.getPrompt()
|
||||
// Keep up to 5 AI prompts pre-fetched. Each request is tracked individually
|
||||
// so we always know exactly how many are in-flight vs. ready.
|
||||
function refillPrompts() {
|
||||
const needed = 5 - promptQueue.current.length - pendingFetches.current.length
|
||||
for (let i = 0; i < needed; i++) {
|
||||
const p = api.getPrompt()
|
||||
.then(data => { promptQueue.current.push(data) })
|
||||
.catch(() => {}) // server always returns something; this is a safety net
|
||||
.finally(() => {
|
||||
pendingFetches.current = pendingFetches.current.filter(x => x !== p)
|
||||
})
|
||||
pendingFetches.current = [...pendingFetches.current, p]
|
||||
}
|
||||
return pendingPrompt.current
|
||||
}
|
||||
|
||||
async function fetchPrompt() {
|
||||
setPromptLoading(true)
|
||||
setPromptText(null)
|
||||
const promise = kickPromptFetch()
|
||||
try {
|
||||
const data = await promise
|
||||
pendingPrompt.current = null // clear so the next kick starts fresh
|
||||
setPromptText(data.prompt)
|
||||
setPromptSource(data.source ?? null)
|
||||
setPromptModel(data.model ?? null)
|
||||
kickPromptFetch() // pre-load the next one immediately
|
||||
} catch {
|
||||
pendingPrompt.current = null
|
||||
toast('Could not fetch a prompt', 'error')
|
||||
} finally {
|
||||
setPromptLoading(false)
|
||||
}
|
||||
function fetchPrompt() {
|
||||
// Use an AI prompt if one is ready, otherwise fall back to a built-in instantly
|
||||
const item = promptQueue.current.shift() ?? LOCAL_PROMPTS.next()
|
||||
setPromptText(item.prompt)
|
||||
setPromptSource(item.source ?? 'local')
|
||||
setPromptModel(item.model ?? null)
|
||||
refillPrompts() // top the queue back up to 5
|
||||
}
|
||||
|
||||
function insertPrompt() {
|
||||
@ -251,19 +273,20 @@ export default function EditorPage() {
|
||||
</div>
|
||||
<div className="topbar-right">
|
||||
<div className="prompt-wrap">
|
||||
<button className="btn btn-ghost" onClick={fetchPrompt} disabled={promptLoading} title="Get a writing prompt">
|
||||
{promptLoading ? '…' : <>💡 <span>Prompt</span></>}
|
||||
<button className="btn btn-ghost" onClick={fetchPrompt} title="Get a writing prompt">
|
||||
💡 <span>Prompt</span>
|
||||
</button>
|
||||
{promptText && (
|
||||
<div className="prompt-popover">
|
||||
<p className="prompt-text">{promptText}</p>
|
||||
{promptSource && (
|
||||
<div className={`prompt-source prompt-source--${promptSource}`}>
|
||||
{promptSource === 'ollama'
|
||||
? <>🤖 AI · {promptModel || 'ollama'}</>
|
||||
: <>📚 Built-in prompt</>}
|
||||
</div>
|
||||
<span
|
||||
className={`prompt-source-icon prompt-source--${promptSource}`}
|
||||
title={promptSource === 'ollama' ? `AI · ${promptModel || 'ollama'}` : 'Built-in prompt'}
|
||||
>
|
||||
{promptSource === 'ollama' ? '🤖' : '📚'}
|
||||
</span>
|
||||
)}
|
||||
<p className="prompt-text">{promptText}</p>
|
||||
<div className="prompt-actions">
|
||||
<button className="btn btn-ghost" onClick={fetchPrompt}>↻ Another</button>
|
||||
<button className="btn btn-primary" onClick={insertPrompt}>Insert</button>
|
||||
|
||||
@ -1488,18 +1488,14 @@ button { cursor: pointer; font-family: inherit; }
|
||||
margin-bottom: 0.875rem;
|
||||
}
|
||||
|
||||
.prompt-source {
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 0.05em;
|
||||
font-family: var(--font-head);
|
||||
margin-bottom: 0.6rem;
|
||||
display: inline-block;
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: 99px;
|
||||
border: 1px solid currentColor;
|
||||
.prompt-source-icon {
|
||||
position: absolute;
|
||||
top: 0.6rem;
|
||||
right: 0.7rem;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1;
|
||||
opacity: 0.55;
|
||||
}
|
||||
.prompt-source--ollama { color: var(--accent-hi); }
|
||||
.prompt-source--local { color: var(--text-muted); }
|
||||
|
||||
.prompt-actions {
|
||||
display: flex;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user