🎉 initial commit

This commit is contained in:
2026-02-20 14:17:34 +10:00
commit e21d1c7b58
33 changed files with 8207 additions and 0 deletions

101
src/main/aiService.ts Normal file
View File

@@ -0,0 +1,101 @@
import Anthropic from '@anthropic-ai/sdk'
import type { AIPayload } from '../renderer/types/editor'
let _client: Anthropic | null = null
function getClient(): Anthropic {
if (!_client) {
const apiKey = process.env.ANTHROPIC_API_KEY
if (!apiKey || apiKey === 'your-api-key-here') {
throw new Error(
'ANTHROPIC_API_KEY is not set. Add it to app/.env.local'
)
}
_client = new Anthropic({ apiKey })
}
return _client
}
function buildSystemPrompt(payload: AIPayload): string {
const chapterName =
payload.documentPath.split('/').pop()?.replace(/\.md$/, '') ?? 'Unknown chapter'
const chapterContext = `You are a literary editor assistant helping with a gothic/historical fiction novel set in the Basque Country. The current chapter is: "${chapterName}".
Key characters: Esti, Marko, Garbi, Irati, Cardinal Nikolai, Amaya, Izotz, Sua, Señor Jiménez, the Genboa family.
The chapter text is provided below. When identifying specific passages, quote the EXACT text from the document so it can be located and highlighted in the editor.
--- CHAPTER ---
${payload.documentContent}
--- END CHAPTER ---`
const modeInstructions: Record<AIPayload['mode'], string> = {
chat: 'Answer questions about the chapter, characters, plot, or craft. Be specific and cite passages where relevant.',
passive_voice: `Identify ALL instances of passive voice in this chapter.
For each instance, respond in this exact format:
PASSIVE: "[exact quoted sentence]"
WHY: [brief explanation]
SUGGESTION: "[rewritten in active voice]"
List every instance you find, then give a brief overall summary.`,
consistency: `Check this chapter carefully for consistency issues:
- Character names spelled or used inconsistently
- Timeline contradictions or impossibilities
- Repeated words or phrases appearing too close together (within a page)
- Setting details that seem contradictory
- Character behaviour inconsistent with their established personality
For each issue found:
ISSUE: [type of issue]
PASSAGE: "[exact quoted text]"
PROBLEM: [explanation]
SUGGESTION: [how to fix it]`,
style: `Analyze the writing style and provide specific improvement suggestions:
- Pacing: identify slow passages or rushed moments
- Sentence variety: flag runs of similar length or structure
- Show don't tell: identify passages that tell emotion/state rather than showing it
- Gothic atmosphere: passages where the atmospheric tone is inconsistent
- Dialogue: any dialogue that feels stilted or unnatural
For each suggestion:
ISSUE: [type: Pacing / Sentence Variety / Show-Don't-Tell / Atmosphere / Dialogue]
PASSAGE: "[exact quoted text]"
PROBLEM: [specific explanation]
SUGGESTION: [concrete rewrite or approach]`
}
return `${chapterContext}\n\n${modeInstructions[payload.mode]}`
}
export async function streamMessage(
payload: AIPayload,
onChunk: (chunk: string) => void
): Promise<void> {
const client = getClient()
const messages = [
...payload.conversationHistory.slice(-10),
{ role: 'user' as const, content: payload.userMessage }
]
const stream = client.messages.stream({
model: 'claude-sonnet-4-5',
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)
}
}
}

71
src/main/fileSystem.ts Normal file
View File

@@ -0,0 +1,71 @@
import { readdir, readFile, writeFile } from 'fs/promises'
import { join } from 'path'
import type { FileNode } from '../renderer/types/editor'
const DRAFT_ROOT =
process.env.DRAFT_PATH ?? '/Users/pori/WebstormProjects/hohoff/draft'
const PART_ORDER = ['Prologue', 'Content Warning', 'Part I', 'Part II', 'Part III', 'Part IV', 'Epilogue', 'The first time']
function sortDraftNodes(a: FileNode, b: FileNode): number {
const aIdx = PART_ORDER.findIndex((o) => a.name.startsWith(o))
const bIdx = PART_ORDER.findIndex((o) => b.name.startsWith(o))
if (aIdx !== -1 && bIdx !== -1) return aIdx - bIdx
if (aIdx !== -1) return -1
if (bIdx !== -1) return 1
return a.name.localeCompare(b.name)
}
export async function listDraftFiles(): Promise<FileNode[]> {
const entries = await readdir(DRAFT_ROOT, { withFileTypes: true })
const nodes: FileNode[] = []
for (const entry of entries) {
if (entry.name.startsWith('.')) continue
const fullPath = join(DRAFT_ROOT, entry.name)
if (entry.isDirectory()) {
const children = await readdir(fullPath, { withFileTypes: true })
const childNodes: FileNode[] = children
.filter((c) => c.name.endsWith('.md') && !c.name.startsWith('.'))
.map((c) => ({
name: c.name.replace(/\.md$/, ''),
path: join(fullPath, c.name),
type: 'file' as const
}))
.sort((a, b) => a.name.localeCompare(b.name))
nodes.push({
name: entry.name,
path: fullPath,
type: 'directory',
children: childNodes
})
} else if (entry.name.endsWith('.md')) {
nodes.push({
name: entry.name.replace(/\.md$/, ''),
path: fullPath,
type: 'file'
})
}
}
return nodes.sort(sortDraftNodes)
}
function assertInDraftRoot(filePath: string): void {
const resolved = filePath.startsWith('/') ? filePath : join(DRAFT_ROOT, filePath)
if (!resolved.startsWith(DRAFT_ROOT)) {
throw new Error('Access denied: path outside draft directory')
}
}
export async function readMarkdownFile(filePath: string): Promise<string> {
assertInDraftRoot(filePath)
return await readFile(filePath, 'utf-8')
}
export async function writeMarkdownFile(filePath: string, content: string): Promise<void> {
assertInDraftRoot(filePath)
await writeFile(filePath, content, 'utf-8')
}

65
src/main/index.ts Normal file
View File

@@ -0,0 +1,65 @@
import { app, BrowserWindow, shell } from 'electron'
import { join, resolve } from 'path'
import { config } from 'dotenv'
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
import { registerIpcHandlers } from './ipcHandlers'
// Load .env then .env.local so values in .env.local override .env
config({ path: resolve(process.cwd(), '.env') })
config({ path: resolve(process.cwd(), '.env.local'), override: true })
function createWindow(): void {
const mainWindow = new BrowserWindow({
width: 1440,
height: 900,
minWidth: 900,
minHeight: 600,
title: 'Hohoff Editor',
show: false,
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
sandbox: false,
contextIsolation: true,
nodeIntegration: false
}
})
mainWindow.on('ready-to-show', () => {
mainWindow.show()
})
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'])
mainWindow.webContents.openDevTools({ mode: 'detach' })
} else {
mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
}
}
app.whenReady().then(() => {
electronApp.setAppUserModelId('com.hohoff.editor')
app.on('browser-window-created', (_, window) => {
optimizer.watchWindowShortcuts(window)
})
registerIpcHandlers()
createWindow()
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow()
}
})
})
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit()
}
})

36
src/main/ipcHandlers.ts Normal file
View File

@@ -0,0 +1,36 @@
import { ipcMain } from 'electron'
import { listDraftFiles, readMarkdownFile, writeMarkdownFile } from './fileSystem'
import { streamMessage } from './aiService'
import type { AIPayload } from '../renderer/types/editor'
export function registerIpcHandlers(): void {
ipcMain.handle('fs:listFiles', async () => {
return await listDraftFiles()
})
ipcMain.handle('fs:readFile', async (_event, filePath: string) => {
return await readMarkdownFile(filePath)
})
ipcMain.handle('fs:writeFile', async (_event, filePath: string, content: string) => {
await writeMarkdownFile(filePath, content)
})
ipcMain.handle('ai:streamMessage', async (event, payload: AIPayload) => {
try {
await streamMessage(payload, (chunk: string) => {
if (!event.sender.isDestroyed()) {
event.sender.send('ai:chunk', chunk)
}
})
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)
}
}
})
}