feat: ship v0.6.0 with cockpit, knowledge base, and chapter bridge

Deliver P5 writer workflow: writing cockpit, manual knowledge CRUD with review, chapter bridge with optional AI, publish status, and stock buffer reminders.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-06 23:03:11 +08:00
parent 78f046890d
commit b33d2e7b34
45 changed files with 2389 additions and 29 deletions
+11
View File
@@ -1,6 +1,7 @@
import { create } from 'zustand'
import type { BookMeta, Volume, Chapter, OutlineItem, SettingEntry, InspirationEntry } from '@shared/types'
import { ipcCall } from '@renderer/lib/ipc-client'
import { useCockpitStore } from '@renderer/stores/useCockpitStore'
interface BookState {
books: BookMeta[]
@@ -62,6 +63,16 @@ export const useBookStore = create<BookState>((set, get) => ({
activeVolumeId: result.chapters.find((c) => c.id === lastChapter)?.volumeId ?? firstVolume,
selectedChapterId: lastChapter
})
const cockpit = useCockpitStore.getState()
const shouldShow = await cockpit.shouldShowOnOpen(bookId)
if (shouldShow) {
await cockpit.markSeen(bookId)
cockpit.openModal()
void cockpit.loadSummary(
bookId,
result.chapters.find((c) => c.id === lastChapter)?.volumeId ?? firstVolume ?? undefined
)
}
},
setSelectedChapter: (chapterId) => {
const ch = get().chapters.find((c) => c.id === chapterId)
+42
View File
@@ -0,0 +1,42 @@
import { create } from 'zustand'
import type { CockpitSummary } from '@shared/types'
import { ipcCall } from '@renderer/lib/ipc-client'
interface CockpitState {
open: boolean
summary: CockpitSummary | null
loading: boolean
bridgeChapterId: string | null
loadSummary: (bookId: string, volumeId?: string) => Promise<void>
openModal: () => void
close: () => void
markSeen: (bookId: string) => Promise<void>
shouldShowOnOpen: (bookId: string) => Promise<boolean>
openBridgeForChapter: (chapterId: string) => void
clearBridgeChapter: () => void
}
export const useCockpitStore = create<CockpitState>((set) => ({
open: false,
summary: null,
loading: false,
bridgeChapterId: null,
loadSummary: async (bookId, volumeId) => {
set({ loading: true })
try {
const summary = await ipcCall(() => window.electronAPI.cockpit.getSummary(bookId, volumeId))
set({ summary })
} finally {
set({ loading: false })
}
},
openModal: () => set({ open: true }),
close: () => set({ open: false }),
markSeen: async (bookId) => {
await ipcCall(() => window.electronAPI.cockpit.markSeen(bookId))
},
shouldShowOnOpen: async (bookId) =>
ipcCall(() => window.electronAPI.cockpit.shouldShowOnOpen(bookId)),
openBridgeForChapter: (chapterId) => set({ bridgeChapterId: chapterId, open: false }),
clearBridgeChapter: () => set({ bridgeChapterId: null })
}))
+97
View File
@@ -0,0 +1,97 @@
import { create } from 'zustand'
import type {
CreateKnowledgeInput,
KnowledgeEntry,
KnowledgeStatus,
KnowledgeType
} from '@shared/types'
import { ipcCall } from '@renderer/lib/ipc-client'
import { useAppStore } from '@renderer/stores/useAppStore'
export type KnowledgeTab = 'pending' | 'all' | 'foreshadow'
interface KnowledgeState {
entries: KnowledgeEntry[]
stats: { buried: number; resolved: number; forgotten: number }
tab: KnowledgeTab
forgottenOnly: boolean
editorOpen: boolean
editingId: string | null
load: (bookId: string) => Promise<void>
setTab: (tab: KnowledgeTab) => void
openForgottenFilter: () => void
openEditor: (id?: string | null) => void
closeEditor: () => void
create: (bookId: string, input: CreateKnowledgeInput) => Promise<KnowledgeEntry>
update: (
bookId: string,
id: string,
patch: Partial<CreateKnowledgeInput & { status: KnowledgeStatus }>
) => Promise<KnowledgeEntry>
deleteEntry: (bookId: string, id: string) => Promise<void>
approve: (bookId: string, id: string) => Promise<void>
reject: (bookId: string, id: string) => Promise<void>
batchApprove: (bookId: string, ids: string[]) => Promise<void>
filteredEntries: () => KnowledgeEntry[]
}
export const useKnowledgeStore = create<KnowledgeState>((set, get) => ({
entries: [],
stats: { buried: 0, resolved: 0, forgotten: 0 },
tab: 'all',
forgottenOnly: false,
editorOpen: false,
editingId: null,
load: async (bookId) => {
const [entries, stats] = await Promise.all([
ipcCall(() => window.electronAPI.knowledge.list(bookId)),
ipcCall(() => window.electronAPI.knowledge.stats(bookId))
])
set({ entries, stats })
},
setTab: (tab) => set({ tab, forgottenOnly: tab === 'foreshadow' ? get().forgottenOnly : false }),
openForgottenFilter: () => {
useAppStore.getState().setRightPanel('knowledge')
set({ tab: 'foreshadow', forgottenOnly: true })
},
openEditor: (id = null) => set({ editorOpen: true, editingId: id }),
closeEditor: () => set({ editorOpen: false, editingId: null }),
create: async (bookId, input) => {
const entry = await ipcCall(() => window.electronAPI.knowledge.create(bookId, input))
await get().load(bookId)
return entry
},
update: async (bookId, id, patch) => {
const entry = await ipcCall(() => window.electronAPI.knowledge.update(bookId, id, patch))
await get().load(bookId)
return entry
},
deleteEntry: async (bookId, id) => {
await ipcCall(() => window.electronAPI.knowledge.delete(bookId, id))
await get().load(bookId)
},
approve: async (bookId, id) => {
await ipcCall(() => window.electronAPI.knowledge.approve(bookId, id))
await get().load(bookId)
},
reject: async (bookId, id) => {
await ipcCall(() => window.electronAPI.knowledge.reject(bookId, id))
await get().load(bookId)
},
batchApprove: async (bookId, ids) => {
await ipcCall(() => window.electronAPI.knowledge.batchApprove(bookId, ids))
await get().load(bookId)
},
filteredEntries: () => {
const { entries, tab, forgottenOnly } = get()
let list = entries
if (tab === 'pending') list = list.filter((e) => e.status === 'pending')
else if (tab === 'foreshadow') {
list = list.filter((e) => e.type === 'foreshadow')
if (forgottenOnly) {
// forgotten filter applied client-side via reload with filter in panel
}
}
return list
}
}))
+2
View File
@@ -17,6 +17,8 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
theme: 'default',
language: 'zh-CN',
dailyWordGoal: 0,
updateSchedule: 'none',
stockBufferThreshold: 3,
shortcuts: {} as Record<ActionId, string>,
aiConfig: { ...DEFAULT_AI_CONFIG },
namingIgnoreWords: [] as string[],