Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 99 additions & 0 deletions apps/web/e2e/chat-composer-performance.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.

import { expect, test, type Page } from '@playwright/test';

type InputMode = 'direct' | 'composition';

async function measureInputToPaint(page: Page, mode: InputMode) {
return page
.locator('textarea[name="agent-message"]')
.evaluate(async (textarea, inputMode) => {
const input = textarea as HTMLTextAreaElement;
const valueSetter = Object.getOwnPropertyDescriptor(
HTMLTextAreaElement.prototype,
'value',
)?.set;
if (!valueSetter) throw new Error('textarea value setter unavailable');

const samples: number[] = [];
for (let index = 0; index < 12; index++) {
const nextCharacter = inputMode === 'composition' ? '你' : 'a';
const duration = await new Promise<number>((resolve) => {
const start = performance.now();
if (inputMode === 'composition') {
input.dispatchEvent(
new CompositionEvent('compositionstart', { bubbles: true }),
);
}
valueSetter.call(input, input.value + nextCharacter);
input.dispatchEvent(
new InputEvent('input', {
bubbles: true,
data: nextCharacter,
inputType:
inputMode === 'composition'
? 'insertCompositionText'
: 'insertText',
isComposing: inputMode === 'composition',
}),
);
if (inputMode === 'composition') {
input.dispatchEvent(
new CompositionEvent('compositionend', {
bubbles: true,
data: nextCharacter,
}),
);
}
requestAnimationFrame(() => resolve(performance.now() - start));
});
samples.push(duration);
await new Promise<void>((resolve) =>
requestAnimationFrame(() => resolve()),
);
}

samples.sort((left, right) => left - right);
return {
median: samples[Math.floor(samples.length / 2)] ?? 0,
p95: samples[Math.ceil(samples.length * 0.95) - 1] ?? 0,
};
}, mode);
}

async function openFixture(page: Page, messageCount: number) {
await page.goto(`/playground/chat-performance?messages=${messageCount}`);
await expect(
page.locator(`[data-chat-performance-fixture="${messageCount}"]`),
).toBeVisible();
await expect(page.locator('[data-chat-user-message]')).toHaveCount(
Math.ceil(messageCount / 2),
);
await expect(page.locator('textarea[name="agent-message"]')).toBeEditable();
await page.waitForTimeout(500);
}

test('input-to-paint remains stable with 200 historical messages', async ({
page,
}) => {
await openFixture(page, 0);
const freshDirect = await measureInputToPaint(page, 'direct');
await openFixture(page, 0);
const freshComposition = await measureInputToPaint(page, 'composition');

await openFixture(page, 200);
const longDirect = await measureInputToPaint(page, 'direct');
await openFixture(page, 200);
const longComposition = await measureInputToPaint(page, 'composition');

console.log({
freshDirect,
longDirect,
freshComposition,
longComposition,
});

expect(longDirect.p95 - freshDirect.p95).toBeLessThanOrEqual(8);
expect(longComposition.p95 - freshComposition.p95).toBeLessThanOrEqual(8);
});
8 changes: 8 additions & 0 deletions apps/web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,14 @@ const playgroundRoutes = import.meta.env.DEV
.default,
}),
},
{
path: '/playground/chat-performance',
lazy: async () => ({
Component: (
await import('./pages/playground/ChatPerformancePlaygroundPage')
).default,
}),
},
{
path: '/playground/agent-nodes',
lazy: async () => ({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
* Milkdown-backed renderer for AI chat messages.
*/

import { memo } from 'react';

import { parseArtifactUrl } from '@huabu/shared';

import { MilkdownPreview } from '@/components/Milkdown';
Expand All @@ -13,7 +15,6 @@ import { setDragPayload } from '@/utils/io/dragDrop';

import type { ImageDragPayload, NoteDragPayload } from '@/utils/io/dragDrop';
import type { NodeOrigin } from '@huabu/shared';
import type { FC } from 'react';

interface MilkdownMessageCardProps {
content: string;
Expand Down Expand Up @@ -87,10 +88,10 @@ export function buildImageDragPayload(
};
}

export const MilkdownMessageCard: FC<MilkdownMessageCardProps> = ({
export const MilkdownMessageCard = memo(function MilkdownMessageCard({
content,
threadId,
}) => {
}: MilkdownMessageCardProps) {
const canvasId = useCanvasStore((s) => s.canvasId);

return (
Expand All @@ -111,4 +112,4 @@ export const MilkdownMessageCard: FC<MilkdownMessageCardProps> = ({
}}
/>
);
};
});
7 changes: 4 additions & 3 deletions apps/web/src/components/Messages/AIMessage/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// Licensed under the MIT license.

import { Copy } from 'lucide-react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';

import { groupByThinkingPhase, type SegmentGroup } from './groupParts';
Expand Down Expand Up @@ -106,12 +107,12 @@ function renderToolGroup(
}
}

export const AIMessage = ({
export const AIMessage = memo(function AIMessage({
messageId,
segments,
isStreaming,
hideActions,
}: AIMessageProps) => {
}: AIMessageProps) {
const { t } = useTranslation();
const addNode = useCanvasStore((state) => state.addNode);
const { threadId } = useChatSession();
Expand Down Expand Up @@ -282,4 +283,4 @@ export const AIMessage = ({
</div>
</div>
);
};
});
Loading
Loading