mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-16 02:18:06 -05:00
Compare commits
10 Commits
main
...
fix/copilo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
23fdbbfea9 | ||
|
|
59df90ab0c | ||
|
|
a10f32dfa5 | ||
|
|
72384f190d | ||
|
|
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(
|
||||
|
||||
@@ -22,6 +22,13 @@ export async function GET(_request: Request, { params }: { params: Promise<{ id:
|
||||
.select({
|
||||
id: chat.id,
|
||||
identifier: chat.identifier,
|
||||
title: chat.title,
|
||||
description: chat.description,
|
||||
customizations: chat.customizations,
|
||||
authType: chat.authType,
|
||||
allowedEmails: chat.allowedEmails,
|
||||
outputConfigs: chat.outputConfigs,
|
||||
password: chat.password,
|
||||
isActive: chat.isActive,
|
||||
})
|
||||
.from(chat)
|
||||
@@ -34,6 +41,13 @@ export async function GET(_request: Request, { params }: { params: Promise<{ id:
|
||||
? {
|
||||
id: deploymentResults[0].id,
|
||||
identifier: deploymentResults[0].identifier,
|
||||
title: deploymentResults[0].title,
|
||||
description: deploymentResults[0].description,
|
||||
customizations: deploymentResults[0].customizations,
|
||||
authType: deploymentResults[0].authType,
|
||||
allowedEmails: deploymentResults[0].allowedEmails,
|
||||
outputConfigs: deploymentResults[0].outputConfigs,
|
||||
hasPassword: Boolean(deploymentResults[0].password),
|
||||
}
|
||||
: null
|
||||
|
||||
|
||||
@@ -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)')
|
||||
|
||||
@@ -1,10 +1,20 @@
|
||||
'use client'
|
||||
|
||||
import { memo, useEffect, useRef, useState } from 'react'
|
||||
import { memo, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { ChevronUp } from 'lucide-react'
|
||||
import CopilotMarkdownRenderer from './markdown-renderer'
|
||||
|
||||
/**
|
||||
* Removes thinking tags (raw or escaped) from streamed content.
|
||||
*/
|
||||
function stripThinkingTags(text: string): string {
|
||||
return text
|
||||
.replace(/<\/?thinking[^>]*>/gi, '')
|
||||
.replace(/<\/?thinking[^&]*>/gi, '')
|
||||
.trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* Max height for thinking content before internal scrolling kicks in
|
||||
*/
|
||||
@@ -187,6 +197,9 @@ export function ThinkingBlock({
|
||||
label = 'Thought',
|
||||
hasSpecialTags = false,
|
||||
}: ThinkingBlockProps) {
|
||||
// Strip thinking tags from content on render to handle persisted messages
|
||||
const cleanContent = useMemo(() => stripThinkingTags(content || ''), [content])
|
||||
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
const [duration, setDuration] = useState(0)
|
||||
const [userHasScrolledAway, setUserHasScrolledAway] = useState(false)
|
||||
@@ -209,10 +222,10 @@ export function ThinkingBlock({
|
||||
return
|
||||
}
|
||||
|
||||
if (!userCollapsedRef.current && content && content.trim().length > 0) {
|
||||
if (!userCollapsedRef.current && cleanContent && cleanContent.length > 0) {
|
||||
setIsExpanded(true)
|
||||
}
|
||||
}, [isStreaming, content, hasFollowingContent, hasSpecialTags])
|
||||
}, [isStreaming, cleanContent, hasFollowingContent, hasSpecialTags])
|
||||
|
||||
// Reset start time when streaming begins
|
||||
useEffect(() => {
|
||||
@@ -298,7 +311,7 @@ export function ThinkingBlock({
|
||||
return `${seconds}s`
|
||||
}
|
||||
|
||||
const hasContent = content && content.trim().length > 0
|
||||
const hasContent = cleanContent.length > 0
|
||||
// Thinking is "done" when streaming ends OR when there's following content (like a tool call) OR when special tags appear
|
||||
const isThinkingDone = !isStreaming || hasFollowingContent || hasSpecialTags
|
||||
const durationText = `${label} for ${formatDuration(duration)}`
|
||||
@@ -374,7 +387,7 @@ export function ThinkingBlock({
|
||||
isExpanded ? 'mt-1.5 max-h-[150px] opacity-100' : 'max-h-0 opacity-0'
|
||||
)}
|
||||
>
|
||||
<SmoothThinkingText content={content} isStreaming={isStreaming && !hasFollowingContent} />
|
||||
<SmoothThinkingText content={cleanContent} isStreaming={isStreaming && !hasFollowingContent} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -412,7 +425,7 @@ export function ThinkingBlock({
|
||||
>
|
||||
{/* Completed thinking text - dimmed with markdown */}
|
||||
<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)]'>
|
||||
<CopilotMarkdownRenderer content={content} />
|
||||
<CopilotMarkdownRenderer content={cleanContent} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { type FC, memo, useCallback, useMemo, useState } from 'react'
|
||||
import { type FC, memo, useCallback, useMemo, useRef, useState } from 'react'
|
||||
import { RotateCcw } from 'lucide-react'
|
||||
import { Button } from '@/components/emcn'
|
||||
import {
|
||||
@@ -93,6 +93,8 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
||||
// UI state
|
||||
const [isHoveringMessage, setIsHoveringMessage] = useState(false)
|
||||
|
||||
const cancelEditRef = useRef<(() => void) | null>(null)
|
||||
|
||||
// Checkpoint management hook
|
||||
const {
|
||||
showRestoreConfirmation,
|
||||
@@ -112,7 +114,8 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
||||
messages,
|
||||
messageCheckpoints,
|
||||
onRevertModeChange,
|
||||
onEditModeChange
|
||||
onEditModeChange,
|
||||
() => cancelEditRef.current?.()
|
||||
)
|
||||
|
||||
// Message editing hook
|
||||
@@ -142,6 +145,8 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
||||
pendingEditRef,
|
||||
})
|
||||
|
||||
cancelEditRef.current = handleCancelEdit
|
||||
|
||||
// Get clean text content with double newline parsing
|
||||
const cleanTextContent = useMemo(() => {
|
||||
if (!message.content) return ''
|
||||
|
||||
@@ -22,7 +22,8 @@ export function useCheckpointManagement(
|
||||
messages: CopilotMessage[],
|
||||
messageCheckpoints: any[],
|
||||
onRevertModeChange?: (isReverting: boolean) => void,
|
||||
onEditModeChange?: (isEditing: boolean) => void
|
||||
onEditModeChange?: (isEditing: boolean) => void,
|
||||
onCancelEdit?: () => void
|
||||
) {
|
||||
const [showRestoreConfirmation, setShowRestoreConfirmation] = useState(false)
|
||||
const [showCheckpointDiscardModal, setShowCheckpointDiscardModal] = useState(false)
|
||||
@@ -57,7 +58,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 +141,7 @@ export function useCheckpointManagement(
|
||||
const { messageCheckpoints: currentCheckpoints } = useCopilotStore.getState()
|
||||
const updatedCheckpoints = {
|
||||
...currentCheckpoints,
|
||||
[message.id]: messageCheckpoints.slice(1),
|
||||
[message.id]: [],
|
||||
}
|
||||
useCopilotStore.setState({ messageCheckpoints: updatedCheckpoints })
|
||||
|
||||
@@ -154,6 +155,8 @@ export function useCheckpointManagement(
|
||||
}
|
||||
|
||||
setShowCheckpointDiscardModal(false)
|
||||
onEditModeChange?.(false)
|
||||
onCancelEdit?.()
|
||||
|
||||
const { sendMessage } = useCopilotStore.getState()
|
||||
if (pendingEditRef.current) {
|
||||
@@ -180,13 +183,22 @@ export function useCheckpointManagement(
|
||||
} finally {
|
||||
setIsProcessingDiscard(false)
|
||||
}
|
||||
}, [messageCheckpoints, revertToCheckpoint, message, messages])
|
||||
}, [
|
||||
messageCheckpoints,
|
||||
revertToCheckpoint,
|
||||
message,
|
||||
messages,
|
||||
onEditModeChange,
|
||||
onCancelEdit,
|
||||
])
|
||||
|
||||
/**
|
||||
* Cancels checkpoint discard and clears pending edit
|
||||
*/
|
||||
const handleCancelCheckpointDiscard = useCallback(() => {
|
||||
setShowCheckpointDiscardModal(false)
|
||||
onEditModeChange?.(false)
|
||||
onCancelEdit?.()
|
||||
pendingEditRef.current = null
|
||||
}, [])
|
||||
|
||||
@@ -218,7 +230,7 @@ export function useCheckpointManagement(
|
||||
}
|
||||
pendingEditRef.current = null
|
||||
}
|
||||
}, [message, messages])
|
||||
}, [message, messages, onEditModeChange, onCancelEdit])
|
||||
|
||||
/**
|
||||
* Handles keyboard events for restore confirmation (Escape/Enter)
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -25,36 +25,41 @@ export class GetBlockOptionsClientTool extends BaseClientTool {
|
||||
|
||||
static readonly metadata: BaseClientToolMetadata = {
|
||||
displayNames: {
|
||||
[ClientToolCallState.generating]: { text: 'Getting block options', icon: Loader2 },
|
||||
[ClientToolCallState.pending]: { text: 'Getting block options', icon: Loader2 },
|
||||
[ClientToolCallState.executing]: { text: 'Getting block options', icon: Loader2 },
|
||||
[ClientToolCallState.success]: { text: 'Retrieved block options', icon: ListFilter },
|
||||
[ClientToolCallState.error]: { text: 'Failed to get block options', icon: XCircle },
|
||||
[ClientToolCallState.aborted]: { text: 'Aborted getting block options', icon: XCircle },
|
||||
[ClientToolCallState.generating]: { text: 'Getting block operations', icon: Loader2 },
|
||||
[ClientToolCallState.pending]: { text: 'Getting block operations', icon: Loader2 },
|
||||
[ClientToolCallState.executing]: { text: 'Getting block operations', icon: Loader2 },
|
||||
[ClientToolCallState.success]: { text: 'Retrieved block operations', icon: ListFilter },
|
||||
[ClientToolCallState.error]: { text: 'Failed to get block operations', icon: XCircle },
|
||||
[ClientToolCallState.aborted]: { text: 'Aborted getting block operations', icon: XCircle },
|
||||
[ClientToolCallState.rejected]: {
|
||||
text: 'Skipped getting block options',
|
||||
text: 'Skipped getting block operations',
|
||||
icon: MinusCircle,
|
||||
},
|
||||
},
|
||||
getDynamicText: (params, state) => {
|
||||
if (params?.blockId && typeof params.blockId === 'string') {
|
||||
const blockId =
|
||||
(params as any)?.blockId ||
|
||||
(params as any)?.blockType ||
|
||||
(params as any)?.block_id ||
|
||||
(params as any)?.block_type
|
||||
if (typeof blockId === 'string') {
|
||||
// Look up the block config to get the human-readable name
|
||||
const blockConfig = getBlock(params.blockId)
|
||||
const blockName = (blockConfig?.name ?? params.blockId.replace(/_/g, ' ')).toLowerCase()
|
||||
const blockConfig = getBlock(blockId)
|
||||
const blockName = (blockConfig?.name ?? blockId.replace(/_/g, ' ')).toLowerCase()
|
||||
|
||||
switch (state) {
|
||||
case ClientToolCallState.success:
|
||||
return `Retrieved ${blockName} options`
|
||||
return `Retrieved ${blockName} operations`
|
||||
case ClientToolCallState.executing:
|
||||
case ClientToolCallState.generating:
|
||||
case ClientToolCallState.pending:
|
||||
return `Retrieving ${blockName} options`
|
||||
return `Retrieving ${blockName} operations`
|
||||
case ClientToolCallState.error:
|
||||
return `Failed to retrieve ${blockName} options`
|
||||
return `Failed to retrieve ${blockName} operations`
|
||||
case ClientToolCallState.aborted:
|
||||
return `Aborted retrieving ${blockName} options`
|
||||
return `Aborted retrieving ${blockName} operations`
|
||||
case ClientToolCallState.rejected:
|
||||
return `Skipped retrieving ${blockName} options`
|
||||
return `Skipped retrieving ${blockName} operations`
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
|
||||
@@ -28,6 +28,7 @@ import './workflow/deploy-api'
|
||||
import './workflow/deploy-chat'
|
||||
import './workflow/deploy-mcp'
|
||||
import './workflow/edit-workflow'
|
||||
import './workflow/redeploy'
|
||||
import './workflow/run-workflow'
|
||||
import './workflow/set-global-workflow-variables'
|
||||
|
||||
|
||||
@@ -15,6 +15,8 @@ interface ApiDeploymentDetails {
|
||||
isDeployed: boolean
|
||||
deployedAt: string | null
|
||||
endpoint: string | null
|
||||
apiKey: string | null
|
||||
needsRedeployment: boolean
|
||||
}
|
||||
|
||||
interface ChatDeploymentDetails {
|
||||
@@ -22,6 +24,14 @@ interface ChatDeploymentDetails {
|
||||
chatId: string | null
|
||||
identifier: string | null
|
||||
chatUrl: string | null
|
||||
title: string | null
|
||||
description: string | null
|
||||
authType: string | null
|
||||
allowedEmails: string[] | null
|
||||
outputConfigs: Array<{ blockId: string; path: string }> | null
|
||||
welcomeMessage: string | null
|
||||
primaryColor: string | null
|
||||
hasPassword: boolean
|
||||
}
|
||||
|
||||
interface McpDeploymentDetails {
|
||||
@@ -31,6 +41,8 @@ interface McpDeploymentDetails {
|
||||
serverName: string
|
||||
toolName: string
|
||||
toolDescription: string | null
|
||||
parameterSchema?: Record<string, unknown> | null
|
||||
toolId?: string | null
|
||||
}>
|
||||
}
|
||||
|
||||
@@ -96,6 +108,8 @@ export class CheckDeploymentStatusClientTool extends BaseClientTool {
|
||||
isDeployed: isApiDeployed,
|
||||
deployedAt: apiDeploy?.deployedAt || null,
|
||||
endpoint: isApiDeployed ? `${appUrl}/api/workflows/${workflowId}/execute` : null,
|
||||
apiKey: apiDeploy?.apiKey || null,
|
||||
needsRedeployment: apiDeploy?.needsRedeployment === true,
|
||||
}
|
||||
|
||||
// Chat deployment details
|
||||
@@ -105,6 +119,18 @@ export class CheckDeploymentStatusClientTool extends BaseClientTool {
|
||||
chatId: chatDeploy?.deployment?.id || null,
|
||||
identifier: chatDeploy?.deployment?.identifier || null,
|
||||
chatUrl: isChatDeployed ? `${appUrl}/chat/${chatDeploy?.deployment?.identifier}` : null,
|
||||
title: chatDeploy?.deployment?.title || null,
|
||||
description: chatDeploy?.deployment?.description || null,
|
||||
authType: chatDeploy?.deployment?.authType || null,
|
||||
allowedEmails: Array.isArray(chatDeploy?.deployment?.allowedEmails)
|
||||
? chatDeploy?.deployment?.allowedEmails
|
||||
: null,
|
||||
outputConfigs: Array.isArray(chatDeploy?.deployment?.outputConfigs)
|
||||
? chatDeploy?.deployment?.outputConfigs
|
||||
: null,
|
||||
welcomeMessage: chatDeploy?.deployment?.customizations?.welcomeMessage || null,
|
||||
primaryColor: chatDeploy?.deployment?.customizations?.primaryColor || null,
|
||||
hasPassword: chatDeploy?.deployment?.hasPassword === true,
|
||||
}
|
||||
|
||||
// MCP deployment details - find servers that have this workflow as a tool
|
||||
@@ -129,6 +155,8 @@ export class CheckDeploymentStatusClientTool extends BaseClientTool {
|
||||
serverName: server.name,
|
||||
toolName: tool.toolName,
|
||||
toolDescription: tool.toolDescription,
|
||||
parameterSchema: tool.parameterSchema ?? null,
|
||||
toolId: tool.id ?? null,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -208,54 +208,70 @@ export class DeployChatClientTool extends BaseClientTool {
|
||||
return
|
||||
}
|
||||
|
||||
// Deploy action - validate required fields
|
||||
if (!args?.identifier && !workflow?.name) {
|
||||
throw new Error('Either identifier or workflow name is required')
|
||||
this.setState(ClientToolCallState.executing)
|
||||
|
||||
const statusRes = await fetch(`/api/workflows/${workflowId}/chat/status`)
|
||||
const statusJson = statusRes.ok ? await statusRes.json() : null
|
||||
const existingDeployment = statusJson?.deployment || null
|
||||
|
||||
const baseIdentifier =
|
||||
existingDeployment?.identifier || this.generateIdentifier(workflow?.name || 'chat')
|
||||
const baseTitle = existingDeployment?.title || workflow?.name || 'Chat'
|
||||
const baseDescription = existingDeployment?.description || ''
|
||||
const baseAuthType = existingDeployment?.authType || 'public'
|
||||
const baseWelcomeMessage =
|
||||
existingDeployment?.customizations?.welcomeMessage || 'Hi there! How can I help you today?'
|
||||
const basePrimaryColor =
|
||||
existingDeployment?.customizations?.primaryColor || 'var(--brand-primary-hover-hex)'
|
||||
const baseAllowedEmails = Array.isArray(existingDeployment?.allowedEmails)
|
||||
? existingDeployment.allowedEmails
|
||||
: []
|
||||
const baseOutputConfigs = Array.isArray(existingDeployment?.outputConfigs)
|
||||
? existingDeployment.outputConfigs
|
||||
: []
|
||||
|
||||
const identifier = args?.identifier || baseIdentifier
|
||||
const title = args?.title || baseTitle
|
||||
const description = args?.description ?? baseDescription
|
||||
const authType = args?.authType || baseAuthType
|
||||
const welcomeMessage = args?.welcomeMessage || baseWelcomeMessage
|
||||
const outputConfigs = args?.outputConfigs || baseOutputConfigs
|
||||
const allowedEmails = args?.allowedEmails || baseAllowedEmails
|
||||
const primaryColor = basePrimaryColor
|
||||
|
||||
if (!identifier || !title) {
|
||||
throw new Error('Chat identifier and title are required')
|
||||
}
|
||||
|
||||
if (!args?.title && !workflow?.name) {
|
||||
throw new Error('Chat title is required')
|
||||
}
|
||||
|
||||
const identifier = args?.identifier || this.generateIdentifier(workflow?.name || 'chat')
|
||||
const title = args?.title || workflow?.name || 'Chat'
|
||||
const description = args?.description || ''
|
||||
const authType = args?.authType || 'public'
|
||||
const welcomeMessage = args?.welcomeMessage || 'Hi there! How can I help you today?'
|
||||
|
||||
// Validate auth-specific requirements
|
||||
if (authType === 'password' && !args?.password) {
|
||||
if (authType === 'password' && !args?.password && !existingDeployment?.hasPassword) {
|
||||
throw new Error('Password is required when using password protection')
|
||||
}
|
||||
|
||||
if (
|
||||
(authType === 'email' || authType === 'sso') &&
|
||||
(!args?.allowedEmails || args.allowedEmails.length === 0)
|
||||
) {
|
||||
if ((authType === 'email' || authType === 'sso') && allowedEmails.length === 0) {
|
||||
throw new Error(`At least one email or domain is required when using ${authType} access`)
|
||||
}
|
||||
|
||||
this.setState(ClientToolCallState.executing)
|
||||
|
||||
const outputConfigs = args?.outputConfigs || []
|
||||
|
||||
const payload = {
|
||||
workflowId,
|
||||
identifier: identifier.trim(),
|
||||
title: title.trim(),
|
||||
description: description.trim(),
|
||||
customizations: {
|
||||
primaryColor: 'var(--brand-primary-hover-hex)',
|
||||
primaryColor,
|
||||
welcomeMessage: welcomeMessage.trim(),
|
||||
},
|
||||
authType,
|
||||
password: authType === 'password' ? args?.password : undefined,
|
||||
allowedEmails: authType === 'email' || authType === 'sso' ? args?.allowedEmails : [],
|
||||
allowedEmails: authType === 'email' || authType === 'sso' ? allowedEmails : [],
|
||||
outputConfigs,
|
||||
}
|
||||
|
||||
const res = await fetch('/api/chat', {
|
||||
method: 'POST',
|
||||
const isUpdating = Boolean(existingDeployment?.id)
|
||||
const endpoint = isUpdating ? `/api/chat/manage/${existingDeployment.id}` : '/api/chat'
|
||||
const method = isUpdating ? 'PATCH' : 'POST'
|
||||
|
||||
const res = await fetch(endpoint, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
@@ -265,18 +281,18 @@ export class DeployChatClientTool extends BaseClientTool {
|
||||
if (!res.ok) {
|
||||
if (json.error === 'Identifier already in use') {
|
||||
this.setState(ClientToolCallState.error)
|
||||
await this.markToolComplete(
|
||||
400,
|
||||
`The identifier "${identifier}" is already in use. Please choose a different one.`,
|
||||
{
|
||||
success: false,
|
||||
action: 'deploy',
|
||||
isDeployed: false,
|
||||
identifier,
|
||||
error: `Identifier "${identifier}" is already taken`,
|
||||
errorCode: 'IDENTIFIER_TAKEN',
|
||||
}
|
||||
)
|
||||
await this.markToolComplete(
|
||||
400,
|
||||
`The identifier "${identifier}" is already in use. Please choose a different one.`,
|
||||
{
|
||||
success: false,
|
||||
action: 'deploy',
|
||||
isDeployed: false,
|
||||
identifier,
|
||||
error: `Identifier "${identifier}" is already taken`,
|
||||
errorCode: 'IDENTIFIER_TAKEN',
|
||||
}
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -128,7 +128,6 @@ export class DeployMcpClientTool extends BaseClientTool {
|
||||
|
||||
this.setState(ClientToolCallState.executing)
|
||||
|
||||
// Build parameter schema with descriptions if provided
|
||||
let parameterSchema: Record<string, unknown> | undefined
|
||||
if (args?.parameterDescriptions && args.parameterDescriptions.length > 0) {
|
||||
const properties: Record<string, { description: string }> = {}
|
||||
@@ -155,9 +154,49 @@ export class DeployMcpClientTool extends BaseClientTool {
|
||||
const data = await res.json()
|
||||
|
||||
if (!res.ok) {
|
||||
// Handle specific error cases
|
||||
if (data.error?.includes('already added')) {
|
||||
throw new Error('This workflow is already deployed to this MCP server')
|
||||
const toolsRes = await fetch(
|
||||
`/api/mcp/workflow-servers/${args.serverId}/tools?workspaceId=${workspaceId}`
|
||||
)
|
||||
const toolsJson = toolsRes.ok ? await toolsRes.json() : null
|
||||
const tools = toolsJson?.data?.tools || []
|
||||
const existingTool = tools.find((tool: any) => tool.workflowId === workflowId)
|
||||
if (!existingTool?.id) {
|
||||
throw new Error('This workflow is already deployed to this MCP server')
|
||||
}
|
||||
const patchRes = await fetch(
|
||||
`/api/mcp/workflow-servers/${args.serverId}/tools/${existingTool.id}?workspaceId=${workspaceId}`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
toolName: args.toolName?.trim(),
|
||||
toolDescription: args.toolDescription?.trim(),
|
||||
parameterSchema,
|
||||
}),
|
||||
}
|
||||
)
|
||||
const patchJson = patchRes.ok ? await patchRes.json() : null
|
||||
if (!patchRes.ok) {
|
||||
const patchError = patchJson?.error || `Failed to update MCP tool (${patchRes.status})`
|
||||
throw new Error(patchError)
|
||||
}
|
||||
const updatedTool = patchJson?.data?.tool
|
||||
this.setState(ClientToolCallState.success)
|
||||
await this.markToolComplete(
|
||||
200,
|
||||
`Workflow MCP tool updated to "${updatedTool?.toolName || existingTool.toolName}".`,
|
||||
{
|
||||
success: true,
|
||||
toolId: updatedTool?.id || existingTool.id,
|
||||
toolName: updatedTool?.toolName || existingTool.toolName,
|
||||
toolDescription: updatedTool?.toolDescription || existingTool.toolDescription,
|
||||
serverId: args.serverId,
|
||||
updated: true,
|
||||
}
|
||||
)
|
||||
logger.info('Updated workflow MCP tool', { toolId: existingTool.id })
|
||||
return
|
||||
}
|
||||
if (data.error?.includes('not deployed')) {
|
||||
throw new Error('Workflow must be deployed before adding as an MCP tool')
|
||||
|
||||
@@ -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)
|
||||
|
||||
72
apps/sim/lib/copilot/tools/client/workflow/redeploy.ts
Normal file
72
apps/sim/lib/copilot/tools/client/workflow/redeploy.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { Loader2, Rocket, XCircle } from 'lucide-react'
|
||||
import {
|
||||
BaseClientTool,
|
||||
type BaseClientToolMetadata,
|
||||
ClientToolCallState,
|
||||
} from '@/lib/copilot/tools/client/base-tool'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
|
||||
export class RedeployClientTool extends BaseClientTool {
|
||||
static readonly id = 'redeploy'
|
||||
private hasExecuted = false
|
||||
|
||||
constructor(toolCallId: string) {
|
||||
super(toolCallId, RedeployClientTool.id, RedeployClientTool.metadata)
|
||||
}
|
||||
|
||||
static readonly metadata: BaseClientToolMetadata = {
|
||||
displayNames: {
|
||||
[ClientToolCallState.generating]: { text: 'Redeploying workflow', icon: Loader2 },
|
||||
[ClientToolCallState.pending]: { text: 'Redeploy workflow', icon: Loader2 },
|
||||
[ClientToolCallState.executing]: { text: 'Redeploying workflow', icon: Loader2 },
|
||||
[ClientToolCallState.success]: { text: 'Redeployed workflow', icon: Rocket },
|
||||
[ClientToolCallState.error]: { text: 'Failed to redeploy workflow', icon: XCircle },
|
||||
[ClientToolCallState.aborted]: { text: 'Aborted redeploy', icon: XCircle },
|
||||
[ClientToolCallState.rejected]: { text: 'Skipped redeploy', icon: XCircle },
|
||||
},
|
||||
interrupt: undefined,
|
||||
}
|
||||
|
||||
async execute(): Promise<void> {
|
||||
const logger = createLogger('RedeployClientTool')
|
||||
try {
|
||||
if (this.hasExecuted) {
|
||||
logger.info('execute skipped (already executed)', { toolCallId: this.toolCallId })
|
||||
return
|
||||
}
|
||||
this.hasExecuted = true
|
||||
|
||||
this.setState(ClientToolCallState.executing)
|
||||
|
||||
const { activeWorkflowId } = useWorkflowRegistry.getState()
|
||||
if (!activeWorkflowId) {
|
||||
throw new Error('No workflow ID provided')
|
||||
}
|
||||
|
||||
const res = await fetch(`/api/workflows/${activeWorkflowId}/deploy`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ deployChatEnabled: false }),
|
||||
})
|
||||
|
||||
const json = await res.json().catch(() => ({}))
|
||||
if (!res.ok) {
|
||||
const errorText = json?.error || `Server error (${res.status})`
|
||||
throw new Error(errorText)
|
||||
}
|
||||
|
||||
this.setState(ClientToolCallState.success)
|
||||
await this.markToolComplete(200, 'Workflow redeployed', {
|
||||
workflowId: activeWorkflowId,
|
||||
deployedAt: json?.deployedAt || null,
|
||||
schedule: json?.schedule,
|
||||
})
|
||||
} catch (error: any) {
|
||||
logger.error('Redeploy failed', { message: error?.message })
|
||||
this.setState(ClientToolCallState.error)
|
||||
await this.markToolComplete(500, error?.message || 'Failed to redeploy workflow')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
@@ -71,6 +72,7 @@ import { ListUserWorkflowsClientTool } from '@/lib/copilot/tools/client/workflow
|
||||
import { ListWorkspaceMcpServersClientTool } from '@/lib/copilot/tools/client/workflow/list-workspace-mcp-servers'
|
||||
import { ManageCustomToolClientTool } from '@/lib/copilot/tools/client/workflow/manage-custom-tool'
|
||||
import { ManageMcpToolClientTool } from '@/lib/copilot/tools/client/workflow/manage-mcp-tool'
|
||||
import { RedeployClientTool } from '@/lib/copilot/tools/client/workflow/redeploy'
|
||||
import { RunWorkflowClientTool } from '@/lib/copilot/tools/client/workflow/run-workflow'
|
||||
import { SetGlobalWorkflowVariablesClientTool } from '@/lib/copilot/tools/client/workflow/set-global-workflow-variables'
|
||||
import { getQueryClient } from '@/app/_shell/providers/query-provider'
|
||||
@@ -84,7 +86,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')
|
||||
|
||||
@@ -147,6 +151,7 @@ const CLIENT_TOOL_INSTANTIATORS: Record<string, (id: string) => any> = {
|
||||
deploy_api: (id) => new DeployApiClientTool(id),
|
||||
deploy_chat: (id) => new DeployChatClientTool(id),
|
||||
deploy_mcp: (id) => new DeployMcpClientTool(id),
|
||||
redeploy: (id) => new RedeployClientTool(id),
|
||||
list_workspace_mcp_servers: (id) => new ListWorkspaceMcpServersClientTool(id),
|
||||
create_workspace_mcp_server: (id) => new CreateWorkspaceMcpServerClientTool(id),
|
||||
check_deployment_status: (id) => new CheckDeploymentStatusClientTool(id),
|
||||
@@ -209,6 +214,7 @@ export const CLASS_TOOL_METADATA: Record<string, BaseClientToolMetadata | undefi
|
||||
deploy_api: (DeployApiClientTool as any)?.metadata,
|
||||
deploy_chat: (DeployChatClientTool as any)?.metadata,
|
||||
deploy_mcp: (DeployMcpClientTool as any)?.metadata,
|
||||
redeploy: (RedeployClientTool as any)?.metadata,
|
||||
list_workspace_mcp_servers: (ListWorkspaceMcpServersClientTool as any)?.metadata,
|
||||
create_workspace_mcp_server: (CreateWorkspaceMcpServerClientTool as any)?.metadata,
|
||||
check_deployment_status: (CheckDeploymentStatusClientTool as any)?.metadata,
|
||||
@@ -237,6 +243,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 +367,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 +381,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 +636,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 +933,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 +952,131 @@ 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 (raw or escaped) from streamed content.
|
||||
*/
|
||||
function stripThinkingTags(text: string): string {
|
||||
return text
|
||||
.replace(/<\/?thinking[^>]*>/gi, '')
|
||||
.replace(/<\/?thinking[^&]*>/gi, '')
|
||||
}
|
||||
|
||||
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 +1267,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 +1303,15 @@ 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)
|
||||
|
||||
|
||||
// Do not execute on partial tool_call frames
|
||||
if (isPartial) {
|
||||
return
|
||||
}
|
||||
|
||||
// Prefer interface-based registry to determine interrupt and execute
|
||||
try {
|
||||
const def = name ? getTool(name) : undefined
|
||||
@@ -1275,44 +1494,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 +1520,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 +1558,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 +1579,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 +1670,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 +1719,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 +1757,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 +1861,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 +1872,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)
|
||||
},
|
||||
@@ -1819,6 +1897,7 @@ const subAgentSSEHandlers: Record<string, SSEHandler> = {
|
||||
const id: string | undefined = toolData.id || data?.toolCallId
|
||||
const name: string | undefined = toolData.name || data?.toolName
|
||||
if (!id || !name) return
|
||||
const isPartial = toolData.partial === true
|
||||
|
||||
// Arguments can come in different locations depending on SSE format
|
||||
// Check multiple possible locations
|
||||
@@ -1885,6 +1964,10 @@ const subAgentSSEHandlers: Record<string, SSEHandler> = {
|
||||
|
||||
updateToolCallWithSubAgentData(context, get, set, parentToolCallId)
|
||||
|
||||
if (isPartial) {
|
||||
return
|
||||
}
|
||||
|
||||
// Execute client tools in parallel (non-blocking) - same pattern as main tool_call handler
|
||||
try {
|
||||
const def = getTool(name)
|
||||
@@ -2002,6 +2085,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 +2200,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 +2224,7 @@ const initialState = {
|
||||
suppressAutoSelect: false,
|
||||
autoAllowedTools: [] as string[],
|
||||
messageQueue: [] as import('./types').QueuedMessage[],
|
||||
suppressAbortContinueOption: false,
|
||||
}
|
||||
|
||||
export const useCopilotStore = create<CopilotStore>()(
|
||||
@@ -2154,7 +2247,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 +2281,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 +2374,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)
|
||||
@@ -2454,7 +2547,14 @@ export const useCopilotStore = create<CopilotStore>()(
|
||||
|
||||
// Send a message (streaming only)
|
||||
sendMessage: async (message: string, options = {}) => {
|
||||
const { workflowId, currentChat, mode, revertState, isSendingMessage } = get()
|
||||
const {
|
||||
workflowId,
|
||||
currentChat,
|
||||
mode,
|
||||
revertState,
|
||||
isSendingMessage,
|
||||
abortController: activeAbortController,
|
||||
} = get()
|
||||
const {
|
||||
stream = true,
|
||||
fileAttachments,
|
||||
@@ -2470,7 +2570,17 @@ export const useCopilotStore = create<CopilotStore>()(
|
||||
if (!workflowId) return
|
||||
|
||||
// If already sending a message, queue this one instead
|
||||
if (isSendingMessage) {
|
||||
if (isSendingMessage && !activeAbortController) {
|
||||
logger.warn('[Copilot] sendMessage: stale sending state detected, clearing', {
|
||||
originalMessageId: messageId,
|
||||
})
|
||||
set({ isSendingMessage: false })
|
||||
} else if (isSendingMessage && activeAbortController?.signal.aborted) {
|
||||
logger.warn('[Copilot] sendMessage: aborted controller detected, clearing', {
|
||||
originalMessageId: messageId,
|
||||
})
|
||||
set({ isSendingMessage: false, abortController: null })
|
||||
} else if (isSendingMessage) {
|
||||
get().addToQueue(message, { fileAttachments, contexts, messageId })
|
||||
logger.info('[Copilot] Message queued (already sending)', {
|
||||
queueLength: get().messageQueue.length + 1,
|
||||
@@ -2479,11 +2589,17 @@ export const useCopilotStore = create<CopilotStore>()(
|
||||
return
|
||||
}
|
||||
|
||||
const abortController = new AbortController()
|
||||
set({ isSendingMessage: true, error: null, abortController })
|
||||
const nextAbortController = new AbortController()
|
||||
set({ isSendingMessage: true, error: null, abortController: nextAbortController })
|
||||
|
||||
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 +2664,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
|
||||
@@ -2570,7 +2686,7 @@ export const useCopilotStore = create<CopilotStore>()(
|
||||
fileAttachments,
|
||||
contexts: filteredContexts,
|
||||
commands: commands?.length ? commands : undefined,
|
||||
abortSignal: abortController.signal,
|
||||
abortSignal: nextAbortController.signal,
|
||||
})
|
||||
|
||||
if (result.success && result.stream) {
|
||||
@@ -2640,12 +2756,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 +2771,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 +3082,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 +3131,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 +3149,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 +3200,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 +3312,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 +3331,9 @@ export const useCopilotStore = create<CopilotStore>()(
|
||||
: block
|
||||
)
|
||||
}
|
||||
if (context.wasAborted && !context.suppressContinueOption) {
|
||||
sanitizedContentBlocks = appendContinueOptionBlock(sanitizedContentBlocks)
|
||||
}
|
||||
|
||||
if (context.contentBlocks) {
|
||||
context.contentBlocks.forEach((block) => {
|
||||
@@ -3187,21 +3344,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 +3881,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