Consolidation

This commit is contained in:
Siddharth Ganesan
2026-02-24 14:55:35 -08:00
parent 724aaa1432
commit 87f5c464d9
7 changed files with 419 additions and 198 deletions

View File

@@ -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,

View File

@@ -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 })
}
}

View File

@@ -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

View File

@@ -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)) {

View 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 |

View File

@@ -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

View File

@@ -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,