mirror of
https://github.com/simstudioai/sim.git
synced 2026-02-11 07:04:58 -05:00
Compare commits
1 Commits
v0.5.87
...
feat/strea
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
031866e07c |
@@ -38,7 +38,6 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const isInternalCall = auth.authType === 'internal_jwt'
|
||||
const userId = auth.userId || null
|
||||
|
||||
let workflowData = await getWorkflowById(workflowId)
|
||||
@@ -48,32 +47,29 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
||||
return NextResponse.json({ error: 'Workflow not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (isInternalCall && !userId) {
|
||||
// Internal system calls (e.g. workflow-in-workflow executor) may not carry a userId.
|
||||
// These are already authenticated via internal JWT; allow read access.
|
||||
logger.info(`[${requestId}] Internal API call for workflow ${workflowId}`)
|
||||
} else if (!userId) {
|
||||
// Check if user has access to this workflow
|
||||
if (!userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized access attempt for workflow ${workflowId}`)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
} else {
|
||||
const authorization = await authorizeWorkflowByWorkspacePermission({
|
||||
workflowId,
|
||||
userId,
|
||||
action: 'read',
|
||||
})
|
||||
if (!authorization.workflow) {
|
||||
logger.warn(`[${requestId}] Workflow ${workflowId} not found`)
|
||||
return NextResponse.json({ error: 'Workflow not found' }, { status: 404 })
|
||||
}
|
||||
}
|
||||
|
||||
workflowData = authorization.workflow
|
||||
if (!authorization.allowed) {
|
||||
logger.warn(`[${requestId}] User ${userId} denied access to workflow ${workflowId}`)
|
||||
return NextResponse.json(
|
||||
{ error: authorization.message || 'Access denied' },
|
||||
{ status: authorization.status }
|
||||
)
|
||||
}
|
||||
const authorization = await authorizeWorkflowByWorkspacePermission({
|
||||
workflowId,
|
||||
userId,
|
||||
action: 'read',
|
||||
})
|
||||
if (!authorization.workflow) {
|
||||
logger.warn(`[${requestId}] Workflow ${workflowId} not found`)
|
||||
return NextResponse.json({ error: 'Workflow not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
workflowData = authorization.workflow
|
||||
if (!authorization.allowed) {
|
||||
logger.warn(`[${requestId}] User ${userId} denied access to workflow ${workflowId}`)
|
||||
return NextResponse.json(
|
||||
{ error: authorization.message || 'Access denied' },
|
||||
{ status: authorization.status }
|
||||
)
|
||||
}
|
||||
|
||||
logger.debug(`[${requestId}] Attempting to load workflow ${workflowId} from normalized tables`)
|
||||
|
||||
@@ -108,7 +108,7 @@ const SmoothThinkingText = memo(
|
||||
return (
|
||||
<div
|
||||
ref={textRef}
|
||||
className='[&_*]:!text-[var(--text-muted)] [&_*]:!text-[12px] [&_*]:!leading-[1.4] [&_p]:!m-0 [&_p]:!mb-1 [&_h1]:!text-[12px] [&_h1]:!font-semibold [&_h1]:!m-0 [&_h1]:!mb-1 [&_h2]:!text-[12px] [&_h2]:!font-semibold [&_h2]:!m-0 [&_h2]:!mb-1 [&_h3]:!text-[12px] [&_h3]:!font-semibold [&_h3]:!m-0 [&_h3]:!mb-1 [&_code]:!text-[11px] [&_ul]:!pl-5 [&_ul]:!my-1 [&_ol]:!pl-6 [&_ol]:!my-1 [&_li]:!my-0.5 [&_li]:!py-0 font-season text-[12px] text-[var(--text-muted)]'
|
||||
className='[&_*]:!text-[var(--text-muted)] [&_*]:!text-[12px] [&_*]:!leading-[1.4] [&_p]:!m-0 [&_p]:!mb-1 [&_h1]:!text-[12px] [&_h1]:!font-semibold [&_h1]:!m-0 [&_h1]:!mb-1 [&_h2]:!text-[12px] [&_h2]:!font-semibold [&_h2]:!m-0 [&_h2]:!mb-1 [&_h3]:!text-[12px] [&_h3]:!font-semibold [&_h3]:!m-0 [&_h3]:!mb-1 [&_code]:!text-[11px] [&_ul]:!pl-5 [&_ul]:!my-1 [&_ol]:!pl-8 [&_ol]:!my-1 [&_li]:!my-0.5 [&_li]:!py-0 font-season text-[12px] text-[var(--text-muted)]'
|
||||
>
|
||||
<CopilotMarkdownRenderer content={displayedContent} />
|
||||
</div>
|
||||
@@ -355,7 +355,7 @@ export function ThinkingBlock({
|
||||
isExpanded ? 'mt-1.5 max-h-[150px] opacity-100' : 'max-h-0 opacity-0'
|
||||
)}
|
||||
>
|
||||
<div className='[&_*]:!text-[var(--text-muted)] [&_*]:!text-[12px] [&_*]:!leading-[1.4] [&_p]:!m-0 [&_p]:!mb-1 [&_h1]:!text-[12px] [&_h1]:!font-semibold [&_h1]:!m-0 [&_h1]:!mb-1 [&_h2]:!text-[12px] [&_h2]:!font-semibold [&_h2]:!m-0 [&_h2]:!mb-1 [&_h3]:!text-[12px] [&_h3]:!font-semibold [&_h3]:!m-0 [&_h3]:!mb-1 [&_code]:!text-[11px] [&_ul]:!pl-5 [&_ul]:!my-1 [&_ol]:!pl-6 [&_ol]:!my-1 [&_li]:!my-0.5 [&_li]:!py-0 font-season text-[12px] text-[var(--text-muted)]'>
|
||||
<div className='[&_*]:!text-[var(--text-muted)] [&_*]:!text-[12px] [&_*]:!leading-[1.4] [&_p]:!m-0 [&_p]:!mb-1 [&_h1]:!text-[12px] [&_h1]:!font-semibold [&_h1]:!m-0 [&_h1]:!mb-1 [&_h2]:!text-[12px] [&_h2]:!font-semibold [&_h2]:!m-0 [&_h2]:!mb-1 [&_h3]:!text-[12px] [&_h3]:!font-semibold [&_h3]:!m-0 [&_h3]:!mb-1 [&_code]:!text-[11px] [&_ul]:!pl-5 [&_ul]:!my-1 [&_ol]:!pl-8 [&_ol]:!my-1 [&_li]:!my-0.5 [&_li]:!py-0 font-season text-[12px] text-[var(--text-muted)]'>
|
||||
<CopilotMarkdownRenderer content={cleanContent} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { serializeMessagesForDB } from './serialization'
|
||||
|
||||
const logger = createLogger('CopilotMessagePersistence')
|
||||
|
||||
export async function persistMessages(params: {
|
||||
interface PersistParams {
|
||||
chatId: string
|
||||
messages: CopilotMessage[]
|
||||
sensitiveCredentialIds?: Set<string>
|
||||
@@ -13,24 +13,29 @@ export async function persistMessages(params: {
|
||||
mode?: string
|
||||
model?: string
|
||||
conversationId?: string
|
||||
}): Promise<boolean> {
|
||||
}
|
||||
|
||||
/** Builds the JSON body used by both fetch and sendBeacon persistence paths. */
|
||||
function buildPersistBody(params: PersistParams): string {
|
||||
const dbMessages = serializeMessagesForDB(
|
||||
params.messages,
|
||||
params.sensitiveCredentialIds ?? new Set<string>()
|
||||
)
|
||||
return JSON.stringify({
|
||||
chatId: params.chatId,
|
||||
messages: dbMessages,
|
||||
...(params.planArtifact !== undefined ? { planArtifact: params.planArtifact } : {}),
|
||||
...(params.mode || params.model ? { config: { mode: params.mode, model: params.model } } : {}),
|
||||
...(params.conversationId ? { conversationId: params.conversationId } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
export async function persistMessages(params: PersistParams): Promise<boolean> {
|
||||
try {
|
||||
const dbMessages = serializeMessagesForDB(
|
||||
params.messages,
|
||||
params.sensitiveCredentialIds ?? new Set<string>()
|
||||
)
|
||||
const response = await fetch(COPILOT_UPDATE_MESSAGES_API_PATH, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
chatId: params.chatId,
|
||||
messages: dbMessages,
|
||||
...(params.planArtifact !== undefined ? { planArtifact: params.planArtifact } : {}),
|
||||
...(params.mode || params.model
|
||||
? { config: { mode: params.mode, model: params.model } }
|
||||
: {}),
|
||||
...(params.conversationId ? { conversationId: params.conversationId } : {}),
|
||||
}),
|
||||
body: buildPersistBody(params),
|
||||
})
|
||||
return response.ok
|
||||
} catch (error) {
|
||||
@@ -41,3 +46,27 @@ export async function persistMessages(params: {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists messages using navigator.sendBeacon, which is reliable during page unload.
|
||||
* Unlike fetch, sendBeacon is guaranteed to be queued even when the page is being torn down.
|
||||
*/
|
||||
export function persistMessagesBeacon(params: PersistParams): boolean {
|
||||
try {
|
||||
const body = buildPersistBody(params)
|
||||
const blob = new Blob([body], { type: 'application/json' })
|
||||
const sent = navigator.sendBeacon(COPILOT_UPDATE_MESSAGES_API_PATH, blob)
|
||||
if (!sent) {
|
||||
logger.warn('sendBeacon returned false — browser may have rejected the request', {
|
||||
chatId: params.chatId,
|
||||
})
|
||||
}
|
||||
return sent
|
||||
} catch (error) {
|
||||
logger.warn('Failed to persist messages via sendBeacon', {
|
||||
chatId: params.chatId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
buildToolCallsById,
|
||||
normalizeMessagesForUI,
|
||||
persistMessages,
|
||||
persistMessagesBeacon,
|
||||
saveMessageCheckpoint,
|
||||
} from '@/lib/copilot/messages'
|
||||
import type { CopilotTransportMode } from '@/lib/copilot/models'
|
||||
@@ -78,6 +79,28 @@ let _isPageUnloading = false
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('beforeunload', () => {
|
||||
_isPageUnloading = true
|
||||
|
||||
// Emergency persistence: flush any pending streaming updates to the store and
|
||||
// persist via sendBeacon (which is guaranteed to be queued during page teardown).
|
||||
// Without this, thinking blocks and in-progress content are lost on refresh.
|
||||
try {
|
||||
const state = useCopilotStore.getState()
|
||||
if (state.isSendingMessage && state.currentChat) {
|
||||
// Flush batched streaming updates into the store messages
|
||||
flushStreamingUpdates(useCopilotStore.setState.bind(useCopilotStore))
|
||||
const flushedState = useCopilotStore.getState()
|
||||
persistMessagesBeacon({
|
||||
chatId: flushedState.currentChat!.id,
|
||||
messages: flushedState.messages,
|
||||
sensitiveCredentialIds: flushedState.sensitiveCredentialIds,
|
||||
planArtifact: flushedState.streamingPlanContent || null,
|
||||
mode: flushedState.mode,
|
||||
model: flushedState.selectedModel,
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
// Best-effort — don't let errors prevent page unload
|
||||
}
|
||||
})
|
||||
}
|
||||
function isPageUnloading(): boolean {
|
||||
@@ -1461,19 +1484,26 @@ export const useCopilotStore = create<CopilotStore>()(
|
||||
// Immediately put all in-progress tools into aborted state
|
||||
abortAllInProgressTools(set, get)
|
||||
|
||||
// Persist whatever contentBlocks/text we have to keep ordering for reloads
|
||||
// Persist whatever contentBlocks/text we have to keep ordering for reloads.
|
||||
// During page unload, use sendBeacon which is guaranteed to be queued even
|
||||
// as the page tears down. Regular async fetch won't complete in time.
|
||||
const { currentChat, streamingPlanContent, mode, selectedModel } = get()
|
||||
if (currentChat) {
|
||||
try {
|
||||
const currentMessages = get().messages
|
||||
void persistMessages({
|
||||
const persistParams = {
|
||||
chatId: currentChat.id,
|
||||
messages: currentMessages,
|
||||
sensitiveCredentialIds: get().sensitiveCredentialIds,
|
||||
planArtifact: streamingPlanContent || null,
|
||||
mode,
|
||||
model: selectedModel,
|
||||
})
|
||||
}
|
||||
if (isPageUnloading()) {
|
||||
persistMessagesBeacon(persistParams)
|
||||
} else {
|
||||
void persistMessages(persistParams)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('[Copilot] Failed to queue abort snapshot persistence', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
|
||||
Reference in New Issue
Block a user