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
+23 -10
View File
@@ -16,10 +16,12 @@ import { useTabStore } from '@renderer/stores/useTabStore'
import { useBookStore } from '@renderer/stores/useBookStore'
import { useEditStore } from '@renderer/stores/useEditStore'
import { SearchModal } from '@renderer/components/search/SearchModal'
import { InspirationModal } from '@renderer/components/inspiration/InspirationModal'
import { useSearchStore } from '@renderer/stores/useSearchStore'
import { useReferenceStore } from '@renderer/stores/useReferenceStore'
import { flushEditorSave } from '@renderer/components/editor/TipTapEditor'
import { insertLandmarkInEditor } from '@renderer/lib/editor-commands'
import { useAiStore } from '@renderer/stores/useAiStore'
import { ipcCall } from '@renderer/lib/ipc-client'
function AppInner(): React.JSX.Element {
@@ -48,6 +50,11 @@ function AppInner(): React.JSX.Element {
useEffect(() => {
const openSearch = (): void => useSearchStore.getState().setOpen(true)
const openLandmarks = (): void => useAppStore.getState().setLandmarksModalOpen(true)
const openInspiration = (): void => {
if (useAppStore.getState().view !== 'editor') return
if (!useBookStore.getState().currentBookId) return
useAppStore.getState().setInspirationModalOpen(true)
}
const onInsertLandmark = (e: Event): void => {
const label = (e as CustomEvent<{ label: string }>).detail?.label
if (!label) return
@@ -59,13 +66,26 @@ function AppInner(): React.JSX.Element {
window.electronAPI.bookmark.create(currentBookId, target.id, 0, label, 'todo')
)
}
const onSetAiOnline = (e: Event): void => {
const online = (e as CustomEvent<{ online: boolean }>).detail?.online
if (typeof online === 'boolean') useAiStore.setState({ online })
}
const onFlushEditor = (): void => {
void flushEditorSave()
}
window.addEventListener('bilin:e2e-open-search', openSearch)
window.addEventListener('bilin:e2e-open-landmarks', openLandmarks)
window.addEventListener('bilin:e2e-capture-inspiration', openInspiration)
window.addEventListener('bilin:e2e-insert-landmark', onInsertLandmark)
window.addEventListener('bilin:e2e-set-ai-online', onSetAiOnline)
window.addEventListener('bilin:e2e-flush-editor', onFlushEditor)
return () => {
window.removeEventListener('bilin:e2e-open-search', openSearch)
window.removeEventListener('bilin:e2e-open-landmarks', openLandmarks)
window.removeEventListener('bilin:e2e-capture-inspiration', openInspiration)
window.removeEventListener('bilin:e2e-insert-landmark', onInsertLandmark)
window.removeEventListener('bilin:e2e-set-ai-online', onSetAiOnline)
window.removeEventListener('bilin:e2e-flush-editor', onFlushEditor)
}
}, [])
@@ -123,16 +143,8 @@ function AppInner(): React.JSX.Element {
}
if (action === 'captureInspiration') {
if (view !== 'editor') return
const { currentBookId, addInspirationLocal } = useBookStore.getState()
if (!currentBookId) return
void (async () => {
const item = await ipcCall(() =>
window.electronAPI.inspiration.create(currentBookId, t('inspiration.newItem'))
)
addInspirationLocal(item)
useAppStore.getState().setSidebarPanel('inspiration')
await useEditStore.getState().switchTarget({ kind: 'inspiration', id: item.id })
})()
if (!useBookStore.getState().currentBookId) return
useAppStore.getState().setInspirationModalOpen(true)
return
}
showToast(t('feature.comingSoon'))
@@ -154,6 +166,7 @@ function AppInner(): React.JSX.Element {
</div>
{showOnboarding && <OnboardingWizard onComplete={handleOnboardingComplete} />}
<SearchModal />
<InspirationModal />
{toast && <div className="toast">{toast}</div>}
</div>
)
+229
View File
@@ -0,0 +1,229 @@
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import type { AiWritingMode } from '@shared/types'
import { useBookStore } from '@renderer/stores/useBookStore'
import { useAppStore } from '@renderer/stores/useAppStore'
import { useAiStore } from '@renderer/stores/useAiStore'
import { useSettingsStore } from '@renderer/stores/useSettingsStore'
import { applySlashCommand } from '@renderer/lib/ai-slash-commands'
import { insertToEditor } from '@renderer/lib/editor-commands'
import { ipcCall } from '@renderer/lib/ipc-client'
import { ContextEditorModal } from '@renderer/components/ai/ContextEditorModal'
const MODES: { id: AiWritingMode; labelKey: string }[] = [
{ id: 'chat', labelKey: 'ai.mode.chat' },
{ id: 'interactive', labelKey: 'ai.mode.interactive' },
{ id: 'auto', labelKey: 'ai.mode.auto' },
{ id: 'wizard', labelKey: 'ai.mode.wizard' }
]
const QUICK_CMDS = ['/续写', '/扩写', '/润色', '/总结', '/纠错']
export function AiPanel(): React.JSX.Element {
const { t } = useTranslation()
const showToast = useAppStore((s) => s.showToast)
const bookId = useBookStore((s) => s.currentBookId)
const sessions = useAiStore((s) => s.sessions)
const activeSessionId = useAiStore((s) => s.activeSessionId)
const messages = useAiStore((s) => s.messages)
const streamingBuffer = useAiStore((s) => s.streamingBuffer)
const streamingMessageId = useAiStore((s) => s.streamingMessageId)
const sending = useAiStore((s) => s.sending)
const online = useAiStore((s) => s.online)
const backend = useSettingsStore((s) => s.aiConfig.backend)
const disabled = backend !== 'lmstudio' && !online
const writingMode = useAiStore((s) => s.writingMode)
const contextSummary = useAiStore((s) => s.contextSummary)
const loadSessions = useAiStore((s) => s.loadSessions)
const selectSession = useAiStore((s) => s.selectSession)
const createSession = useAiStore((s) => s.createSession)
const sendMessage = useAiStore((s) => s.sendMessage)
const setWritingMode = useAiStore((s) => s.setWritingMode)
const [input, setInput] = useState('')
const [contextOpen, setContextOpen] = useState(false)
const messagesEndRef = useRef<HTMLDivElement>(null)
const prevOnlineRef = useRef(online)
useEffect(() => {
if (!prevOnlineRef.current && online && backend !== 'lmstudio') {
showToast(t('ai.networkRestored'))
}
prevOnlineRef.current = online
}, [online, backend, showToast, t])
useEffect(() => {
useAiStore.getState().mount()
return () => useAiStore.getState().unmount()
}, [])
useEffect(() => {
if (bookId) void loadSessions(bookId)
}, [bookId, loadSessions])
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages, streamingBuffer])
const handleSend = (): void => {
const text = input.trim()
if (!text || sending || disabled) return
const { display, prompt } = applySlashCommand(text, t)
setInput('')
void sendMessage(display, prompt !== display ? prompt : undefined)
}
const handleInsert = async (content: string): Promise<void> => {
try {
await insertToEditor(content, t('snapshot.ai'), async (bookId, chapterId, html, label) => {
await ipcCall(() =>
window.electronAPI.snapshot.create(bookId, chapterId, 'ai', label, html)
)
})
showToast(t('ai.inserted'))
} catch (err) {
showToast(err instanceof Error ? err.message : String(err))
}
}
const handleModeClick = (mode: AiWritingMode): void => {
if (mode !== 'chat') {
showToast(t('feature.comingSoon'))
return
}
setWritingMode(mode)
}
if (!bookId) {
return (
<div className="ai-panel-empty" data-testid="ai-panel">
{t('ai.noBook')}
</div>
)
}
return (
<>
<div id="ai-panel" className="ai-panel" data-testid="ai-panel">
<div className="ai-session-bar">
<select
data-testid="ai-session-select"
value={activeSessionId ?? ''}
onChange={(e) => void selectSession(e.target.value)}
>
{sessions.length === 0 && <option value="">{t('ai.noSession')}</option>}
{sessions.map((s) => (
<option key={s.id} value={s.id}>
{s.title || t('ai.untitledSession')}
</option>
))}
</select>
<button
type="button"
className="btn"
data-testid="ai-session-new"
onClick={() => void createSession(bookId)}
>
+
</button>
</div>
<div className="ai-mode-tabs" role="tablist">
{MODES.map((mode) => (
<button
key={mode.id}
type="button"
className={`ai-mode-tab ${writingMode === mode.id ? 'active' : ''}`}
onClick={() => handleModeClick(mode.id)}
>
{t(mode.labelKey)}
</button>
))}
</div>
<button
type="button"
className="context-preview"
data-testid="ai-context-preview"
onClick={() => setContextOpen(true)}
>
{contextSummary}
</button>
{disabled && (
<div className="offline-banner show" data-testid="ai-offline-banner">
{t('ai.offlineBanner')}
</div>
)}
<div className="chat-messages" data-testid="ai-chat-messages">
{messages.map((msg) => (
<div
key={msg.id}
className={`chat-msg ${msg.role === 'user' ? 'user' : 'ai'}`}
data-testid={msg.role === 'assistant' ? 'chat-message-assistant' : 'chat-message-user'}
>
<div className="chat-msg-body">{msg.content}</div>
{msg.role === 'assistant' && (
<button
type="button"
className="chat-insert-btn"
data-testid="ai-insert-to-editor"
onClick={() => void handleInsert(msg.content)}
>
{t('ai.insertToEditor')}
</button>
)}
</div>
))}
{streamingMessageId && streamingBuffer && (
<div className="chat-msg ai" data-testid="chat-message-assistant">
{streamingBuffer}
</div>
)}
<div ref={messagesEndRef} />
</div>
<div className="quick-cmds">
{QUICK_CMDS.map((cmd) => (
<button
key={cmd}
type="button"
className="quick-cmd"
data-testid={`ai-quick-cmd-${cmd.slice(1)}`}
disabled={disabled || sending}
onClick={() => !disabled && setInput(cmd + ' ')}
>
{cmd}
</button>
))}
</div>
<div className="chat-input-area">
<input
type="text"
data-testid="ai-chat-input"
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder={t('ai.inputPlaceholder')}
disabled={sending || disabled}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
handleSend()
}
}}
/>
<button
type="button"
data-testid="ai-send"
disabled={sending || disabled || !input.trim()}
onClick={handleSend}
>
{t('ai.send')}
</button>
</div>
</div>
<ContextEditorModal open={contextOpen} onClose={() => setContextOpen(false)} />
</>
)
}
@@ -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>
)
}
@@ -0,0 +1,124 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import type { NamingConflict } from '@shared/types'
import { useBookStore } from '@renderer/stores/useBookStore'
import { useSettingsStore } from '@renderer/stores/useSettingsStore'
import { useAppStore } from '@renderer/stores/useAppStore'
import { useEditStore } from '@renderer/stores/useEditStore'
import { ipcCall } from '@renderer/lib/ipc-client'
interface NamingCheckModalProps {
open: boolean
onClose: () => void
}
export function NamingCheckModal({ open, onClose }: NamingCheckModalProps): React.JSX.Element | null {
const { t } = useTranslation()
const bookId = useBookStore((s) => s.currentBookId)
const openBook = useBookStore((s) => s.openBook)
const namingIgnoreWords = useSettingsStore((s) => s.namingIgnoreWords)
const updateSettings = useSettingsStore((s) => s.update)
const showToast = useAppStore((s) => s.showToast)
const reloadTarget = useEditStore((s) => s.reloadTarget)
const [loading, setLoading] = useState(false)
const [conflicts, setConflicts] = useState<NamingConflict[]>([])
if (!open) return null
const runCheck = async (): Promise<void> => {
const activeBookId = useBookStore.getState().currentBookId
if (!activeBookId) return
setLoading(true)
try {
const rows = await ipcCall(() => window.electronAPI.ai.namingCheck(activeBookId))
const ignore = new Set(namingIgnoreWords)
setConflicts(
rows.filter((row) => !ignore.has(row.termA) && !ignore.has(row.termB))
)
if (rows.length === 0) {
showToast(t('naming.noConflicts'))
}
} catch (err) {
showToast(err instanceof Error ? err.message : String(err))
setConflicts([])
} finally {
setLoading(false)
}
}
const handleIgnore = (row: NamingConflict): void => {
const next = Array.from(new Set([...namingIgnoreWords, row.termA, row.termB]))
void updateSettings({ namingIgnoreWords: next })
setConflicts((list) => list.filter((item) => item !== row))
showToast(t('naming.ignored'))
}
const handleReplace = async (row: NamingConflict): Promise<void> => {
if (!bookId) return
const canonical = row.suggestedCanonical || row.termA
const from = row.termB
if (!from || from === canonical) return
try {
const result = await ipcCall(() =>
window.electronAPI.search.replace(bookId, from, canonical, false, { caseSensitive: true })
)
await openBook(bookId)
reloadTarget()
setConflicts((list) => list.filter((item) => item !== row))
showToast(t('naming.replaced', { count: result.count }))
} catch (err) {
showToast(err instanceof Error ? err.message : String(err))
}
}
return (
<div className="modal-overlay naming-modal" data-testid="naming-check-modal">
<div className="modal-content">
<div className="modal-header">
<h3>{t('naming.title')}</h3>
<button type="button" className="modal-close" onClick={onClose}>
</button>
</div>
<div className="naming-modal-body">
{conflicts.length === 0 && !loading && (
<p className="naming-empty">{t('naming.emptyHint')}</p>
)}
{conflicts.map((row, index) => (
<div key={`${row.termA}-${row.termB}-${index}`} className="naming-row" data-testid="naming-row">
<div className="naming-terms">
<strong>{row.termA}</strong> <strong>{row.termB}</strong>
</div>
<div className="naming-meta">
{t('naming.confidence', { value: Math.round(row.confidence * 100) })}
{row.reason ? ` · ${row.reason}` : ''}
</div>
<div className="naming-actions">
<button type="button" className="btn" onClick={() => handleIgnore(row)}>
{t('naming.ignore')}
</button>
<button type="button" className="btn btn-primary" onClick={() => void handleReplace(row)}>
{t('naming.replace', { term: row.suggestedCanonical || row.termA })}
</button>
</div>
</div>
))}
</div>
<div className="modal-footer">
<button type="button" className="btn" onClick={onClose}>
{t('dialog.cancel')}
</button>
<button
type="button"
className="btn btn-primary"
data-testid="naming-run-check"
disabled={loading || !bookId}
onClick={() => void runCheck()}
>
{loading ? t('naming.running') : t('naming.run')}
</button>
</div>
</div>
</div>
)
}
@@ -8,11 +8,11 @@ import { useSetAtom } from 'jotai'
import type { EditTarget } from '@shared/types'
import { editorDirtyAtom, editorSavingAtom, lastSavedAtAtom, wordCountAtom } from '@renderer/atoms/editorAtoms'
import { useBookStore } from '@renderer/stores/useBookStore'
import { flushEditSession, registerEditorFlush } from '@renderer/stores/useEditStore'
import { flushEditSession, registerEditorFlush, useEditStore } from '@renderer/stores/useEditStore'
import { ipcCall } from '@renderer/lib/ipc-client'
import { LandmarkMark } from '@renderer/extensions/landmark-mark'
import { SettingMention } from '@renderer/extensions/mention'
import { registerHighlightToken, registerInsertLandmark } from '@renderer/lib/editor-commands'
import { registerHighlightToken, registerInsertLandmark, registerEditorInsert } from '@renderer/lib/editor-commands'
function countPlain(text: string): number {
const stripped = text.replace(/<[^>]+>/g, '').replace(/\s+/g, '')
const cjk = stripped.match(/[\u4e00-\u9fff\u3400-\u4dbf]/g)
@@ -149,6 +149,13 @@ export function TipTapEditor({ target, bookId }: TipTapEditorProps): React.JSX.E
}
},
editorProps: {
handleClick: (_view, _pos, event) => {
const el = (event.target as HTMLElement).closest('[data-setting-mention]')
if (!el) return false
const id = el.getAttribute('data-id')
if (id) void useEditStore.getState().switchTarget({ kind: 'setting', id })
return true
},
handleDOMEvents: {
dragover: (_view, event) => {
event.preventDefault()
@@ -282,7 +289,29 @@ export function TipTapEditor({ target, bookId }: TipTapEditorProps): React.JSX.E
}, [editor])
useEffect(() => {
if (!editor || !target) return
if (!editor || !bookId || !target || target.kind !== 'chapter') {
registerEditorInsert(null, () => {})
return
}
registerEditorInsert(
{
bookId,
chapterId: target.id,
getHtml: () => editor.getHTML()
},
(text) => {
editor.chain().focus().insertContent(text).run()
}
)
return () => registerEditorInsert(null, () => {})
}, [editor, bookId, target])
useEffect(() => {
if (!editor) return
if (!target) {
targetKeyRef.current = null
return
}
const key = `${target.kind}:${target.id}`
if (targetKeyRef.current === key) return
targetKeyRef.current = key
@@ -0,0 +1,144 @@
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useAppStore } from '@renderer/stores/useAppStore'
import { useBookStore } from '@renderer/stores/useBookStore'
import { useEditStore } from '@renderer/stores/useEditStore'
import { ipcCall } from '@renderer/lib/ipc-client'
type SpeechRecognitionCtor = new () => SpeechRecognition
function getSpeechRecognition(): SpeechRecognitionCtor | null {
const w = window as Window & {
SpeechRecognition?: SpeechRecognitionCtor
webkitSpeechRecognition?: SpeechRecognitionCtor
}
return w.SpeechRecognition ?? w.webkitSpeechRecognition ?? null
}
export function InspirationModal(): React.JSX.Element | null {
const { t } = useTranslation()
const open = useAppStore((s) => s.inspirationModalOpen)
const setOpen = useAppStore((s) => s.setInspirationModalOpen)
const setSidebarPanel = useAppStore((s) => s.setSidebarPanel)
const bookId = useBookStore((s) => s.currentBookId)
const addInspirationLocal = useBookStore((s) => s.addInspirationLocal)
const switchTarget = useEditStore((s) => s.switchTarget)
const [title, setTitle] = useState('')
const [content, setContent] = useState('')
const [tags, setTags] = useState('')
const contentRef = useRef<HTMLTextAreaElement>(null)
const speechAvailable = getSpeechRecognition() !== null
useEffect(() => {
if (!open) return
setTitle('')
setContent('')
setTags('')
const timer = setTimeout(() => contentRef.current?.focus(), 100)
return () => clearTimeout(timer)
}, [open])
const handleVoice = (): void => {
const SpeechRecognition = getSpeechRecognition()
if (!SpeechRecognition) return
const rec = new SpeechRecognition()
rec.lang = 'zh-CN'
rec.onresult = (e) => {
const transcript = e.results[0]?.[0]?.transcript ?? ''
if (transcript) setContent((c) => c + transcript)
}
rec.start()
}
const handleSave = async (): Promise<void> => {
if (!bookId || !content.trim()) return
const tagList = tags
.split(/[,]/)
.map((x) => x.trim())
.filter(Boolean)
const item = await ipcCall(() =>
window.electronAPI.inspiration.create(
bookId,
title.trim() || t('inspiration.newItem'),
content.trim(),
tagList
)
)
addInspirationLocal(item)
setSidebarPanel('inspiration')
await switchTarget({ kind: 'inspiration', id: item.id })
setOpen(false)
}
if (!open) return null
return (
<div
className="modal-overlay"
id="inspirationModal"
data-testid="inspiration-modal"
role="dialog"
aria-modal="true"
aria-label={t('inspiration.captureTitle')}
>
<div className="modal-content inspiration-modal">
<div className="modal-header">
<h3>{t('inspiration.captureTitle')}</h3>
<button type="button" className="modal-close" onClick={() => setOpen(false)}>
</button>
</div>
<div className="modal-body inspiration-capture">
<input
className="form-control form-control--wide"
data-testid="inspiration-title-input"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder={t('inspiration.titlePlaceholder')}
/>
<textarea
ref={contentRef}
className="inspiration-textarea"
data-testid="inspiration-content-input"
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder={t('inspiration.contentPlaceholder')}
rows={6}
/>
<label className="form-label">{t('inspiration.tagsLabel')}</label>
<input
className="form-control form-control--wide"
data-testid="inspiration-tags-input"
value={tags}
onChange={(e) => setTags(e.target.value)}
placeholder={t('inspiration.tagsPlaceholder')}
/>
</div>
<div className="modal-footer">
<button
type="button"
className="btn"
data-testid="inspiration-voice-btn"
disabled={!speechAvailable}
title={speechAvailable ? t('inspiration.voice') : t('inspiration.voiceUnavailable')}
onClick={handleVoice}
>
🎤 {t('inspiration.voice')}
</button>
<button type="button" className="btn" onClick={() => setOpen(false)}>
{t('dialog.cancel')}
</button>
<button
type="button"
className="btn btn-primary"
data-testid="inspiration-save-btn"
disabled={!content.trim()}
onClick={() => void handleSave()}
>
{t('inspiration.save')}
</button>
</div>
</div>
</div>
)
}
@@ -0,0 +1,27 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { NamingCheckModal } from '@renderer/components/ai/NamingCheckModal'
export function KnowledgePanel(): React.JSX.Element {
const { t } = useTranslation()
const [namingOpen, setNamingOpen] = useState(false)
return (
<>
<div className="knowledge-panel" data-testid="knowledge-panel">
<div className="knowledge-toolbar">
<button
type="button"
className="btn"
data-testid="naming-check-open"
onClick={() => setNamingOpen(true)}
>
{t('naming.open')}
</button>
</div>
<div className="placeholder-box">{t('knowledge.placeholder')}</div>
</div>
<NamingCheckModal open={namingOpen} onClose={() => setNamingOpen(false)} />
</>
)
}
@@ -1,4 +1,4 @@
import { useEffect, useMemo } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useAtomValue } from 'jotai'
import { useAppStore } from '@renderer/stores/useAppStore'
@@ -122,6 +122,55 @@ export function EditorLayout(): React.JSX.Element {
setActiveVolume(vol.id)
}
const [dragOverChapterId, setDragOverChapterId] = useState<string | null>(null)
const handleChapterDropOnItem = async (
draggedId: string,
targetVolumeId: string,
targetChapterId: string
): Promise<void> => {
if (!currentBookId || draggedId === targetChapterId) return
await flushEditorSave()
const dragged = chapters.find((c) => c.id === draggedId)
if (!dragged) return
const volChapters = chapters
.filter((c) => c.volumeId === targetVolumeId)
.sort((a, b) => a.sortOrder - b.sortOrder)
const targetIdx = volChapters.findIndex((c) => c.id === targetChapterId)
if (dragged.volumeId === targetVolumeId) {
const ids = volChapters.map((c) => c.id)
const from = ids.indexOf(draggedId)
if (from < 0) return
ids.splice(from, 1)
const insertAt = ids.indexOf(targetChapterId)
ids.splice(insertAt, 0, draggedId)
await ipcCall(() => window.electronAPI.chapter.reorder(currentBookId, targetVolumeId, ids))
} else {
await ipcCall(() =>
window.electronAPI.chapter.moveToVolume(
currentBookId,
draggedId,
targetVolumeId,
Math.max(0, targetIdx)
)
)
}
await refreshChapters()
}
const handleChapterDropOnVolume = async (draggedId: string, targetVolumeId: string): Promise<void> => {
if (!currentBookId) return
await flushEditorSave()
const dragged = chapters.find((c) => c.id === draggedId)
if (!dragged) return
const targetLen = chapters.filter((c) => c.volumeId === targetVolumeId).length
if (dragged.volumeId === targetVolumeId) return
await ipcCall(() =>
window.electronAPI.chapter.moveToVolume(currentBookId, draggedId, targetVolumeId, targetLen)
)
await refreshChapters()
}
const handleInsertLandmark = async (): Promise<void> => {
if (!currentBookId || target?.kind !== 'chapter') {
showToast(t('landmark.chapterOnly'))
@@ -163,7 +212,14 @@ export function EditorLayout(): React.JSX.Element {
<div key={vol.id}>
<div
className="vol-header"
data-volume-id={vol.id}
onClick={() => setActiveVolume(vol.id)}
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => {
e.preventDefault()
const draggedId = e.dataTransfer.getData('application/x-bilin-chapter')
if (draggedId) void handleChapterDropOnVolume(draggedId, vol.id)
}}
style={{ color: activeVolumeId === vol.id ? 'var(--accent-light)' : undefined }}
>
{vol.name}
@@ -173,7 +229,24 @@ export function EditorLayout(): React.JSX.Element {
.map((ch, idx) => (
<div
key={ch.id}
className={`chapter-item ${target?.kind === 'chapter' && target.id === ch.id ? 'active' : ''}`}
className={`chapter-item ${target?.kind === 'chapter' && target.id === ch.id ? 'active' : ''} ${dragOverChapterId === ch.id ? 'drag-over' : ''}`}
draggable
data-testid={`chapter-item-${ch.id}`}
onDragStart={(e) => {
e.dataTransfer.setData('application/x-bilin-chapter', ch.id)
e.dataTransfer.effectAllowed = 'move'
}}
onDragOver={(e) => {
e.preventDefault()
setDragOverChapterId(ch.id)
}}
onDragLeave={() => setDragOverChapterId(null)}
onDrop={(e) => {
e.preventDefault()
setDragOverChapterId(null)
const draggedId = e.dataTransfer.getData('application/x-bilin-chapter')
if (draggedId) void handleChapterDropOnItem(draggedId, vol.id, ch.id)
}}
onClick={() => void handleSelectChapter(ch.id)}
onKeyDown={(e) => e.key === 'Enter' && void handleSelectChapter(ch.id)}
role="button"
@@ -1,6 +1,8 @@
import { useTranslation } from 'react-i18next'
import { useAppStore } from '@renderer/stores/useAppStore'
import { WordFreqPanel } from '@renderer/components/wordfreq/WordFreqPanel'
import { AiPanel } from '@renderer/components/ai/AiPanel'
import { KnowledgePanel } from '@renderer/components/knowledge/KnowledgePanel'
export function RightPanel(): React.JSX.Element {
const { t } = useTranslation()
@@ -28,12 +30,11 @@ export function RightPanel(): React.JSX.Element {
</button>
))}
</div>
<div className={`panel-content ${rightPanel === 'ai' ? 'active' : ''}`}>
<p style={{ fontSize: 11, color: 'var(--text-muted)', marginBottom: 8 }}>{t('panel.aiPlaceholder')}</p>
<input className="ai-input-disabled" disabled placeholder={t('feature.comingSoon')} />
<div className={`panel-content ai-panel-wrap ${rightPanel === 'ai' ? 'active' : ''}`}>
<AiPanel />
</div>
<div className={`panel-content ${rightPanel === 'knowledge' ? 'active' : ''}`}>
<div className="placeholder-box">{t('feature.comingSoon')}</div>
<KnowledgePanel />
</div>
<div className={`panel-content ${rightPanel === 'wordfreq' ? 'active' : ''}`}>
<WordFreqPanel />
@@ -7,11 +7,13 @@ import { useEditStore } from '@renderer/stores/useEditStore'
export function ReferencePanel(): React.JSX.Element {
const { t } = useTranslation()
const open = useReferenceStore((s) => s.open)
const pinned = useReferenceStore((s) => s.pinned)
const tab = useReferenceStore((s) => s.tab)
const query = useReferenceStore((s) => s.query)
const setTab = useReferenceStore((s) => s.setTab)
const setQuery = useReferenceStore((s) => s.setQuery)
const setOpen = useReferenceStore((s) => s.setOpen)
const setPinned = useReferenceStore((s) => s.setPinned)
const switchTarget = useEditStore((s) => s.switchTarget)
const outlines = useBookStore((s) => s.outlines)
const settings = useBookStore((s) => s.settings)
@@ -43,9 +45,28 @@ export function ReferencePanel(): React.JSX.Element {
<div id="reference-panel" className="open" data-testid="reference-panel">
<div className="ref-header">
<span>{t('reference.title')}</span>
<button type="button" className="btn" onClick={() => setOpen(false)}>
</button>
<div className="ref-header-actions">
<button
type="button"
className={`btn ref-pin-btn ${pinned ? 'active' : ''}`}
data-testid="reference-pin"
title={pinned ? t('reference.unpin') : t('reference.pin')}
onClick={() => setPinned(!pinned)}
>
📌
</button>
<button
type="button"
className="btn"
data-testid="reference-close"
onClick={() => {
if (pinned) setPinned(false)
else setOpen(false)
}}
>
{pinned ? t('reference.unpin') : '✕'}
</button>
</div>
</div>
<input
className="form-control form-control--wide"
@@ -4,6 +4,7 @@ import type { SearchResult } from '@shared/types'
import { useSearchStore } from '@renderer/stores/useSearchStore'
import { useBookStore } from '@renderer/stores/useBookStore'
import { useEditStore } from '@renderer/stores/useEditStore'
import { useAppStore } from '@renderer/stores/useAppStore'
import { ipcCall } from '@renderer/lib/ipc-client'
export function SearchModal(): React.JSX.Element | null {
@@ -17,11 +18,16 @@ export function SearchModal(): React.JSX.Element | null {
const setRegex = useSearchStore((s) => s.setRegex)
const setCaseSensitive = useSearchStore((s) => s.setCaseSensitive)
const bookId = useBookStore((s) => s.currentBookId)
const refreshChapters = useBookStore((s) => s.refreshChapters)
const setSelectedChapter = useBookStore((s) => s.setSelectedChapter)
const switchTarget = useEditStore((s) => s.switchTarget)
const reloadTarget = useEditStore((s) => s.reloadTarget)
const showToast = useAppStore((s) => s.showToast)
const [results, setResults] = useState<SearchResult[]>([])
const [history, setHistory] = useState<string[]>([])
const [selectedIdx, setSelectedIdx] = useState(0)
const [replacement, setReplacement] = useState('')
const [replacePreviews, setReplacePreviews] = useState<SearchResult[]>([])
useEffect(() => {
if (!open) return
@@ -58,6 +64,26 @@ export function SearchModal(): React.JSX.Element | null {
}
}
const previewReplace = async (): Promise<void> => {
if (!bookId || !query.trim()) return
const result = await ipcCall(() =>
window.electronAPI.search.replace(bookId, query, replacement, true, { regex, caseSensitive })
)
setReplacePreviews(result.previews)
}
const executeReplace = async (): Promise<void> => {
if (!bookId || !query.trim()) return
if (!window.confirm(t('search.replaceConfirm'))) return
const result = await ipcCall(() =>
window.electronAPI.search.replace(bookId, query, replacement, false, { regex, caseSensitive })
)
await refreshChapters()
reloadTarget()
showToast(t('search.replaceDone', { count: result.count }))
setReplacePreviews([])
}
if (!open) return null
return (
@@ -121,6 +147,46 @@ export function SearchModal(): React.JSX.Element | null {
</div>
))}
</div>
<details className="search-replace-panel" data-testid="search-replace-panel">
<summary>{t('search.replace')}</summary>
<input
className="form-control form-control--wide"
data-testid="search-replace-input"
value={replacement}
onChange={(e) => setReplacement(e.target.value)}
placeholder={t('search.replace')}
/>
<div className="search-replace-actions">
<button
type="button"
className="btn"
data-testid="search-replace-preview"
onClick={() => void previewReplace()}
>
{t('search.replacePreview')}
</button>
<button
type="button"
className="btn btn-primary"
data-testid="search-replace-execute"
onClick={() => void executeReplace()}
>
{t('search.replaceExecute')}
</button>
</div>
{replacePreviews.length > 0 && (
<div className="search-replace-previews" data-testid="search-replace-previews">
{replacePreviews.map((hit) => (
<div key={`${hit.kind}-${hit.id}`} className="search-result">
<div className="sr-type">
{hit.kind} · {hit.title}
</div>
<div>{hit.snippet}</div>
</div>
))}
</div>
)}
</details>
<div className="modal-footer">
<button type="button" className="btn" onClick={() => setOpen(false)}>
{t('dialog.cancel')}
@@ -0,0 +1,142 @@
import { useState } from 'react'
import { useTranslation, Trans } from 'react-i18next'
import type { AiBackend, AiConfig } from '@shared/types'
import { useSettingsStore } from '@renderer/stores/useSettingsStore'
import { useAppStore } from '@renderer/stores/useAppStore'
import { ipcCall } from '@renderer/lib/ipc-client'
const BACKENDS: { id: AiBackend; labelKey: string }[] = [
{ id: 'lmstudio', labelKey: 'ai.settings.backend.lmstudio' },
{ id: 'openai', labelKey: 'ai.settings.backend.openai' },
{ id: 'anthropic', labelKey: 'ai.settings.backend.anthropic' }
]
export function AiSettingsPage(): React.JSX.Element {
const { t } = useTranslation()
const showToast = useAppStore((s) => s.showToast)
const settings = useSettingsStore()
const aiConfig = settings.aiConfig
const [testing, setTesting] = useState(false)
const patchAiConfig = (patch: Partial<AiConfig>): void => {
void settings.update({ aiConfig: { ...aiConfig, ...patch } })
}
const handleTest = async (): Promise<void> => {
setTesting(true)
try {
await ipcCall(() => window.electronAPI.ai.testConnection(aiConfig))
showToast(t('ai.settings.testSuccess'))
} catch (err) {
showToast(err instanceof Error ? err.message : String(err))
} finally {
setTesting(false)
}
}
const cloudBackend = aiConfig.backend !== 'lmstudio'
return (
<div data-testid="ai-settings-page">
<div className="setting-item">
<span>{t('ai.settings.backend')}</span>
<select
data-testid="ai-settings-backend"
className="form-control"
value={aiConfig.backend}
onChange={(e) => patchAiConfig({ backend: e.target.value as AiBackend })}
>
{BACKENDS.map((b) => (
<option key={b.id} value={b.id}>
{t(b.labelKey)}
</option>
))}
</select>
</div>
<div className="setting-item">
<span>{t('ai.settings.baseUrl')}</span>
<input
data-testid="ai-settings-base-url"
className="form-control"
value={aiConfig.baseUrl}
onChange={(e) => patchAiConfig({ baseUrl: e.target.value })}
/>
</div>
<div className="setting-item">
<span>{t('ai.settings.model')}</span>
<input
data-testid="ai-settings-model"
className="form-control"
value={aiConfig.model}
onChange={(e) => patchAiConfig({ model: e.target.value })}
/>
</div>
{cloudBackend && (
<div className="setting-item">
<span>{t('ai.settings.apiKey')}</span>
<input
data-testid="ai-settings-api-key"
type="password"
className="form-control"
value={aiConfig.apiKey ?? ''}
onChange={(e) => patchAiConfig({ apiKey: e.target.value })}
autoComplete="off"
/>
</div>
)}
<div className="setting-item">
<span>{t('ai.settings.timeout')}</span>
<input
data-testid="ai-settings-timeout"
type="number"
min={5}
max={600}
className="form-control form-control--narrow"
value={Math.round(aiConfig.timeoutMs / 1000)}
onChange={(e) => {
const seconds = Number(e.target.value)
if (!Number.isFinite(seconds)) return
patchAiConfig({ timeoutMs: Math.max(5, seconds) * 1000 })
}}
/>
</div>
{cloudBackend && (
<p className="ai-license-notice" data-testid="ai-settings-license">
<Trans
i18nKey="ai.settings.licenseNotice"
components={{
openai: (
<a
href="https://openai.com/policies/terms-of-use"
target="_blank"
rel="noreferrer"
/>
),
anthropic: (
<a href="https://www.anthropic.com/legal/terms" target="_blank" rel="noreferrer" />
)
}}
/>
</p>
)}
<div className="setting-item">
<span />
<button
type="button"
className="btn"
data-testid="ai-settings-test"
disabled={testing}
onClick={() => void handleTest()}
>
{testing ? t('ai.settings.testing') : t('ai.settings.testConnection')}
</button>
</div>
</div>
)
}
@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next'
import type { ThemeId, Language } from '@shared/types'
import { useSettingsStore } from '@renderer/stores/useSettingsStore'
import { ShortcutEditor } from '@renderer/components/settings/ShortcutEditor'
import { AiSettingsPage } from '@renderer/components/settings/AiSettingsPage'
const THEMES: { id: ThemeId; key: string }[] = [
{ id: 'default', key: 'theme.default' },
@@ -17,7 +18,7 @@ const THEMES: { id: ThemeId; key: string }[] = [
export function SettingsPage(): React.JSX.Element {
const { t } = useTranslation()
const settings = useSettingsStore()
const [section, setSection] = useState<'general' | 'shortcuts' | 'about'>('general')
const [section, setSection] = useState<'general' | 'ai' | 'shortcuts' | 'about'>('general')
return (
<div id="settings-page">
@@ -30,6 +31,15 @@ export function SettingsPage(): React.JSX.Element {
>
{t('settings.general')}
</div>
<div
className={`settings-nav-item ${section === 'ai' ? 'active' : ''}`}
onClick={() => setSection('ai')}
role="button"
tabIndex={0}
data-testid="settings-nav-ai"
>
{t('settings.ai')}
</div>
<div
className={`settings-nav-item ${section === 'shortcuts' ? 'active' : ''}`}
onClick={() => setSection('shortcuts')}
@@ -89,10 +99,11 @@ export function SettingsPage(): React.JSX.Element {
</div>
</>
)}
{section === 'ai' && <AiSettingsPage />}
{section === 'shortcuts' && <ShortcutEditor />}
{section === 'about' && (
<p style={{ color: 'var(--text-secondary)', lineHeight: 1.8 }}>
{t('app.name')} v0.1.0
{t('app.name')} v0.3.0
<br />
{t('app.tagline')}
</p>
+15 -1
View File
@@ -10,15 +10,29 @@ export const SettingMention = Mark.create({
}
},
parseHTML() {
return [{ tag: 'span[data-setting-mention]' }]
return [
{
tag: 'span[data-setting-mention]',
getAttrs: (el) => {
if (!(el instanceof HTMLElement)) return false
return {
id: el.getAttribute('data-id'),
label: el.getAttribute('data-label') ?? el.textContent?.replace(/^@/, '') ?? ''
}
}
}
]
},
renderHTML({ HTMLAttributes }) {
const label = HTMLAttributes.label ?? ''
const id = HTMLAttributes.id ?? ''
return [
'span',
mergeAttributes(HTMLAttributes, {
'data-setting-mention': 'true',
class: 'setting-mention',
'data-id': id,
'data-label': label,
'data-testid': 'setting-mention'
}),
`@${label}`
+16
View File
@@ -0,0 +1,16 @@
import type { TFunction } from 'i18next'
import type { ContextPreviewItem } from '@shared/types'
export function formatContextSummary(preview: ContextPreviewItem[], t: TFunction): string {
if (preview.length === 0) return t('ai.contextEmpty')
const counts = { chapter: 0, outline: 0, setting: 0, inspiration: 0 }
for (const item of preview) counts[item.kind]++
return t('ai.contextSummary', {
chapters: counts.chapter,
outlines: counts.outline,
settings: counts.setting,
inspirations: counts.inspiration
})
}
+22
View File
@@ -0,0 +1,22 @@
import type { TFunction } from 'i18next'
const SLASH_KEYS: Record<string, string> = {
'/续写': 'ai.slash.continue',
'/扩写': 'ai.slash.expand',
'/润色': 'ai.slash.polish',
'/总结': 'ai.slash.summarize',
'/纠错': 'ai.slash.proofread'
}
export function resolveSlashCommand(input: string, t: TFunction): string | null {
const token = input.trim().split(/\s+/)[0]
const key = SLASH_KEYS[token]
if (!key) return null
return t(key)
}
export function applySlashCommand(input: string, t: TFunction): { display: string; prompt: string } {
const display = input.trim()
const resolved = resolveSlashCommand(display, t)
return { display, prompt: resolved ?? display }
}
+34
View File
@@ -3,8 +3,16 @@ export interface LandmarkInsert {
landmarkType?: string
}
type EditorInsertContext = {
bookId: string
chapterId: string
getHtml: () => string
}
let insertLandmarkFn: ((payload: LandmarkInsert) => void) | null = null
let highlightTokenFn: ((token: string) => void) | null = null
let editorInsertContext: EditorInsertContext | null = null
let insertTextFn: ((text: string) => void) | null = null
export function registerInsertLandmark(fn: (payload: LandmarkInsert) => void): void {
insertLandmarkFn = fn
@@ -14,6 +22,14 @@ export function registerHighlightToken(fn: (token: string) => void): void {
highlightTokenFn = fn
}
export function registerEditorInsert(
context: EditorInsertContext | null,
insert: (text: string) => void
): void {
editorInsertContext = context
insertTextFn = insert
}
export function insertLandmarkInEditor(payload: LandmarkInsert): void {
insertLandmarkFn?.(payload)
}
@@ -21,3 +37,21 @@ export function insertLandmarkInEditor(payload: LandmarkInsert): void {
export function highlightTokenInEditor(token: string): void {
highlightTokenFn?.(token)
}
export async function insertToEditor(
text: string,
snapshotLabel: string,
createSnapshot: (
bookId: string,
chapterId: string,
html: string,
label: string
) => Promise<void>
): Promise<void> {
if (!editorInsertContext || !insertTextFn) {
throw new Error('No chapter editor active')
}
const { bookId, chapterId, getHtml } = editorInsertContext
await createSnapshot(bookId, chapterId, getHtml(), snapshotLabel)
insertTextFn(text)
}
+178
View File
@@ -0,0 +1,178 @@
import { create } from 'zustand'
import type { AiContextBinding, AiMessage, AiSession, AiStreamChunkEvent, AiWritingMode, ContextPreviewItem } from '@shared/types'
import { ipcCall } from '@renderer/lib/ipc-client'
import { useBookStore } from '@renderer/stores/useBookStore'
import { formatContextSummary } from '@renderer/lib/ai-context-summary'
import i18n from '@renderer/i18n'
interface AiStore {
sessions: AiSession[]
activeSessionId: string | null
messages: AiMessage[]
streamingMessageId: string | null
streamingBuffer: string
sending: boolean
online: boolean
writingMode: AiWritingMode
contextPreview: ContextPreviewItem[]
contextSummary: string
listenersAttached: boolean
mount: () => void
unmount: () => void
loadSessions: (bookId: string) => Promise<void>
selectSession: (sessionId: string) => Promise<void>
createSession: (bookId: string) => Promise<void>
sendMessage: (content: string, prompt?: string) => Promise<void>
refreshContextPreview: () => Promise<void>
appendStreamChunk: (payload: AiStreamChunkEvent) => void
setWritingMode: (mode: AiWritingMode) => void
}
let unsubStream: (() => void) | null = null
let unsubNetwork: (() => void) | null = null
export const useAiStore = create<AiStore>((set, get) => ({
sessions: [],
activeSessionId: null,
messages: [],
streamingMessageId: null,
streamingBuffer: '',
sending: false,
online: true,
writingMode: 'chat',
contextPreview: [],
contextSummary: i18n.t('ai.contextEmpty'),
listenersAttached: false,
mount: () => {
if (get().listenersAttached) return
unsubStream = window.electronAPI.onAiStreamChunk((payload) => {
get().appendStreamChunk(payload)
})
unsubNetwork = window.electronAPI.onAiNetworkStatus((payload) => {
set({ online: payload.online })
})
set({ listenersAttached: true })
},
unmount: () => {
unsubStream?.()
unsubNetwork?.()
unsubStream = null
unsubNetwork = null
set({ listenersAttached: false })
},
loadSessions: async (bookId) => {
const sessions = await ipcCall(() => window.electronAPI.ai.sessionList(bookId))
const activeSessionId = get().activeSessionId
const stillActive = activeSessionId && sessions.some((s) => s.id === activeSessionId)
set({ sessions })
if (stillActive && activeSessionId) {
await get().selectSession(activeSessionId)
} else if (sessions.length > 0) {
await get().selectSession(sessions[0].id)
} else {
set({
activeSessionId: null,
messages: [],
contextPreview: [],
contextSummary: i18n.t('ai.contextEmpty')
})
}
},
selectSession: async (sessionId) => {
const bookId = useBookStore.getState().currentBookId
if (!bookId) return
const messages = await ipcCall(() => window.electronAPI.ai.messageList(bookId, sessionId))
set({
activeSessionId: sessionId,
messages,
streamingMessageId: null,
streamingBuffer: ''
})
await get().refreshContextPreview()
},
createSession: async (bookId) => {
const session = await ipcCall(() => window.electronAPI.ai.sessionCreate(bookId))
set((s) => ({
sessions: [session, ...s.sessions],
activeSessionId: session.id,
messages: [],
streamingMessageId: null,
streamingBuffer: '',
contextPreview: [],
contextSummary: i18n.t('ai.contextEmpty')
}))
},
refreshContextPreview: async () => {
const bookId = useBookStore.getState().currentBookId
const sessionId = get().activeSessionId
if (!bookId || !sessionId) {
set({ contextPreview: [], contextSummary: i18n.t('ai.contextEmpty') })
return
}
const session = get().sessions.find((s) => s.id === sessionId)
if (!session) return
const result = await ipcCall(() =>
window.electronAPI.ai.buildContext(bookId, session.context)
)
set({
contextPreview: result.preview,
contextSummary: formatContextSummary(result.preview, i18n.t)
})
},
sendMessage: async (content, prompt) => {
const trimmed = content.trim()
if (!trimmed || get().sending) return
const bookId = useBookStore.getState().currentBookId
if (!bookId) return
let sessionId = get().activeSessionId
if (!sessionId) {
await get().createSession(bookId)
sessionId = get().activeSessionId
}
if (!sessionId) return
const optimisticUser: AiMessage = {
id: `optimistic-${Date.now()}`,
sessionId,
role: 'user',
content: trimmed,
createdAt: new Date().toISOString()
}
set({
sending: true,
streamingMessageId: null,
streamingBuffer: '',
messages: [...get().messages, optimisticUser]
})
try {
await ipcCall(() => window.electronAPI.ai.chat(bookId, sessionId, trimmed, prompt))
const messages = await ipcCall(() => window.electronAPI.ai.messageList(bookId, sessionId))
const sessions = await ipcCall(() => window.electronAPI.ai.sessionList(bookId))
set({ messages, sessions, streamingMessageId: null, streamingBuffer: '' })
} finally {
set({ sending: false })
}
},
appendStreamChunk: (payload) => {
const { messageId, delta, done } = payload
if (done) return
set((s) => ({
streamingMessageId: messageId,
streamingBuffer:
s.streamingMessageId === messageId ? s.streamingBuffer + delta : s.streamingBuffer + delta
}))
},
setWritingMode: (mode) => set({ writingMode: mode })
}))
+4
View File
@@ -8,12 +8,14 @@ interface AppState {
rightPanel: 'ai' | 'knowledge' | 'wordfreq' | 'book-settings'
versionModalOpen: boolean
landmarksModalOpen: boolean
inspirationModalOpen: boolean
toast: string | null
setView: (view: AppView) => void
setSidebarPanel: (panel: AppState['sidebarPanel']) => void
setRightPanel: (panel: AppState['rightPanel']) => void
setVersionModalOpen: (open: boolean) => void
setLandmarksModalOpen: (open: boolean) => void
setInspirationModalOpen: (open: boolean) => void
showToast: (message: string) => void
clearToast: () => void
}
@@ -24,12 +26,14 @@ export const useAppStore = create<AppState>((set) => ({
rightPanel: 'ai',
versionModalOpen: false,
landmarksModalOpen: false,
inspirationModalOpen: false,
toast: null,
setView: (view) => set({ view }),
setSidebarPanel: (sidebarPanel) => set({ sidebarPanel }),
setRightPanel: (rightPanel) => set({ rightPanel }),
setVersionModalOpen: (versionModalOpen) => set({ versionModalOpen }),
setLandmarksModalOpen: (landmarksModalOpen) => set({ landmarksModalOpen }),
setInspirationModalOpen: (inspirationModalOpen) => set({ inspirationModalOpen }),
showToast: (toast) => {
set({ toast })
setTimeout(() => set({ toast: null }), 2500)
+7
View File
@@ -4,6 +4,7 @@ import type { EditTarget } from '@shared/types'
interface EditState {
target: EditTarget | null
switchTarget: (next: EditTarget) => Promise<void>
reloadTarget: () => void
setTarget: (next: EditTarget | null) => void
}
@@ -23,5 +24,11 @@ export const useEditStore = create<EditState>((set) => ({
await flushEditSession()
set({ target: next })
},
reloadTarget: () => {
const next = useEditStore.getState().target
if (!next) return
set({ target: null })
queueMicrotask(() => set({ target: next }))
},
setTarget: (next) => set({ target: next })
}))
+8 -1
View File
@@ -4,10 +4,12 @@ type ReferenceTab = 'setting' | 'outline' | 'inspiration'
interface ReferenceState {
open: boolean
pinned: boolean
tab: ReferenceTab
query: string
pinnedIds: string[]
setOpen: (open: boolean) => void
setPinned: (pinned: boolean) => void
setTab: (tab: ReferenceTab) => void
setQuery: (query: string) => void
togglePin: (id: string) => void
@@ -15,10 +17,15 @@ interface ReferenceState {
export const useReferenceStore = create<ReferenceState>((set, get) => ({
open: false,
pinned: false,
tab: 'setting',
query: '',
pinnedIds: [],
setOpen: (open) => set({ open }),
setOpen: (open) => {
if (!open && get().pinned) return
set({ open })
},
setPinned: (pinned) => set({ pinned }),
setTab: (tab) => set({ tab }),
setQuery: (query) => set({ query }),
togglePin: (id) => {
+3
View File
@@ -1,5 +1,6 @@
import { create } from 'zustand'
import type { ThemeId, Language, GlobalSettings, ActionId } from '@shared/types'
import { DEFAULT_AI_CONFIG } from '@shared/types'
import { ipcCall } from '@renderer/lib/ipc-client'
import { applyTheme } from '@renderer/lib/theme'
import i18n from '@renderer/i18n'
@@ -17,6 +18,8 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
language: 'zh-CN',
dailyWordGoal: 0,
shortcuts: {} as Record<ActionId, string>,
aiConfig: { ...DEFAULT_AI_CONFIG },
namingIgnoreWords: [] as string[],
loaded: false,
load: async () => {
const data = await ipcCall(() => window.electronAPI.settings.get())
+390
View File
@@ -354,6 +354,10 @@
color: var(--text-primary);
}
.chapter-item.drag-over {
box-shadow: inset 0 2px 0 var(--accent);
}
.ch-badge {
margin-left: auto;
font-size: 10px;
@@ -500,6 +504,314 @@
flex-direction: column;
}
.panel-content.active {
display: flex;
flex-direction: column;
}
.panel-content.ai-panel-wrap {
padding: 0;
overflow: hidden;
}
.ai-panel {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.ai-panel-empty {
padding: 12px;
font-size: 12px;
color: var(--text-muted);
}
.ai-session-bar {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 10px;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.ai-session-bar select {
flex: 1;
font-size: 11px;
padding: 4px 8px;
background: var(--bg-tertiary);
color: var(--text-primary);
border: 1px solid var(--border);
border-radius: 4px;
}
.ai-mode-tabs {
display: flex;
gap: 2px;
padding: 6px 8px;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.ai-mode-tab {
flex: 1;
padding: 5px 4px;
font-size: 10px;
text-align: center;
border-radius: 4px;
cursor: pointer;
color: var(--text-muted);
background: transparent;
border: none;
}
.ai-mode-tab.active {
background: var(--accent);
color: #fff;
font-weight: 600;
}
.context-preview {
padding: 6px 10px;
font-size: 10px;
color: var(--text-muted);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
text-align: left;
background: transparent;
border-left: none;
border-right: none;
border-top: none;
cursor: pointer;
width: 100%;
}
.context-preview:hover {
color: var(--accent-light);
}
.context-editor-modal {
max-width: 520px;
width: 90vw;
}
.context-editor-tabs {
display: flex;
gap: 4px;
margin-bottom: 10px;
}
.context-editor-tab {
flex: 1;
font-size: 11px;
padding: 6px 4px;
border: 1px solid var(--border);
background: var(--bg-tertiary);
color: var(--text-secondary);
border-radius: 4px;
cursor: pointer;
}
.context-editor-tab.active {
background: var(--accent);
color: #fff;
border-color: var(--accent);
}
.context-editor-list {
max-height: 280px;
overflow-y: auto;
margin-bottom: 12px;
}
.context-editor-item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 4px;
font-size: 12px;
cursor: pointer;
}
.offline-banner {
display: none;
padding: 6px 12px;
background: var(--orange, #f59e0b);
color: #000;
font-size: 11px;
text-align: center;
font-weight: 600;
flex-shrink: 0;
}
.offline-banner.show {
display: block;
}
.chat-messages {
flex: 1;
overflow-y: auto;
padding: 12px;
display: flex;
flex-direction: column;
gap: 10px;
min-height: 0;
}
.chat-msg {
padding: 10px 12px;
border-radius: var(--radius-sm);
font-size: 12px;
line-height: 1.6;
max-width: 92%;
white-space: pre-wrap;
word-break: break-word;
}
.chat-msg.user {
align-self: flex-end;
background: var(--accent-dark, var(--accent));
color: #fff;
}
.chat-msg.ai {
align-self: flex-start;
background: var(--bg-tertiary);
color: var(--text-primary);
border: 1px solid var(--border);
}
.quick-cmds {
display: flex;
flex-wrap: wrap;
gap: 4px;
padding: 6px 8px;
border-top: 1px solid var(--border);
flex-shrink: 0;
}
.quick-cmd {
font-size: 10px;
padding: 3px 8px;
border-radius: 4px;
background: var(--bg-tertiary);
border: 1px solid var(--border);
color: var(--text-secondary);
cursor: pointer;
}
.quick-cmd:hover:not(:disabled) {
background: var(--bg-hover);
color: var(--text-primary);
}
.quick-cmd:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.chat-msg {
position: relative;
}
.chat-insert-btn {
margin-top: 6px;
font-size: 10px;
padding: 2px 8px;
border-radius: 4px;
border: 1px solid var(--border);
background: var(--bg-surface);
color: var(--text-secondary);
cursor: pointer;
}
.chat-insert-btn:hover {
color: var(--text-primary);
border-color: var(--accent);
}
.knowledge-toolbar {
padding: 10px;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.knowledge-panel {
display: flex;
flex-direction: column;
height: 100%;
}
.naming-modal-body {
max-height: 360px;
overflow-y: auto;
padding: 8px 12px;
}
.naming-row {
padding: 10px;
margin-bottom: 8px;
border-radius: var(--radius-sm);
background: var(--bg-tertiary);
border: 1px solid var(--border);
}
.naming-terms {
font-size: 13px;
margin-bottom: 4px;
}
.naming-meta {
font-size: 11px;
color: var(--text-secondary);
margin-bottom: 8px;
}
.naming-actions {
display: flex;
gap: 6px;
}
.naming-empty {
font-size: 12px;
color: var(--text-secondary);
padding: 12px 4px;
}
.chat-input-area {
display: flex;
gap: 6px;
padding: 8px;
border-top: 1px solid var(--border);
flex-shrink: 0;
}
.chat-input-area input {
flex: 1;
padding: 8px 10px;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: var(--bg-tertiary);
color: var(--text-primary);
font-size: 12px;
}
.chat-input-area button {
padding: 8px 14px;
border-radius: var(--radius-sm);
border: none;
background: var(--accent);
color: #fff;
font-size: 12px;
cursor: pointer;
}
.chat-input-area button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.placeholder-box {
color: var(--text-muted);
font-size: 12px;
@@ -556,6 +868,21 @@
font-size: 13px;
}
.ai-license-notice {
margin: 4px 0 12px;
padding: 10px 14px;
font-size: 12px;
line-height: 1.6;
color: var(--text-secondary);
background: color-mix(in srgb, var(--accent) 8%, var(--bg-tertiary));
border-radius: var(--radius-sm);
border-left: 3px solid var(--accent);
}
.ai-license-notice a {
color: var(--accent);
}
.shortcut-key {
min-width: 140px;
padding: 6px 12px;
@@ -734,6 +1061,44 @@
font-weight: 600;
}
.ref-header-actions {
display: flex;
gap: 4px;
align-items: center;
}
.ref-pin-btn.active {
color: var(--accent-light);
}
.inspiration-modal {
max-width: 480px;
}
.inspiration-capture {
display: flex;
flex-direction: column;
gap: 8px;
}
.inspiration-textarea {
width: 100%;
min-height: 120px;
padding: 10px;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: var(--bg-tertiary);
color: var(--text-primary);
font-size: 13px;
line-height: 1.6;
resize: vertical;
}
.form-label {
font-size: 11px;
color: var(--text-secondary);
}
.ref-tabs {
display: flex;
gap: 4px;
@@ -862,6 +1227,31 @@
background: var(--bg-hover);
}
.search-replace-panel {
margin-top: 8px;
font-size: 12px;
}
.search-replace-panel summary {
cursor: pointer;
color: var(--text-secondary);
margin-bottom: 8px;
}
.search-replace-actions {
display: flex;
gap: 8px;
margin-top: 8px;
}
.search-replace-previews {
margin-top: 8px;
max-height: 160px;
overflow-y: auto;
border: 1px solid var(--border);
border-radius: 4px;
}
.sr-type {
font-size: 10px;
color: var(--accent-light);