返回 oh-my-ppt
thinkingStore.ts
根目录 / src / renderer / src / store / thinkingStore.ts
1 import { create } from 'zustand'
2 import { ipc } from '@renderer/lib/ipc'
3 import type {
4 ThinkingStage,
5 ThinkingSource,
6 ThinkingChatMessage,
7 ThinkingPageOutlineUpdate
8 } from '@shared/thinking'
9
10 interface ThinkingStep {
11 type: 'tool_call' | 'tool_result'
12 toolName: string
13 summary: string
14 }
15
16 interface ThinkingStore {
17 thinkingId: string | null
18 stage: ThinkingStage
19 thinkingMd: string
20 contextMd: string
21 sources: ThinkingSource[]
22 messages: ThinkingChatMessage[]
23 thinkingSteps: ThinkingStep[]
24 animatingText: string
25 loading: boolean
26 error: string | null
27
28 createWorkspace: () => Promise<string>
29 loadWorkspace: (thinkingId: string) => Promise<void>
30 loadLatestWorkspace: () => Promise<string | null>
31 refreshWorkspace: (thinkingId?: string) => Promise<boolean>
32 updatePageOutline: (page: ThinkingPageOutlineUpdate) => Promise<void>
33 addMessage: (message: ThinkingChatMessage) => void
34 sendMessage: (content: string, attachments?: ThinkingSource[], modelConfigId?: string) => void
35 addThinkingStep: (step: ThinkingStep) => void
36 setAnimatingText: (text: string) => void
37 setLoading: (loading: boolean) => void
38 setError: (error: string | null) => void
39 reset: () => void
40 }
41
42 let streamListenersReady = false
43
44 const readStoredLocale = (): 'zh' | 'en' => {
45 if (typeof window === 'undefined') return 'zh'
46 return window.localStorage.getItem('oh-my-ppt:lang') === 'en' ? 'en' : 'zh'
47 }
48
49 function formatChatFailureMessage(error: unknown): string {
50 const message = error instanceof Error ? error.message : String(error || '')
51 const compactMessage = message.trim().replace(/\s+/g, ' ').slice(0, 500)
52 if (readStoredLocale() === 'en') {
53 return [
54 'LLM reply failed. Please try again.',
55 compactMessage ? `Error: ${compactMessage}` : ''
56 ]
57 .filter(Boolean)
58 .join('\n\n')
59 }
60 return [
61 'LLM 回复失败了,请重试一次。',
62 compactMessage ? `错误:${compactMessage}` : ''
63 ]
64 .filter(Boolean)
65 .join('\n\n')
66 }
67
68 function hasAssistantReply(messages: ThinkingChatMessage[], reply: string): boolean {
69 const normalized = reply.trim()
70 if (!normalized) return false
71 return messages.some((message) => message.role === 'assistant' && message.content.trim() === normalized)
72 }
73
74 function ensureThinkingStreamListeners(
75 set: (
76 partial:
77 | Partial<ThinkingStore>
78 | ((state: ThinkingStore) => Partial<ThinkingStore> | ThinkingStore)
79 ) => void,
80 get: () => ThinkingStore
81 ): void {
82 if (streamListenersReady) return
83 streamListenersReady = true
84
85 ipc.onThinkingStreamThinking((payload) => {
86 const state = get()
87 if (payload.thinkingId !== state.thinkingId) return
88 state.addThinkingStep({
89 type: payload.type as 'tool_call' | 'tool_result',
90 toolName: payload.toolName,
91 summary: payload.summary
92 })
93 })
94
95 ipc.onThinkingStreamEnd((payload) => {
96 const state = get()
97 if (payload.thinkingId !== state.thinkingId) return
98
99 set({
100 thinkingMd: payload.thinkingMd,
101 contextMd: payload.contextMd,
102 stage: payload.stage
103 })
104 void get().refreshWorkspace(payload.thinkingId)
105
106 const fullText = payload.reply.trim()
107 if (!fullText || hasAssistantReply(get().messages, fullText)) {
108 set({ loading: false, thinkingSteps: [], animatingText: '' })
109 return
110 }
111
112 let index = 0
113 const charsPerTick = 3
114 const tickMs = 20
115 const animate = (): void => {
116 const current = get()
117 if (!current.loading || current.thinkingId !== payload.thinkingId) return
118 index = Math.min(index + charsPerTick, fullText.length)
119 current.setAnimatingText(fullText.slice(0, index))
120 if (index < fullText.length) {
121 setTimeout(animate, tickMs)
122 } else {
123 if (!hasAssistantReply(get().messages, fullText)) {
124 current.addMessage({
125 role: 'assistant',
126 content: fullText,
127 timestamp: Date.now()
128 })
129 }
130 set({
131 loading: false,
132 thinkingSteps: [],
133 animatingText: ''
134 })
135 }
136 }
137 animate()
138 })
139 }
140
141 export const useThinkingStore = create<ThinkingStore>((set, get) => {
142 return {
143 thinkingId: null,
144 stage: 'collect',
145 thinkingMd: '',
146 contextMd: '',
147 sources: [],
148 messages: [],
149 thinkingSteps: [],
150 animatingText: '',
151 loading: false,
152 error: null,
153
154 createWorkspace: async () => {
155 ensureThinkingStreamListeners(set, get)
156 set({
157 thinkingId: null,
158 stage: 'collect',
159 thinkingMd: '',
160 contextMd: '',
161 sources: [],
162 messages: [],
163 thinkingSteps: [],
164 animatingText: '',
165 loading: true,
166 error: null
167 })
168 try {
169 const workspace = await ipc.thinkingCreateWorkspace()
170 set({
171 thinkingId: workspace.thinkingId,
172 stage: workspace.stage,
173 thinkingMd: workspace.thinkingMd,
174 contextMd: workspace.contextMd,
175 sources: workspace.sources,
176 messages: workspace.messages,
177 thinkingSteps: [],
178 animatingText: '',
179 loading: false
180 })
181 return workspace.thinkingId
182 } catch (err) {
183 set({
184 error: err instanceof Error ? err.message : 'Failed to create workspace',
185 loading: false
186 })
187 throw err
188 }
189 },
190
191 loadWorkspace: async (thinkingId) => {
192 ensureThinkingStreamListeners(set, get)
193 set({
194 thinkingId,
195 stage: 'collect',
196 thinkingMd: '',
197 contextMd: '',
198 sources: [],
199 messages: [],
200 thinkingSteps: [],
201 animatingText: '',
202 loading: true,
203 error: null
204 })
205 try {
206 const workspace = await ipc.thinkingGetWorkspace(thinkingId)
207 set({
208 thinkingId: workspace.thinkingId,
209 stage: workspace.stage,
210 thinkingMd: workspace.thinkingMd,
211 contextMd: workspace.contextMd,
212 sources: workspace.sources,
213 messages: workspace.messages,
214 thinkingSteps: [],
215 animatingText: '',
216 loading: false
217 })
218 } catch (err) {
219 set({
220 error: err instanceof Error ? err.message : 'Failed to load workspace',
221 loading: false
222 })
223 }
224 },
225
226 loadLatestWorkspace: async () => {
227 ensureThinkingStreamListeners(set, get)
228 try {
229 const result = await ipc.thinkingGetLatestWorkspace()
230 if (!result) return null
231 set({
232 thinkingId: result.thinkingId,
233 stage: result.stage,
234 thinkingMd: result.thinkingMd,
235 contextMd: result.contextMd,
236 sources: result.sources,
237 messages: result.messages,
238 thinkingSteps: [],
239 animatingText: '',
240 loading: false
241 })
242 return result.thinkingId
243 } catch {
244 return null
245 }
246 },
247
248 refreshWorkspace: async (thinkingId) => {
249 const activeThinkingId = thinkingId || get().thinkingId
250 if (!activeThinkingId) return false
251
252 const workspace = await ipc.thinkingGetWorkspace(activeThinkingId)
253 const current = get()
254 if (current.thinkingId !== activeThinkingId || workspace.thinkingId !== activeThinkingId) {
255 return false
256 }
257
258 const sourcesChanged = JSON.stringify(current.sources) !== JSON.stringify(workspace.sources)
259 const messagesChanged =
260 !current.loading && JSON.stringify(current.messages) !== JSON.stringify(workspace.messages)
261 const changed =
262 current.thinkingMd !== workspace.thinkingMd ||
263 current.contextMd !== workspace.contextMd ||
264 current.stage !== workspace.stage ||
265 sourcesChanged ||
266 messagesChanged
267
268 set({
269 thinkingMd: workspace.thinkingMd,
270 contextMd: workspace.contextMd,
271 stage: workspace.stage,
272 sources: workspace.sources,
273 ...(current.loading ? {} : { messages: workspace.messages })
274 })
275
276 return changed
277 },
278
279 updatePageOutline: async (page) => {
280 const { thinkingId, loading } = get()
281 if (!thinkingId) throw new Error('Thinking workspace is not ready')
282 if (loading) throw new Error('Thinking workspace is busy')
283 const result = await ipc.thinkingUpdatePageOutline({ thinkingId, page })
284 set((state) =>
285 state.thinkingId === thinkingId
286 ? {
287 thinkingMd: result.thinkingMd
288 }
289 : state
290 )
291 },
292
293 addMessage: (message) =>
294 set((state) => ({ messages: [...state.messages, message] })),
295
296 addThinkingStep: (step) =>
297 set((state) => {
298 const summary = step.summary.trim()
299 if (!summary || step.type === 'tool_result') return state
300
301 const lastStep = state.thinkingSteps[state.thinkingSteps.length - 1]
302 if (lastStep && lastStep.summary === summary) return state
303
304 const alreadyRecent = state.thinkingSteps
305 .slice(-3)
306 .some((item) => item.summary === summary && item.toolName === step.toolName)
307 if (alreadyRecent) return state
308
309 return {
310 thinkingSteps: [
311 ...state.thinkingSteps,
312 {
313 ...step,
314 summary
315 }
316 ].slice(-6)
317 }
318 }),
319
320 setAnimatingText: (text) =>
321 set({ animatingText: text }),
322
323 sendMessage: (content, attachments, modelConfigId) => {
324 ensureThinkingStreamListeners(set, get)
325 const { thinkingId, messages } = get()
326 if (!thinkingId) return
327 const recentMessages = messages.slice(-8)
328
329 get().addMessage({
330 role: 'user',
331 content,
332 timestamp: Date.now(),
333 ...(attachments && attachments.length > 0 ? { attachments } : {})
334 })
335
336 set({ loading: true, error: null, thinkingSteps: [], animatingText: '' })
337
338 // Fire-and-forget: the IPC call returns the full result,
339 // but we show thinking events and animate the reply via stream listeners.
340 ipc.thinkingChat({
341 thinkingId,
342 modelConfigId,
343 userMessage: content,
344 recentMessages,
345 ...(attachments && attachments.length > 0 ? { attachments } : {})
346 }).catch((err) => {
347 const errorMessage = err instanceof Error ? err.message : 'Chat failed'
348 set((state) => {
349 if (state.thinkingId !== thinkingId) return state
350 return {
351 error: errorMessage,
352 animatingText: '',
353 loading: false,
354 thinkingSteps: [],
355 messages: [
356 ...state.messages,
357 {
358 role: 'assistant',
359 content: formatChatFailureMessage(err),
360 timestamp: Date.now()
361 }
362 ]
363 }
364 })
365 })
366 },
367
368 setLoading: (loading) => set({ loading }),
369 setError: (error) => set({ error }),
370
371 reset: () =>
372 set({
373 thinkingId: null,
374 stage: 'collect',
375 thinkingMd: '',
376 contextMd: '',
377 sources: [],
378 messages: [],
379 thinkingSteps: [],
380 animatingText: '',
381 loading: false,
382 error: null
383 })
384 }
385 })
386
386 lines TYPESCRIPT