mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-21 21:15:56 +08:00
feat(mothership): request ids (#3645)
* Include rid * Persist rid * fix ui * address comments * update types --------- Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>
This commit is contained in:
co-authored by
Vikhyath Mondreti
parent
5f89c7140c
commit
168cd585cb
@@ -279,6 +279,7 @@ export async function POST(req: NextRequest) {
|
||||
role: 'assistant' as const,
|
||||
content: result.content,
|
||||
timestamp: new Date().toISOString(),
|
||||
...(result.requestId ? { requestId: result.requestId } : {}),
|
||||
}
|
||||
if (result.toolCalls.length > 0) {
|
||||
assistantMessage.toolCalls = result.toolCalls
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export { ErrorState, type ErrorStateProps } from './error'
|
||||
export { InlineRenameInput } from './inline-rename-input'
|
||||
export { MessageActions } from './message-actions'
|
||||
export { ownerCell } from './resource/components/owner-cell/owner-cell'
|
||||
export type {
|
||||
BreadcrumbEditing,
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { MessageActions } from './message-actions'
|
||||
@@ -0,0 +1,84 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Check, Copy, Ellipsis, Hash } from 'lucide-react'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/emcn'
|
||||
|
||||
interface MessageActionsProps {
|
||||
content: string
|
||||
requestId?: string
|
||||
}
|
||||
|
||||
export function MessageActions({ content, requestId }: MessageActionsProps) {
|
||||
const [copied, setCopied] = useState<'message' | 'request' | null>(null)
|
||||
const resetTimeoutRef = useRef<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (resetTimeoutRef.current !== null) {
|
||||
window.clearTimeout(resetTimeoutRef.current)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const copyToClipboard = useCallback(async (text: string, type: 'message' | 'request') => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
setCopied(type)
|
||||
if (resetTimeoutRef.current !== null) {
|
||||
window.clearTimeout(resetTimeoutRef.current)
|
||||
}
|
||||
resetTimeoutRef.current = window.setTimeout(() => setCopied(null), 1500)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (!content && !requestId) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type='button'
|
||||
aria-label='More options'
|
||||
className='flex h-5 w-5 items-center justify-center rounded-sm text-[var(--text-icon)] opacity-0 transition-colors transition-opacity hover:bg-[var(--surface-3)] hover:text-[var(--text-primary)] focus-visible:opacity-100 focus-visible:outline-none group-hover/msg:opacity-100 data-[state=open]:opacity-100'
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<Ellipsis className='h-3 w-3' strokeWidth={2} />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end' side='top' sideOffset={4}>
|
||||
<DropdownMenuItem
|
||||
disabled={!content}
|
||||
onSelect={(event) => {
|
||||
event.stopPropagation()
|
||||
void copyToClipboard(content, 'message')
|
||||
}}
|
||||
>
|
||||
{copied === 'message' ? <Check /> : <Copy />}
|
||||
<span>Copy Message</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
disabled={!requestId}
|
||||
onSelect={(event) => {
|
||||
event.stopPropagation()
|
||||
if (requestId) {
|
||||
void copyToClipboard(requestId, 'request')
|
||||
}
|
||||
}}
|
||||
>
|
||||
{copied === 'request' ? <Check /> : <Hash />}
|
||||
<span>Copy Request ID</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
LandingWorkflowSeedStorage,
|
||||
} from '@/lib/core/utils/browser-storage'
|
||||
import { persistImportedWorkflow } from '@/lib/workflows/operations/import-export'
|
||||
import { MessageActions } from '@/app/workspace/[workspaceId]/components'
|
||||
import { useChatHistory, useMarkTaskRead } from '@/hooks/queries/tasks'
|
||||
import type { ChatContext } from '@/stores/panel'
|
||||
import { useSidebarStore } from '@/stores/sidebar/store'
|
||||
@@ -414,7 +415,12 @@ export function Home({ chatId }: HomeProps = {}) {
|
||||
const isLastMessage = index === messages.length - 1
|
||||
|
||||
return (
|
||||
<div key={msg.id} className='pb-4'>
|
||||
<div key={msg.id} className='group/msg relative pb-5'>
|
||||
{!isThisStreaming && (msg.content || msg.contentBlocks?.length) && (
|
||||
<div className='absolute right-0 bottom-0 z-10'>
|
||||
<MessageActions content={msg.content} requestId={msg.requestId} />
|
||||
</div>
|
||||
)}
|
||||
<MessageContent
|
||||
blocks={msg.contentBlocks || []}
|
||||
fallbackContent={msg.content}
|
||||
|
||||
@@ -142,6 +142,7 @@ function mapStoredMessage(msg: TaskStoredMessage): ChatMessage {
|
||||
id: msg.id,
|
||||
role: msg.role,
|
||||
content: msg.content,
|
||||
...(msg.requestId ? { requestId: msg.requestId } : {}),
|
||||
}
|
||||
|
||||
const hasContentBlocks = Array.isArray(msg.contentBlocks) && msg.contentBlocks.length > 0
|
||||
@@ -509,6 +510,7 @@ export function useChat(
|
||||
let activeSubagent: string | undefined
|
||||
let runningText = ''
|
||||
let lastContentSource: 'main' | 'subagent' | null = null
|
||||
let streamRequestId: string | undefined
|
||||
|
||||
streamingContentRef.current = ''
|
||||
streamingBlocksRef.current = []
|
||||
@@ -526,14 +528,21 @@ export function useChat(
|
||||
const flush = () => {
|
||||
if (isStale()) return
|
||||
streamingBlocksRef.current = [...blocks]
|
||||
const snapshot = { content: runningText, contentBlocks: [...blocks] }
|
||||
const snapshot: Partial<ChatMessage> = {
|
||||
content: runningText,
|
||||
contentBlocks: [...blocks],
|
||||
}
|
||||
if (streamRequestId) snapshot.requestId = streamRequestId
|
||||
setMessages((prev) => {
|
||||
if (expectedGen !== undefined && streamGenRef.current !== expectedGen) return prev
|
||||
const idx = prev.findIndex((m) => m.id === assistantId)
|
||||
if (idx >= 0) {
|
||||
return prev.map((m) => (m.id === assistantId ? { ...m, ...snapshot } : m))
|
||||
}
|
||||
return [...prev, { id: assistantId, role: 'assistant' as const, ...snapshot }]
|
||||
return [
|
||||
...prev,
|
||||
{ id: assistantId, role: 'assistant' as const, content: '', ...snapshot },
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
@@ -597,6 +606,14 @@ export function useChat(
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'request_id': {
|
||||
const rid = typeof parsed.data === 'string' ? parsed.data : undefined
|
||||
if (rid) {
|
||||
streamRequestId = rid
|
||||
flush()
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'content': {
|
||||
const chunk = typeof parsed.data === 'string' ? parsed.data : (parsed.content ?? '')
|
||||
if (chunk) {
|
||||
|
||||
@@ -33,6 +33,7 @@ export interface QueuedMessage {
|
||||
*/
|
||||
export type SSEEventType =
|
||||
| 'chat_id'
|
||||
| 'request_id'
|
||||
| 'title_updated'
|
||||
| 'content'
|
||||
| 'reasoning' // openai reasoning - render as thinking text
|
||||
@@ -199,6 +200,7 @@ export interface ChatMessage {
|
||||
contentBlocks?: ContentBlock[]
|
||||
attachments?: ChatMessageAttachment[]
|
||||
contexts?: ChatMessageContext[]
|
||||
requestId?: string
|
||||
}
|
||||
|
||||
export const SUBAGENT_LABELS: Record<SubagentName, string> = {
|
||||
|
||||
+8
-2
@@ -3,6 +3,7 @@
|
||||
import { type FC, memo, useCallback, useMemo, useRef, useState } from 'react'
|
||||
import { RotateCcw } from 'lucide-react'
|
||||
import { Button } from '@/components/emcn'
|
||||
import { MessageActions } from '@/app/workspace/[workspaceId]/components'
|
||||
import {
|
||||
OptionsSelector,
|
||||
parseSpecialTags,
|
||||
@@ -409,10 +410,15 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
||||
if (isAssistant) {
|
||||
return (
|
||||
<div
|
||||
className={`w-full max-w-full flex-none overflow-hidden [max-width:var(--panel-max-width)] ${isDimmed ? 'opacity-40' : 'opacity-100'}`}
|
||||
className={`group/msg relative w-full max-w-full flex-none overflow-hidden [max-width:var(--panel-max-width)] ${isDimmed ? 'opacity-40' : 'opacity-100'}`}
|
||||
style={{ '--panel-max-width': `${panelWidth - 16}px` } as React.CSSProperties}
|
||||
>
|
||||
<div className='max-w-full space-y-[4px] px-[2px] pb-[4px]'>
|
||||
{!isStreaming && (message.content || message.contentBlocks?.length) && (
|
||||
<div className='absolute right-0 bottom-0 z-10'>
|
||||
<MessageActions content={message.content} requestId={message.requestId} />
|
||||
</div>
|
||||
)}
|
||||
<div className='max-w-full space-y-[4px] px-[2px] pb-5'>
|
||||
{/* Content blocks in chronological order */}
|
||||
{memoizedContentBlocks || (isStreaming && <div className='min-h-0' />)}
|
||||
|
||||
|
||||
@@ -54,6 +54,7 @@ export interface TaskStoredMessage {
|
||||
id: string
|
||||
role: 'user' | 'assistant'
|
||||
content: string
|
||||
requestId?: string
|
||||
toolCalls?: TaskStoredToolCall[]
|
||||
contentBlocks?: TaskStoredContentBlock[]
|
||||
fileAttachments?: TaskStoredFileAttachment[]
|
||||
|
||||
@@ -92,6 +92,7 @@ export function flushStreamingUpdates(set: StoreSet) {
|
||||
if (update) {
|
||||
return {
|
||||
...msg,
|
||||
requestId: update.requestId ?? msg.requestId,
|
||||
content: '',
|
||||
contentBlocks:
|
||||
update.contentBlocks.length > 0
|
||||
@@ -129,6 +130,7 @@ export function updateStreamingMessage(set: StoreSet, context: ClientStreamingCo
|
||||
const newMessages = [...messages]
|
||||
newMessages[messages.length - 1] = {
|
||||
...lastMessage,
|
||||
requestId: lastMessageUpdate.requestId ?? lastMessage.requestId,
|
||||
content: '',
|
||||
contentBlocks:
|
||||
lastMessageUpdate.contentBlocks.length > 0
|
||||
@@ -143,6 +145,7 @@ export function updateStreamingMessage(set: StoreSet, context: ClientStreamingCo
|
||||
if (update) {
|
||||
return {
|
||||
...msg,
|
||||
requestId: update.requestId ?? msg.requestId,
|
||||
content: '',
|
||||
contentBlocks:
|
||||
update.contentBlocks.length > 0
|
||||
@@ -429,6 +432,12 @@ export const sseHandlers: Record<string, SSEHandler> = {
|
||||
writeActiveStreamToStorage(updatedStream)
|
||||
}
|
||||
},
|
||||
request_id: (data, context) => {
|
||||
const requestId = typeof data.data === 'string' ? data.data : undefined
|
||||
if (requestId) {
|
||||
context.requestId = requestId
|
||||
}
|
||||
},
|
||||
title_updated: (_data, _context, get, set) => {
|
||||
const title = _data.title
|
||||
if (!title) return
|
||||
|
||||
@@ -22,6 +22,7 @@ export interface ClientContentBlock {
|
||||
|
||||
export interface StreamingContext {
|
||||
messageId: string
|
||||
requestId?: string
|
||||
accumulatedContent: string
|
||||
contentBlocks: ClientContentBlock[]
|
||||
currentTextBlock: ClientContentBlock | null
|
||||
|
||||
@@ -141,6 +141,10 @@ export function serializeMessagesForDB(
|
||||
timestamp,
|
||||
}
|
||||
|
||||
if (msg.requestId) {
|
||||
serialized.requestId = msg.requestId
|
||||
}
|
||||
|
||||
if (Array.isArray(msg.contentBlocks) && msg.contentBlocks.length > 0) {
|
||||
serialized.contentBlocks = deepClone(msg.contentBlocks)
|
||||
}
|
||||
|
||||
@@ -76,6 +76,7 @@ export async function orchestrateCopilotStream(
|
||||
contentBlocks: context.contentBlocks,
|
||||
toolCalls: buildToolCallSummaries(context),
|
||||
chatId: context.chatId,
|
||||
requestId: context.requestId,
|
||||
errors: context.errors.length ? context.errors : undefined,
|
||||
usage: context.usage,
|
||||
cost: context.cost,
|
||||
|
||||
@@ -187,6 +187,12 @@ export const sseHandlers: Record<string, SSEHandler> = {
|
||||
execContext.chatId = chatId
|
||||
}
|
||||
},
|
||||
request_id: (event, context) => {
|
||||
const rid = typeof event.data === 'string' ? event.data : undefined
|
||||
if (rid) {
|
||||
context.requestId = rid
|
||||
}
|
||||
},
|
||||
title_updated: () => {},
|
||||
tool_result: (event, context) => {
|
||||
const data = getEventData(event)
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { MothershipResource } from '@/lib/copilot/resource-types'
|
||||
|
||||
export type SSEEventType =
|
||||
| 'chat_id'
|
||||
| 'request_id'
|
||||
| 'title_updated'
|
||||
| 'content'
|
||||
| 'reasoning'
|
||||
@@ -88,6 +89,7 @@ export interface ContentBlock {
|
||||
|
||||
export interface StreamingContext {
|
||||
chatId?: string
|
||||
requestId?: string
|
||||
messageId: string
|
||||
accumulatedContent: string
|
||||
contentBlocks: ContentBlock[]
|
||||
@@ -154,6 +156,7 @@ export interface OrchestratorResult {
|
||||
contentBlocks: ContentBlock[]
|
||||
toolCalls: ToolCallSummary[]
|
||||
chatId?: string
|
||||
requestId?: string
|
||||
error?: string
|
||||
errors?: string[]
|
||||
usage?: { prompt: number; completion: number }
|
||||
|
||||
@@ -224,6 +224,7 @@ function replaceTextBlocks(blocks: ClientContentBlock[], text: string): ClientCo
|
||||
function createClientStreamingContext(messageId: string): ClientStreamingContext {
|
||||
return {
|
||||
messageId,
|
||||
requestId: undefined,
|
||||
accumulatedContent: '',
|
||||
contentBlocks: [],
|
||||
currentTextBlock: null,
|
||||
@@ -2043,6 +2044,7 @@ export const useCopilotStore = create<CopilotStore>()(
|
||||
msg.id === assistantMessageId
|
||||
? {
|
||||
...msg,
|
||||
requestId: context.requestId ?? msg.requestId,
|
||||
content: finalContentWithOptions,
|
||||
contentBlocks: sanitizedContentBlocks,
|
||||
}
|
||||
|
||||
@@ -70,6 +70,7 @@ export interface CopilotMessage {
|
||||
role: 'user' | 'assistant' | 'system'
|
||||
content: string
|
||||
timestamp: string
|
||||
requestId?: string
|
||||
citations?: { id: number; title: string; url: string; similarity?: number }[]
|
||||
toolCalls?: CopilotToolCall[]
|
||||
contentBlocks?: ClientContentBlock[]
|
||||
|
||||
Reference in New Issue
Block a user