daily prompts

This commit is contained in:
2026-06-08 19:50:49 +10:00
parent 92e4761c35
commit df8cd67c90
7 changed files with 257 additions and 1 deletions

View File

@@ -95,6 +95,24 @@ Be candid. If the fit is poor, say so plainly.`
return `You are an expert flash fiction editor. ${wordConstraint}${collectionSection}${marketSection}${storyBlock}\n\n${modeInstructions[payload.mode]}`
}
export async function streamPrompt(onChunk: (chunk: string) => void): Promise<void> {
const client = getClient()
const stream = client.messages.stream({
model: 'claude-haiku-4-5-20251001',
max_tokens: 120,
messages: [{
role: 'user',
content: 'Generate a single flash fiction writing prompt in one sentence (under 25 words). Be specific and evocative — give a concrete situation, image, or constraint. No preamble, no label, just the prompt itself.'
}]
})
for await (const chunk of stream) {
if (chunk.type === 'content_block_delta' && chunk.delta.type === 'text_delta') {
onChunk(chunk.delta.text)
}
}
await stream.finalMessage()
}
export async function streamMessage(
payload: AIPayload,
onChunk: (chunk: string) => void

View File

@@ -8,7 +8,7 @@ import {
saveRevision, listRevisions, loadRevision
} from './fileSystem'
import type { Market, Submission, StoryMeta } from './fileSystem'
import { streamMessage, resetClient } from './aiService'
import { streamMessage, streamPrompt, resetClient } from './aiService'
import type { AIPayload } from './aiService'
import { readGlobalConfig, writeGlobalConfig } from './globalConfig'
import type { GlobalConfig } from './globalConfig'
@@ -89,6 +89,29 @@ export function registerIpcHandlers(): void {
})
// ── AI streaming ──────────────────────────────────────────────────────────────
ipcMain.handle('ai:generatePrompt', async (event) => {
try {
let pending = ''
let flushTimer: ReturnType<typeof setTimeout> | null = null
const flush = (): void => {
if (flushTimer) { clearTimeout(flushTimer); flushTimer = null }
if (pending && !event.sender.isDestroyed()) {
event.sender.send('ai:promptChunk', pending)
pending = ''
}
}
await streamPrompt((chunk: string) => {
pending += chunk
if (!flushTimer) flushTimer = setTimeout(flush, 30)
})
flush()
if (!event.sender.isDestroyed()) event.sender.send('ai:promptDone')
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
if (!event.sender.isDestroyed()) event.sender.send('ai:promptError', message)
}
})
ipcMain.handle('ai:streamMessage', async (event, payload: AIPayload) => {
try {
let pending = ''

View File

@@ -44,6 +44,27 @@ contextBridge.exposeInMainWorld('api', {
pickFolder: (): Promise<string | null> => ipcRenderer.invoke('config:pickFolder'),
// AI
generatePrompt: (onChunk: (chunk: string) => void): Promise<void> => {
return new Promise((resolve, reject) => {
const chunkHandler = (_: Electron.IpcRendererEvent, chunk: string): void => onChunk(chunk)
const doneHandler = (): void => {
ipcRenderer.removeListener('ai:promptChunk', chunkHandler)
ipcRenderer.removeListener('ai:promptDone', doneHandler)
ipcRenderer.removeListener('ai:promptError', errorHandler)
resolve()
}
const errorHandler = (_: Electron.IpcRendererEvent, message: string): void => {
ipcRenderer.removeListener('ai:promptChunk', chunkHandler)
ipcRenderer.removeListener('ai:promptDone', doneHandler)
ipcRenderer.removeListener('ai:promptError', errorHandler)
reject(new Error(message))
}
ipcRenderer.on('ai:promptChunk', chunkHandler)
ipcRenderer.on('ai:promptDone', doneHandler)
ipcRenderer.on('ai:promptError', errorHandler)
ipcRenderer.invoke('ai:generatePrompt').catch(reject)
})
},
streamAIMessage: (payload: AIPayload, onChunk: (chunk: string) => void): Promise<void> => {
return new Promise((resolve, reject) => {
const chunkHandler = (_: Electron.IpcRendererEvent, chunk: string): void => onChunk(chunk)

View File

@@ -1,4 +1,5 @@
import { useBorgesStore } from '../../store/borgesStore'
import { PromptHero } from './PromptHero'
function daysSince(iso: string): number {
return Math.floor((Date.now() - new Date(iso).getTime()) / 86_400_000)
@@ -42,6 +43,7 @@ export function Dashboard(): JSX.Element {
return (
<div className="dashboard">
<PromptHero />
<div className="dashboard-greeting">
{stories.length === 0
? 'Welcome to Borges. Create your first story to get started.'

View File

@@ -0,0 +1,112 @@
import { useEffect, useRef, useState } from 'react'
import { useBorgesStore } from '../../store/borgesStore'
const CACHE_KEY = 'borges:dailyPrompt'
interface CachedPrompt {
date: string
text: string
}
function todayISO(): string {
return new Date().toISOString().slice(0, 10)
}
export function PromptHero(): JSX.Element {
const { stories, setStories, setActiveStory, isDirty, activeStoryPath, activeStoryContent, markSaved } = useBorgesStore()
const [prompt, setPrompt] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const abortRef = useRef(false)
const fetchPrompt = async (): Promise<void> => {
abortRef.current = false
setLoading(true)
setError(null)
setPrompt('')
try {
await window.api.generatePrompt((chunk) => {
if (!abortRef.current) setPrompt((p) => p + chunk)
})
setLoading(false)
} catch (err) {
if (!abortRef.current) {
setError(err instanceof Error ? err.message : 'Failed to generate prompt.')
setLoading(false)
}
}
}
useEffect(() => {
const cached = localStorage.getItem(CACHE_KEY)
if (cached) {
try {
const parsed: CachedPrompt = JSON.parse(cached)
if (parsed.date === todayISO() && parsed.text) {
setPrompt(parsed.text)
return
}
} catch { /* invalid cache */ }
}
fetchPrompt()
return () => { abortRef.current = true }
}, [])
// Persist completed prompt to localStorage
useEffect(() => {
if (!loading && prompt && !error) {
localStorage.setItem(CACHE_KEY, JSON.stringify({ date: todayISO(), text: prompt }))
}
}, [loading, prompt, error])
const handleRegenerate = (): void => {
localStorage.removeItem(CACHE_KEY)
fetchPrompt()
}
const handleWrite = async (): Promise<void> => {
if (isDirty && activeStoryPath) {
await window.api.writeStory(activeStoryPath, activeStoryContent)
await window.api.saveRevision(activeStoryPath, activeStoryContent)
markSaved()
}
const name = `Story ${stories.length + 1}`
const created = await window.api.createStory(name)
const refreshed = await window.api.listStories()
setStories(refreshed)
const story = refreshed.find((s) => s.path === created.path)
if (story) {
const initial = `> ${prompt}\n\n`
await window.api.writeStory(story.path, initial)
setActiveStory(story.path, story.id, initial)
}
}
return (
<div className="prompt-hero">
<div className="prompt-hero-label">Today's prompt</div>
<div className={`prompt-hero-text${loading ? ' prompt-hero-text--loading' : ''}`}>
{error
? <span className="prompt-hero-error">{error}</span>
: prompt || <span className="prompt-hero-placeholder"> </span>
}
</div>
<div className="prompt-hero-actions">
<button
className="prompt-hero-btn prompt-hero-btn--primary"
onClick={handleWrite}
disabled={loading || !prompt || !!error}
>
Write this
</button>
<button
className="prompt-hero-btn"
onClick={handleRegenerate}
disabled={loading}
>
{loading ? 'Generating' : 'New prompt'}
</button>
</div>
</div>
)
}

View File

@@ -377,6 +377,85 @@ textarea { resize: vertical; }
.dashboard-row-flag { font-size: 11px; color: var(--warn); flex-shrink: 0; }
.dashboard-empty { font-size: 13px; color: var(--text3); padding: 8px 0; }
/* ── Prompt hero ──────────────────────────────────────────────────────────── */
.prompt-hero {
background: var(--bg2);
border: 1px solid var(--border);
border-radius: 10px;
padding: 28px 32px 24px;
margin-bottom: 32px;
}
.prompt-hero-label {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text3);
margin-bottom: 14px;
}
.prompt-hero-text {
font-size: 18px;
font-weight: 300;
line-height: 1.55;
color: var(--text);
min-height: 28px;
margin-bottom: 22px;
font-style: italic;
}
.prompt-hero-text--loading {
opacity: 0.7;
}
.prompt-hero-placeholder {
display: inline-block;
width: 120px;
height: 18px;
background: var(--bg3);
border-radius: 3px;
animation: prompt-pulse 1.2s ease-in-out infinite;
}
@keyframes prompt-pulse {
0%, 100% { opacity: 0.5; }
50% { opacity: 1; }
}
.prompt-hero-error {
font-size: 13px;
font-style: normal;
color: var(--danger);
}
.prompt-hero-actions {
display: flex;
gap: 10px;
}
.prompt-hero-btn {
padding: 6px 16px;
border-radius: 5px;
font-size: 13px;
border: 1px solid var(--border);
background: var(--bg3);
color: var(--text2);
cursor: pointer;
transition: background 0.12s, color 0.12s;
}
.prompt-hero-btn:hover:not(:disabled) {
background: var(--bg);
color: var(--text);
}
.prompt-hero-btn:disabled {
opacity: 0.45;
cursor: default;
}
.prompt-hero-btn--primary {
background: var(--accent);
color: var(--bg);
border-color: var(--accent);
font-weight: 500;
}
.prompt-hero-btn--primary:hover:not(:disabled) {
background: var(--accent2);
border-color: var(--accent2);
color: var(--bg);
}
/* ── Submission panel ─────────────────────────────────────────────────────── */
.sub-panel {
grid-area: subpanel;

View File

@@ -56,6 +56,7 @@ declare global {
readConfig(): Promise<GlobalConfig>
writeConfig(updates: Partial<GlobalConfig>): Promise<void>
pickFolder(): Promise<string | null>
generatePrompt(onChunk: (chunk: string) => void): Promise<void>
streamAIMessage(payload: AIPayload, onChunk: (chunk: string) => void): Promise<void>
showEditorContextMenu(): Promise<void>
showStoryContextMenu(storyId: string): Promise<string | null>