feat(copilot): context (#1157)

* Copilot updates

* Set/get vars

* Credentials opener v1

* Progress

* Checkpoint?

* Context v1

* Workflow references

* Add knowledge base context

* Blocks

* Templates

* Much better pills

* workflow updates

* Major ui

* Workflow box colors

* Much i mproved ui

* Improvements

* Much better

* Add @ icon

* Welcome page

* Update tool names

* Matches

* UPdate ordering

* Good sort

* Good @ handling

* Update placeholder

* Updates

* Lint

* Almost there

* Wrapped up?

* Lint

* Builid error fix

* Build fix?

* Lint

* Fix load vars
This commit is contained in:
Siddharth Ganesan
2025-08-27 21:07:51 -07:00
committed by GitHub
parent fed4e507cc
commit 06e9a6b302
23 changed files with 3358 additions and 93 deletions

View File

@@ -39,7 +39,7 @@ const ChatMessageSchema = z.object({
chatId: z.string().optional(),
workflowId: z.string().min(1, 'Workflow ID is required'),
mode: z.enum(['ask', 'agent']).optional().default('agent'),
depth: z.number().int().min(-2).max(3).optional().default(0),
depth: z.number().int().min(0).max(3).optional().default(0),
prefetch: z.boolean().optional(),
createNewChat: z.boolean().optional().default(false),
stream: z.boolean().optional().default(true),
@@ -47,6 +47,19 @@ const ChatMessageSchema = z.object({
fileAttachments: z.array(FileAttachmentSchema).optional(),
provider: z.string().optional().default('openai'),
conversationId: z.string().optional(),
contexts: z
.array(
z.object({
kind: z.enum(['past_chat', 'workflow', 'blocks', 'logs', 'knowledge', 'templates']),
label: z.string(),
chatId: z.string().optional(),
workflowId: z.string().optional(),
knowledgeId: z.string().optional(),
blockId: z.string().optional(),
templateId: z.string().optional(),
})
)
.optional(),
})
/**
@@ -81,7 +94,38 @@ export async function POST(req: NextRequest) {
fileAttachments,
provider,
conversationId,
contexts,
} = ChatMessageSchema.parse(body)
try {
logger.info(`[${tracker.requestId}] Received chat POST`, {
hasContexts: Array.isArray(contexts),
contextsCount: Array.isArray(contexts) ? contexts.length : 0,
contextsPreview: Array.isArray(contexts)
? contexts.map((c: any) => ({
kind: c?.kind,
chatId: c?.chatId,
workflowId: c?.workflowId,
label: c?.label,
}))
: undefined,
})
} catch {}
// Preprocess contexts server-side
let agentContexts: Array<{ type: string; content: string }> = []
if (Array.isArray(contexts) && contexts.length > 0) {
try {
const { processContextsServer } = await import('@/lib/copilot/process-contents')
const processed = await processContextsServer(contexts as any, authenticatedUserId)
agentContexts = processed
logger.info(`[${tracker.requestId}] Contexts processed for request`, {
processedCount: agentContexts.length,
kinds: agentContexts.map((c) => c.type),
lengthPreview: agentContexts.map((c) => c.content?.length ?? 0),
})
} catch (e) {
logger.error(`[${tracker.requestId}] Failed to process contexts`, e)
}
}
// Consolidation mapping: map negative depths to base depth with prefetch=true
let effectiveDepth: number | undefined = typeof depth === 'number' ? depth : undefined
@@ -312,8 +356,15 @@ export async function POST(req: NextRequest) {
...(typeof effectiveDepth === 'number' ? { depth: effectiveDepth } : {}),
...(typeof effectivePrefetch === 'boolean' ? { prefetch: effectivePrefetch } : {}),
...(session?.user?.name && { userName: session.user.name }),
...(agentContexts.length > 0 && { context: agentContexts }),
}
try {
logger.info(`[${tracker.requestId}] About to call Sim Agent with context`, {
context: (requestPayload as any).context,
})
} catch {}
const simAgentResponse = await fetch(`${SIM_AGENT_API_URL}/api/chat-completion-streaming`, {
method: 'POST',
headers: {
@@ -350,6 +401,11 @@ export async function POST(req: NextRequest) {
content: message,
timestamp: new Date().toISOString(),
...(fileAttachments && fileAttachments.length > 0 && { fileAttachments }),
...(Array.isArray(contexts) && contexts.length > 0 && { contexts }),
...(Array.isArray(contexts) &&
contexts.length > 0 && {
contentBlocks: [{ type: 'contexts', contexts: contexts as any, timestamp: Date.now() }],
}),
}
// Create a pass-through stream that captures the response
@@ -683,6 +739,11 @@ export async function POST(req: NextRequest) {
content: message,
timestamp: new Date().toISOString(),
...(fileAttachments && fileAttachments.length > 0 && { fileAttachments }),
...(Array.isArray(contexts) && contexts.length > 0 && { contexts }),
...(Array.isArray(contexts) &&
contexts.length > 0 && {
contentBlocks: [{ type: 'contexts', contexts: contexts as any, timestamp: Date.now() }],
}),
}
const assistantMessage = {

View File

@@ -0,0 +1,39 @@
import { desc, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import {
authenticateCopilotRequestSessionOnly,
createInternalServerErrorResponse,
createUnauthorizedResponse,
} from '@/lib/copilot/auth'
import { createLogger } from '@/lib/logs/console/logger'
import { db } from '@/db'
import { copilotChats } from '@/db/schema'
const logger = createLogger('CopilotChatsListAPI')
export async function GET(_req: NextRequest) {
try {
const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly()
if (!isAuthenticated || !userId) {
return createUnauthorizedResponse()
}
const chats = await db
.select({
id: copilotChats.id,
title: copilotChats.title,
workflowId: copilotChats.workflowId,
updatedAt: copilotChats.updatedAt,
})
.from(copilotChats)
.where(eq(copilotChats.userId, userId))
.orderBy(desc(copilotChats.updatedAt))
logger.info(`Retrieved ${chats.length} chats for user ${userId}`)
return NextResponse.json({ success: true, chats })
} catch (error) {
logger.error('Error fetching user copilot chats:', error)
return createInternalServerErrorResponse('Failed to fetch user chats')
}
}

View File

@@ -1,7 +1,21 @@
'use client'
import { type FC, memo, useEffect, useMemo, useState } from 'react'
import { Check, Clipboard, Loader2, RotateCcw, ThumbsDown, ThumbsUp, X } from 'lucide-react'
import {
Blocks,
Bot,
Check,
Clipboard,
Info,
LibraryBig,
Loader2,
RotateCcw,
Shapes,
ThumbsDown,
ThumbsUp,
Workflow,
X,
} from 'lucide-react'
import { InlineToolCall } from '@/lib/copilot/inline-tool-call'
import { createLogger } from '@/lib/logs/console/logger'
import {
@@ -31,6 +45,7 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
const [showUpvoteSuccess, setShowUpvoteSuccess] = useState(false)
const [showDownvoteSuccess, setShowDownvoteSuccess] = useState(false)
const [showRestoreConfirmation, setShowRestoreConfirmation] = useState(false)
const [showAllContexts, setShowAllContexts] = useState(false)
// Get checkpoint functionality from copilot store
const {
@@ -357,6 +372,78 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
</div>
)}
{/* Context chips displayed above the message bubble, independent of inline text */}
{(Array.isArray((message as any).contexts) && (message as any).contexts.length > 0) ||
(Array.isArray(message.contentBlocks) &&
(message.contentBlocks as any[]).some((b: any) => b?.type === 'contexts')) ? (
<div className='flex items-center justify-end gap-0'>
<div className='min-w-0 max-w-[80%]'>
<div className='mb-1 flex flex-wrap justify-end gap-1.5'>
{(() => {
const direct = Array.isArray((message as any).contexts)
? ((message as any).contexts as any[])
: []
const block = Array.isArray(message.contentBlocks)
? (message.contentBlocks as any[]).find((b: any) => b?.type === 'contexts')
: null
const fromBlock = Array.isArray((block as any)?.contexts)
? ((block as any).contexts as any[])
: []
const allContexts = direct.length > 0 ? direct : fromBlock
const MAX_VISIBLE = 4
const visible = showAllContexts
? allContexts
: allContexts.slice(0, MAX_VISIBLE)
return (
<>
{visible.map((ctx: any, idx: number) => (
<span
key={`ctx-${idx}-${ctx?.label || ctx?.kind}`}
className='inline-flex items-center gap-1 rounded-full bg-[color-mix(in_srgb,var(--brand-primary-hover-hex)_14%,transparent)] px-1.5 py-0.5 text-[11px] text-foreground'
title={ctx?.label || ctx?.kind}
>
{ctx?.kind === 'past_chat' ? (
<Bot className='h-3 w-3 text-muted-foreground' />
) : ctx?.kind === 'workflow' ? (
<Workflow className='h-3 w-3 text-muted-foreground' />
) : ctx?.kind === 'blocks' ? (
<Blocks className='h-3 w-3 text-muted-foreground' />
) : ctx?.kind === 'knowledge' ? (
<LibraryBig className='h-3 w-3 text-muted-foreground' />
) : ctx?.kind === 'templates' ? (
<Shapes className='h-3 w-3 text-muted-foreground' />
) : (
<Info className='h-3 w-3 text-muted-foreground' />
)}
<span className='max-w-[140px] truncate'>
{ctx?.label || ctx?.kind}
</span>
</span>
))}
{allContexts.length > MAX_VISIBLE && (
<button
type='button'
onClick={() => setShowAllContexts((v) => !v)}
className='inline-flex items-center gap-1 rounded-full bg-[color-mix(in_srgb,var(--brand-primary-hover-hex)_10%,transparent)] px-1.5 py-0.5 text-[11px] text-foreground hover:bg-[color-mix(in_srgb,var(--brand-primary-hover-hex)_14%,transparent)]'
title={
showAllContexts
? 'Show less'
: `Show ${allContexts.length - MAX_VISIBLE} more`
}
>
{showAllContexts
? 'Show less'
: `+${allContexts.length - MAX_VISIBLE} more`}
</button>
)}
</>
)
})()}
</div>
</div>
</div>
) : null}
<div className='flex items-center justify-end gap-0'>
{hasCheckpoints && (
<div className='mr-1 inline-flex items-center justify-center'>
@@ -408,7 +495,39 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
}}
>
<div className='whitespace-pre-wrap break-words font-normal text-base text-foreground leading-relaxed'>
<WordWrap text={message.content} />
{(() => {
const text = message.content || ''
const contexts: any[] = Array.isArray((message as any).contexts)
? ((message as any).contexts as any[])
: []
const labels = contexts.map((c) => c?.label).filter(Boolean) as string[]
if (!labels.length) return <WordWrap text={text} />
const escapeRegex = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const pattern = new RegExp(`@(${labels.map(escapeRegex).join('|')})`, 'g')
const nodes: React.ReactNode[] = []
let lastIndex = 0
let match: RegExpExecArray | null
while ((match = pattern.exec(text)) !== null) {
const i = match.index
const before = text.slice(lastIndex, i)
if (before) nodes.push(before)
const mention = match[0]
nodes.push(
<span
key={`mention-${i}-${lastIndex}`}
className='rounded-[6px] bg-[color-mix(in_srgb,var(--brand-primary-hover-hex)_14%,transparent)] px-1'
>
{mention}
</span>
)
lastIndex = i + mention.length
}
const tail = text.slice(lastIndex)
if (tail) nodes.push(tail)
return nodes
})()}
</div>
</div>
</div>

View File

@@ -1,6 +1,6 @@
'use client'
import { Bot } from 'lucide-react'
import { Blocks, Bot, LibraryBig, Workflow } from 'lucide-react'
interface CopilotWelcomeProps {
onQuestionClick?: (question: string) => void
@@ -8,49 +8,90 @@ interface CopilotWelcomeProps {
}
export function CopilotWelcome({ onQuestionClick, mode = 'ask' }: CopilotWelcomeProps) {
const askQuestions = [
'How do I create a workflow?',
'What tools are available?',
'What does my workflow do?',
]
const agentQuestions = [
'Help me build a workflow',
'Help me optimize my workflow',
'Help me debug my workflow',
]
const exampleQuestions = mode === 'ask' ? askQuestions : agentQuestions
const handleQuestionClick = (question: string) => {
onQuestionClick?.(question)
}
const subtitle =
mode === 'ask'
? 'Ask about workflows, tools, or how to get started'
: 'Build, edit, and optimize workflows'
const capabilities =
mode === 'agent'
? [
{
title: 'Build & edit workflows',
question: 'Help me build a workflow',
Icon: Workflow,
},
{
title: 'Optimize workflows',
question: 'Help me optimize my workflow',
Icon: Blocks,
},
{
title: 'Debug workflows',
question: 'Help me debug my workflow',
Icon: LibraryBig,
},
]
: [
{
title: 'Understand my workflow',
question: 'What does my workflow do?',
Icon: Workflow,
},
{
title: 'Discover tools',
question: 'What tools are available?',
Icon: Blocks,
},
{
title: 'Get started',
question: 'How do I create a workflow?',
Icon: LibraryBig,
},
]
return (
<div className='flex h-full flex-col items-center justify-center px-4 py-10'>
<div className='space-y-6 text-center'>
<Bot className='mx-auto h-12 w-12 text-muted-foreground' />
<div className='space-y-2'>
<h3 className='font-medium text-lg'>How can I help you today?</h3>
<p className='text-muted-foreground text-sm'>
{mode === 'ask'
? 'Ask me anything about your workflows, available tools, or how to get started.'
: 'I can help you build, edit, and optimize workflows. What would you like to do?'}
</p>
<div className='relative h-full w-full overflow-hidden px-4 pt-8 pb-6'>
<div className='relative mx-auto w-full max-w-xl'>
{/* Header */}
<div className='flex flex-col items-center text-center'>
<Bot className='h-12 w-12 text-[var(--brand-primary-hover-hex)]' strokeWidth={1.5} />
<h3 className='mt-2 font-medium text-foreground text-lg sm:text-xl'>{subtitle}</h3>
</div>
<div className='mx-auto max-w-sm space-y-3'>
<div className='font-medium text-muted-foreground text-xs'>Try asking:</div>
<div className='flex flex-wrap justify-center gap-2'>
{exampleQuestions.map((question, index) => (
<button
key={index}
className='inline-flex cursor-pointer items-center rounded-full bg-muted/60 px-3 py-1.5 font-medium text-muted-foreground text-xs transition-all hover:scale-105 hover:bg-muted hover:text-foreground active:scale-95'
onClick={() => handleQuestionClick(question)}
>
{question}
</button>
))}
</div>
{/* Unified capability cards */}
<div className='mt-7 space-y-2.5'>
{capabilities.map(({ title, question, Icon }, idx) => (
<button
key={idx}
type='button'
onClick={() => handleQuestionClick(question)}
className='w-full rounded-[10px] border bg-background/60 p-3 text-left transition-colors hover:bg-muted focus:outline-none focus:ring-2 focus:ring-[var(--brand-primary-hover-hex)]/30'
>
<div className='flex items-start gap-2'>
<div className='mt-0.5 flex h-6 w-6 items-center justify-center rounded bg-[color-mix(in_srgb,var(--brand-primary-hover-hex)_16%,transparent)] text-[var(--brand-primary-hover-hex)]'>
<Icon className='h-3.5 w-3.5' />
</div>
<div>
<div className='font-medium text-xs'>{title}</div>
<p className='mt-1 text-[11px] text-muted-foreground'>{question}</p>
</div>
</div>
</button>
))}
</div>
{/* Tips */}
<div className='mt-6 text-center text-[11px] text-muted-foreground'>
<p>
Tip: Use <span className='font-medium text-foreground'>@</span> to reference chats,
workflows, knowledge, blocks, or templates
</p>
<p className='mt-1.5'>Shift+Enter for newline</p>
</div>
</div>
</div>

View File

@@ -346,7 +346,7 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
// Handle message submission
const handleSubmit = useCallback(
async (query: string, fileAttachments?: MessageFileAttachment[]) => {
async (query: string, fileAttachments?: MessageFileAttachment[], contexts?: any[]) => {
if (!query || isSendingMessage || !activeWorkflowId) return
// Clear todos when sending a new message
@@ -356,7 +356,7 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
}
try {
await sendMessage(query, { stream: true, fileAttachments })
await sendMessage(query, { stream: true, fileAttachments, contexts })
logger.info(
'Sent message:',
query,

View File

@@ -16,6 +16,7 @@ const logger = createLogger('Credentials')
interface CredentialsProps {
onOpenChange?: (open: boolean) => void
registerCloseHandler?: (handler: (open: boolean) => void) => void
}
interface ServiceInfo extends OAuthServiceConfig {
@@ -24,7 +25,7 @@ interface ServiceInfo extends OAuthServiceConfig {
accounts?: { id: string; name: string }[]
}
export function Credentials({ onOpenChange }: CredentialsProps) {
export function Credentials({ onOpenChange, registerCloseHandler }: CredentialsProps) {
const router = useRouter()
const searchParams = useSearchParams()
const { data: session } = useSession()
@@ -39,6 +40,8 @@ export function Credentials({ onOpenChange }: CredentialsProps) {
const [_pendingScopes, setPendingScopes] = useState<string[]>([])
const [authSuccess, setAuthSuccess] = useState(false)
const [showActionRequired, setShowActionRequired] = useState(false)
const prevConnectedIdsRef = useRef<Set<string>>(new Set())
const connectionAddedRef = useRef<boolean>(false)
// Define available services from our standardized OAuth providers
const defineServices = (): ServiceInfo[] => {
@@ -186,6 +189,43 @@ export function Credentials({ onOpenChange }: CredentialsProps) {
}
}, [userId])
// Track when a new connection is added compared to previous render
useEffect(() => {
try {
const currentConnected = new Set<string>()
services.forEach((svc) => {
if (svc.isConnected) currentConnected.add(svc.id)
})
// Detect new connections by comparing to previous connected set
for (const id of currentConnected) {
if (!prevConnectedIdsRef.current.has(id)) {
connectionAddedRef.current = true
break
}
}
prevConnectedIdsRef.current = currentConnected
} catch {}
}, [services])
// On mount, register a close handler so the parent modal can delegate close events here
useEffect(() => {
if (!registerCloseHandler) return
const handle = (open: boolean) => {
if (open) return
try {
if (typeof window !== 'undefined') {
window.dispatchEvent(
new CustomEvent('oauth-integration-closed', {
detail: { success: connectionAddedRef.current === true },
})
)
}
} catch {}
onOpenChange?.(open)
}
registerCloseHandler(handle)
}, [registerCloseHandler, onOpenChange])
// Handle connect button click
const handleConnect = async (service: ServiceInfo) => {
try {

View File

@@ -48,6 +48,7 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
const { activeOrganization } = useOrganizationStore()
const hasLoadedInitialData = useRef(false)
const environmentCloseHandler = useRef<((open: boolean) => void) | null>(null)
const credentialsCloseHandler = useRef<((open: boolean) => void) | null>(null)
useEffect(() => {
async function loadAllSettings() {
@@ -100,6 +101,8 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
const handleDialogOpenChange = (newOpen: boolean) => {
if (!newOpen && activeSection === 'environment' && environmentCloseHandler.current) {
environmentCloseHandler.current(newOpen)
} else if (!newOpen && activeSection === 'credentials' && credentialsCloseHandler.current) {
credentialsCloseHandler.current(newOpen)
} else {
onOpenChange(newOpen)
}
@@ -139,7 +142,12 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
<Account onOpenChange={onOpenChange} />
</div>
<div className={cn('h-full', activeSection === 'credentials' ? 'block' : 'hidden')}>
<Credentials onOpenChange={onOpenChange} />
<Credentials
onOpenChange={onOpenChange}
registerCloseHandler={(handler) => {
credentialsCloseHandler.current = handler
}}
/>
</div>
<div className={cn('h-full', activeSection === 'apikeys' ? 'block' : 'hidden')}>
<ApiKeys onOpenChange={onOpenChange} />

View File

@@ -76,14 +76,140 @@ export function ToolCallExecution({ toolCall, isCompact = false }: ToolCallProps
{toolCall.parameters &&
Object.keys(toolCall.parameters).length > 0 &&
(toolCall.name === 'make_api_request' ||
toolCall.name === 'set_environment_variables') && (
<div className='min-w-0 max-w-full rounded bg-amber-100 p-2 dark:bg-amber-900'>
<div className='mb-1 font-medium text-amber-800 text-xs dark:text-amber-200'>
Parameters:
</div>
<div className='min-w-0 max-w-full break-all font-mono text-amber-700 text-xs dark:text-amber-300'>
{JSON.stringify(toolCall.parameters, null, 2)}
</div>
toolCall.name === 'set_environment_variables' ||
toolCall.name === 'set_global_workflow_variables') && (
<div className='min-w-0 max-w-full rounded border border-amber-200 bg-amber-50 p-2 dark:border-amber-800 dark:bg-amber-950'>
{toolCall.name === 'make_api_request' ? (
<div className='w-full overflow-hidden rounded border border-muted bg-card'>
<div className='grid grid-cols-2 gap-0 border-muted/60 border-b bg-muted/40 px-2 py-1.5'>
<div className='font-medium text-[10px] text-muted-foreground uppercase tracking-wide'>
Method
</div>
<div className='font-medium text-[10px] text-muted-foreground uppercase tracking-wide'>
Endpoint
</div>
</div>
<div className='grid grid-cols-[auto_1fr] items-center gap-2 px-2 py-2'>
<div>
<span className='inline-flex rounded bg-muted px-1.5 py-0.5 font-mono text-muted-foreground text-xs'>
{String((toolCall.parameters as any).method || '').toUpperCase() ||
'GET'}
</span>
</div>
<div className='min-w-0'>
<span
className='block overflow-x-auto whitespace-nowrap font-mono text-foreground text-xs'
title={String((toolCall.parameters as any).url || '')}
>
{String((toolCall.parameters as any).url || '') || 'URL not provided'}
</span>
</div>
</div>
</div>
) : null}
{toolCall.name === 'set_environment_variables'
? (() => {
const variables =
(toolCall.parameters as any).variables &&
typeof (toolCall.parameters as any).variables === 'object'
? (toolCall.parameters as any).variables
: {}
const entries = Object.entries(variables)
return (
<div className='w-full overflow-hidden rounded border border-amber-200 bg-amber-50 dark:border-amber-800 dark:bg-amber-950'>
<div className='grid grid-cols-2 gap-0 border-amber-200/60 border-b px-2 py-1.5 dark:border-amber-800/60'>
<div className='font-medium text-[10px] text-amber-700 uppercase tracking-wide dark:text-amber-300'>
Name
</div>
<div className='font-medium text-[10px] text-amber-700 uppercase tracking-wide dark:text-amber-300'>
Value
</div>
</div>
{entries.length === 0 ? (
<div className='px-2 py-2 text-muted-foreground text-xs'>
No variables provided
</div>
) : (
<div className='divide-y divide-amber-200 dark:divide-amber-800'>
{entries.map(([k, v]) => (
<div
key={k}
className='grid grid-cols-[auto_1fr] items-center gap-2 px-2 py-1.5'
>
<div className='truncate font-medium text-amber-800 text-xs dark:text-amber-200'>
{k}
</div>
<div className='min-w-0'>
<span className='block overflow-x-auto whitespace-nowrap font-mono text-amber-700 text-xs dark:text-amber-300'>
{String(v)}
</span>
</div>
</div>
))}
</div>
)}
</div>
)
})()
: null}
{toolCall.name === 'set_global_workflow_variables'
? (() => {
const ops = Array.isArray((toolCall.parameters as any).operations)
? ((toolCall.parameters as any).operations as any[])
: []
return (
<div className='w-full overflow-hidden rounded border border-amber-200 bg-amber-50 dark:border-amber-800 dark:bg-amber-950'>
<div className='grid grid-cols-3 gap-0 border-amber-200/60 border-b px-2 py-1.5 dark:border-amber-800/60'>
<div className='font-medium text-[10px] text-amber-700 uppercase tracking-wide dark:text-amber-300'>
Name
</div>
<div className='font-medium text-[10px] text-amber-700 uppercase tracking-wide dark:text-amber-300'>
Type
</div>
<div className='font-medium text-[10px] text-amber-700 uppercase tracking-wide dark:text-amber-300'>
Value
</div>
</div>
{ops.length === 0 ? (
<div className='px-2 py-2 text-muted-foreground text-xs'>
No operations provided
</div>
) : (
<div className='divide-y divide-amber-200 dark:divide-amber-800'>
{ops.map((op, idx) => (
<div
key={idx}
className='grid grid-cols-3 items-center gap-0 px-2 py-1.5'
>
<div className='min-w-0'>
<span className='truncate text-amber-800 text-xs dark:text-amber-200'>
{String(op.name || '')}
</span>
</div>
<div>
<span className='rounded border px-1 py-0.5 text-[10px] text-muted-foreground'>
{String(op.type || '')}
</span>
</div>
<div className='min-w-0'>
{op.value !== undefined ? (
<span className='block overflow-x-auto whitespace-nowrap font-mono text-amber-700 text-xs dark:text-amber-300'>
{String(op.value)}
</span>
) : (
<span className='text-muted-foreground text-xs'></span>
)}
</div>
</div>
))}
</div>
)}
</div>
)
})()
: null}
</div>
)}
</div>

View File

@@ -57,13 +57,14 @@ export interface SendMessageRequest {
chatId?: string
workflowId?: string
mode?: 'ask' | 'agent'
depth?: -2 | -1 | 0 | 1 | 2 | 3
depth?: 0 | 1 | 2 | 3
prefetch?: boolean
createNewChat?: boolean
stream?: boolean
implicitFeedback?: string
fileAttachments?: MessageFileAttachment[]
abortSignal?: AbortSignal
contexts?: Array<{ kind: string; label?: string; chatId?: string; workflowId?: string }>
}
/**
@@ -103,6 +104,23 @@ export async function sendStreamingMessage(
): Promise<StreamingResponse> {
try {
const { abortSignal, ...requestBody } = request
try {
const preview = Array.isArray((requestBody as any).contexts)
? (requestBody as any).contexts.map((c: any) => ({
kind: c?.kind,
chatId: c?.chatId,
workflowId: c?.workflowId,
label: c?.label,
}))
: undefined
logger.info('Preparing to send streaming message', {
hasContexts: Array.isArray((requestBody as any).contexts),
contextsCount: Array.isArray((requestBody as any).contexts)
? (requestBody as any).contexts.length
: 0,
contextsPreview: preview,
})
} catch {}
const response = await fetch('/api/copilot/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },

View File

@@ -255,11 +255,15 @@ export function InlineToolCall({
const isExpandablePending =
toolCall.state === 'pending' &&
(toolCall.name === 'make_api_request' || toolCall.name === 'set_environment_variables')
(toolCall.name === 'make_api_request' ||
toolCall.name === 'set_environment_variables' ||
toolCall.name === 'set_global_workflow_variables')
const [expanded, setExpanded] = useState(isExpandablePending)
const isExpandableTool =
toolCall.name === 'make_api_request' || toolCall.name === 'set_environment_variables'
toolCall.name === 'make_api_request' ||
toolCall.name === 'set_environment_variables' ||
toolCall.name === 'set_global_workflow_variables'
const showButtons = shouldShowRunSkipButtons(toolCall)
const showMoveToBackground =
@@ -291,11 +295,30 @@ export function InlineToolCall({
const url = params.url || ''
const method = (params.method || '').toUpperCase()
return (
<div className='mt-0.5 flex items-center gap-2'>
<span className='truncate text-foreground text-xs' title={url}>
{method ? `${method} ` : ''}
{url || 'URL not provided'}
</span>
<div className='mt-0.5 w-full overflow-hidden rounded border border-muted bg-card'>
<div className='grid grid-cols-2 gap-0 border-muted/60 border-b bg-muted/40 px-2 py-1.5'>
<div className='font-medium text-[10px] text-muted-foreground uppercase tracking-wide'>
Method
</div>
<div className='font-medium text-[10px] text-muted-foreground uppercase tracking-wide'>
Endpoint
</div>
</div>
<div className='grid grid-cols-[auto_1fr] items-center gap-2 px-2 py-2'>
<div>
<span className='inline-flex rounded bg-muted px-1.5 py-0.5 font-mono text-muted-foreground text-xs'>
{method || 'GET'}
</span>
</div>
<div className='min-w-0'>
<span
className='block overflow-x-auto whitespace-nowrap font-mono text-foreground text-xs'
title={url}
>
{url || 'URL not provided'}
</span>
</div>
</div>
</div>
)
}
@@ -305,16 +328,77 @@ export function InlineToolCall({
params.variables && typeof params.variables === 'object' ? params.variables : {}
const entries = Object.entries(variables)
return (
<div className='mt-0.5'>
<div className='mt-0.5 w-full overflow-hidden rounded border border-muted bg-card'>
<div className='grid grid-cols-2 gap-0 border-muted/60 border-b bg-muted/40 px-2 py-1.5'>
<div className='font-medium text-[10px] text-muted-foreground uppercase tracking-wide'>
Name
</div>
<div className='font-medium text-[10px] text-muted-foreground uppercase tracking-wide'>
Value
</div>
</div>
{entries.length === 0 ? (
<span className='text-muted-foreground text-xs'>No variables provided</span>
<div className='px-2 py-2 text-muted-foreground text-xs'>No variables provided</div>
) : (
<div className='space-y-0.5'>
<div className='divide-y divide-muted/60'>
{entries.map(([k, v]) => (
<div key={k} className='flex items-center gap-0.5'>
<span className='font-medium text-muted-foreground text-xs'>{k}</span>
<span className='mx-1 font-medium text-muted-foreground text-xs'>:</span>
<span className='truncate font-medium text-foreground text-xs'>{String(v)}</span>
<div key={k} className='grid grid-cols-[auto_1fr] items-center gap-2 px-2 py-1.5'>
<div className='truncate font-medium text-amber-800 text-xs dark:text-amber-200'>
{k}
</div>
<div className='min-w-0'>
<span className='block overflow-x-auto whitespace-nowrap font-mono text-amber-700 text-xs dark:text-amber-300'>
{String(v)}
</span>
</div>
</div>
))}
</div>
)}
</div>
)
}
if (toolCall.name === 'set_global_workflow_variables') {
const ops = Array.isArray(params.operations) ? (params.operations as any[]) : []
return (
<div className='mt-0.5 w-full overflow-hidden rounded border border-muted bg-card'>
<div className='grid grid-cols-3 gap-0 border-muted/60 border-b bg-muted/40 px-2 py-1.5'>
<div className='font-medium text-[10px] text-muted-foreground uppercase tracking-wide'>
Name
</div>
<div className='font-medium text-[10px] text-muted-foreground uppercase tracking-wide'>
Type
</div>
<div className='font-medium text-[10px] text-muted-foreground uppercase tracking-wide'>
Value
</div>
</div>
{ops.length === 0 ? (
<div className='px-2 py-2 text-muted-foreground text-xs'>No operations provided</div>
) : (
<div className='divide-y divide-amber-200 dark:divide-amber-800'>
{ops.map((op, idx) => (
<div key={idx} className='grid grid-cols-3 items-center gap-0 px-2 py-1.5'>
<div className='min-w-0'>
<span className='truncate text-amber-800 text-xs dark:text-amber-200'>
{String(op.name || '')}
</span>
</div>
<div>
<span className='rounded border px-1 py-0.5 text-[10px] text-muted-foreground'>
{String(op.type || '')}
</span>
</div>
<div className='min-w-0'>
{op.value !== undefined ? (
<span className='block overflow-x-auto whitespace-nowrap font-mono text-amber-700 text-xs dark:text-amber-300'>
{String(op.value)}
</span>
) : (
<span className='text-muted-foreground text-xs'></span>
)}
</div>
</div>
))}
</div>

View File

@@ -0,0 +1,358 @@
import { and, eq, isNull } from 'drizzle-orm'
import { createLogger } from '@/lib/logs/console/logger'
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/db-helpers'
import { db } from '@/db'
import { copilotChats, document, knowledgeBase, templates } from '@/db/schema'
import type { ChatContext } from '@/stores/copilot/types'
export type AgentContextType =
| 'past_chat'
| 'workflow'
| 'blocks'
| 'logs'
| 'knowledge'
| 'templates'
export interface AgentContext {
type: AgentContextType
tag: string
content: string
}
const logger = createLogger('ProcessContents')
export async function processContexts(
contexts: ChatContext[] | undefined
): Promise<AgentContext[]> {
if (!Array.isArray(contexts) || contexts.length === 0) return []
const tasks = contexts.map(async (ctx) => {
try {
if (ctx.kind === 'past_chat') {
return await processPastChatViaApi(ctx.chatId, ctx.label ? `@${ctx.label}` : '@')
}
if (ctx.kind === 'workflow' && ctx.workflowId) {
return await processWorkflowFromDb(ctx.workflowId, ctx.label ? `@${ctx.label}` : '@')
}
if (ctx.kind === 'knowledge' && (ctx as any).knowledgeId) {
return await processKnowledgeFromDb(
(ctx as any).knowledgeId,
ctx.label ? `@${ctx.label}` : '@'
)
}
if (ctx.kind === 'blocks' && (ctx as any).blockId) {
return await processBlockMetadata((ctx as any).blockId, ctx.label ? `@${ctx.label}` : '@')
}
if (ctx.kind === 'templates' && (ctx as any).templateId) {
return await processTemplateFromDb(
(ctx as any).templateId,
ctx.label ? `@${ctx.label}` : '@'
)
}
// Other kinds can be added here: workflow, blocks, logs, knowledge, templates
return null
} catch (error) {
logger.error('Failed processing context', { ctx, error })
return null
}
})
const results = await Promise.all(tasks)
return results.filter((r): r is AgentContext => !!r)
}
// Server-side variant (recommended for use in API routes)
export async function processContextsServer(
contexts: ChatContext[] | undefined,
userId: string
): Promise<AgentContext[]> {
if (!Array.isArray(contexts) || contexts.length === 0) return []
const tasks = contexts.map(async (ctx) => {
try {
if (ctx.kind === 'past_chat' && ctx.chatId) {
return await processPastChatFromDb(ctx.chatId, userId, ctx.label ? `@${ctx.label}` : '@')
}
if (ctx.kind === 'workflow' && ctx.workflowId) {
return await processWorkflowFromDb(ctx.workflowId, ctx.label ? `@${ctx.label}` : '@')
}
if (ctx.kind === 'knowledge' && (ctx as any).knowledgeId) {
return await processKnowledgeFromDb(
(ctx as any).knowledgeId,
ctx.label ? `@${ctx.label}` : '@'
)
}
if (ctx.kind === 'blocks' && (ctx as any).blockId) {
return await processBlockMetadata((ctx as any).blockId, ctx.label ? `@${ctx.label}` : '@')
}
if (ctx.kind === 'templates' && (ctx as any).templateId) {
return await processTemplateFromDb(
(ctx as any).templateId,
ctx.label ? `@${ctx.label}` : '@'
)
}
return null
} catch (error) {
logger.error('Failed processing context (server)', { ctx, error })
return null
}
})
const results = await Promise.all(tasks)
const filtered = results.filter(
(r): r is AgentContext => !!r && typeof r.content === 'string' && r.content.trim().length > 0
)
logger.info('Processed contexts (server)', {
totalRequested: contexts.length,
totalProcessed: filtered.length,
kinds: Array.from(filtered.reduce((s, r) => s.add(r.type), new Set<string>())),
})
return filtered
}
async function processPastChatFromDb(
chatId: string,
userId: string,
tag: string
): Promise<AgentContext | null> {
try {
const rows = await db
.select({ messages: copilotChats.messages })
.from(copilotChats)
.where(and(eq(copilotChats.id, chatId), eq(copilotChats.userId, userId)))
.limit(1)
const messages = Array.isArray(rows?.[0]?.messages) ? (rows[0] as any).messages : []
const content = messages
.map((m: any) => {
const role = m.role || 'user'
let text = ''
if (Array.isArray(m.contentBlocks) && m.contentBlocks.length > 0) {
text = m.contentBlocks
.filter((b: any) => b?.type === 'text')
.map((b: any) => String(b.content || ''))
.join('')
.trim()
}
if (!text && typeof m.content === 'string') text = m.content
return `${role}: ${text}`.trim()
})
.filter((s: string) => s.length > 0)
.join('\n')
logger.info('Processed past_chat context from DB', {
chatId,
length: content.length,
lines: content ? content.split('\n').length : 0,
})
return { type: 'past_chat', tag, content }
} catch (error) {
logger.error('Error processing past chat from db', { chatId, error })
return null
}
}
async function processWorkflowFromDb(
workflowId: string,
tag: string
): Promise<AgentContext | null> {
try {
const normalized = await loadWorkflowFromNormalizedTables(workflowId)
if (!normalized) {
logger.warn('No normalized workflow data found', { workflowId })
return null
}
const workflowState = {
blocks: normalized.blocks || {},
edges: normalized.edges || [],
loops: normalized.loops || {},
parallels: normalized.parallels || {},
}
// Match get-user-workflow format: just the workflow state JSON
const content = JSON.stringify(workflowState, null, 2)
logger.info('Processed workflow context', {
workflowId,
blocks: Object.keys(workflowState.blocks || {}).length,
edges: workflowState.edges.length,
})
return { type: 'workflow', tag, content }
} catch (error) {
logger.error('Error processing workflow context', { workflowId, error })
return null
}
}
async function processPastChat(chatId: string, tagOverride?: string): Promise<AgentContext | null> {
try {
const resp = await fetch(`/api/copilot/chat/${encodeURIComponent(chatId)}`)
if (!resp.ok) {
logger.error('Failed to fetch past chat', { chatId, status: resp.status })
return null
}
const data = await resp.json()
const messages = Array.isArray(data?.chat?.messages) ? data.chat.messages : []
const content = messages
.map((m: any) => {
const role = m.role || 'user'
// Prefer contentBlocks text if present (joins text blocks), else use content
let text = ''
if (Array.isArray(m.contentBlocks) && m.contentBlocks.length > 0) {
text = m.contentBlocks
.filter((b: any) => b?.type === 'text')
.map((b: any) => String(b.content || ''))
.join('')
.trim()
}
if (!text && typeof m.content === 'string') text = m.content
return `${role}: ${text}`.trim()
})
.filter((s: string) => s.length > 0)
.join('\n')
logger.info('Processed past_chat context via API', { chatId, length: content.length })
return { type: 'past_chat', tag: tagOverride || '@', content }
} catch (error) {
logger.error('Error processing past chat', { chatId, error })
return null
}
}
// Back-compat alias; used by processContexts above
async function processPastChatViaApi(chatId: string, tag?: string) {
return processPastChat(chatId, tag)
}
async function processKnowledgeFromDb(
knowledgeBaseId: string,
tag: string
): Promise<AgentContext | null> {
try {
// Load KB metadata
const kbRows = await db
.select({
id: knowledgeBase.id,
name: knowledgeBase.name,
updatedAt: knowledgeBase.updatedAt,
})
.from(knowledgeBase)
.where(and(eq(knowledgeBase.id, knowledgeBaseId), isNull(knowledgeBase.deletedAt)))
.limit(1)
const kb = kbRows?.[0]
if (!kb) return null
// Load up to 20 recent doc filenames
const docRows = await db
.select({ filename: document.filename })
.from(document)
.where(and(eq(document.knowledgeBaseId, knowledgeBaseId), isNull(document.deletedAt)))
.limit(20)
const sampleDocuments = docRows.map((d: any) => d.filename).filter(Boolean)
// We don't have total via this quick select; fallback to sample count
const summary = {
id: kb.id,
name: kb.name,
docCount: sampleDocuments.length,
sampleDocuments,
}
const content = JSON.stringify(summary)
return { type: 'knowledge', tag, content }
} catch (error) {
logger.error('Error processing knowledge context (db)', { knowledgeBaseId, error })
return null
}
}
async function processBlockMetadata(blockId: string, tag: string): Promise<AgentContext | null> {
try {
// Reuse registry to match get_blocks_metadata tool result
const { registry: blockRegistry } = await import('@/blocks/registry')
const { tools: toolsRegistry } = await import('@/tools/registry')
const SPECIAL_BLOCKS_METADATA: Record<string, any> = {}
let metadata: any = {}
if ((SPECIAL_BLOCKS_METADATA as any)[blockId]) {
metadata = { ...(SPECIAL_BLOCKS_METADATA as any)[blockId] }
metadata.tools = metadata.tools?.access || []
} else {
const blockConfig: any = (blockRegistry as any)[blockId]
if (!blockConfig) {
return null
}
metadata = {
id: blockId,
name: blockConfig.name || blockId,
description: blockConfig.description || '',
longDescription: blockConfig.longDescription,
category: blockConfig.category,
bgColor: blockConfig.bgColor,
inputs: blockConfig.inputs || {},
outputs: blockConfig.outputs || {},
tools: blockConfig.tools?.access || [],
hideFromToolbar: blockConfig.hideFromToolbar,
}
if (blockConfig.subBlocks && Array.isArray(blockConfig.subBlocks)) {
metadata.subBlocks = (blockConfig.subBlocks as any[]).map((sb: any) => ({
id: sb.id,
name: sb.name,
type: sb.type,
description: sb.description,
default: sb.default,
options: Array.isArray(sb.options) ? sb.options : [],
}))
} else {
metadata.subBlocks = []
}
}
if (Array.isArray(metadata.tools) && metadata.tools.length > 0) {
metadata.toolDetails = {}
for (const toolId of metadata.tools) {
const tool = (toolsRegistry as any)[toolId]
if (tool) {
metadata.toolDetails[toolId] = { name: tool.name, description: tool.description }
}
}
}
const content = JSON.stringify({ metadata })
return { type: 'blocks', tag, content }
} catch (error) {
logger.error('Error processing block metadata', { blockId, error })
return null
}
}
async function processTemplateFromDb(
templateId: string,
tag: string
): Promise<AgentContext | null> {
try {
const rows = await db
.select({
id: templates.id,
name: templates.name,
description: templates.description,
category: templates.category,
author: templates.author,
stars: templates.stars,
state: templates.state,
})
.from(templates)
.where(eq(templates.id, templateId))
.limit(1)
const t = rows?.[0]
if (!t) return null
const workflowState = (t as any).state || {}
// Match get-user-workflow format: just the workflow state JSON
const summary = {
id: t.id,
name: t.name,
description: t.description || '',
category: t.category,
author: t.author,
stars: t.stars || 0,
workflow: workflowState,
}
const content = JSON.stringify(summary)
return { type: 'templates', tag, content }
} catch (error) {
logger.error('Error processing template context (db)', { templateId, error })
return null
}
}

View File

@@ -22,6 +22,14 @@ export const ToolIds = z.enum([
'list_gdrive_files',
'read_gdrive_file',
'reason',
// New tools
'list_user_workflows',
'get_workflow_from_name',
// New variable tools
'get_global_workflow_variables',
'set_global_workflow_variables',
// New
'oauth_request_access',
])
export type ToolId = z.infer<typeof ToolIds>
@@ -45,6 +53,23 @@ const NumberOptional = z.number().optional()
// Tool argument schemas (per SSE examples provided)
export const ToolArgSchemas = {
get_user_workflow: z.object({}),
// New tools
list_user_workflows: z.object({}),
get_workflow_from_name: z.object({ workflow_name: z.string() }),
// New variable tools
get_global_workflow_variables: z.object({}),
set_global_workflow_variables: z.object({
operations: z.array(
z.object({
operation: z.enum(['add', 'delete', 'edit']),
name: z.string(),
type: z.enum(['plain', 'number', 'boolean', 'array', 'object']).optional(),
value: z.string().optional(),
})
),
}),
// New
oauth_request_access: z.object({}),
build_workflow: z.object({
yamlContent: z.string(),
@@ -152,6 +177,21 @@ function toolCallSSEFor<TName extends ToolId, TArgs extends z.ZodTypeAny>(
export const ToolSSESchemas = {
get_user_workflow: toolCallSSEFor('get_user_workflow', ToolArgSchemas.get_user_workflow),
// New tools
list_user_workflows: toolCallSSEFor('list_user_workflows', ToolArgSchemas.list_user_workflows),
get_workflow_from_name: toolCallSSEFor(
'get_workflow_from_name',
ToolArgSchemas.get_workflow_from_name
),
// New variable tools
get_global_workflow_variables: toolCallSSEFor(
'get_global_workflow_variables',
ToolArgSchemas.get_global_workflow_variables
),
set_global_workflow_variables: toolCallSSEFor(
'set_global_workflow_variables',
ToolArgSchemas.set_global_workflow_variables
),
build_workflow: toolCallSSEFor('build_workflow', ToolArgSchemas.build_workflow),
edit_workflow: toolCallSSEFor('edit_workflow', ToolArgSchemas.edit_workflow),
run_workflow: toolCallSSEFor('run_workflow', ToolArgSchemas.run_workflow),
@@ -187,11 +227,13 @@ export const ToolSSESchemas = {
),
gdrive_request_access: toolCallSSEFor(
'gdrive_request_access',
ToolArgSchemas.gdrive_request_access
ToolArgSchemas.gdrive_request_access as any
),
list_gdrive_files: toolCallSSEFor('list_gdrive_files', ToolArgSchemas.list_gdrive_files),
read_gdrive_file: toolCallSSEFor('read_gdrive_file', ToolArgSchemas.read_gdrive_file),
reason: toolCallSSEFor('reason', ToolArgSchemas.reason),
// New
oauth_request_access: toolCallSSEFor('oauth_request_access', ToolArgSchemas.oauth_request_access),
} as const
export type ToolSSESchemaMap = typeof ToolSSESchemas
@@ -225,6 +267,25 @@ const ExecutionEntry = z.object({
export const ToolResultSchemas = {
get_user_workflow: z.object({ yamlContent: z.string() }).or(z.string()),
// New tools
list_user_workflows: z.object({ workflow_names: z.array(z.string()) }),
get_workflow_from_name: z
.object({ yamlContent: z.string() })
.or(z.object({ userWorkflow: z.string() }))
.or(z.string()),
// New variable tools
get_global_workflow_variables: z
.object({ variables: z.record(z.any()) })
.or(z.array(z.object({ name: z.string(), value: z.any() }))),
set_global_workflow_variables: z
.object({ variables: z.record(z.any()) })
.or(z.object({ message: z.any().optional(), data: z.any().optional() })),
// New
oauth_request_access: z.object({
granted: z.boolean().optional(),
message: z.string().optional(),
}),
build_workflow: BuildOrEditWorkflowResult,
edit_workflow: BuildOrEditWorkflowResult,
run_workflow: z.object({

View File

@@ -0,0 +1,90 @@
import { CheckCircle, Loader2, MinusCircle, PlugZap, X, XCircle } from 'lucide-react'
import {
BaseClientTool,
type BaseClientToolMetadata,
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
export class OAuthRequestAccessClientTool extends BaseClientTool {
static readonly id = 'oauth_request_access'
private cleanupListener?: () => void
constructor(toolCallId: string) {
super(toolCallId, OAuthRequestAccessClientTool.id, OAuthRequestAccessClientTool.metadata)
}
static readonly metadata: BaseClientToolMetadata = {
displayNames: {
[ClientToolCallState.generating]: { text: 'Requesting integration access', icon: Loader2 },
[ClientToolCallState.pending]: { text: 'Requesting integration access', icon: Loader2 },
[ClientToolCallState.executing]: { text: 'Requesting integration access', icon: Loader2 },
[ClientToolCallState.rejected]: { text: 'Skipped integration access', icon: MinusCircle },
[ClientToolCallState.success]: { text: 'Integration connected', icon: CheckCircle },
[ClientToolCallState.error]: { text: 'Failed to request integration access', icon: X },
[ClientToolCallState.aborted]: { text: 'Aborted integration access request', icon: XCircle },
},
interrupt: {
accept: { text: 'Connect', icon: PlugZap },
reject: { text: 'Skip', icon: MinusCircle },
},
}
async handleAccept(): Promise<void> {
try {
// Move to executing (we're waiting for the user to connect an integration)
this.setState(ClientToolCallState.executing)
if (typeof window !== 'undefined') {
// Listen for modal close; complete success on connection, otherwise mark skipped/rejected
const onClosed = async (evt: Event) => {
try {
const detail = (evt as CustomEvent).detail as { success?: boolean }
if (detail?.success) {
await this.markToolComplete(200, { granted: true })
this.setState(ClientToolCallState.success)
} else {
await this.markToolComplete(200, 'Tool execution was skipped by the user')
this.setState(ClientToolCallState.rejected)
}
} finally {
if (this.cleanupListener) this.cleanupListener()
this.cleanupListener = undefined
}
}
window.addEventListener(
'oauth-integration-closed',
onClosed as EventListener,
{
once: true,
} as any
)
this.cleanupListener = () =>
window.removeEventListener('oauth-integration-closed', onClosed as EventListener)
window.dispatchEvent(new CustomEvent('open-settings', { detail: { tab: 'credentials' } }))
}
} catch (e) {
this.setState(ClientToolCallState.error)
await this.markToolComplete(500, 'Failed to open integrations settings')
}
}
async handleReject(): Promise<void> {
await super.handleReject()
this.setState(ClientToolCallState.rejected)
if (this.cleanupListener) this.cleanupListener()
this.cleanupListener = undefined
}
async completeAfterConnection(): Promise<void> {
await this.markToolComplete(200, { granted: true })
this.setState(ClientToolCallState.success)
if (this.cleanupListener) this.cleanupListener()
this.cleanupListener = undefined
}
async execute(): Promise<void> {
await this.handleAccept()
}
}

View File

@@ -27,10 +27,10 @@ export class SetEnvironmentVariablesClientTool extends BaseClientTool {
static readonly metadata: BaseClientToolMetadata = {
displayNames: {
[ClientToolCallState.generating]: {
text: 'Setting environment variables',
text: 'Preparing to set environment variables',
icon: Loader2,
},
[ClientToolCallState.pending]: { text: 'Setting environment variables', icon: Loader2 },
[ClientToolCallState.pending]: { text: 'Set environment variables?', icon: Settings2 },
[ClientToolCallState.executing]: { text: 'Setting environment variables', icon: Loader2 },
[ClientToolCallState.success]: { text: 'Set environment variables', icon: Settings2 },
[ClientToolCallState.error]: { text: 'Failed to set environment variables', icon: X },

View File

@@ -0,0 +1,68 @@
import { List, Loader2, X, XCircle } from 'lucide-react'
import {
BaseClientTool,
type BaseClientToolMetadata,
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
import { createLogger } from '@/lib/logs/console/logger'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
const logger = createLogger('GetGlobalWorkflowVariablesClientTool')
export class GetGlobalWorkflowVariablesClientTool extends BaseClientTool {
static readonly id = 'get_global_workflow_variables'
constructor(toolCallId: string) {
super(
toolCallId,
GetGlobalWorkflowVariablesClientTool.id,
GetGlobalWorkflowVariablesClientTool.metadata
)
}
static readonly metadata: BaseClientToolMetadata = {
displayNames: {
[ClientToolCallState.generating]: { text: 'Fetching workflow variables', icon: Loader2 },
[ClientToolCallState.pending]: { text: 'Fetching workflow variables', icon: List },
[ClientToolCallState.executing]: { text: 'Fetching workflow variables', icon: Loader2 },
[ClientToolCallState.aborted]: { text: 'Aborted fetching variables', icon: XCircle },
[ClientToolCallState.success]: { text: 'Workflow variables retrieved', icon: List },
[ClientToolCallState.error]: { text: 'Failed to fetch variables', icon: X },
[ClientToolCallState.rejected]: { text: 'Skipped fetching variables', icon: XCircle },
},
}
async execute(): Promise<void> {
try {
this.setState(ClientToolCallState.executing)
const { activeWorkflowId } = useWorkflowRegistry.getState()
if (!activeWorkflowId) {
await this.markToolComplete(400, 'No active workflow found')
this.setState(ClientToolCallState.error)
return
}
const res = await fetch(`/api/workflows/${activeWorkflowId}/variables`, { method: 'GET' })
if (!res.ok) {
const text = await res.text().catch(() => '')
await this.markToolComplete(res.status, text || 'Failed to fetch workflow variables')
this.setState(ClientToolCallState.error)
return
}
const json = await res.json()
const varsRecord = (json?.data as Record<string, any>) || {}
// Convert to name/value pairs for clarity
const variables = Object.values(varsRecord).map((v: any) => ({
name: String(v?.name || ''),
value: (v as any)?.value,
}))
logger.info('Fetched workflow variables', { count: variables.length })
await this.markToolComplete(200, `Found ${variables.length} variable(s)`, { variables })
this.setState(ClientToolCallState.success)
} catch (error: any) {
const message = error instanceof Error ? error.message : String(error)
await this.markToolComplete(500, message || 'Failed to fetch workflow variables')
this.setState(ClientToolCallState.error)
}
}
}

View File

@@ -0,0 +1,95 @@
import { FileText, Loader2, X, XCircle } from 'lucide-react'
import {
BaseClientTool,
type BaseClientToolMetadata,
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
import { createLogger } from '@/lib/logs/console/logger'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
const logger = createLogger('GetWorkflowFromNameClientTool')
interface GetWorkflowFromNameArgs {
workflow_name: string
}
export class GetWorkflowFromNameClientTool extends BaseClientTool {
static readonly id = 'get_workflow_from_name'
constructor(toolCallId: string) {
super(toolCallId, GetWorkflowFromNameClientTool.id, GetWorkflowFromNameClientTool.metadata)
}
static readonly metadata: BaseClientToolMetadata = {
displayNames: {
[ClientToolCallState.generating]: { text: 'Retrieving workflow', icon: Loader2 },
[ClientToolCallState.pending]: { text: 'Retrieving workflow', icon: FileText },
[ClientToolCallState.executing]: { text: 'Retrieving workflow', icon: Loader2 },
[ClientToolCallState.aborted]: { text: 'Aborted retrieving workflow', icon: XCircle },
[ClientToolCallState.success]: { text: 'Retrieved workflow', icon: FileText },
[ClientToolCallState.error]: { text: 'Failed to retrieve workflow', icon: X },
[ClientToolCallState.rejected]: { text: 'Skipped retrieving workflow', icon: XCircle },
},
}
async execute(args?: GetWorkflowFromNameArgs): Promise<void> {
try {
this.setState(ClientToolCallState.executing)
const workflowName = args?.workflow_name?.trim()
if (!workflowName) {
await this.markToolComplete(400, 'workflow_name is required')
this.setState(ClientToolCallState.error)
return
}
// Try to find by name from registry first to get ID
const registry = useWorkflowRegistry.getState()
const match = Object.values((registry as any).workflows || {}).find(
(w: any) =>
String(w?.name || '')
.trim()
.toLowerCase() === workflowName.toLowerCase()
) as any
if (!match?.id) {
await this.markToolComplete(404, `Workflow not found: ${workflowName}`)
this.setState(ClientToolCallState.error)
return
}
// Fetch full workflow from API route (normalized tables)
const res = await fetch(`/api/workflows/${encodeURIComponent(match.id)}`, { method: 'GET' })
if (!res.ok) {
const text = await res.text().catch(() => '')
await this.markToolComplete(res.status, text || 'Failed to fetch workflow by name')
this.setState(ClientToolCallState.error)
return
}
const json = await res.json()
const wf = json?.data
if (!wf?.state?.blocks) {
await this.markToolComplete(422, 'Workflow state is empty or invalid')
this.setState(ClientToolCallState.error)
return
}
// Convert state to the same string format as get_user_workflow
const workflowState = {
blocks: wf.state.blocks || {},
edges: wf.state.edges || [],
loops: wf.state.loops || {},
parallels: wf.state.parallels || {},
}
const userWorkflow = JSON.stringify(workflowState, null, 2)
await this.markToolComplete(200, `Retrieved workflow ${workflowName}`, { userWorkflow })
this.setState(ClientToolCallState.success)
} catch (error: any) {
const message = error instanceof Error ? error.message : String(error)
await this.markToolComplete(500, message || 'Failed to retrieve workflow by name')
this.setState(ClientToolCallState.error)
}
}
}

View File

@@ -0,0 +1,60 @@
import { ListChecks, Loader2, X, XCircle } from 'lucide-react'
import {
BaseClientTool,
type BaseClientToolMetadata,
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
import { createLogger } from '@/lib/logs/console/logger'
const logger = createLogger('ListUserWorkflowsClientTool')
export class ListUserWorkflowsClientTool extends BaseClientTool {
static readonly id = 'list_user_workflows'
constructor(toolCallId: string) {
super(toolCallId, ListUserWorkflowsClientTool.id, ListUserWorkflowsClientTool.metadata)
}
static readonly metadata: BaseClientToolMetadata = {
displayNames: {
[ClientToolCallState.generating]: { text: 'Listing your workflows', icon: Loader2 },
[ClientToolCallState.pending]: { text: 'Listing your workflows', icon: ListChecks },
[ClientToolCallState.executing]: { text: 'Listing your workflows', icon: Loader2 },
[ClientToolCallState.aborted]: { text: 'Aborted listing workflows', icon: XCircle },
[ClientToolCallState.success]: { text: 'Listed your workflows', icon: ListChecks },
[ClientToolCallState.error]: { text: 'Failed to list workflows', icon: X },
[ClientToolCallState.rejected]: { text: 'Skipped listing workflows', icon: XCircle },
},
}
async execute(): Promise<void> {
try {
this.setState(ClientToolCallState.executing)
const res = await fetch('/api/workflows/sync', { method: 'GET' })
if (!res.ok) {
const text = await res.text().catch(() => '')
await this.markToolComplete(res.status, text || 'Failed to fetch workflows')
this.setState(ClientToolCallState.error)
return
}
const json = await res.json()
const workflows = Array.isArray(json?.data) ? json.data : []
const names = workflows
.map((w: any) => (typeof w?.name === 'string' ? w.name : null))
.filter((n: string | null) => !!n)
logger.info('Found workflows', { count: names.length })
await this.markToolComplete(200, `Found ${names.length} workflow(s)`, {
workflow_names: names,
})
this.setState(ClientToolCallState.success)
} catch (error: any) {
const message = error instanceof Error ? error.message : String(error)
await this.markToolComplete(500, message || 'Failed to list workflows')
this.setState(ClientToolCallState.error)
}
}
}

View File

@@ -24,7 +24,7 @@ export class RunWorkflowClientTool extends BaseClientTool {
static readonly metadata: BaseClientToolMetadata = {
displayNames: {
[ClientToolCallState.generating]: { text: 'Preparing to run your workflow', icon: Play },
[ClientToolCallState.generating]: { text: 'Preparing to run your workflow', icon: Loader2 },
[ClientToolCallState.pending]: { text: 'Run this workflow?', icon: Play },
[ClientToolCallState.executing]: { text: 'Running your workflow', icon: Loader2 },
[ClientToolCallState.success]: { text: 'Workflow executed', icon: Play },

View File

@@ -0,0 +1,213 @@
import { Loader2, Settings2, X, XCircle } from 'lucide-react'
import {
BaseClientTool,
type BaseClientToolMetadata,
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
import { createLogger } from '@/lib/logs/console/logger'
import { useVariablesStore } from '@/stores/panel/variables/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
interface OperationItem {
operation: 'add' | 'edit' | 'delete'
name: string
type?: 'plain' | 'number' | 'boolean' | 'array' | 'object'
value?: string
}
interface SetGlobalVarsArgs {
operations: OperationItem[]
workflowId?: string
}
export class SetGlobalWorkflowVariablesClientTool extends BaseClientTool {
static readonly id = 'set_global_workflow_variables'
constructor(toolCallId: string) {
super(
toolCallId,
SetGlobalWorkflowVariablesClientTool.id,
SetGlobalWorkflowVariablesClientTool.metadata
)
}
static readonly metadata: BaseClientToolMetadata = {
displayNames: {
[ClientToolCallState.generating]: {
text: 'Preparing to set workflow variables',
icon: Loader2,
},
[ClientToolCallState.pending]: { text: 'Set workflow variables?', icon: Settings2 },
[ClientToolCallState.executing]: { text: 'Setting workflow variables', icon: Loader2 },
[ClientToolCallState.success]: { text: 'Workflow variables updated', icon: Settings2 },
[ClientToolCallState.error]: { text: 'Failed to set workflow variables', icon: X },
[ClientToolCallState.aborted]: { text: 'Aborted setting variables', icon: XCircle },
[ClientToolCallState.rejected]: { text: 'Skipped setting variables', icon: XCircle },
},
interrupt: {
accept: { text: 'Apply', icon: Settings2 },
reject: { text: 'Skip', icon: XCircle },
},
}
async handleReject(): Promise<void> {
await super.handleReject()
this.setState(ClientToolCallState.rejected)
}
async handleAccept(args?: SetGlobalVarsArgs): Promise<void> {
const logger = createLogger('SetGlobalWorkflowVariablesClientTool')
try {
this.setState(ClientToolCallState.executing)
const payload: SetGlobalVarsArgs = { ...(args || { operations: [] }) }
if (!payload.workflowId) {
const { activeWorkflowId } = useWorkflowRegistry.getState()
if (activeWorkflowId) payload.workflowId = activeWorkflowId
}
if (!payload.workflowId) {
throw new Error('No active workflow found')
}
// Fetch current variables so we can construct full array payload
const getRes = await fetch(`/api/workflows/${payload.workflowId}/variables`, {
method: 'GET',
})
if (!getRes.ok) {
const txt = await getRes.text().catch(() => '')
throw new Error(txt || 'Failed to load current variables')
}
const currentJson = await getRes.json()
const currentVarsRecord = (currentJson?.data as Record<string, any>) || {}
// Helper to convert string -> typed value
function coerceValue(
value: string | undefined,
type?: 'plain' | 'number' | 'boolean' | 'array' | 'object'
) {
if (value === undefined) return value
const t = type || 'plain'
try {
if (t === 'number') {
const n = Number(value)
if (Number.isNaN(n)) return value
return n
}
if (t === 'boolean') {
const v = String(value).trim().toLowerCase()
if (v === 'true') return true
if (v === 'false') return false
return value
}
if (t === 'array' || t === 'object') {
const parsed = JSON.parse(value)
if (t === 'array' && Array.isArray(parsed)) return parsed
if (t === 'object' && parsed && typeof parsed === 'object' && !Array.isArray(parsed))
return parsed
return value
}
} catch {}
return value
}
// Build mutable map by variable name
const byName: Record<string, any> = {}
Object.values(currentVarsRecord).forEach((v: any) => {
if (v && typeof v === 'object' && v.id && v.name) byName[String(v.name)] = v
})
// Apply operations in order
for (const op of payload.operations || []) {
const key = String(op.name)
const nextType = (op.type as any) || byName[key]?.type || 'plain'
if (op.operation === 'delete') {
delete byName[key]
continue
}
const typedValue = coerceValue(op.value, nextType)
if (op.operation === 'add') {
byName[key] = {
id: crypto.randomUUID(),
workflowId: payload.workflowId,
name: key,
type: nextType,
value: typedValue,
}
continue
}
if (op.operation === 'edit') {
if (!byName[key]) {
// If editing a non-existent variable, create it
byName[key] = {
id: crypto.randomUUID(),
workflowId: payload.workflowId,
name: key,
type: nextType,
value: typedValue,
}
} else {
byName[key] = {
...byName[key],
type: nextType,
...(op.value !== undefined ? { value: typedValue } : {}),
}
}
}
}
const variablesArray = Object.values(byName)
// POST full variables array to persist
const res = await fetch(`/api/workflows/${payload.workflowId}/variables`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ variables: variablesArray }),
})
if (!res.ok) {
const txt = await res.text().catch(() => '')
throw new Error(txt || `Failed to update variables (${res.status})`)
}
try {
const { activeWorkflowId } = useWorkflowRegistry.getState()
if (activeWorkflowId) {
// Fetch the updated variables from the API
const refreshRes = await fetch(`/api/workflows/${activeWorkflowId}/variables`, {
method: 'GET',
})
if (refreshRes.ok) {
const refreshJson = await refreshRes.json()
const updatedVarsRecord = (refreshJson?.data as Record<string, any>) || {}
// Update the variables store with the fresh data
useVariablesStore.setState((state) => {
// Remove old variables for this workflow
const withoutWorkflow = Object.fromEntries(
Object.entries(state.variables).filter(([, v]) => v.workflowId !== activeWorkflowId)
)
// Add the updated variables
return {
variables: { ...withoutWorkflow, ...updatedVarsRecord },
}
})
logger.info('Refreshed variables in store', { workflowId: activeWorkflowId })
}
}
} catch (refreshError) {
logger.warn('Failed to refresh variables in store', { error: refreshError })
}
await this.markToolComplete(200, 'Workflow variables updated', { variables: byName })
this.setState(ClientToolCallState.success)
} catch (e: any) {
const message = e instanceof Error ? e.message : String(e)
this.setState(ClientToolCallState.error)
await this.markToolComplete(500, message || 'Failed to set workflow variables')
}
}
async execute(args?: SetGlobalVarsArgs): Promise<void> {
await this.handleAccept(args)
}
}

View File

@@ -38,7 +38,7 @@ export async function generateChatTitle(message: string): Promise<string | null>
{
role: 'system',
content:
'Generate a very short title (3-5 words max) for a chat that starts with this message. The title should be concise and descriptive.',
'Generate a very short title (3-5 words max) for a chat that starts with this message. The title should be concise and descriptive. Do not wrap the title in quotes.',
},
{
role: 'user',

View File

@@ -21,6 +21,7 @@ import {
import { CheckoffTodoClientTool } from '@/lib/copilot/tools/client/other/checkoff-todo'
import { MakeApiRequestClientTool } from '@/lib/copilot/tools/client/other/make-api-request'
import { MarkTodoInProgressClientTool } from '@/lib/copilot/tools/client/other/mark-todo-in-progress'
import { OAuthRequestAccessClientTool } from '@/lib/copilot/tools/client/other/oauth-request-access'
import { PlanClientTool } from '@/lib/copilot/tools/client/other/plan'
import { SearchDocumentationClientTool } from '@/lib/copilot/tools/client/other/search-documentation'
import { SearchOnlineClientTool } from '@/lib/copilot/tools/client/other/search-online'
@@ -30,11 +31,16 @@ import { GetOAuthCredentialsClientTool } from '@/lib/copilot/tools/client/user/g
import { SetEnvironmentVariablesClientTool } from '@/lib/copilot/tools/client/user/set-environment-variables'
import { BuildWorkflowClientTool } from '@/lib/copilot/tools/client/workflow/build-workflow'
import { EditWorkflowClientTool } from '@/lib/copilot/tools/client/workflow/edit-workflow'
import { GetGlobalWorkflowVariablesClientTool } from '@/lib/copilot/tools/client/workflow/get-global-workflow-variables'
import { GetUserWorkflowClientTool } from '@/lib/copilot/tools/client/workflow/get-user-workflow'
import { GetWorkflowConsoleClientTool } from '@/lib/copilot/tools/client/workflow/get-workflow-console'
import { GetWorkflowFromNameClientTool } from '@/lib/copilot/tools/client/workflow/get-workflow-from-name'
import { ListUserWorkflowsClientTool } from '@/lib/copilot/tools/client/workflow/list-user-workflows'
import { RunWorkflowClientTool } from '@/lib/copilot/tools/client/workflow/run-workflow'
import { SetGlobalWorkflowVariablesClientTool } from '@/lib/copilot/tools/client/workflow/set-global-workflow-variables'
import { createLogger } from '@/lib/logs/console/logger'
import type {
ChatContext,
CopilotMessage,
CopilotStore,
CopilotToolCall,
@@ -72,9 +78,14 @@ const CLIENT_TOOL_INSTANTIATORS: Record<string, (id: string) => any> = {
checkoff_todo: (id) => new CheckoffTodoClientTool(id),
mark_todo_in_progress: (id) => new MarkTodoInProgressClientTool(id),
gdrive_request_access: (id) => new GDriveRequestAccessClientTool(id),
oauth_request_access: (id) => new OAuthRequestAccessClientTool(id),
edit_workflow: (id) => new EditWorkflowClientTool(id),
build_workflow: (id) => new BuildWorkflowClientTool(id),
get_user_workflow: (id) => new GetUserWorkflowClientTool(id),
list_user_workflows: (id) => new ListUserWorkflowsClientTool(id),
get_workflow_from_name: (id) => new GetWorkflowFromNameClientTool(id),
get_global_workflow_variables: (id) => new GetGlobalWorkflowVariablesClientTool(id),
set_global_workflow_variables: (id) => new SetGlobalWorkflowVariablesClientTool(id),
}
// Read-only static metadata for class-based tools (no instances)
@@ -98,6 +109,11 @@ export const CLASS_TOOL_METADATA: Record<string, BaseClientToolMetadata | undefi
edit_workflow: (EditWorkflowClientTool as any)?.metadata,
build_workflow: (BuildWorkflowClientTool as any)?.metadata,
get_user_workflow: (GetUserWorkflowClientTool as any)?.metadata,
list_user_workflows: (ListUserWorkflowsClientTool as any)?.metadata,
get_workflow_from_name: (GetWorkflowFromNameClientTool as any)?.metadata,
get_global_workflow_variables: (GetGlobalWorkflowVariablesClientTool as any)?.metadata,
set_global_workflow_variables: (SetGlobalWorkflowVariablesClientTool as any)?.metadata,
oauth_request_access: (OAuthRequestAccessClientTool as any)?.metadata,
}
function ensureClientToolInstance(toolName: string | undefined, toolCallId: string | undefined) {
@@ -250,7 +266,19 @@ function abortAllInProgressTools(set: any, get: () => CopilotStore) {
function normalizeMessagesForUI(messages: CopilotMessage[]): CopilotMessage[] {
try {
return messages.map((message) => {
if (message.role !== 'assistant') return message
if (message.role !== 'assistant') {
// For user messages (and others), restore contexts from a saved contexts block
if (Array.isArray(message.contentBlocks) && message.contentBlocks.length > 0) {
const ctxBlock = (message.contentBlocks as any[]).find((b: any) => b?.type === 'contexts')
if (ctxBlock && Array.isArray((ctxBlock as any).contexts)) {
return {
...message,
contexts: (ctxBlock as any).contexts,
}
}
}
return message
}
// Use existing contentBlocks ordering if present; otherwise only render text content
const blocks: any[] = Array.isArray(message.contentBlocks)
@@ -393,7 +421,8 @@ class StringBuilder {
// Helpers
function createUserMessage(
content: string,
fileAttachments?: MessageFileAttachment[]
fileAttachments?: MessageFileAttachment[],
contexts?: ChatContext[]
): CopilotMessage {
return {
id: crypto.randomUUID(),
@@ -401,6 +430,13 @@ function createUserMessage(
content,
timestamp: new Date().toISOString(),
...(fileAttachments && fileAttachments.length > 0 && { fileAttachments }),
...(contexts && contexts.length > 0 && { contexts }),
...(contexts &&
contexts.length > 0 && {
contentBlocks: [
{ type: 'contexts', contexts: contexts as any, timestamp: Date.now() },
] as any,
}),
}
}
@@ -465,6 +501,10 @@ function validateMessagesForLLM(messages: CopilotMessage[]): any[] {
msg.fileAttachments.length > 0 && {
fileAttachments: msg.fileAttachments,
}),
...((msg as any).contexts &&
Array.isArray((msg as any).contexts) && {
contexts: (msg as any).contexts,
}),
}
})
.filter((m) => {
@@ -1439,16 +1479,21 @@ export const useCopilotStore = create<CopilotStore>()(
// Send a message (streaming only)
sendMessage: async (message: string, options = {}) => {
const { workflowId, currentChat, mode, revertState } = get()
const { stream = true, fileAttachments } = options as {
const {
stream = true,
fileAttachments,
contexts,
} = options as {
stream?: boolean
fileAttachments?: MessageFileAttachment[]
contexts?: ChatContext[]
}
if (!workflowId) return
const abortController = new AbortController()
set({ isSendingMessage: true, error: null, abortController })
const userMessage = createUserMessage(message, fileAttachments)
const userMessage = createUserMessage(message, fileAttachments, contexts)
const streamingMessage = createStreamingMessage()
let newMessages: CopilotMessage[]
@@ -1473,6 +1518,22 @@ export const useCopilotStore = create<CopilotStore>()(
}
try {
// Debug: log contexts presence before sending
try {
logger.info('sendMessage: preparing request', {
hasContexts: Array.isArray(contexts),
contextsCount: Array.isArray(contexts) ? contexts.length : 0,
contextsPreview: Array.isArray(contexts)
? contexts.map((c: any) => ({
kind: c?.kind,
chatId: (c as any)?.chatId,
workflowId: (c as any)?.workflowId,
label: (c as any)?.label,
}))
: undefined,
})
} catch {}
const result = await sendStreamingMessage({
message,
userMessageId: userMessage.id,
@@ -1484,6 +1545,7 @@ export const useCopilotStore = create<CopilotStore>()(
createNewChat: !currentChat,
stream,
fileAttachments,
contexts,
abortSignal: abortController.signal,
})

View File

@@ -35,10 +35,21 @@ export interface CopilotMessage {
startTime?: number
}
| { type: 'tool_call'; toolCall: CopilotToolCall; timestamp: number }
| { type: 'contexts'; contexts: ChatContext[]; timestamp: number }
>
fileAttachments?: MessageFileAttachment[]
contexts?: ChatContext[]
}
// Contexts attached to a user message
export type ChatContext =
| { kind: 'past_chat'; chatId: string; label: string }
| { kind: 'workflow'; workflowId: string; label: string }
| { kind: 'blocks'; blockIds: string[]; label: string }
| { kind: 'logs'; label: string }
| { kind: 'knowledge'; knowledgeId?: string; label: string }
| { kind: 'templates'; templateId?: string; label: string }
export interface CopilotChat {
id: string
title: string | null
@@ -107,7 +118,11 @@ export interface CopilotActions {
sendMessage: (
message: string,
options?: { stream?: boolean; fileAttachments?: MessageFileAttachment[] }
options?: {
stream?: boolean
fileAttachments?: MessageFileAttachment[]
contexts?: ChatContext[]
}
) => Promise<void>
abortMessage: () => void
sendImplicitFeedback: (