Repository navigation
Show streamed Expert tool calls, reasoning and progress messages #8895
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
9c1302d
5e21fe3
76b80b8
16759a1
4691884
679afd5
a95e217
23655d4
d4c632b
fe2b006
fee3cbc
2b1551f
61bcac2
55ef515
9133790
68bd792
3c31571
45ef699
e823c2b
5bd0a12
b3aa0d0
d7a1553
e8883b7
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,6 +1,10 @@ | ||
| <template> | ||
| <div class="flex flex-col gap-3"> | ||
| <tool-calls v-if="toolCalls" :message="toolCalls" /> | ||
| <template v-for="item in activity" :key="item._uuid"> | ||
| <reasoning-block v-if="item.kind === 'reasoning'" :item="item" :message-uuid="_uuid" /> | ||
| <agent-message v-else-if="item.kind === 'message'" :item="item" :message-uuid="_uuid" /> | ||
| <timeline-item v-else :item="item" /> | ||
| </template> | ||
| <answer-wrapper | ||
| v-for="(fAnswer, key) in visibleItems" | ||
| :key="slugify(`${fAnswer.kind}-${fAnswer.title}-${fAnswer.summary}-${fAnswer._uuid}`)" | ||
|
|
@@ -13,16 +17,18 @@ | |
| </template> | ||
|
|
||
| <script> | ||
| import AgentMessage from './components/AgentMessage.vue' | ||
| import AnswerWrapper from './components/AnswerWrapper.vue' | ||
| import ReasoningBlock from './components/ReasoningBlock.vue' | ||
| import TimelineItem from './components/TimelineItem.vue' | ||
|
|
||
| import ToolCalls from './components/ToolCalls.vue' | ||
|
|
||
| import { ACTIVITY_KINDS } from '@/composables/Components/expert/streamedActivity.js' | ||
| import useStreamingList from '@/composables/StreamingListHelper.js' | ||
| import { slugify } from '@/composables/strings/String.js' | ||
|
|
||
| export default { | ||
| name: 'AiMessage', | ||
| components: { ToolCalls, AnswerWrapper }, | ||
| components: { AgentMessage, AnswerWrapper, ReasoningBlock, TimelineItem }, | ||
| props: { | ||
| query: { | ||
| type: String, | ||
|
|
@@ -58,6 +64,12 @@ export default { | |
| required: true, | ||
| type: Boolean | ||
| }, | ||
| // eslint-disable-next-line vue/prop-name-casing | ||
| _transactionId: { | ||
| required: false, | ||
| type: String, | ||
| default: null | ||
| }, | ||
| instant: { | ||
| type: Boolean, | ||
| default: false | ||
|
|
@@ -69,45 +81,23 @@ export default { | |
| return { initStreamer, setSubItemStreamedState, visibleItems, items } | ||
| }, | ||
| computed: { | ||
| toolCalls () { | ||
| // todo we need to reconsider how we serve data to the tool calls component. This is ok for now as it | ||
| // only impacts the immediate child component (was hacked out of the expert store) | ||
| const mcpItems = this.answer.filter(answer => ['mcp_tool', 'mcp_resource', 'mcp_resource_template', 'mcp_prompt'].includes(answer.kind)) | ||
|
|
||
| // Handle MCP calls if present - includes tools, resources, and prompts | ||
| if (mcpItems.length > 0) { | ||
| const toolCalls = mcpItems.map(item => ({ | ||
| id: item.toolId, | ||
| name: item.toolName, | ||
| title: item.toolTitle || item.toolName, | ||
| kind: item.kind, | ||
| args: item.input, | ||
| output: item.output, | ||
| durationMs: item.durationMs | ||
| })) | ||
|
|
||
| // Calculate total duration in seconds | ||
| const totalDurationMs = mcpItems.reduce((sum, item) => sum + (item.durationMs || 0), 0) | ||
| const totalDurationSec = (totalDurationMs / 1000).toFixed(2) | ||
|
|
||
| return { | ||
| type: 'ai', | ||
| kind: 'tool_calls', | ||
| toolCalls, | ||
| duration: totalDurationSec, | ||
| content: `${toolCalls.length} tool call(s)`, | ||
| timestamp: Date.now() | ||
| } | ||
| } | ||
|
|
||
| return null | ||
| activity () { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Small UX note, not a bug: a paginated turn gave me ten identical "List Hosted Instances" cards with nothing to expand, ten rows where the old strip was one collapsed line. Could be worth folding consecutive identical cards without a reason into one, or leaving it as a follow-up.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Every tool call now has to carry a reason, in support and Insights alike, so each paged call should get its own card with its reason and something to expand. Cards with nothing to expand would come from a turn on an Expert from before that change, or from an older saved conversation. Could you check which it was? If it still happens on a fresh turn, I'll look into it.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Try with Expert on Cloud, it should be there @cstns |
||
| return this.answer.filter(answer => ACTIVITY_KINDS.includes(answer.kind)) | ||
| }, | ||
| filteredAnswers () { | ||
| return this.answer.filter(answer => !['mcp_tool', 'mcp_resource', 'mcp_resource_template', 'mcp_prompt'].includes(answer.kind)) | ||
| terminalAnswers () { | ||
| return this.answer.filter(answer => !ACTIVITY_KINDS.includes(answer.kind)) | ||
| } | ||
| }, | ||
| watch: { | ||
| // A streamed bubble mounts holding only activity; the final reply merges in later. | ||
| terminalAnswers (answers) { | ||
| if (answers.length > this.visibleItems.length) { | ||
| this.initStreamer(answers, { shouldStream: !this.instant && !this._streamed }) | ||
| } | ||
| } | ||
| }, | ||
| async mounted () { | ||
| await this.initStreamer(this.filteredAnswers, { shouldStream: !this.instant && !this._streamed }) | ||
| await this.initStreamer(this.terminalAnswers, { shouldStream: !this.instant && !this._streamed }) | ||
| }, | ||
| methods: { slugify } | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,37 @@ | ||
| <template> | ||
| <message-bubble type="ai" class="ff-expert-agent-message"> | ||
| <rich-content | ||
| :content="item.text || ''" | ||
| :message-uuid="messageUuid" | ||
| :answer-uuid="item._uuid" | ||
| :should-stream="false" | ||
| /> | ||
| </message-bubble> | ||
| </template> | ||
|
|
||
| <script> | ||
| import MessageBubble from './MessageBubble.vue' | ||
| import RichContent from './resources/RichContent.vue' | ||
|
|
||
| export default { | ||
| name: 'AgentMessage', | ||
| components: { MessageBubble, RichContent }, | ||
| props: { | ||
| item: { | ||
| type: Object, | ||
| required: true | ||
| }, | ||
| messageUuid: { | ||
| type: String, | ||
| required: true | ||
| } | ||
| } | ||
| } | ||
| </script> | ||
|
|
||
| <style scoped lang="scss"> | ||
| .ff-expert-agent-message { | ||
| align-self: flex-start; | ||
| max-width: 90%; | ||
| } | ||
| </style> |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,87 @@ | ||
| <template> | ||
| <collapsible-section class="ff-expert-reasoning"> | ||
| <template #header> | ||
| <LightBulbIcon class="ff-expert-reasoning--glyph" /> | ||
| <span class="ff-expert-reasoning--label">{{ item.done ? 'Thought' : 'Thinking' }}</span> | ||
| </template> | ||
| <template #summary="{ expanded }"> | ||
| <div v-if="preview && !expanded" class="ff-expert-reasoning--preview" :title="preview">{{ preview }}</div> | ||
| </template> | ||
| <rich-content | ||
| class="ff-expert-reasoning--body" | ||
| :content="item.text || ''" | ||
| :message-uuid="messageUuid" | ||
| :answer-uuid="item._uuid" | ||
| :should-stream="false" | ||
| /> | ||
| </collapsible-section> | ||
| </template> | ||
|
|
||
| <script> | ||
| import { LightBulbIcon } from '@heroicons/vue/20/solid' | ||
|
|
||
| import CollapsibleSection from './CollapsibleSection.vue' | ||
| import RichContent from './resources/RichContent.vue' | ||
|
|
||
| // Collapsed whether streaming or done, so the block keeps its height while the reasoning | ||
| // arrives. One line under the header shows the latest sentence while streaming and the first | ||
| // once done. | ||
| export default { | ||
| name: 'ReasoningBlock', | ||
| components: { CollapsibleSection, LightBulbIcon, RichContent }, | ||
| props: { | ||
| item: { | ||
| type: Object, | ||
| required: true | ||
| }, | ||
| messageUuid: { | ||
| type: String, | ||
| required: true | ||
| } | ||
| }, | ||
| computed: { | ||
| preview () { | ||
| if (typeof this.item.text !== 'string') return '' | ||
| const plain = this.item.text.replace(/[*_`#>]/g, '') | ||
| const sentences = plain.split(/(?<=[.!?])\s+|\n+/).map(s => s.trim()).filter(Boolean) | ||
| return (this.item.done ? sentences[0] : sentences[sentences.length - 1]) || '' | ||
| } | ||
| } | ||
| } | ||
| </script> | ||
|
|
||
| <style scoped lang="scss"> | ||
| .ff-expert-reasoning { | ||
| max-width: 90%; | ||
| } | ||
|
|
||
| .ff-expert-reasoning--glyph { | ||
| width: 0.875rem; | ||
| height: 0.875rem; | ||
| flex-shrink: 0; | ||
| } | ||
|
|
||
| .ff-expert-reasoning--label { | ||
| font-weight: 500; | ||
| flex-shrink: 0; | ||
| } | ||
|
|
||
| .ff-expert-reasoning--preview { | ||
| padding-left: 1.5rem; | ||
| font-size: 0.8125rem; | ||
| color: var(--ff-color-text-subtle); | ||
| overflow: hidden; | ||
| text-overflow: ellipsis; | ||
| white-space: nowrap; | ||
| } | ||
|
|
||
| .ff-expert-reasoning--body { | ||
| display: block; | ||
| margin-top: 0.25rem; | ||
| padding-left: 0.75rem; | ||
| border-left: 2px solid var(--ff-color-border-subtle); | ||
| color: var(--ff-color-text-subtle); | ||
| font-size: 0.875rem; | ||
| line-height: 1.5; | ||
| } | ||
| </style> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nit: after Stop,
hasStreamedActivitygoes false as soon as the in-flight map clears, so the loader comes back with the generic rotating lines under a bubble that was just streaming. Cosmetic, and the stopping indicator predates this PR.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done in e8883b7. A turn that is still stopping keeps the loader to its dots.