8ce35a1e8e
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>
151 lines
5.2 KiB
TypeScript
151 lines
5.2 KiB
TypeScript
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>
|
|
)
|
|
}
|