Merge branch 'feat/mothership-copilot' of github.com:simstudioai/sim into feat/mothership-copilot

This commit is contained in:
Vikhyath Mondreti
2026-03-09 12:13:34 -07:00
18 changed files with 652 additions and 119 deletions

View File

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

View File

@@ -4,7 +4,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { useParams } from 'next/navigation'
import { LandingPromptStorage } from '@/lib/core/utils/browser-storage'
import { MessageContent, UserInput } from './components'
import { MessageContent, MothershipView, UserInput } from './components'
import { useChat } from './hooks'
const logger = createLogger('Home')
@@ -28,10 +28,17 @@ export function Home({ chatId }: HomeProps = {}) {
setInputValue(prompt)
}
}, [])
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()
@@ -118,6 +125,15 @@ export function Home({ chatId }: HomeProps = {}) {
/>
</div>
</div>
{resources.length > 0 && (
<MothershipView
workspaceId={workspaceId}
resources={resources}
activeResourceId={activeResourceId}
onSelectResource={setActiveResourceId}
/>
)}
</div>
)
}

View File

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

View File

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

View File

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

View File

@@ -246,6 +246,11 @@ export const sseHandlers: Record<string, SSEHandler> = {
updatedMap[toolCallId] = {
...current,
state: targetState,
result: {
success: !!data?.success,
output: data?.result ?? undefined,
error: (data?.error as string) ?? undefined,
},
display: resolveToolDisplay(
current.name,
targetState,

View File

@@ -1,7 +1,13 @@
import crypto from 'crypto'
import { db } from '@sim/db'
import { chat, workflow, workflowMcpServer, workflowMcpTool } from '@sim/db/schema'
import { eq, inArray } from 'drizzle-orm'
import {
chat,
workflow,
workflowDeploymentVersion,
workflowMcpServer,
workflowMcpTool,
} from '@sim/db/schema'
import { and, eq, inArray } from 'drizzle-orm'
import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/orchestrator/types'
import { mcpPubSub } from '@/lib/mcp/pubsub'
import { generateParameterSchemaForWorkflow } from '@/lib/mcp/workflow-mcp-sync'
@@ -313,3 +319,87 @@ export async function executeDeleteWorkspaceMcpServer(
return { success: false, error: error instanceof Error ? error.message : String(error) }
}
}
export async function executeGetDeploymentVersion(
params: { workflowId?: string; version?: number },
context: ExecutionContext
): Promise<ToolCallResult> {
try {
const workflowId = params.workflowId || context.workflowId
if (!workflowId) {
return { success: false, error: 'workflowId is required' }
}
const version = params.version
if (version === undefined || version === null) {
return { success: false, error: 'version is required' }
}
await ensureWorkflowAccess(workflowId, context.userId)
const [row] = await db
.select({ state: workflowDeploymentVersion.state })
.from(workflowDeploymentVersion)
.where(
and(
eq(workflowDeploymentVersion.workflowId, workflowId),
eq(workflowDeploymentVersion.version, version)
)
)
.limit(1)
if (!row?.state) {
return { success: false, error: `Deployment version ${version} not found` }
}
return { success: true, output: { version, deployedState: row.state } }
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) }
}
}
export async function executeRevertToVersion(
params: { workflowId?: string; version?: number },
context: ExecutionContext
): Promise<ToolCallResult> {
try {
const workflowId = params.workflowId || context.workflowId
if (!workflowId) {
return { success: false, error: 'workflowId is required' }
}
const version = params.version
if (version === undefined || version === null) {
return { success: false, error: 'version is required' }
}
await ensureWorkflowAccess(workflowId, context.userId)
const baseUrl =
process.env.NEXT_PUBLIC_APP_URL || process.env.APP_URL || 'http://localhost:3000'
const response = await fetch(
`${baseUrl}/api/workflows/${workflowId}/deployments/${version}/revert`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': process.env.INTERNAL_API_SECRET || '',
},
}
)
if (!response.ok) {
const body = await response.json().catch(() => ({}))
return { success: false, error: body.error || `Failed to revert (HTTP ${response.status})` }
}
const result = await response.json()
return {
success: true,
output: {
message: `Reverted workflow to deployment version ${version}`,
lastSaved: result.lastSaved,
},
}
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) }
}
}

View File

@@ -1,5 +1,5 @@
import { db } from '@sim/db'
import { mcpServers, pendingCredentialDraft, user } from '@sim/db/schema'
import { credential, mcpServers, pendingCredentialDraft, user } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, isNull, lt } from 'drizzle-orm'
import { SIM_AGENT_API_URL } from '@/lib/copilot/constants'
@@ -34,8 +34,10 @@ import {
executeDeployApi,
executeDeployChat,
executeDeployMcp,
executeGetDeploymentVersion,
executeListWorkspaceMcpServers,
executeRedeploy,
executeRevertToVersion,
executeUpdateWorkspaceMcpServer,
} from './deployment-tools'
import { executeIntegrationToolDirect } from './integration-tools'
@@ -876,6 +878,57 @@ const SIM_WORKFLOW_TOOL_HANDLERS: Record<
executeUpdateWorkspaceMcpServer(p as unknown as UpdateWorkspaceMcpServerParams, c),
delete_workspace_mcp_server: (p, c) =>
executeDeleteWorkspaceMcpServer(p as unknown as DeleteWorkspaceMcpServerParams, c),
get_deployment_version: (p, c) =>
executeGetDeploymentVersion(p as { workflowId?: string; version?: number }, c),
revert_to_version: (p, c) =>
executeRevertToVersion(p as { workflowId?: string; version?: number }, c),
manage_credential: async (p, c) => {
const params = p as { operation: string; credentialId: string; displayName?: string }
const { operation, credentialId, displayName } = params
if (!credentialId) {
return { success: false, error: 'credentialId is required' }
}
try {
const [row] = await db
.select({ id: credential.id, type: credential.type, displayName: credential.displayName })
.from(credential)
.where(eq(credential.id, credentialId))
.limit(1)
if (!row) {
return { success: false, error: 'Credential not found' }
}
if (row.type !== 'oauth') {
return {
success: false,
error:
'Only OAuth credentials can be managed with this tool. Use set_environment_variables for env vars.',
}
}
switch (operation) {
case 'rename': {
if (!displayName) {
return { success: false, error: 'displayName is required for rename' }
}
await db
.update(credential)
.set({ displayName, updatedAt: new Date() })
.where(eq(credential.id, credentialId))
return { success: true, output: { credentialId, displayName } }
}
case 'delete': {
await db.delete(credential).where(eq(credential.id, credentialId))
return { success: true, output: { credentialId, deleted: true } }
}
default:
return {
success: false,
error: `Unknown operation: ${operation}. Use "rename" or "delete".`,
}
}
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) }
}
},
create_job: (p, c) => executeCreateJob(p, c),
manage_job: (p, c) => executeManageJob(p, c),
complete_job: (p, c) => executeCompleteJob(p, c),

View File

@@ -352,7 +352,7 @@ export async function executeManageJob(
if (!['active', 'paused'].includes(args.status)) {
return { success: false, error: 'status must be "active" or "paused"' }
}
updates.status = args.status
updates.status = args.status === 'paused' ? 'disabled' : args.status
}
if (args.cron !== undefined) {

View File

@@ -37,26 +37,6 @@ export const DIRECT_TOOL_DEFS: DirectToolDef[] = [
},
annotations: { readOnlyHint: true },
},
{
name: 'list_workflows',
toolId: 'list_user_workflows',
description:
'List all workflows the user has access to. Returns workflow IDs, names, workspace, and folder info. Use workspaceId/folderId to scope results.',
inputSchema: {
type: 'object',
properties: {
workspaceId: {
type: 'string',
description: 'Optional workspace ID to filter workflows.',
},
folderId: {
type: 'string',
description: 'Optional folder ID to filter workflows.',
},
},
},
annotations: { readOnlyHint: true },
},
{
name: 'list_folders',
toolId: 'list_folders',
@@ -74,23 +54,6 @@ export const DIRECT_TOOL_DEFS: DirectToolDef[] = [
},
annotations: { readOnlyHint: true },
},
{
name: 'get_workflow',
toolId: 'get_user_workflow',
description:
'Get a workflow by ID. Returns the full workflow definition including all blocks, connections, and configuration.',
inputSchema: {
type: 'object',
properties: {
workflowId: {
type: 'string',
description: 'Workflow ID to retrieve.',
},
},
required: ['workflowId'],
},
annotations: { readOnlyHint: true },
},
{
name: 'create_workflow',
toolId: 'create_workflow',

View File

@@ -4,11 +4,26 @@ import type { WorkspaceFileArgs, WorkspaceFileResult } from '@/lib/copilot/tools
import {
deleteWorkspaceFile,
getWorkspaceFile,
updateWorkspaceFileContent,
uploadWorkspaceFile,
} from '@/lib/uploads/contexts/workspace/workspace-file-manager'
const logger = createLogger('WorkspaceFileServerTool')
const EXT_TO_MIME: Record<string, string> = {
'.txt': 'text/plain',
'.md': 'text/markdown',
'.html': 'text/html',
'.json': 'application/json',
'.csv': 'text/csv',
}
function inferContentType(fileName: string, explicitType?: string): string {
if (explicitType) return explicitType
const ext = fileName.slice(fileName.lastIndexOf('.')).toLowerCase()
return EXT_TO_MIME[ext] || 'text/plain'
}
export const workspaceFileServerTool: BaseServerTool<WorkspaceFileArgs, WorkspaceFileResult> = {
name: 'workspace_file',
async execute(
@@ -33,8 +48,7 @@ export const workspaceFileServerTool: BaseServerTool<WorkspaceFileArgs, Workspac
case 'write': {
const fileName = (args as Record<string, unknown>).fileName as string | undefined
const content = (args as Record<string, unknown>).content as string | undefined
const contentType =
((args as Record<string, unknown>).contentType as string) || 'text/plain'
const explicitType = (args as Record<string, unknown>).contentType as string | undefined
if (!fileName) {
return { success: false, message: 'fileName is required for write operation' }
@@ -43,6 +57,7 @@ export const workspaceFileServerTool: BaseServerTool<WorkspaceFileArgs, Workspac
return { success: false, message: 'content is required for write operation' }
}
const contentType = inferContentType(fileName, explicitType)
const fileBuffer = Buffer.from(content, 'utf-8')
const result = await uploadWorkspaceFile(
workspaceId,
@@ -72,6 +87,43 @@ export const workspaceFileServerTool: BaseServerTool<WorkspaceFileArgs, Workspac
}
}
case 'update': {
const fileId = (args as Record<string, unknown>).fileId as string | undefined
const content = (args as Record<string, unknown>).content as string | undefined
if (!fileId) {
return { success: false, message: 'fileId is required for update operation' }
}
if (content === undefined || content === null) {
return { success: false, message: 'content is required for update operation' }
}
const fileRecord = await getWorkspaceFile(workspaceId, fileId)
if (!fileRecord) {
return { success: false, message: `File with ID "${fileId}" not found` }
}
const fileBuffer = Buffer.from(content, 'utf-8')
await updateWorkspaceFileContent(workspaceId, fileId, context.userId, fileBuffer)
logger.info('Workspace file updated via copilot', {
fileId,
name: fileRecord.name,
size: fileBuffer.length,
userId: context.userId,
})
return {
success: true,
message: `File "${fileRecord.name}" updated successfully (${fileBuffer.length} bytes)`,
data: {
id: fileId,
name: fileRecord.name,
size: fileBuffer.length,
},
}
}
case 'delete': {
const fileId = (args as Record<string, unknown>).fileId as string | undefined
if (!fileId) {

View File

@@ -6,7 +6,12 @@ import { generateInternalToken } from '@/lib/auth/internal'
import type { BaseServerTool } from '@/lib/copilot/tools/server/base-tool'
import type { KnowledgeBaseArgs, KnowledgeBaseResult } from '@/lib/copilot/tools/shared/schemas'
import { getInternalApiBaseUrl } from '@/lib/core/utils/urls'
import { createSingleDocument, processDocumentAsync } from '@/lib/knowledge/documents/service'
import {
createSingleDocument,
deleteDocument,
processDocumentAsync,
updateDocument,
} from '@/lib/knowledge/documents/service'
import { generateSearchEmbedding } from '@/lib/knowledge/embeddings'
import {
createKnowledgeBase,
@@ -381,6 +386,55 @@ export const knowledgeBaseServerTool: BaseServerTool<KnowledgeBaseArgs, Knowledg
}
}
case 'delete_document': {
if (!args.knowledgeBaseId) {
return { success: false, message: 'knowledgeBaseId is required for delete_document' }
}
if (!args.documentId) {
return { success: false, message: 'documentId is required for delete_document' }
}
const requestId = crypto.randomUUID().slice(0, 8)
const result = await deleteDocument(args.documentId, requestId)
return {
success: result.success,
message: result.message,
data: { documentId: args.documentId, knowledgeBaseId: args.knowledgeBaseId },
}
}
case 'update_document': {
if (!args.knowledgeBaseId) {
return { success: false, message: 'knowledgeBaseId is required for update_document' }
}
if (!args.documentId) {
return { success: false, message: 'documentId is required for update_document' }
}
const updateData: { filename?: string; enabled?: boolean } = {}
if (args.filename !== undefined) {
updateData.filename = args.filename
}
if (args.enabled !== undefined) {
updateData.enabled = args.enabled
}
if (Object.keys(updateData).length === 0) {
return {
success: false,
message: 'At least one of filename or enabled is required for update_document',
}
}
const requestId = crypto.randomUUID().slice(0, 8)
await updateDocument(args.documentId, updateData, requestId)
return {
success: true,
message: `Document updated successfully`,
data: {
documentId: args.documentId,
knowledgeBaseId: args.knowledgeBaseId,
...updateData,
},
}
}
case 'list_tags': {
if (!args.knowledgeBaseId) {
return {

View File

@@ -27,6 +27,8 @@ const WRITE_ACTIONS: Record<string, string[]> = {
'add_file',
'update',
'delete',
'delete_document',
'update_document',
'create_tag',
'update_tag',
'delete_tag',
@@ -54,6 +56,8 @@ const WRITE_ACTIONS: Record<string, string[]> = {
manage_custom_tool: ['add', 'edit', 'delete'],
manage_mcp_tool: ['add', 'edit', 'delete'],
manage_skill: ['add', 'edit', 'delete'],
manage_credential: ['rename', 'delete'],
workspace_file: ['write', 'update', 'delete'],
}
function isActionAllowed(toolName: string, action: string, userPermission: string): boolean {

View File

@@ -28,6 +28,8 @@ export const KnowledgeBaseArgsSchema = z.object({
'update',
'delete',
'add_file',
'delete_document',
'update_document',
'list_tags',
'create_tag',
'update_tag',
@@ -84,6 +86,12 @@ export const KnowledgeBaseArgsSchema = z.object({
connectorStatus: z.enum(['active', 'paused']).optional(),
/** Tag definition IDs to disable (optional for add_connector) */
disabledTagIds: z.array(z.string()).optional(),
/** Document ID (required for delete_document, update_document) */
documentId: z.string().optional(),
/** Enable/disable a document (optional for update_document) */
enabled: z.boolean().optional(),
/** New filename for a document (optional for update_document) */
filename: z.string().optional(),
})
.optional(),
})
@@ -160,7 +168,7 @@ export type UserTableResult = z.infer<typeof UserTableResultSchema>
// workspace_file - shared schema used by server tool and Go catalog
export const WorkspaceFileArgsSchema = z.object({
operation: z.enum(['write', 'delete']),
operation: z.enum(['write', 'update', 'delete']),
args: z
.object({
fileId: z.string().optional(),

View File

@@ -413,6 +413,7 @@ export function serializeCredentials(
accounts: Array<{
id?: string
providerId: string
displayName?: string | null
scope: string | null
createdAt: Date
}>
@@ -421,6 +422,7 @@ export function serializeCredentials(
accounts.map((a) => ({
id: a.id || undefined,
provider: a.providerId,
displayName: a.displayName || undefined,
scope: a.scope || undefined,
connectedAt: a.createdAt.toISOString(),
})),
@@ -519,6 +521,14 @@ export interface DeploymentData {
isPublished: boolean
capabilities: unknown
} | null
versions?: Array<{
id: string
version: number
name: string | null
description: string | null
isActive: boolean
createdAt: Date
}>
}
/**
@@ -593,6 +603,34 @@ export function serializeDeployments(data: DeploymentData): string {
return JSON.stringify(result, null, 2)
}
/**
* Serialize deployment version history for VFS workflows/{name}/versions.json.
* Lists all versions without full state — use get_deployment_version tool to fetch a version's state.
*/
export function serializeVersions(
versions: Array<{
id: string
version: number
name: string | null
description: string | null
isActive: boolean
createdAt: Date
}>
): string {
return JSON.stringify(
versions.map((v) => ({
id: v.id,
version: v.version,
name: v.name || undefined,
description: v.description || undefined,
isActive: v.isActive,
createdAt: v.createdAt.toISOString(),
})),
null,
2
)
}
/**
* Serialize a custom tool for VFS custom-tools/{name}.json
*/

View File

@@ -44,6 +44,7 @@ import {
serializeTaskSession,
serializeTriggerOverview,
serializeTriggerSchema,
serializeVersions,
serializeWorkflowMeta,
} from '@/lib/copilot/vfs/serializers'
import { buildWorkspaceMd, type WorkspaceMdData } from '@/lib/copilot/workspace-context'
@@ -542,6 +543,9 @@ export class WorkspaceVFS {
)
if (deploymentData) {
this.files.set(`${prefix}deployment.json`, serializeDeployments(deploymentData))
if (deploymentData.versions && deploymentData.versions.length > 0) {
this.files.set(`${prefix}versions.json`, serializeVersions(deploymentData.versions))
}
}
} catch (err) {
logger.warn('Failed to load deployment data', {
@@ -744,7 +748,7 @@ export class WorkspaceVFS {
deployedAt: Date | null,
currentNormalized?: Awaited<ReturnType<typeof loadWorkflowFromNormalizedTables>>
): Promise<DeploymentData | null> {
const [chatRows, formRows, mcpRows, a2aRows, versionRows] = await Promise.all([
const [chatRows, formRows, mcpRows, a2aRows, versionRows, allVersionRows] = await Promise.all([
db
.select({
id: chatTable.id,
@@ -808,6 +812,18 @@ export class WorkspaceVFS {
)
.limit(1)
: Promise.resolve([]),
db
.select({
id: workflowDeploymentVersion.id,
version: workflowDeploymentVersion.version,
name: workflowDeploymentVersion.name,
description: workflowDeploymentVersion.description,
isActive: workflowDeploymentVersion.isActive,
createdAt: workflowDeploymentVersion.createdAt,
})
.from(workflowDeploymentVersion)
.where(eq(workflowDeploymentVersion.workflowId, workflowId))
.orderBy(desc(workflowDeploymentVersion.version)),
])
const hasAnyDeployment =
@@ -816,7 +832,7 @@ export class WorkspaceVFS {
formRows.length > 0 ||
mcpRows.length > 0 ||
a2aRows.length > 0
if (!hasAnyDeployment) return null
if (!hasAnyDeployment && allVersionRows.length === 0) return null
let needsRedeployment: boolean | undefined
const deployedVersion = versionRows[0]
@@ -849,6 +865,7 @@ export class WorkspaceVFS {
form: formRows[0] ?? null,
mcp: mcpRows,
a2a: a2aRows[0] ?? null,
versions: allVersionRows,
}
}
@@ -1159,6 +1176,7 @@ export class WorkspaceVFS {
...oauthCredentials.map((c) => ({
id: c.id,
providerId: c.providerId,
displayName: c.displayName,
scope: null,
createdAt: c.updatedAt,
})),

View File

@@ -29,6 +29,8 @@ export interface CopilotToolCall {
display?: ClientToolDisplay
/** UI metadata from the copilot SSE event (used as fallback for unregistered tools) */
serverUI?: ServerToolUI
/** Persisted tool call result for rendering resources on chat reload */
result?: { success: boolean; output?: unknown; error?: string }
/** Tool should be executed client-side (set by Go backend via SSE) */
clientExecutable?: boolean
/** Content streamed from a subagent (e.g., debug agent) */

View File

@@ -1,6 +1,5 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"workspaces": {
"": {
"name": "simstudio",