feat: ship v0.3.0 with P2.1 editor polish and P3 AI collaboration

Add chapter reorder, search replace, inspiration capture, and AI chat with context editing, settings, offline handling, and naming checks. Fix dev black screen by keeping process.env reads in the main process only.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-06 16:33:56 +08:00
parent 0b5aa146bd
commit 8ce35a1e8e
59 changed files with 3808 additions and 100 deletions
@@ -0,0 +1,150 @@
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import type { AiContextBinding } from '@shared/types'
import { useBookStore } from '@renderer/stores/useBookStore'
import { useAiStore } from '@renderer/stores/useAiStore'
import { ipcCall } from '@renderer/lib/ipc-client'
import { formatContextSummary } from '@renderer/lib/ai-context-summary'
import i18n from '@renderer/i18n'
type ContextTab = 'chapter' | 'outline' | 'setting' | 'inspiration'
interface ContextEditorModalProps {
open: boolean
onClose: () => void
}
const EMPTY_BINDING: AiContextBinding = {
chapterIds: [],
outlineIds: [],
settingIds: [],
inspirationIds: []
}
export function ContextEditorModal({ open, onClose }: ContextEditorModalProps): React.JSX.Element | null {
const { t } = useTranslation()
const bookId = useBookStore((s) => s.currentBookId)
const chapters = useBookStore((s) => s.chapters)
const outlines = useBookStore((s) => s.outlines)
const settings = useBookStore((s) => s.settings)
const inspirations = useBookStore((s) => s.inspirations)
const activeSessionId = useAiStore((s) => s.activeSessionId)
const sessions = useAiStore((s) => s.sessions)
const [tab, setTab] = useState<ContextTab>('setting')
const [binding, setBinding] = useState<AiContextBinding>(EMPTY_BINDING)
useEffect(() => {
if (!open) return
const session = sessions.find((s) => s.id === activeSessionId)
setBinding(session?.context ?? EMPTY_BINDING)
}, [open, activeSessionId, sessions])
const toggle = (kind: ContextTab, id: string): void => {
const key =
kind === 'chapter'
? 'chapterIds'
: kind === 'outline'
? 'outlineIds'
: kind === 'setting'
? 'settingIds'
: 'inspirationIds'
setBinding((prev) => {
const list = prev[key]
return {
...prev,
[key]: list.includes(id) ? list.filter((x) => x !== id) : [...list, id]
}
})
}
const handleSave = async (): Promise<void> => {
if (!bookId || !activeSessionId) return
const updated = await ipcCall(() =>
window.electronAPI.ai.sessionUpdate(bookId, activeSessionId, { context: binding })
)
useAiStore.setState((s) => ({
sessions: s.sessions.map((x) => (x.id === updated.id ? updated : x))
}))
await useAiStore.getState().refreshContextPreview()
onClose()
}
const handleRefresh = async (): Promise<void> => {
if (!bookId) return
const result = await ipcCall(() => window.electronAPI.ai.buildContext(bookId, binding))
useAiStore.setState({
contextPreview: result.preview,
contextSummary: formatContextSummary(result.preview, i18n.t)
})
}
if (!open) return null
const items =
tab === 'chapter'
? chapters.map((c) => ({ id: c.id, title: c.title }))
: tab === 'outline'
? outlines.map((o) => ({ id: o.id, title: o.title }))
: tab === 'setting'
? settings.map((s) => ({ id: s.id, title: s.name }))
: inspirations.map((i) => ({ id: i.id, title: i.title || t('inspiration.untitled') }))
const selectedIds =
tab === 'chapter'
? binding.chapterIds
: tab === 'outline'
? binding.outlineIds
: tab === 'setting'
? binding.settingIds
: binding.inspirationIds
return (
<div className="modal-overlay" data-testid="context-editor-modal">
<div className="modal-content context-editor-modal">
<div className="modal-header">
<h3>{t('ai.contextEditorTitle')}</h3>
<button type="button" className="modal-close" onClick={onClose}>
</button>
</div>
<div className="context-editor-tabs">
{(['chapter', 'outline', 'setting', 'inspiration'] as const).map((id) => (
<button
key={id}
type="button"
className={`context-editor-tab ${tab === id ? 'active' : ''}`}
data-testid={`context-tab-${id}`}
onClick={() => setTab(id)}
>
{t(`ai.contextTab.${id}`)}
</button>
))}
</div>
<div className="context-editor-list">
{items.length === 0 && <div className="sidebar-empty">{t('ai.contextEmptyList')}</div>}
{items.map((item) => (
<label key={item.id} className="context-editor-item" data-testid={`context-item-${item.id}`}>
<input
type="checkbox"
checked={selectedIds.includes(item.id)}
onChange={() => toggle(tab, item.id)}
/>
<span>{item.title}</span>
</label>
))}
</div>
<div className="modal-footer">
<button type="button" className="btn" data-testid="context-refresh" onClick={() => void handleRefresh()}>
{t('ai.contextRefresh')}
</button>
<button type="button" className="btn" onClick={onClose}>
{t('dialog.cancel')}
</button>
<button type="button" className="btn btn-primary" data-testid="context-save" onClick={() => void handleSave()}>
{t('ai.contextSave')}
</button>
</div>
</div>
</div>
)
}