mirror of
https://github.com/simstudioai/sim.git
synced 2026-02-11 23:14: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 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const isInternalCall = auth.authType === 'internal_jwt'
|
|
||||||
const userId = auth.userId || null
|
const userId = auth.userId || null
|
||||||
|
|
||||||
let workflowData = await getWorkflowById(workflowId)
|
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 })
|
return NextResponse.json({ error: 'Workflow not found' }, { status: 404 })
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isInternalCall && !userId) {
|
// Check if user has access to this workflow
|
||||||
// Internal system calls (e.g. workflow-in-workflow executor) may not carry a userId.
|
if (!userId) {
|
||||||
// These are already authenticated via internal JWT; allow read access.
|
|
||||||
logger.info(`[${requestId}] Internal API call for workflow ${workflowId}`)
|
|
||||||
} else if (!userId) {
|
|
||||||
logger.warn(`[${requestId}] Unauthorized access attempt for workflow ${workflowId}`)
|
logger.warn(`[${requestId}] Unauthorized access attempt for workflow ${workflowId}`)
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
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
|
const authorization = await authorizeWorkflowByWorkspacePermission({
|
||||||
if (!authorization.allowed) {
|
workflowId,
|
||||||
logger.warn(`[${requestId}] User ${userId} denied access to workflow ${workflowId}`)
|
userId,
|
||||||
return NextResponse.json(
|
action: 'read',
|
||||||
{ error: authorization.message || 'Access denied' },
|
})
|
||||||
{ status: authorization.status }
|
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`)
|
logger.debug(`[${requestId}] Attempting to load workflow ${workflowId} from normalized tables`)
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ const SmoothThinkingText = memo(
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={textRef}
|
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} />
|
<CopilotMarkdownRenderer content={displayedContent} />
|
||||||
</div>
|
</div>
|
||||||
@@ -355,7 +355,7 @@ export function ThinkingBlock({
|
|||||||
isExpanded ? 'mt-1.5 max-h-[150px] opacity-100' : 'max-h-0 opacity-0'
|
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} />
|
<CopilotMarkdownRenderer content={cleanContent} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { serializeMessagesForDB } from './serialization'
|
|||||||
|
|
||||||
const logger = createLogger('CopilotMessagePersistence')
|
const logger = createLogger('CopilotMessagePersistence')
|
||||||
|
|
||||||
export async function persistMessages(params: {
|
interface PersistParams {
|
||||||
chatId: string
|
chatId: string
|
||||||
messages: CopilotMessage[]
|
messages: CopilotMessage[]
|
||||||
sensitiveCredentialIds?: Set<string>
|
sensitiveCredentialIds?: Set<string>
|
||||||
@@ -13,24 +13,29 @@ export async function persistMessages(params: {
|
|||||||
mode?: string
|
mode?: string
|
||||||
model?: string
|
model?: string
|
||||||
conversationId?: 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 {
|
try {
|
||||||
const dbMessages = serializeMessagesForDB(
|
|
||||||
params.messages,
|
|
||||||
params.sensitiveCredentialIds ?? new Set<string>()
|
|
||||||
)
|
|
||||||
const response = await fetch(COPILOT_UPDATE_MESSAGES_API_PATH, {
|
const response = await fetch(COPILOT_UPDATE_MESSAGES_API_PATH, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: buildPersistBody(params),
|
||||||
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 } : {}),
|
|
||||||
}),
|
|
||||||
})
|
})
|
||||||
return response.ok
|
return response.ok
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -41,3 +46,27 @@ export async function persistMessages(params: {
|
|||||||
return false
|
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,
|
buildToolCallsById,
|
||||||
normalizeMessagesForUI,
|
normalizeMessagesForUI,
|
||||||
persistMessages,
|
persistMessages,
|
||||||
|
persistMessagesBeacon,
|
||||||
saveMessageCheckpoint,
|
saveMessageCheckpoint,
|
||||||
} from '@/lib/copilot/messages'
|
} from '@/lib/copilot/messages'
|
||||||
import type { CopilotTransportMode } from '@/lib/copilot/models'
|
import type { CopilotTransportMode } from '@/lib/copilot/models'
|
||||||
@@ -78,6 +79,28 @@ let _isPageUnloading = false
|
|||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
window.addEventListener('beforeunload', () => {
|
window.addEventListener('beforeunload', () => {
|
||||||
_isPageUnloading = true
|
_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 {
|
function isPageUnloading(): boolean {
|
||||||
@@ -1461,19 +1484,26 @@ export const useCopilotStore = create<CopilotStore>()(
|
|||||||
// Immediately put all in-progress tools into aborted state
|
// Immediately put all in-progress tools into aborted state
|
||||||
abortAllInProgressTools(set, get)
|
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()
|
const { currentChat, streamingPlanContent, mode, selectedModel } = get()
|
||||||
if (currentChat) {
|
if (currentChat) {
|
||||||
try {
|
try {
|
||||||
const currentMessages = get().messages
|
const currentMessages = get().messages
|
||||||
void persistMessages({
|
const persistParams = {
|
||||||
chatId: currentChat.id,
|
chatId: currentChat.id,
|
||||||
messages: currentMessages,
|
messages: currentMessages,
|
||||||
sensitiveCredentialIds: get().sensitiveCredentialIds,
|
sensitiveCredentialIds: get().sensitiveCredentialIds,
|
||||||
planArtifact: streamingPlanContent || null,
|
planArtifact: streamingPlanContent || null,
|
||||||
mode,
|
mode,
|
||||||
model: selectedModel,
|
model: selectedModel,
|
||||||
})
|
}
|
||||||
|
if (isPageUnloading()) {
|
||||||
|
persistMessagesBeacon(persistParams)
|
||||||
|
} else {
|
||||||
|
void persistMessages(persistParams)
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.warn('[Copilot] Failed to queue abort snapshot persistence', {
|
logger.warn('[Copilot] Failed to queue abort snapshot persistence', {
|
||||||
error: error instanceof Error ? error.message : String(error),
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
|||||||
Reference in New Issue
Block a user