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
+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())