mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-27 08:02:39 +00:00
perf: keep unchanged transcript rows stable while a chat reply streams (#11962)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
8a0318a28f
commit
4c97368c9d
@@ -48,7 +48,7 @@
|
||||
import { getAiChatManager } from './aiChatManagerContext'
|
||||
import ChatTypingIndicator from './ChatTypingIndicator.svelte'
|
||||
import ToolGroupDisplay from './ToolGroupDisplay.svelte'
|
||||
import { chatItemKey, groupToolRuns } from './toolGroups'
|
||||
import { chatItemKey, createChatItemGrouper } from './toolGroups'
|
||||
import AIChatInput from './AIChatInput.svelte'
|
||||
import { STARTER_PLACEHOLDER, type ComposerSelection } from './composerBox'
|
||||
import AttachedFilesBar from './files/AttachedFilesBar.svelte'
|
||||
@@ -189,7 +189,8 @@
|
||||
const mcpMenu = new McpMenu(aiChatManager, () => assistantSettings?.open('mcp'))
|
||||
let plusMenuOpen = $state(false)
|
||||
let editingMessageIndex = $state<number | null>(null)
|
||||
const chatItems = $derived(groupToolRuns(messages))
|
||||
const groupChatItems = createChatItemGrouper()
|
||||
const chatItems = $derived(groupChatItems(messages))
|
||||
|
||||
// Escape stops the generation when focus is on the chat (or parked on
|
||||
// body), but stays with other widgets (e.g. the session's Monaco editor).
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { DisplayMessage } from './shared'
|
||||
import { groupHeader, groupToolRuns, type GroupState, type ToolGroup } from './toolGroups'
|
||||
import {
|
||||
createChatItemGrouper,
|
||||
groupHeader,
|
||||
groupToolRuns,
|
||||
type GroupState,
|
||||
type ToolGroup
|
||||
} from './toolGroups'
|
||||
|
||||
let nextId = 0
|
||||
function tool(
|
||||
@@ -274,3 +280,37 @@ describe('groupToolRuns', () => {
|
||||
expect(header([mcp('list_issues'), other])).toBe('List issues, get issue')
|
||||
})
|
||||
})
|
||||
|
||||
describe('createChatItemGrouper', () => {
|
||||
it('keeps the history rows while only the live answer changes', () => {
|
||||
const group = createChatItemGrouper()
|
||||
const history = [
|
||||
assistant('done'),
|
||||
tool('read_workspace_item'),
|
||||
tool('list_runs'),
|
||||
assistant('more')
|
||||
]
|
||||
const first = group([...history, assistant('a', { streaming: true })])
|
||||
const second = group([...history, assistant('ab', { streaming: true })])
|
||||
expect(second).toHaveLength(first.length)
|
||||
second.slice(0, -1).forEach((item, i) => expect(item).toBe(first[i]))
|
||||
expect(second.at(-1)).toMatchObject({ kind: 'message', index: 4, message: { content: 'ab' } })
|
||||
expect(shape([...history, assistant('ab', { streaming: true })])).toEqual(
|
||||
second.map((item) =>
|
||||
item.kind === 'group' ? { [item.groupKind]: item.entries.map((e) => e.index) } : item.index
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps every row but the one a tool status change replaced', () => {
|
||||
const group = createChatItemGrouper()
|
||||
const history = [assistant('done'), tool('run_script'), assistant('more')]
|
||||
const first = group(history)
|
||||
const updated = [history[0], { ...history[1], isLoading: false }, history[2]]
|
||||
const second = group(updated)
|
||||
expect(second[0]).toBe(first[0])
|
||||
expect(second[1]).not.toBe(first[1])
|
||||
expect(second[1]).toMatchObject({ message: updated[1] })
|
||||
expect(second[2]).toBe(first[2])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -295,6 +295,49 @@ export function groupToolRuns(messages: DisplayMessage[]): ChatItem[] {
|
||||
return items
|
||||
}
|
||||
|
||||
function sameChatItem(a: ChatItem, b: ChatItem): boolean {
|
||||
if (a.kind === 'message') {
|
||||
return b.kind === 'message' && a.message === b.message && a.index === b.index
|
||||
}
|
||||
return (
|
||||
b.kind === 'group' &&
|
||||
a.groupKind === b.groupKind &&
|
||||
a.key === b.key &&
|
||||
a.target === b.target &&
|
||||
a.entries.length === b.entries.length &&
|
||||
a.entries.every((e, i) => e.message === b.entries[i].message && e.index === b.entries[i].index)
|
||||
)
|
||||
}
|
||||
|
||||
/** `groupToolRuns` for a transcript that is re-sent whole on every streamed chunk and on every
|
||||
* tool status change: a row whose messages did not change keeps its previous item object. A
|
||||
* fresh item per row re-renders the whole history each time (every answer re-parsing its
|
||||
* markdown, every tool row its result), which freezes the tab on long sessions.
|
||||
*
|
||||
* A changed message is told apart by identity alone, so a row must be replaced, never mutated
|
||||
* in place, for its grouping to update. */
|
||||
export function createChatItemGrouper(): (messages: DisplayMessage[]) => ChatItem[] {
|
||||
let settled: DisplayMessage[] = []
|
||||
let settledItems: ChatItem[] = []
|
||||
return (messages) => {
|
||||
// The live answer only ever comes last, and it can neither join nor end a group
|
||||
// differently than the end of the transcript does, so it is grouped on its own and the
|
||||
// rows before it are not regrouped while it streams.
|
||||
const last = messages.at(-1)
|
||||
const live = last?.role === 'assistant' && last.streaming ? last : undefined
|
||||
const n = live ? messages.length - 1 : messages.length
|
||||
if (n !== settled.length || settled.some((m, i) => m !== messages[i])) {
|
||||
const previous = new Map(settledItems.map((item) => [chatItemKey(item), item]))
|
||||
settled = messages.slice(0, n)
|
||||
settledItems = groupToolRuns(settled).map((item) => {
|
||||
const before = previous.get(chatItemKey(item))
|
||||
return before && sameChatItem(before, item) ? before : item
|
||||
})
|
||||
}
|
||||
return live ? [...settledItems, { kind: 'message', message: live, index: n }] : settledItems
|
||||
}
|
||||
}
|
||||
|
||||
// The server's full resource path: two servers can share a last segment (u/alice/github,
|
||||
// f/team/github), so only the display shortens it.
|
||||
function mcpServerPath(call: ToolDisplayMessage): string | undefined {
|
||||
|
||||
Reference in New Issue
Block a user