mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
Checkpoint
This commit is contained in:
@@ -1,12 +1,96 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { Send, Square } from 'lucide-react'
|
||||
import { Check, CircleAlert, Loader2, Send, Square, Zap } from 'lucide-react'
|
||||
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':
|
||||
return <Loader2 className='h-3 w-3 animate-spin text-[var(--text-tertiary)]' />
|
||||
case 'success':
|
||||
return <Check className='h-3 w-3 text-emerald-500' />
|
||||
case 'error':
|
||||
return <CircleAlert className='h-3 w-3 text-red-400' />
|
||||
}
|
||||
}
|
||||
|
||||
/** Formats a tool name for display: "edit_workflow" → "Edit Workflow". */
|
||||
function formatToolName(name: string): string {
|
||||
return name
|
||||
.replace(/_v\d+$/, '')
|
||||
.split('_')
|
||||
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
/** Compact inline rendering of a single tool call. */
|
||||
function ToolCallItem({ toolCall }: { toolCall: ToolCallInfo }) {
|
||||
const label = toolCall.displayTitle || formatToolName(toolCall.name)
|
||||
|
||||
return (
|
||||
<div className='flex items-center gap-2 rounded-md border border-[var(--border)] bg-[var(--surface-2)] px-3 py-1.5'>
|
||||
<Zap className='h-3 w-3 flex-shrink-0 text-[var(--text-tertiary)]' />
|
||||
<span className='min-w-0 flex-1 truncate text-xs text-[var(--text-secondary)]'>{label}</span>
|
||||
<ToolStatusIcon status={toolCall.status} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Renders a subagent activity label. */
|
||||
function SubagentLabel({ label }: { label: string }) {
|
||||
return (
|
||||
<div className='flex items-center gap-2 py-0.5'>
|
||||
<Loader2 className='h-3 w-3 animate-spin text-[var(--text-tertiary)]' />
|
||||
<span className='text-xs text-[var(--text-tertiary)]'>{label}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Renders structured content blocks for an assistant message. */
|
||||
function AssistantContent({ blocks, isStreaming }: { blocks: ContentBlock[]; isStreaming: boolean }) {
|
||||
return (
|
||||
<div className='space-y-2'>
|
||||
{blocks.map((block, i) => {
|
||||
switch (block.type) {
|
||||
case 'text': {
|
||||
if (!block.content?.trim()) return null
|
||||
return (
|
||||
<div key={`text-${i}`} className='prose-sm prose-invert max-w-none'>
|
||||
<ReactMarkdown remarkPlugins={REMARK_PLUGINS}>{block.content}</ReactMarkdown>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
case 'tool_call': {
|
||||
if (!block.toolCall) return null
|
||||
return <ToolCallItem key={block.toolCall.id} toolCall={block.toolCall} />
|
||||
}
|
||||
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')
|
||||
if (!isLastSubagent) return null
|
||||
return <SubagentLabel key={`sub-${i}`} label={block.content} />
|
||||
}
|
||||
default:
|
||||
return null
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function Chat() {
|
||||
const { workspaceId } = useParams<{ workspaceId: string }>()
|
||||
const [inputValue, setInputValue] = useState('')
|
||||
@@ -44,7 +128,7 @@ export function Chat() {
|
||||
<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>
|
||||
<h1 className='font-medium text-[16px] text-[var(--text-primary)]'>Mothership</h1>
|
||||
</div>
|
||||
|
||||
{/* Messages area */}
|
||||
@@ -61,34 +145,59 @@ export function Chat() {
|
||||
<div className='mx-auto max-w-3xl space-y-4'>
|
||||
{messages.map((msg) => {
|
||||
const isStreamingEmpty =
|
||||
isSending && msg.role === 'assistant' && !msg.content
|
||||
isSending &&
|
||||
msg.role === 'assistant' &&
|
||||
!msg.content &&
|
||||
(!msg.contentBlocks || msg.contentBlocks.length === 0)
|
||||
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)]'>
|
||||
<div className='flex items-center gap-2 rounded-lg bg-[var(--surface-3)] px-4 py-2 text-sm text-[var(--text-secondary)]'>
|
||||
<Loader2 className='h-3 w-3 animate-spin' />
|
||||
Thinking...
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (msg.role === 'assistant' && !msg.content) return null
|
||||
|
||||
// Skip empty assistant messages
|
||||
if (
|
||||
msg.role === 'assistant' &&
|
||||
!msg.content &&
|
||||
(!msg.contentBlocks || msg.contentBlocks.length === 0)
|
||||
)
|
||||
return null
|
||||
|
||||
// User messages
|
||||
if (msg.role === 'user') {
|
||||
return (
|
||||
<div key={msg.id} className='flex justify-end'>
|
||||
<div className='max-w-[85%] rounded-lg bg-[var(--accent)] px-4 py-2 text-sm text-[var(--accent-foreground)]'>
|
||||
<p className='whitespace-pre-wrap'>{msg.content}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Assistant messages with content blocks
|
||||
const hasBlocks = msg.contentBlocks && msg.contentBlocks.length > 0
|
||||
const isThisMessageStreaming = isSending && msg === messages[messages.length - 1]
|
||||
|
||||
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)]'
|
||||
<div key={msg.id} className='flex justify-start'>
|
||||
<div className='max-w-[85%] rounded-lg bg-[var(--surface-3)] px-4 py-2 text-sm text-[var(--text-primary)]'>
|
||||
{hasBlocks ? (
|
||||
<AssistantContent
|
||||
blocks={msg.contentBlocks!}
|
||||
isStreaming={isThisMessageStreaming}
|
||||
/>
|
||||
) : (
|
||||
<div className='prose-sm prose-invert max-w-none'>
|
||||
<ReactMarkdown remarkPlugins={REMARK_PLUGINS}>
|
||||
{msg.content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<p className='whitespace-pre-wrap'>{msg.content}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -5,11 +5,38 @@ import { createLogger } from '@sim/logger'
|
||||
|
||||
const logger = createLogger('useWorkspaceChat')
|
||||
|
||||
interface ChatMessage {
|
||||
/** 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
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
id: string
|
||||
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
|
||||
}
|
||||
|
||||
interface UseWorkspaceChatProps {
|
||||
@@ -25,6 +52,20 @@ 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',
|
||||
custom_tool: 'Creating tool',
|
||||
superagent: 'Executing action',
|
||||
plan: 'Planning',
|
||||
debug: 'Debugging',
|
||||
edit: 'Editing workflow',
|
||||
}
|
||||
|
||||
export function useWorkspaceChat({ workspaceId }: UseWorkspaceChatProps): UseWorkspaceChatReturn {
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([])
|
||||
const [isSending, setIsSending] = useState(false)
|
||||
@@ -51,6 +92,8 @@ export function useWorkspaceChat({ workspaceId }: UseWorkspaceChatProps): UseWor
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
timestamp: new Date().toISOString(),
|
||||
contentBlocks: [],
|
||||
activeSubagent: null,
|
||||
}
|
||||
|
||||
setMessages((prev) => [...prev, userMessage, assistantMessage])
|
||||
@@ -58,6 +101,40 @@ 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
|
||||
|
||||
/** 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
|
||||
const newBlock: ContentBlock = { type: 'text', content: '' }
|
||||
blocksRef.push(newBlock)
|
||||
return newBlock
|
||||
}
|
||||
|
||||
/** Push updated blocks + content into the assistant message. */
|
||||
const flushBlocks = (extra?: Partial<ChatMessage>) => {
|
||||
const fullText = blocksRef
|
||||
.filter((b) => b.type === 'text')
|
||||
.map((b) => b.content ?? '')
|
||||
.join('')
|
||||
setMessages((prev) =>
|
||||
prev.map((msg) =>
|
||||
msg.id === assistantMessage.id
|
||||
? {
|
||||
...msg,
|
||||
content: fullText,
|
||||
contentBlocks: [...blocksRef],
|
||||
...extra,
|
||||
}
|
||||
: msg
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/copilot/workspace-chat', {
|
||||
method: 'POST',
|
||||
@@ -98,27 +175,123 @@ export function useWorkspaceChat({ workspaceId }: UseWorkspaceChatProps): UseWor
|
||||
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
|
||||
switch (event.type) {
|
||||
case 'chat_id': {
|
||||
if (event.chatId) {
|
||||
chatIdRef.current = event.chatId
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'content': {
|
||||
if (event.content || event.data) {
|
||||
const chunk =
|
||||
typeof event.data === 'string' ? event.data : event.content || ''
|
||||
if (chunk) {
|
||||
const textBlock = ensureTextBlock()
|
||||
textBlock.content = (textBlock.content ?? '') + chunk
|
||||
flushBlocks()
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'tool_generating':
|
||||
case 'tool_call': {
|
||||
const toolCallId = event.toolCallId
|
||||
const toolName = event.toolName || event.data?.name || 'unknown'
|
||||
if (!toolCallId) break
|
||||
|
||||
const ui = event.ui || event.data?.ui
|
||||
const displayTitle = ui?.title || ui?.phaseLabel
|
||||
|
||||
if (!toolCallMapRef.has(toolCallId)) {
|
||||
const toolBlock: ContentBlock = {
|
||||
type: 'tool_call',
|
||||
toolCall: {
|
||||
id: toolCallId,
|
||||
name: toolName,
|
||||
status: 'executing',
|
||||
displayTitle,
|
||||
},
|
||||
}
|
||||
toolCallMapRef.set(toolCallId, blocksRef.length)
|
||||
blocksRef.push(toolBlock)
|
||||
} else {
|
||||
const idx = toolCallMapRef.get(toolCallId)!
|
||||
const existing = blocksRef[idx]
|
||||
if (existing.toolCall) {
|
||||
existing.toolCall.name = toolName
|
||||
if (displayTitle) existing.toolCall.displayTitle = displayTitle
|
||||
}
|
||||
}
|
||||
flushBlocks()
|
||||
break
|
||||
}
|
||||
|
||||
case 'tool_result': {
|
||||
const toolCallId = event.toolCallId || event.data?.id
|
||||
if (!toolCallId) break
|
||||
const idx = toolCallMapRef.get(toolCallId)
|
||||
if (idx !== undefined) {
|
||||
const block = blocksRef[idx]
|
||||
if (block.toolCall) {
|
||||
block.toolCall.status = event.success ? 'success' : 'error'
|
||||
}
|
||||
flushBlocks()
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'tool_error': {
|
||||
const toolCallId = event.toolCallId || event.data?.id
|
||||
if (!toolCallId) break
|
||||
const idx = toolCallMapRef.get(toolCallId)
|
||||
if (idx !== undefined) {
|
||||
const block = blocksRef[idx]
|
||||
if (block.toolCall) {
|
||||
block.toolCall.status = 'error'
|
||||
}
|
||||
flushBlocks()
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'subagent_start': {
|
||||
const subagentName = event.subagent || event.data?.agent
|
||||
if (subagentName) {
|
||||
const label = SUBAGENT_LABELS[subagentName] || subagentName
|
||||
const subBlock: ContentBlock = {
|
||||
type: 'subagent',
|
||||
content: label,
|
||||
}
|
||||
blocksRef.push(subBlock)
|
||||
flushBlocks({ activeSubagent: label })
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'subagent_end': {
|
||||
flushBlocks({ activeSubagent: null })
|
||||
break
|
||||
}
|
||||
|
||||
case 'error': {
|
||||
setError(event.error || 'An error occurred')
|
||||
break
|
||||
}
|
||||
|
||||
case 'done': {
|
||||
if (event.content && typeof event.content === 'string') {
|
||||
setMessages((prev) =>
|
||||
prev.map((msg) =>
|
||||
msg.id === assistantMessage.id && !msg.content
|
||||
? { ...msg, content: event.content }
|
||||
: msg
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
|
||||
@@ -250,7 +250,7 @@ export const Sidebar = memo(function Sidebar() {
|
||||
[
|
||||
{
|
||||
id: 'chat',
|
||||
label: 'Chat',
|
||||
label: 'Mothership',
|
||||
icon: MessageSquare,
|
||||
href: `/workspace/${workspaceId}/chat`,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user