返回 oh-my-ppt
HtmlEditorAiPanel.tsx
根目录 / src / renderer / src / components / html-editor / HtmlEditorAiPanel.tsx
1 import { useEffect, useRef, useState, type ReactElement } from 'react'
2 import { Loader2, Send, Sparkles, Tag, X } from 'lucide-react'
3 import { useT } from '../../i18n'
4 import { ipc, type HtmlEditorAiElementContext, type HtmlEditorAiMessage } from '../../lib/ipc'
5 import { useModelAction } from '../../hooks/useModelAction'
6 import { useHtmlEditStore } from '../../store/htmlEditStore'
7 import { useHtmlEditHistoryStore } from '../../store/htmlEditHistoryStore'
8 import { useHtmlEditorAiStore } from '../../store/htmlEditorAiStore'
9 import { useHtmlEditorStore } from '../../store/htmlEditorStore'
10 import { useHtmlEditorUiStore } from '../../store/htmlEditorUiStore'
11 import { Button } from '../ui/Button'
12 import { Textarea } from '../ui/Input'
13 import { ModelSplitButton } from '../model/ModelActionButton'
14
15 const MAX_AI_HISTORY_MESSAGES = 6
16 const MAX_AI_HISTORY_MESSAGE_LENGTH = 1_800
17 const MAX_AI_ELEMENT_HTML_LENGTH = 10_000
18
19 function HtmlEditorAiMessageBubble({
20 role,
21 content,
22 selectedElement
23 }: {
24 role: HtmlEditorAiMessage['role']
25 content: string
26 selectedElement?: HtmlEditorAiElementContext
27 }): ReactElement {
28 const isUser = role === 'user'
29 const selectedElementLabel =
30 selectedElement?.label ||
31 (selectedElement?.elementTag
32 ? `<${selectedElement.elementTag}>`
33 : selectedElement?.selector || '')
34 return (
35 <div className={`flex w-full ${isUser ? 'justify-end' : 'justify-start'}`}>
36 <div
37 className={`max-w-[280px] whitespace-pre-wrap break-words rounded-2xl border px-3 py-2 text-[13px] leading-5 shadow-sm ${
38 isUser
39 ? 'border-[#c7d9b4]/80 bg-[#e6f1dc]/85 text-[#34402c]'
40 : 'border-[#ded2bd]/80 bg-[#fffaf1]/90 text-[#3f372b]'
41 }`}
42 >
43 {isUser && selectedElement?.selector ? (
44 <div
45 className="mb-1.5 flex min-w-0 items-center gap-1 text-[10px] leading-4 text-[#537044]"
46 title={selectedElement.selector}
47 >
48 <Tag className="h-3 w-3 shrink-0" />
49 <span className="truncate">{selectedElementLabel}</span>
50 </div>
51 ) : null}
52 {content}
53 </div>
54 </div>
55 )
56 }
57
58 export function HtmlEditorAiPanel(): ReactElement {
59 const t = useT()
60 const modelAction = useModelAction()
61 const docId = useHtmlEditorStore((state) => state.docId)
62 const documentTitle = useHtmlEditorStore((state) => state.title)
63 const input = useHtmlEditorAiStore((state) => state.input)
64 const messages = useHtmlEditorAiStore((state) => state.messages)
65 const isSending = useHtmlEditorAiStore((state) => state.isSending)
66 const error = useHtmlEditorAiStore((state) => state.error)
67 const setInput = useHtmlEditorAiStore((state) => state.setInput)
68 const addMessage = useHtmlEditorAiStore((state) => state.addMessage)
69 const setMessages = useHtmlEditorAiStore((state) => state.setMessages)
70 const setSending = useHtmlEditorAiStore((state) => state.setSending)
71 const setError = useHtmlEditorAiStore((state) => state.setError)
72 const setPlan = useHtmlEditorAiStore((state) => state.setPlan)
73 const clearConversation = useHtmlEditorAiStore((state) => state.clearConversation)
74 const pendingPlan = useHtmlEditorAiStore((state) => state.pendingPlan)
75 const requiresConfirmation = useHtmlEditorAiStore((state) => state.requiresConfirmation)
76 const selectedSelector = useHtmlEditorUiStore((state) => state.selectedSelector)
77 const selectorLabel = useHtmlEditorUiStore((state) => state.selectorLabel)
78 const elementTag = useHtmlEditorUiStore((state) => state.elementTag)
79 const elementText = useHtmlEditorUiStore((state) => state.elementText)
80 const messagesEndRef = useRef<HTMLDivElement>(null)
81 const composingRef = useRef(false)
82 const [selectedHtml, setSelectedHtml] = useState('')
83
84 useEffect(() => {
85 messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
86 }, [messages, isSending])
87
88 useEffect(() => {
89 let disposed = false
90 if (!selectedSelector) {
91 setSelectedHtml('')
92 return () => {
93 disposed = true
94 }
95 }
96 const iframe = useHtmlEditStore.getState().iframeHandle
97 if (!iframe?.readElementHtml) return undefined
98 void iframe
99 .readElementHtml(selectedSelector)
100 .then((html) => {
101 if (!disposed) setSelectedHtml(String(html || '').slice(0, MAX_AI_ELEMENT_HTML_LENGTH))
102 })
103 .catch(() => {
104 if (!disposed) setSelectedHtml('')
105 })
106 return () => {
107 disposed = true
108 }
109 }, [selectedSelector])
110
111 useEffect(() => {
112 let disposed = false
113 if (!docId) return undefined
114 void ipc
115 .listHtmlEditorMessages({ docId })
116 .then(({ messages: storedMessages }) => {
117 if (disposed) return
118 setMessages(storedMessages)
119 const latestPlanMessage = [...storedMessages]
120 .reverse()
121 .find((message) => message.role === 'assistant' && message.plan)
122 setPlan({
123 intent: latestPlanMessage?.intent || latestPlanMessage?.plan?.intent || 'other',
124 plan: latestPlanMessage?.plan || null,
125 requiresConfirmation: latestPlanMessage?.requiresConfirmation === true
126 })
127 })
128 .catch((loadError) => {
129 if (!disposed) {
130 setError(loadError instanceof Error ? loadError.message : t('htmlEditor.aiFailed'))
131 }
132 })
133 return () => {
134 disposed = true
135 }
136 }, [docId, setError, setMessages, setPlan, t])
137
138 const sendMessage = async (modelConfigId: string): Promise<void> => {
139 const userMessage = input.trim()
140 if (!userMessage || isSending || !docId) return
141
142 const recentMessages: HtmlEditorAiMessage[] = messages
143 .slice(-MAX_AI_HISTORY_MESSAGES)
144 .map(({ role, content }) => ({
145 role,
146 content: content.slice(0, MAX_AI_HISTORY_MESSAGE_LENGTH)
147 }))
148 const planForConfirmation = requiresConfirmation ? pendingPlan : undefined
149 const messageSelectedElement = selectedSelector
150 ? {
151 selector: selectedSelector,
152 label: selectorLabel,
153 elementTag,
154 elementText
155 }
156 : undefined
157 addMessage({ role: 'user', content: userMessage, selectedElement: messageSelectedElement })
158 setInput('')
159 setError(null)
160 setPlan({ intent: 'other', plan: null, requiresConfirmation: false })
161 setSending(true)
162
163 try {
164 let latestSelectedHtml = selectedHtml
165 if (selectedSelector) {
166 const iframe = useHtmlEditStore.getState().iframeHandle
167 if (iframe?.readElementHtml) {
168 try {
169 latestSelectedHtml = (await iframe.readElementHtml(selectedSelector)).slice(
170 0,
171 MAX_AI_ELEMENT_HTML_LENGTH
172 )
173 } catch {
174 // Use the last successfully read fragment when the webview is reloading.
175 }
176 }
177 }
178 const result = await ipc.htmlEditorAiChat({
179 documentId: docId,
180 documentTitle,
181 pageHtml: '',
182 selectedElement: selectedSelector
183 ? {
184 selector: selectedSelector,
185 label: selectorLabel,
186 elementTag,
187 elementText,
188 html: latestSelectedHtml
189 }
190 : undefined,
191 recentMessages,
192 pendingPlan: planForConfirmation || undefined,
193 userMessage,
194 modelConfigId
195 })
196 addMessage({
197 role: 'assistant',
198 content: result.reply,
199 selectedElement: messageSelectedElement
200 })
201 if (result.applied && result.appliedHtml) {
202 useHtmlEditorStore.getState().setHtml(result.appliedHtml)
203 useHtmlEditStore.getState().resetForPage()
204 useHtmlEditHistoryStore.getState().clearPage(docId)
205 useHtmlEditorUiStore.getState().clearSelectedElement()
206 useHtmlEditorUiStore.getState().bumpPreviewKey()
207 }
208 setPlan({
209 intent: result.intent,
210 plan: result.plan,
211 requiresConfirmation: result.requiresConfirmation
212 })
213 } catch (sendError) {
214 setError(sendError instanceof Error ? sendError.message : t('htmlEditor.aiFailed'))
215 } finally {
216 setSending(false)
217 }
218 }
219
220 const sendDisabled = !input.trim() || isSending || !docId
221 const selectedLabel = selectorLabel || selectedSelector || ''
222
223 const handleClearConversation = async (): Promise<void> => {
224 if (!docId) return
225 try {
226 await ipc.clearHtmlEditorMessages({ docId })
227 clearConversation()
228 } catch (clearError) {
229 setError(clearError instanceof Error ? clearError.message : t('htmlEditor.aiFailed'))
230 }
231 }
232
233 return (
234 <aside className="flex h-full w-[360px] shrink-0 flex-col border-l border-[#e2dccf] bg-[#f5f1e8]">
235 <div className="flex shrink-0 items-center gap-2 border-b border-[#e2dccf] px-3 py-2.5">
236 <Sparkles className="h-4 w-4 text-[#5d6b4d]" />
237 <div className="min-w-0 flex-1">
238 <div className="text-sm font-semibold text-[#34402c]">{t('htmlEditor.aiMode')}</div>
239 <div className="text-[10px] text-[#7a806e]">{t('htmlEditor.aiInspectHint')}</div>
240 </div>
241 <button
242 type="button"
243 onClick={() => {
244 useHtmlEditorAiStore.getState().setEnabled(false)
245 useHtmlEditorUiStore.getState().clearSelectedElement()
246 }}
247 className="inline-flex h-7 w-7 items-center justify-center rounded-md text-[#6d604d] transition-colors hover:bg-[#ece5d6]"
248 title={t('htmlEditor.closeAiMode')}
249 aria-label={t('htmlEditor.closeAiMode')}
250 >
251 <X className="h-4 w-4" />
252 </button>
253 </div>
254
255 <div className="min-h-0 flex-1 overflow-y-auto px-3 py-3">
256 <div className="space-y-2.5">
257 {messages.length === 0 ? (
258 <div className="py-10 text-center text-xs leading-5 text-[#817664]">
259 {t('htmlEditor.aiEmpty')}
260 </div>
261 ) : (
262 messages.map((message) => (
263 <HtmlEditorAiMessageBubble
264 key={message.id}
265 role={message.role}
266 content={message.content}
267 selectedElement={message.selectedElement}
268 />
269 ))
270 )}
271 {isSending ? (
272 <div className="flex items-center gap-2 text-xs text-[#7a806e]">
273 <Loader2 className="h-3.5 w-3.5 animate-spin" />
274 {t('htmlEditor.aiThinking')}
275 </div>
276 ) : null}
277 {error ? (
278 <div className="rounded-xl bg-[#f3e6e2] px-3 py-2 text-xs leading-5 text-[#8e5a53]">
279 {error}
280 </div>
281 ) : null}
282 <div ref={messagesEndRef} />
283 </div>
284 </div>
285
286 <div className="shrink-0 border-t border-[#e2dccf] bg-[#fffaf1]/45 p-3">
287 {selectedSelector ? (
288 <div className="mb-2 rounded-xl border border-[#c7d9b4]/70 bg-[#e6f1dc]/70 px-2.5 py-2">
289 <div className="mb-1 flex items-center justify-between gap-2">
290 <span className="text-[10px] font-semibold tracking-wide text-[#4f6340]">
291 {t('htmlEditor.selectedElement')}
292 </span>
293 <button
294 type="button"
295 onClick={() => useHtmlEditorUiStore.getState().clearSelectedElement()}
296 className="inline-flex h-5 w-5 items-center justify-center rounded-full text-[#64735a] hover:bg-[#d4e4c1]"
297 title={t('htmlEditor.clearSelectedElement')}
298 aria-label={t('htmlEditor.clearSelectedElement')}
299 >
300 <X className="h-3 w-3" />
301 </button>
302 </div>
303 <div className="truncate text-xs font-medium text-[#405333]" title={selectedSelector}>
304 {selectedLabel}
305 </div>
306 <div className="mt-0.5 truncate text-[11px] text-[#5f6e50]">
307 {elementTag ? `<${elementTag}>` : ''}
308 {elementText ? ` ${elementText}` : ''}
309 </div>
310 </div>
311 ) : (
312 <div className="mb-2 rounded-xl border border-dashed border-[#cfc4b1] bg-[#fffaf1]/70 px-3 py-2 text-xs leading-5 text-[#766c5b]">
313 {t('htmlEditor.aiSelectHint')}
314 </div>
315 )}
316 <Textarea
317 value={input}
318 onChange={(event) => setInput(event.target.value)}
319 onCompositionStart={() => {
320 composingRef.current = true
321 }}
322 onCompositionEnd={() => {
323 composingRef.current = false
324 }}
325 onKeyDown={(event) => {
326 if (event.key === 'Enter' && !event.shiftKey) {
327 if (composingRef.current || event.nativeEvent.isComposing) return
328 event.preventDefault()
329 if (!sendDisabled) {
330 void modelAction.ensureModelActive().then((modelConfigId) => {
331 if (modelConfigId) void sendMessage(modelConfigId)
332 })
333 }
334 }
335 }}
336 placeholder={t('htmlEditor.aiPlaceholder')}
337 disabled={isSending}
338 rows={4}
339 className="min-h-[96px] resize-none rounded-xl border-[#ded2bd]/75 bg-[#fffdf8]/90 text-[13px] leading-5 focus-visible:border-[#9bb98a] focus-visible:ring-0 focus-visible:ring-offset-0"
340 />
341 <div className="mt-2 flex items-center justify-between gap-2">
342 <span className="min-w-0 truncate text-[10px] text-[#827662]">
343 {selectedSelector
344 ? t('htmlEditor.aiSelectedContext')
345 : t('htmlEditor.aiDocumentContext')}
346 </span>
347 <ModelSplitButton
348 modelAction={modelAction}
349 label={t('htmlEditor.aiSend')}
350 loadingLabel={t('htmlEditor.aiThinking')}
351 loading={isSending}
352 disabled={sendDisabled}
353 icon={Send}
354 tone="subtle"
355 size="sm"
356 className="shrink-0"
357 mainClassName="h-8 px-2.5 text-xs"
358 triggerClassName="h-8 px-1.5"
359 onRun={sendMessage}
360 />
361 </div>
362 <Button
363 type="button"
364 variant="ghost"
365 size="sm"
366 onClick={() => void handleClearConversation()}
367 className="mt-1 h-7 px-1 text-[11px] text-[#827662] hover:bg-transparent hover:text-[#4f6340]"
368 >
369 {t('htmlEditor.aiClearConversation')}
370 </Button>
371 </div>
372 </aside>
373 )
374 }
375
375 lines Plain Text