✨ 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]}`
|
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(
|
export async function streamMessage(
|
||||||
payload: AIPayload,
|
payload: AIPayload,
|
||||||
onChunk: (chunk: string) => void
|
onChunk: (chunk: string) => void
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
saveRevision, listRevisions, loadRevision
|
saveRevision, listRevisions, loadRevision
|
||||||
} from './fileSystem'
|
} from './fileSystem'
|
||||||
import type { Market, Submission, StoryMeta } 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 type { AIPayload } from './aiService'
|
||||||
import { readGlobalConfig, writeGlobalConfig } from './globalConfig'
|
import { readGlobalConfig, writeGlobalConfig } from './globalConfig'
|
||||||
import type { GlobalConfig } from './globalConfig'
|
import type { GlobalConfig } from './globalConfig'
|
||||||
@@ -89,6 +89,29 @@ export function registerIpcHandlers(): void {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// ── AI streaming ──────────────────────────────────────────────────────────────
|
// ── 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) => {
|
ipcMain.handle('ai:streamMessage', async (event, payload: AIPayload) => {
|
||||||
try {
|
try {
|
||||||
let pending = ''
|
let pending = ''
|
||||||
|
|||||||
@@ -44,6 +44,27 @@ contextBridge.exposeInMainWorld('api', {
|
|||||||
pickFolder: (): Promise<string | null> => ipcRenderer.invoke('config:pickFolder'),
|
pickFolder: (): Promise<string | null> => ipcRenderer.invoke('config:pickFolder'),
|
||||||
|
|
||||||
// AI
|
// 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> => {
|
streamAIMessage: (payload: AIPayload, onChunk: (chunk: string) => void): Promise<void> => {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const chunkHandler = (_: Electron.IpcRendererEvent, chunk: string): void => onChunk(chunk)
|
const chunkHandler = (_: Electron.IpcRendererEvent, chunk: string): void => onChunk(chunk)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useBorgesStore } from '../../store/borgesStore'
|
import { useBorgesStore } from '../../store/borgesStore'
|
||||||
|
import { PromptHero } from './PromptHero'
|
||||||
|
|
||||||
function daysSince(iso: string): number {
|
function daysSince(iso: string): number {
|
||||||
return Math.floor((Date.now() - new Date(iso).getTime()) / 86_400_000)
|
return Math.floor((Date.now() - new Date(iso).getTime()) / 86_400_000)
|
||||||
@@ -42,6 +43,7 @@ export function Dashboard(): JSX.Element {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="dashboard">
|
<div className="dashboard">
|
||||||
|
<PromptHero />
|
||||||
<div className="dashboard-greeting">
|
<div className="dashboard-greeting">
|
||||||
{stories.length === 0
|
{stories.length === 0
|
||||||
? 'Welcome to Borges. Create your first story to get started.'
|
? '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-row-flag { font-size: 11px; color: var(--warn); flex-shrink: 0; }
|
||||||
.dashboard-empty { font-size: 13px; color: var(--text3); padding: 8px 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 ─────────────────────────────────────────────────────── */
|
/* ── Submission panel ─────────────────────────────────────────────────────── */
|
||||||
.sub-panel {
|
.sub-panel {
|
||||||
grid-area: subpanel;
|
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>
|
readConfig(): Promise<GlobalConfig>
|
||||||
writeConfig(updates: Partial<GlobalConfig>): Promise<void>
|
writeConfig(updates: Partial<GlobalConfig>): Promise<void>
|
||||||
pickFolder(): Promise<string | null>
|
pickFolder(): Promise<string | null>
|
||||||
|
generatePrompt(onChunk: (chunk: string) => void): Promise<void>
|
||||||
streamAIMessage(payload: AIPayload, onChunk: (chunk: string) => void): Promise<void>
|
streamAIMessage(payload: AIPayload, onChunk: (chunk: string) => void): Promise<void>
|
||||||
showEditorContextMenu(): Promise<void>
|
showEditorContextMenu(): Promise<void>
|
||||||
showStoryContextMenu(storyId: string): Promise<string | null>
|
showStoryContextMenu(storyId: string): Promise<string | null>
|
||||||
|
|||||||
Reference in New Issue
Block a user