mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
feat(mothership): resource viewer
This commit is contained in:
@@ -1,14 +1,90 @@
|
||||
'use client'
|
||||
|
||||
export function MothershipView() {
|
||||
import { useMemo } from 'react'
|
||||
import { Skeleton } from '@/components/emcn'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { FileViewer } from '@/app/workspace/[workspaceId]/files/components/file-viewer'
|
||||
import type { MothershipResource } from '@/app/workspace/[workspaceId]/home/types'
|
||||
import { Table } from '@/app/workspace/[workspaceId]/tables/[tableId]/components'
|
||||
import { useWorkspaceFiles } from '@/hooks/queries/workspace-files'
|
||||
|
||||
interface MothershipViewProps {
|
||||
workspaceId: string
|
||||
resources: MothershipResource[]
|
||||
activeResourceId: string | null
|
||||
onSelectResource: (id: string) => void
|
||||
}
|
||||
|
||||
export function MothershipView({
|
||||
workspaceId,
|
||||
resources,
|
||||
activeResourceId,
|
||||
onSelectResource,
|
||||
}: MothershipViewProps) {
|
||||
const active = resources.find((r) => r.id === activeResourceId) ?? resources[0] ?? null
|
||||
|
||||
return (
|
||||
<div className='flex h-full w-[480px] flex-shrink-0 flex-col border-[var(--border)] border-l'>
|
||||
<div className='flex items-center border-[var(--border)] border-b px-[16px] py-[12px]'>
|
||||
<span className='font-medium text-[13px] text-[var(--text-secondary)]'>Mothership</span>
|
||||
<div className='flex h-full w-[50%] min-w-[400px] flex-col border-[var(--border)] border-l'>
|
||||
<div className='flex shrink-0 gap-[2px] overflow-x-auto border-[var(--border)] border-b px-[12px]'>
|
||||
{resources.map((r) => (
|
||||
<button
|
||||
key={r.id}
|
||||
type='button'
|
||||
onClick={() => onSelectResource(r.id)}
|
||||
className={cn(
|
||||
'shrink-0 cursor-pointer border-b-[2px] px-[12px] py-[10px] text-[13px] transition-colors',
|
||||
active?.id === r.id
|
||||
? 'border-[var(--text-primary)] font-medium text-[var(--text-primary)]'
|
||||
: 'border-transparent text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
|
||||
)}
|
||||
>
|
||||
{r.title}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className='flex flex-1 items-center justify-center'>
|
||||
<span className='text-[13px] text-[var(--text-muted)]'>No artifacts yet</span>
|
||||
|
||||
<div className='min-h-0 flex-1 overflow-hidden'>
|
||||
{active?.type === 'table' && (
|
||||
<Table key={active.id} workspaceId={workspaceId} tableId={active.id} embedded />
|
||||
)}
|
||||
{active?.type === 'file' && (
|
||||
<EmbeddedFile key={active.id} workspaceId={workspaceId} fileId={active.id} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface EmbeddedFileProps {
|
||||
workspaceId: string
|
||||
fileId: string
|
||||
}
|
||||
|
||||
function EmbeddedFile({ workspaceId, fileId }: EmbeddedFileProps) {
|
||||
const { data: files = [], isLoading } = useWorkspaceFiles(workspaceId)
|
||||
const file = useMemo(() => files.find((f) => f.id === fileId), [files, fileId])
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className='flex h-full flex-col gap-[8px] p-[24px]'>
|
||||
<Skeleton className='h-[16px] w-[60%]' />
|
||||
<Skeleton className='h-[16px] w-[80%]' />
|
||||
<Skeleton className='h-[16px] w-[40%]' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!file) {
|
||||
return (
|
||||
<div className='flex h-full items-center justify-center'>
|
||||
<span className='text-[13px] text-[var(--text-muted)]'>File not found</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex h-full flex-col overflow-hidden'>
|
||||
<FileViewer key={file.id} file={file} workspaceId={workspaceId} canEdit={true} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { MessageContent, UserInput } from './components'
|
||||
import { MessageContent, MothershipView, UserInput } from './components'
|
||||
import { useChat } from './hooks'
|
||||
|
||||
interface HomeProps {
|
||||
@@ -12,10 +12,16 @@ interface HomeProps {
|
||||
export function Home({ chatId }: HomeProps = {}) {
|
||||
const { workspaceId } = useParams<{ workspaceId: string }>()
|
||||
const [inputValue, setInputValue] = useState('')
|
||||
const { messages, isSending, sendMessage, stopGeneration, chatBottomRef } = useChat(
|
||||
workspaceId,
|
||||
chatId
|
||||
)
|
||||
const {
|
||||
messages,
|
||||
isSending,
|
||||
sendMessage,
|
||||
stopGeneration,
|
||||
chatBottomRef,
|
||||
resources,
|
||||
activeResourceId,
|
||||
setActiveResourceId,
|
||||
} = useChat(workspaceId, chatId)
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
const trimmed = inputValue.trim()
|
||||
@@ -102,6 +108,15 @@ export function Home({ chatId }: HomeProps = {}) {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{resources.length > 0 && (
|
||||
<MothershipView
|
||||
workspaceId={workspaceId}
|
||||
resources={resources}
|
||||
activeResourceId={activeResourceId}
|
||||
onSelectResource={setActiveResourceId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { usePathname, useRouter } from 'next/navigation'
|
||||
import { MOTHERSHIP_CHAT_API_PATH } from '@/lib/copilot/constants'
|
||||
import { tableKeys } from '@/hooks/queries/tables'
|
||||
import {
|
||||
type TaskChatHistory,
|
||||
type TaskStoredContentBlock,
|
||||
@@ -9,10 +11,12 @@ import {
|
||||
taskKeys,
|
||||
useChatHistory,
|
||||
} from '@/hooks/queries/tasks'
|
||||
import { workspaceFilesKeys } from '@/hooks/queries/workspace-files'
|
||||
import type {
|
||||
ChatMessage,
|
||||
ContentBlock,
|
||||
ContentBlockType,
|
||||
MothershipResource,
|
||||
SSEPayload,
|
||||
SSEPayloadData,
|
||||
ToolCallStatus,
|
||||
@@ -26,6 +30,9 @@ export interface UseChatReturn {
|
||||
sendMessage: (message: string) => Promise<void>
|
||||
stopGeneration: () => void
|
||||
chatBottomRef: React.RefObject<HTMLDivElement | null>
|
||||
resources: MothershipResource[]
|
||||
activeResourceId: string | null
|
||||
setActiveResourceId: (id: string | null) => void
|
||||
}
|
||||
|
||||
const STATE_TO_STATUS: Record<string, ToolCallStatus> = {
|
||||
@@ -65,10 +72,66 @@ function mapStoredMessage(msg: TaskStoredMessage): ChatMessage {
|
||||
return mapped
|
||||
}
|
||||
|
||||
const logger = createLogger('useChat')
|
||||
|
||||
function getPayloadData(payload: SSEPayload): SSEPayloadData | undefined {
|
||||
return typeof payload.data === 'object' ? payload.data : undefined
|
||||
}
|
||||
|
||||
const RESOURCE_TOOL_NAMES = new Set(['user_table', 'workspace_file'])
|
||||
|
||||
function getResultData(parsed: SSEPayload): Record<string, unknown> | undefined {
|
||||
const topResult = parsed.result as Record<string, unknown> | undefined
|
||||
const nestedResult =
|
||||
typeof parsed.data === 'object' ? (parsed.data?.result as Record<string, unknown>) : undefined
|
||||
const result = topResult ?? nestedResult
|
||||
return result?.data as Record<string, unknown> | undefined
|
||||
}
|
||||
|
||||
function extractTableResource(
|
||||
parsed: SSEPayload,
|
||||
storedArgs: Record<string, unknown> | undefined,
|
||||
fallbackTableId: string | null
|
||||
): MothershipResource | null {
|
||||
const data = getResultData(parsed)
|
||||
const storedInnerArgs = storedArgs?.args as Record<string, unknown> | undefined
|
||||
|
||||
const table = data?.table as Record<string, unknown> | undefined
|
||||
if (table?.id) {
|
||||
return { type: 'table', id: table.id as string, title: (table.name as string) || 'Table' }
|
||||
}
|
||||
|
||||
const tableId =
|
||||
(data?.tableId as string) ?? storedInnerArgs?.tableId ?? storedArgs?.tableId ?? fallbackTableId
|
||||
const tableName = (data?.tableName as string) || (table?.name as string) || 'Table'
|
||||
if (tableId) return { type: 'table', id: tableId as string, title: tableName }
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function extractFileResource(
|
||||
parsed: SSEPayload,
|
||||
storedArgs: Record<string, unknown> | undefined
|
||||
): MothershipResource | null {
|
||||
const data = getResultData(parsed)
|
||||
const storedInnerArgs = storedArgs?.args as Record<string, unknown> | undefined
|
||||
|
||||
const file = data?.file as Record<string, unknown> | undefined
|
||||
if (file?.id) {
|
||||
return { type: 'file', id: file.id as string, title: (file.name as string) || 'File' }
|
||||
}
|
||||
|
||||
const fileId = (data?.fileId as string) ?? (data?.id as string)
|
||||
const fileName =
|
||||
(data?.fileName as string) ||
|
||||
(data?.name as string) ||
|
||||
(storedInnerArgs?.fileName as string) ||
|
||||
'File'
|
||||
if (fileId && typeof fileId === 'string') return { type: 'file', id: fileId, title: fileName }
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function useChat(workspaceId: string, initialChatId?: string): UseChatReturn {
|
||||
const pathname = usePathname()
|
||||
const queryClient = useQueryClient()
|
||||
@@ -77,6 +140,8 @@ export function useChat(workspaceId: string, initialChatId?: string): UseChatRet
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([])
|
||||
const [isSending, setIsSending] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [resources, setResources] = useState<MothershipResource[]>([])
|
||||
const [activeResourceId, setActiveResourceId] = useState<string | null>(null)
|
||||
const abortControllerRef = useRef<AbortController | null>(null)
|
||||
const chatIdRef = useRef<string | undefined>(initialChatId)
|
||||
const chatBottomRef = useRef<HTMLDivElement>(null)
|
||||
@@ -84,6 +149,7 @@ export function useChat(workspaceId: string, initialChatId?: string): UseChatRet
|
||||
const pendingUserMsgRef = useRef<{ id: string; content: string } | null>(null)
|
||||
const streamIdRef = useRef<string | undefined>(undefined)
|
||||
const sendingRef = useRef(false)
|
||||
const toolArgsMapRef = useRef<Map<string, Record<string, unknown>>>(new Map())
|
||||
|
||||
useEffect(() => {
|
||||
routerRef.current = router
|
||||
@@ -93,12 +159,30 @@ export function useChat(workspaceId: string, initialChatId?: string): UseChatRet
|
||||
|
||||
const { data: chatHistory } = useChatHistory(initialChatId)
|
||||
|
||||
const addResource = useCallback((resource: MothershipResource) => {
|
||||
setResources((prev) => {
|
||||
const existing = prev.find((r) => r.type === resource.type && r.id === resource.id)
|
||||
if (existing) {
|
||||
const keepOldTitle = existing.title !== 'Table' && existing.title !== 'File'
|
||||
const title = keepOldTitle ? existing.title : resource.title
|
||||
if (title === existing.title) return prev
|
||||
return prev.map((r) =>
|
||||
r.id === existing.id && r.type === existing.type ? { ...r, title } : r
|
||||
)
|
||||
}
|
||||
return [...prev, resource]
|
||||
})
|
||||
setActiveResourceId(resource.id)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
chatIdRef.current = initialChatId
|
||||
appliedChatIdRef.current = undefined
|
||||
setMessages([])
|
||||
setError(null)
|
||||
setIsSending(false)
|
||||
setResources([])
|
||||
setActiveResourceId(null)
|
||||
}, [initialChatId])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -110,6 +194,8 @@ export function useChat(workspaceId: string, initialChatId?: string): UseChatRet
|
||||
setMessages([])
|
||||
setError(null)
|
||||
setIsSending(false)
|
||||
setResources([])
|
||||
setActiveResourceId(null)
|
||||
}, [isHomePage])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -124,6 +210,7 @@ export function useChat(workspaceId: string, initialChatId?: string): UseChatRet
|
||||
let buffer = ''
|
||||
const blocks: ContentBlock[] = []
|
||||
const toolMap = new Map<string, number>()
|
||||
let lastTableId: string | null = null
|
||||
|
||||
const ensureTextBlock = (): ContentBlock => {
|
||||
const last = blocks[blocks.length - 1]
|
||||
@@ -164,6 +251,8 @@ export function useChat(workspaceId: string, initialChatId?: string): UseChatRet
|
||||
continue
|
||||
}
|
||||
|
||||
logger.debug('SSE event received', parsed)
|
||||
|
||||
switch (parsed.type) {
|
||||
case 'chat_id': {
|
||||
if (parsed.chatId) {
|
||||
@@ -201,6 +290,14 @@ export function useChat(workspaceId: string, initialChatId?: string): UseChatRet
|
||||
const data = getPayloadData(parsed)
|
||||
const name = parsed.toolName || data?.name || 'unknown'
|
||||
if (!id) break
|
||||
|
||||
if (RESOURCE_TOOL_NAMES.has(name)) {
|
||||
const args = data?.arguments ?? data?.input
|
||||
if (args) {
|
||||
toolArgsMapRef.current.set(id, args)
|
||||
}
|
||||
}
|
||||
|
||||
const ui = parsed.ui || data?.ui
|
||||
if (ui?.hidden) break
|
||||
const displayTitle = ui?.title || ui?.phaseLabel
|
||||
@@ -229,6 +326,33 @@ export function useChat(workspaceId: string, initialChatId?: string): UseChatRet
|
||||
blocks[idx].toolCall!.status = parsed.success ? 'success' : 'error'
|
||||
flush()
|
||||
}
|
||||
|
||||
const toolName = parsed.toolName || getPayloadData(parsed)?.name
|
||||
if (toolName && parsed.success && RESOURCE_TOOL_NAMES.has(toolName)) {
|
||||
const storedArgs = toolArgsMapRef.current.get(id)
|
||||
let resource: MothershipResource | null = null
|
||||
|
||||
if (toolName === 'user_table') {
|
||||
resource = extractTableResource(parsed, storedArgs, lastTableId)
|
||||
if (resource) {
|
||||
lastTableId = resource.id
|
||||
queryClient.invalidateQueries({ queryKey: tableKeys.detail(resource.id) })
|
||||
queryClient.invalidateQueries({ queryKey: tableKeys.rowsRoot(resource.id) })
|
||||
}
|
||||
} else if (toolName === 'workspace_file') {
|
||||
resource = extractFileResource(parsed, storedArgs)
|
||||
if (resource) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: workspaceFilesKeys.list(workspaceId),
|
||||
})
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: workspaceFilesKeys.content(workspaceId, resource.id),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (resource) addResource(resource)
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'tool_error': {
|
||||
@@ -265,7 +389,7 @@ export function useChat(workspaceId: string, initialChatId?: string): UseChatRet
|
||||
}
|
||||
}
|
||||
},
|
||||
[workspaceId, queryClient]
|
||||
[workspaceId, queryClient, addResource]
|
||||
)
|
||||
|
||||
const finalize = useCallback(() => {
|
||||
@@ -401,5 +525,8 @@ export function useChat(workspaceId: string, initialChatId?: string): UseChatRet
|
||||
sendMessage,
|
||||
stopGeneration,
|
||||
chatBottomRef,
|
||||
resources,
|
||||
activeResourceId,
|
||||
setActiveResourceId,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +47,9 @@ export interface SSEPayloadData {
|
||||
ui?: SSEPayloadUI
|
||||
id?: string
|
||||
agent?: string
|
||||
arguments?: Record<string, unknown>
|
||||
input?: Record<string, unknown>
|
||||
result?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface SSEPayload {
|
||||
@@ -60,4 +63,13 @@ export interface SSEPayload {
|
||||
success?: boolean
|
||||
error?: string
|
||||
subagent?: string
|
||||
result?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export type MothershipResourceType = 'table' | 'file'
|
||||
|
||||
export interface MothershipResource {
|
||||
type: MothershipResourceType
|
||||
id: string
|
||||
title: string
|
||||
}
|
||||
|
||||
@@ -125,12 +125,22 @@ function computeNormalizedSelection(
|
||||
}
|
||||
}
|
||||
|
||||
export function Table() {
|
||||
interface TableProps {
|
||||
workspaceId?: string
|
||||
tableId?: string
|
||||
embedded?: boolean
|
||||
}
|
||||
|
||||
export function Table({
|
||||
workspaceId: propWorkspaceId,
|
||||
tableId: propTableId,
|
||||
embedded,
|
||||
}: TableProps = {}) {
|
||||
const params = useParams()
|
||||
const router = useRouter()
|
||||
|
||||
const workspaceId = params.workspaceId as string
|
||||
const tableId = params.tableId as string
|
||||
const workspaceId = propWorkspaceId || (params.workspaceId as string)
|
||||
const tableId = propTableId || (params.tableId as string)
|
||||
|
||||
const [queryOptions, setQueryOptions] = useState<QueryOptions>({
|
||||
filter: null,
|
||||
@@ -729,37 +739,41 @@ export function Table() {
|
||||
|
||||
return (
|
||||
<div className='flex h-full flex-col'>
|
||||
<ResourceHeader
|
||||
icon={TableIcon}
|
||||
breadcrumbs={[
|
||||
{ label: 'Tables', onClick: handleNavigateBack },
|
||||
...(tableData
|
||||
? [
|
||||
{
|
||||
label: tableData.name,
|
||||
dropdownItems: [
|
||||
{ label: 'Rename', icon: Pencil, onClick: () => {} },
|
||||
{!embedded && (
|
||||
<>
|
||||
<ResourceHeader
|
||||
icon={TableIcon}
|
||||
breadcrumbs={[
|
||||
{ label: 'Tables', onClick: handleNavigateBack },
|
||||
...(tableData
|
||||
? [
|
||||
{
|
||||
label: 'Delete',
|
||||
icon: Trash,
|
||||
onClick: () => setShowDeleteTableConfirm(true),
|
||||
label: tableData.name,
|
||||
dropdownItems: [
|
||||
{ label: 'Rename', icon: Pencil, onClick: () => {} },
|
||||
{
|
||||
label: 'Delete',
|
||||
icon: Trash,
|
||||
onClick: () => setShowDeleteTableConfirm(true),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]}
|
||||
create={{
|
||||
label: 'New column',
|
||||
onClick: handleAddColumn,
|
||||
disabled: addColumnMutation.isPending,
|
||||
}}
|
||||
/>
|
||||
]
|
||||
: []),
|
||||
]}
|
||||
create={{
|
||||
label: 'New column',
|
||||
onClick: handleAddColumn,
|
||||
disabled: addColumnMutation.isPending,
|
||||
}}
|
||||
/>
|
||||
|
||||
<ResourceOptionsBar
|
||||
sort={sortConfig}
|
||||
filter={<TableFilter columns={columns} onApply={handleFilterApply} />}
|
||||
/>
|
||||
<ResourceOptionsBar
|
||||
sort={sortConfig}
|
||||
filter={<TableFilter columns={columns} onApply={handleFilterApply} />}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className='min-h-0 flex-1 overflow-auto overscroll-none' data-table-scroll>
|
||||
<table
|
||||
@@ -946,34 +960,36 @@ export function Table() {
|
||||
onDelete={handleContextMenuDelete}
|
||||
/>
|
||||
|
||||
<Modal open={showDeleteTableConfirm} onOpenChange={setShowDeleteTableConfirm}>
|
||||
<ModalContent size='sm'>
|
||||
<ModalHeader>Delete Table</ModalHeader>
|
||||
<ModalBody>
|
||||
<p className='text-[13px] text-[var(--text-secondary)]'>
|
||||
Are you sure you want to delete{' '}
|
||||
<span className='font-medium text-[var(--text-primary)]'>{tableData?.name}</span>?{' '}
|
||||
<span className='text-[var(--text-error)]'>This action cannot be undone.</span>
|
||||
</p>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button
|
||||
variant='default'
|
||||
onClick={() => setShowDeleteTableConfirm(false)}
|
||||
disabled={deleteTableMutation.isPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant='destructive'
|
||||
onClick={handleDeleteTable}
|
||||
disabled={deleteTableMutation.isPending}
|
||||
>
|
||||
{deleteTableMutation.isPending ? 'Deleting...' : 'Delete'}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
{!embedded && (
|
||||
<Modal open={showDeleteTableConfirm} onOpenChange={setShowDeleteTableConfirm}>
|
||||
<ModalContent size='sm'>
|
||||
<ModalHeader>Delete Table</ModalHeader>
|
||||
<ModalBody>
|
||||
<p className='text-[13px] text-[var(--text-secondary)]'>
|
||||
Are you sure you want to delete{' '}
|
||||
<span className='font-medium text-[var(--text-primary)]'>{tableData?.name}</span>?{' '}
|
||||
<span className='text-[var(--text-error)]'>This action cannot be undone.</span>
|
||||
</p>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button
|
||||
variant='default'
|
||||
onClick={() => setShowDeleteTableConfirm(false)}
|
||||
disabled={deleteTableMutation.isPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant='destructive'
|
||||
onClick={handleDeleteTable}
|
||||
disabled={deleteTableMutation.isPending}
|
||||
>
|
||||
{deleteTableMutation.isPending ? 'Deleting...' : 'Delete'}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
open={renamingColumn !== null}
|
||||
|
||||
Reference in New Issue
Block a user