diff --git a/src/main/aiService.ts b/src/main/aiService.ts index f81bbd9..65c19fe 100644 --- a/src/main/aiService.ts +++ b/src/main/aiService.ts @@ -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 { + 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 diff --git a/src/main/ipcHandlers.ts b/src/main/ipcHandlers.ts index b1c1679..d3928f7 100644 --- a/src/main/ipcHandlers.ts +++ b/src/main/ipcHandlers.ts @@ -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 | 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 = '' diff --git a/src/preload/index.ts b/src/preload/index.ts index e71df22..306962d 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -44,6 +44,27 @@ contextBridge.exposeInMainWorld('api', { pickFolder: (): Promise => ipcRenderer.invoke('config:pickFolder'), // AI + generatePrompt: (onChunk: (chunk: string) => void): Promise => { + 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 => { return new Promise((resolve, reject) => { const chunkHandler = (_: Electron.IpcRendererEvent, chunk: string): void => onChunk(chunk) diff --git a/src/renderer/components/Dashboard/Dashboard.tsx b/src/renderer/components/Dashboard/Dashboard.tsx index 4e26b84..f770969 100644 --- a/src/renderer/components/Dashboard/Dashboard.tsx +++ b/src/renderer/components/Dashboard/Dashboard.tsx @@ -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 (
+
{stories.length === 0 ? 'Welcome to Borges. Create your first story to get started.' diff --git a/src/renderer/components/Dashboard/PromptHero.tsx b/src/renderer/components/Dashboard/PromptHero.tsx new file mode 100644 index 0000000..fc62959 --- /dev/null +++ b/src/renderer/components/Dashboard/PromptHero.tsx @@ -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(null) + const abortRef = useRef(false) + + const fetchPrompt = async (): Promise => { + 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 => { + 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 ( +
+
Today's prompt
+
+ {error + ? {error} + : prompt || + } +
+
+ + +
+
+ ) +} diff --git a/src/renderer/styles/app.css b/src/renderer/styles/app.css index a074953..41a7fae 100644 --- a/src/renderer/styles/app.css +++ b/src/renderer/styles/app.css @@ -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; diff --git a/src/renderer/types/global.d.ts b/src/renderer/types/global.d.ts index 59255a4..5f89ef8 100644 --- a/src/renderer/types/global.d.ts +++ b/src/renderer/types/global.d.ts @@ -56,6 +56,7 @@ declare global { readConfig(): Promise writeConfig(updates: Partial): Promise pickFolder(): Promise + generatePrompt(onChunk: (chunk: string) => void): Promise streamAIMessage(payload: AIPayload, onChunk: (chunk: string) => void): Promise showEditorContextMenu(): Promise showStoryContextMenu(storyId: string): Promise