mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-24 20:22:53 +00:00
Keep Assistant footer on freshest stream activity
This commit is contained in:
@@ -278,6 +278,19 @@ runtime cost control, and shared AI transport surfaces.
|
||||
showing a live `Thinking:`/completed `Thought:` row with duration and optional
|
||||
provider summary title while keeping the raw reasoning body out of the
|
||||
transcript.
|
||||
The active-turn status strip follows the same source-backed part freshness
|
||||
rule: OpenCode commit `9ed17da55ab1f7360cc0e01075f763e27fa899e9`
|
||||
updates live assistant parts in place through
|
||||
`packages/opencode/src/cli/cmd/tui/context/sync-v2.tsx`
|
||||
(`latestTool`, `latestText`, `latestReasoning`, and `apply(event)`),
|
||||
renders reasoning headers with `ReasoningPart`/`ReasoningHeader` in
|
||||
`packages/opencode/src/cli/cmd/tui/routes/session/index.tsx`, and keeps the
|
||||
prompt footer as the replacing live status surface in
|
||||
`packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx`. Pulse adapts
|
||||
that by ranking workflow, content, hidden reasoning, and pending-tool footer
|
||||
copy by the freshest activity timestamp: a later answer token can replace an
|
||||
older tool status, and a later in-place tool progress patch can replace the
|
||||
answer status again without moving the transcript's chronological row.
|
||||
OpenCode-parity Assistant UX work must reference OpenCode's actual source
|
||||
implementation for message parts, tool-state mutation, progress rendering,
|
||||
and model/session selection before changing Pulse behavior; parity means
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Component, createEffect, createMemo, createSignal, onCleanup } from 'solid-js';
|
||||
import BrainIcon from 'lucide-solid/icons/brain';
|
||||
import { extractReasoningSummaryTitle } from './reasoningSummary';
|
||||
|
||||
interface ThinkingBlockProps {
|
||||
content?: string;
|
||||
@@ -8,11 +9,6 @@ interface ThinkingBlockProps {
|
||||
updatedAt?: number;
|
||||
}
|
||||
|
||||
const extractReasoningSummaryTitle = (content?: string): string => {
|
||||
const match = content?.trim().match(/^\*\*([^*\n]+)\*\*(?:\r?\n\r?\n|$)/);
|
||||
return match?.[1]?.trim().replace(/\s+/g, ' ') || '';
|
||||
};
|
||||
|
||||
const formatThinkingDuration = (durationMs: number): string => {
|
||||
if (!Number.isFinite(durationMs) || durationMs < 0) return '';
|
||||
if (durationMs < 1000) return '<1s';
|
||||
|
||||
@@ -210,6 +210,132 @@ describe('getAssistantActiveTurnStatus', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('lets newer streamed content replace an older pending tool footer status', () => {
|
||||
expect(
|
||||
getAssistantActiveTurnStatus(
|
||||
[
|
||||
assistantMessage({
|
||||
pendingTools: [
|
||||
{
|
||||
id: 'tool-1',
|
||||
name: 'pulse_get_nodes',
|
||||
input: '{}',
|
||||
progress: 'Reading node inventory',
|
||||
status: 'running',
|
||||
startedAt: 1_000,
|
||||
updatedAt: 1_500,
|
||||
},
|
||||
],
|
||||
streamEvents: [
|
||||
{
|
||||
type: 'pending_tool',
|
||||
toolId: 'tool-1',
|
||||
pendingTool: {
|
||||
id: 'tool-1',
|
||||
name: 'pulse_get_nodes',
|
||||
input: '{}',
|
||||
progress: 'Reading node inventory',
|
||||
status: 'running',
|
||||
startedAt: 1_000,
|
||||
updatedAt: 1_500,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'content',
|
||||
content: 'The node inventory is healthy.',
|
||||
startedAt: 2_000,
|
||||
updatedAt: 2_500,
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
true,
|
||||
),
|
||||
).toEqual({
|
||||
type: 'generating',
|
||||
text: 'Generating response',
|
||||
startedAt: 2_000,
|
||||
});
|
||||
});
|
||||
|
||||
it('lets a newer in-place tool progress patch replace content footer status', () => {
|
||||
expect(
|
||||
getAssistantActiveTurnStatus(
|
||||
[
|
||||
assistantMessage({
|
||||
pendingTools: [
|
||||
{
|
||||
id: 'tool-1',
|
||||
name: 'pulse_read',
|
||||
input: '{}',
|
||||
progress: 'Reading storage layout',
|
||||
status: 'running',
|
||||
startedAt: 1_000,
|
||||
updatedAt: 3_000,
|
||||
},
|
||||
],
|
||||
streamEvents: [
|
||||
{
|
||||
type: 'pending_tool',
|
||||
toolId: 'tool-1',
|
||||
pendingTool: {
|
||||
id: 'tool-1',
|
||||
name: 'pulse_read',
|
||||
input: '{}',
|
||||
progress: 'Reading storage layout',
|
||||
status: 'running',
|
||||
startedAt: 1_000,
|
||||
updatedAt: 3_000,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'content',
|
||||
content: 'I found the node list.',
|
||||
startedAt: 2_000,
|
||||
updatedAt: 2_500,
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
true,
|
||||
),
|
||||
).toEqual({
|
||||
type: 'tool',
|
||||
text: 'Reading storage layout',
|
||||
startedAt: 1_000,
|
||||
});
|
||||
});
|
||||
|
||||
it('surfaces newer hidden reasoning metadata in the footer after content', () => {
|
||||
expect(
|
||||
getAssistantActiveTurnStatus(
|
||||
[
|
||||
assistantMessage({
|
||||
streamEvents: [
|
||||
{
|
||||
type: 'content',
|
||||
content: 'The device count is',
|
||||
startedAt: 1_000,
|
||||
updatedAt: 1_500,
|
||||
},
|
||||
{
|
||||
type: 'thinking',
|
||||
thinking: '**Checking device nodes**\n\nHidden reasoning body.',
|
||||
startedAt: 2_000,
|
||||
updatedAt: 2_500,
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
true,
|
||||
),
|
||||
).toEqual({
|
||||
type: 'thinking',
|
||||
text: 'Thinking: Checking device nodes',
|
||||
startedAt: 2_000,
|
||||
});
|
||||
});
|
||||
|
||||
it('surfaces fresh workflow progress after completed tool evidence', () => {
|
||||
expect(
|
||||
getAssistantActiveTurnStatus(
|
||||
|
||||
@@ -58,6 +58,20 @@ describe('groupStreamEventsForDisplay', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves merged content activity timing', () => {
|
||||
const grouped = groupStreamEventsForDisplay([
|
||||
{ type: 'content', content: 'Hello ', startedAt: 1_000, updatedAt: 1_100 },
|
||||
{ type: 'content', content: 'world', startedAt: 1_500, updatedAt: 2_500 },
|
||||
]);
|
||||
|
||||
expect(grouped[0]).toMatchObject({
|
||||
type: 'content',
|
||||
content: 'Hello world',
|
||||
startedAt: 1_000,
|
||||
updatedAt: 2_500,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps content separated across a tool boundary so order is preserved', () => {
|
||||
const tool: StreamDisplayEvent = {
|
||||
type: 'tool',
|
||||
|
||||
@@ -835,6 +835,9 @@ describe('useChat', () => {
|
||||
const contentEvents = assistant.streamEvents?.filter((e) => e.type === 'content') ?? [];
|
||||
expect(contentEvents).toHaveLength(1);
|
||||
expect(contentEvents[0].content).toBe('Hello world');
|
||||
expect(contentEvents[0].startedAt).toEqual(expect.any(Number));
|
||||
expect(contentEvents[0].updatedAt).toEqual(expect.any(Number));
|
||||
expect(contentEvents[0].updatedAt).toBeGreaterThanOrEqual(contentEvents[0].startedAt || 0);
|
||||
dispose();
|
||||
});
|
||||
|
||||
@@ -920,7 +923,7 @@ describe('useChat', () => {
|
||||
expect(assistant.content).toBe('I will inspect the device nodes.');
|
||||
expect(assistant.content).not.toContain('pulse_read');
|
||||
expect(assistant.content).not.toContain('raw arguments');
|
||||
expect(assistant.streamEvents?.filter((e) => e.type === 'content')).toEqual([
|
||||
expect(assistant.streamEvents?.filter((e) => e.type === 'content')).toMatchObject([
|
||||
{ type: 'content', content: 'I will inspect the device nodes.' },
|
||||
]);
|
||||
dispose();
|
||||
@@ -945,7 +948,7 @@ describe('useChat', () => {
|
||||
expect(assistant.content).not.toContain('pulse_read');
|
||||
expect(assistant.content).not.toContain('target_host');
|
||||
expect(assistant.content).not.toContain('raw arguments');
|
||||
expect(assistant.streamEvents?.filter((e) => e.type === 'content')).toEqual([
|
||||
expect(assistant.streamEvents?.filter((e) => e.type === 'content')).toMatchObject([
|
||||
{ type: 'content', content: 'I will check ' },
|
||||
]);
|
||||
dispose();
|
||||
@@ -999,7 +1002,7 @@ describe('useChat', () => {
|
||||
|
||||
const assistant = chat.messages().find((m) => m.role === 'assistant')!;
|
||||
expect(assistant.content).toBe(compacted);
|
||||
expect(assistant.streamEvents?.filter((e) => e.type === 'content')).toEqual([
|
||||
expect(assistant.streamEvents?.filter((e) => e.type === 'content')).toMatchObject([
|
||||
{ type: 'content', content: compacted },
|
||||
]);
|
||||
expect(assistant.isStreaming).toBe(false);
|
||||
@@ -2450,6 +2453,7 @@ describe('useChat', () => {
|
||||
success: true,
|
||||
},
|
||||
toolId: 'orphan',
|
||||
updatedAt: expect.any(Number),
|
||||
});
|
||||
// pendingTools should remain empty (nothing to remove)
|
||||
expect(assistant.pendingTools).toHaveLength(0);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ChatMessage, PendingTool, StreamDisplayEvent, WorkflowStatus } from './types';
|
||||
import { formatIdentifierLabel } from '@/utils/textPresentation';
|
||||
import { extractReasoningSummaryTitle } from './reasoningSummary';
|
||||
|
||||
export type AssistantActiveTurnStatusKind = 'thinking' | 'tool' | 'generating';
|
||||
|
||||
@@ -9,6 +10,11 @@ export interface AssistantActiveTurnStatus {
|
||||
startedAt?: number;
|
||||
}
|
||||
|
||||
interface AssistantActiveTurnStatusCandidate extends AssistantActiveTurnStatus {
|
||||
activityAt?: number;
|
||||
order: number;
|
||||
}
|
||||
|
||||
const formatToolName = (name?: string) =>
|
||||
formatIdentifierLabel(name, { stripPrefix: 'pulse_', fallback: 'tool', maxLength: 36 });
|
||||
|
||||
@@ -77,11 +83,169 @@ const activePendingToolFromEvents = (
|
||||
const latestPendingToolActivity = (tool?: PendingTool): number =>
|
||||
tool?.updatedAt ?? tool?.startedAt ?? 0;
|
||||
|
||||
const activePendingToolFromState = (pendingTools?: PendingTool[]): PendingTool | undefined =>
|
||||
pendingTools?.reduce<PendingTool | undefined>((current, tool) => {
|
||||
if (!current) return tool;
|
||||
return latestPendingToolActivity(tool) >= latestPendingToolActivity(current) ? tool : current;
|
||||
}, undefined);
|
||||
const pendingToolCandidate = (
|
||||
tool: PendingTool | undefined,
|
||||
order: number,
|
||||
): AssistantActiveTurnStatusCandidate | null => {
|
||||
const text = formatPendingToolStatus(tool);
|
||||
if (!text) return null;
|
||||
const activityAt = latestPendingToolActivity(tool) || undefined;
|
||||
return {
|
||||
type: 'tool',
|
||||
text,
|
||||
startedAt: tool?.startedAt,
|
||||
activityAt,
|
||||
order,
|
||||
};
|
||||
};
|
||||
|
||||
const activePendingToolCandidateFromState = (
|
||||
pendingTools?: PendingTool[],
|
||||
): AssistantActiveTurnStatusCandidate | null =>
|
||||
(pendingTools || []).reduce<AssistantActiveTurnStatusCandidate | null>((current, tool, index) => {
|
||||
const candidate = pendingToolCandidate(tool, index);
|
||||
if (!candidate) return current;
|
||||
return isFresherStatusCandidate(candidate, current) ? candidate : current;
|
||||
}, null);
|
||||
|
||||
const thinkingStatusText = (event: StreamDisplayEvent): string => {
|
||||
if (!event.thinking?.trim()) return '';
|
||||
const title = extractReasoningSummaryTitle(event.thinking);
|
||||
return title ? `Thinking: ${title}` : 'Thinking';
|
||||
};
|
||||
|
||||
const eventActivityAt = (event: StreamDisplayEvent): number | undefined => {
|
||||
if (event.type === 'pending_tool') {
|
||||
return (
|
||||
latestPendingToolActivity(event.pendingTool) ||
|
||||
event.updatedAt ||
|
||||
event.startedAt ||
|
||||
undefined
|
||||
);
|
||||
}
|
||||
return event.updatedAt || event.startedAt || undefined;
|
||||
};
|
||||
|
||||
const toolCompletionKeys = (event: StreamDisplayEvent): string[] =>
|
||||
[event.toolId, event.tool?.name]
|
||||
.map((value) => value?.trim())
|
||||
.filter((value): value is string => !!value);
|
||||
|
||||
const pendingToolKeys = (event: StreamDisplayEvent): string[] =>
|
||||
[event.toolId, event.pendingTool?.id, event.pendingTool?.name]
|
||||
.map((value) => value?.trim())
|
||||
.filter((value): value is string => !!value);
|
||||
|
||||
const isFresherStatusCandidate = (
|
||||
candidate: AssistantActiveTurnStatusCandidate,
|
||||
current: AssistantActiveTurnStatusCandidate | null,
|
||||
): boolean => {
|
||||
if (!current) return true;
|
||||
const candidateTime = candidate.activityAt;
|
||||
const currentTime = current.activityAt;
|
||||
if (candidateTime !== undefined && currentTime !== undefined && candidateTime !== currentTime) {
|
||||
return candidateTime > currentTime;
|
||||
}
|
||||
if (candidateTime !== undefined && currentTime === undefined) return true;
|
||||
if (candidateTime === undefined && currentTime !== undefined) return false;
|
||||
return candidate.order >= current.order;
|
||||
};
|
||||
|
||||
const latestStreamActivityStatus = (
|
||||
events?: StreamDisplayEvent[],
|
||||
): AssistantActiveTurnStatusCandidate | null => {
|
||||
const completedToolKeys = new Set<string>();
|
||||
let current: AssistantActiveTurnStatusCandidate | null = null;
|
||||
|
||||
for (let index = (events?.length || 0) - 1; index >= 0; index -= 1) {
|
||||
const event = events?.[index];
|
||||
if (!event) continue;
|
||||
|
||||
let candidate: AssistantActiveTurnStatusCandidate | null = null;
|
||||
switch (event.type) {
|
||||
case 'tool': {
|
||||
for (const key of toolCompletionKeys(event)) {
|
||||
completedToolKeys.add(key);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'pending_tool': {
|
||||
const keys = pendingToolKeys(event);
|
||||
if (!keys.some((key) => completedToolKeys.has(key))) {
|
||||
candidate = pendingToolCandidate(event.pendingTool, index);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'thinking': {
|
||||
const text = thinkingStatusText(event);
|
||||
if (text) {
|
||||
candidate = {
|
||||
type: 'thinking',
|
||||
text,
|
||||
startedAt: event.startedAt,
|
||||
activityAt: eventActivityAt(event),
|
||||
order: index,
|
||||
};
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'content': {
|
||||
if (event.content?.trim()) {
|
||||
candidate = {
|
||||
type: 'generating',
|
||||
text: 'Generating response',
|
||||
startedAt: event.startedAt,
|
||||
activityAt: eventActivityAt(event),
|
||||
order: index,
|
||||
};
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'approval':
|
||||
if (event.approval) {
|
||||
candidate = {
|
||||
type: 'thinking',
|
||||
text: 'Waiting for approval',
|
||||
activityAt: eventActivityAt(event),
|
||||
order: index,
|
||||
};
|
||||
}
|
||||
break;
|
||||
case 'question':
|
||||
if (event.question) {
|
||||
candidate = {
|
||||
type: 'thinking',
|
||||
text: 'Waiting for answer',
|
||||
activityAt: eventActivityAt(event),
|
||||
order: index,
|
||||
};
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
if (candidate && isFresherStatusCandidate(candidate, current)) {
|
||||
current = candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return current;
|
||||
};
|
||||
|
||||
const workflowStatusCandidate = (
|
||||
status: WorkflowStatus | undefined,
|
||||
): AssistantActiveTurnStatusCandidate | null => {
|
||||
const text = formatAssistantWorkflowStatus(status);
|
||||
if (!text) return null;
|
||||
return {
|
||||
type: status?.tool ? 'tool' : 'thinking',
|
||||
text,
|
||||
startedAt: status?.startedAt,
|
||||
activityAt: status?.startedAt,
|
||||
order: Number.MAX_SAFE_INTEGER,
|
||||
};
|
||||
};
|
||||
|
||||
const hasVisibleAssistantOutput = (message: ChatMessage): boolean => {
|
||||
if ((message.content || '').trim() || message.error) return true;
|
||||
@@ -106,24 +270,38 @@ export const getAssistantActiveTurnStatus = (
|
||||
return { type: 'thinking', text: 'Waiting for assistant' };
|
||||
}
|
||||
|
||||
const pendingTool =
|
||||
activePendingToolFromState(assistantMessage.pendingTools) ||
|
||||
activePendingToolFromEvents(assistantMessage.streamEvents);
|
||||
const pendingToolText = formatPendingToolStatus(pendingTool);
|
||||
if (pendingToolText) {
|
||||
return { type: 'tool', text: pendingToolText, startedAt: pendingTool?.startedAt };
|
||||
const statusCandidates: AssistantActiveTurnStatusCandidate[] = [];
|
||||
const statePendingToolCandidate = activePendingToolCandidateFromState(assistantMessage.pendingTools);
|
||||
if (statePendingToolCandidate) {
|
||||
statusCandidates.push(statePendingToolCandidate);
|
||||
}
|
||||
const eventStatusCandidate = latestStreamActivityStatus(assistantMessage.streamEvents);
|
||||
if (eventStatusCandidate) {
|
||||
statusCandidates.push(eventStatusCandidate);
|
||||
} else {
|
||||
const pendingTool = activePendingToolFromEvents(assistantMessage.streamEvents);
|
||||
const eventPendingToolCandidate = pendingToolCandidate(pendingTool, 0);
|
||||
if (eventPendingToolCandidate) {
|
||||
statusCandidates.push(eventPendingToolCandidate);
|
||||
}
|
||||
}
|
||||
if (assistantMessage.isStreaming !== false) {
|
||||
const workflowCandidate = workflowStatusCandidate(assistantMessage.workflowStatus);
|
||||
if (workflowCandidate) {
|
||||
statusCandidates.push(workflowCandidate);
|
||||
}
|
||||
}
|
||||
|
||||
const workflowStatusText =
|
||||
assistantMessage.isStreaming === false
|
||||
? ''
|
||||
: formatAssistantWorkflowStatus(assistantMessage.workflowStatus);
|
||||
const freshestStatus = statusCandidates.reduce<AssistantActiveTurnStatusCandidate | null>(
|
||||
(current, candidate) => (isFresherStatusCandidate(candidate, current) ? candidate : current),
|
||||
null,
|
||||
);
|
||||
|
||||
if (workflowStatusText) {
|
||||
if (freshestStatus) {
|
||||
return {
|
||||
type: assistantMessage.workflowStatus?.tool ? 'tool' : 'thinking',
|
||||
text: workflowStatusText,
|
||||
startedAt: assistantMessage.workflowStatus?.startedAt,
|
||||
type: freshestStatus.type,
|
||||
text: freshestStatus.text,
|
||||
startedAt: freshestStatus.startedAt,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -238,11 +238,17 @@ export function useChat(options: UseChatOptions = {}) {
|
||||
if (event.type === 'content' && events.length > 0) {
|
||||
const last = events[events.length - 1];
|
||||
if (last.type === 'content') {
|
||||
const now = Date.now();
|
||||
return {
|
||||
...msg,
|
||||
streamEvents: [
|
||||
...events.slice(0, -1),
|
||||
{ ...last, content: (last.content || '') + (event.content || '') },
|
||||
{
|
||||
...last,
|
||||
content: (last.content || '') + (event.content || ''),
|
||||
startedAt: last.startedAt || event.startedAt || now,
|
||||
updatedAt: event.updatedAt || now,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -721,7 +727,13 @@ export function useChat(options: UseChatOptions = {}) {
|
||||
: msg;
|
||||
if (!visible.text) return baseMsg;
|
||||
// Add to streamEvents for chronological display
|
||||
const updated = addStreamEvent(baseMsg, { type: 'content', content: visible.text });
|
||||
const now = Date.now();
|
||||
const updated = addStreamEvent(baseMsg, {
|
||||
type: 'content',
|
||||
content: visible.text,
|
||||
startedAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
return {
|
||||
...updated,
|
||||
content: appendMessageContent(baseMsg, visible.text),
|
||||
@@ -900,12 +912,20 @@ export function useChat(options: UseChatOptions = {}) {
|
||||
}
|
||||
return true;
|
||||
});
|
||||
updatedEvents.push({ type: 'tool', tool: newToolCall, toolId: data.id });
|
||||
const completedAt = Date.now();
|
||||
updatedEvents.push({
|
||||
type: 'tool',
|
||||
tool: newToolCall,
|
||||
toolId: data.id,
|
||||
startedAt: pendingTools[resolvedPendingIndex]?.startedAt,
|
||||
updatedAt: completedAt,
|
||||
});
|
||||
} else {
|
||||
// No approval - replace the pending_tool in place. If the terminal
|
||||
// event is the first visible evidence, keep the completed row.
|
||||
updatedEvents = [...events];
|
||||
let replacedPendingTool = false;
|
||||
const completedAt = Date.now();
|
||||
for (let i = events.length - 1; i >= 0; i--) {
|
||||
const evt = events[i];
|
||||
if (
|
||||
@@ -914,13 +934,24 @@ export function useChat(options: UseChatOptions = {}) {
|
||||
? evt.toolId === data.id
|
||||
: normalizeChatToolName(evt.pendingTool?.name || '') === normalizedEndName)
|
||||
) {
|
||||
updatedEvents[i] = { type: 'tool', tool: newToolCall, toolId: data.id };
|
||||
updatedEvents[i] = {
|
||||
type: 'tool',
|
||||
tool: newToolCall,
|
||||
toolId: data.id,
|
||||
startedAt: evt.pendingTool?.startedAt || evt.startedAt,
|
||||
updatedAt: completedAt,
|
||||
};
|
||||
replacedPendingTool = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!replacedPendingTool) {
|
||||
updatedEvents.push({ type: 'tool', tool: newToolCall, toolId: data.id });
|
||||
updatedEvents.push({
|
||||
type: 'tool',
|
||||
tool: newToolCall,
|
||||
toolId: data.id,
|
||||
updatedAt: completedAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1068,7 +1099,12 @@ export function useChat(options: UseChatOptions = {}) {
|
||||
clearOutputArtifactState(assistantId);
|
||||
const flushedMsg = pendingText
|
||||
? {
|
||||
...addStreamEvent(msg, { type: 'content', content: pendingText }),
|
||||
...addStreamEvent(msg, {
|
||||
type: 'content',
|
||||
content: pendingText,
|
||||
startedAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
}),
|
||||
content: appendMessageContent(msg, pendingText),
|
||||
}
|
||||
: msg;
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
export const extractReasoningSummaryTitle = (content?: string): string => {
|
||||
const match = content?.trim().match(/^\*\*([^*\n]+)\*\*(?:\r?\n\r?\n|$)/);
|
||||
return match?.[1]?.trim().replace(/\s+/g, ' ') || '';
|
||||
};
|
||||
@@ -29,9 +29,12 @@ export const groupStreamEventsForDisplay = (
|
||||
case 'content': {
|
||||
if (!evt.content) break; // skip empty deltas
|
||||
if (contentIdx >= 0) {
|
||||
const current = grouped[contentIdx];
|
||||
grouped[contentIdx] = {
|
||||
...grouped[contentIdx],
|
||||
content: (grouped[contentIdx].content || '') + evt.content,
|
||||
...current,
|
||||
content: (current.content || '') + evt.content,
|
||||
startedAt: current.startedAt || evt.startedAt,
|
||||
updatedAt: evt.updatedAt || current.updatedAt,
|
||||
};
|
||||
} else {
|
||||
grouped.push({ ...evt });
|
||||
|
||||
Reference in New Issue
Block a user