mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
Consolidation
This commit is contained in:
@@ -81,15 +81,17 @@ const FileAttachmentSchema = z.object({
|
||||
|
||||
const ChatMessageSchema = z.object({
|
||||
message: z.string().min(1, 'Message is required'),
|
||||
userMessageId: z.string().optional(), // ID from frontend for the user message
|
||||
userMessageId: z.string().optional(),
|
||||
chatId: z.string().optional(),
|
||||
workflowId: z.string().optional(),
|
||||
workspaceId: z.string().optional(),
|
||||
workflowName: z.string().optional(),
|
||||
model: z.string().optional().default('claude-opus-4-5'),
|
||||
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),
|
||||
headless: z.boolean().optional(),
|
||||
implicitFeedback: z.string().optional(),
|
||||
fileAttachments: z.array(FileAttachmentSchema).optional(),
|
||||
provider: z.string().optional(),
|
||||
@@ -116,7 +118,6 @@ const ChatMessageSchema = z.object({
|
||||
blockIds: z.array(z.string()).optional(),
|
||||
templateId: z.string().optional(),
|
||||
executionId: z.string().optional(),
|
||||
// For workflow_block, provide both workflowId and blockId
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
@@ -146,12 +147,14 @@ export async function POST(req: NextRequest) {
|
||||
userMessageId,
|
||||
chatId,
|
||||
workflowId: providedWorkflowId,
|
||||
workspaceId: providedWorkspaceId,
|
||||
workflowName,
|
||||
model,
|
||||
mode,
|
||||
prefetch,
|
||||
createNewChat,
|
||||
stream,
|
||||
headless,
|
||||
implicitFeedback,
|
||||
fileAttachments,
|
||||
provider,
|
||||
@@ -174,23 +177,38 @@ export async function POST(req: NextRequest) {
|
||||
})
|
||||
: contexts
|
||||
|
||||
// Resolve workflowId - if not provided, use first workflow or find by name
|
||||
const resolved = await resolveWorkflowIdForUser(
|
||||
authenticatedUserId,
|
||||
providedWorkflowId,
|
||||
workflowName
|
||||
)
|
||||
if (!resolved) {
|
||||
return createBadRequestResponse(
|
||||
'No workflows found. Create a workflow first or provide a valid workflowId.'
|
||||
)
|
||||
}
|
||||
const workflowId = resolved.workflowId
|
||||
// Resolve scope: workflow-mode (has workflowId) or workspace-mode (workspaceId only)
|
||||
let workflowId: string | undefined
|
||||
let workflowResolvedName: string | undefined
|
||||
let workspaceId: string | undefined
|
||||
const isWorkspaceMode = !providedWorkflowId && !workflowName
|
||||
|
||||
if (!isWorkspaceMode) {
|
||||
const resolved = await resolveWorkflowIdForUser(
|
||||
authenticatedUserId,
|
||||
providedWorkflowId,
|
||||
workflowName
|
||||
)
|
||||
if (!resolved) {
|
||||
return createBadRequestResponse(
|
||||
'No workflows found. Create a workflow first or provide a valid workflowId.'
|
||||
)
|
||||
}
|
||||
workflowId = resolved.workflowId
|
||||
workflowResolvedName = resolved.workflowName
|
||||
} else {
|
||||
if (!providedWorkspaceId) {
|
||||
return createBadRequestResponse('workspaceId is required when no workflowId is provided.')
|
||||
}
|
||||
workspaceId = providedWorkspaceId
|
||||
}
|
||||
|
||||
// Ensure we have a consistent user message ID for this request
|
||||
const userMessageIdToUse = userMessageId || crypto.randomUUID()
|
||||
try {
|
||||
logger.info(`[${tracker.requestId}] Received chat POST`, {
|
||||
isWorkspaceMode,
|
||||
workflowId,
|
||||
workspaceId,
|
||||
hasContexts: Array.isArray(normalizedContexts),
|
||||
contextsCount: Array.isArray(normalizedContexts) ? normalizedContexts.length : 0,
|
||||
contextsPreview: Array.isArray(normalizedContexts)
|
||||
@@ -204,7 +222,7 @@ export async function POST(req: NextRequest) {
|
||||
: undefined,
|
||||
})
|
||||
} catch {}
|
||||
// Preprocess contexts server-side
|
||||
|
||||
let agentContexts: Array<{ type: string; content: string }> = []
|
||||
if (Array.isArray(normalizedContexts) && normalizedContexts.length > 0) {
|
||||
try {
|
||||
@@ -234,7 +252,6 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
// Handle chat context
|
||||
let currentChat: any = null
|
||||
let conversationHistory: any[] = []
|
||||
let actualChatId = chatId
|
||||
@@ -245,6 +262,7 @@ export async function POST(req: NextRequest) {
|
||||
chatId,
|
||||
userId: authenticatedUserId,
|
||||
workflowId,
|
||||
workspaceId,
|
||||
model: selectedModel,
|
||||
})
|
||||
currentChat = chatResult.chat
|
||||
@@ -263,8 +281,8 @@ export async function POST(req: NextRequest) {
|
||||
const requestPayload = await buildCopilotRequestPayload(
|
||||
{
|
||||
message,
|
||||
workflowId,
|
||||
workflowName: resolved.workflowName,
|
||||
workflowId: workflowId || '',
|
||||
workflowName: workflowResolvedName,
|
||||
userId: authenticatedUserId,
|
||||
userMessageId: userMessageIdToUse,
|
||||
mode,
|
||||
@@ -284,8 +302,17 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
)
|
||||
|
||||
if (isWorkspaceMode) {
|
||||
requestPayload.source = 'workspace-chat'
|
||||
requestPayload.headless = true
|
||||
}
|
||||
if (headless !== undefined) {
|
||||
requestPayload.headless = headless
|
||||
}
|
||||
|
||||
try {
|
||||
logger.info(`[${tracker.requestId}] About to call Sim Agent`, {
|
||||
isWorkspaceMode,
|
||||
hasContext: agentContexts.length > 0,
|
||||
contextCount: agentContexts.length,
|
||||
hasConversationId: !!effectiveConversationId,
|
||||
@@ -372,9 +399,10 @@ export async function POST(req: NextRequest) {
|
||||
const result = await orchestrateCopilotStream(requestPayload, {
|
||||
userId: authenticatedUserId,
|
||||
workflowId,
|
||||
workspaceId,
|
||||
chatId: actualChatId,
|
||||
autoExecuteTools: true,
|
||||
interactive: true,
|
||||
interactive: !isWorkspaceMode,
|
||||
onEvent: async (event) => {
|
||||
await pushEvent(event)
|
||||
},
|
||||
@@ -430,9 +458,10 @@ export async function POST(req: NextRequest) {
|
||||
const nonStreamingResult = await orchestrateCopilotStream(requestPayload, {
|
||||
userId: authenticatedUserId,
|
||||
workflowId,
|
||||
workspaceId,
|
||||
chatId: actualChatId,
|
||||
autoExecuteTools: true,
|
||||
interactive: true,
|
||||
interactive: !isWorkspaceMode,
|
||||
})
|
||||
|
||||
const responseData = {
|
||||
@@ -560,16 +589,15 @@ export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(req.url)
|
||||
const workflowId = searchParams.get('workflowId')
|
||||
const workspaceId = searchParams.get('workspaceId')
|
||||
const chatId = searchParams.get('chatId')
|
||||
|
||||
// Get authenticated user using consolidated helper
|
||||
const { userId: authenticatedUserId, isAuthenticated } =
|
||||
await authenticateCopilotRequestSessionOnly()
|
||||
if (!isAuthenticated || !authenticatedUserId) {
|
||||
return createUnauthorizedResponse()
|
||||
}
|
||||
|
||||
// If chatId is provided, fetch a single chat
|
||||
if (chatId) {
|
||||
const [chat] = await db
|
||||
.select({
|
||||
@@ -606,11 +634,14 @@ export async function GET(req: NextRequest) {
|
||||
return NextResponse.json({ success: true, chat: transformedChat })
|
||||
}
|
||||
|
||||
if (!workflowId) {
|
||||
return createBadRequestResponse('workflowId or chatId is required')
|
||||
if (!workflowId && !workspaceId) {
|
||||
return createBadRequestResponse('workflowId, workspaceId, or chatId is required')
|
||||
}
|
||||
|
||||
// Fetch chats for this user and workflow
|
||||
const scopeFilter = workflowId
|
||||
? eq(copilotChats.workflowId, workflowId)
|
||||
: eq(copilotChats.workspaceId, workspaceId!)
|
||||
|
||||
const chats = await db
|
||||
.select({
|
||||
id: copilotChats.id,
|
||||
@@ -623,12 +654,9 @@ export async function GET(req: NextRequest) {
|
||||
updatedAt: copilotChats.updatedAt,
|
||||
})
|
||||
.from(copilotChats)
|
||||
.where(
|
||||
and(eq(copilotChats.userId, authenticatedUserId), eq(copilotChats.workflowId, workflowId))
|
||||
)
|
||||
.where(and(eq(copilotChats.userId, authenticatedUserId), scopeFilter))
|
||||
.orderBy(desc(copilotChats.updatedAt))
|
||||
|
||||
// Transform the data to include message count
|
||||
const transformedChats = chats.map((chat) => ({
|
||||
id: chat.id,
|
||||
title: chat.title,
|
||||
@@ -641,7 +669,8 @@ export async function GET(req: NextRequest) {
|
||||
updatedAt: chat.updatedAt,
|
||||
}))
|
||||
|
||||
logger.info(`Retrieved ${transformedChats.length} chats for workflow ${workflowId}`)
|
||||
const scope = workflowId ? `workflow ${workflowId}` : `workspace ${workspaceId}`
|
||||
logger.info(`Retrieved ${transformedChats.length} chats for ${scope}`)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
|
||||
@@ -1,130 +0,0 @@
|
||||
import { db } from '@sim/db'
|
||||
import { copilotChats } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { resolveOrCreateChat } from '@/lib/copilot/chat-lifecycle'
|
||||
import { SIM_AGENT_VERSION } from '@/lib/copilot/constants'
|
||||
import { orchestrateCopilotStream } from '@/lib/copilot/orchestrator'
|
||||
import type { SSEEvent } from '@/lib/copilot/orchestrator/types'
|
||||
// Workspace prompt is now generated by the Go copilot backend (detected via source: 'workspace-chat')
|
||||
|
||||
const logger = createLogger('WorkspaceChatAPI')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const runtime = 'nodejs'
|
||||
export const maxDuration = 300
|
||||
|
||||
const WorkspaceChatSchema = z.object({
|
||||
message: z.string().min(1, 'Message is required'),
|
||||
workspaceId: z.string().min(1, 'workspaceId is required'),
|
||||
chatId: z.string().optional(),
|
||||
model: z.string().optional().default('claude-opus-4-5'),
|
||||
})
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await req.json()
|
||||
const { message, workspaceId, chatId, model } = WorkspaceChatSchema.parse(body)
|
||||
|
||||
const chatResult = await resolveOrCreateChat({
|
||||
chatId,
|
||||
userId: session.user.id,
|
||||
workspaceId,
|
||||
model,
|
||||
})
|
||||
|
||||
const requestPayload: Record<string, unknown> = {
|
||||
message,
|
||||
userId: session.user.id,
|
||||
model,
|
||||
mode: 'agent',
|
||||
headless: true,
|
||||
messageId: crypto.randomUUID(),
|
||||
version: SIM_AGENT_VERSION,
|
||||
source: 'workspace-chat',
|
||||
stream: true,
|
||||
...(chatResult.chatId ? { chatId: chatResult.chatId } : {}),
|
||||
}
|
||||
|
||||
const encoder = new TextEncoder()
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
const pushEvent = (event: Record<string, unknown>) => {
|
||||
try {
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`))
|
||||
} catch {
|
||||
// Client disconnected
|
||||
}
|
||||
}
|
||||
|
||||
if (chatResult.chatId) {
|
||||
pushEvent({ type: 'chat_id', chatId: chatResult.chatId })
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await orchestrateCopilotStream(requestPayload, {
|
||||
userId: session.user.id,
|
||||
workspaceId,
|
||||
chatId: chatResult.chatId || undefined,
|
||||
autoExecuteTools: true,
|
||||
interactive: false,
|
||||
onEvent: async (event: SSEEvent) => {
|
||||
pushEvent(event as unknown as Record<string, unknown>)
|
||||
},
|
||||
})
|
||||
|
||||
if (chatResult.chatId && result.conversationId) {
|
||||
await db
|
||||
.update(copilotChats)
|
||||
.set({
|
||||
updatedAt: new Date(),
|
||||
conversationId: result.conversationId,
|
||||
})
|
||||
.where(eq(copilotChats.id, chatResult.chatId))
|
||||
}
|
||||
|
||||
pushEvent({
|
||||
type: 'done',
|
||||
success: result.success,
|
||||
content: result.content,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Workspace chat orchestration failed', { error })
|
||||
pushEvent({
|
||||
type: 'error',
|
||||
error: error instanceof Error ? error.message : 'Chat failed',
|
||||
})
|
||||
} finally {
|
||||
controller.close()
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
'X-Accel-Buffering': 'no',
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid request', details: error.errors },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
logger.error('Workspace chat error', { error })
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -6,13 +6,11 @@ import { useParams } from 'next/navigation'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import { Button } from '@/components/emcn'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import type { ContentBlock, ToolCallInfo, ToolCallStatus } from './hooks/use-workspace-chat'
|
||||
import { useWorkspaceChat } from './hooks/use-workspace-chat'
|
||||
|
||||
const REMARK_PLUGINS = [remarkGfm]
|
||||
|
||||
/** Status icon for a tool call. */
|
||||
function ToolStatusIcon({ status }: { status: ToolCallStatus }) {
|
||||
switch (status) {
|
||||
case 'executing':
|
||||
@@ -24,7 +22,6 @@ function ToolStatusIcon({ status }: { status: ToolCallStatus }) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Formats a tool name for display: "edit_workflow" → "Edit Workflow". */
|
||||
function formatToolName(name: string): string {
|
||||
return name
|
||||
.replace(/_v\d+$/, '')
|
||||
@@ -33,7 +30,6 @@ function formatToolName(name: string): string {
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
/** Compact inline rendering of a single tool call. */
|
||||
function ToolCallItem({ toolCall }: { toolCall: ToolCallInfo }) {
|
||||
const label = toolCall.displayTitle || formatToolName(toolCall.name)
|
||||
|
||||
@@ -46,7 +42,6 @@ function ToolCallItem({ toolCall }: { toolCall: ToolCallInfo }) {
|
||||
)
|
||||
}
|
||||
|
||||
/** Renders a subagent activity label. */
|
||||
function SubagentLabel({ label }: { label: string }) {
|
||||
return (
|
||||
<div className='flex items-center gap-2 py-0.5'>
|
||||
@@ -56,7 +51,6 @@ function SubagentLabel({ label }: { label: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
/** Renders structured content blocks for an assistant message. */
|
||||
function AssistantContent({ blocks, isStreaming }: { blocks: ContentBlock[]; isStreaming: boolean }) {
|
||||
return (
|
||||
<div className='space-y-2'>
|
||||
@@ -76,10 +70,8 @@ function AssistantContent({ blocks, isStreaming }: { blocks: ContentBlock[]; isS
|
||||
}
|
||||
case 'subagent': {
|
||||
if (!block.content) return null
|
||||
// Only show the subagent label if it's the last subagent block and we're streaming
|
||||
const isLastSubagent =
|
||||
isStreaming &&
|
||||
blocks.slice(i + 1).every((b) => b.type !== 'subagent')
|
||||
isStreaming && blocks.slice(i + 1).every((b) => b.type !== 'subagent')
|
||||
if (!isLastSubagent) return null
|
||||
return <SubagentLabel key={`sub-${i}`} label={block.content} />
|
||||
}
|
||||
@@ -126,12 +118,10 @@ export function Chat() {
|
||||
|
||||
return (
|
||||
<div className='flex h-full flex-col'>
|
||||
{/* Header */}
|
||||
<div className='flex flex-shrink-0 items-center border-b border-[var(--border)] px-6 py-3'>
|
||||
<h1 className='font-medium text-[16px] text-[var(--text-primary)]'>Mothership</h1>
|
||||
</div>
|
||||
|
||||
{/* Messages area */}
|
||||
<div className='flex-1 overflow-y-auto px-6 py-4'>
|
||||
{messages.length === 0 && !isSending ? (
|
||||
<div className='flex h-full items-center justify-center'>
|
||||
@@ -160,7 +150,6 @@ export function Chat() {
|
||||
)
|
||||
}
|
||||
|
||||
// Skip empty assistant messages
|
||||
if (
|
||||
msg.role === 'assistant' &&
|
||||
!msg.content &&
|
||||
@@ -168,7 +157,6 @@ export function Chat() {
|
||||
)
|
||||
return null
|
||||
|
||||
// User messages
|
||||
if (msg.role === 'user') {
|
||||
return (
|
||||
<div key={msg.id} className='flex justify-end'>
|
||||
@@ -179,7 +167,6 @@ export function Chat() {
|
||||
)
|
||||
}
|
||||
|
||||
// Assistant messages with content blocks
|
||||
const hasBlocks = msg.contentBlocks && msg.contentBlocks.length > 0
|
||||
const isThisMessageStreaming = isSending && msg === messages[messages.length - 1]
|
||||
|
||||
@@ -207,14 +194,12 @@ export function Chat() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Error display */}
|
||||
{error && (
|
||||
<div className='px-6 pb-2'>
|
||||
<p className='text-xs text-red-500'>{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Input area */}
|
||||
<div className='flex-shrink-0 border-t border-[var(--border)] px-6 py-4'>
|
||||
<div className='mx-auto flex max-w-3xl items-end gap-2'>
|
||||
<textarea
|
||||
|
||||
@@ -2,29 +2,24 @@
|
||||
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { COPILOT_CHAT_API_PATH } from '@/lib/copilot/constants'
|
||||
|
||||
const logger = createLogger('useWorkspaceChat')
|
||||
|
||||
/** Status of a tool call as it progresses through execution. */
|
||||
export type ToolCallStatus = 'executing' | 'success' | 'error'
|
||||
|
||||
/** Lightweight info about a single tool call rendered in the chat. */
|
||||
export interface ToolCallInfo {
|
||||
id: string
|
||||
name: string
|
||||
status: ToolCallStatus
|
||||
/** Human-readable title from the backend ToolUI metadata. */
|
||||
displayTitle?: string
|
||||
}
|
||||
|
||||
/** A content block inside an assistant message. */
|
||||
export type ContentBlockType = 'text' | 'tool_call' | 'subagent'
|
||||
|
||||
export interface ContentBlock {
|
||||
type: ContentBlockType
|
||||
/** Text content (for 'text' and 'subagent' blocks). */
|
||||
content?: string
|
||||
/** Tool call info (for 'tool_call' blocks). */
|
||||
toolCall?: ToolCallInfo
|
||||
}
|
||||
|
||||
@@ -33,9 +28,7 @@ export interface ChatMessage {
|
||||
role: 'user' | 'assistant'
|
||||
content: string
|
||||
timestamp: string
|
||||
/** Structured content blocks for rich rendering. When present, prefer over `content`. */
|
||||
contentBlocks?: ContentBlock[]
|
||||
/** Name of the currently active subagent (shown as a label while streaming). */
|
||||
activeSubagent?: string | null
|
||||
}
|
||||
|
||||
@@ -52,13 +45,13 @@ interface UseWorkspaceChatReturn {
|
||||
clearMessages: () => void
|
||||
}
|
||||
|
||||
/** Maps subagent IDs to human-readable labels. */
|
||||
const SUBAGENT_LABELS: Record<string, string> = {
|
||||
build: 'Building',
|
||||
deploy: 'Deploying',
|
||||
auth: 'Connecting credentials',
|
||||
research: 'Researching',
|
||||
knowledge: 'Managing knowledge base',
|
||||
table: 'Managing tables',
|
||||
custom_tool: 'Creating tool',
|
||||
superagent: 'Executing action',
|
||||
plan: 'Planning',
|
||||
@@ -101,12 +94,9 @@ export function useWorkspaceChat({ workspaceId }: UseWorkspaceChatProps): UseWor
|
||||
const abortController = new AbortController()
|
||||
abortControllerRef.current = abortController
|
||||
|
||||
// Mutable refs for the streaming context so we can build content blocks
|
||||
// without relying on stale React state closures.
|
||||
const blocksRef: ContentBlock[] = []
|
||||
const toolCallMapRef = new Map<string, number>() // toolCallId → index in blocksRef
|
||||
const toolCallMapRef = new Map<string, number>()
|
||||
|
||||
/** Ensure the last block is a text block and return it. */
|
||||
const ensureTextBlock = (): ContentBlock => {
|
||||
const last = blocksRef[blocksRef.length - 1]
|
||||
if (last && last.type === 'text') return last
|
||||
@@ -115,7 +105,6 @@ export function useWorkspaceChat({ workspaceId }: UseWorkspaceChatProps): UseWor
|
||||
return newBlock
|
||||
}
|
||||
|
||||
/** Push updated blocks + content into the assistant message. */
|
||||
const flushBlocks = (extra?: Partial<ChatMessage>) => {
|
||||
const fullText = blocksRef
|
||||
.filter((b) => b.type === 'text')
|
||||
@@ -136,12 +125,14 @@ export function useWorkspaceChat({ workspaceId }: UseWorkspaceChatProps): UseWor
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/copilot/workspace-chat', {
|
||||
const response = await fetch(COPILOT_CHAT_API_PATH, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
message,
|
||||
workspaceId,
|
||||
stream: true,
|
||||
createNewChat: !chatIdRef.current,
|
||||
...(chatIdRef.current ? { chatId: chatIdRef.current } : {}),
|
||||
}),
|
||||
signal: abortController.signal,
|
||||
@@ -203,6 +194,9 @@ export function useWorkspaceChat({ workspaceId }: UseWorkspaceChatProps): UseWor
|
||||
if (!toolCallId) break
|
||||
|
||||
const ui = event.ui || event.data?.ui
|
||||
const hidden = ui?.hidden
|
||||
if (hidden) break
|
||||
|
||||
const displayTitle = ui?.title || ui?.phaseLabel
|
||||
|
||||
if (!toolCallMapRef.has(toolCallId)) {
|
||||
|
||||
341
apps/sim/lib/copilot/SSE_CLIENT_GUIDE.md
Normal file
341
apps/sim/lib/copilot/SSE_CLIENT_GUIDE.md
Normal file
@@ -0,0 +1,341 @@
|
||||
# Copilot SSE Client Integration Guide
|
||||
|
||||
How to consume the copilot SSE stream from any client UI.
|
||||
|
||||
## Endpoint
|
||||
|
||||
```
|
||||
POST /api/copilot/chat
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
### Request body
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|---------------|----------|----------|-------------|
|
||||
| `message` | string | yes | User message |
|
||||
| `workspaceId` | string | yes* | Workspace scope (required when no `workflowId`) |
|
||||
| `workflowId` | string | no | Workflow scope — when set, copilot operates on this workflow |
|
||||
| `chatId` | string | no | Continue an existing conversation |
|
||||
| `createNewChat` | boolean | no | Create a new persisted chat session |
|
||||
| `stream` | boolean | no | Default `true`. Set to get SSE stream |
|
||||
| `model` | string | no | Model ID (default: `claude-opus-4-5`) |
|
||||
| `mode` | string | no | `agent` / `ask` / `plan` |
|
||||
| `headless` | boolean | no | Skip interactive confirmation for all tools |
|
||||
|
||||
*Either `workflowId` or `workspaceId` must be provided. When only `workspaceId` is sent, the copilot runs in workspace mode (no workflow context).
|
||||
|
||||
### Response
|
||||
|
||||
`Content-Type: text/event-stream` — each line is `data: <JSON>\n\n`.
|
||||
|
||||
---
|
||||
|
||||
## SSE Event Types
|
||||
|
||||
Every event has a `type` field. The `state` field (when present) is the authoritative tool call lifecycle state set by the Go backend — clients should use it directly without deriving state from other fields.
|
||||
|
||||
### Session events
|
||||
|
||||
#### `chat_id`
|
||||
Emitted once at the start. Store this to continue the conversation.
|
||||
```json
|
||||
{ "type": "chat_id", "chatId": "uuid" }
|
||||
```
|
||||
|
||||
#### `title_updated`
|
||||
Chat title was generated asynchronously.
|
||||
```json
|
||||
{ "type": "title_updated", "title": "My chat title" }
|
||||
```
|
||||
|
||||
### Content events
|
||||
|
||||
#### `content`
|
||||
Streamed text chunks from the assistant. Append to the current text block.
|
||||
```json
|
||||
{ "type": "content", "data": "Hello, " }
|
||||
```
|
||||
May also appear as `{ "type": "content", "content": "Hello, " }`. Check `data` first, fall back to `content`.
|
||||
|
||||
#### `reasoning`
|
||||
Model thinking/reasoning content (if the model supports it). Render in a collapsible "thinking" block.
|
||||
```json
|
||||
{ "type": "reasoning", "content": "Let me think about...", "phase": "thinking" }
|
||||
```
|
||||
|
||||
### Tool call lifecycle
|
||||
|
||||
Tools follow this lifecycle: `generating → pending|executing → success|error|rejected`.
|
||||
|
||||
The `state` field on each event tells you exactly what to render.
|
||||
|
||||
#### `tool_generating`
|
||||
The model is streaming the tool call arguments. Create a placeholder block.
|
||||
```json
|
||||
{
|
||||
"type": "tool_generating",
|
||||
"state": "generating",
|
||||
"toolCallId": "toolu_abc123",
|
||||
"toolName": "edit_workflow",
|
||||
"ui": {
|
||||
"title": "Editing workflow",
|
||||
"icon": "pencil",
|
||||
"phaseLabel": "Build"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### `tool_call`
|
||||
Arguments are finalized. The `state` tells you what to render:
|
||||
- `"pending"` — user approval required. Show Allow/Skip buttons.
|
||||
- `"executing"` — tool is running. Show spinner.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "tool_call",
|
||||
"state": "pending",
|
||||
"toolCallId": "toolu_abc123",
|
||||
"toolName": "deploy_api",
|
||||
"data": { "id": "toolu_abc123", "name": "deploy_api", "arguments": { ... } },
|
||||
"ui": {
|
||||
"title": "Deploying API",
|
||||
"icon": "rocket",
|
||||
"requiresConfirmation": true,
|
||||
"clientExecutable": false,
|
||||
"hidden": false,
|
||||
"internal": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Partial tool calls** (argument streaming): `tool_call` events with `data.partial: true` have no `state` field. Keep the current state, just update arguments for display.
|
||||
|
||||
#### `tool_result`
|
||||
Tool execution completed.
|
||||
```json
|
||||
{
|
||||
"type": "tool_result",
|
||||
"state": "success",
|
||||
"toolCallId": "toolu_abc123",
|
||||
"success": true,
|
||||
"result": { ... }
|
||||
}
|
||||
```
|
||||
`state` will be `"success"`, `"error"`, or `"rejected"`.
|
||||
|
||||
#### `tool_error`
|
||||
Tool execution failed (error on the Sim server side, not from Go).
|
||||
```json
|
||||
{
|
||||
"type": "tool_error",
|
||||
"state": "error",
|
||||
"toolCallId": "toolu_abc123",
|
||||
"error": "Connection timeout"
|
||||
}
|
||||
```
|
||||
|
||||
### Subagent events
|
||||
|
||||
Subagents are specialized agents (build, deploy, auth, research, knowledge, table, etc.) that handle complex tasks. Their events are scoped by a parent tool call.
|
||||
|
||||
#### `subagent_start`
|
||||
A subagent session started. All subsequent events with `"subagent": "<name>"` belong to this session.
|
||||
```json
|
||||
{
|
||||
"type": "subagent_start",
|
||||
"subagent": "build",
|
||||
"data": { "tool_call_id": "toolu_parent123" }
|
||||
}
|
||||
```
|
||||
Render a label like "Building..." under the parent tool call.
|
||||
|
||||
#### `subagent_end`
|
||||
Subagent session completed.
|
||||
```json
|
||||
{ "type": "subagent_end", "subagent": "build" }
|
||||
```
|
||||
|
||||
#### Nested events
|
||||
While a subagent is active, you'll receive `content`, `tool_generating`, `tool_call`, `tool_result`, etc. with `"subagent": "build"` on them. These are the subagent's own tool calls and text, nested under the parent.
|
||||
|
||||
### Terminal events
|
||||
|
||||
#### `done`
|
||||
Stream completed. May include final content.
|
||||
```json
|
||||
{ "type": "done", "success": true, "content": "..." }
|
||||
```
|
||||
|
||||
#### `error`
|
||||
Fatal stream error.
|
||||
```json
|
||||
{ "type": "error", "error": "An unexpected error occurred" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## UI Metadata (`ui` field)
|
||||
|
||||
Present on `tool_generating` and `tool_call` events. Use for rendering:
|
||||
|
||||
| Field | Type | Description |
|
||||
|------------------------|---------|-------------|
|
||||
| `title` | string | Human-readable title (e.g., "Editing workflow") |
|
||||
| `phaseLabel` | string | Category label (e.g., "Build", "Deploy") |
|
||||
| `icon` | string | Icon identifier |
|
||||
| `requiresConfirmation` | boolean | If `true`, show approval UI (Allow/Skip) |
|
||||
| `clientExecutable` | boolean | If `true`, tool should execute on the client (e.g., `run_workflow`) |
|
||||
| `internal` | boolean | If `true`, this is an internal tool (subagent trigger). Skip rendering |
|
||||
| `hidden` | boolean | If `true`, don't render this tool call at all |
|
||||
|
||||
---
|
||||
|
||||
## Tool Call State Machine
|
||||
|
||||
```
|
||||
tool_generating → state: "generating" → Show placeholder with spinner
|
||||
tool_call → state: "pending" → Show Allow/Skip buttons
|
||||
tool_call → state: "executing" → Show spinner
|
||||
tool_result → state: "success" → Show checkmark
|
||||
tool_result → state: "error" → Show error icon
|
||||
tool_result → state: "rejected" → Show skipped/rejected
|
||||
tool_error → state: "error" → Show error icon
|
||||
```
|
||||
|
||||
Client-only states (not from SSE, managed locally):
|
||||
- `background` — tool running in background (client UX decision)
|
||||
- `aborted` — user aborted the stream
|
||||
- `review` — client wants user to review result
|
||||
|
||||
---
|
||||
|
||||
## Handling User Confirmation
|
||||
|
||||
When a tool arrives with `state: "pending"`:
|
||||
|
||||
1. Render Allow/Skip buttons
|
||||
2. On Allow: `POST /api/copilot/confirm` with `{ toolCallId, status: "accepted" }`
|
||||
3. On Skip: `POST /api/copilot/confirm` with `{ toolCallId, status: "rejected" }`
|
||||
4. Optimistically update to `executing` / `rejected`
|
||||
5. The next SSE event (`tool_result`) will confirm the final state
|
||||
|
||||
For `clientExecutable` tools (e.g., `run_workflow`): after accepting, the client must execute the tool locally and report the result via `POST /api/copilot/confirm` with `{ toolCallId, status: "success"|"error", data: { ... } }`.
|
||||
|
||||
---
|
||||
|
||||
## Identifying Tool Categories
|
||||
|
||||
Use the `toolName` and `ui` metadata to determine what the tool does. Common patterns:
|
||||
|
||||
| Tool name pattern | Category | What to render |
|
||||
|--------------------------|-------------------|----------------|
|
||||
| `edit_workflow` | Workflow editing | Diff preview, block changes |
|
||||
| `deploy_*`, `redeploy` | Deployment | Deploy status |
|
||||
| `user_table` | Table management | Table creation/query results |
|
||||
| `knowledge_base` | Knowledge bases | KB operations |
|
||||
| `run_workflow`, `run_block` | Execution | Execution results (client-executable) |
|
||||
| `read`, `glob`, `grep` | VFS | File browser (often `hidden`) |
|
||||
| `search_documentation` | Research | Doc search results |
|
||||
| `navigate_ui` | Navigation | UI navigation command |
|
||||
|
||||
### Structured results
|
||||
|
||||
The `structured_result` event carries rich data that tools return. The `subagent_result` event similarly carries subagent completion data. Parse `result` / `data` to render tables, KB entries, deployment URLs, etc.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "structured_result",
|
||||
"data": {
|
||||
"action": "table_created",
|
||||
"tables": [{ "id": "tbl_...", "name": "tasks" }],
|
||||
"success": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Minimal Client Example
|
||||
|
||||
```typescript
|
||||
const response = await fetch('/api/copilot/chat', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
message: 'Create a tasks table',
|
||||
workspaceId: 'ws_123',
|
||||
stream: true,
|
||||
createNewChat: true,
|
||||
}),
|
||||
})
|
||||
|
||||
const reader = response.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
const lines = buffer.split('\n')
|
||||
buffer = lines.pop() || ''
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith('data: ')) continue
|
||||
const event = JSON.parse(line.slice(6))
|
||||
|
||||
switch (event.type) {
|
||||
case 'chat_id':
|
||||
// Store event.chatId for follow-up messages
|
||||
break
|
||||
case 'content':
|
||||
// Append event.data || event.content to text
|
||||
break
|
||||
case 'tool_generating':
|
||||
case 'tool_call':
|
||||
if (event.ui?.hidden) break
|
||||
// Create/update tool block using event.state
|
||||
// If event.state === 'pending', show approval buttons
|
||||
break
|
||||
case 'tool_result':
|
||||
case 'tool_error':
|
||||
// Update tool block with event.state
|
||||
break
|
||||
case 'subagent_start':
|
||||
// Show subagent activity label
|
||||
break
|
||||
case 'subagent_end':
|
||||
// Clear subagent label
|
||||
break
|
||||
case 'done':
|
||||
// Stream complete
|
||||
break
|
||||
case 'error':
|
||||
// Show error
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Subagent Labels
|
||||
|
||||
Map subagent IDs to display labels:
|
||||
|
||||
| Subagent ID | Display label |
|
||||
|----------------|---------------|
|
||||
| `build` | Building |
|
||||
| `deploy` | Deploying |
|
||||
| `auth` | Connecting credentials |
|
||||
| `research` | Researching |
|
||||
| `knowledge` | Managing knowledge base |
|
||||
| `table` | Managing tables |
|
||||
| `custom_tool` | Creating tool |
|
||||
| `superagent` | Executing action |
|
||||
| `plan` | Planning |
|
||||
| `debug` | Debugging |
|
||||
| `edit` | Editing workflow |
|
||||
@@ -64,9 +64,10 @@ export interface MessageFileAttachment {
|
||||
*/
|
||||
export interface SendMessageRequest {
|
||||
message: string
|
||||
userMessageId?: string // ID from frontend for the user message
|
||||
userMessageId?: string
|
||||
chatId?: string
|
||||
workflowId?: string
|
||||
workspaceId?: string
|
||||
mode?: CopilotMode | CopilotTransportMode
|
||||
model?: CopilotModelId
|
||||
provider?: string
|
||||
|
||||
@@ -9,7 +9,7 @@ const logger = createLogger('CopilotChatPayload')
|
||||
|
||||
export interface BuildPayloadParams {
|
||||
message: string
|
||||
workflowId: string
|
||||
workflowId?: string
|
||||
workflowName?: string
|
||||
userId: string
|
||||
userMessageId: string
|
||||
@@ -84,10 +84,11 @@ export async function buildCopilotRequestPayload(
|
||||
let credentials: CredentialsPayload | null = null
|
||||
|
||||
if (effectiveMode === 'build') {
|
||||
// function_execute sandbox tool is now defined in Go — no need to send it
|
||||
|
||||
try {
|
||||
const rawCredentials = await getCredentialsServerTool.execute({ workflowId }, { userId })
|
||||
const rawCredentials = await getCredentialsServerTool.execute(
|
||||
workflowId ? { workflowId } : {},
|
||||
{ userId }
|
||||
)
|
||||
|
||||
const oauthMap: CredentialsPayload['oauth'] = {}
|
||||
const connectedOAuth: Array<{ provider: string; name: string; scopes?: string[] }> = []
|
||||
@@ -152,7 +153,7 @@ export async function buildCopilotRequestPayload(
|
||||
|
||||
return {
|
||||
message,
|
||||
workflowId,
|
||||
...(workflowId ? { workflowId } : {}),
|
||||
...(params.workflowName ? { workflowName: params.workflowName } : {}),
|
||||
userId,
|
||||
model: selectedModel,
|
||||
|
||||
Reference in New Issue
Block a user