Feat(references) add at to reference sim resources(#3560)

* feat(chat) add at sign

* Address bugbot issues

* Remove extra chatcontext defs

* Add table and file to schema

* Add icon to chip for files

---------

Co-authored-by: Theodore Li <theo@sim.ai>
This commit is contained in:
Theodore Li
2026-03-13 01:32:37 -07:00
committed by GitHub
parent 2d8899b2ff
commit 8dbdebd01b
14 changed files with 1222 additions and 47 deletions

View File

@@ -55,6 +55,8 @@ const MothershipMessageSchema = z.object({
'knowledge',
'templates',
'docs',
'table',
'file',
]),
label: z.string(),
chatId: z.string().optional(),
@@ -64,6 +66,8 @@ const MothershipMessageSchema = z.object({
blockIds: z.array(z.string()).optional(),
templateId: z.string().optional(),
executionId: z.string().optional(),
tableId: z.string().optional(),
fileId: z.string().optional(),
})
)
.optional(),
@@ -162,6 +166,17 @@ export async function POST(req: NextRequest) {
size: f.size,
})),
}),
...(contexts &&
contexts.length > 0 && {
contexts: contexts.map((c) => ({
kind: c.kind,
label: c.label,
...(c.workflowId && { workflowId: c.workflowId }),
...(c.knowledgeId && { knowledgeId: c.knowledgeId }),
...(c.tableId && { tableId: c.tableId }),
...(c.fileId && { fileId: c.fileId }),
})),
}),
}
const [updated] = await db

View File

@@ -2,3 +2,4 @@ export { MessageContent } from './message-content'
export { MothershipView } from './mothership-view'
export { TemplatePrompts } from './template-prompts'
export { UserInput } from './user-input'
export { UserMessageContent } from './user-message-content'

View File

@@ -0,0 +1,99 @@
'use client'
import { X } from 'lucide-react'
import { Badge } from '@/components/emcn'
import { Database, File as FileIcon, Table as TableIcon } from '@/components/emcn/icons'
import { WorkflowIcon } from '@/components/icons'
import { cn } from '@/lib/core/utils/cn'
import type { ChatContext } from '@/stores/panel'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
interface ContextPillsProps {
contexts: ChatContext[]
onRemoveContext: (context: ChatContext) => void
}
function WorkflowPillIcon({ workflowId, className }: { workflowId: string; className?: string }) {
const color = useWorkflowRegistry((state) => state.workflows[workflowId]?.color ?? '#888')
return (
<div
className={cn('flex-shrink-0 rounded-[3px] border-[2px]', className)}
style={{
backgroundColor: color,
borderColor: `${color}60`,
backgroundClip: 'padding-box',
}}
/>
)
}
function getContextIcon(ctx: ChatContext) {
switch (ctx.kind) {
case 'workflow':
case 'current_workflow':
return (
<WorkflowPillIcon
workflowId={ctx.workflowId}
className='mr-[4px] h-[10px] w-[10px]'
/>
)
case 'workflow_block':
return (
<WorkflowPillIcon
workflowId={ctx.workflowId}
className='mr-[4px] h-[10px] w-[10px]'
/>
)
case 'knowledge':
return <Database className='mr-[4px] h-[10px] w-[10px] text-[var(--text-icon)]' />
case 'templates':
return <WorkflowIcon className='mr-[4px] h-[10px] w-[10px] text-[var(--text-icon)]' />
case 'past_chat':
return null
case 'logs':
return <FileIcon className='mr-[4px] h-[10px] w-[10px] text-[var(--text-icon)]' />
case 'blocks':
return <TableIcon className='mr-[4px] h-[10px] w-[10px] text-[var(--text-icon)]' />
case 'table':
return <TableIcon className='mr-[4px] h-[10px] w-[10px] text-[var(--text-icon)]' />
case 'file':
return <FileIcon className='mr-[4px] h-[10px] w-[10px] text-[var(--text-icon)]' />
case 'docs':
return <FileIcon className='mr-[4px] h-[10px] w-[10px] text-[var(--text-icon)]' />
default:
return null
}
}
export function ContextPills({ contexts, onRemoveContext }: ContextPillsProps) {
const visibleContexts = contexts.filter((c) => c.kind !== 'current_workflow')
if (visibleContexts.length === 0) {
return null
}
return (
<>
{visibleContexts.map((ctx, idx) => (
<Badge
key={`selctx-${idx}-${ctx.label}`}
variant='outline'
className='inline-flex items-center gap-1 rounded-[6px] px-2 py-[4.5px] text-xs leading-[12px]'
title={ctx.label}
>
{getContextIcon(ctx)}
<span className='max-w-[140px] truncate leading-[12px]'>{ctx.label}</span>
<button
type='button'
onClick={() => onRemoveContext(ctx)}
className='text-muted-foreground transition-colors hover:text-foreground'
title='Remove context'
aria-label='Remove context'
>
<X className='h-3 w-3' strokeWidth={1.75} />
</button>
</Badge>
))}
</>
)
}

View File

@@ -0,0 +1 @@
export { ContextPills } from './context-pills'

View File

@@ -0,0 +1,115 @@
'use client'
import { cn } from '@/lib/core/utils/cn'
import type { ChatMessageContext } from '../types'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
interface UserMessageContentProps {
content: string
contexts?: ChatMessageContext[]
}
function escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
interface MentionRange {
start: number
end: number
token: string
context: ChatMessageContext
}
function computeMentionRanges(text: string, contexts: ChatMessageContext[]): MentionRange[] {
const ranges: MentionRange[] = []
for (const ctx of contexts) {
if (!ctx.label) continue
const token = `@${ctx.label}`
const pattern = new RegExp(`(^|\\s)(${escapeRegex(token)})(\\s|$)`, 'g')
let match: RegExpExecArray | null
while ((match = pattern.exec(text)) !== null) {
const leadingSpace = match[1]
const tokenStart = match.index + leadingSpace.length
const tokenEnd = tokenStart + token.length
ranges.push({ start: tokenStart, end: tokenEnd, token, context: ctx })
}
}
ranges.sort((a, b) => a.start - b.start)
return ranges
}
function MentionHighlight({ context, token }: { context: ChatMessageContext; token: string }) {
const workflowColor = useWorkflowRegistry((state) => {
if (context.kind === 'workflow' || context.kind === 'current_workflow') {
return state.workflows[context.workflowId || '']?.color ?? null
}
return null
})
const bgColor = workflowColor
? `${workflowColor}40`
: 'rgba(50, 189, 126, 0.4)'
return (
<span
className='rounded-[4px] py-[1px] px-[2px]'
style={{ backgroundColor: bgColor }}
>
{token}
</span>
)
}
export function UserMessageContent({ content, contexts }: UserMessageContentProps) {
if (!contexts || contexts.length === 0) {
return (
<p className='whitespace-pre-wrap font-[430] font-[family-name:var(--font-inter)] text-[15px] text-[var(--text-primary)] leading-[23px] tracking-[0] antialiased'>
{content}
</p>
)
}
const ranges = computeMentionRanges(content, contexts)
if (ranges.length === 0) {
return (
<p className='whitespace-pre-wrap font-[430] font-[family-name:var(--font-inter)] text-[15px] text-[var(--text-primary)] leading-[23px] tracking-[0] antialiased'>
{content}
</p>
)
}
const elements: React.ReactNode[] = []
let lastIndex = 0
for (let i = 0; i < ranges.length; i++) {
const range = ranges[i]
if (range.start > lastIndex) {
const before = content.slice(lastIndex, range.start)
elements.push(<span key={`text-${i}-${lastIndex}`}>{before}</span>)
}
elements.push(
<MentionHighlight
key={`mention-${i}-${range.start}`}
context={range.context}
token={range.token}
/>
)
lastIndex = range.end
}
const tail = content.slice(lastIndex)
if (tail) {
elements.push(<span key={`tail-${lastIndex}`}>{tail}</span>)
}
return (
<p className='whitespace-pre-wrap font-[430] font-[family-name:var(--font-inter)] text-[15px] text-[var(--text-primary)] leading-[23px] tracking-[0] antialiased'>
{elements}
</p>
)
}

View File

@@ -15,8 +15,10 @@ import {
} from '@/lib/core/utils/browser-storage'
import { persistImportedWorkflow } from '@/lib/workflows/operations/import-export'
import { useChatHistory, useMarkTaskRead } from '@/hooks/queries/tasks'
import { MessageContent, MothershipView, TemplatePrompts, UserInput } from './components'
import { MessageContent, MothershipView, TemplatePrompts, UserInput, UserMessageContent } from './components'
import type { FileAttachmentForApi } from './components/user-input/user-input'
import type { ChatContext } from '@/stores/panel'
import type { MothershipResource, MothershipResourceType } from './types'
import { useAutoScroll, useChat } from './hooks'
const logger = createLogger('Home')
@@ -231,14 +233,61 @@ export function Home({ chatId }: HomeProps = {}) {
}, [skipResourceTransition])
const handleSubmit = useCallback(
(text: string, fileAttachments?: FileAttachmentForApi[]) => {
(text: string, fileAttachments?: FileAttachmentForApi[], contexts?: ChatContext[]) => {
const trimmed = text.trim()
if (!trimmed && !(fileAttachments && fileAttachments.length > 0)) return
sendMessage(trimmed || 'Analyze the attached file(s).', fileAttachments)
sendMessage(trimmed || 'Analyze the attached file(s).', fileAttachments, contexts)
},
[sendMessage]
)
const handleContextAdd = useCallback(
(context: ChatContext) => {
let resourceType: MothershipResourceType | null = null
let resourceId: string | null = null
let resourceTitle: string = context.label
switch (context.kind) {
case 'workflow':
case 'current_workflow':
resourceType = 'workflow'
resourceId = context.workflowId
break
case 'knowledge':
if (context.knowledgeId) {
resourceType = 'knowledgebase'
resourceId = context.knowledgeId
}
break
case 'table':
if (context.tableId) {
resourceType = 'table'
resourceId = context.tableId
}
break
case 'file':
if (context.fileId) {
resourceType = 'file'
resourceId = context.fileId
}
break
default:
break
}
if (resourceType && resourceId) {
const resource: MothershipResource = {
type: resourceType,
id: resourceId,
title: resourceTitle,
}
addResource(resource)
handleResourceEvent()
}
},
[addResource, handleResourceEvent]
)
const scrollContainerRef = useAutoScroll(isSending)
const hasMessages = messages.length > 0
@@ -268,6 +317,7 @@ export function Home({ chatId }: HomeProps = {}) {
onStopGeneration={stopGeneration}
isInitialView={false}
userId={session?.user?.id}
onContextAdd={handleContextAdd}
/>
</ChatSkeleton>
)
@@ -288,6 +338,7 @@ export function Home({ chatId }: HomeProps = {}) {
isSending={isSending}
onStopGeneration={stopGeneration}
userId={session?.user?.id}
onContextAdd={handleContextAdd}
/>
</div>
</div>
@@ -337,9 +388,7 @@ export function Home({ chatId }: HomeProps = {}) {
</div>
)}
<div className='max-w-[70%] rounded-[16px] bg-[var(--surface-5)] px-3.5 py-2'>
<p className='whitespace-pre-wrap font-[430] font-[family-name:var(--font-inter)] text-[15px] text-[var(--text-primary)] leading-[23px] tracking-[0] antialiased'>
{msg.content}
</p>
<UserMessageContent content={msg.content} contexts={msg.contexts} />
</div>
</div>
)
@@ -384,6 +433,7 @@ export function Home({ chatId }: HomeProps = {}) {
onStopGeneration={stopGeneration}
isInitialView={false}
userId={session?.user?.id}
onContextAdd={handleContextAdd}
/>
</div>
</div>

View File

@@ -20,6 +20,7 @@ import {
taskKeys,
useChatHistory,
} from '@/hooks/queries/tasks'
import type { ChatContext } from '@/stores/panel'
import { getTopInsertionSortOrder } from '@/hooks/queries/utils/top-insertion-sort-order'
import { useExecutionStream } from '@/hooks/use-execution-stream'
import { useExecutionStore } from '@/stores/execution/store'
@@ -45,7 +46,7 @@ export interface UseChatReturn {
isSending: boolean
error: string | null
resolvedChatId: string | undefined
sendMessage: (message: string, fileAttachments?: FileAttachmentForApi[]) => Promise<void>
sendMessage: (message: string, fileAttachments?: FileAttachmentForApi[], contexts?: ChatContext[]) => Promise<void>
stopGeneration: () => Promise<void>
resources: MothershipResource[]
activeResourceId: string | null
@@ -145,6 +146,17 @@ function mapStoredMessage(msg: TaskStoredMessage): ChatMessage {
mapped.attachments = msg.fileAttachments.map(toDisplayAttachment)
}
if (Array.isArray(msg.contexts) && msg.contexts.length > 0) {
mapped.contexts = msg.contexts.map((c) => ({
kind: c.kind,
label: c.label,
...(c.workflowId && { workflowId: c.workflowId }),
...(c.knowledgeId && { knowledgeId: c.knowledgeId }),
...(c.tableId && { tableId: c.tableId }),
...(c.fileId && { fileId: c.fileId }),
}))
}
return mapped
}
@@ -257,6 +269,18 @@ export function useChat(
return [...prev, resource]
})
setActiveResourceId(resource.id)
// Persist to database if we have a chat ID
const currentChatId = chatIdRef.current
if (currentChatId) {
fetch('/api/copilot/chat/resources', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ chatId: currentChatId, resource }),
}).catch((err) => {
logger.warn('Failed to persist resource', err)
})
}
}, [])
const removeResource = useCallback((resourceType: MothershipResourceType, resourceId: string) => {
@@ -695,7 +719,7 @@ export function useChat(
}, [chatHistory?.activeStreamId, processSSEStream, finalize])
const sendMessage = useCallback(
async (message: string, fileAttachments?: FileAttachmentForApi[]) => {
async (message: string, fileAttachments?: FileAttachmentForApi[], contexts?: ChatContext[]) => {
if (!message.trim() || !workspaceId) return
if (sendingRef.current) {
@@ -746,9 +770,24 @@ export function useChat(
const userAttachments = storedAttachments?.map(toDisplayAttachment)
const messageContexts = contexts?.map((c) => ({
kind: c.kind,
label: c.label,
...('workflowId' in c && c.workflowId ? { workflowId: c.workflowId } : {}),
...('knowledgeId' in c && c.knowledgeId ? { knowledgeId: c.knowledgeId } : {}),
...('tableId' in c && c.tableId ? { tableId: c.tableId } : {}),
...('fileId' in c && c.fileId ? { fileId: c.fileId } : {}),
}))
setMessages((prev) => [
...prev,
{ id: userMessageId, role: 'user', content: message, attachments: userAttachments },
{
id: userMessageId,
role: 'user',
content: message,
attachments: userAttachments,
...(messageContexts && messageContexts.length > 0 ? { contexts: messageContexts } : {}),
},
{ id: assistantId, role: 'assistant', content: '', contentBlocks: [] },
])
@@ -776,6 +815,7 @@ export function useChat(
...(chatIdRef.current ? { chatId: chatIdRef.current } : {}),
...(fileAttachments && fileAttachments.length > 0 ? { fileAttachments } : {}),
...(resourceAttachments ? { resourceAttachments } : {}),
...(contexts && contexts.length > 0 ? { contexts } : {}),
userTimezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
}),
signal: abortController.signal,

View File

@@ -141,12 +141,22 @@ export interface ChatMessageAttachment {
previewUrl?: string
}
export interface ChatMessageContext {
kind: string
label: string
workflowId?: string
knowledgeId?: string
tableId?: string
fileId?: string
}
export interface ChatMessage {
id: string
role: 'user' | 'assistant'
content: string
contentBlocks?: ContentBlock[]
attachments?: ChatMessageAttachment[]
contexts?: ChatMessageContext[]
}
export const SUBAGENT_LABELS: Record<SubagentName, string> = {

View File

@@ -283,7 +283,7 @@ export function useMentionMenu({
// Add leading space only if not at start and previous char isn't whitespace
const needsLeadingSpace = before.length > 0 && !before.endsWith(' ')
// Always add trailing space for easy continued typing
const insertion = `${needsLeadingSpace ? ' ' : ''}@${label} `
const insertion = `${needsLeadingSpace ? ' ' : ''}@${label} `
const next = `${before}${insertion}${after}`
onMessageChange(next)

View File

@@ -148,6 +148,8 @@ type CurrentWorkflowContext = Extract<ChatContext, { kind: 'current_workflow' }>
type BlocksContext = Extract<ChatContext, { kind: 'blocks' }>
type WorkflowBlockContext = Extract<ChatContext, { kind: 'workflow_block' }>
type KnowledgeContext = Extract<ChatContext, { kind: 'knowledge' }>
type TableContext = Extract<ChatContext, { kind: 'table' }>
type FileContext = Extract<ChatContext, { kind: 'file' }>
type TemplatesContext = Extract<ChatContext, { kind: 'templates' }>
type LogsContext = Extract<ChatContext, { kind: 'logs' }>
type SlashCommandContext = Extract<ChatContext, { kind: 'slash_command' }>
@@ -184,6 +186,14 @@ export function areContextsEqual(c: ChatContext, context: ChatContext): boolean
const ctx = context as KnowledgeContext
return c.knowledgeId === ctx.knowledgeId
}
case 'table': {
const ctx = context as TableContext
return c.tableId === ctx.tableId
}
case 'file': {
const ctx = context as FileContext
return c.fileId === ctx.fileId
}
case 'templates': {
const ctx = context as TemplatesContext
return c.templateId === ctx.templateId

View File

@@ -35,6 +35,15 @@ export interface TaskStoredFileAttachment {
size: number
}
export interface TaskStoredMessageContext {
kind: string
label: string
workflowId?: string
knowledgeId?: string
tableId?: string
fileId?: string
}
export interface TaskStoredMessage {
id: string
role: 'user' | 'assistant'
@@ -42,6 +51,7 @@ export interface TaskStoredMessage {
toolCalls?: TaskStoredToolCall[]
contentBlocks?: TaskStoredContentBlock[]
fileAttachments?: TaskStoredFileAttachment[]
contexts?: TaskStoredMessageContext[]
}
export interface TaskStoredContentBlock {

View File

@@ -21,6 +21,8 @@ export type AgentContextType =
| 'blocks'
| 'logs'
| 'knowledge'
| 'table'
| 'file'
| 'templates'
| 'workflow_block'
| 'docs'
@@ -120,6 +122,16 @@ export async function processContextsServer(
if (ctx.kind === 'workflow_block' && ctx.workflowId && ctx.blockId) {
return await processWorkflowBlockFromDb(ctx.workflowId, ctx.blockId, ctx.label)
}
if (ctx.kind === 'table' && ctx.tableId) {
const result = await resolveTableResource(ctx.tableId)
if (!result) return null
return { type: 'table', tag: ctx.label ? `@${ctx.label}` : '@', content: result.content }
}
if (ctx.kind === 'file' && ctx.fileId && workspaceId) {
const result = await resolveFileResource(ctx.fileId, workspaceId)
if (!result) return null
return { type: 'file', tag: ctx.label ? `@${ctx.label}` : '@', content: result.content }
}
if (ctx.kind === 'docs') {
try {
const { searchDocumentationServerTool } = await import(

View File

@@ -101,6 +101,8 @@ export type ChatContext =
| { kind: 'logs'; executionId?: string; label: string }
| { kind: 'workflow_block'; workflowId: string; blockId: string; label: string }
| { kind: 'knowledge'; knowledgeId?: string; label: string }
| { kind: 'table'; tableId: string; label: string }
| { kind: 'file'; fileId: string; label: string }
| { kind: 'templates'; templateId?: string; label: string }
| { kind: 'docs'; label: string }
| { kind: 'slash_command'; command: string; label: string }