🎉 initial commit
This commit is contained in:
123
src/main/aiService.ts
Normal file
123
src/main/aiService.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import Anthropic from '@anthropic-ai/sdk'
|
||||
import { getApiKey } from './globalConfig'
|
||||
import type { Market } from './fileSystem'
|
||||
|
||||
export type AnalysisMode = 'compression' | 'ending' | 'tone' | 'market_fit' | 'chat'
|
||||
|
||||
export interface AIPayload {
|
||||
mode: AnalysisMode
|
||||
storyContent: string
|
||||
storyId: string
|
||||
wordCountTarget?: number
|
||||
targetMarket?: Market
|
||||
collectionContext?: string
|
||||
useCollectionContext: boolean
|
||||
useMarketBrief: boolean
|
||||
conversationHistory: { role: 'user' | 'assistant'; content: string }[]
|
||||
userMessage: string
|
||||
}
|
||||
|
||||
let _client: Anthropic | null = null
|
||||
|
||||
export function resetClient(): void {
|
||||
_client = null
|
||||
}
|
||||
|
||||
function getClient(): Anthropic {
|
||||
if (!_client) {
|
||||
const apiKey = getApiKey()
|
||||
if (!apiKey || apiKey === 'your-api-key-here') {
|
||||
throw new Error('API key not set. Open Borges → Preferences to configure it.')
|
||||
}
|
||||
_client = new Anthropic({ apiKey })
|
||||
}
|
||||
return _client
|
||||
}
|
||||
|
||||
function buildSystemPrompt(payload: AIPayload): string {
|
||||
const wordConstraint = payload.wordCountTarget
|
||||
? `The target word count is ${payload.wordCountTarget} words.`
|
||||
: ''
|
||||
|
||||
const collectionSection =
|
||||
payload.useCollectionContext && payload.collectionContext
|
||||
? `\n\n=== COLLECTION CONTEXT ===\n${payload.collectionContext}\n=== END COLLECTION CONTEXT ===`
|
||||
: ''
|
||||
|
||||
const marketSection =
|
||||
payload.useMarketBrief && payload.targetMarket
|
||||
? `\n\n=== MARKET BRIEF: ${payload.targetMarket.name} ===\nWord count range: ${payload.targetMarket.wordCountMin ?? 0}–${payload.targetMarket.wordCountMax}\nSimultaneous subs allowed: ${payload.targetMarket.simultaneousSubs}\nGenres: ${payload.targetMarket.genres.join(', ')}\n${payload.targetMarket.notes ? `Notes: ${payload.targetMarket.notes}` : ''}\n=== END MARKET BRIEF ===`
|
||||
: ''
|
||||
|
||||
const storyBlock = `\n\n=== STORY ===\n${payload.storyContent}\n=== END STORY ===`
|
||||
|
||||
const modeInstructions: Record<AnalysisMode, string> = {
|
||||
chat: 'You are a literary editor helping with flash fiction. Answer questions about the story — craft, compression, character, market fit, or anything else the writer asks. Be specific and cite exact passages.',
|
||||
|
||||
compression: `You are a flash fiction editor specializing in compression. Identify every sentence, clause, or phrase carrying redundant or low-yield content. Flag passive constructions, hedging adverbs, and over-explained beats.
|
||||
|
||||
For each issue, use this EXACT format:
|
||||
ISSUE: Compression
|
||||
PASSAGE: "[exact verbatim text from the story]"
|
||||
PROBLEM: [one sentence explaining what is bloated]
|
||||
SUGGESTION: "[rewritten tighter version]"
|
||||
|
||||
Be rigorous — flash fiction tolerates no fat. List every instance, then give a brief summary.`,
|
||||
|
||||
ending: `You are a flash fiction editor evaluating the story's ending. Look at the final paragraph (or final sentence for very short pieces).
|
||||
|
||||
Evaluate:
|
||||
- Is the weight right? Does it carry the emotional/thematic load of everything before it?
|
||||
- Is it earned? Does the story build toward this moment?
|
||||
- Does it close, open, or pivot — and is that the right choice for this story?
|
||||
|
||||
Write a prose critique of 150–300 words. No annotation format — this is a direct assessment. Be honest about what isn't working and concrete about what would.`,
|
||||
|
||||
tone: `You are a flash fiction editor mapping tonal register. First, identify the dominant tone of the piece (e.g., dread, irony, tenderness, menace, melancholy). Then find every sentence that drifts out of that tone.
|
||||
|
||||
For each outlier, use this EXACT format:
|
||||
ISSUE: Tone drift
|
||||
PASSAGE: "[exact verbatim text from the story]"
|
||||
PROBLEM: [one sentence: how does this sentence break the tonal register?]
|
||||
SUGGESTION: "[rewritten version that holds the dominant tone]"
|
||||
|
||||
List all outliers, then name the dominant tone in a single closing sentence.`,
|
||||
|
||||
market_fit: `You are a literary submissions editor. Evaluate how well this flash fiction piece fits the target market described in the Market Brief above.
|
||||
|
||||
Provide:
|
||||
1. A brief qualitative assessment (2–3 sentences): what aspects of the piece align with this market's aesthetic, and what works against it?
|
||||
2. Two or three specific, actionable suggestions for bringing the piece closer to what this market publishes.
|
||||
|
||||
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 streamMessage(
|
||||
payload: AIPayload,
|
||||
onChunk: (chunk: string) => void
|
||||
): Promise<void> {
|
||||
const client = getClient()
|
||||
|
||||
const messages: Anthropic.MessageParam[] = [
|
||||
...payload.conversationHistory.slice(-10),
|
||||
{ role: 'user', content: payload.userMessage }
|
||||
]
|
||||
|
||||
const stream = client.messages.stream({
|
||||
model: 'claude-sonnet-4-6',
|
||||
max_tokens: 4096,
|
||||
system: buildSystemPrompt(payload),
|
||||
messages
|
||||
})
|
||||
|
||||
for await (const chunk of stream) {
|
||||
if (chunk.type === 'content_block_delta' && chunk.delta.type === 'text_delta') {
|
||||
onChunk(chunk.delta.text)
|
||||
}
|
||||
}
|
||||
|
||||
await stream.finalMessage()
|
||||
}
|
||||
321
src/main/fileSystem.ts
Normal file
321
src/main/fileSystem.ts
Normal file
@@ -0,0 +1,321 @@
|
||||
import { readdir, readFile, writeFile, mkdir, unlink, rename as fsRename, rm } from 'fs/promises'
|
||||
import { join, basename } from 'path'
|
||||
import { getCollectionRoot } from './globalConfig'
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface StoryMeta {
|
||||
title?: string
|
||||
wordCountTarget?: number
|
||||
tags?: string[]
|
||||
notes?: string
|
||||
}
|
||||
|
||||
export interface Market {
|
||||
id: string
|
||||
name: string
|
||||
url?: string
|
||||
wordCountMin?: number
|
||||
wordCountMax: number
|
||||
simultaneousSubs: boolean
|
||||
responseTimeWeeks?: number
|
||||
genres: string[]
|
||||
notes?: string
|
||||
active: boolean
|
||||
}
|
||||
|
||||
export interface Submission {
|
||||
id: string
|
||||
storyId: string
|
||||
marketId: string
|
||||
submittedAt: string
|
||||
status: 'pending' | 'withdrawn' | 'rejected' | 'accepted' | 'pending-revision'
|
||||
statusUpdatedAt: string
|
||||
notes?: string
|
||||
simultaneous: boolean
|
||||
}
|
||||
|
||||
export interface CollectionConfig {
|
||||
stories: Record<string, StoryMeta>
|
||||
collectionContext?: string
|
||||
}
|
||||
|
||||
export interface RevisionMeta {
|
||||
id: string
|
||||
timestamp: number
|
||||
wordCount: number
|
||||
}
|
||||
|
||||
// ─── Path helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
const borgesDir = (): string => join(getCollectionRoot(), '.borges')
|
||||
const configFile = (): string => join(borgesDir(), 'config.json')
|
||||
const orderFile = (): string => join(borgesDir(), 'order.json')
|
||||
const sessionFile = (): string => join(borgesDir(), 'session.json')
|
||||
const marketsFile = (): string => join(borgesDir(), 'markets.json')
|
||||
const submissionsFile = (): string => join(borgesDir(), 'submissions.json')
|
||||
const revisionsDir = (): string => join(borgesDir(), 'revisions')
|
||||
|
||||
const MAX_REVISIONS = 50
|
||||
|
||||
function assertInCollection(filePath: string): void {
|
||||
const root = getCollectionRoot()
|
||||
const resolved = filePath.startsWith('/') ? filePath : join(root, filePath)
|
||||
if (!resolved.startsWith(root)) throw new Error('Access denied: path outside collection')
|
||||
}
|
||||
|
||||
// ─── Collection config ────────────────────────────────────────────────────────
|
||||
|
||||
async function readConfig(): Promise<CollectionConfig> {
|
||||
try {
|
||||
return JSON.parse(await readFile(configFile(), 'utf-8'))
|
||||
} catch {
|
||||
return { stories: {} }
|
||||
}
|
||||
}
|
||||
|
||||
async function writeConfig(cfg: CollectionConfig): Promise<void> {
|
||||
await mkdir(borgesDir(), { recursive: true })
|
||||
await writeFile(configFile(), JSON.stringify(cfg, null, 2), 'utf-8')
|
||||
}
|
||||
|
||||
export async function getStoryMeta(storyId: string): Promise<StoryMeta> {
|
||||
const cfg = await readConfig()
|
||||
return cfg.stories[storyId] ?? {}
|
||||
}
|
||||
|
||||
export async function setStoryMeta(storyId: string, meta: StoryMeta): Promise<void> {
|
||||
const cfg = await readConfig()
|
||||
cfg.stories[storyId] = { ...cfg.stories[storyId], ...meta }
|
||||
await writeConfig(cfg)
|
||||
}
|
||||
|
||||
export async function getCollectionConfig(): Promise<CollectionConfig> {
|
||||
return readConfig()
|
||||
}
|
||||
|
||||
export async function setCollectionContext(context: string): Promise<void> {
|
||||
const cfg = await readConfig()
|
||||
cfg.collectionContext = context
|
||||
await writeConfig(cfg)
|
||||
}
|
||||
|
||||
// ─── Story listing ────────────────────────────────────────────────────────────
|
||||
|
||||
export interface StoryFile {
|
||||
id: string
|
||||
path: string
|
||||
wordCount: number
|
||||
meta: StoryMeta
|
||||
}
|
||||
|
||||
function countWords(text: string): number {
|
||||
return text.trim() === '' ? 0 : text.trim().split(/\s+/).length
|
||||
}
|
||||
|
||||
async function readOrderList(): Promise<string[]> {
|
||||
try {
|
||||
return JSON.parse(await readFile(orderFile(), 'utf-8'))
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveOrderList(order: string[]): Promise<void> {
|
||||
await mkdir(borgesDir(), { recursive: true })
|
||||
await writeFile(orderFile(), JSON.stringify(order, null, 2), 'utf-8')
|
||||
}
|
||||
|
||||
export async function listStories(): Promise<StoryFile[]> {
|
||||
await mkdir(getCollectionRoot(), { recursive: true })
|
||||
const [entries, order, cfg] = await Promise.all([
|
||||
readdir(getCollectionRoot(), { withFileTypes: true }),
|
||||
readOrderList(),
|
||||
readConfig()
|
||||
])
|
||||
|
||||
const mdFiles = entries.filter(
|
||||
(e) => e.isFile() && e.name.endsWith('.md') && !e.name.startsWith('.')
|
||||
)
|
||||
|
||||
const stories: StoryFile[] = await Promise.all(
|
||||
mdFiles.map(async (e) => {
|
||||
const id = e.name.replace(/\.md$/, '')
|
||||
const path = join(getCollectionRoot(), e.name)
|
||||
const content = await readFile(path, 'utf-8').catch(() => '')
|
||||
return { id, path, wordCount: countWords(content), meta: cfg.stories[id] ?? {} }
|
||||
})
|
||||
)
|
||||
|
||||
const map = new Map(stories.map((s) => [s.id, s]))
|
||||
const ordered = order.filter((id) => map.has(id)).map((id) => map.get(id)!)
|
||||
const rest = stories.filter((s) => !order.includes(s.id)).sort((a, b) => a.id.localeCompare(b.id))
|
||||
return [...ordered, ...rest]
|
||||
}
|
||||
|
||||
// ─── Story CRUD ───────────────────────────────────────────────────────────────
|
||||
|
||||
export async function readStory(filePath: string): Promise<string> {
|
||||
assertInCollection(filePath)
|
||||
return readFile(filePath, 'utf-8')
|
||||
}
|
||||
|
||||
export async function writeStory(filePath: string, content: string): Promise<void> {
|
||||
assertInCollection(filePath)
|
||||
await writeFile(filePath, content, 'utf-8')
|
||||
}
|
||||
|
||||
export async function createStory(name: string): Promise<{ id: string; path: string }> {
|
||||
const id = name
|
||||
const path = join(getCollectionRoot(), `${name}.md`)
|
||||
assertInCollection(path)
|
||||
await writeFile(path, '', 'utf-8')
|
||||
return { id, path }
|
||||
}
|
||||
|
||||
export async function renameStory(oldPath: string, newName: string): Promise<string> {
|
||||
assertInCollection(oldPath)
|
||||
const newPath = join(getCollectionRoot(), `${newName}.md`)
|
||||
assertInCollection(newPath)
|
||||
await fsRename(oldPath, newPath)
|
||||
// Update meta key
|
||||
const cfg = await readConfig()
|
||||
const oldId = basename(oldPath, '.md')
|
||||
if (cfg.stories[oldId]) {
|
||||
cfg.stories[newName] = cfg.stories[oldId]
|
||||
delete cfg.stories[oldId]
|
||||
await writeConfig(cfg)
|
||||
}
|
||||
return newPath
|
||||
}
|
||||
|
||||
export async function deleteStory(filePath: string): Promise<void> {
|
||||
assertInCollection(filePath)
|
||||
await rm(filePath, { force: true })
|
||||
const id = basename(filePath, '.md')
|
||||
const cfg = await readConfig()
|
||||
delete cfg.stories[id]
|
||||
await writeConfig(cfg)
|
||||
}
|
||||
|
||||
// ─── Session ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function readSession(): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
return JSON.parse(await readFile(sessionFile(), 'utf-8'))
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeSession(data: Record<string, unknown>): Promise<void> {
|
||||
await mkdir(borgesDir(), { recursive: true })
|
||||
await writeFile(sessionFile(), JSON.stringify(data), 'utf-8')
|
||||
}
|
||||
|
||||
// ─── Markets ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function listMarkets(): Promise<Market[]> {
|
||||
try {
|
||||
return JSON.parse(await readFile(marketsFile(), 'utf-8'))
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveMarkets(markets: Market[]): Promise<void> {
|
||||
await mkdir(borgesDir(), { recursive: true })
|
||||
await writeFile(marketsFile(), JSON.stringify(markets, null, 2), 'utf-8')
|
||||
}
|
||||
|
||||
export async function upsertMarket(market: Market): Promise<void> {
|
||||
const markets = await listMarkets()
|
||||
const idx = markets.findIndex((m) => m.id === market.id)
|
||||
if (idx === -1) markets.push(market)
|
||||
else markets[idx] = market
|
||||
await saveMarkets(markets)
|
||||
}
|
||||
|
||||
export async function deleteMarket(id: string): Promise<void> {
|
||||
const markets = await listMarkets()
|
||||
await saveMarkets(markets.filter((m) => m.id !== id))
|
||||
}
|
||||
|
||||
// ─── Submissions ──────────────────────────────────────────────────────────────
|
||||
|
||||
export async function listSubmissions(): Promise<Submission[]> {
|
||||
try {
|
||||
return JSON.parse(await readFile(submissionsFile(), 'utf-8'))
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveSubmissions(subs: Submission[]): Promise<void> {
|
||||
await mkdir(borgesDir(), { recursive: true })
|
||||
await writeFile(submissionsFile(), JSON.stringify(subs, null, 2), 'utf-8')
|
||||
}
|
||||
|
||||
export async function addSubmission(sub: Submission): Promise<void> {
|
||||
const subs = await listSubmissions()
|
||||
subs.push(sub)
|
||||
await saveSubmissions(subs)
|
||||
}
|
||||
|
||||
export async function updateSubmission(id: string, updates: Partial<Submission>): Promise<void> {
|
||||
const subs = await listSubmissions()
|
||||
const idx = subs.findIndex((s) => s.id === id)
|
||||
if (idx === -1) throw new Error('Submission not found')
|
||||
subs[idx] = { ...subs[idx], ...updates }
|
||||
await saveSubmissions(subs)
|
||||
}
|
||||
|
||||
// ─── Revisions ────────────────────────────────────────────────────────────────
|
||||
|
||||
function storySlug(filePath: string): string {
|
||||
return basename(filePath, '.md').replace(/[^a-zA-Z0-9_-]/g, '_')
|
||||
}
|
||||
|
||||
function shortId(): string {
|
||||
return Math.random().toString(36).slice(2, 7)
|
||||
}
|
||||
|
||||
export async function saveRevision(filePath: string, content: string): Promise<void> {
|
||||
assertInCollection(filePath)
|
||||
const slug = storySlug(filePath)
|
||||
const dir = join(revisionsDir(), slug)
|
||||
await mkdir(dir, { recursive: true })
|
||||
const timestamp = Date.now()
|
||||
const id = `${timestamp}_${shortId()}`
|
||||
await writeFile(join(dir, `${id}.json`), JSON.stringify({ id, timestamp, wordCount: countWords(content), content }), 'utf-8')
|
||||
const entries = (await readdir(dir)).filter((e) => e.endsWith('.json')).sort()
|
||||
if (entries.length > MAX_REVISIONS) {
|
||||
await Promise.all(entries.slice(0, entries.length - MAX_REVISIONS).map((f) => unlink(join(dir, f))))
|
||||
}
|
||||
}
|
||||
|
||||
export async function listRevisions(filePath: string): Promise<RevisionMeta[]> {
|
||||
assertInCollection(filePath)
|
||||
const slug = storySlug(filePath)
|
||||
const dir = join(revisionsDir(), slug)
|
||||
try {
|
||||
const entries = (await readdir(dir)).filter((e) => e.endsWith('.json')).sort().reverse()
|
||||
return Promise.all(
|
||||
entries.map(async (f) => {
|
||||
const raw = JSON.parse(await readFile(join(dir, f), 'utf-8'))
|
||||
return { id: raw.id, timestamp: raw.timestamp, wordCount: raw.wordCount }
|
||||
})
|
||||
)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadRevision(filePath: string, revisionId: string): Promise<string> {
|
||||
assertInCollection(filePath)
|
||||
if (!/^[\w-]+$/.test(revisionId)) throw new Error('Invalid revision ID')
|
||||
const slug = storySlug(filePath)
|
||||
const revPath = join(revisionsDir(), slug, `${revisionId}.json`)
|
||||
const raw = JSON.parse(await readFile(revPath, 'utf-8'))
|
||||
return raw.content
|
||||
}
|
||||
38
src/main/globalConfig.ts
Normal file
38
src/main/globalConfig.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { join } from 'path'
|
||||
import { homedir } from 'os'
|
||||
import { readFileSync, writeFileSync, mkdirSync } from 'fs'
|
||||
|
||||
export interface GlobalConfig {
|
||||
apiKey?: string
|
||||
collectionPath?: string
|
||||
fontSize?: number
|
||||
theme?: 'dark' | 'light'
|
||||
defaultWordCountTarget?: number
|
||||
}
|
||||
|
||||
const CONFIG_DIR = join(homedir(), '.borges')
|
||||
const CONFIG_FILE = join(CONFIG_DIR, 'config.json')
|
||||
|
||||
let _config: GlobalConfig = {}
|
||||
|
||||
try {
|
||||
_config = JSON.parse(readFileSync(CONFIG_FILE, 'utf-8'))
|
||||
} catch {
|
||||
// first run
|
||||
}
|
||||
|
||||
export const getCollectionRoot = (): string =>
|
||||
_config.collectionPath ?? join(homedir(), 'Documents', 'borges-collection')
|
||||
|
||||
export const getApiKey = (): string | undefined =>
|
||||
_config.apiKey ?? process.env.ANTHROPIC_API_KEY
|
||||
|
||||
export function readGlobalConfig(): GlobalConfig {
|
||||
return { ..._config }
|
||||
}
|
||||
|
||||
export function writeGlobalConfig(updates: Partial<GlobalConfig>): void {
|
||||
_config = { ..._config, ...updates }
|
||||
mkdirSync(CONFIG_DIR, { recursive: true })
|
||||
writeFileSync(CONFIG_FILE, JSON.stringify(_config, null, 2), 'utf-8')
|
||||
}
|
||||
149
src/main/index.ts
Normal file
149
src/main/index.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import { app, BrowserWindow, shell, nativeImage, Menu, dialog } from 'electron'
|
||||
import { join } from 'path'
|
||||
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
|
||||
import { registerIpcHandlers } from './ipcHandlers'
|
||||
import { getCollectionRoot } from './globalConfig'
|
||||
|
||||
app.setName('Borges')
|
||||
|
||||
function send(win: BrowserWindow, action: string): void {
|
||||
if (!win.isDestroyed()) win.webContents.send('menu:action', action)
|
||||
}
|
||||
|
||||
function buildAppMenu(win: BrowserWindow): void {
|
||||
const isMac = process.platform === 'darwin'
|
||||
|
||||
const template: Electron.MenuItemConstructorOptions[] = [
|
||||
...(isMac ? ([{
|
||||
label: 'Borges',
|
||||
submenu: [
|
||||
{
|
||||
label: 'About Borges',
|
||||
click: () => dialog.showMessageBox(win, { type: 'info', title: 'Borges', message: 'Borges', detail: `Version ${app.getVersion()}\n\nFlash fiction writing, analysis, and submission tracking.` })
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{ label: 'Preferences…', accelerator: 'CmdOrCtrl+,', click: () => send(win, 'openSettings') },
|
||||
{ type: 'separator' },
|
||||
{ role: 'services' },
|
||||
{ type: 'separator' },
|
||||
{ role: 'hide' },
|
||||
{ role: 'hideOthers' },
|
||||
{ role: 'unhide' },
|
||||
{ type: 'separator' },
|
||||
{ role: 'quit' }
|
||||
]
|
||||
}] as Electron.MenuItemConstructorOptions[]) : []),
|
||||
|
||||
{
|
||||
label: 'File',
|
||||
submenu: [
|
||||
{ label: 'New Story', accelerator: 'CmdOrCtrl+N', click: () => send(win, 'newStory') },
|
||||
{ type: 'separator' },
|
||||
{ label: 'Save', accelerator: 'CmdOrCtrl+S', click: () => send(win, 'save') },
|
||||
{ type: 'separator' },
|
||||
{ label: 'Open Collection Folder in Finder', click: () => shell.openPath(getCollectionRoot()) },
|
||||
{ type: 'separator' },
|
||||
...(!isMac ? ([
|
||||
{ label: 'Preferences…', accelerator: 'CmdOrCtrl+,', click: () => send(win, 'openSettings') },
|
||||
{ type: 'separator' }
|
||||
] as Electron.MenuItemConstructorOptions[]) : []),
|
||||
isMac ? { role: 'close' } : { role: 'quit' }
|
||||
]
|
||||
},
|
||||
|
||||
{
|
||||
label: 'Edit',
|
||||
submenu: [
|
||||
{ role: 'undo' }, { role: 'redo' }, { type: 'separator' },
|
||||
{ role: 'cut' }, { role: 'copy' }, { role: 'paste' },
|
||||
{ type: 'separator' }, { role: 'selectAll' }
|
||||
]
|
||||
},
|
||||
|
||||
{
|
||||
label: 'View',
|
||||
submenu: [
|
||||
{ label: 'Focus Mode', accelerator: 'CmdOrCtrl+Shift+G', click: () => send(win, 'toggleFocusMode') },
|
||||
{ type: 'separator' },
|
||||
{ label: 'Toggle Story List', accelerator: 'CmdOrCtrl+Shift+1', click: () => send(win, 'toggleSidebar') },
|
||||
{ label: 'Toggle Submission Panel', accelerator: 'CmdOrCtrl+Shift+2', click: () => send(win, 'toggleSubmissionPanel') },
|
||||
{ label: 'Toggle AI Chat', accelerator: 'CmdOrCtrl+Shift+3', click: () => send(win, 'toggleChat') },
|
||||
{ label: 'Toggle Revision History', accelerator: 'CmdOrCtrl+Shift+R', click: () => send(win, 'toggleRevisions') },
|
||||
{ type: 'separator' },
|
||||
{ label: 'Toggle Dark Mode', click: () => send(win, 'toggleTheme') },
|
||||
{ type: 'separator' },
|
||||
{ label: 'Increase Font Size', accelerator: 'CmdOrCtrl+=', click: () => send(win, 'fontIncrease') },
|
||||
{ label: 'Decrease Font Size', accelerator: 'CmdOrCtrl+-', click: () => send(win, 'fontDecrease') },
|
||||
{ label: 'Reset Font Size', accelerator: 'CmdOrCtrl+0', click: () => send(win, 'fontReset') }
|
||||
]
|
||||
},
|
||||
|
||||
{
|
||||
label: 'Window',
|
||||
submenu: [
|
||||
{ role: 'minimize' }, { role: 'zoom' },
|
||||
...(isMac ? ([{ type: 'separator' }, { role: 'front' }] as Electron.MenuItemConstructorOptions[]) : [])
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
Menu.setApplicationMenu(Menu.buildFromTemplate(template))
|
||||
}
|
||||
|
||||
function createWindow(): BrowserWindow {
|
||||
const icon = nativeImage.createFromPath(join(__dirname, '../../resources/icon.png'))
|
||||
const mainWindow = new BrowserWindow({
|
||||
width: 1440,
|
||||
height: 900,
|
||||
minWidth: 900,
|
||||
minHeight: 600,
|
||||
title: 'Borges',
|
||||
titleBarStyle: 'hiddenInset',
|
||||
icon,
|
||||
show: false,
|
||||
webPreferences: {
|
||||
preload: join(__dirname, '../preload/index.js'),
|
||||
sandbox: false,
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false
|
||||
}
|
||||
})
|
||||
|
||||
mainWindow.on('ready-to-show', () => mainWindow.show())
|
||||
mainWindow.on('enter-full-screen', () => mainWindow.setWindowButtonVisibility(false))
|
||||
mainWindow.on('leave-full-screen', () => mainWindow.setWindowButtonVisibility(true))
|
||||
mainWindow.webContents.setWindowOpenHandler((details) => {
|
||||
shell.openExternal(details.url)
|
||||
return { action: 'deny' }
|
||||
})
|
||||
|
||||
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
|
||||
mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL'])
|
||||
} else {
|
||||
mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
|
||||
}
|
||||
|
||||
return mainWindow
|
||||
}
|
||||
|
||||
app.whenReady().then(() => {
|
||||
electronApp.setAppUserModelId('com.borges.editor')
|
||||
|
||||
if (process.platform === 'darwin') {
|
||||
app.dock.setIcon(nativeImage.createFromPath(join(__dirname, '../../resources/icon.png')))
|
||||
}
|
||||
|
||||
app.on('browser-window-created', (_, window) => optimizer.watchWindowShortcuts(window))
|
||||
|
||||
registerIpcHandlers()
|
||||
const mainWindow = createWindow()
|
||||
buildAppMenu(mainWindow)
|
||||
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) createWindow()
|
||||
})
|
||||
})
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
if (process.platform !== 'darwin') app.quit()
|
||||
})
|
||||
89
src/main/ipcHandlers.ts
Normal file
89
src/main/ipcHandlers.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { ipcMain, dialog, BrowserWindow } from 'electron'
|
||||
import {
|
||||
listStories, readStory, writeStory, createStory, renameStory, deleteStory,
|
||||
getStoryMeta, setStoryMeta, getCollectionConfig, setCollectionContext,
|
||||
saveOrderList, readSession, writeSession,
|
||||
listMarkets, upsertMarket, deleteMarket,
|
||||
listSubmissions, addSubmission, updateSubmission,
|
||||
saveRevision, listRevisions, loadRevision
|
||||
} from './fileSystem'
|
||||
import type { Market, Submission, StoryMeta } from './fileSystem'
|
||||
import { streamMessage, resetClient } from './aiService'
|
||||
import type { AIPayload } from './aiService'
|
||||
import { readGlobalConfig, writeGlobalConfig } from './globalConfig'
|
||||
import type { GlobalConfig } from './globalConfig'
|
||||
|
||||
export function registerIpcHandlers(): void {
|
||||
// ── Stories ──────────────────────────────────────────────────────────────────
|
||||
ipcMain.handle('stories:list', async () => listStories())
|
||||
ipcMain.handle('stories:read', async (_e, path: string) => readStory(path))
|
||||
ipcMain.handle('stories:write', async (_e, path: string, content: string) => writeStory(path, content))
|
||||
ipcMain.handle('stories:create', async (_e, name: string) => createStory(name))
|
||||
ipcMain.handle('stories:rename', async (_e, oldPath: string, newName: string) => renameStory(oldPath, newName))
|
||||
ipcMain.handle('stories:delete', async (_e, path: string) => deleteStory(path))
|
||||
ipcMain.handle('stories:getMeta', async (_e, storyId: string) => getStoryMeta(storyId))
|
||||
ipcMain.handle('stories:setMeta', async (_e, storyId: string, meta: StoryMeta) => setStoryMeta(storyId, meta))
|
||||
ipcMain.handle('stories:saveOrder', async (_e, order: string[]) => saveOrderList(order))
|
||||
|
||||
// ── Collection config ─────────────────────────────────────────────────────────
|
||||
ipcMain.handle('collection:getConfig', async () => getCollectionConfig())
|
||||
ipcMain.handle('collection:setContext', async (_e, context: string) => setCollectionContext(context))
|
||||
|
||||
// ── Session ──────────────────────────────────────────────────────────────────
|
||||
ipcMain.handle('session:read', async () => readSession())
|
||||
ipcMain.handle('session:write', async (_e, data: Record<string, unknown>) => writeSession(data))
|
||||
|
||||
// ── Markets ──────────────────────────────────────────────────────────────────
|
||||
ipcMain.handle('markets:list', async () => listMarkets())
|
||||
ipcMain.handle('markets:upsert', async (_e, market: Market) => upsertMarket(market))
|
||||
ipcMain.handle('markets:delete', async (_e, id: string) => deleteMarket(id))
|
||||
|
||||
// ── Submissions ──────────────────────────────────────────────────────────────
|
||||
ipcMain.handle('submissions:list', async () => listSubmissions())
|
||||
ipcMain.handle('submissions:add', async (_e, sub: Submission) => addSubmission(sub))
|
||||
ipcMain.handle('submissions:update', async (_e, id: string, updates: Partial<Submission>) => updateSubmission(id, updates))
|
||||
|
||||
// ── Revisions ─────────────────────────────────────────────────────────────────
|
||||
ipcMain.handle('revisions:save', async (_e, path: string, content: string) => saveRevision(path, content))
|
||||
ipcMain.handle('revisions:list', async (_e, path: string) => listRevisions(path))
|
||||
ipcMain.handle('revisions:load', async (_e, path: string, id: string) => loadRevision(path, id))
|
||||
|
||||
// ── Config ────────────────────────────────────────────────────────────────────
|
||||
ipcMain.handle('config:read', async () => readGlobalConfig())
|
||||
ipcMain.handle('config:write', async (_e, updates: Partial<GlobalConfig>) => {
|
||||
writeGlobalConfig(updates)
|
||||
if (updates.apiKey !== undefined) resetClient()
|
||||
})
|
||||
ipcMain.handle('config:pickFolder', async (event): Promise<string | null> => {
|
||||
const win = BrowserWindow.fromWebContents(event.sender)
|
||||
const result = await dialog.showOpenDialog(win!, { properties: ['openDirectory'] })
|
||||
return result.canceled ? null : result.filePaths[0]
|
||||
})
|
||||
|
||||
// ── AI streaming ──────────────────────────────────────────────────────────────
|
||||
ipcMain.handle('ai:streamMessage', async (event, payload: AIPayload) => {
|
||||
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:chunk', pending)
|
||||
pending = ''
|
||||
}
|
||||
}
|
||||
|
||||
await streamMessage(payload, (chunk: string) => {
|
||||
pending += chunk
|
||||
if (!flushTimer) flushTimer = setTimeout(flush, 30)
|
||||
})
|
||||
|
||||
flush()
|
||||
if (!event.sender.isDestroyed()) event.sender.send('ai:done')
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
if (!event.sender.isDestroyed()) event.sender.send('ai:error', message)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user