mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
Merge branch 'feat/mothership-copilot' of github.com:simstudioai/sim into feat/mothership-copilot
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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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,
|
||||
})),
|
||||
|
||||
@@ -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) */
|
||||
|
||||
Reference in New Issue
Block a user