🎉 initial commit
This commit is contained in:
101
src/main/aiService.ts
Normal file
101
src/main/aiService.ts
Normal 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
71
src/main/fileSystem.ts
Normal 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
65
src/main/index.ts
Normal 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
36
src/main/ipcHandlers.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user