This commit is contained in:
Siddharth Ganesan
2026-02-17 15:28:23 -08:00
parent 8ebe753bd8
commit b197f68828
13 changed files with 12121 additions and 13 deletions

View File

@@ -0,0 +1,131 @@
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'
import { getWorkspaceChatSystemPrompt } from '@/lib/copilot/workspace-prompt'
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,
systemPrompt: getWorkspaceChatSystemPrompt(),
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

@@ -0,0 +1,150 @@
'use client'
import { useCallback, useRef, useState } from 'react'
import { Send, Square } from 'lucide-react'
import { useParams } from 'next/navigation'
import { Button } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import { useWorkspaceChat } from './hooks/use-workspace-chat'
export function Chat() {
const { workspaceId } = useParams<{ workspaceId: string }>()
const [inputValue, setInputValue] = useState('')
const inputRef = useRef<HTMLTextAreaElement>(null)
const messagesEndRef = useRef<HTMLDivElement>(null)
const { messages, isSending, error, sendMessage, abortMessage } = useWorkspaceChat({
workspaceId,
})
const scrollToBottom = useCallback(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [])
const handleSubmit = useCallback(async () => {
const trimmed = inputValue.trim()
if (!trimmed || !workspaceId) return
setInputValue('')
await sendMessage(trimmed)
scrollToBottom()
}, [inputValue, workspaceId, sendMessage, scrollToBottom])
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSubmit()
}
},
[handleSubmit]
)
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)]'>Chat</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'>
<div className='flex flex-col items-center gap-3 text-center'>
<p className='text-[var(--text-secondary)] text-sm'>
Ask anything about your workspace build workflows, manage resources, get help.
</p>
</div>
</div>
) : (
<div className='mx-auto max-w-3xl space-y-4'>
{messages.map((msg) => {
const isStreamingEmpty =
isSending && msg.role === 'assistant' && !msg.content
if (isStreamingEmpty) {
return (
<div key={msg.id} className='flex justify-start'>
<div className='rounded-lg bg-[var(--surface-3)] px-4 py-2 text-sm text-[var(--text-secondary)]'>
Thinking...
</div>
</div>
)
}
if (msg.role === 'assistant' && !msg.content) return null
return (
<div
key={msg.id}
className={cn(
'flex',
msg.role === 'user' ? 'justify-end' : 'justify-start'
)}
>
<div
className={cn(
'max-w-[85%] rounded-lg px-4 py-2 text-sm',
msg.role === 'user'
? 'bg-[var(--accent)] text-[var(--accent-foreground)]'
: 'bg-[var(--surface-3)] text-[var(--text-primary)]'
)}
>
<p className='whitespace-pre-wrap'>{msg.content}</p>
</div>
</div>
)
})}
<div ref={messagesEndRef} />
</div>
)}
</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
ref={inputRef}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder='Send a message...'
rows={1}
className='flex-1 resize-none rounded-lg border border-[var(--border)] bg-[var(--surface-2)] px-4 py-2.5 text-sm text-[var(--text-primary)] placeholder:text-[var(--text-tertiary)] focus:border-[var(--accent)] focus:outline-none'
style={{ maxHeight: '120px' }}
onInput={(e) => {
const target = e.target as HTMLTextAreaElement
target.style.height = 'auto'
target.style.height = `${Math.min(target.scrollHeight, 120)}px`
}}
/>
{isSending ? (
<Button
variant='ghost'
size='sm'
onClick={abortMessage}
className='h-[38px] w-[38px] flex-shrink-0 p-0'
>
<Square className='h-4 w-4' />
</Button>
) : (
<Button
variant='ghost'
size='sm'
onClick={handleSubmit}
disabled={!inputValue.trim()}
className='h-[38px] w-[38px] flex-shrink-0 p-0'
>
<Send className='h-4 w-4' />
</Button>
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,173 @@
'use client'
import { useCallback, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
const logger = createLogger('useWorkspaceChat')
interface ChatMessage {
id: string
role: 'user' | 'assistant'
content: string
timestamp: string
}
interface UseWorkspaceChatProps {
workspaceId: string
}
interface UseWorkspaceChatReturn {
messages: ChatMessage[]
isSending: boolean
error: string | null
sendMessage: (message: string) => Promise<void>
abortMessage: () => void
clearMessages: () => void
}
export function useWorkspaceChat({ workspaceId }: UseWorkspaceChatProps): UseWorkspaceChatReturn {
const [messages, setMessages] = useState<ChatMessage[]>([])
const [isSending, setIsSending] = useState(false)
const [error, setError] = useState<string | null>(null)
const abortControllerRef = useRef<AbortController | null>(null)
const chatIdRef = useRef<string | undefined>(undefined)
const sendMessage = useCallback(
async (message: string) => {
if (!message.trim() || !workspaceId) return
setError(null)
setIsSending(true)
const userMessage: ChatMessage = {
id: crypto.randomUUID(),
role: 'user',
content: message,
timestamp: new Date().toISOString(),
}
const assistantMessage: ChatMessage = {
id: crypto.randomUUID(),
role: 'assistant',
content: '',
timestamp: new Date().toISOString(),
}
setMessages((prev) => [...prev, userMessage, assistantMessage])
const abortController = new AbortController()
abortControllerRef.current = abortController
try {
const response = await fetch('/api/copilot/workspace-chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
message,
workspaceId,
...(chatIdRef.current ? { chatId: chatIdRef.current } : {}),
}),
signal: abortController.signal,
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
throw new Error(errorData.error || `Request failed: ${response.status}`)
}
if (!response.body) {
throw new Error('No response body')
}
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
try {
const event = JSON.parse(line.slice(6))
if (event.type === 'chat_id' && event.chatId) {
chatIdRef.current = event.chatId
} else if (event.type === 'content' && event.content) {
setMessages((prev) =>
prev.map((msg) =>
msg.id === assistantMessage.id
? { ...msg, content: msg.content + event.content }
: msg
)
)
} else if (event.type === 'error') {
setError(event.error || 'An error occurred')
} else if (event.type === 'done') {
if (event.content && typeof event.content === 'string') {
setMessages((prev) =>
prev.map((msg) =>
msg.id === assistantMessage.id && !msg.content
? { ...msg, content: event.content }
: msg
)
)
}
}
} catch {
// Skip malformed SSE lines
}
}
}
} catch (err) {
if (err instanceof Error && err.name === 'AbortError') {
logger.info('Message aborted by user')
return
}
const errorMessage = err instanceof Error ? err.message : 'Failed to send message'
logger.error('Failed to send workspace chat message', { error: errorMessage })
setError(errorMessage)
setMessages((prev) =>
prev.map((msg) =>
msg.id === assistantMessage.id && !msg.content
? { ...msg, content: 'Sorry, something went wrong. Please try again.' }
: msg
)
)
} finally {
setIsSending(false)
abortControllerRef.current = null
}
},
[workspaceId]
)
const abortMessage = useCallback(() => {
abortControllerRef.current?.abort()
setIsSending(false)
}, [])
const clearMessages = useCallback(() => {
setMessages([])
setError(null)
chatIdRef.current = undefined
}, [])
return {
messages,
isSending,
error,
sendMessage,
abortMessage,
clearMessages,
}
}

View File

@@ -0,0 +1,7 @@
export default function ChatLayout({ children }: { children: React.ReactNode }) {
return (
<div className='flex h-full flex-1 flex-col overflow-hidden pl-[var(--sidebar-width)]'>
{children}
</div>
)
}

View File

@@ -0,0 +1,26 @@
import { redirect } from 'next/navigation'
import { getSession } from '@/lib/auth'
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
import { Chat } from './chat'
interface ChatPageProps {
params: Promise<{
workspaceId: string
}>
}
export default async function ChatPage({ params }: ChatPageProps) {
const { workspaceId } = await params
const session = await getSession()
if (!session?.user?.id) {
redirect('/')
}
const hasPermission = await verifyWorkspaceMembership(session.user.id, workspaceId)
if (!hasPermission) {
redirect('/')
}
return <Chat />
}

View File

@@ -2,7 +2,7 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { Database, HelpCircle, Layout, Plus, Search, Settings } from 'lucide-react'
import { Database, HelpCircle, Layout, MessageSquare, Plus, Search, Settings } from 'lucide-react'
import Link from 'next/link'
import { useParams, usePathname, useRouter } from 'next/navigation'
import { Button, Download, FolderPlus, Library, Loader, Tooltip } from '@/components/emcn'
@@ -248,6 +248,12 @@ export const Sidebar = memo(function Sidebar() {
const footerNavigationItems = useMemo(
() =>
[
{
id: 'chat',
label: 'Chat',
icon: MessageSquare,
href: `/workspace/${workspaceId}/chat`,
},
{
id: 'logs',
label: 'Logs',

View File

@@ -15,14 +15,16 @@ export interface ChatLoadResult {
/**
* Resolve or create a copilot chat session.
* If chatId is provided, loads the existing chat. Otherwise creates a new one.
* Supports both workflow-scoped and workspace-scoped chats.
*/
export async function resolveOrCreateChat(params: {
chatId?: string
userId: string
workflowId: string
workflowId?: string
workspaceId?: string
model: string
}): Promise<ChatLoadResult> {
const { chatId, userId, workflowId, model } = params
const { chatId, userId, workflowId, workspaceId, model } = params
if (chatId) {
const [chat] = await db
@@ -43,7 +45,8 @@ export async function resolveOrCreateChat(params: {
.insert(copilotChats)
.values({
userId,
workflowId,
...(workflowId ? { workflowId } : {}),
...(workspaceId ? { workspaceId } : {}),
title: null,
model,
messages: [],
@@ -51,7 +54,7 @@ export async function resolveOrCreateChat(params: {
.returning()
if (!newChat) {
logger.warn('Failed to create new copilot chat row', { userId, workflowId })
logger.warn('Failed to create new copilot chat row', { userId, workflowId, workspaceId })
return {
chatId: '',
chat: null,

View File

@@ -1,15 +1,21 @@
import { createLogger } from '@sim/logger'
import { SIM_AGENT_API_URL } from '@/lib/copilot/constants'
import { prepareExecutionContext } from '@/lib/copilot/orchestrator/tool-executor'
import type { OrchestratorOptions, OrchestratorResult } from '@/lib/copilot/orchestrator/types'
import type {
ExecutionContext,
OrchestratorOptions,
OrchestratorResult,
} from '@/lib/copilot/orchestrator/types'
import { env } from '@/lib/core/config/env'
import { getEffectiveDecryptedEnv } from '@/lib/environment/utils'
import { buildToolCallSummaries, createStreamingContext, runStreamLoop } from './stream-core'
const logger = createLogger('CopilotOrchestrator')
export interface OrchestrateStreamOptions extends OrchestratorOptions {
userId: string
workflowId: string
workflowId?: string
workspaceId?: string
chatId?: string
}
@@ -17,8 +23,20 @@ export async function orchestrateCopilotStream(
requestPayload: Record<string, unknown>,
options: OrchestrateStreamOptions
): Promise<OrchestratorResult> {
const { userId, workflowId, chatId } = options
const execContext = await prepareExecutionContext(userId, workflowId)
const { userId, workflowId, workspaceId, chatId } = options
let execContext: ExecutionContext
if (workflowId) {
execContext = await prepareExecutionContext(userId, workflowId)
} else {
const decryptedEnvVars = await getEffectiveDecryptedEnv(userId, workspaceId)
execContext = {
userId,
workflowId: '',
workspaceId,
decryptedEnvVars,
}
}
const payloadMsgId = requestPayload?.messageId
const context = createStreamingContext({

View File

@@ -0,0 +1,66 @@
/**
* System prompt for workspace-level chat.
*
* Sent as `systemPrompt` in the Go request payload, which overrides the
* default agent prompt (see copilot/internal/chat/service.go:300-303).
*
* Only references subagents available in agent mode (build and discovery
* are excluded from agent mode tools in the Go backend).
*/
export function getWorkspaceChatSystemPrompt(): string {
const currentDate = new Date().toISOString().split('T')[0]
return `# Sim Workspace Assistant
Current Date: ${currentDate}
You are the Sim workspace assistant — a helpful AI that manages an entire workspace of workflows. The user is chatting from the workspace level, not from within a specific workflow.
## Your Role
You help users with their workspace: answering questions, building and debugging workflows, managing integrations, and providing guidance. You delegate complex tasks to specialized subagents.
## Platform Knowledge
Sim is a workflow automation platform. Workflows are visual pipelines of blocks (Agent, Function, Condition, Router, API, etc.). Workflows can be triggered manually, via API, webhooks, or schedules. They can be deployed as APIs, Chat UIs, or MCP tools.
## Subagents
You have access to these specialized subagents. Call them by name to delegate tasks:
| Subagent | Purpose | When to Use |
|----------|---------|-------------|
| **plan** | Gather info, create execution plans | Building new workflows, planning fixes |
| **edit** | Execute plans, make workflow changes | ONLY after plan returns steps |
| **debug** | Investigate errors, provide diagnosis | User reports something broken |
| **test** | Run workflow, verify results | After edits to validate |
| **deploy** | Deploy/undeploy workflows | Publish as API, Chat, or MCP |
| **workflow** | Env vars, settings, list workflows | Configuration and workflow discovery |
| **auth** | Connect OAuth integrations | Slack, Gmail, Google Sheets, etc. |
| **knowledge** | Create/query knowledge bases | RAG, document search |
| **research** | External API docs, best practices | Stripe, Twilio, etc. |
| **info** | Block details, outputs, variables | Quick lookups about workflow state |
| **superagent** | Interact with external services NOW | Read emails, send Slack, check calendar |
## Direct Tools
- **get_user_workflow(workflowId)** — Get workflow structure and blocks. Requires the workflow ID.
- **search_online** — Search the web for information.
## Decision Flow
- User says something broke → **debug()** first, then plan() → edit()
- User wants to build/automate something → **plan()** → edit() → test()
- User wants to DO something NOW (send email, check calendar) → **superagent()**
- User wants to deploy → **deploy()**
- User asks about their workflows → **workflow()** or **info()**
- User needs OAuth → **auth()**
## Important
- **You work at the workspace level.** When a user mentions a workflow, ask for the workflow name or ID if not provided.
- **Always delegate complex work** to the appropriate subagent.
- **Debug first** when something doesn't work — don't guess.
- Be concise and results-focused.
- Think internally, speak to the user only when the task is complete or you need input.
`
}

View File

@@ -0,0 +1,4 @@
ALTER TABLE "copilot_chats" ALTER COLUMN "workflow_id" DROP NOT NULL;--> statement-breakpoint
ALTER TABLE "copilot_chats" ADD COLUMN "workspace_id" text;--> statement-breakpoint
ALTER TABLE "copilot_chats" ADD CONSTRAINT "copilot_chats_workspace_id_workspace_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspace"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "copilot_chats_user_workspace_idx" ON "copilot_chats" USING btree ("user_id","workspace_id");

File diff suppressed because it is too large Load Diff

View File

@@ -1079,6 +1079,13 @@
"when": 1770869658697,
"tag": "0154_bumpy_living_mummy",
"breakpoints": true
},
{
"idx": 155,
"version": "7",
"when": 1771370340147,
"tag": "0155_cuddly_slapstick",
"breakpoints": true
}
]
}
}

View File

@@ -1499,9 +1499,8 @@ export const copilotChats = pgTable(
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
workflowId: text('workflow_id')
.notNull()
.references(() => workflow.id, { onDelete: 'cascade' }),
workflowId: text('workflow_id').references(() => workflow.id, { onDelete: 'cascade' }),
workspaceId: text('workspace_id').references(() => workspace.id, { onDelete: 'cascade' }),
title: text('title'),
messages: jsonb('messages').notNull().default('[]'),
model: text('model').notNull().default('claude-3-7-sonnet-latest'),
@@ -1518,6 +1517,12 @@ export const copilotChats = pgTable(
workflowIdIdx: index('copilot_chats_workflow_id_idx').on(table.workflowId),
userWorkflowIdx: index('copilot_chats_user_workflow_idx').on(table.userId, table.workflowId),
// Workspace access pattern
userWorkspaceIdx2: index('copilot_chats_user_workspace_idx').on(
table.userId,
table.workspaceId
),
// Ordering indexes
createdAtIdx: index('copilot_chats_created_at_idx').on(table.createdAt),
updatedAtIdx: index('copilot_chats_updated_at_idx').on(table.updatedAt),