mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
fix(chat): copy orchestrator responses as markdown (#6903)
This commit is contained in:
+17
-44
@@ -15,6 +15,7 @@ import {
|
||||
ThumbsUp,
|
||||
Tooltip,
|
||||
toast,
|
||||
useCopyToClipboard,
|
||||
} from '@sim/emcn'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { isLiveAssistantMessageId } from '@/lib/copilot/chat/effective-transcript'
|
||||
@@ -23,34 +24,15 @@ import { useSubmitCopilotFeedback } from '@/hooks/queries/copilot-feedback'
|
||||
import { useForkMothershipChat } from '@/hooks/queries/mothership-chats'
|
||||
import { useFolderStore } from '@/stores/folders/store'
|
||||
|
||||
const SPECIAL_TAGS = 'thinking|options|usage_upgrade|credential|mothership-error|file|question'
|
||||
|
||||
function toPlainText(raw: string): string {
|
||||
return (
|
||||
raw
|
||||
// Strip special tags and their contents
|
||||
.replace(new RegExp(`<\\/?(${SPECIAL_TAGS})(?:>[\\s\\S]*?<\\/(${SPECIAL_TAGS})>|>)`, 'g'), '')
|
||||
// Strip markdown
|
||||
.replace(/^#{1,6}\s+/gm, '')
|
||||
.replace(/\*\*(.+?)\*\*/g, '$1')
|
||||
.replace(/\*(.+?)\*/g, '$1')
|
||||
.replace(/`{3}[\s\S]*?`{3}/g, '')
|
||||
.replace(/`(.+?)`/g, '$1')
|
||||
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
|
||||
.replace(/^[>\-*]\s+/gm, '')
|
||||
.replace(/!\[[^\]]*\]\([^)]+\)/g, '')
|
||||
// Normalize whitespace
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim()
|
||||
)
|
||||
}
|
||||
|
||||
const ICON_CLASS = 'size-[14px]'
|
||||
const BUTTON_CLASS =
|
||||
'flex size-[26px] items-center justify-center rounded-[6px] text-[var(--text-icon)] transition-colors hover-hover:bg-[var(--surface-hover)] focus-visible:outline-none'
|
||||
|
||||
interface MessageActionsProps {
|
||||
content: string
|
||||
getCopyContent?: () => string
|
||||
hasCopyContent?: boolean
|
||||
prepareContentForCopy?: (content: string) => string
|
||||
userQuery?: string
|
||||
requestId?: string
|
||||
messageId?: string
|
||||
@@ -58,6 +40,9 @@ interface MessageActionsProps {
|
||||
|
||||
export const MessageActions = memo(function MessageActions({
|
||||
content,
|
||||
getCopyContent,
|
||||
hasCopyContent,
|
||||
prepareContentForCopy,
|
||||
userQuery,
|
||||
requestId,
|
||||
messageId,
|
||||
@@ -65,40 +50,28 @@ export const MessageActions = memo(function MessageActions({
|
||||
const router = useRouter()
|
||||
const params = useParams<{ workspaceId: string }>()
|
||||
const { chatId } = useChatSurface()
|
||||
const [copied, setCopied] = useState(false)
|
||||
const { copied, copy: copyMessage } = useCopyToClipboard({ resetMs: 1500 })
|
||||
const [copiedRequestId, setCopiedRequestId] = useState(false)
|
||||
const [pendingFeedback, setPendingFeedback] = useState<'up' | 'down' | null>(null)
|
||||
const [feedbackText, setFeedbackText] = useState('')
|
||||
const resetTimeoutRef = useRef<number | null>(null)
|
||||
const requestIdTimeoutRef = useRef<number | null>(null)
|
||||
const submitFeedback = useSubmitCopilotFeedback()
|
||||
const forkChat = useForkMothershipChat(params.workspaceId)
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (resetTimeoutRef.current !== null) {
|
||||
window.clearTimeout(resetTimeoutRef.current)
|
||||
}
|
||||
if (requestIdTimeoutRef.current !== null) {
|
||||
window.clearTimeout(requestIdTimeoutRef.current)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const copyToClipboard = async () => {
|
||||
if (!content) return
|
||||
const text = toPlainText(content)
|
||||
if (!text) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
setCopied(true)
|
||||
if (resetTimeoutRef.current !== null) {
|
||||
window.clearTimeout(resetTimeoutRef.current)
|
||||
}
|
||||
resetTimeoutRef.current = window.setTimeout(() => setCopied(false), 1500)
|
||||
} catch {
|
||||
/* clipboard unavailable */
|
||||
}
|
||||
const copyToClipboard = () => {
|
||||
const contentToCopy = getCopyContent?.() ?? content
|
||||
if (!contentToCopy) return
|
||||
const markdown = prepareContentForCopy?.(contentToCopy) ?? contentToCopy
|
||||
if (!markdown) return
|
||||
void copyMessage(markdown)
|
||||
}
|
||||
|
||||
const copyRequestId = async () => {
|
||||
@@ -166,18 +139,18 @@ export const MessageActions = memo(function MessageActions({
|
||||
}
|
||||
}
|
||||
|
||||
const hasContent = Boolean(content)
|
||||
const canCopyContent = hasCopyContent ?? Boolean(content)
|
||||
const canSubmitFeedback = Boolean(chatId && userQuery)
|
||||
// A live (just-streamed) assistant message carries a synthetic id that the
|
||||
// persisted transcript doesn't know — forking it would 400. The button
|
||||
// appears once the transcript refetch swaps in the persisted message id.
|
||||
const canFork = Boolean(chatId && messageId && !isLiveAssistantMessageId(messageId))
|
||||
if (!hasContent && !canSubmitFeedback && !canFork) return null
|
||||
if (!canCopyContent && !canSubmitFeedback && !canFork) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='flex items-center gap-0.5'>
|
||||
{hasContent && (
|
||||
{canCopyContent && (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<button
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export {
|
||||
assistantMessageHasRenderableContent,
|
||||
getOrchestratorMessageText,
|
||||
MessageContent,
|
||||
} from './message-content'
|
||||
export type { MessagePhase } from './utils'
|
||||
|
||||
+61
@@ -27,6 +27,7 @@ import type { ContentBlock } from '../../types'
|
||||
import {
|
||||
assistantMessageHasVisibleExecutingTool,
|
||||
deriveThinkingLabel,
|
||||
getOrchestratorMessageText,
|
||||
parseBlocks,
|
||||
shouldSmoothTextSegment,
|
||||
} from './message-content'
|
||||
@@ -100,6 +101,66 @@ function toolEnvelope(
|
||||
} as PersistedStreamEventEnvelope
|
||||
}
|
||||
|
||||
describe('getOrchestratorMessageText', () => {
|
||||
it('copies only orchestrator text from span-based messages', () => {
|
||||
const blocks: ContentBlock[] = [
|
||||
subagentStart('research', 'span-visible', 'main'),
|
||||
{
|
||||
type: 'subagent_text',
|
||||
content: 'Visible research. ',
|
||||
spanId: 'span-visible',
|
||||
timestamp: 2,
|
||||
},
|
||||
{
|
||||
type: 'subagent_text',
|
||||
content: 'Hidden orphan. ',
|
||||
spanId: 'span-orphan',
|
||||
timestamp: 3,
|
||||
},
|
||||
mainText('Main answer.'),
|
||||
]
|
||||
|
||||
expect(getOrchestratorMessageText(blocks, 'Fallback.')).toBe('Main answer.')
|
||||
})
|
||||
|
||||
it('copies only orchestrator text from legacy messages', () => {
|
||||
const blocks: ContentBlock[] = [
|
||||
{ type: 'subagent_text', content: 'Hidden orphan. ', timestamp: 1 },
|
||||
{
|
||||
type: 'subagent',
|
||||
content: 'research',
|
||||
parentToolCallId: 'dispatch-visible',
|
||||
timestamp: 2,
|
||||
},
|
||||
{
|
||||
type: 'subagent_text',
|
||||
content: 'Visible research. ',
|
||||
parentToolCallId: 'dispatch-visible',
|
||||
timestamp: 3,
|
||||
},
|
||||
mainText('Main answer.'),
|
||||
]
|
||||
|
||||
expect(getOrchestratorMessageText(blocks, 'Fallback.')).toBe('Main answer.')
|
||||
})
|
||||
|
||||
it('separates orchestrator text blocks around excluded subagent output', () => {
|
||||
const blocks: ContentBlock[] = [
|
||||
mainText('Starting answer.'),
|
||||
subagentStart('research', 'span-visible', 'main'),
|
||||
{
|
||||
type: 'subagent_text',
|
||||
content: 'Visible research.',
|
||||
spanId: 'span-visible',
|
||||
timestamp: 2,
|
||||
},
|
||||
mainText('Main answer.'),
|
||||
]
|
||||
|
||||
expect(getOrchestratorMessageText(blocks, 'Fallback.')).toBe('Starting answer.\n\nMain answer.')
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseBlocks span-identity tree', () => {
|
||||
it('refines a completed credential rename with its previous and new names', () => {
|
||||
const segments = parseBlocks([
|
||||
|
||||
+17
@@ -492,6 +492,23 @@ export function parseBlocks(blocks: ContentBlock[]): MessageSegment[] {
|
||||
return parseBlocksLegacy(blocks)
|
||||
}
|
||||
|
||||
function joinRenderableText(parts: string[]): string {
|
||||
return parts.filter(Boolean).join('\n\n')
|
||||
}
|
||||
|
||||
/** Returns only top-level orchestrator text, excluding agent groups and other UI segments. */
|
||||
export function getOrchestratorMessageText(
|
||||
blocks: ContentBlock[],
|
||||
fallbackContent: string
|
||||
): string {
|
||||
const parsed = blocks.length > 0 ? parseBlocks(blocks) : []
|
||||
if (parsed.length === 0) return fallbackContent
|
||||
|
||||
return joinRenderableText(
|
||||
parsed.map((segment) => (segment.type === 'text' ? segment.content : ''))
|
||||
)
|
||||
}
|
||||
|
||||
function parseBlocksLegacy(blocks: ContentBlock[]): MessageSegment[] {
|
||||
const segments: MessageSegment[] = []
|
||||
const groupsByKey = new Map<string, AgentGroupSegment>()
|
||||
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { toCopyableMarkdown } from '@/app/workspace/[workspaceId]/home/components/mothership-chat/copyable-markdown'
|
||||
|
||||
describe('toCopyableMarkdown', () => {
|
||||
it('preserves message Markdown, including fenced code and its language', () => {
|
||||
const message = [
|
||||
'# Elevator diagnosis',
|
||||
'',
|
||||
'The bug is in `dispatch_legacy.py`:',
|
||||
'',
|
||||
'```python',
|
||||
'def next_stop(requests, current):',
|
||||
' ranked = sorted(requests)',
|
||||
' return ranked[1:]',
|
||||
'```',
|
||||
'',
|
||||
'**Result:** the closest request *was not* always selected.',
|
||||
].join('\n')
|
||||
|
||||
expect(toCopyableMarkdown(message)).toBe(message)
|
||||
})
|
||||
|
||||
it('removes internal structured tags without flattening surrounding Markdown', () => {
|
||||
const message = [
|
||||
'Before **formatted text**.',
|
||||
'<credential>{"type":"service_account","provider":"gmail"}</credential>',
|
||||
'After [a link](https://example.com).',
|
||||
].join('\n')
|
||||
|
||||
expect(toCopyableMarkdown(message)).toBe(
|
||||
['Before **formatted text**.', '', 'After [a link](https://example.com).'].join('\n')
|
||||
)
|
||||
})
|
||||
|
||||
it('preserves tag-shaped text that the chat renders literally', () => {
|
||||
const message = [
|
||||
'Document `<credential>example</credential>`.',
|
||||
'',
|
||||
'```html',
|
||||
'<file>example</file>',
|
||||
'<question>example</question>',
|
||||
'```',
|
||||
].join('\n')
|
||||
|
||||
expect(toCopyableMarkdown(message)).toBe(message)
|
||||
})
|
||||
})
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
import { sanitizeChatDisplayContent } from '@/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-sanitize'
|
||||
import { parseSpecialTags } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags'
|
||||
|
||||
export function toCopyableMarkdown(raw: string): string {
|
||||
const displayContent = sanitizeChatDisplayContent(raw)
|
||||
const { segments } = parseSpecialTags(displayContent, false)
|
||||
|
||||
return segments
|
||||
.reduce((markdown, segment) => {
|
||||
return segment.type === 'text' ? markdown + segment.content : markdown
|
||||
}, '')
|
||||
.trim()
|
||||
}
|
||||
+11
@@ -18,6 +18,7 @@ import { ChatMessageAttachments } from '@/app/workspace/[workspaceId]/home/compo
|
||||
import { ChatSurfaceProvider } from '@/app/workspace/[workspaceId]/home/components/chat-surface-context'
|
||||
import {
|
||||
assistantMessageHasRenderableContent,
|
||||
getOrchestratorMessageText,
|
||||
MessageContent,
|
||||
type MessagePhase,
|
||||
} from '@/app/workspace/[workspaceId]/home/components/message-content'
|
||||
@@ -29,6 +30,7 @@ import {
|
||||
parseLastCredentialTag,
|
||||
parseLastQuestionTag,
|
||||
} from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags'
|
||||
import { toCopyableMarkdown } from '@/app/workspace/[workspaceId]/home/components/mothership-chat/copyable-markdown'
|
||||
import { nextSizerFloor } from '@/app/workspace/[workspaceId]/home/components/mothership-chat/sizer-floor'
|
||||
import { QueuedMessages } from '@/app/workspace/[workspaceId]/home/components/queued-messages'
|
||||
import {
|
||||
@@ -225,6 +227,12 @@ const AssistantMessageRow = memo(function AssistantMessageRow({
|
||||
onAnimatingChangeRef.current?.(phase !== 'settled')
|
||||
}, [phase])
|
||||
|
||||
const getCopyContent = useCallback(
|
||||
() => getOrchestratorMessageText(blocks, message.content),
|
||||
[blocks, message.content]
|
||||
)
|
||||
const prepareContentForCopy = useCallback((content: string) => toCopyableMarkdown(content), [])
|
||||
|
||||
const hasRenderableAssistant = assistantMessageHasRenderableContent(blocks, message.content ?? '')
|
||||
if (!hasRenderableAssistant && !trimmedContent && !isStreaming) {
|
||||
return null
|
||||
@@ -281,6 +289,9 @@ const AssistantMessageRow = memo(function AssistantMessageRow({
|
||||
actionsEligible ? (
|
||||
<MessageActions
|
||||
content={message.content}
|
||||
getCopyContent={getCopyContent}
|
||||
hasCopyContent={Boolean(getOrchestratorMessageText(blocks, message.content).trim())}
|
||||
prepareContentForCopy={prepareContentForCopy}
|
||||
userQuery={precedingUserContent}
|
||||
requestId={message.requestId}
|
||||
messageId={message.id}
|
||||
|
||||
Reference in New Issue
Block a user