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:
@@ -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 })
|
||||
}))
|
||||
@@ -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)
|
||||
|
||||
@@ -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 })
|
||||
}))
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user