🎉 initial commit
This commit is contained in:
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules/
|
||||||
|
out/
|
||||||
|
dist/
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
20
electron.vite.config.ts
Normal file
20
electron.vite.config.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { resolve } from 'path'
|
||||||
|
import { defineConfig, externalizeDepsPlugin } from 'electron-vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
main: {
|
||||||
|
plugins: [externalizeDepsPlugin()]
|
||||||
|
},
|
||||||
|
preload: {
|
||||||
|
plugins: [externalizeDepsPlugin()]
|
||||||
|
},
|
||||||
|
renderer: {
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@renderer': resolve('src/renderer')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
plugins: [react()]
|
||||||
|
}
|
||||||
|
})
|
||||||
6236
package-lock.json
generated
Normal file
6236
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
49
package.json
Normal file
49
package.json
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
{
|
||||||
|
"name": "hohoff-editor",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Novel editor for Hohoff",
|
||||||
|
"main": "out/main/index.js",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "electron-vite dev",
|
||||||
|
"build": "electron-vite build",
|
||||||
|
"preview": "electron-vite preview",
|
||||||
|
"typecheck": "tsc --noEmit -p tsconfig.node.json && tsc --noEmit -p tsconfig.web.json"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@electron-toolkit/tsconfig": "^1.0.1",
|
||||||
|
"@types/node": "^20.0.0",
|
||||||
|
"@types/react": "^18.2.0",
|
||||||
|
"@types/react-dom": "^18.2.0",
|
||||||
|
"@vitejs/plugin-react": "^4.2.0",
|
||||||
|
"electron": "^33.0.0",
|
||||||
|
"electron-builder": "^24.0.0",
|
||||||
|
"electron-vite": "^2.3.0",
|
||||||
|
"typescript": "^5.4.0",
|
||||||
|
"vite": "^5.2.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@anthropic-ai/sdk": "^0.29.0",
|
||||||
|
"@codemirror/commands": "^6.6.0",
|
||||||
|
"@codemirror/lang-markdown": "^6.3.0",
|
||||||
|
"@codemirror/language": "^6.10.0",
|
||||||
|
"@codemirror/search": "^6.5.0",
|
||||||
|
"@codemirror/state": "^6.4.0",
|
||||||
|
"@codemirror/view": "^6.35.0",
|
||||||
|
"@electron-toolkit/preload": "^3.0.0",
|
||||||
|
"@electron-toolkit/utils": "^3.0.0",
|
||||||
|
"codemirror": "^6.0.1",
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0",
|
||||||
|
"zustand": "^4.5.0"
|
||||||
|
},
|
||||||
|
"build": {
|
||||||
|
"appId": "com.hohoff.editor",
|
||||||
|
"productName": "Hohoff Editor",
|
||||||
|
"mac": {
|
||||||
|
"target": "dmg"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"out/**/*"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
47
src/preload/index.ts
Normal file
47
src/preload/index.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { contextBridge, ipcRenderer } from 'electron'
|
||||||
|
import type { FileNode, AIPayload } from '../renderer/types/editor'
|
||||||
|
|
||||||
|
contextBridge.exposeInMainWorld('api', {
|
||||||
|
listFiles: (): Promise<FileNode[]> => ipcRenderer.invoke('fs:listFiles'),
|
||||||
|
|
||||||
|
readFile: (filePath: string): Promise<string> =>
|
||||||
|
ipcRenderer.invoke('fs:readFile', filePath),
|
||||||
|
|
||||||
|
writeFile: (filePath: string, content: string): Promise<void> =>
|
||||||
|
ipcRenderer.invoke('fs:writeFile', filePath, content),
|
||||||
|
|
||||||
|
streamAIMessage: (
|
||||||
|
payload: AIPayload,
|
||||||
|
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:chunk', chunkHandler)
|
||||||
|
ipcRenderer.removeListener('ai:done', doneHandler)
|
||||||
|
ipcRenderer.removeListener('ai:error', errorHandler)
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
const errorHandler = (_: Electron.IpcRendererEvent, message: string): void => {
|
||||||
|
ipcRenderer.removeListener('ai:chunk', chunkHandler)
|
||||||
|
ipcRenderer.removeListener('ai:done', doneHandler)
|
||||||
|
ipcRenderer.removeListener('ai:error', errorHandler)
|
||||||
|
reject(new Error(message))
|
||||||
|
}
|
||||||
|
|
||||||
|
ipcRenderer.on('ai:chunk', chunkHandler)
|
||||||
|
ipcRenderer.on('ai:done', doneHandler)
|
||||||
|
ipcRenderer.on('ai:error', errorHandler)
|
||||||
|
|
||||||
|
ipcRenderer.invoke('ai:streamMessage', payload).catch(reject)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
removeAIListener: (): void => {
|
||||||
|
ipcRenderer.removeAllListeners('ai:chunk')
|
||||||
|
ipcRenderer.removeAllListeners('ai:done')
|
||||||
|
ipcRenderer.removeAllListeners('ai:error')
|
||||||
|
}
|
||||||
|
})
|
||||||
47
src/renderer/App.tsx
Normal file
47
src/renderer/App.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { useEffect } from 'react'
|
||||||
|
import { FileTree } from './components/FileTree/FileTree'
|
||||||
|
import { MarkdownEditor } from './components/Editor/MarkdownEditor'
|
||||||
|
import { ChatPanel } from './components/AIChat/ChatPanel'
|
||||||
|
import { AnalysisToolbar } from './components/Toolbar/AnalysisToolbar'
|
||||||
|
import { useEditorStore } from './store/editorStore'
|
||||||
|
import './styles/app.css'
|
||||||
|
|
||||||
|
export default function App(): JSX.Element {
|
||||||
|
const { setFileTree, activeFilePath, isDirty, markSaved, activeFileContent } =
|
||||||
|
useEditorStore()
|
||||||
|
|
||||||
|
// Load file tree on mount
|
||||||
|
useEffect(() => {
|
||||||
|
window.api.listFiles().then(setFileTree)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Handle Cmd+S / Ctrl+S
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = async (e: KeyboardEvent): Promise<void> => {
|
||||||
|
if ((e.metaKey || e.ctrlKey) && e.key === 's') {
|
||||||
|
e.preventDefault()
|
||||||
|
if (activeFilePath && isDirty) {
|
||||||
|
await window.api.writeFile(activeFilePath, activeFileContent)
|
||||||
|
markSaved()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', handler)
|
||||||
|
return () => window.removeEventListener('keydown', handler)
|
||||||
|
}, [activeFilePath, isDirty, activeFileContent])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="app-layout">
|
||||||
|
<aside className="sidebar">
|
||||||
|
<FileTree />
|
||||||
|
</aside>
|
||||||
|
<main className="editor-area">
|
||||||
|
<AnalysisToolbar />
|
||||||
|
<MarkdownEditor />
|
||||||
|
</main>
|
||||||
|
<aside className="chat-area">
|
||||||
|
<ChatPanel />
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
196
src/renderer/components/AIChat/Chat.css
Normal file
196
src/renderer/components/AIChat/Chat.css
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
.chat-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
background: var(--sidebar-bg);
|
||||||
|
border-left: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 14px 14px 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-muted);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-clear-btn {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 2px 7px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.15s, border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-clear-btn:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-messages {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-placeholder {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 13px;
|
||||||
|
font-family: var(--font-serif);
|
||||||
|
font-style: italic;
|
||||||
|
text-align: center;
|
||||||
|
margin: 24px 0;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-message {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-message-label {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-message-user .chat-message-label {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-message-content {
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.65;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
background: var(--message-bg);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-message-user .chat-message-content {
|
||||||
|
background: var(--user-message-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-streaming-cursor {
|
||||||
|
animation: blink 1s step-end infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes blink {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-typing {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: var(--message-bg);
|
||||||
|
border-radius: 6px;
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-typing span {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--text-muted);
|
||||||
|
animation: bounce 1.2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-typing span:nth-child(2) { animation-delay: 0.2s; }
|
||||||
|
.chat-typing span:nth-child(3) { animation-delay: 0.4s; }
|
||||||
|
|
||||||
|
@keyframes bounce {
|
||||||
|
0%, 80%, 100% { transform: translateY(0); }
|
||||||
|
40% { transform: translateY(-5px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-error {
|
||||||
|
background: rgba(200, 60, 60, 0.15);
|
||||||
|
border: 1px solid rgba(200, 60, 60, 0.4);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #e07070;
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-input-area {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-textarea {
|
||||||
|
flex: 1;
|
||||||
|
background: var(--input-bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
padding: 8px 10px;
|
||||||
|
resize: none;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
min-height: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-textarea:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-textarea::placeholder {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-textarea:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-send-btn {
|
||||||
|
background: var(--accent);
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #1a1208;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-send-btn:disabled {
|
||||||
|
opacity: 0.35;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-send-btn:not(:disabled):hover {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
60
src/renderer/components/AIChat/ChatInput.tsx
Normal file
60
src/renderer/components/AIChat/ChatInput.tsx
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { useState, useRef, type KeyboardEvent } from 'react'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onSend: (text: string) => void
|
||||||
|
disabled: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChatInput({ onSend, disabled }: Props): JSX.Element {
|
||||||
|
const [value, setValue] = useState('')
|
||||||
|
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||||
|
|
||||||
|
const submit = (): void => {
|
||||||
|
const trimmed = value.trim()
|
||||||
|
if (!trimmed || disabled) return
|
||||||
|
onSend(trimmed)
|
||||||
|
setValue('')
|
||||||
|
if (textareaRef.current) {
|
||||||
|
textareaRef.current.style.height = 'auto'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>): void => {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault()
|
||||||
|
submit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleInput = (): void => {
|
||||||
|
const el = textareaRef.current
|
||||||
|
if (el) {
|
||||||
|
el.style.height = 'auto'
|
||||||
|
el.style.height = `${Math.min(el.scrollHeight, 160)}px`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="chat-input-area">
|
||||||
|
<textarea
|
||||||
|
ref={textareaRef}
|
||||||
|
className="chat-textarea"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => setValue(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
onInput={handleInput}
|
||||||
|
placeholder={disabled ? 'Open a chapter first…' : 'Ask about this chapter… (Enter to send)'}
|
||||||
|
disabled={disabled}
|
||||||
|
rows={1}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="chat-send-btn"
|
||||||
|
onClick={submit}
|
||||||
|
disabled={disabled || !value.trim()}
|
||||||
|
aria-label="Send"
|
||||||
|
>
|
||||||
|
↑
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
18
src/renderer/components/AIChat/ChatMessageItem.tsx
Normal file
18
src/renderer/components/AIChat/ChatMessageItem.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import type { ChatMessage } from '../../types/editor'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
message: ChatMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChatMessageItem({ message }: Props): JSX.Element {
|
||||||
|
return (
|
||||||
|
<div className={`chat-message chat-message-${message.role}`}>
|
||||||
|
<div className="chat-message-label">
|
||||||
|
{message.role === 'user' ? 'You' : 'Editor AI'}
|
||||||
|
</div>
|
||||||
|
<div className="chat-message-content">
|
||||||
|
{message.content || <span className="chat-streaming-cursor">▍</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
117
src/renderer/components/AIChat/ChatPanel.tsx
Normal file
117
src/renderer/components/AIChat/ChatPanel.tsx
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import { useRef, useEffect } from 'react'
|
||||||
|
import { useEditorStore } from '../../store/editorStore'
|
||||||
|
import { ChatMessageItem } from './ChatMessageItem'
|
||||||
|
import { ChatInput } from './ChatInput'
|
||||||
|
import { parseAnnotationsFromAIResponse } from '../../utils/annotationParser'
|
||||||
|
import './Chat.css'
|
||||||
|
|
||||||
|
export function ChatPanel(): JSX.Element {
|
||||||
|
const {
|
||||||
|
chatHistory,
|
||||||
|
isAILoading,
|
||||||
|
aiError,
|
||||||
|
activeFileContent,
|
||||||
|
activeFilePath,
|
||||||
|
analysisMode,
|
||||||
|
addUserMessage,
|
||||||
|
startAssistantMessage,
|
||||||
|
appendToLastAssistantMessage,
|
||||||
|
setAILoading,
|
||||||
|
setAIError,
|
||||||
|
setAnnotations,
|
||||||
|
clearChat
|
||||||
|
} = useEditorStore()
|
||||||
|
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
const sendMessage = async (text: string): Promise<void> => {
|
||||||
|
if (!activeFilePath || isAILoading) return
|
||||||
|
|
||||||
|
setAIError(null)
|
||||||
|
addUserMessage(text)
|
||||||
|
startAssistantMessage()
|
||||||
|
setAILoading(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const mode = analysisMode === 'none' ? 'chat' : analysisMode
|
||||||
|
await window.api.streamAIMessage(
|
||||||
|
{
|
||||||
|
mode,
|
||||||
|
documentContent: activeFileContent,
|
||||||
|
documentPath: activeFilePath,
|
||||||
|
conversationHistory: chatHistory
|
||||||
|
.slice(-10)
|
||||||
|
.map((m) => ({ role: m.role, content: m.content })),
|
||||||
|
userMessage: text
|
||||||
|
},
|
||||||
|
(chunk: string) => {
|
||||||
|
appendToLastAssistantMessage(chunk)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// After streaming, parse AI response for annotations
|
||||||
|
const currentHistory = useEditorStore.getState().chatHistory
|
||||||
|
const lastMsg = currentHistory[currentHistory.length - 1]
|
||||||
|
if (lastMsg?.role === 'assistant' && lastMsg.content.length > 0) {
|
||||||
|
const annotations = parseAnnotationsFromAIResponse(lastMsg.content, activeFileContent)
|
||||||
|
if (annotations.length > 0) {
|
||||||
|
setAnnotations(annotations)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : 'An error occurred'
|
||||||
|
setAIError(message)
|
||||||
|
} finally {
|
||||||
|
setAILoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-scroll to bottom when new content arrives
|
||||||
|
useEffect(() => {
|
||||||
|
const el = scrollRef.current
|
||||||
|
if (el) {
|
||||||
|
el.scrollTop = el.scrollHeight
|
||||||
|
}
|
||||||
|
}, [chatHistory])
|
||||||
|
|
||||||
|
const hasFile = Boolean(activeFilePath)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="chat-panel">
|
||||||
|
<div className="chat-header">
|
||||||
|
<span>AI Editor</span>
|
||||||
|
{chatHistory.length > 0 && (
|
||||||
|
<button className="chat-clear-btn" onClick={clearChat} title="Clear conversation">
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="chat-messages" ref={scrollRef}>
|
||||||
|
{!hasFile && (
|
||||||
|
<p className="chat-placeholder">Open a chapter to start a conversation about it.</p>
|
||||||
|
)}
|
||||||
|
{hasFile && chatHistory.length === 0 && (
|
||||||
|
<p className="chat-placeholder">
|
||||||
|
Ask anything about the current chapter — passive voice, plot, character, style...
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{chatHistory.map((msg) => (
|
||||||
|
<ChatMessageItem key={msg.id} message={msg} />
|
||||||
|
))}
|
||||||
|
{isAILoading && chatHistory[chatHistory.length - 1]?.content === '' && (
|
||||||
|
<div className="chat-typing">
|
||||||
|
<span />
|
||||||
|
<span />
|
||||||
|
<span />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{aiError && (
|
||||||
|
<div className="chat-error">{aiError}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ChatInput onSend={sendMessage} disabled={!hasFile || isAILoading} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
38
src/renderer/components/Editor/Editor.css
Normal file
38
src/renderer/components/Editor/Editor.css
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
.editor-container {
|
||||||
|
position: relative;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.codemirror-host {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.codemirror-host .cm-editor {
|
||||||
|
height: 100%;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-empty {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: var(--font-serif);
|
||||||
|
font-size: 15px;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-empty p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-empty-hint {
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
172
src/renderer/components/Editor/MarkdownEditor.tsx
Normal file
172
src/renderer/components/Editor/MarkdownEditor.tsx
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
import { EditorView, Decoration, type DecorationSet } from '@codemirror/view'
|
||||||
|
import { EditorState, StateField, StateEffect, RangeSetBuilder } from '@codemirror/state'
|
||||||
|
import { markdown } from '@codemirror/lang-markdown'
|
||||||
|
import { syntaxHighlighting, defaultHighlightStyle } from '@codemirror/language'
|
||||||
|
import { history, defaultKeymap, historyKeymap } from '@codemirror/commands'
|
||||||
|
import { keymap } from '@codemirror/view'
|
||||||
|
import { useEditorStore } from '../../store/editorStore'
|
||||||
|
import type { TextAnnotation } from '../../types/editor'
|
||||||
|
import './Editor.css'
|
||||||
|
|
||||||
|
// StateEffect to push new annotations into the editor
|
||||||
|
export const setAnnotationsEffect = StateEffect.define<TextAnnotation[]>()
|
||||||
|
|
||||||
|
// StateField tracks the decoration set derived from annotations
|
||||||
|
const annotationField = StateField.define<DecorationSet>({
|
||||||
|
create: () => Decoration.none,
|
||||||
|
update(deco, tr) {
|
||||||
|
deco = deco.map(tr.changes)
|
||||||
|
for (const effect of tr.effects) {
|
||||||
|
if (effect.is(setAnnotationsEffect)) {
|
||||||
|
const builder = new RangeSetBuilder<Decoration>()
|
||||||
|
const sorted = [...effect.value].sort((a, b) => a.from - b.from)
|
||||||
|
for (const ann of sorted) {
|
||||||
|
const docLen = tr.newDoc.length
|
||||||
|
const from = Math.max(0, Math.min(ann.from, docLen))
|
||||||
|
const to = Math.max(from, Math.min(ann.to, docLen))
|
||||||
|
if (from < to) {
|
||||||
|
builder.add(
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
Decoration.mark({
|
||||||
|
class: `annotation annotation-${ann.type}`,
|
||||||
|
attributes: { 'data-id': ann.id, title: ann.message }
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return builder.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return deco
|
||||||
|
},
|
||||||
|
provide: (f) => EditorView.decorations.from(f)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Dark gothic theme for CodeMirror
|
||||||
|
const hohoffTheme = EditorView.theme(
|
||||||
|
{
|
||||||
|
'&': {
|
||||||
|
height: '100%',
|
||||||
|
fontSize: '15px',
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
color: 'var(--text-primary)'
|
||||||
|
},
|
||||||
|
'.cm-scroller': {
|
||||||
|
fontFamily: 'var(--font-serif)',
|
||||||
|
lineHeight: '1.8',
|
||||||
|
overflow: 'auto'
|
||||||
|
},
|
||||||
|
'.cm-content': {
|
||||||
|
padding: '24px 32px',
|
||||||
|
maxWidth: '740px',
|
||||||
|
margin: '0 auto',
|
||||||
|
caretColor: 'var(--accent)'
|
||||||
|
},
|
||||||
|
'.cm-cursor': { borderLeftColor: 'var(--accent)' },
|
||||||
|
'.cm-selectionBackground': { backgroundColor: 'rgba(167,139,95,0.2)' },
|
||||||
|
'&.cm-focused .cm-selectionBackground': { backgroundColor: 'rgba(167,139,95,0.3)' },
|
||||||
|
'.cm-line': { padding: '0' },
|
||||||
|
'.cm-gutters': { display: 'none' },
|
||||||
|
'.cm-activeLine': { backgroundColor: 'transparent' },
|
||||||
|
'.cm-activeLineGutter': { backgroundColor: 'transparent' },
|
||||||
|
|
||||||
|
// Markdown heading styles
|
||||||
|
'.tok-heading': { fontWeight: '700', color: 'var(--heading-color)' },
|
||||||
|
'.tok-heading1': { fontSize: '1.4em' },
|
||||||
|
'.tok-heading2': { fontSize: '1.2em' },
|
||||||
|
'.tok-emphasis': { fontStyle: 'italic' },
|
||||||
|
'.tok-strong': { fontWeight: '700' },
|
||||||
|
|
||||||
|
// Annotation highlight styles
|
||||||
|
'.annotation-passive_voice': {
|
||||||
|
backgroundColor: 'rgba(255, 200, 0, 0.18)',
|
||||||
|
borderBottom: '2px solid rgba(255, 200, 0, 0.7)',
|
||||||
|
borderRadius: '2px'
|
||||||
|
},
|
||||||
|
'.annotation-consistency': {
|
||||||
|
backgroundColor: 'rgba(220, 80, 80, 0.18)',
|
||||||
|
borderBottom: '2px solid rgba(220, 80, 80, 0.7)',
|
||||||
|
borderRadius: '2px'
|
||||||
|
},
|
||||||
|
'.annotation-style': {
|
||||||
|
backgroundColor: 'rgba(80, 160, 255, 0.18)',
|
||||||
|
borderBottom: '2px solid rgba(80, 160, 255, 0.7)',
|
||||||
|
borderRadius: '2px'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ dark: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
export function MarkdownEditor(): JSX.Element {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
|
const viewRef = useRef<EditorView | null>(null)
|
||||||
|
const { activeFilePath, activeFileContent, setContent, annotations } = useEditorStore()
|
||||||
|
|
||||||
|
// Initialize CodeMirror once
|
||||||
|
useEffect(() => {
|
||||||
|
if (!containerRef.current) return
|
||||||
|
|
||||||
|
const view = new EditorView({
|
||||||
|
state: EditorState.create({
|
||||||
|
doc: '',
|
||||||
|
extensions: [
|
||||||
|
history(),
|
||||||
|
keymap.of([...defaultKeymap, ...historyKeymap]),
|
||||||
|
markdown(),
|
||||||
|
syntaxHighlighting(defaultHighlightStyle),
|
||||||
|
annotationField,
|
||||||
|
hohoffTheme,
|
||||||
|
EditorView.lineWrapping,
|
||||||
|
EditorView.updateListener.of((update) => {
|
||||||
|
if (update.docChanged) {
|
||||||
|
setContent(update.state.doc.toString())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
parent: containerRef.current
|
||||||
|
})
|
||||||
|
|
||||||
|
viewRef.current = view
|
||||||
|
return () => {
|
||||||
|
view.destroy()
|
||||||
|
viewRef.current = null
|
||||||
|
}
|
||||||
|
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
// When the active file changes, replace editor content
|
||||||
|
useEffect(() => {
|
||||||
|
const view = viewRef.current
|
||||||
|
if (!view) return
|
||||||
|
const current = view.state.doc.toString()
|
||||||
|
if (current !== activeFileContent) {
|
||||||
|
view.dispatch({
|
||||||
|
changes: { from: 0, to: current.length, insert: activeFileContent }
|
||||||
|
})
|
||||||
|
// Scroll to top on file switch
|
||||||
|
view.dispatch({ selection: { anchor: 0 } })
|
||||||
|
view.scrollDOM.scrollTop = 0
|
||||||
|
}
|
||||||
|
}, [activeFilePath]) // Only sync on file switch
|
||||||
|
|
||||||
|
// Push annotation decorations into CodeMirror
|
||||||
|
useEffect(() => {
|
||||||
|
const view = viewRef.current
|
||||||
|
if (!view) return
|
||||||
|
view.dispatch({ effects: setAnnotationsEffect.of(annotations) })
|
||||||
|
}, [annotations])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="editor-container">
|
||||||
|
{!activeFilePath && (
|
||||||
|
<div className="editor-empty">
|
||||||
|
<p>Select a chapter from the sidebar to begin editing.</p>
|
||||||
|
<p className="editor-empty-hint">Your draft files will never be modified without saving (Cmd+S).</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div ref={containerRef} className="codemirror-host" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
80
src/renderer/components/FileTree/FileTree.css
Normal file
80
src/renderer/components/FileTree/FileTree.css
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
.file-tree {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-tree-header {
|
||||||
|
padding: 14px 12px 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-tree-list {
|
||||||
|
overflow-y: auto;
|
||||||
|
flex: 1;
|
||||||
|
padding: 6px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-dir-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
width: 100%;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
padding: 6px 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
transition: color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-dir-header:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-arrow {
|
||||||
|
font-size: 10px;
|
||||||
|
width: 10px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-file {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: var(--font-serif);
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 5px 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
transition: color 0.15s, background 0.15s;
|
||||||
|
border-radius: 3px;
|
||||||
|
margin: 0 4px;
|
||||||
|
width: calc(100% - 8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-file:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
background: var(--hover-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-file.active {
|
||||||
|
color: var(--accent);
|
||||||
|
background: var(--active-bg);
|
||||||
|
}
|
||||||
18
src/renderer/components/FileTree/FileTree.tsx
Normal file
18
src/renderer/components/FileTree/FileTree.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { useEditorStore } from '../../store/editorStore'
|
||||||
|
import { FileTreeNode } from './FileTreeNode'
|
||||||
|
import './FileTree.css'
|
||||||
|
|
||||||
|
export function FileTree(): JSX.Element {
|
||||||
|
const { fileTree } = useEditorStore()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav className="file-tree">
|
||||||
|
<div className="file-tree-header">HOHOFF</div>
|
||||||
|
<div className="file-tree-list">
|
||||||
|
{fileTree.map((node) => (
|
||||||
|
<FileTreeNode key={node.path} node={node} depth={0} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
)
|
||||||
|
}
|
||||||
51
src/renderer/components/FileTree/FileTreeNode.tsx
Normal file
51
src/renderer/components/FileTree/FileTreeNode.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useEditorStore } from '../../store/editorStore'
|
||||||
|
import type { FileNode } from '../../types/editor'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
node: FileNode
|
||||||
|
depth: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FileTreeNode({ node, depth }: Props): JSX.Element {
|
||||||
|
const [expanded, setExpanded] = useState(true)
|
||||||
|
const { activeFilePath, setActiveFile } = useEditorStore()
|
||||||
|
|
||||||
|
const openFile = async (): Promise<void> => {
|
||||||
|
if (node.type === 'file') {
|
||||||
|
const content = await window.api.readFile(node.path)
|
||||||
|
setActiveFile(node.path, content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.type === 'directory') {
|
||||||
|
return (
|
||||||
|
<div className="tree-dir">
|
||||||
|
<button
|
||||||
|
className="tree-dir-header"
|
||||||
|
style={{ paddingLeft: `${depth * 12 + 8}px` }}
|
||||||
|
onClick={() => setExpanded(!expanded)}
|
||||||
|
>
|
||||||
|
<span className="tree-arrow">{expanded ? '▾' : '▸'}</span>
|
||||||
|
{node.name}
|
||||||
|
</button>
|
||||||
|
{expanded && node.children?.map((child) => (
|
||||||
|
<FileTreeNode key={child.path} node={child} depth={depth + 1} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const isActive = activeFilePath === node.path
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={`tree-file${isActive ? ' active' : ''}`}
|
||||||
|
style={{ paddingLeft: `${depth * 12 + 20}px` }}
|
||||||
|
onClick={openFile}
|
||||||
|
title={node.name}
|
||||||
|
>
|
||||||
|
{node.name}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
146
src/renderer/components/Toolbar/AnalysisToolbar.tsx
Normal file
146
src/renderer/components/Toolbar/AnalysisToolbar.tsx
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import { useEditorStore } from '../../store/editorStore'
|
||||||
|
import { detectPassiveVoice } from '../../utils/passiveVoice'
|
||||||
|
import { parseAnnotationsFromAIResponse } from '../../utils/annotationParser'
|
||||||
|
import './Toolbar.css'
|
||||||
|
|
||||||
|
export function AnalysisToolbar(): JSX.Element {
|
||||||
|
const {
|
||||||
|
activeFilePath,
|
||||||
|
activeFileContent,
|
||||||
|
isDirty,
|
||||||
|
analysisMode,
|
||||||
|
annotations,
|
||||||
|
isAILoading,
|
||||||
|
setAnnotations,
|
||||||
|
clearAnnotations,
|
||||||
|
setAnalysisMode,
|
||||||
|
addUserMessage,
|
||||||
|
startAssistantMessage,
|
||||||
|
appendToLastAssistantMessage,
|
||||||
|
setAILoading,
|
||||||
|
setAIError,
|
||||||
|
chatHistory
|
||||||
|
} = useEditorStore()
|
||||||
|
|
||||||
|
const hasFile = Boolean(activeFilePath)
|
||||||
|
|
||||||
|
const runPassiveVoice = (): void => {
|
||||||
|
if (!hasFile) return
|
||||||
|
const found = detectPassiveVoice(activeFileContent)
|
||||||
|
setAnnotations(found)
|
||||||
|
setAnalysisMode('passive_voice')
|
||||||
|
}
|
||||||
|
|
||||||
|
const runAIAnalysis = async (mode: 'consistency' | 'style'): Promise<void> => {
|
||||||
|
if (!activeFilePath || isAILoading) return
|
||||||
|
|
||||||
|
setAnalysisMode(mode)
|
||||||
|
setAIError(null)
|
||||||
|
|
||||||
|
const prompt =
|
||||||
|
mode === 'consistency'
|
||||||
|
? 'Please check this chapter for consistency issues (character names, timeline, repeated phrases).'
|
||||||
|
: 'Please analyze the style and pacing of this chapter and suggest improvements.'
|
||||||
|
|
||||||
|
addUserMessage(prompt)
|
||||||
|
startAssistantMessage()
|
||||||
|
setAILoading(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await window.api.streamAIMessage(
|
||||||
|
{
|
||||||
|
mode,
|
||||||
|
documentContent: activeFileContent,
|
||||||
|
documentPath: activeFilePath,
|
||||||
|
conversationHistory: chatHistory
|
||||||
|
.slice(-10)
|
||||||
|
.map((m) => ({ role: m.role, content: m.content })),
|
||||||
|
userMessage: prompt
|
||||||
|
},
|
||||||
|
(chunk: string) => {
|
||||||
|
appendToLastAssistantMessage(chunk)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Parse annotations from response
|
||||||
|
const currentHistory = useEditorStore.getState().chatHistory
|
||||||
|
const lastMsg = currentHistory[currentHistory.length - 1]
|
||||||
|
if (lastMsg?.role === 'assistant' && lastMsg.content.length > 0) {
|
||||||
|
const newAnnotations = parseAnnotationsFromAIResponse(lastMsg.content, activeFileContent)
|
||||||
|
if (newAnnotations.length > 0) setAnnotations(newAnnotations)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setAIError(err instanceof Error ? err.message : 'Analysis failed')
|
||||||
|
} finally {
|
||||||
|
setAILoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const passiveCount = annotations.filter((a) => a.type === 'passive_voice').length
|
||||||
|
const otherCount = annotations.filter((a) => a.type !== 'passive_voice').length
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="toolbar">
|
||||||
|
<div className="toolbar-left">
|
||||||
|
<button
|
||||||
|
className={`toolbar-btn${analysisMode === 'passive_voice' ? ' active' : ''}`}
|
||||||
|
onClick={runPassiveVoice}
|
||||||
|
disabled={!hasFile}
|
||||||
|
title="Highlight passive voice sentences instantly (no AI required)"
|
||||||
|
>
|
||||||
|
Passive Voice
|
||||||
|
{analysisMode === 'passive_voice' && passiveCount > 0 && (
|
||||||
|
<span className="toolbar-badge">{passiveCount}</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className={`toolbar-btn${analysisMode === 'consistency' ? ' active' : ''}`}
|
||||||
|
onClick={() => runAIAnalysis('consistency')}
|
||||||
|
disabled={!hasFile || isAILoading}
|
||||||
|
title="Check character names, timeline, and repeated phrases via AI"
|
||||||
|
>
|
||||||
|
{isAILoading && analysisMode === 'consistency' ? 'Checking…' : 'Consistency'}
|
||||||
|
{analysisMode === 'consistency' && otherCount > 0 && (
|
||||||
|
<span className="toolbar-badge">{otherCount}</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className={`toolbar-btn${analysisMode === 'style' ? ' active' : ''}`}
|
||||||
|
onClick={() => runAIAnalysis('style')}
|
||||||
|
disabled={!hasFile || isAILoading}
|
||||||
|
title="Pacing, sentence variety, show-don't-tell feedback via AI"
|
||||||
|
>
|
||||||
|
{isAILoading && analysisMode === 'style' ? 'Analyzing…' : 'Style'}
|
||||||
|
{analysisMode === 'style' && otherCount > 0 && (
|
||||||
|
<span className="toolbar-badge">{otherCount}</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{annotations.length > 0 && (
|
||||||
|
<button
|
||||||
|
className="toolbar-btn toolbar-btn-clear"
|
||||||
|
onClick={clearAnnotations}
|
||||||
|
title="Remove all highlights"
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="toolbar-right">
|
||||||
|
{isDirty && (
|
||||||
|
<span className="toolbar-dirty" title="Unsaved changes — press Cmd+S to save">
|
||||||
|
●
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{activeFilePath && (
|
||||||
|
<span className="toolbar-filename">
|
||||||
|
{activeFilePath.split('/').pop()?.replace(/\.md$/, '')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
97
src/renderer/components/Toolbar/Toolbar.css
Normal file
97
src/renderer/components/Toolbar/Toolbar.css
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 6px 12px;
|
||||||
|
background: var(--toolbar-bg);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 42px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-btn {
|
||||||
|
position: relative;
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 5px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
padding: 4px 11px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.15s, border-color 0.15s, background 0.15s;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-btn:hover:not(:disabled) {
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-btn.active {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
background: rgba(167, 139, 95, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-btn:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-btn-clear {
|
||||||
|
border-color: transparent;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-btn-clear:hover:not(:disabled) {
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-color: var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--accent);
|
||||||
|
color: #1a1208;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0 5px;
|
||||||
|
min-width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
margin-left: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-dirty {
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 14px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-filename {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 11px;
|
||||||
|
font-family: var(--font-serif);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
max-width: 260px;
|
||||||
|
}
|
||||||
12
src/renderer/index.html
Normal file
12
src/renderer/index.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Hohoff Editor</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="./main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
10
src/renderer/main.tsx
Normal file
10
src/renderer/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { StrictMode } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import './styles/global.css'
|
||||||
|
import App from './App'
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>
|
||||||
|
)
|
||||||
119
src/renderer/store/editorStore.ts
Normal file
119
src/renderer/store/editorStore.ts
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import { create } from 'zustand'
|
||||||
|
import type { FileNode, ChatMessage, TextAnnotation, AnalysisMode } from '../types/editor'
|
||||||
|
|
||||||
|
interface EditorState {
|
||||||
|
// File tree
|
||||||
|
fileTree: FileNode[]
|
||||||
|
setFileTree: (tree: FileNode[]) => void
|
||||||
|
|
||||||
|
// Active file
|
||||||
|
activeFilePath: string | null
|
||||||
|
activeFileContent: string
|
||||||
|
isDirty: boolean
|
||||||
|
setActiveFile: (path: string, content: string) => void
|
||||||
|
setContent: (content: string) => void
|
||||||
|
markSaved: () => void
|
||||||
|
|
||||||
|
// Chat - persisted per file path
|
||||||
|
chatHistoryByFile: Record<string, ChatMessage[]>
|
||||||
|
chatHistory: ChatMessage[]
|
||||||
|
isAILoading: boolean
|
||||||
|
aiError: string | null
|
||||||
|
addUserMessage: (text: string) => void
|
||||||
|
startAssistantMessage: () => void
|
||||||
|
appendToLastAssistantMessage: (chunk: string) => void
|
||||||
|
setAILoading: (loading: boolean) => void
|
||||||
|
setAIError: (error: string | null) => void
|
||||||
|
clearChat: () => void
|
||||||
|
|
||||||
|
// Annotations (highlights in editor)
|
||||||
|
annotations: TextAnnotation[]
|
||||||
|
setAnnotations: (annotations: TextAnnotation[]) => void
|
||||||
|
clearAnnotations: () => void
|
||||||
|
|
||||||
|
// Analysis mode
|
||||||
|
analysisMode: AnalysisMode
|
||||||
|
setAnalysisMode: (mode: AnalysisMode) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useEditorStore = create<EditorState>((set, get) => ({
|
||||||
|
fileTree: [],
|
||||||
|
setFileTree: (fileTree) => set({ fileTree }),
|
||||||
|
|
||||||
|
activeFilePath: null,
|
||||||
|
activeFileContent: '',
|
||||||
|
isDirty: false,
|
||||||
|
setActiveFile: (path, content) => {
|
||||||
|
const existing = get().chatHistoryByFile[path] ?? []
|
||||||
|
set({
|
||||||
|
activeFilePath: path,
|
||||||
|
activeFileContent: content,
|
||||||
|
isDirty: false,
|
||||||
|
chatHistory: existing,
|
||||||
|
annotations: [],
|
||||||
|
analysisMode: 'none'
|
||||||
|
})
|
||||||
|
},
|
||||||
|
setContent: (content) => set({ activeFileContent: content, isDirty: true }),
|
||||||
|
markSaved: () => set({ isDirty: false }),
|
||||||
|
|
||||||
|
chatHistoryByFile: {},
|
||||||
|
chatHistory: [],
|
||||||
|
isAILoading: false,
|
||||||
|
aiError: null,
|
||||||
|
|
||||||
|
addUserMessage: (text) => {
|
||||||
|
const msg: ChatMessage = { id: `user-${Date.now()}`, role: 'user', content: text }
|
||||||
|
set((s) => {
|
||||||
|
const history = [...s.chatHistory, msg]
|
||||||
|
const byFile = s.activeFilePath
|
||||||
|
? { ...s.chatHistoryByFile, [s.activeFilePath]: history }
|
||||||
|
: s.chatHistoryByFile
|
||||||
|
return { chatHistory: history, chatHistoryByFile: byFile }
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
startAssistantMessage: () => {
|
||||||
|
const msg: ChatMessage = { id: `asst-${Date.now()}`, role: 'assistant', content: '' }
|
||||||
|
set((s) => {
|
||||||
|
const history = [...s.chatHistory, msg]
|
||||||
|
const byFile = s.activeFilePath
|
||||||
|
? { ...s.chatHistoryByFile, [s.activeFilePath]: history }
|
||||||
|
: s.chatHistoryByFile
|
||||||
|
return { chatHistory: history, chatHistoryByFile: byFile }
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
appendToLastAssistantMessage: (chunk) => {
|
||||||
|
set((s) => {
|
||||||
|
const history = [...s.chatHistory]
|
||||||
|
const last = history[history.length - 1]
|
||||||
|
if (last?.role === 'assistant') {
|
||||||
|
history[history.length - 1] = { ...last, content: last.content + chunk }
|
||||||
|
}
|
||||||
|
const byFile = s.activeFilePath
|
||||||
|
? { ...s.chatHistoryByFile, [s.activeFilePath]: history }
|
||||||
|
: s.chatHistoryByFile
|
||||||
|
return { chatHistory: history, chatHistoryByFile: byFile }
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
setAILoading: (isAILoading) => set({ isAILoading }),
|
||||||
|
setAIError: (aiError) => set({ aiError }),
|
||||||
|
|
||||||
|
clearChat: () => {
|
||||||
|
set((s) => {
|
||||||
|
const byFile = s.activeFilePath
|
||||||
|
? { ...s.chatHistoryByFile, [s.activeFilePath]: [] }
|
||||||
|
: s.chatHistoryByFile
|
||||||
|
return { chatHistory: [], chatHistoryByFile: byFile }
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
annotations: [],
|
||||||
|
setAnnotations: (annotations) => set({ annotations }),
|
||||||
|
clearAnnotations: () => set({ annotations: [], analysisMode: 'none' }),
|
||||||
|
|
||||||
|
analysisMode: 'none',
|
||||||
|
setAnalysisMode: (analysisMode) => set({ analysisMode })
|
||||||
|
}))
|
||||||
31
src/renderer/styles/app.css
Normal file
31
src/renderer/styles/app.css
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
.app-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 220px 1fr 340px;
|
||||||
|
grid-template-rows: 100vh;
|
||||||
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Sidebar ──────────────────────────────────────────────────── */
|
||||||
|
.sidebar {
|
||||||
|
background: var(--sidebar-bg);
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Editor area ──────────────────────────────────────────────── */
|
||||||
|
.editor-area {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--editor-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Chat area ────────────────────────────────────────────────── */
|
||||||
|
.chat-area {
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
68
src/renderer/styles/global.css
Normal file
68
src/renderer/styles/global.css
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
/* ─── Reset ────────────────────────────────────────────────────── */
|
||||||
|
*, *::before, *::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Design tokens ────────────────────────────────────────────── */
|
||||||
|
:root {
|
||||||
|
/* Background layers */
|
||||||
|
--bg-base: #131110;
|
||||||
|
--sidebar-bg: #0f0e0c;
|
||||||
|
--toolbar-bg: #161412;
|
||||||
|
--editor-bg: #131110;
|
||||||
|
--message-bg: #1c1a17;
|
||||||
|
--user-message-bg:#1e1c17;
|
||||||
|
--input-bg: #1c1a17;
|
||||||
|
|
||||||
|
/* Borders */
|
||||||
|
--border: #2a2620;
|
||||||
|
--hover-bg: rgba(167,139,95,0.08);
|
||||||
|
--active-bg: rgba(167,139,95,0.12);
|
||||||
|
|
||||||
|
/* Text */
|
||||||
|
--text-primary: #e8e0d0;
|
||||||
|
--text-secondary: #b5a990;
|
||||||
|
--text-muted: #7a6f5e;
|
||||||
|
|
||||||
|
/* Heading color in editor */
|
||||||
|
--heading-color: #d4b896;
|
||||||
|
|
||||||
|
/* Accent (aged gold) */
|
||||||
|
--accent: #a78b5f;
|
||||||
|
--accent-hover: #c4a870;
|
||||||
|
|
||||||
|
/* Typography */
|
||||||
|
--font-serif: 'Georgia', 'Times New Roman', serif;
|
||||||
|
--font-sans: -apple-system, 'Segoe UI', Helvetica, Arial, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Base ─────────────────────────────────────────────────────── */
|
||||||
|
html, body, #root {
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: var(--bg-base);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Scrollbars ───────────────────────────────────────────────── */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--border);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--text-muted);
|
||||||
|
}
|
||||||
36
src/renderer/types/editor.ts
Normal file
36
src/renderer/types/editor.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
export interface FileNode {
|
||||||
|
name: string
|
||||||
|
path: string
|
||||||
|
type: 'file' | 'directory'
|
||||||
|
children?: FileNode[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatMessage {
|
||||||
|
id: string
|
||||||
|
role: 'user' | 'assistant'
|
||||||
|
content: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AnnotationType = 'passive_voice' | 'consistency' | 'style'
|
||||||
|
|
||||||
|
export interface TextAnnotation {
|
||||||
|
id: string
|
||||||
|
type: AnnotationType
|
||||||
|
from: number
|
||||||
|
to: number
|
||||||
|
matchedText: string
|
||||||
|
message: string
|
||||||
|
suggestion?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AnalysisMode = 'none' | 'passive_voice' | 'consistency' | 'style'
|
||||||
|
|
||||||
|
export type AIMode = 'chat' | 'passive_voice' | 'consistency' | 'style'
|
||||||
|
|
||||||
|
export interface AIPayload {
|
||||||
|
mode: AIMode
|
||||||
|
documentContent: string
|
||||||
|
documentPath: string
|
||||||
|
conversationHistory: Array<{ role: 'user' | 'assistant'; content: string }>
|
||||||
|
userMessage: string
|
||||||
|
}
|
||||||
16
src/renderer/types/global.d.ts
vendored
Normal file
16
src/renderer/types/global.d.ts
vendored
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import type { FileNode, AIPayload } from './editor'
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
api: {
|
||||||
|
listFiles: () => Promise<FileNode[]>
|
||||||
|
readFile: (filePath: string) => Promise<string>
|
||||||
|
writeFile: (filePath: string, content: string) => Promise<void>
|
||||||
|
streamAIMessage: (
|
||||||
|
payload: AIPayload,
|
||||||
|
onChunk: (chunk: string) => void
|
||||||
|
) => Promise<void>
|
||||||
|
removeAIListener: () => void
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
125
src/renderer/utils/annotationParser.ts
Normal file
125
src/renderer/utils/annotationParser.ts
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import type { TextAnnotation, AnnotationType } from '../types/editor'
|
||||||
|
|
||||||
|
// Attempt to locate AI-quoted text in the document and create highlight annotations
|
||||||
|
export function parseAnnotationsFromAIResponse(
|
||||||
|
aiResponse: string,
|
||||||
|
documentContent: string
|
||||||
|
): TextAnnotation[] {
|
||||||
|
const annotations: TextAnnotation[] = []
|
||||||
|
let id = 0
|
||||||
|
|
||||||
|
// Match quoted strings — handles "straight", "curly", and 'single' quotes
|
||||||
|
// Minimum 10 chars to avoid matching short words
|
||||||
|
const quotePattern = /["""''](.{10,300}?)["""'']/g
|
||||||
|
let match: RegExpExecArray | null
|
||||||
|
|
||||||
|
while ((match = quotePattern.exec(aiResponse)) !== null) {
|
||||||
|
const quotedText = match[1].trim()
|
||||||
|
|
||||||
|
// Try exact match first
|
||||||
|
let docIndex = documentContent.indexOf(quotedText)
|
||||||
|
|
||||||
|
// If not found exactly, try a normalized version (collapse whitespace)
|
||||||
|
if (docIndex === -1) {
|
||||||
|
const normalized = quotedText.replace(/\s+/g, ' ')
|
||||||
|
docIndex = findNormalized(documentContent, normalized)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (docIndex === -1) continue
|
||||||
|
|
||||||
|
// Don't annotate the same range twice
|
||||||
|
const alreadyAnnotated = annotations.some(
|
||||||
|
(a) => a.from === docIndex && a.to === docIndex + quotedText.length
|
||||||
|
)
|
||||||
|
if (alreadyAnnotated) continue
|
||||||
|
|
||||||
|
// Determine annotation type from context around the quote in the AI response
|
||||||
|
const contextStart = Math.max(0, match.index - 200)
|
||||||
|
const contextBefore = aiResponse.slice(contextStart, match.index).toLowerCase()
|
||||||
|
const type = classifyType(contextBefore)
|
||||||
|
|
||||||
|
// Try to extract a suggestion from text after the quote
|
||||||
|
const afterQuote = aiResponse.slice(match.index + match[0].length, match.index + match[0].length + 400)
|
||||||
|
const suggestion = extractSuggestion(afterQuote)
|
||||||
|
|
||||||
|
// Extract a short message label from the ISSUE/PROBLEM line before the quote
|
||||||
|
const message = extractMessage(aiResponse, match.index)
|
||||||
|
|
||||||
|
annotations.push({
|
||||||
|
id: `ai-${id++}`,
|
||||||
|
type,
|
||||||
|
from: docIndex,
|
||||||
|
to: docIndex + quotedText.length,
|
||||||
|
matchedText: quotedText,
|
||||||
|
message: message || `${type.replace('_', ' ')} — hover for details`,
|
||||||
|
suggestion
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return annotations
|
||||||
|
}
|
||||||
|
|
||||||
|
function classifyType(contextBefore: string): AnnotationType {
|
||||||
|
if (contextBefore.includes('passive')) return 'passive_voice'
|
||||||
|
if (
|
||||||
|
contextBefore.includes('consistency') ||
|
||||||
|
contextBefore.includes('character') ||
|
||||||
|
contextBefore.includes('timeline') ||
|
||||||
|
contextBefore.includes('repeated') ||
|
||||||
|
contextBefore.includes('contradiction')
|
||||||
|
) {
|
||||||
|
return 'consistency'
|
||||||
|
}
|
||||||
|
return 'style'
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractSuggestion(text: string): string | undefined {
|
||||||
|
// Look for SUGGESTION: "..." pattern
|
||||||
|
const m = text.match(/SUGGESTION:\s*["""'](.{5,200}?)["""']/i)
|
||||||
|
return m?.[1]?.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractMessage(response: string, quoteIndex: number): string {
|
||||||
|
// Look back for ISSUE: or PROBLEM: line
|
||||||
|
const before = response.slice(Math.max(0, quoteIndex - 300), quoteIndex)
|
||||||
|
const issueMatch = before.match(/(?:ISSUE|PROBLEM|WHY):\s*(.+?)(?:\n|$)/gi)
|
||||||
|
if (issueMatch) {
|
||||||
|
const last = issueMatch[issueMatch.length - 1]
|
||||||
|
return last.replace(/^(?:ISSUE|PROBLEM|WHY):\s*/i, '').trim().slice(0, 120)
|
||||||
|
}
|
||||||
|
// Fall back to the last sentence before the quote
|
||||||
|
const sentences = before.split(/[.!?]\s+/)
|
||||||
|
const last = sentences[sentences.length - 1]?.trim()
|
||||||
|
return last?.slice(0, 120) ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find a normalized string in a document (ignores whitespace differences)
|
||||||
|
function findNormalized(document: string, normalized: string): number {
|
||||||
|
const words = normalized.split(' ')
|
||||||
|
if (words.length < 3) return -1
|
||||||
|
|
||||||
|
// Search for the first few words as an anchor
|
||||||
|
const anchor = words.slice(0, 4).join(' ')
|
||||||
|
let searchFrom = 0
|
||||||
|
|
||||||
|
while (searchFrom < document.length) {
|
||||||
|
const idx = document.indexOf(words[0], searchFrom)
|
||||||
|
if (idx === -1) break
|
||||||
|
|
||||||
|
// Extract a comparable slice from the document
|
||||||
|
const slice = document.slice(idx, idx + normalized.length * 2).replace(/\s+/g, ' ')
|
||||||
|
if (slice.startsWith(normalized)) {
|
||||||
|
return idx
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the anchor matches
|
||||||
|
const docSlice = document.slice(idx, idx + anchor.length + 20).replace(/\s+/g, ' ')
|
||||||
|
if (docSlice.startsWith(anchor)) {
|
||||||
|
return idx
|
||||||
|
}
|
||||||
|
|
||||||
|
searchFrom = idx + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
return -1
|
||||||
|
}
|
||||||
88
src/renderer/utils/passiveVoice.ts
Normal file
88
src/renderer/utils/passiveVoice.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import type { TextAnnotation } from '../types/editor'
|
||||||
|
|
||||||
|
// Common irregular past participles
|
||||||
|
const IRREGULAR_PP =
|
||||||
|
'written|known|seen|found|made|done|given|taken|left|told|shown|brought|' +
|
||||||
|
'felt|kept|held|set|put|become|come|run|begun|gone|sent|built|paid|said|' +
|
||||||
|
'heard|met|read|lost|won|broken|fallen|grown|drawn|driven|eaten|forgotten|' +
|
||||||
|
'hidden|ridden|risen|stolen|sworn|thrown|worn|woken|chosen|frozen|gotten|' +
|
||||||
|
'proven|shaken|spoken|stolen|undertaken|woven|withdrawn|born|caught|bought|' +
|
||||||
|
'brought|fought|taught|thought|sought|hit|hurt|let|put|cut|shut|split|spread|' +
|
||||||
|
'led|fed|bled|bred|fled|sped|spun|stung|struck|strung|swung|flung|clung|' +
|
||||||
|
'rung|sung|slung|hung|dug|dug|stuck|struck|stunk|shrunk|drunk|sunk|sprung'
|
||||||
|
|
||||||
|
// Pattern: [to-be form] [optional adverb] [past participle]
|
||||||
|
// Handles: "was written", "is being known", "were quickly sent"
|
||||||
|
const PASSIVE_PATTERN = new RegExp(
|
||||||
|
`\\b(is|was|were|are|been|being|be|am)\\b(\\s+\\w+ly)?\\s+(${IRREGULAR_PP}|\\w+ed)\\b`,
|
||||||
|
'gi'
|
||||||
|
)
|
||||||
|
|
||||||
|
function findSentenceStart(text: string, pos: number): number {
|
||||||
|
let i = pos - 1
|
||||||
|
while (i > 0) {
|
||||||
|
// Look for sentence-ending punctuation followed by whitespace
|
||||||
|
if (/[.!?]/.test(text[i]) && i + 1 < text.length && /\s/.test(text[i + 1])) {
|
||||||
|
return i + 2
|
||||||
|
}
|
||||||
|
// Also stop at paragraph breaks
|
||||||
|
if (text[i] === '\n' && i > 0 && text[i - 1] === '\n') {
|
||||||
|
return i + 1
|
||||||
|
}
|
||||||
|
i--
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function findSentenceEnd(text: string, pos: number): number {
|
||||||
|
let i = pos
|
||||||
|
while (i < text.length) {
|
||||||
|
if (/[.!?]/.test(text[i])) {
|
||||||
|
return i + 1
|
||||||
|
}
|
||||||
|
if (text[i] === '\n') {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
return text.length
|
||||||
|
}
|
||||||
|
|
||||||
|
export function detectPassiveVoice(text: string): TextAnnotation[] {
|
||||||
|
const annotations: TextAnnotation[] = []
|
||||||
|
const seenRanges = new Set<string>()
|
||||||
|
|
||||||
|
PASSIVE_PATTERN.lastIndex = 0
|
||||||
|
let match: RegExpExecArray | null
|
||||||
|
|
||||||
|
while ((match = PASSIVE_PATTERN.exec(text)) !== null) {
|
||||||
|
const matchStart = match.index
|
||||||
|
const matchEnd = match.index + match[0].length
|
||||||
|
|
||||||
|
// Skip if this looks like "has been" (perfect passive is sometimes fine)
|
||||||
|
// and skip matches inside markdown headers
|
||||||
|
const lineStart = text.lastIndexOf('\n', matchStart) + 1
|
||||||
|
const lineText = text.slice(lineStart, matchEnd)
|
||||||
|
if (lineText.trimStart().startsWith('#')) continue
|
||||||
|
|
||||||
|
const from = findSentenceStart(text, matchStart)
|
||||||
|
const to = findSentenceEnd(text, matchEnd)
|
||||||
|
const key = `${from}-${to}`
|
||||||
|
|
||||||
|
if (seenRanges.has(key)) continue
|
||||||
|
seenRanges.add(key)
|
||||||
|
|
||||||
|
const sentence = text.slice(from, to).trim()
|
||||||
|
annotations.push({
|
||||||
|
id: `pv-${matchStart}`,
|
||||||
|
type: 'passive_voice',
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
matchedText: sentence,
|
||||||
|
message: `Passive voice: "${match[0].trim()}"`,
|
||||||
|
suggestion: undefined
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return annotations
|
||||||
|
}
|
||||||
7
tsconfig.json
Normal file
7
tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.node.json" },
|
||||||
|
{ "path": "./tsconfig.web.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
12
tsconfig.node.json
Normal file
12
tsconfig.node.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"extends": "@electron-toolkit/tsconfig/tsconfig.node.json",
|
||||||
|
"include": [
|
||||||
|
"electron.vite.config.*",
|
||||||
|
"src/main/**/*",
|
||||||
|
"src/preload/**/*"
|
||||||
|
],
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"types": ["electron-vite/node"]
|
||||||
|
}
|
||||||
|
}
|
||||||
11
tsconfig.web.json
Normal file
11
tsconfig.web.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"extends": "@electron-toolkit/tsconfig/tsconfig.web.json",
|
||||||
|
"include": ["src/renderer/**/*"],
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@renderer/*": ["src/renderer/*"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user