mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-28 03:00:29 -04:00
feat(description): add deployment version descriptions (#3048)
* feat(description): added version description for deployments table * feat(description): refactor to tanstack query and remove useEffect * add wand to generate diff * ack comments * removed redundant logic, kept single source of truth for diff * updated docs * use consolidated sse parsing util, add loops & parallels check * DRY
This commit is contained in:
@@ -9,13 +9,24 @@ import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/
|
||||
|
||||
const logger = createLogger('WorkflowDeploymentVersionAPI')
|
||||
|
||||
const patchBodySchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, 'Name cannot be empty')
|
||||
.max(100, 'Name must be 100 characters or less'),
|
||||
})
|
||||
const patchBodySchema = z
|
||||
.object({
|
||||
name: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, 'Name cannot be empty')
|
||||
.max(100, 'Name must be 100 characters or less')
|
||||
.optional(),
|
||||
description: z
|
||||
.string()
|
||||
.trim()
|
||||
.max(500, 'Description must be 500 characters or less')
|
||||
.nullable()
|
||||
.optional(),
|
||||
})
|
||||
.refine((data) => data.name !== undefined || data.description !== undefined, {
|
||||
message: 'At least one of name or description must be provided',
|
||||
})
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const runtime = 'nodejs'
|
||||
@@ -88,33 +99,46 @@ export async function PATCH(
|
||||
return createErrorResponse(validation.error.errors[0]?.message || 'Invalid request body', 400)
|
||||
}
|
||||
|
||||
const { name } = validation.data
|
||||
const { name, description } = validation.data
|
||||
|
||||
const updateData: { name?: string; description?: string | null } = {}
|
||||
if (name !== undefined) {
|
||||
updateData.name = name
|
||||
}
|
||||
if (description !== undefined) {
|
||||
updateData.description = description
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(workflowDeploymentVersion)
|
||||
.set({ name })
|
||||
.set(updateData)
|
||||
.where(
|
||||
and(
|
||||
eq(workflowDeploymentVersion.workflowId, id),
|
||||
eq(workflowDeploymentVersion.version, versionNum)
|
||||
)
|
||||
)
|
||||
.returning({ id: workflowDeploymentVersion.id, name: workflowDeploymentVersion.name })
|
||||
.returning({
|
||||
id: workflowDeploymentVersion.id,
|
||||
name: workflowDeploymentVersion.name,
|
||||
description: workflowDeploymentVersion.description,
|
||||
})
|
||||
|
||||
if (!updated) {
|
||||
return createErrorResponse('Deployment version not found', 404)
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] Renamed deployment version ${version} for workflow ${id} to "${name}"`
|
||||
)
|
||||
logger.info(`[${requestId}] Updated deployment version ${version} for workflow ${id}`, {
|
||||
name: updateData.name,
|
||||
description: updateData.description,
|
||||
})
|
||||
|
||||
return createSuccessResponse({ name: updated.name })
|
||||
return createSuccessResponse({ name: updated.name, description: updated.description })
|
||||
} catch (error: any) {
|
||||
logger.error(
|
||||
`[${requestId}] Error renaming deployment version ${version} for workflow ${id}`,
|
||||
`[${requestId}] Error updating deployment version ${version} for workflow ${id}`,
|
||||
error
|
||||
)
|
||||
return createErrorResponse(error.message || 'Failed to rename deployment version', 500)
|
||||
return createErrorResponse(error.message || 'Failed to update deployment version', 500)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
||||
id: workflowDeploymentVersion.id,
|
||||
version: workflowDeploymentVersion.version,
|
||||
name: workflowDeploymentVersion.name,
|
||||
description: workflowDeploymentVersion.description,
|
||||
isActive: workflowDeploymentVersion.isActive,
|
||||
createdAt: workflowDeploymentVersion.createdAt,
|
||||
createdBy: workflowDeploymentVersion.createdBy,
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import {
|
||||
Button,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
Textarea,
|
||||
} from '@/components/emcn'
|
||||
import {
|
||||
useGenerateVersionDescription,
|
||||
useUpdateDeploymentVersion,
|
||||
} from '@/hooks/queries/deployments'
|
||||
|
||||
interface VersionDescriptionModalProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
workflowId: string
|
||||
version: number
|
||||
versionName: string
|
||||
currentDescription: string | null | undefined
|
||||
}
|
||||
|
||||
export function VersionDescriptionModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
workflowId,
|
||||
version,
|
||||
versionName,
|
||||
currentDescription,
|
||||
}: VersionDescriptionModalProps) {
|
||||
const initialDescriptionRef = useRef(currentDescription || '')
|
||||
const [description, setDescription] = useState(initialDescriptionRef.current)
|
||||
const [showUnsavedChangesAlert, setShowUnsavedChangesAlert] = useState(false)
|
||||
|
||||
const updateMutation = useUpdateDeploymentVersion()
|
||||
const generateMutation = useGenerateVersionDescription()
|
||||
|
||||
const hasChanges = description.trim() !== initialDescriptionRef.current.trim()
|
||||
const isGenerating = generateMutation.isPending
|
||||
|
||||
const handleCloseAttempt = useCallback(() => {
|
||||
if (updateMutation.isPending || isGenerating) {
|
||||
return
|
||||
}
|
||||
if (hasChanges) {
|
||||
setShowUnsavedChangesAlert(true)
|
||||
} else {
|
||||
onOpenChange(false)
|
||||
}
|
||||
}, [hasChanges, updateMutation.isPending, isGenerating, onOpenChange])
|
||||
|
||||
const handleDiscardChanges = useCallback(() => {
|
||||
setShowUnsavedChangesAlert(false)
|
||||
setDescription(initialDescriptionRef.current)
|
||||
onOpenChange(false)
|
||||
}, [onOpenChange])
|
||||
|
||||
const handleGenerateDescription = useCallback(() => {
|
||||
generateMutation.mutate({
|
||||
workflowId,
|
||||
version,
|
||||
onStreamChunk: (accumulated) => {
|
||||
setDescription(accumulated)
|
||||
},
|
||||
})
|
||||
}, [workflowId, version, generateMutation])
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
if (!workflowId) return
|
||||
|
||||
updateMutation.mutate(
|
||||
{
|
||||
workflowId,
|
||||
version,
|
||||
description: description.trim() || null,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
onOpenChange(false)
|
||||
},
|
||||
}
|
||||
)
|
||||
}, [workflowId, version, description, updateMutation, onOpenChange])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal open={open} onOpenChange={(openState) => !openState && handleCloseAttempt()}>
|
||||
<ModalContent className='max-w-[480px]'>
|
||||
<ModalHeader>
|
||||
<span>Version Description</span>
|
||||
</ModalHeader>
|
||||
<ModalBody className='space-y-[10px]'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<p className='text-[12px] text-[var(--text-secondary)]'>
|
||||
{currentDescription ? 'Edit the' : 'Add a'} description for{' '}
|
||||
<span className='font-medium text-[var(--text-primary)]'>{versionName}</span>
|
||||
</p>
|
||||
<Button
|
||||
variant='active'
|
||||
className='-my-1 h-5 px-2 py-0 text-[11px]'
|
||||
onClick={handleGenerateDescription}
|
||||
disabled={isGenerating || updateMutation.isPending}
|
||||
>
|
||||
{isGenerating ? 'Generating...' : 'Generate'}
|
||||
</Button>
|
||||
</div>
|
||||
<Textarea
|
||||
placeholder='Describe the changes in this deployment version...'
|
||||
className='min-h-[120px] resize-none'
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
maxLength={500}
|
||||
disabled={isGenerating}
|
||||
/>
|
||||
<div className='flex items-center justify-between'>
|
||||
{(updateMutation.error || generateMutation.error) && (
|
||||
<p className='text-[12px] text-[var(--text-error)]'>
|
||||
{updateMutation.error?.message || generateMutation.error?.message}
|
||||
</p>
|
||||
)}
|
||||
{!updateMutation.error && !generateMutation.error && <div />}
|
||||
<p className='text-[11px] text-[var(--text-tertiary)]'>{description.length}/500</p>
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button
|
||||
variant='default'
|
||||
onClick={handleCloseAttempt}
|
||||
disabled={updateMutation.isPending || isGenerating}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant='tertiary'
|
||||
onClick={handleSave}
|
||||
disabled={updateMutation.isPending || isGenerating || !hasChanges}
|
||||
>
|
||||
{updateMutation.isPending ? 'Saving...' : 'Save'}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
<Modal open={showUnsavedChangesAlert} onOpenChange={setShowUnsavedChangesAlert}>
|
||||
<ModalContent className='max-w-[400px]'>
|
||||
<ModalHeader>
|
||||
<span>Unsaved Changes</span>
|
||||
</ModalHeader>
|
||||
<ModalBody>
|
||||
<p className='text-[14px] text-[var(--text-secondary)]'>
|
||||
You have unsaved changes. Are you sure you want to discard them?
|
||||
</p>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant='default' onClick={() => setShowUnsavedChangesAlert(false)}>
|
||||
Keep Editing
|
||||
</Button>
|
||||
<Button variant='destructive' onClick={handleDiscardChanges}>
|
||||
Discard Changes
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,26 +1,31 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import clsx from 'clsx'
|
||||
import { MoreVertical, Pencil, RotateCcw, SendToBack } from 'lucide-react'
|
||||
import { Button, Popover, PopoverContent, PopoverItem, PopoverTrigger } from '@/components/emcn'
|
||||
import { FileText, MoreVertical, Pencil, RotateCcw, SendToBack } from 'lucide-react'
|
||||
import {
|
||||
Button,
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverItem,
|
||||
PopoverTrigger,
|
||||
Tooltip,
|
||||
} from '@/components/emcn'
|
||||
import { Skeleton } from '@/components/ui'
|
||||
import { formatDateTime } from '@/lib/core/utils/formatting'
|
||||
import type { WorkflowDeploymentVersionResponse } from '@/lib/workflows/persistence/utils'
|
||||
import { useUpdateDeploymentVersion } from '@/hooks/queries/deployments'
|
||||
import { VersionDescriptionModal } from './version-description-modal'
|
||||
|
||||
const logger = createLogger('Versions')
|
||||
|
||||
/** Shared styling constants aligned with terminal component */
|
||||
const HEADER_TEXT_CLASS = 'font-medium text-[var(--text-tertiary)] text-[12px]'
|
||||
const ROW_TEXT_CLASS = 'font-medium text-[var(--text-primary)] text-[12px]'
|
||||
const COLUMN_BASE_CLASS = 'flex-shrink-0'
|
||||
|
||||
/** Column width configuration */
|
||||
const COLUMN_WIDTHS = {
|
||||
VERSION: 'w-[180px]',
|
||||
DEPLOYED_BY: 'w-[140px]',
|
||||
TIMESTAMP: 'flex-1',
|
||||
ACTIONS: 'w-[32px]',
|
||||
ACTIONS: 'w-[56px]',
|
||||
} as const
|
||||
|
||||
interface VersionsProps {
|
||||
@@ -31,34 +36,6 @@ interface VersionsProps {
|
||||
onSelectVersion: (version: number | null) => void
|
||||
onPromoteToLive: (version: number) => void
|
||||
onLoadDeployment: (version: number) => void
|
||||
fetchVersions: () => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a timestamp into a readable string.
|
||||
* @param value - The date string or Date object to format
|
||||
* @returns Formatted string like "8:36 PM PT on Oct 11, 2025"
|
||||
*/
|
||||
const formatDate = (value: string | Date): string => {
|
||||
const date = value instanceof Date ? value : new Date(value)
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return '-'
|
||||
}
|
||||
|
||||
const timePart = date.toLocaleTimeString('en-US', {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true,
|
||||
timeZoneName: 'short',
|
||||
})
|
||||
|
||||
const datePart = date.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})
|
||||
|
||||
return `${timePart} on ${datePart}`
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -73,14 +50,15 @@ export function Versions({
|
||||
onSelectVersion,
|
||||
onPromoteToLive,
|
||||
onLoadDeployment,
|
||||
fetchVersions,
|
||||
}: VersionsProps) {
|
||||
const [editingVersion, setEditingVersion] = useState<number | null>(null)
|
||||
const [editValue, setEditValue] = useState('')
|
||||
const [isRenaming, setIsRenaming] = useState(false)
|
||||
const [openDropdown, setOpenDropdown] = useState<number | null>(null)
|
||||
const [descriptionModalVersion, setDescriptionModalVersion] = useState<number | null>(null)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const renameMutation = useUpdateDeploymentVersion()
|
||||
|
||||
useEffect(() => {
|
||||
if (editingVersion !== null && inputRef.current) {
|
||||
inputRef.current.focus()
|
||||
@@ -94,7 +72,8 @@ export function Versions({
|
||||
setEditValue(currentName || `v${version}`)
|
||||
}
|
||||
|
||||
const handleSaveRename = async (version: number) => {
|
||||
const handleSaveRename = (version: number) => {
|
||||
if (renameMutation.isPending) return
|
||||
if (!workflowId || !editValue.trim()) {
|
||||
setEditingVersion(null)
|
||||
return
|
||||
@@ -108,25 +87,21 @@ export function Versions({
|
||||
return
|
||||
}
|
||||
|
||||
setIsRenaming(true)
|
||||
try {
|
||||
const res = await fetch(`/api/workflows/${workflowId}/deployments/${version}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: editValue.trim() }),
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
await fetchVersions()
|
||||
setEditingVersion(null)
|
||||
} else {
|
||||
logger.error('Failed to rename version')
|
||||
renameMutation.mutate(
|
||||
{
|
||||
workflowId,
|
||||
version,
|
||||
name: editValue.trim(),
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setEditingVersion(null)
|
||||
},
|
||||
onError: () => {
|
||||
// Keep editing state open on error so user can retry
|
||||
},
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error renaming version:', error)
|
||||
} finally {
|
||||
setIsRenaming(false)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const handleCancelRename = () => {
|
||||
@@ -149,6 +124,16 @@ export function Versions({
|
||||
onLoadDeployment(version)
|
||||
}
|
||||
|
||||
const handleOpenDescriptionModal = (version: number) => {
|
||||
setOpenDropdown(null)
|
||||
setDescriptionModalVersion(version)
|
||||
}
|
||||
|
||||
const descriptionModalVersionData =
|
||||
descriptionModalVersion !== null
|
||||
? versions.find((v) => v.version === descriptionModalVersion)
|
||||
: null
|
||||
|
||||
if (versionsLoading && versions.length === 0) {
|
||||
return (
|
||||
<div className='overflow-hidden rounded-[4px] border border-[var(--border)]'>
|
||||
@@ -179,7 +164,14 @@ export function Versions({
|
||||
<div className={clsx(COLUMN_WIDTHS.TIMESTAMP, 'min-w-0')}>
|
||||
<Skeleton className='h-[12px] w-[160px]' />
|
||||
</div>
|
||||
<div className={clsx(COLUMN_WIDTHS.ACTIONS, COLUMN_BASE_CLASS, 'flex justify-end')}>
|
||||
<div
|
||||
className={clsx(
|
||||
COLUMN_WIDTHS.ACTIONS,
|
||||
COLUMN_BASE_CLASS,
|
||||
'flex justify-end gap-[2px]'
|
||||
)}
|
||||
>
|
||||
<Skeleton className='h-[20px] w-[20px] rounded-[4px]' />
|
||||
<Skeleton className='h-[20px] w-[20px] rounded-[4px]' />
|
||||
</div>
|
||||
</div>
|
||||
@@ -257,7 +249,7 @@ export function Versions({
|
||||
'text-[var(--text-primary)] focus:outline-none focus:ring-0'
|
||||
)}
|
||||
maxLength={100}
|
||||
disabled={isRenaming}
|
||||
disabled={renameMutation.isPending}
|
||||
autoComplete='off'
|
||||
autoCorrect='off'
|
||||
autoCapitalize='off'
|
||||
@@ -289,14 +281,40 @@ export function Versions({
|
||||
<span
|
||||
className={clsx('block truncate text-[var(--text-tertiary)]', ROW_TEXT_CLASS)}
|
||||
>
|
||||
{formatDate(v.createdAt)}
|
||||
{formatDateTime(new Date(v.createdAt))}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={clsx(COLUMN_WIDTHS.ACTIONS, COLUMN_BASE_CLASS, 'flex justify-end')}
|
||||
className={clsx(
|
||||
COLUMN_WIDTHS.ACTIONS,
|
||||
COLUMN_BASE_CLASS,
|
||||
'flex items-center justify-end gap-[2px]'
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
className={clsx(
|
||||
'!p-1',
|
||||
!v.description &&
|
||||
'text-[var(--text-quaternary)] hover:text-[var(--text-tertiary)]'
|
||||
)}
|
||||
onClick={() => handleOpenDescriptionModal(v.version)}
|
||||
>
|
||||
<FileText className='h-3.5 w-3.5' />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='top' className='max-w-[240px]'>
|
||||
{v.description ? (
|
||||
<p className='line-clamp-3 text-[12px]'>{v.description}</p>
|
||||
) : (
|
||||
<p className='text-[12px]'>Add description</p>
|
||||
)}
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
<Popover
|
||||
open={openDropdown === v.version}
|
||||
onOpenChange={(open) => setOpenDropdown(open ? v.version : null)}
|
||||
@@ -311,6 +329,10 @@ export function Versions({
|
||||
<Pencil className='h-3 w-3' />
|
||||
<span>Rename</span>
|
||||
</PopoverItem>
|
||||
<PopoverItem onClick={() => handleOpenDescriptionModal(v.version)}>
|
||||
<FileText className='h-3 w-3' />
|
||||
<span>{v.description ? 'Edit description' : 'Add description'}</span>
|
||||
</PopoverItem>
|
||||
{!v.isActive && (
|
||||
<PopoverItem onClick={() => handlePromote(v.version)}>
|
||||
<RotateCcw className='h-3 w-3' />
|
||||
@@ -328,6 +350,20 @@ export function Versions({
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{workflowId && descriptionModalVersionData && (
|
||||
<VersionDescriptionModal
|
||||
key={descriptionModalVersionData.version}
|
||||
open={descriptionModalVersion !== null}
|
||||
onOpenChange={(open) => !open && setDescriptionModalVersion(null)}
|
||||
workflowId={workflowId}
|
||||
version={descriptionModalVersionData.version}
|
||||
versionName={
|
||||
descriptionModalVersionData.name || `v${descriptionModalVersionData.version}`
|
||||
}
|
||||
currentDescription={descriptionModalVersionData.description}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -32,7 +32,6 @@ interface GeneralDeployProps {
|
||||
versionsLoading: boolean
|
||||
onPromoteToLive: (version: number) => Promise<void>
|
||||
onLoadDeploymentComplete: () => void
|
||||
fetchVersions: () => Promise<void>
|
||||
}
|
||||
|
||||
type PreviewMode = 'active' | 'selected'
|
||||
@@ -48,7 +47,6 @@ export function GeneralDeploy({
|
||||
versionsLoading,
|
||||
onPromoteToLive,
|
||||
onLoadDeploymentComplete,
|
||||
fetchVersions,
|
||||
}: GeneralDeployProps) {
|
||||
const [selectedVersion, setSelectedVersion] = useState<number | null>(null)
|
||||
const [previewMode, setPreviewMode] = useState<PreviewMode>('active')
|
||||
@@ -229,7 +227,6 @@ export function GeneralDeploy({
|
||||
onSelectVersion={handleSelectVersion}
|
||||
onPromoteToLive={handlePromoteToLive}
|
||||
onLoadDeployment={handleLoadDeployment}
|
||||
fetchVersions={fetchVersions}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -135,11 +135,9 @@ export function DeployModal({
|
||||
refetch: refetchDeploymentInfo,
|
||||
} = useDeploymentInfo(workflowId, { enabled: open && isDeployed })
|
||||
|
||||
const {
|
||||
data: versionsData,
|
||||
isLoading: versionsLoading,
|
||||
refetch: refetchVersions,
|
||||
} = useDeploymentVersions(workflowId, { enabled: open })
|
||||
const { data: versionsData, isLoading: versionsLoading } = useDeploymentVersions(workflowId, {
|
||||
enabled: open,
|
||||
})
|
||||
|
||||
const {
|
||||
isLoading: isLoadingChat,
|
||||
@@ -450,10 +448,6 @@ export function DeployModal({
|
||||
deleteTrigger?.click()
|
||||
}, [])
|
||||
|
||||
const handleFetchVersions = useCallback(async () => {
|
||||
await refetchVersions()
|
||||
}, [refetchVersions])
|
||||
|
||||
const isSubmitting = deployMutation.isPending
|
||||
const isUndeploying = undeployMutation.isPending
|
||||
|
||||
@@ -512,7 +506,6 @@ export function DeployModal({
|
||||
versionsLoading={versionsLoading}
|
||||
onPromoteToLive={handlePromoteToLive}
|
||||
onLoadDeploymentComplete={handleCloseModal}
|
||||
fetchVersions={handleFetchVersions}
|
||||
/>
|
||||
</ModalTabsContent>
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { readSSEStream } from '@/lib/core/utils/sse'
|
||||
import type { GenerationType } from '@/blocks/types'
|
||||
import { subscriptionKeys } from '@/hooks/queries/subscription'
|
||||
|
||||
@@ -184,52 +185,10 @@ export function useWand({
|
||||
throw new Error('Response body is null')
|
||||
}
|
||||
|
||||
const reader = response.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let accumulatedContent = ''
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
|
||||
const chunk = decoder.decode(value)
|
||||
const lines = chunk.split('\n\n')
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
const lineData = line.substring(6)
|
||||
|
||||
if (lineData === '[DONE]') {
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
const data = JSON.parse(lineData)
|
||||
|
||||
if (data.error) {
|
||||
throw new Error(data.error)
|
||||
}
|
||||
|
||||
if (data.chunk) {
|
||||
accumulatedContent += data.chunk
|
||||
if (onStreamChunk) {
|
||||
onStreamChunk(data.chunk)
|
||||
}
|
||||
}
|
||||
|
||||
if (data.done) {
|
||||
break
|
||||
}
|
||||
} catch (parseError) {
|
||||
logger.debug('Failed to parse SSE line', { line, parseError })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock()
|
||||
}
|
||||
const accumulatedContent = await readSSEStream(response.body, {
|
||||
onChunk: onStreamChunk,
|
||||
signal: abortControllerRef.current?.signal,
|
||||
})
|
||||
|
||||
if (accumulatedContent) {
|
||||
onGeneratedContent(accumulatedContent)
|
||||
|
||||
Reference in New Issue
Block a user