Files
borges/src/renderer/store/borgesStore.ts
2026-06-08 17:15:43 +10:00

263 lines
9.3 KiB
TypeScript

import { create } from 'zustand'
import type { StoryFile, Market, Submission, ChatMessage, AnalysisMode, TextAnnotation, RevisionMeta } from '../types/borges'
interface BorgesState {
// Stories
stories: StoryFile[]
setStories: (stories: StoryFile[]) => void
moveStory: (fromIdx: number, toIdx: number) => void
// Active story
activeStoryPath: string | null
activeStoryId: string | null
activeStoryContent: string
isDirty: boolean
setActiveStory: (path: string, id: string, content: string) => void
setContent: (content: string) => void
markSaved: () => void
clearActiveStory: () => void
// Markets & Submissions
markets: Market[]
setMarkets: (markets: Market[]) => void
submissions: Submission[]
setSubmissions: (submissions: Submission[]) => void
selectedMarketId: string | null
setSelectedMarketId: (id: string | null) => void
// AI chat
chatHistory: ChatMessage[]
chatHistoryByStory: Record<string, ChatMessage[]>
isAILoading: boolean
aiError: string | null
addUserMessage: (text: string) => void
startAssistantMessage: () => void
appendToLastMessage: (chunk: string) => void
setAILoading: (v: boolean) => void
setAIError: (e: string | null) => void
newChat: () => void
// Analysis
analysisMode: AnalysisMode
setAnalysisMode: (mode: AnalysisMode) => void
annotations: TextAnnotation[]
setAnnotations: (anns: TextAnnotation[]) => void
removeAnnotation: (id: string) => void
clearAnnotations: () => void
useCollectionContext: boolean
toggleCollectionContext: () => void
useMarketBrief: boolean
toggleMarketBrief: () => void
// Revisions
revisionPanelOpen: boolean
toggleRevisionPanel: () => void
revisions: RevisionMeta[]
setRevisions: (revisions: RevisionMeta[]) => void
// UI layout
sidebarOpen: boolean
setSidebarOpen: (v: boolean) => void
submissionPanelOpen: boolean
setSubmissionPanelOpen: (v: boolean) => void
chatOpen: boolean
setChatOpen: (v: boolean) => void
focusMode: boolean
toggleFocusMode: () => void
submissionPanelTab: 'story' | 'markets'
setSubmissionPanelTab: (tab: 'story' | 'markets') => void
// Theme & font
theme: 'dark' | 'light'
toggleTheme: () => void
fontSize: number
setFontSize: (size: number) => void
initPrefs: () => Promise<void>
// Session
loadSession: () => Promise<void>
}
let _sessionTimer: ReturnType<typeof setTimeout> | null = null
function scheduleSave(getData: () => Record<string, unknown>): void {
if (_sessionTimer) clearTimeout(_sessionTimer)
_sessionTimer = setTimeout(() => {
const api = (window as Window).api
api?.writeSession(getData()).catch(console.error)
}, 1500)
}
let _autoSaveTimer: ReturnType<typeof setTimeout> | null = null
function scheduleAutoSave(getData: () => { path: string; content: string } | null): void {
if (_autoSaveTimer) clearTimeout(_autoSaveTimer)
_autoSaveTimer = setTimeout(() => {
const d = getData()
if (!d) return
const api = (window as Window).api
api?.writeStory(d.path, d.content).catch(console.error)
}, 10_000)
}
export const useBorgesStore = create<BorgesState>((set, get) => ({
stories: [],
setStories: (stories) => set({ stories }),
moveStory: (fromIdx, toIdx) => {
set((s) => {
const stories = [...s.stories]
const [moved] = stories.splice(fromIdx, 1)
stories.splice(toIdx, 0, moved)
const order = stories.map((s) => s.id)
window.api.saveOrder(order).catch(console.error)
return { stories }
})
},
activeStoryPath: null,
activeStoryId: null,
activeStoryContent: '',
isDirty: false,
setActiveStory: (path, id, content) => {
const s = get()
const history = s.chatHistoryByStory[id] ?? []
set({ activeStoryPath: path, activeStoryId: id, activeStoryContent: content, isDirty: false, chatHistory: history, annotations: [], analysisMode: 'none' })
scheduleSave(() => ({ activeStoryPath: get().activeStoryPath, chatHistoryByStory: get().chatHistoryByStory }))
},
setContent: (content) => {
set({ activeStoryContent: content, isDirty: true })
scheduleAutoSave(() => {
const { activeStoryPath, activeStoryContent } = get()
return activeStoryPath ? { path: activeStoryPath, content: activeStoryContent } : null
})
},
markSaved: () => set({ isDirty: false }),
clearActiveStory: () => set({ activeStoryPath: null, activeStoryId: null, activeStoryContent: '', isDirty: false, chatHistory: [], annotations: [], analysisMode: 'none' }),
markets: [],
setMarkets: (markets) => set({ markets }),
submissions: [],
setSubmissions: (submissions) => set({ submissions }),
selectedMarketId: null,
setSelectedMarketId: (id) => set({ selectedMarketId: id }),
chatHistory: [],
chatHistoryByStory: {},
isAILoading: false,
aiError: null,
addUserMessage: (text) => {
const msg: ChatMessage = { id: `user-${Date.now()}`, role: 'user', content: text }
set((s) => {
const history = [...s.chatHistory, msg]
const chatHistoryByStory = s.activeStoryId
? { ...s.chatHistoryByStory, [s.activeStoryId]: history }
: s.chatHistoryByStory
return { chatHistory: history, chatHistoryByStory }
})
scheduleSave(() => ({ activeStoryPath: get().activeStoryPath, chatHistoryByStory: get().chatHistoryByStory }))
},
startAssistantMessage: () => {
const msg: ChatMessage = { id: `asst-${Date.now()}`, role: 'assistant', content: '' }
set((s) => ({ chatHistory: [...s.chatHistory, msg] }))
},
appendToLastMessage: (chunk) => {
set((s) => {
const history = [...s.chatHistory]
const last = history[history.length - 1]
if (last?.role === 'assistant') history[history.length - 1] = { ...last, content: last.content + chunk }
return { chatHistory: history }
})
},
setAILoading: (isAILoading) => {
if (!isAILoading) {
const s = get()
if (s.activeStoryId) {
const chatHistoryByStory = { ...s.chatHistoryByStory, [s.activeStoryId]: s.chatHistory }
set({ isAILoading, chatHistoryByStory })
scheduleSave(() => ({ activeStoryPath: get().activeStoryPath, chatHistoryByStory: get().chatHistoryByStory }))
return
}
}
set({ isAILoading })
},
setAIError: (aiError) => set({ aiError }),
newChat: () => {
set((s) => {
const chatHistoryByStory = s.activeStoryId
? { ...s.chatHistoryByStory, [s.activeStoryId]: [] }
: s.chatHistoryByStory
return { chatHistory: [], chatHistoryByStory }
})
},
analysisMode: 'none',
setAnalysisMode: (analysisMode) => set({ analysisMode }),
annotations: [],
setAnnotations: (annotations) => set({ annotations }),
removeAnnotation: (id) => set((s) => ({ annotations: s.annotations.filter((a) => a.id !== id) })),
clearAnnotations: () => set({ annotations: [], analysisMode: 'none' }),
useCollectionContext: false,
toggleCollectionContext: () => set((s) => ({ useCollectionContext: !s.useCollectionContext })),
useMarketBrief: false,
toggleMarketBrief: () => set((s) => ({ useMarketBrief: !s.useMarketBrief })),
revisionPanelOpen: false,
toggleRevisionPanel: () => set((s) => ({ revisionPanelOpen: !s.revisionPanelOpen })),
revisions: [],
setRevisions: (revisions) => set({ revisions }),
sidebarOpen: localStorage.getItem('sidebarOpen') !== 'false',
setSidebarOpen: (v) => { localStorage.setItem('sidebarOpen', String(v)); set({ sidebarOpen: v }) },
submissionPanelOpen: localStorage.getItem('submissionPanelOpen') !== 'false',
setSubmissionPanelOpen: (v) => { localStorage.setItem('submissionPanelOpen', String(v)); set({ submissionPanelOpen: v }) },
chatOpen: localStorage.getItem('chatOpen') !== 'false',
setChatOpen: (v) => { localStorage.setItem('chatOpen', String(v)); set({ chatOpen: v }) },
focusMode: false,
toggleFocusMode: () => set((s) => ({ focusMode: !s.focusMode })),
submissionPanelTab: 'story',
setSubmissionPanelTab: (tab) => set({ submissionPanelTab: tab }),
theme: 'dark',
toggleTheme: () => {
set((s) => {
const next = s.theme === 'dark' ? 'light' : 'dark'
window.api.writeConfig({ theme: next })
document.documentElement.classList.toggle('light', next === 'light')
return { theme: next }
})
},
fontSize: 15,
setFontSize: (size) => {
const clamped = Math.max(11, Math.min(24, size))
window.api.writeConfig({ fontSize: clamped })
set({ fontSize: clamped })
},
initPrefs: async () => {
const cfg = await window.api.readConfig()
const fontSize = Math.max(11, Math.min(24, cfg.fontSize ?? 15))
const theme = cfg.theme ?? 'dark'
document.documentElement.classList.toggle('light', theme === 'light')
set({ fontSize, theme })
},
loadSession: async () => {
try {
const data = await window.api.readSession()
const patch: Partial<BorgesState> = {}
if (data.chatHistoryByStory && typeof data.chatHistoryByStory === 'object') {
patch.chatHistoryByStory = data.chatHistoryByStory as Record<string, ChatMessage[]>
}
set(patch)
if (typeof data.activeStoryPath === 'string') {
try {
const content = await window.api.readStory(data.activeStoryPath)
const id = data.activeStoryPath.split('/').pop()?.replace(/\.md$/, '') ?? ''
get().setActiveStory(data.activeStoryPath, id, content)
} catch {
// file moved/deleted
}
}
} catch {
// no session
}
}
}))