@nuasite/cms 0.20.1 → 0.20.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,180 +0,0 @@
1
- import { useEffect, useRef, useState } from 'preact/hooks'
2
- import { Z_INDEX } from '../constants'
3
-
4
- export interface AITooltipCallbacks {
5
- onPromptSubmit: (prompt: string, elementId: string) => void
6
- }
7
-
8
- export interface AITooltipProps {
9
- callbacks: AITooltipCallbacks
10
- visible: boolean
11
- elementId: string | null
12
- rect: DOMRect | null
13
- processing: boolean
14
- }
15
-
16
- export function AITooltip({ callbacks, visible, elementId, rect, processing }: AITooltipProps) {
17
- const [isExpanded, setIsExpanded] = useState(false)
18
- const [prompt, setPrompt] = useState('')
19
- const inputRef = useRef<HTMLInputElement>(null)
20
-
21
- useEffect(() => {
22
- if (!visible) {
23
- setIsExpanded(false)
24
- setPrompt('')
25
- }
26
- }, [visible])
27
-
28
- useEffect(() => {
29
- if (isExpanded && inputRef.current) {
30
- setTimeout(() => inputRef.current?.focus(), 50)
31
- }
32
- }, [isExpanded])
33
-
34
- if (!visible || !rect || !elementId) {
35
- return null
36
- }
37
-
38
- // Don't show empty tooltip when not expanded and not processing
39
- // (the content for this state is not yet implemented)
40
- if (!isExpanded && !processing) {
41
- return null
42
- }
43
-
44
- // Calculate position
45
- const tooltipWidth = 200
46
- const tooltipHeight = 50
47
- let left = rect.left + rect.width / 2 - tooltipWidth / 2
48
- let top = rect.top - tooltipHeight - 8
49
-
50
- const padding = 10
51
- const maxLeft = window.innerWidth - tooltipWidth - padding
52
- const minLeft = padding
53
-
54
- left = Math.max(minLeft, Math.min(left, maxLeft))
55
-
56
- if (top < padding) {
57
- top = rect.bottom + 8
58
- }
59
-
60
- const maxTop = window.innerHeight - tooltipHeight - padding
61
- top = Math.min(top, maxTop)
62
-
63
- const handleExpand = (e: MouseEvent) => {
64
- e.preventDefault()
65
- e.stopPropagation()
66
- if (!isExpanded && elementId) {
67
- setIsExpanded(true)
68
- }
69
- }
70
-
71
- const handleCancel = (e: MouseEvent) => {
72
- e.preventDefault()
73
- e.stopPropagation()
74
- setIsExpanded(false)
75
- }
76
-
77
- const handleSubmit = (e: Event) => {
78
- e.preventDefault()
79
- e.stopPropagation()
80
- if (prompt.trim() && elementId) {
81
- callbacks.onPromptSubmit(prompt.trim(), elementId)
82
- setIsExpanded(false)
83
- setPrompt('')
84
- }
85
- }
86
-
87
- const handleMouseDown = (e: MouseEvent) => {
88
- // Stop propagation to prevent the click-outside handler from hiding the tooltip
89
- e.stopPropagation()
90
- }
91
-
92
- return (
93
- <div
94
- data-cms-ui
95
- onMouseDown={handleMouseDown}
96
- onClick={(e: MouseEvent) => e.stopPropagation()}
97
- style={{
98
- position: 'fixed',
99
- left: `${left}px`,
100
- top: `${top}px`,
101
- zIndex: Z_INDEX.OVERLAY,
102
- fontFamily: 'system-ui, -apple-system, BlinkMacSystemFont, sans-serif',
103
- fontSize: '12px',
104
- }}
105
- >
106
- <div
107
- class={`tooltip ${isExpanded ? 'expanded' : ''} ${
108
- processing ? 'processing' : ''
109
- } bg-cms-dark shadow-[0_8px_32px_rgba(0,0,0,0.3)] border border-white/10 text-white cursor-pointer transition-all duration-200 pointer-events-auto select-none rounded-cms-lg`}
110
- onClick={handleExpand}
111
- onMouseDown={handleMouseDown}
112
- style={{
113
- padding: isExpanded ? '16px' : '10px 14px',
114
- minWidth: isExpanded ? '280px' : 'auto',
115
- maxWidth: isExpanded ? '320px' : 'auto',
116
- }}
117
- >
118
- {
119
- // TODO: Implement AI tooltip
120
- /*{!isExpanded && !processing && (
121
- <div class="flex items-center gap-1.5 text-cms-secondary">
122
- <svg
123
- width="14"
124
- height="14"
125
- viewBox="0 0 24 24"
126
- fill="none"
127
- stroke="currentColor"
128
- stroke-width="2.5"
129
- stroke-linecap="round"
130
- stroke-linejoin="round"
131
- >
132
- <path d="M12 3l1.912 5.813a2 2 0 0 0 1.275 1.275L21 12l-5.813 1.912a2 2 0 0 0-1.275 1.275L12 21l-1.912-5.813a2 2 0 0 0-1.275-1.275L3 12l5.813-1.912a2 2 0 0 0 1.275-1.275L12 3z" />
133
- </svg>
134
- <span class="font-medium">Ask AI to edit</span>
135
- </div>
136
- )}*/
137
- }
138
- {processing && (
139
- <div class="flex items-center gap-2 text-white/70">
140
- <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" class="animate-spin">
141
- <path d="M12 2v4m0 12v4M4.93 4.93l2.83 2.83m8.48 8.48l2.83 2.83M2 12h4m12 0h4M4.93 19.07l2.83-2.83m8.48-8.48l2.83-2.83" />
142
- </svg>
143
- <span class="font-medium">Processing...</span>
144
- </div>
145
- )}
146
- {isExpanded && !processing && (
147
- <form class="prompt-form flex flex-col gap-3" onSubmit={handleSubmit}>
148
- <label class="text-[11px] text-white/50 font-medium">What would you like to change?</label>
149
- <input
150
- ref={inputRef}
151
- type="text"
152
- placeholder="e.g., make it shorter..."
153
- value={prompt}
154
- onInput={(e) => setPrompt((e.target as HTMLInputElement).value)}
155
- onMouseDown={(e) => e.stopPropagation()}
156
- class="w-full px-3 py-2 border border-white/20 bg-white/10 text-white text-xs font-sans outline-none focus:border-white/40 focus:ring-1 focus:ring-white/10 transition-all rounded-cms-sm placeholder:text-white/40"
157
- />
158
- <div class="flex gap-2 justify-end">
159
- <button
160
- type="button"
161
- onClick={handleCancel}
162
- onMouseDown={(e) => e.stopPropagation()}
163
- class="px-3 py-1.5 text-[11px] font-medium cursor-pointer transition-all bg-white/10 text-white/80 hover:bg-white/20 hover:text-white rounded-cms-pill"
164
- >
165
- Cancel
166
- </button>
167
- <button
168
- type="submit"
169
- onMouseDown={(e) => e.stopPropagation()}
170
- class="px-3 py-1.5 text-[11px] font-medium cursor-pointer transition-all bg-cms-primary text-cms-primary-text hover:bg-cms-primary-hover rounded-cms-pill"
171
- >
172
- Apply
173
- </button>
174
- </div>
175
- </form>
176
- )}
177
- </div>
178
- </div>
179
- )
180
- }
@@ -1,345 +0,0 @@
1
- import { useCallback, useMemo, useRef } from 'preact/hooks'
2
- import { AIService, type CmsAiAction } from '../ai'
3
- import { getChatHistory } from '../api'
4
- import { getEditableTextFromElement, logDebug } from '../dom'
5
- import { handleElementChange } from '../editor'
6
- import { getComponentDefinition, getComponentInstance, getManifestEntry } from '../manifest'
7
- import * as signals from '../signals'
8
- import type { AIStatusType, ChatMessage, CmsConfig } from '../types'
9
-
10
- export interface AIHandlersOptions {
11
- config: CmsConfig
12
- showToast: (message: string, type?: 'info' | 'success' | 'error') => void
13
- onTooltipHide: () => void
14
- onUIUpdate?: () => void
15
- }
16
-
17
- // Counter for generating unique message IDs
18
- let messageIdCounter = 0
19
- function nextMessageId(prefix: string): string {
20
- return `${prefix}-${Date.now()}-${++messageIdCounter}`
21
- }
22
-
23
- /**
24
- * Hook providing AI-related handlers for the CMS editor.
25
- * Uses signals directly for state management.
26
- */
27
- export function useAIHandlers({
28
- config,
29
- showToast,
30
- onTooltipHide,
31
- onUIUpdate,
32
- }: AIHandlersOptions) {
33
- // Create AI service instance - memoized to avoid recreation on each render
34
- const aiService = useMemo(() => new AIService(config), [config])
35
-
36
- // Guard against stale chat history responses
37
- const historyRequestRef = useRef(0)
38
-
39
- /**
40
- * Toggle AI chat visibility
41
- */
42
- const handleAIChatToggle = useCallback(async () => {
43
- if (signals.isChatOpen.value) {
44
- signals.setAIChatOpen(false)
45
- } else {
46
- signals.setAIChatOpen(true)
47
- const currentId = signals.currentEditingId.value
48
- if (currentId) {
49
- signals.setChatContextElement(currentId)
50
- }
51
-
52
- // Load chat history when opening
53
- const requestId = ++historyRequestRef.current
54
- try {
55
- const history = await getChatHistory(config.apiBase)
56
- // Discard if a newer request has been made
57
- if (requestId !== historyRequestRef.current) return
58
- if (history.messages && history.messages.length > 0) {
59
- // Convert API messages to ChatMessage format
60
- const chatMessages: ChatMessage[] = history.messages
61
- .filter((msg) => {
62
- // Skip tool-role messages that may have slipped through
63
- if (msg.role === 'tool') return false
64
- const content = msg.content?.trim() || ''
65
- // Skip empty messages
66
- if (!content) return false
67
- return true
68
- })
69
- .map((msg) => ({
70
- id: msg.id,
71
- role: msg.role as 'user' | 'assistant',
72
- content: msg.content || '',
73
- timestamp: new Date(msg.created_at).getTime(),
74
- }))
75
- signals.setChatMessages(chatMessages)
76
- }
77
- } catch (error) {
78
- logDebug(config.debug, 'Failed to load chat history:', error)
79
- }
80
- }
81
- }, [config])
82
-
83
- /**
84
- * Close AI chat
85
- */
86
- const handleChatClose = useCallback(() => {
87
- signals.setAIChatOpen(false)
88
- }, [])
89
-
90
- /**
91
- * Cancel in-progress AI request
92
- */
93
- const handleChatCancel = useCallback(() => {
94
- aiService.abort()
95
- signals.setAIProcessing(false)
96
- signals.clearAIStatus()
97
- // Clean up empty assistant message
98
- const messages = signals.chatMessages.value
99
- const lastMsg = messages[messages.length - 1]
100
- if (lastMsg?.role === 'assistant' && !lastMsg.content.trim()) {
101
- signals.setChatMessages(messages.filter((m) => m.id !== lastMsg.id))
102
- }
103
- }, [aiService])
104
-
105
- /**
106
- * Handle AI prompt submission from tooltip
107
- */
108
- const handleTooltipPromptSubmit = useCallback(
109
- async (prompt: string, elementId: string) => {
110
- const change = signals.getPendingChange(elementId)
111
- if (!change) {
112
- showToast('Element not found', 'error')
113
- return
114
- }
115
-
116
- const currentContent = getEditableTextFromElement(change.element)
117
- const manifest = signals.manifest.value
118
-
119
- signals.setAIProcessing(true)
120
-
121
- logDebug(
122
- config.debug,
123
- 'Tooltip AI request for element:',
124
- elementId,
125
- 'prompt:',
126
- prompt,
127
- )
128
-
129
- try {
130
- await aiService.streamRequest(
131
- {
132
- prompt,
133
- elementId,
134
- currentContent,
135
- context: getManifestEntry(manifest, elementId)?.sourcePath,
136
- },
137
- {
138
- onToken: (_token, fullText) => {
139
- change.element.textContent = fullText
140
- },
141
- onComplete: (finalText) => {
142
- logDebug(config.debug, 'Tooltip AI completed:', finalText)
143
- change.element.textContent = finalText
144
- handleElementChange(
145
- config,
146
- elementId,
147
- change.element,
148
- onUIUpdate,
149
- )
150
-
151
- signals.setAIProcessing(false)
152
- onTooltipHide()
153
- showToast('AI edit applied', 'success')
154
- },
155
- onError: (error) => {
156
- logDebug(config.debug, 'Tooltip AI error:', error)
157
- signals.setAIProcessing(false)
158
- showToast(`AI error: ${error.message}`, 'error')
159
- },
160
- },
161
- )
162
- } catch (error) {
163
- signals.setAIProcessing(false)
164
- showToast('AI request failed', 'error')
165
- }
166
- },
167
- [config, aiService, showToast, onTooltipHide, onUIUpdate],
168
- )
169
-
170
- /**
171
- * Handle chat message send
172
- */
173
- const handleChatSend = useCallback(
174
- async (message: string, elementId?: string) => {
175
- const userMessage: ChatMessage = {
176
- id: nextMessageId('user'),
177
- role: 'user',
178
- content: message,
179
- elementId,
180
- timestamp: Date.now(),
181
- }
182
- signals.addChatMessage(userMessage)
183
-
184
- const assistantMessageId = nextMessageId('assistant')
185
- const assistantMessage: ChatMessage = {
186
- id: assistantMessageId,
187
- role: 'assistant',
188
- content: '',
189
- elementId,
190
- timestamp: Date.now(),
191
- }
192
-
193
- signals.setAIProcessing(true)
194
- signals.clearAIStatus()
195
-
196
- const manifest = signals.manifest.value
197
- const componentInstance = elementId ? getComponentInstance(manifest, elementId) : null
198
-
199
- let currentContent: string | undefined
200
- let context: string | undefined
201
-
202
- if (componentInstance) {
203
- // Component context: send component name, props, and source file
204
- currentContent = JSON.stringify({
205
- component: componentInstance.componentName,
206
- props: componentInstance.props,
207
- })
208
- context = componentInstance.file
209
- } else {
210
- const entry = elementId ? getManifestEntry(manifest, elementId) : null
211
- const parentComponent = entry?.parentComponentId
212
- ? getComponentInstance(manifest, entry.parentComponentId)
213
- : null
214
-
215
- const change = elementId ? signals.getPendingChange(elementId) : null
216
- currentContent = change
217
- ? getEditableTextFromElement(change.element)
218
- : undefined
219
- // Use the entry's source file, or fall back to parent component's file
220
- context = entry?.sourcePath
221
- ?? parentComponent?.file
222
- ?? undefined
223
- }
224
-
225
- const handleAction = (action: CmsAiAction) => {
226
- logDebug(config.debug, 'AI action received:', action)
227
- if (action.name === 'preview' && action.url) {
228
- // Open preview in new tab
229
- window.open(action.url, '_blank')
230
- } else if (action.name === 'refresh') {
231
- // Refresh the current page to show new content
232
- window.location.reload()
233
- }
234
- }
235
-
236
- let hasStarted = false
237
-
238
- try {
239
- await aiService.streamRequest(
240
- {
241
- prompt: message,
242
- elementId: elementId || '',
243
- currentContent: currentContent || '',
244
- context,
245
- },
246
- {
247
- onStart: () => {
248
- if (!hasStarted) {
249
- signals.addChatMessage(assistantMessage)
250
- hasStarted = true
251
- }
252
- },
253
- onToken: (_token, fullText) => {
254
- if (!hasStarted) {
255
- signals.addChatMessage(assistantMessage)
256
- hasStarted = true
257
- }
258
- // Update the message content using the new helper
259
- signals.updateChatMessage(assistantMessageId, fullText)
260
- },
261
- onStatus: (status, statusMessage) => {
262
- // Map SSE status strings to AIStatusType
263
- const statusMap: Record<string, AIStatusType> = {
264
- thinking: 'thinking',
265
- coding: 'coding',
266
- building: 'building',
267
- deploying: 'deploying',
268
- complete: 'complete',
269
- }
270
- const mappedStatus = statusMap[status] ?? null
271
- signals.setAIStatus(mappedStatus, statusMessage)
272
- },
273
- onAction: handleAction,
274
- onComplete: (finalText) => {
275
- if (hasStarted && !finalText.trim()) {
276
- // Remove empty assistant message instead of leaving an empty bubble
277
- signals.setChatMessages(
278
- signals.chatMessages.value.filter((m) => m.id !== assistantMessageId),
279
- )
280
- } else {
281
- signals.updateChatMessage(assistantMessageId, finalText)
282
- }
283
- signals.setAIProcessing(false)
284
- signals.clearAIStatus()
285
- },
286
- onError: (error) => {
287
- // Remove empty assistant message, keep if it has partial content
288
- const msg = signals.chatMessages.value.find((m) => m.id === assistantMessageId)
289
- if (msg && !msg.content.trim()) {
290
- signals.setChatMessages(
291
- signals.chatMessages.value.filter((m) => m.id !== assistantMessageId),
292
- )
293
- }
294
- signals.setAIProcessing(false)
295
- signals.clearAIStatus()
296
- showToast(`AI error: ${error.message}`, 'error')
297
- },
298
- },
299
- )
300
- } catch (error) {
301
- if (hasStarted) {
302
- // Remove the empty assistant message if no content was received
303
- const msg = signals.chatMessages.value.find((m) => m.id === assistantMessageId)
304
- if (msg && !msg.content.trim()) {
305
- signals.setChatMessages(
306
- signals.chatMessages.value.filter((m) => m.id !== assistantMessageId),
307
- )
308
- }
309
- }
310
- signals.setAIProcessing(false)
311
- signals.clearAIStatus()
312
- showToast('AI request failed', 'error')
313
- }
314
- },
315
- [config, aiService, showToast],
316
- )
317
-
318
- /**
319
- * Apply chat content to an element
320
- */
321
- const handleApplyToElement = useCallback(
322
- (content: string, elementId: string) => {
323
- const change = signals.getPendingChange(elementId)
324
- if (!change) {
325
- showToast('Element not found', 'error')
326
- return
327
- }
328
-
329
- change.element.textContent = content
330
- handleElementChange(config, elementId, change.element, onUIUpdate)
331
- showToast('Content applied to element', 'success')
332
- },
333
- [config, showToast, onUIUpdate],
334
- )
335
-
336
- return {
337
- aiService,
338
- handleAIChatToggle,
339
- handleChatClose,
340
- handleChatCancel,
341
- handleTooltipPromptSubmit,
342
- handleChatSend,
343
- handleApplyToElement,
344
- }
345
- }