✨ daily prompts
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 = ''
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.'
|
||||
|
||||
112
src/renderer/components/Dashboard/PromptHero.tsx
Normal file
112
src/renderer/components/Dashboard/PromptHero.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
1
src/renderer/types/global.d.ts
vendored
1
src/renderer/types/global.d.ts
vendored
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user