mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b572f1f61 | ||
|
|
b497033795 | ||
|
|
666dc67aa2 | ||
|
|
7af7a225f2 | ||
|
|
228578e282 | ||
|
|
be647469ac | ||
|
|
96b171cf74 | ||
|
|
cdea2404e3 |
@@ -1,7 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo, useRef, useState } from 'react'
|
||||
import { Turnstile, type TurnstileInstance } from '@marsidev/react-turnstile'
|
||||
import { useRef, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { Eye, EyeOff } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
@@ -88,8 +87,6 @@ export default function LoginPage({
|
||||
const [passwordErrors, setPasswordErrors] = useState<string[]>([])
|
||||
const [showValidationError, setShowValidationError] = useState(false)
|
||||
const [formError, setFormError] = useState<string | null>(null)
|
||||
const turnstileRef = useRef<TurnstileInstance>(null)
|
||||
const turnstileSiteKey = useMemo(() => getEnv('NEXT_PUBLIC_TURNSTILE_SITE_KEY'), [])
|
||||
const buttonClass = useBrandedButtonClass()
|
||||
|
||||
const callbackUrlParam = searchParams?.get('callbackUrl')
|
||||
@@ -169,20 +166,6 @@ export default function LoginPage({
|
||||
const safeCallbackUrl = callbackUrl
|
||||
let errorHandled = false
|
||||
|
||||
// Execute Turnstile challenge on submit and get a fresh token
|
||||
let token: string | undefined
|
||||
if (turnstileSiteKey && turnstileRef.current) {
|
||||
try {
|
||||
turnstileRef.current.reset()
|
||||
turnstileRef.current.execute()
|
||||
token = await turnstileRef.current.getResponsePromise(15_000)
|
||||
} catch {
|
||||
setFormError('Captcha verification failed. Please try again.')
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
setFormError(null)
|
||||
const result = await client.signIn.email(
|
||||
{
|
||||
@@ -191,11 +174,6 @@ export default function LoginPage({
|
||||
callbackURL: safeCallbackUrl,
|
||||
},
|
||||
{
|
||||
fetchOptions: {
|
||||
headers: {
|
||||
...(token ? { 'x-captcha-response': token } : {}),
|
||||
},
|
||||
},
|
||||
onError: (ctx) => {
|
||||
logger.error('Login error:', ctx.error)
|
||||
|
||||
@@ -464,16 +442,6 @@ export default function LoginPage({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{turnstileSiteKey && (
|
||||
<div className='absolute'>
|
||||
<Turnstile
|
||||
ref={turnstileRef}
|
||||
siteKey={turnstileSiteKey}
|
||||
options={{ size: 'invisible', execution: 'execute' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{resetSuccessMessage && (
|
||||
<div className='text-[#4CAF50] text-xs'>
|
||||
<p>{resetSuccessMessage}</p>
|
||||
|
||||
@@ -93,6 +93,8 @@ function SignupFormContent({
|
||||
const [showEmailValidationError, setShowEmailValidationError] = useState(false)
|
||||
const [formError, setFormError] = useState<string | null>(null)
|
||||
const turnstileRef = useRef<TurnstileInstance>(null)
|
||||
const captchaResolveRef = useRef<((token: string) => void) | null>(null)
|
||||
const captchaRejectRef = useRef<((reason: Error) => void) | null>(null)
|
||||
const turnstileSiteKey = useMemo(() => getEnv('NEXT_PUBLIC_TURNSTILE_SITE_KEY'), [])
|
||||
const buttonClass = useBrandedButtonClass()
|
||||
|
||||
@@ -249,17 +251,30 @@ function SignupFormContent({
|
||||
|
||||
const sanitizedName = trimmedName
|
||||
|
||||
// Execute Turnstile challenge on submit and get a fresh token
|
||||
let token: string | undefined
|
||||
if (turnstileSiteKey && turnstileRef.current) {
|
||||
const widget = turnstileRef.current
|
||||
if (turnstileSiteKey && widget) {
|
||||
let timeoutId: ReturnType<typeof setTimeout> | undefined
|
||||
try {
|
||||
turnstileRef.current.reset()
|
||||
turnstileRef.current.execute()
|
||||
token = await turnstileRef.current.getResponsePromise(15_000)
|
||||
widget.reset()
|
||||
token = await Promise.race([
|
||||
new Promise<string>((resolve, reject) => {
|
||||
captchaResolveRef.current = resolve
|
||||
captchaRejectRef.current = reject
|
||||
widget.execute()
|
||||
}),
|
||||
new Promise<string>((_, reject) => {
|
||||
timeoutId = setTimeout(() => reject(new Error('Captcha timed out')), 15_000)
|
||||
}),
|
||||
])
|
||||
} catch {
|
||||
setFormError('Captcha verification failed. Please try again.')
|
||||
setIsLoading(false)
|
||||
return
|
||||
} finally {
|
||||
clearTimeout(timeoutId)
|
||||
captchaResolveRef.current = null
|
||||
captchaRejectRef.current = null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -478,13 +493,14 @@ function SignupFormContent({
|
||||
</div>
|
||||
|
||||
{turnstileSiteKey && (
|
||||
<div className='absolute'>
|
||||
<Turnstile
|
||||
ref={turnstileRef}
|
||||
siteKey={turnstileSiteKey}
|
||||
options={{ size: 'invisible', execution: 'execute' }}
|
||||
/>
|
||||
</div>
|
||||
<Turnstile
|
||||
ref={turnstileRef}
|
||||
siteKey={turnstileSiteKey}
|
||||
onSuccess={(token) => captchaResolveRef.current?.(token)}
|
||||
onError={() => captchaRejectRef.current?.(new Error('Captcha verification failed'))}
|
||||
onExpire={() => captchaRejectRef.current?.(new Error('Captcha token expired'))}
|
||||
options={{ execution: 'execute' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{formError && (
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { Step } from 'react-joyride'
|
||||
|
||||
export const navTourSteps: Step[] = [
|
||||
{
|
||||
target: '[data-item-id="home"]',
|
||||
target: '[data-tour="nav-home"]',
|
||||
title: 'Home',
|
||||
content:
|
||||
'Your starting point. Describe what you want to build in plain language or pick a template to get started.',
|
||||
@@ -11,7 +11,7 @@ export const navTourSteps: Step[] = [
|
||||
spotlightPadding: 0,
|
||||
},
|
||||
{
|
||||
target: '[data-item-id="search"]',
|
||||
target: '[data-tour="nav-search"]',
|
||||
title: 'Search',
|
||||
content: 'Quickly find workflows, blocks, and tools. Use Cmd+K to open it from anywhere.',
|
||||
placement: 'right',
|
||||
@@ -19,7 +19,7 @@ export const navTourSteps: Step[] = [
|
||||
spotlightPadding: 0,
|
||||
},
|
||||
{
|
||||
target: '[data-item-id="tables"]',
|
||||
target: '[data-tour="nav-tables"]',
|
||||
title: 'Tables',
|
||||
content:
|
||||
'Store and query structured data. Your workflows can read and write to tables directly.',
|
||||
@@ -27,14 +27,14 @@ export const navTourSteps: Step[] = [
|
||||
disableBeacon: true,
|
||||
},
|
||||
{
|
||||
target: '[data-item-id="files"]',
|
||||
target: '[data-tour="nav-files"]',
|
||||
title: 'Files',
|
||||
content: 'Upload and manage files that your workflows can process, transform, or reference.',
|
||||
placement: 'right',
|
||||
disableBeacon: true,
|
||||
},
|
||||
{
|
||||
target: '[data-item-id="knowledge-base"]',
|
||||
target: '[data-tour="nav-knowledge-base"]',
|
||||
title: 'Knowledge Base',
|
||||
content:
|
||||
'Build knowledge bases from your documents. Set up connectors to give your agents realtime access to your data sources from sources like Notion, Drive, Slack, Confluence, and more.',
|
||||
@@ -42,7 +42,7 @@ export const navTourSteps: Step[] = [
|
||||
disableBeacon: true,
|
||||
},
|
||||
{
|
||||
target: '[data-item-id="scheduled-tasks"]',
|
||||
target: '[data-tour="nav-scheduled-tasks"]',
|
||||
title: 'Scheduled Tasks',
|
||||
content:
|
||||
'View and manage background tasks. Set up new tasks, or view the tasks the Mothership is monitoring for upcoming or past executions.',
|
||||
@@ -50,7 +50,7 @@ export const navTourSteps: Step[] = [
|
||||
disableBeacon: true,
|
||||
},
|
||||
{
|
||||
target: '[data-item-id="logs"]',
|
||||
target: '[data-tour="nav-logs"]',
|
||||
title: 'Logs',
|
||||
content:
|
||||
'Monitor every workflow execution. See inputs, outputs, errors, and timing for each run. View analytics on performance and costs, filter previous runs, and view snapshots of the workflow at the time of execution.',
|
||||
@@ -58,7 +58,7 @@ export const navTourSteps: Step[] = [
|
||||
disableBeacon: true,
|
||||
},
|
||||
{
|
||||
target: '.tasks-section',
|
||||
target: '[data-tour="nav-tasks"]',
|
||||
title: 'Tasks',
|
||||
content:
|
||||
'Tasks that work for you. Mothership can create, edit, and delete resource throughout the platform. It can also perform actions on your behalf, like sending emails, creating tasks, and more.',
|
||||
@@ -66,7 +66,7 @@ export const navTourSteps: Step[] = [
|
||||
disableBeacon: true,
|
||||
},
|
||||
{
|
||||
target: '.workflows-section',
|
||||
target: '[data-tour="nav-workflows"]',
|
||||
title: 'Workflows',
|
||||
content:
|
||||
'All your workflows live here. Create new ones with the + button and organize them into folders. Deploy your workflows as API, webhook, schedule, or chat widget. Then hit Run to test it out.',
|
||||
|
||||
@@ -72,12 +72,22 @@ export function TourTooltipAdapter({
|
||||
}
|
||||
}, [step])
|
||||
|
||||
const refCallback = useCallback(
|
||||
/**
|
||||
* Forwards the Joyride tooltip ref safely, handling both
|
||||
* callback refs and RefObject refs from the library.
|
||||
* Memoized to prevent ref churn (null → node cycling) on re-renders.
|
||||
*/
|
||||
const setJoyrideRef = useCallback(
|
||||
(node: HTMLDivElement | null) => {
|
||||
if (tooltipProps.ref) {
|
||||
;(tooltipProps.ref as React.RefCallback<HTMLDivElement>)(node)
|
||||
const { ref } = tooltipProps
|
||||
if (!ref) return
|
||||
if (typeof ref === 'function') {
|
||||
ref(node)
|
||||
} else {
|
||||
;(ref as React.MutableRefObject<HTMLDivElement | null>).current = node
|
||||
}
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[tooltipProps.ref]
|
||||
)
|
||||
|
||||
@@ -86,7 +96,7 @@ export function TourTooltipAdapter({
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
ref={refCallback}
|
||||
ref={setJoyrideRef}
|
||||
role={tooltipProps.role}
|
||||
aria-modal={tooltipProps['aria-modal']}
|
||||
style={{ position: 'absolute', opacity: 0, pointerEvents: 'none', width: 0, height: 0 }}
|
||||
|
||||
@@ -65,6 +65,13 @@ function clearTourCompletion(storageKey: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks which tours have already attempted auto-start in this page session.
|
||||
* Module-level so it survives component remounts (e.g. navigating between
|
||||
* workflows remounts WorkflowTour), while still resetting on full page reload.
|
||||
*/
|
||||
const autoStartAttempted = new Set<string>()
|
||||
|
||||
/**
|
||||
* Shared hook for managing product tour state with smooth transitions.
|
||||
*
|
||||
@@ -87,16 +94,51 @@ export function useTour({
|
||||
const [isTooltipVisible, setIsTooltipVisible] = useState(true)
|
||||
const [isEntrance, setIsEntrance] = useState(true)
|
||||
|
||||
const hasAutoStarted = useRef(false)
|
||||
const disabledRef = useRef(disabled)
|
||||
const retriggerTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const transitionTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const rafRef = useRef<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
disabledRef.current = disabled
|
||||
}, [disabled])
|
||||
|
||||
/**
|
||||
* Schedules a two-frame rAF to reveal the tooltip after the browser
|
||||
* finishes repositioning. Stores the outer frame ID in `rafRef` so
|
||||
* it can be cancelled on unmount or when the tour is interrupted.
|
||||
*/
|
||||
const scheduleReveal = useCallback(() => {
|
||||
if (rafRef.current) {
|
||||
cancelAnimationFrame(rafRef.current)
|
||||
}
|
||||
rafRef.current = requestAnimationFrame(() => {
|
||||
rafRef.current = requestAnimationFrame(() => {
|
||||
rafRef.current = null
|
||||
setIsTooltipVisible(true)
|
||||
})
|
||||
})
|
||||
}, [])
|
||||
|
||||
/** Cancels any pending transition timer and rAF reveal */
|
||||
const cancelPendingTransitions = useCallback(() => {
|
||||
if (transitionTimerRef.current) {
|
||||
clearTimeout(transitionTimerRef.current)
|
||||
transitionTimerRef.current = null
|
||||
}
|
||||
if (rafRef.current) {
|
||||
cancelAnimationFrame(rafRef.current)
|
||||
rafRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
const stopTour = useCallback(() => {
|
||||
cancelPendingTransitions()
|
||||
setRun(false)
|
||||
setIsTooltipVisible(true)
|
||||
setIsEntrance(true)
|
||||
markTourCompleted(storageKey)
|
||||
}, [storageKey])
|
||||
}, [storageKey, cancelPendingTransitions])
|
||||
|
||||
/** Transition to a new step with a coordinated fade-out/fade-in */
|
||||
const transitionToStep = useCallback(
|
||||
@@ -106,65 +148,48 @@ export function useTour({
|
||||
return
|
||||
}
|
||||
|
||||
/** Hide tooltip during transition */
|
||||
setIsTooltipVisible(false)
|
||||
|
||||
if (transitionTimerRef.current) {
|
||||
clearTimeout(transitionTimerRef.current)
|
||||
}
|
||||
cancelPendingTransitions()
|
||||
|
||||
transitionTimerRef.current = setTimeout(() => {
|
||||
transitionTimerRef.current = null
|
||||
setStepIndex(newIndex)
|
||||
setIsEntrance(false)
|
||||
|
||||
/**
|
||||
* Wait for the browser to process the Radix Popover repositioning
|
||||
* before showing the tooltip at the new position.
|
||||
*/
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
setIsTooltipVisible(true)
|
||||
})
|
||||
})
|
||||
scheduleReveal()
|
||||
}, FADE_OUT_MS)
|
||||
},
|
||||
[steps.length, stopTour]
|
||||
[steps.length, stopTour, cancelPendingTransitions, scheduleReveal]
|
||||
)
|
||||
|
||||
/** Stop the tour when disabled becomes true (e.g. navigating away from the relevant page) */
|
||||
useEffect(() => {
|
||||
if (disabled && run) {
|
||||
cancelPendingTransitions()
|
||||
setRun(false)
|
||||
setIsTooltipVisible(true)
|
||||
setIsEntrance(true)
|
||||
logger.info(`${tourName} paused — disabled became true`)
|
||||
}
|
||||
}, [disabled, run, tourName])
|
||||
}, [disabled, run, tourName, cancelPendingTransitions])
|
||||
|
||||
/** Auto-start on first visit */
|
||||
/** Auto-start on first visit (once per page session per tour) */
|
||||
useEffect(() => {
|
||||
if (disabled || hasAutoStarted.current) return
|
||||
if (disabled || autoStartAttempted.has(storageKey) || isTourCompleted(storageKey)) return
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
hasAutoStarted.current = true
|
||||
if (!isTourCompleted(storageKey)) {
|
||||
setStepIndex(0)
|
||||
setIsEntrance(true)
|
||||
setIsTooltipVisible(false)
|
||||
setRun(true)
|
||||
logger.info(`Auto-starting ${tourName}`)
|
||||
if (disabledRef.current) return
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
setIsTooltipVisible(true)
|
||||
})
|
||||
})
|
||||
}
|
||||
autoStartAttempted.add(storageKey)
|
||||
setStepIndex(0)
|
||||
setIsEntrance(true)
|
||||
setIsTooltipVisible(false)
|
||||
setRun(true)
|
||||
logger.info(`Auto-starting ${tourName}`)
|
||||
scheduleReveal()
|
||||
}, autoStartDelay)
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}, [storageKey, autoStartDelay, tourName, disabled])
|
||||
}, [disabled, storageKey, autoStartDelay, tourName, scheduleReveal])
|
||||
|
||||
/** Listen for manual trigger events */
|
||||
useEffect(() => {
|
||||
@@ -179,11 +204,6 @@ export function useTour({
|
||||
clearTimeout(retriggerTimerRef.current)
|
||||
}
|
||||
|
||||
/**
|
||||
* Start with the tooltip hidden so Joyride can mount, find the
|
||||
* target element, and position its overlay/spotlight before the
|
||||
* tooltip card appears.
|
||||
*/
|
||||
retriggerTimerRef.current = setTimeout(() => {
|
||||
retriggerTimerRef.current = null
|
||||
setStepIndex(0)
|
||||
@@ -191,12 +211,7 @@ export function useTour({
|
||||
setIsTooltipVisible(false)
|
||||
setRun(true)
|
||||
logger.info(`${tourName} triggered via event`)
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
setIsTooltipVisible(true)
|
||||
})
|
||||
})
|
||||
scheduleReveal()
|
||||
}, 50)
|
||||
}
|
||||
|
||||
@@ -207,15 +222,17 @@ export function useTour({
|
||||
clearTimeout(retriggerTimerRef.current)
|
||||
}
|
||||
}
|
||||
}, [triggerEvent, resettable, storageKey, tourName])
|
||||
}, [triggerEvent, resettable, storageKey, tourName, scheduleReveal])
|
||||
|
||||
/** Clean up all pending async work on unmount */
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (transitionTimerRef.current) {
|
||||
clearTimeout(transitionTimerRef.current)
|
||||
cancelPendingTransitions()
|
||||
if (retriggerTimerRef.current) {
|
||||
clearTimeout(retriggerTimerRef.current)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
}, [cancelPendingTransitions])
|
||||
|
||||
const handleCallback = useCallback(
|
||||
(data: CallBackProps) => {
|
||||
|
||||
@@ -10,7 +10,7 @@ export const workflowTourSteps: Step[] = [
|
||||
disableBeacon: true,
|
||||
},
|
||||
{
|
||||
target: '[data-tab-button="copilot"]',
|
||||
target: '[data-tour="tab-copilot"]',
|
||||
title: 'AI Copilot',
|
||||
content:
|
||||
'Build and debug workflows using natural language. Describe what you want and Copilot creates the blocks for you.',
|
||||
@@ -19,7 +19,7 @@ export const workflowTourSteps: Step[] = [
|
||||
spotlightPadding: 0,
|
||||
},
|
||||
{
|
||||
target: '[data-tab-button="toolbar"]',
|
||||
target: '[data-tour="tab-toolbar"]',
|
||||
title: 'Block Library',
|
||||
content:
|
||||
'Browse all available blocks and triggers. Drag them onto the canvas to build your workflow step by step.',
|
||||
@@ -28,7 +28,7 @@ export const workflowTourSteps: Step[] = [
|
||||
spotlightPadding: 0,
|
||||
},
|
||||
{
|
||||
target: '[data-tab-button="editor"]',
|
||||
target: '[data-tour="tab-editor"]',
|
||||
title: 'Block Editor',
|
||||
content:
|
||||
'Click any block on the canvas to configure it here. Set inputs, credentials, and fine-tune behavior.',
|
||||
|
||||
@@ -3,6 +3,7 @@ export {
|
||||
assistantMessageHasRenderableContent,
|
||||
MessageContent,
|
||||
} from './message-content'
|
||||
export { MothershipChat } from './mothership-chat/mothership-chat'
|
||||
export { MothershipView } from './mothership-view'
|
||||
export { QueuedMessages } from './queued-messages'
|
||||
export { TemplatePrompts } from './template-prompts'
|
||||
|
||||
@@ -44,6 +44,21 @@ export function AgentGroup({
|
||||
const [expanded, setExpanded] = useState(defaultExpanded || !allDone)
|
||||
const [mounted, setMounted] = useState(defaultExpanded || !allDone)
|
||||
const didAutoCollapseRef = useRef(allDone)
|
||||
const wasAutoExpandedRef = useRef(defaultExpanded)
|
||||
|
||||
useEffect(() => {
|
||||
if (defaultExpanded) {
|
||||
wasAutoExpandedRef.current = true
|
||||
setMounted(true)
|
||||
setExpanded(true)
|
||||
return
|
||||
}
|
||||
|
||||
if (wasAutoExpandedRef.current && allDone) {
|
||||
wasAutoExpandedRef.current = false
|
||||
setExpanded(false)
|
||||
}
|
||||
}, [defaultExpanded, allDone])
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoCollapse || didAutoCollapseRef.current) return
|
||||
@@ -65,7 +80,10 @@ export function AgentGroup({
|
||||
{hasItems ? (
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => setExpanded((prev) => !prev)}
|
||||
onClick={() => {
|
||||
wasAutoExpandedRef.current = false
|
||||
setExpanded((prev) => !prev)
|
||||
}}
|
||||
className='flex cursor-pointer items-center gap-[8px]'
|
||||
>
|
||||
<div className='flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center'>
|
||||
|
||||
@@ -389,7 +389,7 @@ export function MessageContent({
|
||||
return (
|
||||
<div key={segment.id} className={isStreaming ? 'animate-stream-fade-in' : undefined}>
|
||||
<AgentGroup
|
||||
key={`${segment.id}-${segment.id === lastOpenSubagentGroupId ? 'expanded' : 'default'}`}
|
||||
key={segment.id}
|
||||
agentName={segment.agentName}
|
||||
agentLabel={segment.agentLabel}
|
||||
items={segment.items}
|
||||
@@ -413,7 +413,9 @@ export function MessageContent({
|
||||
return (
|
||||
<div key={`stopped-${i}`} className='flex items-center gap-[8px]'>
|
||||
<CircleStop className='h-[16px] w-[16px] flex-shrink-0 text-[var(--text-icon)]' />
|
||||
<span className='font-base text-[14px] text-[var(--text-body)]'>Stopped</span>
|
||||
<span className='font-base text-[14px] text-[var(--text-body)]'>
|
||||
Stopped by user
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
'use client'
|
||||
|
||||
import { useLayoutEffect, useRef } from 'react'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { MessageActions } from '@/app/workspace/[workspaceId]/components'
|
||||
import { ChatMessageAttachments } from '@/app/workspace/[workspaceId]/home/components/chat-message-attachments'
|
||||
import {
|
||||
assistantMessageHasRenderableContent,
|
||||
MessageContent,
|
||||
} from '@/app/workspace/[workspaceId]/home/components/message-content'
|
||||
import { PendingTagIndicator } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags'
|
||||
import { QueuedMessages } from '@/app/workspace/[workspaceId]/home/components/queued-messages'
|
||||
import { UserInput } from '@/app/workspace/[workspaceId]/home/components/user-input'
|
||||
import { UserMessageContent } from '@/app/workspace/[workspaceId]/home/components/user-message-content'
|
||||
import { useAutoScroll } from '@/app/workspace/[workspaceId]/home/hooks'
|
||||
import type {
|
||||
ChatMessage,
|
||||
FileAttachmentForApi,
|
||||
QueuedMessage,
|
||||
} from '@/app/workspace/[workspaceId]/home/types'
|
||||
import type { ChatContext } from '@/stores/panel'
|
||||
|
||||
interface MothershipChatProps {
|
||||
messages: ChatMessage[]
|
||||
isSending: boolean
|
||||
onSubmit: (
|
||||
text: string,
|
||||
fileAttachments?: FileAttachmentForApi[],
|
||||
contexts?: ChatContext[]
|
||||
) => void
|
||||
onStopGeneration: () => void
|
||||
messageQueue: QueuedMessage[]
|
||||
onRemoveQueuedMessage: (id: string) => void
|
||||
onSendQueuedMessage: (id: string) => Promise<void>
|
||||
onEditQueuedMessage: (id: string) => void
|
||||
userId?: string
|
||||
onContextAdd?: (context: ChatContext) => void
|
||||
editValue?: string
|
||||
onEditValueConsumed?: () => void
|
||||
layout?: 'mothership-view' | 'copilot-view'
|
||||
initialScrollBlocked?: boolean
|
||||
animateInput?: boolean
|
||||
onInputAnimationEnd?: () => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
const LAYOUT_STYLES = {
|
||||
'mothership-view': {
|
||||
scrollContainer:
|
||||
'min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-6 pt-4 pb-8 [scrollbar-gutter:stable]',
|
||||
content: 'mx-auto max-w-[42rem] space-y-6',
|
||||
userRow: 'flex flex-col items-end gap-[6px] pt-3',
|
||||
attachmentWidth: 'max-w-[70%]',
|
||||
userBubble: 'max-w-[70%] overflow-hidden rounded-[16px] bg-[var(--surface-5)] px-3.5 py-2',
|
||||
assistantRow: 'group/msg relative pb-5',
|
||||
footer: 'flex-shrink-0 px-[24px] pb-[16px]',
|
||||
footerInner: 'mx-auto max-w-[42rem]',
|
||||
},
|
||||
'copilot-view': {
|
||||
scrollContainer: 'min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-3 pt-2 pb-4',
|
||||
content: 'space-y-4',
|
||||
userRow: 'flex flex-col items-end gap-[6px] pt-2',
|
||||
attachmentWidth: 'max-w-[85%]',
|
||||
userBubble: 'max-w-[85%] overflow-hidden rounded-[16px] bg-[var(--surface-5)] px-3 py-2',
|
||||
assistantRow: 'group/msg relative pb-3',
|
||||
footer: 'flex-shrink-0 px-3 pb-3',
|
||||
footerInner: '',
|
||||
},
|
||||
} as const
|
||||
|
||||
export function MothershipChat({
|
||||
messages,
|
||||
isSending,
|
||||
onSubmit,
|
||||
onStopGeneration,
|
||||
messageQueue,
|
||||
onRemoveQueuedMessage,
|
||||
onSendQueuedMessage,
|
||||
onEditQueuedMessage,
|
||||
userId,
|
||||
onContextAdd,
|
||||
editValue,
|
||||
onEditValueConsumed,
|
||||
layout = 'mothership-view',
|
||||
initialScrollBlocked = false,
|
||||
animateInput = false,
|
||||
onInputAnimationEnd,
|
||||
className,
|
||||
}: MothershipChatProps) {
|
||||
const styles = LAYOUT_STYLES[layout]
|
||||
const { ref: scrollContainerRef, scrollToBottom } = useAutoScroll(isSending)
|
||||
const hasMessages = messages.length > 0
|
||||
const initialScrollDoneRef = useRef(false)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!hasMessages) {
|
||||
initialScrollDoneRef.current = false
|
||||
return
|
||||
}
|
||||
if (initialScrollDoneRef.current || initialScrollBlocked) return
|
||||
initialScrollDoneRef.current = true
|
||||
scrollToBottom()
|
||||
}, [hasMessages, initialScrollBlocked, scrollToBottom])
|
||||
|
||||
return (
|
||||
<div className={cn('flex h-full min-h-0 flex-col', className)}>
|
||||
<div ref={scrollContainerRef} className={styles.scrollContainer}>
|
||||
<div className={styles.content}>
|
||||
{messages.map((msg, index) => {
|
||||
if (msg.role === 'user') {
|
||||
const hasAttachments = Boolean(msg.attachments?.length)
|
||||
return (
|
||||
<div key={msg.id} className={styles.userRow}>
|
||||
{hasAttachments && (
|
||||
<ChatMessageAttachments
|
||||
attachments={msg.attachments ?? []}
|
||||
align='end'
|
||||
className={styles.attachmentWidth}
|
||||
/>
|
||||
)}
|
||||
<div className={styles.userBubble}>
|
||||
<UserMessageContent content={msg.content} contexts={msg.contexts} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const hasAnyBlocks = Boolean(msg.contentBlocks?.length)
|
||||
const hasRenderableAssistant = assistantMessageHasRenderableContent(
|
||||
msg.contentBlocks ?? [],
|
||||
msg.content ?? ''
|
||||
)
|
||||
const isLastAssistant = index === messages.length - 1
|
||||
const isThisStreaming = isSending && isLastAssistant
|
||||
|
||||
if (!hasAnyBlocks && !msg.content?.trim() && isThisStreaming) {
|
||||
return <PendingTagIndicator key={msg.id} />
|
||||
}
|
||||
|
||||
if (!hasRenderableAssistant && !msg.content?.trim() && !isThisStreaming) {
|
||||
return null
|
||||
}
|
||||
|
||||
const isLastMessage = index === messages.length - 1
|
||||
|
||||
return (
|
||||
<div key={msg.id} className={styles.assistantRow}>
|
||||
{!isThisStreaming && (msg.content || msg.contentBlocks?.length) && (
|
||||
<div className='absolute right-0 bottom-0 z-10'>
|
||||
<MessageActions content={msg.content} requestId={msg.requestId} />
|
||||
</div>
|
||||
)}
|
||||
<MessageContent
|
||||
blocks={msg.contentBlocks || []}
|
||||
fallbackContent={msg.content}
|
||||
isStreaming={isThisStreaming}
|
||||
onOptionSelect={isLastMessage ? onSubmit : undefined}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(styles.footer, animateInput && 'animate-slide-in-bottom')}
|
||||
onAnimationEnd={animateInput ? onInputAnimationEnd : undefined}
|
||||
>
|
||||
<div className={styles.footerInner}>
|
||||
<QueuedMessages
|
||||
messageQueue={messageQueue}
|
||||
onRemove={onRemoveQueuedMessage}
|
||||
onSendNow={onSendQueuedMessage}
|
||||
onEdit={onEditQueuedMessage}
|
||||
/>
|
||||
<UserInput
|
||||
onSubmit={onSubmit}
|
||||
isSending={isSending}
|
||||
onStopGeneration={onStopGeneration}
|
||||
isInitialView={false}
|
||||
userId={userId}
|
||||
onContextAdd={onContextAdd}
|
||||
editValue={editValue}
|
||||
onEditValueConsumed={onEditValueConsumed}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { PanelLeft } from '@/components/emcn/icons'
|
||||
@@ -11,21 +11,10 @@ import {
|
||||
LandingWorkflowSeedStorage,
|
||||
} from '@/lib/core/utils/browser-storage'
|
||||
import { persistImportedWorkflow } from '@/lib/workflows/operations/import-export'
|
||||
import { MessageActions } from '@/app/workspace/[workspaceId]/components'
|
||||
import { useChatHistory, useMarkTaskRead } from '@/hooks/queries/tasks'
|
||||
import type { ChatContext } from '@/stores/panel'
|
||||
import {
|
||||
assistantMessageHasRenderableContent,
|
||||
ChatMessageAttachments,
|
||||
MessageContent,
|
||||
MothershipView,
|
||||
QueuedMessages,
|
||||
TemplatePrompts,
|
||||
UserInput,
|
||||
UserMessageContent,
|
||||
} from './components'
|
||||
import { PendingTagIndicator } from './components/message-content/components/special-tags'
|
||||
import { useAutoScroll, useChat, useMothershipResize } from './hooks'
|
||||
import { MothershipChat, MothershipView, TemplatePrompts, UserInput } from './components'
|
||||
import { getMothershipUseChatOptions, useChat, useMothershipResize } from './hooks'
|
||||
import type { FileAttachmentForApi, MothershipResource, MothershipResourceType } from './types'
|
||||
|
||||
const logger = createLogger('Home')
|
||||
@@ -173,7 +162,11 @@ export function Home({ chatId }: HomeProps = {}) {
|
||||
sendNow,
|
||||
editQueuedMessage,
|
||||
streamingFile,
|
||||
} = useChat(workspaceId, chatId, { onResourceEvent: handleResourceEvent })
|
||||
} = useChat(
|
||||
workspaceId,
|
||||
chatId,
|
||||
getMothershipUseChatOptions({ onResourceEvent: handleResourceEvent })
|
||||
)
|
||||
|
||||
const [editingInputValue, setEditingInputValue] = useState('')
|
||||
const [prevChatId, setPrevChatId] = useState(chatId)
|
||||
@@ -285,22 +278,7 @@ export function Home({ chatId }: HomeProps = {}) {
|
||||
[addResource, handleResourceEvent]
|
||||
)
|
||||
|
||||
const { ref: scrollContainerRef, scrollToBottom } = useAutoScroll(isSending)
|
||||
|
||||
const hasMessages = messages.length > 0
|
||||
const initialScrollDoneRef = useRef(false)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!hasMessages) {
|
||||
initialScrollDoneRef.current = false
|
||||
return
|
||||
}
|
||||
if (initialScrollDoneRef.current) return
|
||||
if (resources.length > 0 && isResourceCollapsed) return
|
||||
|
||||
initialScrollDoneRef.current = true
|
||||
scrollToBottom()
|
||||
}, [hasMessages, resources.length, isResourceCollapsed, scrollToBottom])
|
||||
|
||||
useEffect(() => {
|
||||
if (hasMessages) return
|
||||
@@ -354,90 +332,23 @@ export function Home({ chatId }: HomeProps = {}) {
|
||||
return (
|
||||
<div className='relative flex h-full bg-[var(--bg)]'>
|
||||
<div className='flex h-full min-w-[320px] flex-1 flex-col'>
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className='min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-6 pt-4 pb-8 [scrollbar-gutter:stable]'
|
||||
>
|
||||
<div className='mx-auto max-w-[42rem] space-y-6'>
|
||||
{messages.map((msg, index) => {
|
||||
if (msg.role === 'user') {
|
||||
const hasAttachments = msg.attachments && msg.attachments.length > 0
|
||||
return (
|
||||
<div key={msg.id} className='flex flex-col items-end gap-[6px] pt-3'>
|
||||
{hasAttachments && (
|
||||
<ChatMessageAttachments
|
||||
attachments={msg.attachments!}
|
||||
align='end'
|
||||
className='max-w-[70%]'
|
||||
/>
|
||||
)}
|
||||
<div className='max-w-[70%] overflow-hidden rounded-[16px] bg-[var(--surface-5)] px-3.5 py-2'>
|
||||
<UserMessageContent content={msg.content} contexts={msg.contexts} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const hasAnyBlocks = Boolean(msg.contentBlocks?.length)
|
||||
const hasRenderableAssistant = assistantMessageHasRenderableContent(
|
||||
msg.contentBlocks ?? [],
|
||||
msg.content ?? ''
|
||||
)
|
||||
const isLastAssistant = msg.role === 'assistant' && index === messages.length - 1
|
||||
const isThisStreaming = isSending && isLastAssistant
|
||||
|
||||
if (!hasAnyBlocks && !msg.content?.trim() && isThisStreaming) {
|
||||
return <PendingTagIndicator key={msg.id} />
|
||||
}
|
||||
|
||||
if (!hasRenderableAssistant && !msg.content?.trim() && !isThisStreaming) {
|
||||
return null
|
||||
}
|
||||
|
||||
const isLastMessage = index === messages.length - 1
|
||||
|
||||
return (
|
||||
<div key={msg.id} className='group/msg relative pb-5'>
|
||||
{!isThisStreaming && (msg.content || msg.contentBlocks?.length) && (
|
||||
<div className='absolute right-0 bottom-0 z-10'>
|
||||
<MessageActions content={msg.content} requestId={msg.requestId} />
|
||||
</div>
|
||||
)}
|
||||
<MessageContent
|
||||
blocks={msg.contentBlocks || []}
|
||||
fallbackContent={msg.content}
|
||||
isStreaming={isThisStreaming}
|
||||
onOptionSelect={isLastMessage ? sendMessage : undefined}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`flex-shrink-0 px-[24px] pb-[16px]${isInputEntering ? ' animate-slide-in-bottom' : ''}`}
|
||||
onAnimationEnd={isInputEntering ? () => setIsInputEntering(false) : undefined}
|
||||
>
|
||||
<div className='mx-auto max-w-[42rem]'>
|
||||
<QueuedMessages
|
||||
messageQueue={messageQueue}
|
||||
onRemove={removeFromQueue}
|
||||
onSendNow={sendNow}
|
||||
onEdit={handleEditQueuedMessage}
|
||||
/>
|
||||
<UserInput
|
||||
onSubmit={handleSubmit}
|
||||
isSending={isSending}
|
||||
onStopGeneration={stopGeneration}
|
||||
isInitialView={false}
|
||||
userId={session?.user?.id}
|
||||
onContextAdd={handleContextAdd}
|
||||
editValue={editingInputValue}
|
||||
onEditValueConsumed={clearEditingValue}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<MothershipChat
|
||||
messages={messages}
|
||||
isSending={isSending}
|
||||
onSubmit={handleSubmit}
|
||||
onStopGeneration={stopGeneration}
|
||||
messageQueue={messageQueue}
|
||||
onRemoveQueuedMessage={removeFromQueue}
|
||||
onSendQueuedMessage={sendNow}
|
||||
onEditQueuedMessage={handleEditQueuedMessage}
|
||||
userId={session?.user?.id}
|
||||
onContextAdd={handleContextAdd}
|
||||
editValue={editingInputValue}
|
||||
onEditValueConsumed={clearEditingValue}
|
||||
animateInput={isInputEntering}
|
||||
onInputAnimationEnd={isInputEntering ? () => setIsInputEntering(false) : undefined}
|
||||
initialScrollBlocked={resources.length > 0 && isResourceCollapsed}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Resize handle — zero-width flex child whose absolute child straddles the border */}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
export { useAnimatedPlaceholder } from './use-animated-placeholder'
|
||||
export { useAutoScroll } from './use-auto-scroll'
|
||||
export type { UseChatReturn } from './use-chat'
|
||||
export { useChat } from './use-chat'
|
||||
export {
|
||||
getMothershipUseChatOptions,
|
||||
getWorkflowCopilotUseChatOptions,
|
||||
useChat,
|
||||
} from './use-chat'
|
||||
export { useMothershipResize } from './use-mothership-resize'
|
||||
export { useStreamingReveal } from './use-streaming-reveal'
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
markRunToolManuallyStopped,
|
||||
reportManualRunToolStop,
|
||||
} from '@/lib/copilot/client-sse/run-tool-execution'
|
||||
import { MOTHERSHIP_CHAT_API_PATH } from '@/lib/copilot/constants'
|
||||
import { COPILOT_CHAT_API_PATH, MOTHERSHIP_CHAT_API_PATH } from '@/lib/copilot/constants'
|
||||
import {
|
||||
extractResourcesFromToolResult,
|
||||
isResourceToolName,
|
||||
@@ -85,8 +85,6 @@ const STATE_TO_STATUS: Record<string, ToolCallStatus> = {
|
||||
const DEPLOY_TOOL_NAMES = new Set(['deploy_api', 'deploy_chat', 'deploy_mcp', 'redeploy'])
|
||||
const RECONNECT_TAIL_ERROR =
|
||||
'Live reconnect failed before the stream finished. The latest response may be incomplete.'
|
||||
const CONTINUE_OPTIONS_CONTENT =
|
||||
'<options>{"continue":{"title":"Continue","description":"Pick up where we left off"}}</options>'
|
||||
|
||||
function mapStoredBlock(block: TaskStoredContentBlock): ContentBlock {
|
||||
const mapped: ContentBlock = {
|
||||
@@ -265,6 +263,29 @@ export interface UseChatOptions {
|
||||
onStreamEnd?: (chatId: string, messages: ChatMessage[]) => void
|
||||
}
|
||||
|
||||
export function getMothershipUseChatOptions(
|
||||
options: Pick<UseChatOptions, 'onResourceEvent' | 'onStreamEnd'> = {}
|
||||
): UseChatOptions {
|
||||
return {
|
||||
apiPath: MOTHERSHIP_CHAT_API_PATH,
|
||||
stopPath: '/api/mothership/chat/stop',
|
||||
...options,
|
||||
}
|
||||
}
|
||||
|
||||
export function getWorkflowCopilotUseChatOptions(
|
||||
options: Pick<
|
||||
UseChatOptions,
|
||||
'workflowId' | 'onToolResult' | 'onTitleUpdate' | 'onStreamEnd'
|
||||
> = {}
|
||||
): UseChatOptions {
|
||||
return {
|
||||
apiPath: COPILOT_CHAT_API_PATH,
|
||||
stopPath: '/api/mothership/chat/stop',
|
||||
...options,
|
||||
}
|
||||
}
|
||||
|
||||
export function useChat(
|
||||
workspaceId: string,
|
||||
initialChatId?: string,
|
||||
@@ -1192,14 +1213,8 @@ export function useChat(
|
||||
|
||||
if (storedBlocks.length > 0) {
|
||||
storedBlocks.push({ type: 'stopped' })
|
||||
storedBlocks.push({ type: 'text', content: CONTINUE_OPTIONS_CONTENT })
|
||||
}
|
||||
|
||||
const persistedContent =
|
||||
content && !content.includes('<options>')
|
||||
? `${content}\n\n${CONTINUE_OPTIONS_CONTENT}`
|
||||
: content
|
||||
|
||||
try {
|
||||
const res = await fetch(stopPathRef.current, {
|
||||
method: 'POST',
|
||||
@@ -1207,7 +1222,7 @@ export function useChat(
|
||||
body: JSON.stringify({
|
||||
chatId,
|
||||
streamId,
|
||||
content: persistedContent,
|
||||
content,
|
||||
...(storedBlocks.length > 0 && { contentBlocks: storedBlocks }),
|
||||
}),
|
||||
})
|
||||
@@ -1233,50 +1248,6 @@ export function useChat(
|
||||
const messagesRef = useRef(messages)
|
||||
messagesRef.current = messages
|
||||
|
||||
const resolveInterruptedToolCalls = useCallback(() => {
|
||||
setMessages((prev) => {
|
||||
const hasAnyExecuting = prev.some((m) =>
|
||||
m.contentBlocks?.some((b) => b.toolCall?.status === 'executing')
|
||||
)
|
||||
if (!hasAnyExecuting) return prev
|
||||
|
||||
let lastAssistantIdx = -1
|
||||
for (let i = prev.length - 1; i >= 0; i--) {
|
||||
if (prev[i].role === 'assistant') {
|
||||
lastAssistantIdx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
return prev.map((msg, idx) => {
|
||||
const hasExecuting = msg.contentBlocks?.some((b) => b.toolCall?.status === 'executing')
|
||||
const isLastAssistant = idx === lastAssistantIdx
|
||||
if (!hasExecuting && !isLastAssistant) return msg
|
||||
|
||||
const blocks: ContentBlock[] = (msg.contentBlocks ?? []).map((block) => {
|
||||
if (block.toolCall?.status !== 'executing') return block
|
||||
return {
|
||||
...block,
|
||||
toolCall: {
|
||||
...block.toolCall,
|
||||
status: 'cancelled' as const,
|
||||
displayTitle: 'Stopped',
|
||||
},
|
||||
}
|
||||
})
|
||||
if (isLastAssistant && !blocks.some((b) => b.type === 'stopped')) {
|
||||
blocks.push({ type: 'stopped' as const })
|
||||
}
|
||||
if (
|
||||
isLastAssistant &&
|
||||
!blocks.some((b) => b.type === 'text' && b.content?.includes('<options>'))
|
||||
) {
|
||||
blocks.push({ type: 'text', content: CONTINUE_OPTIONS_CONTENT })
|
||||
}
|
||||
return { ...msg, contentBlocks: blocks.length > 0 ? blocks : msg.contentBlocks }
|
||||
})
|
||||
})
|
||||
}, [])
|
||||
|
||||
const finalize = useCallback(
|
||||
(options?: { error?: boolean }) => {
|
||||
sendingRef.current = false
|
||||
@@ -1291,8 +1262,6 @@ export function useChat(
|
||||
}
|
||||
}
|
||||
|
||||
resolveInterruptedToolCalls()
|
||||
|
||||
if (options?.error) {
|
||||
setMessageQueue([])
|
||||
return
|
||||
@@ -1308,7 +1277,7 @@ export function useChat(
|
||||
})
|
||||
}
|
||||
},
|
||||
[invalidateChatQueries, resolveInterruptedToolCalls]
|
||||
[invalidateChatQueries]
|
||||
)
|
||||
finalizeRef.current = finalize
|
||||
|
||||
@@ -1466,7 +1435,24 @@ export function useChat(
|
||||
sendingRef.current = false
|
||||
setIsSending(false)
|
||||
|
||||
resolveInterruptedToolCalls()
|
||||
setMessages((prev) =>
|
||||
prev.map((msg) => {
|
||||
if (!msg.contentBlocks?.some((b) => b.toolCall?.status === 'executing')) return msg
|
||||
const updated = msg.contentBlocks!.map((block) => {
|
||||
if (block.toolCall?.status !== 'executing') return block
|
||||
return {
|
||||
...block,
|
||||
toolCall: {
|
||||
...block.toolCall,
|
||||
status: 'cancelled' as const,
|
||||
displayTitle: 'Stopped by user',
|
||||
},
|
||||
}
|
||||
})
|
||||
updated.push({ type: 'stopped' as const })
|
||||
return { ...msg, contentBlocks: updated }
|
||||
})
|
||||
)
|
||||
|
||||
if (sid) {
|
||||
fetch('/api/copilot/chat/abort', {
|
||||
@@ -1532,7 +1518,7 @@ export function useChat(
|
||||
|
||||
reportManualRunToolStop(workflowId, toolCallId).catch(() => {})
|
||||
}
|
||||
}, [invalidateChatQueries, persistPartialResponse, executionStream, resolveInterruptedToolCalls])
|
||||
}, [invalidateChatQueries, persistPartialResponse, executionStream])
|
||||
|
||||
const removeFromQueue = useCallback((id: string) => {
|
||||
messageQueueRef.current = messageQueueRef.current.filter((m) => m.id !== id)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { Camera, Check, Pencil } from 'lucide-react'
|
||||
import { Camera, Check, Info, Pencil } from 'lucide-react'
|
||||
import Image from 'next/image'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import {
|
||||
@@ -376,20 +376,22 @@ export function General() {
|
||||
</div>
|
||||
|
||||
<div className='flex items-center justify-between'>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Label htmlFor='auto-connect' className='cursor-help'>
|
||||
Auto-connect on drop
|
||||
</Label>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='bottom' align='start'>
|
||||
<p>Automatically connect blocks when dropped near each other</p>
|
||||
<Tooltip.Preview
|
||||
src='/tooltips/auto-connect-on-drop.mp4'
|
||||
alt='Auto-connect on drop example'
|
||||
/>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
<div className='flex items-center gap-[6px]'>
|
||||
<Label htmlFor='auto-connect'>Auto-connect on drop</Label>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Info className='h-[14px] w-[14px] cursor-default text-[var(--text-muted)]' />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='bottom' align='start'>
|
||||
<p>Automatically connect blocks when dropped near each other</p>
|
||||
<Tooltip.Preview
|
||||
src='/tooltips/auto-connect-on-drop.mp4'
|
||||
alt='Auto-connect on drop example'
|
||||
loop={false}
|
||||
/>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</div>
|
||||
<Switch
|
||||
id='auto-connect'
|
||||
checked={settings?.autoConnect ?? true}
|
||||
@@ -398,20 +400,21 @@ export function General() {
|
||||
</div>
|
||||
|
||||
<div className='flex items-center justify-between'>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Label htmlFor='error-notifications' className='cursor-help'>
|
||||
Canvas error notifications
|
||||
</Label>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='bottom' align='start'>
|
||||
<p>Show error popups on blocks when a workflow run fails</p>
|
||||
<Tooltip.Preview
|
||||
src='/tooltips/canvas-error-notification.mp4'
|
||||
alt='Canvas error notification example'
|
||||
/>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
<div className='flex items-center gap-[6px]'>
|
||||
<Label htmlFor='error-notifications'>Canvas error notifications</Label>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Info className='h-[14px] w-[14px] cursor-default text-[var(--text-muted)]' />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='bottom' align='start'>
|
||||
<p>Show error popups on blocks when a workflow run fails</p>
|
||||
<Tooltip.Preview
|
||||
src='/tooltips/canvas-error-notification.mp4'
|
||||
alt='Canvas error notification example'
|
||||
/>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</div>
|
||||
<Switch
|
||||
id='error-notifications'
|
||||
checked={settings?.errorNotificationsEnabled ?? true}
|
||||
|
||||
@@ -34,16 +34,9 @@ import { Lock, Unlock, Upload } from '@/components/emcn/icons'
|
||||
import { VariableIcon } from '@/components/icons'
|
||||
import { useSession } from '@/lib/auth/auth-client'
|
||||
import { generateWorkflowJson } from '@/lib/workflows/operations/import-export'
|
||||
import { ConversationListItem, MessageActions } from '@/app/workspace/[workspaceId]/components'
|
||||
import {
|
||||
assistantMessageHasRenderableContent,
|
||||
MessageContent,
|
||||
QueuedMessages,
|
||||
UserInput,
|
||||
UserMessageContent,
|
||||
} from '@/app/workspace/[workspaceId]/home/components'
|
||||
import { PendingTagIndicator } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags'
|
||||
import { useAutoScroll, useChat } from '@/app/workspace/[workspaceId]/home/hooks'
|
||||
import { ConversationListItem } from '@/app/workspace/[workspaceId]/components'
|
||||
import { MothershipChat } from '@/app/workspace/[workspaceId]/home/components'
|
||||
import { getWorkflowCopilotUseChatOptions, useChat } from '@/app/workspace/[workspaceId]/home/hooks'
|
||||
import type { FileAttachmentForApi } from '@/app/workspace/[workspaceId]/home/types'
|
||||
import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
|
||||
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||
@@ -332,13 +325,15 @@ export const Panel = memo(function Panel() {
|
||||
removeFromQueue: copilotRemoveFromQueue,
|
||||
sendNow: copilotSendNow,
|
||||
editQueuedMessage: copilotEditQueuedMessage,
|
||||
} = useChat(workspaceId, copilotChatId, {
|
||||
apiPath: '/api/copilot/chat',
|
||||
stopPath: '/api/mothership/chat/stop',
|
||||
workflowId: activeWorkflowId || undefined,
|
||||
onTitleUpdate: loadCopilotChats,
|
||||
onToolResult: handleCopilotToolResult,
|
||||
})
|
||||
} = useChat(
|
||||
workspaceId,
|
||||
copilotChatId,
|
||||
getWorkflowCopilotUseChatOptions({
|
||||
workflowId: activeWorkflowId || undefined,
|
||||
onTitleUpdate: loadCopilotChats,
|
||||
onToolResult: handleCopilotToolResult,
|
||||
})
|
||||
)
|
||||
|
||||
const handleCopilotNewChat = useCallback(() => {
|
||||
if (!activeWorkflowId || !workspaceId) return
|
||||
@@ -403,9 +398,6 @@ export const Panel = memo(function Panel() {
|
||||
[copilotSendMessage]
|
||||
)
|
||||
|
||||
const { ref: copilotScrollRef, scrollToBottom: copilotScrollToBottom } =
|
||||
useAutoScroll(copilotIsSending)
|
||||
|
||||
/**
|
||||
* Mark hydration as complete on mount
|
||||
* This allows React to take over visibility control from CSS
|
||||
@@ -700,6 +692,7 @@ export const Panel = memo(function Panel() {
|
||||
variant={_hasHydrated && activeTab === 'copilot' ? 'active' : 'ghost'}
|
||||
onClick={() => handleTabClick('copilot')}
|
||||
data-tab-button='copilot'
|
||||
data-tour='tab-copilot'
|
||||
>
|
||||
Copilot
|
||||
</Button>
|
||||
@@ -713,6 +706,7 @@ export const Panel = memo(function Panel() {
|
||||
variant={_hasHydrated && activeTab === 'toolbar' ? 'active' : 'ghost'}
|
||||
onClick={() => handleTabClick('toolbar')}
|
||||
data-tab-button='toolbar'
|
||||
data-tour='tab-toolbar'
|
||||
>
|
||||
Toolbar
|
||||
</Button>
|
||||
@@ -725,6 +719,7 @@ export const Panel = memo(function Panel() {
|
||||
variant={_hasHydrated && activeTab === 'editor' ? 'active' : 'ghost'}
|
||||
onClick={() => handleTabClick('editor')}
|
||||
data-tab-button='editor'
|
||||
data-tour='tab-editor'
|
||||
>
|
||||
Editor
|
||||
</Button>
|
||||
@@ -813,77 +808,21 @@ export const Panel = memo(function Panel() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={copilotScrollRef}
|
||||
className='min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-3 pt-2 pb-4'
|
||||
>
|
||||
<div className='space-y-4'>
|
||||
{copilotMessages.map((msg, index) => {
|
||||
if (msg.role === 'user') {
|
||||
return (
|
||||
<div key={msg.id} className='flex flex-col items-end gap-[6px] pt-2'>
|
||||
<div className='max-w-[85%] overflow-hidden rounded-[16px] bg-[var(--surface-5)] px-3 py-2'>
|
||||
<UserMessageContent content={msg.content} contexts={msg.contexts} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const hasAnyBlocks = Boolean(msg.contentBlocks?.length)
|
||||
const hasRenderableAssistant = assistantMessageHasRenderableContent(
|
||||
msg.contentBlocks ?? [],
|
||||
msg.content ?? ''
|
||||
)
|
||||
const isLastAssistant =
|
||||
msg.role === 'assistant' && index === copilotMessages.length - 1
|
||||
const isThisStreaming = copilotIsSending && isLastAssistant
|
||||
|
||||
if (!hasAnyBlocks && !msg.content?.trim() && isThisStreaming) {
|
||||
return <PendingTagIndicator key={msg.id} />
|
||||
}
|
||||
|
||||
if (!hasRenderableAssistant && !msg.content?.trim() && !isThisStreaming) {
|
||||
return null
|
||||
}
|
||||
|
||||
const isLastMessage = index === copilotMessages.length - 1
|
||||
|
||||
return (
|
||||
<div key={msg.id} className='group/msg relative pb-3'>
|
||||
{!isThisStreaming && (msg.content || msg.contentBlocks?.length) && (
|
||||
<div className='absolute right-0 bottom-0 z-10'>
|
||||
<MessageActions content={msg.content} requestId={msg.requestId} />
|
||||
</div>
|
||||
)}
|
||||
<MessageContent
|
||||
blocks={msg.contentBlocks || []}
|
||||
fallbackContent={msg.content}
|
||||
isStreaming={isThisStreaming}
|
||||
onOptionSelect={isLastMessage ? copilotSendMessage : undefined}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex-shrink-0 px-3 pb-3'>
|
||||
<QueuedMessages
|
||||
messageQueue={copilotMessageQueue}
|
||||
onRemove={copilotRemoveFromQueue}
|
||||
onSendNow={copilotSendNow}
|
||||
onEdit={handleCopilotEditQueuedMessage}
|
||||
/>
|
||||
<UserInput
|
||||
onSubmit={handleCopilotSubmit}
|
||||
isSending={copilotIsSending}
|
||||
onStopGeneration={copilotStopGeneration}
|
||||
isInitialView={false}
|
||||
userId={session?.user?.id}
|
||||
editValue={copilotEditingInputValue}
|
||||
onEditValueConsumed={clearCopilotEditingValue}
|
||||
/>
|
||||
</div>
|
||||
<MothershipChat
|
||||
className='min-h-0 flex-1'
|
||||
messages={copilotMessages}
|
||||
isSending={copilotIsSending}
|
||||
onSubmit={handleCopilotSubmit}
|
||||
onStopGeneration={copilotStopGeneration}
|
||||
messageQueue={copilotMessageQueue}
|
||||
onRemoveQueuedMessage={copilotRemoveFromQueue}
|
||||
onSendQueuedMessage={copilotSendNow}
|
||||
onEditQueuedMessage={handleCopilotEditQueuedMessage}
|
||||
userId={session?.user?.id}
|
||||
editValue={copilotEditingInputValue}
|
||||
onEditValueConsumed={clearCopilotEditingValue}
|
||||
layout='copilot-view'
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
|
||||
@@ -215,6 +215,7 @@ const SidebarNavItem = memo(function SidebarNavItem({
|
||||
<Link
|
||||
href={item.href}
|
||||
data-item-id={item.id}
|
||||
data-tour={`nav-${item.id}`}
|
||||
className={`${baseClasses} ${activeClasses}`}
|
||||
onClick={
|
||||
item.onClick
|
||||
@@ -233,6 +234,7 @@ const SidebarNavItem = memo(function SidebarNavItem({
|
||||
<button
|
||||
type='button'
|
||||
data-item-id={item.id}
|
||||
data-tour={`nav-${item.id}`}
|
||||
className={`${baseClasses} ${activeClasses}`}
|
||||
onClick={item.onClick}
|
||||
>
|
||||
@@ -1139,7 +1141,7 @@ export const Sidebar = memo(function Sidebar() {
|
||||
)}
|
||||
>
|
||||
{/* Tasks */}
|
||||
<div className='tasks-section flex flex-shrink-0 flex-col'>
|
||||
<div className='tasks-section flex flex-shrink-0 flex-col' data-tour='nav-tasks'>
|
||||
<div className='flex h-[18px] flex-shrink-0 items-center justify-between px-[16px]'>
|
||||
<div className='font-base text-[var(--text-icon)] text-small'>All tasks</div>
|
||||
{!isCollapsed && (
|
||||
@@ -1253,7 +1255,10 @@ export const Sidebar = memo(function Sidebar() {
|
||||
</div>
|
||||
|
||||
{/* Workflows */}
|
||||
<div className='workflows-section relative mt-[14px] flex flex-col'>
|
||||
<div
|
||||
className='workflows-section relative mt-[14px] flex flex-col'
|
||||
data-tour='nav-workflows'
|
||||
>
|
||||
<div className='flex h-[18px] flex-shrink-0 items-center justify-between px-[16px]'>
|
||||
<div className='font-base text-[var(--text-icon)] text-small'>Workflows</div>
|
||||
{!isCollapsed && (
|
||||
|
||||
@@ -50,7 +50,7 @@ const Content = React.forwardRef<
|
||||
collisionPadding={8}
|
||||
avoidCollisions={true}
|
||||
className={cn(
|
||||
'z-[10000300] rounded-[4px] bg-[#1b1b1b] px-[8px] py-[3.5px] font-base text-white text-xs shadow-sm dark:bg-[#fdfdfd] dark:text-black',
|
||||
'z-[10000300] max-w-[260px] rounded-[4px] bg-[#1b1b1b] px-[8px] py-[3.5px] font-base text-white text-xs shadow-sm dark:bg-[#fdfdfd] dark:text-black',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -98,6 +98,8 @@ interface PreviewProps {
|
||||
width?: number
|
||||
/** Height of the preview in pixels */
|
||||
height?: number
|
||||
/** Whether video should loop */
|
||||
loop?: boolean
|
||||
/** Optional additional class names */
|
||||
className?: string
|
||||
}
|
||||
@@ -115,20 +117,22 @@ const VIDEO_EXTENSIONS = ['.mp4', '.webm', '.ogg', '.mov'] as const
|
||||
* </Tooltip.Content>
|
||||
* ```
|
||||
*/
|
||||
const Preview = ({ src, alt = '', width = 240, height, className }: PreviewProps) => {
|
||||
const Preview = ({ src, alt = '', width = 240, height, loop = true, className }: PreviewProps) => {
|
||||
const pathname = src.toLowerCase().split('?')[0].split('#')[0]
|
||||
const isVideo = VIDEO_EXTENSIONS.some((ext) => pathname.endsWith(ext))
|
||||
|
||||
return (
|
||||
<div className={cn('mt-[4px] overflow-hidden rounded-[3px]', className)}>
|
||||
<div
|
||||
className={cn('-mx-[8px] -mb-[3.5px] mt-[4px] overflow-hidden rounded-b-[4px]', className)}
|
||||
>
|
||||
{isVideo ? (
|
||||
<video
|
||||
src={src}
|
||||
width={width}
|
||||
height={height}
|
||||
className='block max-w-full'
|
||||
className='block w-full'
|
||||
autoPlay
|
||||
loop
|
||||
loop={loop}
|
||||
muted
|
||||
playsInline
|
||||
preload='none'
|
||||
@@ -140,7 +144,7 @@ const Preview = ({ src, alt = '', width = 240, height, className }: PreviewProps
|
||||
alt={alt}
|
||||
width={width}
|
||||
height={height}
|
||||
className='block max-w-full'
|
||||
className='block w-full'
|
||||
loading='lazy'
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import type * as React from 'react'
|
||||
import { type RefObject, useRef } from 'react'
|
||||
import * as PopoverPrimitive from '@radix-ui/react-popover'
|
||||
import { X } from 'lucide-react'
|
||||
import { createPortal } from 'react-dom'
|
||||
@@ -67,8 +68,13 @@ function TourCard({
|
||||
{step} / {totalSteps}
|
||||
</span>
|
||||
<div className='flex items-center gap-[6px]'>
|
||||
<div className={cn(isFirst && 'pointer-events-none opacity-0')}>
|
||||
<Button variant='default' size='sm' onClick={onBack}>
|
||||
<div className={cn(isFirst && 'invisible')}>
|
||||
<Button
|
||||
variant='default'
|
||||
size='sm'
|
||||
onClick={onBack}
|
||||
tabIndex={isFirst ? -1 : undefined}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
@@ -141,6 +147,9 @@ function TourTooltip({
|
||||
onClose,
|
||||
className,
|
||||
}: TourTooltipProps) {
|
||||
const virtualRef = useRef<HTMLElement | null>(null)
|
||||
virtualRef.current = targetEl
|
||||
|
||||
if (typeof document === 'undefined') return null
|
||||
if (!isVisible) return null
|
||||
|
||||
@@ -186,7 +195,7 @@ function TourTooltip({
|
||||
|
||||
return createPortal(
|
||||
<PopoverPrimitive.Root open>
|
||||
<PopoverPrimitive.Anchor virtualRef={{ current: targetEl }} />
|
||||
<PopoverPrimitive.Anchor virtualRef={virtualRef as RefObject<HTMLElement>} />
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
side={PLACEMENT_TO_SIDE[placement] || 'bottom'}
|
||||
|
||||
@@ -717,7 +717,7 @@ export const auth = betterAuth({
|
||||
captcha({
|
||||
provider: 'cloudflare-turnstile',
|
||||
secretKey: env.TURNSTILE_SECRET_KEY,
|
||||
endpoints: ['/sign-up/email', '/sign-in/email'],
|
||||
endpoints: ['/sign-up/email'],
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
|
||||
@@ -6,13 +6,14 @@ import {
|
||||
knowledgeConnectorSyncLog,
|
||||
} from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq, inArray, isNull, lt, ne, sql } from 'drizzle-orm'
|
||||
import { and, eq, gt, inArray, isNull, lt, ne, or, sql } from 'drizzle-orm'
|
||||
import { decryptApiKey } from '@/lib/api-key/crypto'
|
||||
import { getInternalApiBaseUrl } from '@/lib/core/utils/urls'
|
||||
import type { DocumentData } from '@/lib/knowledge/documents/service'
|
||||
import {
|
||||
hardDeleteDocuments,
|
||||
isTriggerAvailable,
|
||||
processDocumentAsync,
|
||||
processDocumentsWithQueue,
|
||||
} from '@/lib/knowledge/documents/service'
|
||||
import { StorageService } from '@/lib/uploads'
|
||||
import { deleteFile } from '@/lib/uploads/core/storage-service'
|
||||
@@ -39,6 +40,8 @@ class ConnectorDeletedException extends Error {
|
||||
const SYNC_BATCH_SIZE = 5
|
||||
const MAX_PAGES = 500
|
||||
const MAX_SAFE_TITLE_LENGTH = 200
|
||||
const STALE_PROCESSING_MINUTES = 45
|
||||
const RETRY_WINDOW_DAYS = 7
|
||||
|
||||
/** Sanitizes a document title for use in S3 storage keys. */
|
||||
function sanitizeStorageTitle(title: string): string {
|
||||
@@ -147,11 +150,14 @@ export async function dispatchSync(
|
||||
const requestId = options?.requestId ?? crypto.randomUUID()
|
||||
|
||||
if (isTriggerAvailable()) {
|
||||
await knowledgeConnectorSync.trigger({
|
||||
connectorId,
|
||||
fullSync: options?.fullSync,
|
||||
requestId,
|
||||
})
|
||||
await knowledgeConnectorSync.trigger(
|
||||
{
|
||||
connectorId,
|
||||
fullSync: options?.fullSync,
|
||||
requestId,
|
||||
},
|
||||
{ tags: [`connector:${connectorId}`] }
|
||||
)
|
||||
logger.info(`Dispatched connector sync to Trigger.dev`, { connectorId, requestId })
|
||||
} else {
|
||||
executeSync(connectorId, { fullSync: options?.fullSync }).catch((error) => {
|
||||
@@ -395,6 +401,8 @@ export async function executeSync(
|
||||
|
||||
const seenExternalIds = new Set<string>()
|
||||
|
||||
const pendingProcessing: DocumentData[] = []
|
||||
|
||||
const pendingOps: DocOp[] = []
|
||||
for (const extDoc of externalDocs) {
|
||||
seenExternalIds.add(extDoc.externalId)
|
||||
@@ -503,6 +511,7 @@ export async function executeSync(
|
||||
for (let j = 0; j < settled.length; j++) {
|
||||
const outcome = settled[j]
|
||||
if (outcome.status === 'fulfilled') {
|
||||
pendingProcessing.push(outcome.value)
|
||||
if (batch[j].type === 'add') result.docsAdded++
|
||||
else result.docsUpdated++
|
||||
} else {
|
||||
@@ -537,9 +546,14 @@ export async function executeSync(
|
||||
throw new Error(`Knowledge base ${connector.knowledgeBaseId} was deleted during sync`)
|
||||
}
|
||||
|
||||
// Retry stuck documents that failed or never completed processing.
|
||||
// Retry stuck documents that failed, never started, or were abandoned mid-processing.
|
||||
// Only retry docs uploaded BEFORE this sync — docs added in the current sync
|
||||
// are still processing asynchronously and would cause a duplicate processing race.
|
||||
// Documents stuck in 'processing' beyond STALE_PROCESSING_MINUTES are considered
|
||||
// abandoned (e.g. the Trigger.dev task process exited before processing completed).
|
||||
// Documents uploaded more than RETRY_WINDOW_DAYS ago are not retried.
|
||||
const staleProcessingCutoff = new Date(Date.now() - STALE_PROCESSING_MINUTES * 60 * 1000)
|
||||
const retryCutoff = new Date(Date.now() - RETRY_WINDOW_DAYS * 24 * 60 * 60 * 1000)
|
||||
const stuckDocs = await db
|
||||
.select({
|
||||
id: document.id,
|
||||
@@ -552,8 +566,18 @@ export async function executeSync(
|
||||
.where(
|
||||
and(
|
||||
eq(document.connectorId, connectorId),
|
||||
inArray(document.processingStatus, ['pending', 'failed']),
|
||||
or(
|
||||
inArray(document.processingStatus, ['pending', 'failed']),
|
||||
and(
|
||||
eq(document.processingStatus, 'processing'),
|
||||
or(
|
||||
isNull(document.processingStartedAt),
|
||||
lt(document.processingStartedAt, staleProcessingCutoff)
|
||||
)
|
||||
)
|
||||
),
|
||||
lt(document.uploadedAt, syncStartedAt),
|
||||
gt(document.uploadedAt, retryCutoff),
|
||||
eq(document.userExcluded, false),
|
||||
isNull(document.archivedAt),
|
||||
isNull(document.deletedAt)
|
||||
@@ -562,28 +586,60 @@ export async function executeSync(
|
||||
|
||||
if (stuckDocs.length > 0) {
|
||||
logger.info(`Retrying ${stuckDocs.length} stuck documents`, { connectorId })
|
||||
for (const doc of stuckDocs) {
|
||||
processDocumentAsync(
|
||||
connector.knowledgeBaseId,
|
||||
doc.id,
|
||||
{
|
||||
try {
|
||||
await processDocumentsWithQueue(
|
||||
stuckDocs.map((doc) => ({
|
||||
documentId: doc.id,
|
||||
filename: doc.filename ?? 'document.txt',
|
||||
fileUrl: doc.fileUrl ?? '',
|
||||
fileSize: doc.fileSize ?? 0,
|
||||
mimeType: doc.mimeType ?? 'text/plain',
|
||||
},
|
||||
{}
|
||||
).catch((error) => {
|
||||
logger.warn('Failed to retry stuck document', {
|
||||
documentId: doc.id,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
})),
|
||||
connector.knowledgeBaseId,
|
||||
{},
|
||||
crypto.randomUUID()
|
||||
)
|
||||
} catch (error) {
|
||||
logger.warn('Failed to enqueue stuck documents for reprocessing', {
|
||||
connectorId,
|
||||
count: stuckDocs.length,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Enqueue all added/updated documents for processing in a single batch
|
||||
if (pendingProcessing.length > 0) {
|
||||
try {
|
||||
await processDocumentsWithQueue(
|
||||
pendingProcessing,
|
||||
connector.knowledgeBaseId,
|
||||
{},
|
||||
crypto.randomUUID()
|
||||
)
|
||||
} catch (error) {
|
||||
logger.warn('Failed to enqueue documents for processing — will retry on next sync', {
|
||||
connectorId,
|
||||
count: pendingProcessing.length,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
await completeSyncLog(syncLogId, 'completed', result)
|
||||
|
||||
const [{ count: actualDocCount }] = await db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(document)
|
||||
.where(
|
||||
and(
|
||||
eq(document.connectorId, connectorId),
|
||||
eq(document.userExcluded, false),
|
||||
isNull(document.archivedAt),
|
||||
isNull(document.deletedAt)
|
||||
)
|
||||
)
|
||||
|
||||
const now = new Date()
|
||||
await db
|
||||
.update(knowledgeConnector)
|
||||
@@ -591,7 +647,7 @@ export async function executeSync(
|
||||
status: 'active',
|
||||
lastSyncAt: now,
|
||||
lastSyncError: null,
|
||||
lastSyncDocCount: externalDocs.length,
|
||||
lastSyncDocCount: actualDocCount,
|
||||
nextSyncAt: calculateNextSyncTime(connector.syncIntervalMinutes),
|
||||
consecutiveFailures: 0,
|
||||
updatedAt: now,
|
||||
@@ -711,7 +767,7 @@ async function addDocument(
|
||||
connectorType: string,
|
||||
extDoc: ExternalDocument,
|
||||
sourceConfig?: Record<string, unknown>
|
||||
): Promise<void> {
|
||||
): Promise<DocumentData> {
|
||||
if (await isKnowledgeBaseDeleted(knowledgeBaseId)) {
|
||||
throw new Error(`Knowledge base ${knowledgeBaseId} is deleted`)
|
||||
}
|
||||
@@ -773,23 +829,13 @@ async function addDocument(
|
||||
throw error
|
||||
}
|
||||
|
||||
processDocumentAsync(
|
||||
knowledgeBaseId,
|
||||
return {
|
||||
documentId,
|
||||
{
|
||||
filename: processingFilename,
|
||||
fileUrl,
|
||||
fileSize: contentBuffer.length,
|
||||
mimeType: 'text/plain',
|
||||
},
|
||||
{}
|
||||
).catch((error) => {
|
||||
logger.error('Failed to process connector document', {
|
||||
documentId,
|
||||
connectorId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
})
|
||||
filename: processingFilename,
|
||||
fileUrl,
|
||||
fileSize: contentBuffer.length,
|
||||
mimeType: 'text/plain',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -803,7 +849,7 @@ async function updateDocument(
|
||||
connectorType: string,
|
||||
extDoc: ExternalDocument,
|
||||
sourceConfig?: Record<string, unknown>
|
||||
): Promise<void> {
|
||||
): Promise<DocumentData> {
|
||||
if (await isKnowledgeBaseDeleted(knowledgeBaseId)) {
|
||||
throw new Error(`Knowledge base ${knowledgeBaseId} is deleted`)
|
||||
}
|
||||
@@ -894,21 +940,11 @@ async function updateDocument(
|
||||
}
|
||||
}
|
||||
|
||||
processDocumentAsync(
|
||||
knowledgeBaseId,
|
||||
existingDocId,
|
||||
{
|
||||
filename: processingFilename,
|
||||
fileUrl,
|
||||
fileSize: contentBuffer.length,
|
||||
mimeType: 'text/plain',
|
||||
},
|
||||
{}
|
||||
).catch((error) => {
|
||||
logger.error('Failed to re-process updated connector document', {
|
||||
documentId: existingDocId,
|
||||
connectorId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
})
|
||||
return {
|
||||
documentId: existingDocId,
|
||||
filename: processingFilename,
|
||||
fileUrl,
|
||||
fileSize: contentBuffer.length,
|
||||
mimeType: 'text/plain',
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,11 +114,11 @@ export interface DocumentData {
|
||||
}
|
||||
|
||||
export interface ProcessingOptions {
|
||||
chunkSize: number
|
||||
minCharactersPerChunk: number
|
||||
recipe: string
|
||||
lang: string
|
||||
chunkOverlap: number
|
||||
chunkSize?: number
|
||||
minCharactersPerChunk?: number
|
||||
recipe?: string
|
||||
lang?: string
|
||||
chunkOverlap?: number
|
||||
}
|
||||
|
||||
export interface DocumentJobData {
|
||||
@@ -668,7 +668,7 @@ export function isTriggerAvailable(): boolean {
|
||||
export async function processDocumentsWithTrigger(
|
||||
documents: DocumentProcessingPayload[],
|
||||
requestId: string
|
||||
): Promise<{ success: boolean; message: string; jobIds?: string[] }> {
|
||||
): Promise<{ success: boolean; message: string; batchIds?: string[] }> {
|
||||
if (!isTriggerAvailable()) {
|
||||
throw new Error('Trigger.dev is not configured - TRIGGER_SECRET_KEY missing')
|
||||
}
|
||||
@@ -676,19 +676,32 @@ export async function processDocumentsWithTrigger(
|
||||
try {
|
||||
logger.info(`[${requestId}] Triggering background processing for ${documents.length} documents`)
|
||||
|
||||
const jobPromises = documents.map(async (document) => {
|
||||
const job = await tasks.trigger('knowledge-process-document', document)
|
||||
return job.id
|
||||
})
|
||||
const MAX_BATCH_SIZE = 1000
|
||||
const batchIds: string[] = []
|
||||
|
||||
const jobIds = await Promise.all(jobPromises)
|
||||
for (let i = 0; i < documents.length; i += MAX_BATCH_SIZE) {
|
||||
const chunk = documents.slice(i, i + MAX_BATCH_SIZE)
|
||||
const batchResult = await tasks.batchTrigger(
|
||||
'knowledge-process-document',
|
||||
chunk.map((doc) => ({
|
||||
payload: doc,
|
||||
options: {
|
||||
idempotencyKey: `doc-process-${doc.documentId}-${requestId}`,
|
||||
tags: [`kb:${doc.knowledgeBaseId}`, `doc:${doc.documentId}`],
|
||||
},
|
||||
}))
|
||||
)
|
||||
batchIds.push(batchResult.batchId)
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Triggered ${jobIds.length} document processing jobs`)
|
||||
logger.info(
|
||||
`[${requestId}] Triggered ${documents.length} document processing jobs in ${batchIds.length} batch(es)`
|
||||
)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `${documents.length} document processing jobs triggered`,
|
||||
jobIds,
|
||||
batchIds,
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Failed to trigger document processing jobs:`, error)
|
||||
@@ -1590,10 +1603,19 @@ export async function retryDocumentProcessing(
|
||||
chunkOverlap: kbConfig.overlap,
|
||||
}
|
||||
|
||||
processDocumentAsync(knowledgeBaseId, documentId, docData, processingOptions).catch(
|
||||
(error: unknown) => {
|
||||
logger.error(`[${requestId}] Background retry processing error:`, error)
|
||||
}
|
||||
await processDocumentsWithQueue(
|
||||
[
|
||||
{
|
||||
documentId,
|
||||
filename: docData.filename,
|
||||
fileUrl: docData.fileUrl,
|
||||
fileSize: docData.fileSize,
|
||||
mimeType: docData.mimeType,
|
||||
},
|
||||
],
|
||||
knowledgeBaseId,
|
||||
processingOptions,
|
||||
requestId
|
||||
)
|
||||
|
||||
logger.info(`[${requestId}] Document retry initiated: ${documentId}`)
|
||||
|
||||
Binary file not shown.
1
bun.lock
1
bun.lock
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 0,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "simstudio",
|
||||
|
||||
2
packages/db/migrations/0181_dazzling_the_leader.sql
Normal file
2
packages/db/migrations/0181_dazzling_the_leader.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE "user_stats" ALTER COLUMN "total_tokens_used" SET DATA TYPE bigint;--> statement-breakpoint
|
||||
ALTER TABLE "user_stats" ALTER COLUMN "total_copilot_tokens" SET DATA TYPE bigint;
|
||||
14430
packages/db/migrations/meta/0181_snapshot.json
Normal file
14430
packages/db/migrations/meta/0181_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1261,6 +1261,13 @@
|
||||
"when": 1774305252055,
|
||||
"tag": "0180_amused_marvel_boy",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 181,
|
||||
"version": "7",
|
||||
"when": 1774411211528,
|
||||
"tag": "0181_dazzling_the_leader",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -753,7 +753,7 @@ export const userStats = pgTable('user_stats', {
|
||||
totalChatExecutions: integer('total_chat_executions').notNull().default(0),
|
||||
totalMcpExecutions: integer('total_mcp_executions').notNull().default(0),
|
||||
totalA2aExecutions: integer('total_a2a_executions').notNull().default(0),
|
||||
totalTokensUsed: integer('total_tokens_used').notNull().default(0),
|
||||
totalTokensUsed: bigint('total_tokens_used', { mode: 'number' }).notNull().default(0),
|
||||
totalCost: decimal('total_cost').notNull().default('0'),
|
||||
currentUsageLimit: decimal('current_usage_limit').default(DEFAULT_FREE_CREDITS.toString()), // Default $5 (1,000 credits) for free plan, null for team/enterprise
|
||||
usageLimitUpdatedAt: timestamp('usage_limit_updated_at').defaultNow(),
|
||||
@@ -769,7 +769,7 @@ export const userStats = pgTable('user_stats', {
|
||||
totalCopilotCost: decimal('total_copilot_cost').notNull().default('0'),
|
||||
currentPeriodCopilotCost: decimal('current_period_copilot_cost').notNull().default('0'),
|
||||
lastPeriodCopilotCost: decimal('last_period_copilot_cost').default('0'),
|
||||
totalCopilotTokens: integer('total_copilot_tokens').notNull().default(0),
|
||||
totalCopilotTokens: bigint('total_copilot_tokens', { mode: 'number' }).notNull().default(0),
|
||||
totalCopilotCalls: integer('total_copilot_calls').notNull().default(0),
|
||||
// MCP Copilot usage tracking
|
||||
totalMcpCopilotCalls: integer('total_mcp_copilot_calls').notNull().default(0),
|
||||
|
||||
Reference in New Issue
Block a user