mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-15 18:07:56 -05:00
Compare commits
6 Commits
v0.5.60
...
fix/copilo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e63fd8c482 | ||
|
|
080ab94165 | ||
|
|
69309ecf5f | ||
|
|
2bc181d3a6 | ||
|
|
5db5c1c7d6 | ||
|
|
debcd76019 |
@@ -43,6 +43,27 @@ In Sim, the Slack integration enables your agents to programmatically interact w
|
||||
- **Download files**: Retrieve files shared in Slack channels for processing or archival
|
||||
|
||||
This allows for powerful automation scenarios such as sending notifications with dynamic updates, managing conversational flows with editable status messages, acknowledging important messages with reactions, and maintaining clean channels by removing outdated bot messages. Your agents can deliver timely information, update messages as workflows progress, create collaborative documents, or alert team members when attention is needed. This integration bridges the gap between your AI workflows and your team's communication, ensuring everyone stays informed with accurate, up-to-date information. By connecting Sim with Slack, you can create agents that keep your team updated with relevant information at the right time, enhance collaboration by sharing and updating insights automatically, and reduce the need for manual status updates—all while leveraging your existing Slack workspace where your team already communicates.
|
||||
|
||||
## Getting Started
|
||||
|
||||
To connect Slack to your Sim workflows:
|
||||
|
||||
1. Sign up or log in at [sim.ai](https://sim.ai)
|
||||
2. Create a new workflow or open an existing one
|
||||
3. Drag a **Slack** block onto your canvas
|
||||
4. Click the credential selector and choose **Connect**
|
||||
5. Authorize Sim to access your Slack workspace
|
||||
6. Select your target channel or user
|
||||
|
||||
Once connected, you can use any of the Slack operations listed below.
|
||||
|
||||
## AI-Generated Content
|
||||
|
||||
Sim workflows may use AI models to generate messages and responses sent to Slack. AI-generated content may be inaccurate or contain errors. Always review automated outputs, especially for critical communications.
|
||||
|
||||
## Need Help?
|
||||
|
||||
If you encounter issues with the Slack integration, contact us at [help@sim.ai](mailto:help@sim.ai)
|
||||
{/* MANUAL-CONTENT-END */}
|
||||
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { generateChatTitle } from '@/lib/copilot/chat-title'
|
||||
import { getCopilotModel } from '@/lib/copilot/config'
|
||||
import { COPILOT_MODEL_IDS, COPILOT_REQUEST_MODES } from '@/lib/copilot/models'
|
||||
import { SIM_AGENT_API_URL_DEFAULT, SIM_AGENT_VERSION } from '@/lib/copilot/constants'
|
||||
import {
|
||||
authenticateCopilotRequestSessionOnly,
|
||||
@@ -40,34 +41,8 @@ const ChatMessageSchema = z.object({
|
||||
userMessageId: z.string().optional(), // ID from frontend for the user message
|
||||
chatId: z.string().optional(),
|
||||
workflowId: z.string().min(1, 'Workflow ID is required'),
|
||||
model: z
|
||||
.enum([
|
||||
'gpt-5-fast',
|
||||
'gpt-5',
|
||||
'gpt-5-medium',
|
||||
'gpt-5-high',
|
||||
'gpt-5.1-fast',
|
||||
'gpt-5.1',
|
||||
'gpt-5.1-medium',
|
||||
'gpt-5.1-high',
|
||||
'gpt-5-codex',
|
||||
'gpt-5.1-codex',
|
||||
'gpt-5.2',
|
||||
'gpt-5.2-codex',
|
||||
'gpt-5.2-pro',
|
||||
'gpt-4o',
|
||||
'gpt-4.1',
|
||||
'o3',
|
||||
'claude-4-sonnet',
|
||||
'claude-4.5-haiku',
|
||||
'claude-4.5-sonnet',
|
||||
'claude-4.5-opus',
|
||||
'claude-4.1-opus',
|
||||
'gemini-3-pro',
|
||||
])
|
||||
.optional()
|
||||
.default('claude-4.5-opus'),
|
||||
mode: z.enum(['ask', 'agent', 'plan']).optional().default('agent'),
|
||||
model: z.enum(COPILOT_MODEL_IDS).optional().default('claude-4.5-opus'),
|
||||
mode: z.enum(COPILOT_REQUEST_MODES).optional().default('agent'),
|
||||
prefetch: z.boolean().optional(),
|
||||
createNewChat: z.boolean().optional().default(false),
|
||||
stream: z.boolean().optional().default(true),
|
||||
@@ -295,7 +270,8 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
|
||||
const defaults = getCopilotModel('chat')
|
||||
const modelToUse = env.COPILOT_MODEL || defaults.model
|
||||
const selectedModel = model || defaults.model
|
||||
const envModel = env.COPILOT_MODEL || defaults.model
|
||||
|
||||
let providerConfig: CopilotProviderConfig | undefined
|
||||
const providerEnv = env.COPILOT_PROVIDER as any
|
||||
@@ -304,7 +280,7 @@ export async function POST(req: NextRequest) {
|
||||
if (providerEnv === 'azure-openai') {
|
||||
providerConfig = {
|
||||
provider: 'azure-openai',
|
||||
model: modelToUse,
|
||||
model: envModel,
|
||||
apiKey: env.AZURE_OPENAI_API_KEY,
|
||||
apiVersion: 'preview',
|
||||
endpoint: env.AZURE_OPENAI_ENDPOINT,
|
||||
@@ -312,7 +288,7 @@ export async function POST(req: NextRequest) {
|
||||
} else if (providerEnv === 'vertex') {
|
||||
providerConfig = {
|
||||
provider: 'vertex',
|
||||
model: modelToUse,
|
||||
model: envModel,
|
||||
apiKey: env.COPILOT_API_KEY,
|
||||
vertexProject: env.VERTEX_PROJECT,
|
||||
vertexLocation: env.VERTEX_LOCATION,
|
||||
@@ -320,12 +296,15 @@ export async function POST(req: NextRequest) {
|
||||
} else {
|
||||
providerConfig = {
|
||||
provider: providerEnv,
|
||||
model: modelToUse,
|
||||
model: selectedModel,
|
||||
apiKey: env.COPILOT_API_KEY,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const effectiveMode = mode === 'agent' ? 'build' : mode
|
||||
const transportMode = effectiveMode === 'build' ? 'agent' : effectiveMode
|
||||
|
||||
// Determine conversationId to use for this request
|
||||
const effectiveConversationId =
|
||||
(currentChat?.conversationId as string | undefined) || conversationId
|
||||
@@ -345,7 +324,7 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
} | null = null
|
||||
|
||||
if (mode === 'agent') {
|
||||
if (effectiveMode === 'build') {
|
||||
// Build base tools (executed locally, not deferred)
|
||||
// Include function_execute for code execution capability
|
||||
baseTools = [
|
||||
@@ -452,8 +431,8 @@ export async function POST(req: NextRequest) {
|
||||
userId: authenticatedUserId,
|
||||
stream: stream,
|
||||
streamToolCalls: true,
|
||||
model: model,
|
||||
mode: mode,
|
||||
model: selectedModel,
|
||||
mode: transportMode,
|
||||
messageId: userMessageIdToUse,
|
||||
version: SIM_AGENT_VERSION,
|
||||
...(providerConfig ? { provider: providerConfig } : {}),
|
||||
@@ -477,7 +456,7 @@ export async function POST(req: NextRequest) {
|
||||
hasConversationId: !!effectiveConversationId,
|
||||
hasFileAttachments: processedFileContents.length > 0,
|
||||
messageLength: message.length,
|
||||
mode,
|
||||
mode: effectiveMode,
|
||||
hasTools: integrationTools.length > 0,
|
||||
toolCount: integrationTools.length,
|
||||
hasBaseTools: baseTools.length > 0,
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
createRequestTracker,
|
||||
createUnauthorizedResponse,
|
||||
} from '@/lib/copilot/request-helpers'
|
||||
import { COPILOT_MODES } from '@/lib/copilot/models'
|
||||
|
||||
const logger = createLogger('CopilotChatUpdateAPI')
|
||||
|
||||
@@ -45,7 +46,7 @@ const UpdateMessagesSchema = z.object({
|
||||
planArtifact: z.string().nullable().optional(),
|
||||
config: z
|
||||
.object({
|
||||
mode: z.enum(['ask', 'build', 'plan']).optional(),
|
||||
mode: z.enum(COPILOT_MODES).optional(),
|
||||
model: z.string().optional(),
|
||||
})
|
||||
.nullable()
|
||||
|
||||
@@ -2,12 +2,13 @@ import { createLogger } from '@sim/logger'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import type { CopilotModelId } from '@/lib/copilot/models'
|
||||
import { db } from '@/../../packages/db'
|
||||
import { settings } from '@/../../packages/db/schema'
|
||||
|
||||
const logger = createLogger('CopilotUserModelsAPI')
|
||||
|
||||
const DEFAULT_ENABLED_MODELS: Record<string, boolean> = {
|
||||
const DEFAULT_ENABLED_MODELS: Record<CopilotModelId, boolean> = {
|
||||
'gpt-4o': false,
|
||||
'gpt-4.1': false,
|
||||
'gpt-5-fast': false,
|
||||
@@ -28,7 +29,7 @@ const DEFAULT_ENABLED_MODELS: Record<string, boolean> = {
|
||||
'claude-4.5-haiku': true,
|
||||
'claude-4.5-sonnet': true,
|
||||
'claude-4.5-opus': true,
|
||||
// 'claude-4.1-opus': true,
|
||||
'claude-4.1-opus': false,
|
||||
'gemini-3-pro': true,
|
||||
}
|
||||
|
||||
@@ -54,7 +55,9 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
const mergedModels = { ...DEFAULT_ENABLED_MODELS }
|
||||
for (const [modelId, enabled] of Object.entries(userModelsMap)) {
|
||||
mergedModels[modelId] = enabled
|
||||
if (modelId in mergedModels) {
|
||||
mergedModels[modelId as CopilotModelId] = enabled
|
||||
}
|
||||
}
|
||||
|
||||
const hasNewModels = Object.keys(DEFAULT_ENABLED_MODELS).some(
|
||||
|
||||
@@ -7,192 +7,33 @@ import { usePreventZoom } from '@/app/workspace/[workspaceId]/w/[workflowId]/hoo
|
||||
import { useCopilotStore, usePanelStore } from '@/stores/panel'
|
||||
import { useTerminalStore } from '@/stores/terminal'
|
||||
import { useWorkflowDiffStore } from '@/stores/workflow-diff'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { mergeSubblockState } from '@/stores/workflows/utils'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
|
||||
const logger = createLogger('DiffControls')
|
||||
|
||||
export const DiffControls = memo(function DiffControls() {
|
||||
const isTerminalResizing = useTerminalStore((state) => state.isResizing)
|
||||
const isPanelResizing = usePanelStore((state) => state.isResizing)
|
||||
const { isDiffReady, hasActiveDiff, acceptChanges, rejectChanges, baselineWorkflow } =
|
||||
useWorkflowDiffStore(
|
||||
useCallback(
|
||||
(state) => ({
|
||||
isDiffReady: state.isDiffReady,
|
||||
hasActiveDiff: state.hasActiveDiff,
|
||||
acceptChanges: state.acceptChanges,
|
||||
rejectChanges: state.rejectChanges,
|
||||
baselineWorkflow: state.baselineWorkflow,
|
||||
}),
|
||||
[]
|
||||
)
|
||||
)
|
||||
|
||||
const { updatePreviewToolCallState, currentChat, messages } = useCopilotStore(
|
||||
const { isDiffReady, hasActiveDiff, acceptChanges, rejectChanges } = useWorkflowDiffStore(
|
||||
useCallback(
|
||||
(state) => ({
|
||||
updatePreviewToolCallState: state.updatePreviewToolCallState,
|
||||
currentChat: state.currentChat,
|
||||
messages: state.messages,
|
||||
isDiffReady: state.isDiffReady,
|
||||
hasActiveDiff: state.hasActiveDiff,
|
||||
acceptChanges: state.acceptChanges,
|
||||
rejectChanges: state.rejectChanges,
|
||||
}),
|
||||
[]
|
||||
)
|
||||
)
|
||||
|
||||
const { activeWorkflowId } = useWorkflowRegistry(
|
||||
useCallback((state) => ({ activeWorkflowId: state.activeWorkflowId }), [])
|
||||
const { updatePreviewToolCallState } = useCopilotStore(
|
||||
useCallback(
|
||||
(state) => ({
|
||||
updatePreviewToolCallState: state.updatePreviewToolCallState,
|
||||
}),
|
||||
[]
|
||||
)
|
||||
)
|
||||
|
||||
const createCheckpoint = useCallback(async () => {
|
||||
if (!activeWorkflowId || !currentChat?.id) {
|
||||
logger.warn('Cannot create checkpoint: missing workflowId or chatId', {
|
||||
workflowId: activeWorkflowId,
|
||||
chatId: currentChat?.id,
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
logger.info('Creating checkpoint before accepting changes')
|
||||
|
||||
// Use the baseline workflow (state before diff) instead of current state
|
||||
// This ensures reverting to the checkpoint restores the pre-diff state
|
||||
const rawState = baselineWorkflow || useWorkflowStore.getState().getWorkflowState()
|
||||
|
||||
// The baseline already has merged subblock values, but we'll merge again to be safe
|
||||
// This ensures all user inputs and subblock data are captured
|
||||
const blocksWithSubblockValues = mergeSubblockState(rawState.blocks, activeWorkflowId)
|
||||
|
||||
// Filter and complete blocks to ensure all required fields are present
|
||||
// This matches the validation logic from /api/workflows/[id]/state
|
||||
const filteredBlocks = Object.entries(blocksWithSubblockValues).reduce(
|
||||
(acc, [blockId, block]) => {
|
||||
if (block.type && block.name) {
|
||||
// Ensure all required fields are present
|
||||
acc[blockId] = {
|
||||
...block,
|
||||
id: block.id || blockId, // Ensure id field is set
|
||||
enabled: block.enabled !== undefined ? block.enabled : true,
|
||||
horizontalHandles:
|
||||
block.horizontalHandles !== undefined ? block.horizontalHandles : true,
|
||||
height: block.height !== undefined ? block.height : 90,
|
||||
subBlocks: block.subBlocks || {},
|
||||
outputs: block.outputs || {},
|
||||
data: block.data || {},
|
||||
position: block.position || { x: 0, y: 0 }, // Ensure position exists
|
||||
}
|
||||
}
|
||||
return acc
|
||||
},
|
||||
{} as typeof rawState.blocks
|
||||
)
|
||||
|
||||
// Clean the workflow state - only include valid fields, exclude null/undefined values
|
||||
const workflowState = {
|
||||
blocks: filteredBlocks,
|
||||
edges: rawState.edges || [],
|
||||
loops: rawState.loops || {},
|
||||
parallels: rawState.parallels || {},
|
||||
lastSaved: rawState.lastSaved || Date.now(),
|
||||
deploymentStatuses: rawState.deploymentStatuses || {},
|
||||
}
|
||||
|
||||
logger.info('Prepared complete workflow state for checkpoint', {
|
||||
blocksCount: Object.keys(workflowState.blocks).length,
|
||||
edgesCount: workflowState.edges.length,
|
||||
loopsCount: Object.keys(workflowState.loops).length,
|
||||
parallelsCount: Object.keys(workflowState.parallels).length,
|
||||
hasRequiredFields: Object.values(workflowState.blocks).every(
|
||||
(block) => block.id && block.type && block.name && block.position
|
||||
),
|
||||
hasSubblockValues: Object.values(workflowState.blocks).some((block) =>
|
||||
Object.values(block.subBlocks || {}).some(
|
||||
(subblock) => subblock.value !== null && subblock.value !== undefined
|
||||
)
|
||||
),
|
||||
sampleBlock: Object.values(workflowState.blocks)[0],
|
||||
})
|
||||
|
||||
// Find the most recent user message ID from the current chat
|
||||
const userMessages = messages.filter((msg) => msg.role === 'user')
|
||||
const lastUserMessage = userMessages[userMessages.length - 1]
|
||||
const messageId = lastUserMessage?.id
|
||||
|
||||
logger.info('Creating checkpoint with message association', {
|
||||
totalMessages: messages.length,
|
||||
userMessageCount: userMessages.length,
|
||||
lastUserMessageId: messageId,
|
||||
chatId: currentChat.id,
|
||||
entireMessageArray: messages,
|
||||
allMessageIds: messages.map((m) => ({
|
||||
id: m.id,
|
||||
role: m.role,
|
||||
content: m.content.substring(0, 50),
|
||||
})),
|
||||
selectedUserMessages: userMessages.map((m) => ({
|
||||
id: m.id,
|
||||
content: m.content.substring(0, 100),
|
||||
})),
|
||||
allRawMessageIds: messages.map((m) => m.id),
|
||||
userMessageIds: userMessages.map((m) => m.id),
|
||||
checkpointData: {
|
||||
workflowId: activeWorkflowId,
|
||||
chatId: currentChat.id,
|
||||
messageId: messageId,
|
||||
messageFound: !!lastUserMessage,
|
||||
},
|
||||
})
|
||||
|
||||
const response = await fetch('/api/copilot/checkpoints', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
workflowId: activeWorkflowId,
|
||||
chatId: currentChat.id,
|
||||
messageId,
|
||||
workflowState: JSON.stringify(workflowState),
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to create checkpoint: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
const newCheckpoint = result.checkpoint
|
||||
|
||||
logger.info('Checkpoint created successfully', {
|
||||
messageId,
|
||||
chatId: currentChat.id,
|
||||
checkpointId: newCheckpoint?.id,
|
||||
})
|
||||
|
||||
// Update the copilot store immediately to show the checkpoint icon
|
||||
if (newCheckpoint && messageId) {
|
||||
const { messageCheckpoints: currentCheckpoints } = useCopilotStore.getState()
|
||||
const existingCheckpoints = currentCheckpoints[messageId] || []
|
||||
|
||||
const updatedCheckpoints = {
|
||||
...currentCheckpoints,
|
||||
[messageId]: [newCheckpoint, ...existingCheckpoints],
|
||||
}
|
||||
|
||||
useCopilotStore.setState({ messageCheckpoints: updatedCheckpoints })
|
||||
logger.info('Updated copilot store with new checkpoint', {
|
||||
messageId,
|
||||
checkpointId: newCheckpoint.id,
|
||||
})
|
||||
}
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
logger.error('Failed to create checkpoint:', error)
|
||||
return false
|
||||
}
|
||||
}, [activeWorkflowId, currentChat, messages, baselineWorkflow])
|
||||
|
||||
const handleAccept = useCallback(() => {
|
||||
logger.info('Accepting proposed changes with backup protection')
|
||||
|
||||
@@ -229,12 +70,8 @@ export const DiffControls = memo(function DiffControls() {
|
||||
})
|
||||
|
||||
// Create checkpoint in the background (fire-and-forget) so it doesn't block UI
|
||||
createCheckpoint().catch((error) => {
|
||||
logger.warn('Failed to create checkpoint after accept:', error)
|
||||
})
|
||||
|
||||
logger.info('Accept triggered; UI will update optimistically')
|
||||
}, [createCheckpoint, updatePreviewToolCallState, acceptChanges])
|
||||
}, [updatePreviewToolCallState, acceptChanges])
|
||||
|
||||
const handleReject = useCallback(() => {
|
||||
logger.info('Rejecting proposed changes (optimistic)')
|
||||
|
||||
@@ -57,7 +57,7 @@ export function useCheckpointManagement(
|
||||
const { messageCheckpoints: currentCheckpoints } = useCopilotStore.getState()
|
||||
const updatedCheckpoints = {
|
||||
...currentCheckpoints,
|
||||
[message.id]: messageCheckpoints.slice(1),
|
||||
[message.id]: [],
|
||||
}
|
||||
useCopilotStore.setState({ messageCheckpoints: updatedCheckpoints })
|
||||
|
||||
@@ -140,7 +140,7 @@ export function useCheckpointManagement(
|
||||
const { messageCheckpoints: currentCheckpoints } = useCopilotStore.getState()
|
||||
const updatedCheckpoints = {
|
||||
...currentCheckpoints,
|
||||
[message.id]: messageCheckpoints.slice(1),
|
||||
[message.id]: [],
|
||||
}
|
||||
useCopilotStore.setState({ messageCheckpoints: updatedCheckpoints })
|
||||
|
||||
|
||||
@@ -1446,8 +1446,10 @@ function WorkflowEditSummary({ toolCall }: { toolCall: CopilotToolCall }) {
|
||||
blockType = blockType || op.block_type || ''
|
||||
}
|
||||
|
||||
// Fallback name to type or ID
|
||||
if (!blockName) blockName = blockType || blockId
|
||||
if (!blockName) blockName = blockType || ''
|
||||
if (!blockName && !blockType) {
|
||||
continue
|
||||
}
|
||||
|
||||
const change: BlockChange = { blockId, blockName, blockType }
|
||||
|
||||
|
||||
@@ -22,6 +22,9 @@ interface UseContextManagementProps {
|
||||
export function useContextManagement({ message, initialContexts }: UseContextManagementProps) {
|
||||
const [selectedContexts, setSelectedContexts] = useState<ChatContext[]>(initialContexts ?? [])
|
||||
const initializedRef = useRef(false)
|
||||
const escapeRegex = useCallback((value: string) => {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
}, [])
|
||||
|
||||
// Initialize with initial contexts when they're first provided (for edit mode)
|
||||
useEffect(() => {
|
||||
@@ -78,10 +81,8 @@ export function useContextManagement({ message, initialContexts }: UseContextMan
|
||||
// Check for slash command tokens or mention tokens based on kind
|
||||
const isSlashCommand = c.kind === 'slash_command'
|
||||
const prefix = isSlashCommand ? '/' : '@'
|
||||
const tokenWithSpaces = ` ${prefix}${c.label} `
|
||||
const tokenAtStart = `${prefix}${c.label} `
|
||||
// Token can appear with leading space OR at the start of the message
|
||||
return message.includes(tokenWithSpaces) || message.startsWith(tokenAtStart)
|
||||
const tokenPattern = new RegExp(`(^|\\s)${escapeRegex(prefix)}${escapeRegex(c.label)}(\\s|$)`)
|
||||
return tokenPattern.test(message)
|
||||
})
|
||||
return filtered.length === prev.length ? prev : filtered
|
||||
})
|
||||
|
||||
@@ -76,6 +76,15 @@ export function useMentionTokens({
|
||||
ranges.push({ start: idx, end: idx + token.length, label })
|
||||
fromIndex = idx + token.length
|
||||
}
|
||||
|
||||
// Token at end of message without trailing space: "@label" or " /label"
|
||||
const tokenAtEnd = `${prefix}${label}`
|
||||
if (message.endsWith(tokenAtEnd)) {
|
||||
const idx = message.lastIndexOf(tokenAtEnd)
|
||||
const hasLeadingSpace = idx > 0 && message[idx - 1] === ' '
|
||||
const start = hasLeadingSpace ? idx - 1 : idx
|
||||
ranges.push({ start, end: message.length, label })
|
||||
}
|
||||
}
|
||||
|
||||
ranges.sort((a, b) => a.start - b.start)
|
||||
|
||||
@@ -613,7 +613,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
||||
|
||||
const insertTriggerAndOpenMenu = useCallback(
|
||||
(trigger: '@' | '/') => {
|
||||
if (disabled || isLoading) return
|
||||
if (disabled) return
|
||||
const textarea = mentionMenu.textareaRef.current
|
||||
if (!textarea) return
|
||||
|
||||
@@ -642,7 +642,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
||||
}
|
||||
mentionMenu.setSubmenuActiveIndex(0)
|
||||
},
|
||||
[disabled, isLoading, mentionMenu, message, setMessage]
|
||||
[disabled, mentionMenu, message, setMessage]
|
||||
)
|
||||
|
||||
const handleOpenMentionMenuWithAt = useCallback(
|
||||
@@ -735,10 +735,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
||||
variant='outline'
|
||||
onClick={handleOpenMentionMenuWithAt}
|
||||
title='Insert @'
|
||||
className={cn(
|
||||
'cursor-pointer rounded-[6px] p-[4.5px]',
|
||||
(disabled || isLoading) && 'cursor-not-allowed'
|
||||
)}
|
||||
className={cn('cursor-pointer rounded-[6px] p-[4.5px]', disabled && 'cursor-not-allowed')}
|
||||
>
|
||||
<AtSign className='h-3 w-3' strokeWidth={1.75} />
|
||||
</Badge>
|
||||
@@ -747,10 +744,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
||||
variant='outline'
|
||||
onClick={handleOpenSlashMenu}
|
||||
title='Insert /'
|
||||
className={cn(
|
||||
'cursor-pointer rounded-[6px] p-[4.5px]',
|
||||
(disabled || isLoading) && 'cursor-not-allowed'
|
||||
)}
|
||||
className={cn('cursor-pointer rounded-[6px] p-[4.5px]', disabled && 'cursor-not-allowed')}
|
||||
>
|
||||
<span className='flex h-3 w-3 items-center justify-center font-medium text-[11px] leading-none'>
|
||||
/
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import type {
|
||||
CopilotMode,
|
||||
CopilotModelId,
|
||||
CopilotTransportMode,
|
||||
} from '@/lib/copilot/models'
|
||||
|
||||
const logger = createLogger('CopilotAPI')
|
||||
|
||||
@@ -27,8 +32,8 @@ export interface CopilotMessage {
|
||||
* Chat config stored in database
|
||||
*/
|
||||
export interface CopilotChatConfig {
|
||||
mode?: 'ask' | 'build' | 'plan'
|
||||
model?: string
|
||||
mode?: CopilotMode
|
||||
model?: CopilotModelId
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -65,30 +70,8 @@ export interface SendMessageRequest {
|
||||
userMessageId?: string // ID from frontend for the user message
|
||||
chatId?: string
|
||||
workflowId?: string
|
||||
mode?: 'ask' | 'agent' | 'plan'
|
||||
model?:
|
||||
| 'gpt-5-fast'
|
||||
| 'gpt-5'
|
||||
| 'gpt-5-medium'
|
||||
| 'gpt-5-high'
|
||||
| 'gpt-5.1-fast'
|
||||
| 'gpt-5.1'
|
||||
| 'gpt-5.1-medium'
|
||||
| 'gpt-5.1-high'
|
||||
| 'gpt-5-codex'
|
||||
| 'gpt-5.1-codex'
|
||||
| 'gpt-5.2'
|
||||
| 'gpt-5.2-codex'
|
||||
| 'gpt-5.2-pro'
|
||||
| 'gpt-4o'
|
||||
| 'gpt-4.1'
|
||||
| 'o3'
|
||||
| 'claude-4-sonnet'
|
||||
| 'claude-4.5-haiku'
|
||||
| 'claude-4.5-sonnet'
|
||||
| 'claude-4.5-opus'
|
||||
| 'claude-4.1-opus'
|
||||
| 'gemini-3-pro'
|
||||
mode?: CopilotMode | CopilotTransportMode
|
||||
model?: CopilotModelId
|
||||
prefetch?: boolean
|
||||
createNewChat?: boolean
|
||||
stream?: boolean
|
||||
|
||||
36
apps/sim/lib/copilot/models.ts
Normal file
36
apps/sim/lib/copilot/models.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
export const COPILOT_MODEL_IDS = [
|
||||
'gpt-5-fast',
|
||||
'gpt-5',
|
||||
'gpt-5-medium',
|
||||
'gpt-5-high',
|
||||
'gpt-5.1-fast',
|
||||
'gpt-5.1',
|
||||
'gpt-5.1-medium',
|
||||
'gpt-5.1-high',
|
||||
'gpt-5-codex',
|
||||
'gpt-5.1-codex',
|
||||
'gpt-5.2',
|
||||
'gpt-5.2-codex',
|
||||
'gpt-5.2-pro',
|
||||
'gpt-4o',
|
||||
'gpt-4.1',
|
||||
'o3',
|
||||
'claude-4-sonnet',
|
||||
'claude-4.5-haiku',
|
||||
'claude-4.5-sonnet',
|
||||
'claude-4.5-opus',
|
||||
'claude-4.1-opus',
|
||||
'gemini-3-pro',
|
||||
] as const
|
||||
|
||||
export type CopilotModelId = (typeof COPILOT_MODEL_IDS)[number]
|
||||
|
||||
export const COPILOT_MODES = ['ask', 'build', 'plan'] as const
|
||||
export type CopilotMode = (typeof COPILOT_MODES)[number]
|
||||
|
||||
export const COPILOT_TRANSPORT_MODES = ['ask', 'agent', 'plan'] as const
|
||||
export type CopilotTransportMode = (typeof COPILOT_TRANSPORT_MODES)[number]
|
||||
|
||||
export const COPILOT_REQUEST_MODES = ['ask', 'build', 'plan', 'agent'] as const
|
||||
export type CopilotRequestMode = (typeof COPILOT_REQUEST_MODES)[number]
|
||||
|
||||
@@ -38,6 +38,18 @@ export class EditWorkflowClientTool extends BaseClientTool {
|
||||
super(toolCallId, EditWorkflowClientTool.id, EditWorkflowClientTool.metadata)
|
||||
}
|
||||
|
||||
async markToolComplete(status: number, message?: any, data?: any): Promise<boolean> {
|
||||
const logger = createLogger('EditWorkflowClientTool')
|
||||
logger.info('markToolComplete payload', {
|
||||
toolCallId: this.toolCallId,
|
||||
toolName: this.name,
|
||||
status,
|
||||
message,
|
||||
data,
|
||||
})
|
||||
return super.markToolComplete(status, message, data)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get sanitized workflow JSON from a workflow state, merge subblocks, and sanitize for copilot
|
||||
* This matches what get_user_workflow returns
|
||||
@@ -173,21 +185,13 @@ export class EditWorkflowClientTool extends BaseClientTool {
|
||||
async execute(args?: EditWorkflowArgs): Promise<void> {
|
||||
const logger = createLogger('EditWorkflowClientTool')
|
||||
|
||||
if (this.hasExecuted) {
|
||||
logger.info('execute skipped (already executed)', { toolCallId: this.toolCallId })
|
||||
return
|
||||
}
|
||||
|
||||
// Use timeout protection to ensure tool always completes
|
||||
await this.executeWithTimeout(async () => {
|
||||
if (this.hasExecuted) {
|
||||
logger.info('execute skipped (already executed)', { toolCallId: this.toolCallId })
|
||||
// Even if skipped, ensure we mark complete with current workflow state
|
||||
if (!this.hasBeenMarkedComplete()) {
|
||||
const currentWorkflowJson = this.getCurrentWorkflowJsonSafe(logger)
|
||||
await this.markToolComplete(
|
||||
200,
|
||||
'Tool already executed',
|
||||
currentWorkflowJson ? { userWorkflow: currentWorkflowJson } : undefined
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
this.hasExecuted = true
|
||||
logger.info('execute called', { toolCallId: this.toolCallId, argsProvided: !!args })
|
||||
this.setState(ClientToolCallState.executing)
|
||||
|
||||
@@ -495,7 +495,7 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
|
||||
services: {
|
||||
slack: {
|
||||
name: 'Slack',
|
||||
description: 'Send messages using a Slack bot.',
|
||||
description: 'Send messages using a bot for Slack.',
|
||||
providerId: 'slack',
|
||||
icon: SlackIcon,
|
||||
baseProviderIcon: SlackIcon,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger'
|
||||
import { create } from 'zustand'
|
||||
import { devtools } from 'zustand/middleware'
|
||||
import { type CopilotChat, sendStreamingMessage } from '@/lib/copilot/api'
|
||||
import type { CopilotTransportMode } from '@/lib/copilot/models'
|
||||
import type {
|
||||
BaseClientToolMetadata,
|
||||
ClientToolDisplay,
|
||||
@@ -84,7 +85,9 @@ import type {
|
||||
} from '@/stores/panel/copilot/types'
|
||||
import { useWorkflowDiffStore } from '@/stores/workflow-diff/store'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
import { mergeSubblockState } from '@/stores/workflows/utils'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
import type { WorkflowState } from '@/stores/workflows/workflow/types'
|
||||
|
||||
const logger = createLogger('CopilotStore')
|
||||
|
||||
@@ -237,6 +240,7 @@ const TEXT_BLOCK_TYPE = 'text'
|
||||
const THINKING_BLOCK_TYPE = 'thinking'
|
||||
const DATA_PREFIX = 'data: '
|
||||
const DATA_PREFIX_LENGTH = 6
|
||||
const CONTINUE_OPTIONS_TAG = '<options>{"1":"Continue"}</options>'
|
||||
|
||||
// Resolve display text/icon for a tool based on its state
|
||||
function resolveToolDisplay(
|
||||
@@ -360,6 +364,7 @@ function abortAllInProgressTools(set: any, get: () => CopilotStore) {
|
||||
const { toolCallsById, messages } = get()
|
||||
const updatedMap = { ...toolCallsById }
|
||||
const abortedIds = new Set<string>()
|
||||
let hasUpdates = false
|
||||
for (const [id, tc] of Object.entries(toolCallsById)) {
|
||||
const st = tc.state as any
|
||||
// Abort anything not already terminal success/error/rejected/aborted
|
||||
@@ -373,11 +378,19 @@ function abortAllInProgressTools(set: any, get: () => CopilotStore) {
|
||||
updatedMap[id] = {
|
||||
...tc,
|
||||
state: ClientToolCallState.aborted,
|
||||
subAgentStreaming: false,
|
||||
display: resolveToolDisplay(tc.name, ClientToolCallState.aborted, id, (tc as any).params),
|
||||
}
|
||||
hasUpdates = true
|
||||
} else if (tc.subAgentStreaming) {
|
||||
updatedMap[id] = {
|
||||
...tc,
|
||||
subAgentStreaming: false,
|
||||
}
|
||||
hasUpdates = true
|
||||
}
|
||||
}
|
||||
if (abortedIds.size > 0) {
|
||||
if (abortedIds.size > 0 || hasUpdates) {
|
||||
set({ toolCallsById: updatedMap })
|
||||
// Update inline blocks in-place for the latest assistant message only (most relevant)
|
||||
set((s: CopilotStore) => {
|
||||
@@ -620,6 +633,97 @@ function createErrorMessage(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a workflow snapshot suitable for checkpoint persistence.
|
||||
*/
|
||||
function buildCheckpointWorkflowState(workflowId: string): WorkflowState | null {
|
||||
const rawState = useWorkflowStore.getState().getWorkflowState()
|
||||
if (!rawState) return null
|
||||
|
||||
const blocksWithSubblockValues = mergeSubblockState(rawState.blocks, workflowId)
|
||||
|
||||
const filteredBlocks = Object.entries(blocksWithSubblockValues).reduce(
|
||||
(acc, [blockId, block]) => {
|
||||
if (block?.type && block?.name) {
|
||||
acc[blockId] = {
|
||||
...block,
|
||||
id: block.id || blockId,
|
||||
enabled: block.enabled !== undefined ? block.enabled : true,
|
||||
horizontalHandles: block.horizontalHandles !== undefined ? block.horizontalHandles : true,
|
||||
height: block.height !== undefined ? block.height : 90,
|
||||
subBlocks: block.subBlocks || {},
|
||||
outputs: block.outputs || {},
|
||||
data: block.data || {},
|
||||
position: block.position || { x: 0, y: 0 },
|
||||
}
|
||||
}
|
||||
return acc
|
||||
},
|
||||
{} as WorkflowState['blocks']
|
||||
)
|
||||
|
||||
return {
|
||||
blocks: filteredBlocks,
|
||||
edges: rawState.edges || [],
|
||||
loops: rawState.loops || {},
|
||||
parallels: rawState.parallels || {},
|
||||
lastSaved: rawState.lastSaved || Date.now(),
|
||||
deploymentStatuses: rawState.deploymentStatuses || {},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists a previously captured snapshot as a workflow checkpoint.
|
||||
*/
|
||||
async function saveMessageCheckpoint(
|
||||
messageId: string,
|
||||
get: () => CopilotStore,
|
||||
set: (partial: Partial<CopilotStore> | ((state: CopilotStore) => Partial<CopilotStore>)) => void
|
||||
): Promise<boolean> {
|
||||
const { workflowId, currentChat, messageSnapshots, messageCheckpoints } = get()
|
||||
if (!workflowId || !currentChat?.id) return false
|
||||
|
||||
const snapshot = messageSnapshots[messageId]
|
||||
if (!snapshot) return false
|
||||
|
||||
const nextSnapshots = { ...messageSnapshots }
|
||||
delete nextSnapshots[messageId]
|
||||
set({ messageSnapshots: nextSnapshots })
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/copilot/checkpoints', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
workflowId,
|
||||
chatId: currentChat.id,
|
||||
messageId,
|
||||
workflowState: JSON.stringify(snapshot),
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to create checkpoint: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
const newCheckpoint = result.checkpoint
|
||||
if (newCheckpoint) {
|
||||
const existingCheckpoints = messageCheckpoints[messageId] || []
|
||||
const updatedCheckpoints = {
|
||||
...messageCheckpoints,
|
||||
[messageId]: [newCheckpoint, ...existingCheckpoints],
|
||||
}
|
||||
set({ messageCheckpoints: updatedCheckpoints })
|
||||
}
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
logger.error('Failed to create checkpoint from snapshot:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function stripTodoTags(text: string): string {
|
||||
if (!text) return text
|
||||
return text
|
||||
@@ -826,6 +930,8 @@ interface StreamingContext {
|
||||
newChatId?: string
|
||||
doneEventCount: number
|
||||
streamComplete?: boolean
|
||||
wasAborted?: boolean
|
||||
suppressContinueOption?: boolean
|
||||
/** Track active subagent sessions by parent tool call ID */
|
||||
subAgentParentToolCallId?: string
|
||||
/** Track subagent content per parent tool call */
|
||||
@@ -843,6 +949,129 @@ type SSEHandler = (
|
||||
set: any
|
||||
) => Promise<void> | void
|
||||
|
||||
function appendTextBlock(context: StreamingContext, text: string) {
|
||||
if (!text) return
|
||||
context.accumulatedContent.append(text)
|
||||
if (context.currentTextBlock && context.contentBlocks.length > 0) {
|
||||
const lastBlock = context.contentBlocks[context.contentBlocks.length - 1]
|
||||
if (lastBlock.type === TEXT_BLOCK_TYPE && lastBlock === context.currentTextBlock) {
|
||||
lastBlock.content += text
|
||||
return
|
||||
}
|
||||
}
|
||||
context.currentTextBlock = contentBlockPool.get()
|
||||
context.currentTextBlock.type = TEXT_BLOCK_TYPE
|
||||
context.currentTextBlock.content = text
|
||||
context.currentTextBlock.timestamp = Date.now()
|
||||
context.contentBlocks.push(context.currentTextBlock)
|
||||
}
|
||||
|
||||
function appendContinueOption(content: string): string {
|
||||
if (/<options>/i.test(content)) return content
|
||||
const suffix = content.trim().length > 0 ? '\n\n' : ''
|
||||
return `${content}${suffix}${CONTINUE_OPTIONS_TAG}`
|
||||
}
|
||||
|
||||
function appendContinueOptionBlock(blocks: any[]): any[] {
|
||||
if (!Array.isArray(blocks)) return blocks
|
||||
const hasOptions = blocks.some(
|
||||
(block) => block?.type === TEXT_BLOCK_TYPE && typeof block.content === 'string' && /<options>/i.test(block.content)
|
||||
)
|
||||
if (hasOptions) return blocks
|
||||
return [
|
||||
...blocks,
|
||||
{
|
||||
type: TEXT_BLOCK_TYPE,
|
||||
content: CONTINUE_OPTIONS_TAG,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function beginThinkingBlock(context: StreamingContext) {
|
||||
if (!context.currentThinkingBlock) {
|
||||
context.currentThinkingBlock = contentBlockPool.get()
|
||||
context.currentThinkingBlock.type = THINKING_BLOCK_TYPE
|
||||
context.currentThinkingBlock.content = ''
|
||||
context.currentThinkingBlock.timestamp = Date.now()
|
||||
;(context.currentThinkingBlock as any).startTime = Date.now()
|
||||
context.contentBlocks.push(context.currentThinkingBlock)
|
||||
}
|
||||
context.isInThinkingBlock = true
|
||||
context.currentTextBlock = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes thinking tags from streamed content.
|
||||
*/
|
||||
function stripThinkingTags(text: string): string {
|
||||
return text.replace(/<\/?thinking>/g, '')
|
||||
}
|
||||
|
||||
function appendThinkingContent(context: StreamingContext, text: string) {
|
||||
if (!text) return
|
||||
const cleanedText = stripThinkingTags(text)
|
||||
if (!cleanedText) return
|
||||
if (context.currentThinkingBlock) {
|
||||
context.currentThinkingBlock.content += cleanedText
|
||||
} else {
|
||||
context.currentThinkingBlock = contentBlockPool.get()
|
||||
context.currentThinkingBlock.type = THINKING_BLOCK_TYPE
|
||||
context.currentThinkingBlock.content = cleanedText
|
||||
context.currentThinkingBlock.timestamp = Date.now()
|
||||
context.currentThinkingBlock.startTime = Date.now()
|
||||
context.contentBlocks.push(context.currentThinkingBlock)
|
||||
}
|
||||
context.isInThinkingBlock = true
|
||||
context.currentTextBlock = null
|
||||
}
|
||||
|
||||
function finalizeThinkingBlock(context: StreamingContext) {
|
||||
if (context.currentThinkingBlock) {
|
||||
context.currentThinkingBlock.duration =
|
||||
Date.now() - (context.currentThinkingBlock.startTime || Date.now())
|
||||
}
|
||||
context.isInThinkingBlock = false
|
||||
context.currentThinkingBlock = null
|
||||
context.currentTextBlock = null
|
||||
}
|
||||
|
||||
function upsertToolCallBlock(context: StreamingContext, toolCall: CopilotToolCall) {
|
||||
let found = false
|
||||
for (let i = 0; i < context.contentBlocks.length; i++) {
|
||||
const b = context.contentBlocks[i] as any
|
||||
if (b.type === 'tool_call' && b.toolCall?.id === toolCall.id) {
|
||||
context.contentBlocks[i] = { ...b, toolCall }
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!found) {
|
||||
context.contentBlocks.push({ type: 'tool_call', toolCall, timestamp: Date.now() })
|
||||
}
|
||||
}
|
||||
|
||||
function appendSubAgentText(context: StreamingContext, parentToolCallId: string, text: string) {
|
||||
if (!context.subAgentContent[parentToolCallId]) {
|
||||
context.subAgentContent[parentToolCallId] = ''
|
||||
}
|
||||
if (!context.subAgentBlocks[parentToolCallId]) {
|
||||
context.subAgentBlocks[parentToolCallId] = []
|
||||
}
|
||||
context.subAgentContent[parentToolCallId] += text
|
||||
const blocks = context.subAgentBlocks[parentToolCallId]
|
||||
const lastBlock = blocks[blocks.length - 1]
|
||||
if (lastBlock && lastBlock.type === 'subagent_text') {
|
||||
lastBlock.content = (lastBlock.content || '') + text
|
||||
} else {
|
||||
blocks.push({
|
||||
type: 'subagent_text',
|
||||
content: text,
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const sseHandlers: Record<string, SSEHandler> = {
|
||||
chat_id: async (data, context, get) => {
|
||||
context.newChatId = data.chatId
|
||||
@@ -1033,17 +1262,7 @@ const sseHandlers: Record<string, SSEHandler> = {
|
||||
logger.info('[toolCallsById] map updated', updated)
|
||||
|
||||
// Add/refresh inline content block
|
||||
let found = false
|
||||
for (let i = 0; i < context.contentBlocks.length; i++) {
|
||||
const b = context.contentBlocks[i] as any
|
||||
if (b.type === 'tool_call' && b.toolCall?.id === toolCallId) {
|
||||
context.contentBlocks[i] = { ...b, toolCall: tc }
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!found)
|
||||
context.contentBlocks.push({ type: 'tool_call', toolCall: tc, timestamp: Date.now() })
|
||||
upsertToolCallBlock(context, tc)
|
||||
updateStreamingMessage(set, context)
|
||||
}
|
||||
},
|
||||
@@ -1079,20 +1298,10 @@ const sseHandlers: Record<string, SSEHandler> = {
|
||||
logger.info('[toolCallsById] → pending', { id, name, params: args })
|
||||
|
||||
// Ensure an inline content block exists/updated for this tool call
|
||||
let found = false
|
||||
for (let i = 0; i < context.contentBlocks.length; i++) {
|
||||
const b = context.contentBlocks[i] as any
|
||||
if (b.type === 'tool_call' && b.toolCall?.id === id) {
|
||||
context.contentBlocks[i] = { ...b, toolCall: next }
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!found) {
|
||||
context.contentBlocks.push({ type: 'tool_call', toolCall: next, timestamp: Date.now() })
|
||||
}
|
||||
upsertToolCallBlock(context, next)
|
||||
updateStreamingMessage(set, context)
|
||||
|
||||
|
||||
// Prefer interface-based registry to determine interrupt and execute
|
||||
try {
|
||||
const def = name ? getTool(name) : undefined
|
||||
@@ -1275,44 +1484,18 @@ const sseHandlers: Record<string, SSEHandler> = {
|
||||
reasoning: (data, context, _get, set) => {
|
||||
const phase = (data && (data.phase || data?.data?.phase)) as string | undefined
|
||||
if (phase === 'start') {
|
||||
if (!context.currentThinkingBlock) {
|
||||
context.currentThinkingBlock = contentBlockPool.get()
|
||||
context.currentThinkingBlock.type = THINKING_BLOCK_TYPE
|
||||
context.currentThinkingBlock.content = ''
|
||||
context.currentThinkingBlock.timestamp = Date.now()
|
||||
;(context.currentThinkingBlock as any).startTime = Date.now()
|
||||
context.contentBlocks.push(context.currentThinkingBlock)
|
||||
}
|
||||
context.isInThinkingBlock = true
|
||||
context.currentTextBlock = null
|
||||
beginThinkingBlock(context)
|
||||
updateStreamingMessage(set, context)
|
||||
return
|
||||
}
|
||||
if (phase === 'end') {
|
||||
if (context.currentThinkingBlock) {
|
||||
;(context.currentThinkingBlock as any).duration =
|
||||
Date.now() - ((context.currentThinkingBlock as any).startTime || Date.now())
|
||||
}
|
||||
context.isInThinkingBlock = false
|
||||
context.currentThinkingBlock = null
|
||||
context.currentTextBlock = null
|
||||
finalizeThinkingBlock(context)
|
||||
updateStreamingMessage(set, context)
|
||||
return
|
||||
}
|
||||
const chunk: string = typeof data?.data === 'string' ? data.data : data?.content || ''
|
||||
if (!chunk) return
|
||||
if (context.currentThinkingBlock) {
|
||||
context.currentThinkingBlock.content += chunk
|
||||
} else {
|
||||
context.currentThinkingBlock = contentBlockPool.get()
|
||||
context.currentThinkingBlock.type = THINKING_BLOCK_TYPE
|
||||
context.currentThinkingBlock.content = chunk
|
||||
context.currentThinkingBlock.timestamp = Date.now()
|
||||
;(context.currentThinkingBlock as any).startTime = Date.now()
|
||||
context.contentBlocks.push(context.currentThinkingBlock)
|
||||
}
|
||||
context.isInThinkingBlock = true
|
||||
context.currentTextBlock = null
|
||||
appendThinkingContent(context, chunk)
|
||||
updateStreamingMessage(set, context)
|
||||
},
|
||||
content: (data, context, get, set) => {
|
||||
@@ -1327,21 +1510,23 @@ const sseHandlers: Record<string, SSEHandler> = {
|
||||
const designWorkflowStartRegex = /<design_workflow>/
|
||||
const designWorkflowEndRegex = /<\/design_workflow>/
|
||||
|
||||
const appendTextToContent = (text: string) => {
|
||||
if (!text) return
|
||||
context.accumulatedContent.append(text)
|
||||
if (context.currentTextBlock && context.contentBlocks.length > 0) {
|
||||
const lastBlock = context.contentBlocks[context.contentBlocks.length - 1]
|
||||
if (lastBlock.type === TEXT_BLOCK_TYPE && lastBlock === context.currentTextBlock) {
|
||||
lastBlock.content += text
|
||||
return
|
||||
}
|
||||
const splitTrailingPartialTag = (
|
||||
text: string,
|
||||
tags: string[]
|
||||
): { text: string; remaining: string } => {
|
||||
const partialIndex = text.lastIndexOf('<')
|
||||
if (partialIndex < 0) {
|
||||
return { text, remaining: '' }
|
||||
}
|
||||
const possibleTag = text.substring(partialIndex)
|
||||
const matchesTagStart = tags.some((tag) => tag.startsWith(possibleTag))
|
||||
if (!matchesTagStart) {
|
||||
return { text, remaining: '' }
|
||||
}
|
||||
return {
|
||||
text: text.substring(0, partialIndex),
|
||||
remaining: possibleTag,
|
||||
}
|
||||
context.currentTextBlock = contentBlockPool.get()
|
||||
context.currentTextBlock.type = TEXT_BLOCK_TYPE
|
||||
context.currentTextBlock.content = text
|
||||
context.currentTextBlock.timestamp = Date.now()
|
||||
context.contentBlocks.push(context.currentTextBlock)
|
||||
}
|
||||
|
||||
while (contentToProcess.length > 0) {
|
||||
@@ -1363,13 +1548,17 @@ const sseHandlers: Record<string, SSEHandler> = {
|
||||
hasProcessedContent = true
|
||||
} else {
|
||||
// Still in design_workflow block, accumulate content
|
||||
context.designWorkflowContent += contentToProcess
|
||||
const { text, remaining } = splitTrailingPartialTag(contentToProcess, ['</design_workflow>'])
|
||||
context.designWorkflowContent += text
|
||||
|
||||
// Update store with partial content for streaming effect (available in all modes)
|
||||
set({ streamingPlanContent: context.designWorkflowContent })
|
||||
|
||||
contentToProcess = ''
|
||||
contentToProcess = remaining
|
||||
hasProcessedContent = true
|
||||
if (remaining) {
|
||||
break
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
@@ -1380,7 +1569,7 @@ const sseHandlers: Record<string, SSEHandler> = {
|
||||
if (designStartMatch) {
|
||||
const textBeforeDesign = contentToProcess.substring(0, designStartMatch.index)
|
||||
if (textBeforeDesign) {
|
||||
appendTextToContent(textBeforeDesign)
|
||||
appendTextBlock(context, textBeforeDesign)
|
||||
hasProcessedContent = true
|
||||
}
|
||||
context.isInDesignWorkflowBlock = true
|
||||
@@ -1471,63 +1660,27 @@ const sseHandlers: Record<string, SSEHandler> = {
|
||||
const endMatch = thinkingEndRegex.exec(contentToProcess)
|
||||
if (endMatch) {
|
||||
const thinkingContent = contentToProcess.substring(0, endMatch.index)
|
||||
if (context.currentThinkingBlock) {
|
||||
context.currentThinkingBlock.content += thinkingContent
|
||||
} else {
|
||||
context.currentThinkingBlock = contentBlockPool.get()
|
||||
context.currentThinkingBlock.type = THINKING_BLOCK_TYPE
|
||||
context.currentThinkingBlock.content = thinkingContent
|
||||
context.currentThinkingBlock.timestamp = Date.now()
|
||||
context.currentThinkingBlock.startTime = Date.now()
|
||||
context.contentBlocks.push(context.currentThinkingBlock)
|
||||
}
|
||||
context.isInThinkingBlock = false
|
||||
if (context.currentThinkingBlock) {
|
||||
context.currentThinkingBlock.duration =
|
||||
Date.now() - (context.currentThinkingBlock.startTime || Date.now())
|
||||
}
|
||||
context.currentThinkingBlock = null
|
||||
context.currentTextBlock = null
|
||||
appendThinkingContent(context, thinkingContent)
|
||||
finalizeThinkingBlock(context)
|
||||
contentToProcess = contentToProcess.substring(endMatch.index + endMatch[0].length)
|
||||
hasProcessedContent = true
|
||||
} else {
|
||||
if (context.currentThinkingBlock) {
|
||||
context.currentThinkingBlock.content += contentToProcess
|
||||
} else {
|
||||
context.currentThinkingBlock = contentBlockPool.get()
|
||||
context.currentThinkingBlock.type = THINKING_BLOCK_TYPE
|
||||
context.currentThinkingBlock.content = contentToProcess
|
||||
context.currentThinkingBlock.timestamp = Date.now()
|
||||
context.currentThinkingBlock.startTime = Date.now()
|
||||
context.contentBlocks.push(context.currentThinkingBlock)
|
||||
const { text, remaining } = splitTrailingPartialTag(contentToProcess, ['</thinking>'])
|
||||
if (text) {
|
||||
appendThinkingContent(context, text)
|
||||
hasProcessedContent = true
|
||||
}
|
||||
contentToProcess = remaining
|
||||
if (remaining) {
|
||||
break
|
||||
}
|
||||
contentToProcess = ''
|
||||
hasProcessedContent = true
|
||||
}
|
||||
} else {
|
||||
const startMatch = thinkingStartRegex.exec(contentToProcess)
|
||||
if (startMatch) {
|
||||
const textBeforeThinking = contentToProcess.substring(0, startMatch.index)
|
||||
if (textBeforeThinking) {
|
||||
context.accumulatedContent.append(textBeforeThinking)
|
||||
if (context.currentTextBlock && context.contentBlocks.length > 0) {
|
||||
const lastBlock = context.contentBlocks[context.contentBlocks.length - 1]
|
||||
if (lastBlock.type === TEXT_BLOCK_TYPE && lastBlock === context.currentTextBlock) {
|
||||
lastBlock.content += textBeforeThinking
|
||||
} else {
|
||||
context.currentTextBlock = contentBlockPool.get()
|
||||
context.currentTextBlock.type = TEXT_BLOCK_TYPE
|
||||
context.currentTextBlock.content = textBeforeThinking
|
||||
context.currentTextBlock.timestamp = Date.now()
|
||||
context.contentBlocks.push(context.currentTextBlock)
|
||||
}
|
||||
} else {
|
||||
context.currentTextBlock = contentBlockPool.get()
|
||||
context.currentTextBlock.type = TEXT_BLOCK_TYPE
|
||||
context.currentTextBlock.content = textBeforeThinking
|
||||
context.currentTextBlock.timestamp = Date.now()
|
||||
context.contentBlocks.push(context.currentTextBlock)
|
||||
}
|
||||
appendTextBlock(context, textBeforeThinking)
|
||||
hasProcessedContent = true
|
||||
}
|
||||
context.isInThinkingBlock = true
|
||||
@@ -1556,25 +1709,7 @@ const sseHandlers: Record<string, SSEHandler> = {
|
||||
remaining = contentToProcess.substring(partialTagIndex)
|
||||
}
|
||||
if (textToAdd) {
|
||||
context.accumulatedContent.append(textToAdd)
|
||||
if (context.currentTextBlock && context.contentBlocks.length > 0) {
|
||||
const lastBlock = context.contentBlocks[context.contentBlocks.length - 1]
|
||||
if (lastBlock.type === TEXT_BLOCK_TYPE && lastBlock === context.currentTextBlock) {
|
||||
lastBlock.content += textToAdd
|
||||
} else {
|
||||
context.currentTextBlock = contentBlockPool.get()
|
||||
context.currentTextBlock.type = TEXT_BLOCK_TYPE
|
||||
context.currentTextBlock.content = textToAdd
|
||||
context.currentTextBlock.timestamp = Date.now()
|
||||
context.contentBlocks.push(context.currentTextBlock)
|
||||
}
|
||||
} else {
|
||||
context.currentTextBlock = contentBlockPool.get()
|
||||
context.currentTextBlock.type = TEXT_BLOCK_TYPE
|
||||
context.currentTextBlock.content = textToAdd
|
||||
context.currentTextBlock.timestamp = Date.now()
|
||||
context.contentBlocks.push(context.currentTextBlock)
|
||||
}
|
||||
appendTextBlock(context, textToAdd)
|
||||
hasProcessedContent = true
|
||||
}
|
||||
contentToProcess = remaining
|
||||
@@ -1612,37 +1747,13 @@ const sseHandlers: Record<string, SSEHandler> = {
|
||||
stream_end: (_data, context, _get, set) => {
|
||||
if (context.pendingContent) {
|
||||
if (context.isInThinkingBlock && context.currentThinkingBlock) {
|
||||
context.currentThinkingBlock.content += context.pendingContent
|
||||
appendThinkingContent(context, context.pendingContent)
|
||||
} else if (context.pendingContent.trim()) {
|
||||
context.accumulatedContent.append(context.pendingContent)
|
||||
if (context.currentTextBlock && context.contentBlocks.length > 0) {
|
||||
const lastBlock = context.contentBlocks[context.contentBlocks.length - 1]
|
||||
if (lastBlock.type === TEXT_BLOCK_TYPE && lastBlock === context.currentTextBlock) {
|
||||
lastBlock.content += context.pendingContent
|
||||
} else {
|
||||
context.currentTextBlock = contentBlockPool.get()
|
||||
context.currentTextBlock.type = TEXT_BLOCK_TYPE
|
||||
context.currentTextBlock.content = context.pendingContent
|
||||
context.currentTextBlock.timestamp = Date.now()
|
||||
context.contentBlocks.push(context.currentTextBlock)
|
||||
}
|
||||
} else {
|
||||
context.currentTextBlock = contentBlockPool.get()
|
||||
context.currentTextBlock.type = TEXT_BLOCK_TYPE
|
||||
context.currentTextBlock.content = context.pendingContent
|
||||
context.currentTextBlock.timestamp = Date.now()
|
||||
context.contentBlocks.push(context.currentTextBlock)
|
||||
}
|
||||
appendTextBlock(context, context.pendingContent)
|
||||
}
|
||||
context.pendingContent = ''
|
||||
}
|
||||
if (context.currentThinkingBlock) {
|
||||
context.currentThinkingBlock.duration =
|
||||
Date.now() - (context.currentThinkingBlock.startTime || Date.now())
|
||||
}
|
||||
context.isInThinkingBlock = false
|
||||
context.currentThinkingBlock = null
|
||||
context.currentTextBlock = null
|
||||
finalizeThinkingBlock(context)
|
||||
updateStreamingMessage(set, context)
|
||||
},
|
||||
default: () => {},
|
||||
@@ -1740,29 +1851,7 @@ const subAgentSSEHandlers: Record<string, SSEHandler> = {
|
||||
return
|
||||
}
|
||||
|
||||
// Initialize if needed
|
||||
if (!context.subAgentContent[parentToolCallId]) {
|
||||
context.subAgentContent[parentToolCallId] = ''
|
||||
}
|
||||
if (!context.subAgentBlocks[parentToolCallId]) {
|
||||
context.subAgentBlocks[parentToolCallId] = []
|
||||
}
|
||||
|
||||
// Append content
|
||||
context.subAgentContent[parentToolCallId] += data.data
|
||||
|
||||
// Update or create the last text block in subAgentBlocks
|
||||
const blocks = context.subAgentBlocks[parentToolCallId]
|
||||
const lastBlock = blocks[blocks.length - 1]
|
||||
if (lastBlock && lastBlock.type === 'subagent_text') {
|
||||
lastBlock.content = (lastBlock.content || '') + data.data
|
||||
} else {
|
||||
blocks.push({
|
||||
type: 'subagent_text',
|
||||
content: data.data,
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
}
|
||||
appendSubAgentText(context, parentToolCallId, data.data)
|
||||
|
||||
updateToolCallWithSubAgentData(context, get, set, parentToolCallId)
|
||||
},
|
||||
@@ -1773,34 +1862,13 @@ const subAgentSSEHandlers: Record<string, SSEHandler> = {
|
||||
const phase = data?.phase || data?.data?.phase
|
||||
if (!parentToolCallId) return
|
||||
|
||||
// Initialize if needed
|
||||
if (!context.subAgentContent[parentToolCallId]) {
|
||||
context.subAgentContent[parentToolCallId] = ''
|
||||
}
|
||||
if (!context.subAgentBlocks[parentToolCallId]) {
|
||||
context.subAgentBlocks[parentToolCallId] = []
|
||||
}
|
||||
|
||||
// For reasoning, we just append the content (treating start/end as markers)
|
||||
if (phase === 'start' || phase === 'end') return
|
||||
|
||||
const chunk = typeof data?.data === 'string' ? data.data : data?.content || ''
|
||||
if (!chunk) return
|
||||
|
||||
context.subAgentContent[parentToolCallId] += chunk
|
||||
|
||||
// Update or create the last text block in subAgentBlocks
|
||||
const blocks = context.subAgentBlocks[parentToolCallId]
|
||||
const lastBlock = blocks[blocks.length - 1]
|
||||
if (lastBlock && lastBlock.type === 'subagent_text') {
|
||||
lastBlock.content = (lastBlock.content || '') + chunk
|
||||
} else {
|
||||
blocks.push({
|
||||
type: 'subagent_text',
|
||||
content: chunk,
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
}
|
||||
appendSubAgentText(context, parentToolCallId, chunk)
|
||||
|
||||
updateToolCallWithSubAgentData(context, get, set, parentToolCallId)
|
||||
},
|
||||
@@ -2002,6 +2070,14 @@ const MIN_BATCH_INTERVAL = 16
|
||||
const MAX_BATCH_INTERVAL = 50
|
||||
const MAX_QUEUE_SIZE = 5
|
||||
|
||||
function stopStreamingUpdates() {
|
||||
if (streamingUpdateRAF !== null) {
|
||||
cancelAnimationFrame(streamingUpdateRAF)
|
||||
streamingUpdateRAF = null
|
||||
}
|
||||
streamingUpdateQueue.clear()
|
||||
}
|
||||
|
||||
function createOptimizedContentBlocks(contentBlocks: any[]): any[] {
|
||||
const result: any[] = new Array(contentBlocks.length)
|
||||
for (let i = 0; i < contentBlocks.length; i++) {
|
||||
@@ -2109,6 +2185,7 @@ const initialState = {
|
||||
messages: [] as CopilotMessage[],
|
||||
checkpoints: [] as any[],
|
||||
messageCheckpoints: {} as Record<string, any[]>,
|
||||
messageSnapshots: {} as Record<string, WorkflowState>,
|
||||
isLoading: false,
|
||||
isLoadingChats: false,
|
||||
isLoadingCheckpoints: false,
|
||||
@@ -2132,6 +2209,7 @@ const initialState = {
|
||||
suppressAutoSelect: false,
|
||||
autoAllowedTools: [] as string[],
|
||||
messageQueue: [] as import('./types').QueuedMessage[],
|
||||
suppressAbortContinueOption: false,
|
||||
}
|
||||
|
||||
export const useCopilotStore = create<CopilotStore>()(
|
||||
@@ -2154,7 +2232,7 @@ export const useCopilotStore = create<CopilotStore>()(
|
||||
// Abort all in-progress tools and clear any diff preview
|
||||
abortAllInProgressTools(set, get)
|
||||
try {
|
||||
useWorkflowDiffStore.getState().clearDiff()
|
||||
useWorkflowDiffStore.getState().clearDiff({ restoreBaseline: false })
|
||||
} catch {}
|
||||
|
||||
set({
|
||||
@@ -2188,7 +2266,7 @@ export const useCopilotStore = create<CopilotStore>()(
|
||||
// Abort in-progress tools and clear diff when changing chats
|
||||
abortAllInProgressTools(set, get)
|
||||
try {
|
||||
useWorkflowDiffStore.getState().clearDiff()
|
||||
useWorkflowDiffStore.getState().clearDiff({ restoreBaseline: false })
|
||||
} catch {}
|
||||
|
||||
// Restore plan content and config (mode/model) from selected chat
|
||||
@@ -2281,7 +2359,7 @@ export const useCopilotStore = create<CopilotStore>()(
|
||||
// Abort in-progress tools and clear diff on new chat
|
||||
abortAllInProgressTools(set, get)
|
||||
try {
|
||||
useWorkflowDiffStore.getState().clearDiff()
|
||||
useWorkflowDiffStore.getState().clearDiff({ restoreBaseline: false })
|
||||
} catch {}
|
||||
|
||||
// Background-save the current chat before clearing (optimistic)
|
||||
@@ -2484,6 +2562,12 @@ export const useCopilotStore = create<CopilotStore>()(
|
||||
|
||||
const userMessage = createUserMessage(message, fileAttachments, contexts, messageId)
|
||||
const streamingMessage = createStreamingMessage()
|
||||
const snapshot = workflowId ? buildCheckpointWorkflowState(workflowId) : null
|
||||
if (snapshot) {
|
||||
set((state) => ({
|
||||
messageSnapshots: { ...state.messageSnapshots, [userMessage.id]: snapshot },
|
||||
}))
|
||||
}
|
||||
|
||||
let newMessages: CopilotMessage[]
|
||||
if (revertState) {
|
||||
@@ -2548,7 +2632,7 @@ export const useCopilotStore = create<CopilotStore>()(
|
||||
}
|
||||
|
||||
// Call copilot API
|
||||
const apiMode: 'ask' | 'agent' | 'plan' =
|
||||
const apiMode: CopilotTransportMode =
|
||||
mode === 'ask' ? 'ask' : mode === 'plan' ? 'plan' : 'agent'
|
||||
|
||||
// Extract slash commands from contexts (lowercase) and filter them out from contexts
|
||||
@@ -2640,12 +2724,14 @@ export const useCopilotStore = create<CopilotStore>()(
|
||||
},
|
||||
|
||||
// Abort streaming
|
||||
abortMessage: () => {
|
||||
abortMessage: (options?: { suppressContinueOption?: boolean }) => {
|
||||
const { abortController, isSendingMessage, messages } = get()
|
||||
if (!isSendingMessage || !abortController) return
|
||||
set({ isAborting: true })
|
||||
const suppressContinueOption = options?.suppressContinueOption === true
|
||||
set({ isAborting: true, suppressAbortContinueOption: suppressContinueOption })
|
||||
try {
|
||||
abortController.abort()
|
||||
stopStreamingUpdates()
|
||||
const lastMessage = messages[messages.length - 1]
|
||||
if (lastMessage && lastMessage.role === 'assistant') {
|
||||
const textContent =
|
||||
@@ -2653,10 +2739,19 @@ export const useCopilotStore = create<CopilotStore>()(
|
||||
?.filter((b) => b.type === 'text')
|
||||
.map((b: any) => b.content)
|
||||
.join('') || ''
|
||||
const nextContentBlocks = suppressContinueOption
|
||||
? lastMessage.contentBlocks ?? []
|
||||
: appendContinueOptionBlock(lastMessage.contentBlocks ? [...lastMessage.contentBlocks] : [])
|
||||
set((state) => ({
|
||||
messages: state.messages.map((msg) =>
|
||||
msg.id === lastMessage.id
|
||||
? { ...msg, content: textContent.trim() || 'Message was aborted' }
|
||||
? {
|
||||
...msg,
|
||||
content: suppressContinueOption
|
||||
? textContent.trim() || 'Message was aborted'
|
||||
: appendContinueOption(textContent.trim() || 'Message was aborted'),
|
||||
contentBlocks: nextContentBlocks,
|
||||
}
|
||||
: msg
|
||||
),
|
||||
isSendingMessage: false,
|
||||
@@ -2955,6 +3050,10 @@ export const useCopilotStore = create<CopilotStore>()(
|
||||
if (!workflowId) return
|
||||
set({ isRevertingCheckpoint: true, checkpointError: null })
|
||||
try {
|
||||
const { messageCheckpoints } = get()
|
||||
const checkpointMessageId = Object.entries(messageCheckpoints).find(([, cps]) =>
|
||||
(cps || []).some((cp: any) => cp?.id === checkpointId)
|
||||
)?.[0]
|
||||
const response = await fetch('/api/copilot/checkpoints/revert', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -3000,6 +3099,11 @@ export const useCopilotStore = create<CopilotStore>()(
|
||||
},
|
||||
})
|
||||
}
|
||||
if (checkpointMessageId) {
|
||||
const { messageCheckpoints: currentCheckpoints } = get()
|
||||
const updatedCheckpoints = { ...currentCheckpoints, [checkpointMessageId]: [] }
|
||||
set({ messageCheckpoints: updatedCheckpoints })
|
||||
}
|
||||
set({ isRevertingCheckpoint: false })
|
||||
} catch (error) {
|
||||
set({
|
||||
@@ -3013,6 +3117,10 @@ export const useCopilotStore = create<CopilotStore>()(
|
||||
const { messageCheckpoints } = get()
|
||||
return messageCheckpoints[messageId] || []
|
||||
},
|
||||
saveMessageCheckpoint: async (messageId: string) => {
|
||||
if (!messageId) return false
|
||||
return saveMessageCheckpoint(messageId, get, set)
|
||||
},
|
||||
|
||||
// Handle streaming response
|
||||
handleStreamingResponse: async (
|
||||
@@ -3060,7 +3168,19 @@ export const useCopilotStore = create<CopilotStore>()(
|
||||
try {
|
||||
for await (const data of parseSSEStream(reader, decoder)) {
|
||||
const { abortController } = get()
|
||||
if (abortController?.signal.aborted) break
|
||||
if (abortController?.signal.aborted) {
|
||||
context.wasAborted = true
|
||||
const { suppressAbortContinueOption } = get()
|
||||
context.suppressContinueOption = suppressAbortContinueOption === true
|
||||
if (suppressAbortContinueOption) {
|
||||
set({ suppressAbortContinueOption: false })
|
||||
}
|
||||
context.pendingContent = ''
|
||||
finalizeThinkingBlock(context)
|
||||
stopStreamingUpdates()
|
||||
reader.cancel()
|
||||
break
|
||||
}
|
||||
|
||||
// Log SSE events for debugging
|
||||
logger.info('[SSE] Received event', {
|
||||
@@ -3160,7 +3280,9 @@ export const useCopilotStore = create<CopilotStore>()(
|
||||
if (context.streamComplete) break
|
||||
}
|
||||
|
||||
if (sseHandlers.stream_end) sseHandlers.stream_end({}, context, get, set)
|
||||
if (!context.wasAborted && sseHandlers.stream_end) {
|
||||
sseHandlers.stream_end({}, context, get, set)
|
||||
}
|
||||
|
||||
if (streamingUpdateRAF !== null) {
|
||||
cancelAnimationFrame(streamingUpdateRAF)
|
||||
@@ -3177,6 +3299,9 @@ export const useCopilotStore = create<CopilotStore>()(
|
||||
: block
|
||||
)
|
||||
}
|
||||
if (context.wasAborted && !context.suppressContinueOption) {
|
||||
sanitizedContentBlocks = appendContinueOptionBlock(sanitizedContentBlocks)
|
||||
}
|
||||
|
||||
if (context.contentBlocks) {
|
||||
context.contentBlocks.forEach((block) => {
|
||||
@@ -3187,21 +3312,36 @@ export const useCopilotStore = create<CopilotStore>()(
|
||||
}
|
||||
|
||||
const finalContent = stripTodoTags(context.accumulatedContent.toString())
|
||||
set((state) => ({
|
||||
messages: state.messages.map((msg) =>
|
||||
msg.id === assistantMessageId
|
||||
? {
|
||||
...msg,
|
||||
content: finalContent,
|
||||
contentBlocks: sanitizedContentBlocks,
|
||||
}
|
||||
: msg
|
||||
),
|
||||
isSendingMessage: false,
|
||||
isAborting: false,
|
||||
abortController: null,
|
||||
currentUserMessageId: null,
|
||||
}))
|
||||
const finalContentWithOptions = context.wasAborted && !context.suppressContinueOption
|
||||
? appendContinueOption(finalContent)
|
||||
: finalContent
|
||||
set((state) => {
|
||||
const snapshotId = state.currentUserMessageId
|
||||
const nextSnapshots =
|
||||
snapshotId && state.messageSnapshots[snapshotId]
|
||||
? (() => {
|
||||
const updated = { ...state.messageSnapshots }
|
||||
delete updated[snapshotId]
|
||||
return updated
|
||||
})()
|
||||
: state.messageSnapshots
|
||||
return {
|
||||
messages: state.messages.map((msg) =>
|
||||
msg.id === assistantMessageId
|
||||
? {
|
||||
...msg,
|
||||
content: finalContentWithOptions,
|
||||
contentBlocks: sanitizedContentBlocks,
|
||||
}
|
||||
: msg
|
||||
),
|
||||
isSendingMessage: false,
|
||||
isAborting: false,
|
||||
abortController: null,
|
||||
currentUserMessageId: null,
|
||||
messageSnapshots: nextSnapshots,
|
||||
}
|
||||
})
|
||||
|
||||
if (context.newChatId && !get().currentChat) {
|
||||
await get().handleNewChatCreation(context.newChatId)
|
||||
@@ -3709,7 +3849,7 @@ export const useCopilotStore = create<CopilotStore>()(
|
||||
// If currently sending, abort and send this one
|
||||
const { isSendingMessage } = get()
|
||||
if (isSendingMessage) {
|
||||
get().abortMessage()
|
||||
get().abortMessage({ suppressContinueOption: true })
|
||||
// Wait a tick for abort to complete
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import type { CopilotMode, CopilotModelId } from '@/lib/copilot/models'
|
||||
export type { CopilotMode, CopilotModelId } from '@/lib/copilot/models'
|
||||
import type { ClientToolCallState, ClientToolDisplay } from '@/lib/copilot/tools/client/base-tool'
|
||||
import type { WorkflowState } from '@/stores/workflows/workflow/types'
|
||||
|
||||
export type ToolState = ClientToolCallState
|
||||
|
||||
@@ -91,33 +94,9 @@ import type { CopilotChat as ApiCopilotChat } from '@/lib/copilot/api'
|
||||
|
||||
export type CopilotChat = ApiCopilotChat
|
||||
|
||||
export type CopilotMode = 'ask' | 'build' | 'plan'
|
||||
|
||||
export interface CopilotState {
|
||||
mode: CopilotMode
|
||||
selectedModel:
|
||||
| 'gpt-5-fast'
|
||||
| 'gpt-5'
|
||||
| 'gpt-5-medium'
|
||||
| 'gpt-5-high'
|
||||
| 'gpt-5.1-fast'
|
||||
| 'gpt-5.1'
|
||||
| 'gpt-5.1-medium'
|
||||
| 'gpt-5.1-high'
|
||||
| 'gpt-5-codex'
|
||||
| 'gpt-5.1-codex'
|
||||
| 'gpt-5.2'
|
||||
| 'gpt-5.2-codex'
|
||||
| 'gpt-5.2-pro'
|
||||
| 'gpt-4o'
|
||||
| 'gpt-4.1'
|
||||
| 'o3'
|
||||
| 'claude-4-sonnet'
|
||||
| 'claude-4.5-haiku'
|
||||
| 'claude-4.5-sonnet'
|
||||
| 'claude-4.5-opus'
|
||||
| 'claude-4.1-opus'
|
||||
| 'gemini-3-pro'
|
||||
selectedModel: CopilotModelId
|
||||
agentPrefetch: boolean
|
||||
enabledModels: string[] | null // Null means not loaded yet, array of model IDs when loaded
|
||||
isCollapsed: boolean
|
||||
@@ -129,6 +108,7 @@ export interface CopilotState {
|
||||
|
||||
checkpoints: any[]
|
||||
messageCheckpoints: Record<string, any[]>
|
||||
messageSnapshots: Record<string, WorkflowState>
|
||||
|
||||
isLoading: boolean
|
||||
isLoadingChats: boolean
|
||||
@@ -137,6 +117,8 @@ export interface CopilotState {
|
||||
isSaving: boolean
|
||||
isRevertingCheckpoint: boolean
|
||||
isAborting: boolean
|
||||
/** Skip adding Continue option on abort for queued send-now */
|
||||
suppressAbortContinueOption?: boolean
|
||||
|
||||
error: string | null
|
||||
saveError: string | null
|
||||
@@ -197,7 +179,7 @@ export interface CopilotActions {
|
||||
messageId?: string
|
||||
}
|
||||
) => Promise<void>
|
||||
abortMessage: () => void
|
||||
abortMessage: (options?: { suppressContinueOption?: boolean }) => void
|
||||
sendImplicitFeedback: (
|
||||
implicitFeedback: string,
|
||||
toolCallState?: 'accepted' | 'rejected' | 'error'
|
||||
@@ -215,6 +197,7 @@ export interface CopilotActions {
|
||||
loadMessageCheckpoints: (chatId: string) => Promise<void>
|
||||
revertToCheckpoint: (checkpointId: string) => Promise<void>
|
||||
getCheckpointsForMessage: (messageId: string) => any[]
|
||||
saveMessageCheckpoint: (messageId: string) => Promise<boolean>
|
||||
|
||||
clearMessages: () => void
|
||||
clearError: () => void
|
||||
|
||||
@@ -23,6 +23,31 @@ import {
|
||||
const logger = createLogger('WorkflowDiffStore')
|
||||
const diffEngine = new WorkflowDiffEngine()
|
||||
|
||||
/**
|
||||
* Detects when a diff contains no meaningful changes.
|
||||
*/
|
||||
function isEmptyDiffAnalysis(diffAnalysis?: {
|
||||
new_blocks?: string[]
|
||||
edited_blocks?: string[]
|
||||
deleted_blocks?: string[]
|
||||
field_diffs?: Record<string, { changed_fields: string[] }>
|
||||
edge_diff?: { new_edges?: string[]; deleted_edges?: string[] }
|
||||
} | null): boolean {
|
||||
if (!diffAnalysis) return false
|
||||
const hasBlockChanges =
|
||||
(diffAnalysis.new_blocks?.length || 0) > 0 ||
|
||||
(diffAnalysis.edited_blocks?.length || 0) > 0 ||
|
||||
(diffAnalysis.deleted_blocks?.length || 0) > 0
|
||||
const hasEdgeChanges =
|
||||
(diffAnalysis.edge_diff?.new_edges?.length || 0) > 0 ||
|
||||
(diffAnalysis.edge_diff?.deleted_edges?.length || 0) > 0
|
||||
const hasFieldChanges =
|
||||
Object.values(diffAnalysis.field_diffs || {}).some(
|
||||
(diff) => (diff?.changed_fields?.length || 0) > 0
|
||||
)
|
||||
return !hasBlockChanges && !hasEdgeChanges && !hasFieldChanges
|
||||
}
|
||||
|
||||
export const useWorkflowDiffStore = create<WorkflowDiffState & WorkflowDiffActions>()(
|
||||
devtools(
|
||||
(set, get) => {
|
||||
@@ -75,6 +100,24 @@ export const useWorkflowDiffStore = create<WorkflowDiffState & WorkflowDiffActio
|
||||
throw new Error(errorMessage)
|
||||
}
|
||||
|
||||
const diffAnalysisResult = diffResult.diff.diffAnalysis || null
|
||||
if (isEmptyDiffAnalysis(diffAnalysisResult)) {
|
||||
logger.info('No workflow diff detected; skipping diff view')
|
||||
diffEngine.clearDiff()
|
||||
batchedUpdate({
|
||||
hasActiveDiff: false,
|
||||
isShowingDiff: false,
|
||||
isDiffReady: false,
|
||||
baselineWorkflow: null,
|
||||
baselineWorkflowId: null,
|
||||
diffAnalysis: null,
|
||||
diffMetadata: null,
|
||||
diffError: null,
|
||||
_triggerMessageId: null,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const candidateState = diffResult.diff.proposedState
|
||||
|
||||
// Validate proposed workflow using serializer round-trip
|
||||
@@ -103,12 +146,22 @@ export const useWorkflowDiffStore = create<WorkflowDiffState & WorkflowDiffActio
|
||||
isDiffReady: true,
|
||||
baselineWorkflow: baselineWorkflow,
|
||||
baselineWorkflowId,
|
||||
diffAnalysis: diffResult.diff.diffAnalysis || null,
|
||||
diffAnalysis: diffAnalysisResult,
|
||||
diffMetadata: diffResult.diff.metadata,
|
||||
diffError: null,
|
||||
_triggerMessageId: triggerMessageId ?? null,
|
||||
})
|
||||
|
||||
if (triggerMessageId) {
|
||||
import('@/stores/panel/copilot/store')
|
||||
.then(({ useCopilotStore }) =>
|
||||
useCopilotStore.getState().saveMessageCheckpoint(triggerMessageId)
|
||||
)
|
||||
.catch((error) => {
|
||||
logger.warn('Failed to save checkpoint for diff', { error })
|
||||
})
|
||||
}
|
||||
|
||||
logger.info('Workflow diff applied optimistically', {
|
||||
workflowId: activeWorkflowId,
|
||||
blocks: Object.keys(candidateState.blocks || {}).length,
|
||||
|
||||
Reference in New Issue
Block a user