fix(FN-XXXX): show chat streaming feedback during Claude waits
This commit is contained in:
5
.changeset/fix-chat-streaming-feedback.md
Normal file
5
.changeset/fix-chat-streaming-feedback.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Keep chat and quick chat visibly in a connecting or thinking state during long Claude CLI responses, and repair missing spaces in some streamed sentence boundaries.
|
||||||
@@ -823,7 +823,7 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
|
|||||||
const messagesContainer = messagesContainerRef.current;
|
const messagesContainer = messagesContainerRef.current;
|
||||||
if (!messagesContainer) return;
|
if (!messagesContainer) return;
|
||||||
messagesContainer.scrollTop = messagesContainer.scrollHeight;
|
messagesContainer.scrollTop = messagesContainer.scrollHeight;
|
||||||
}, [messages, streamingText]);
|
}, [messages, streamingText, streamingThinking, isStreaming]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (keyboardOverlap <= 0) {
|
if (keyboardOverlap <= 0) {
|
||||||
@@ -1587,7 +1587,58 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
|
|||||||
|
|
||||||
{/* Messages */}
|
{/* Messages */}
|
||||||
<div className="chat-messages" ref={messagesContainerRef}>
|
<div className="chat-messages" ref={messagesContainerRef}>
|
||||||
{messagesLoading ? (
|
{isStreaming ? (
|
||||||
|
<>
|
||||||
|
{messages.map((message) => (
|
||||||
|
<ChatMessageItem
|
||||||
|
key={message.id}
|
||||||
|
message={message}
|
||||||
|
forcePlain={plainTextMessageIds.has(message.id)}
|
||||||
|
agentName={agentName}
|
||||||
|
showAssistantModelTag={showAssistantModelTag}
|
||||||
|
activeModelTag={activeModelTag}
|
||||||
|
activeSessionId={activeSession?.id ?? null}
|
||||||
|
mentionAgentsByName={mentionAgentsByName}
|
||||||
|
onToggleRender={toggleMessageRenderMode}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<div className="chat-message chat-message--assistant chat-message--streaming">
|
||||||
|
<div className="chat-message-avatar">
|
||||||
|
<Bot size={14} />
|
||||||
|
<span>{agentName}</span>
|
||||||
|
{showAssistantModelTag && <span className="chat-model-tag">{activeModelTag}</span>}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`chat-message-render-toggle${plainTextMessageIds.has("__streaming__") ? " chat-message-render-toggle--plain" : ""}`}
|
||||||
|
data-testid="chat-message-render-toggle"
|
||||||
|
aria-label={plainTextMessageIds.has("__streaming__") ? "Show rendered markdown" : "Show plain text"}
|
||||||
|
onClick={() => toggleMessageRenderMode("__streaming__")}
|
||||||
|
>
|
||||||
|
{plainTextMessageIds.has("__streaming__") ? <EyeOff size={14} /> : <Eye size={14} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{streamingText ? (
|
||||||
|
renderAssistantContent(streamingText, plainTextMessageIds.has("__streaming__"))
|
||||||
|
) : (
|
||||||
|
<div className="chat-message-content chat-message-content--waiting">
|
||||||
|
{streamingThinking ? "Thinking…" : "Connecting…"}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{renderToolCalls(streamingToolCalls)}
|
||||||
|
{streamingThinking && (
|
||||||
|
<details className="chat-message-thinking">
|
||||||
|
<summary>Thinking</summary>
|
||||||
|
<pre className="chat-message-thinking-content">{streamingThinking}</pre>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
<div className="chat-typing-indicator">
|
||||||
|
<span />
|
||||||
|
<span />
|
||||||
|
<span />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : messagesLoading ? (
|
||||||
<div style={{ color: "var(--text-secondary)", fontSize: "13px" }}>Loading messages...</div>
|
<div style={{ color: "var(--text-secondary)", fontSize: "13px" }}>Loading messages...</div>
|
||||||
) : messages.length === 0 && !activeSession ? (
|
) : messages.length === 0 && !activeSession ? (
|
||||||
renderEmptyState()
|
renderEmptyState()
|
||||||
@@ -1610,43 +1661,6 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
|
|||||||
onToggleRender={toggleMessageRenderMode}
|
onToggleRender={toggleMessageRenderMode}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{isStreaming && (
|
|
||||||
<div className="chat-message chat-message--assistant chat-message--streaming">
|
|
||||||
<div className="chat-message-avatar">
|
|
||||||
<Bot size={14} />
|
|
||||||
<span>{agentName}</span>
|
|
||||||
{showAssistantModelTag && <span className="chat-model-tag">{activeModelTag}</span>}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`chat-message-render-toggle${plainTextMessageIds.has("__streaming__") ? " chat-message-render-toggle--plain" : ""}`}
|
|
||||||
data-testid="chat-message-render-toggle"
|
|
||||||
aria-label={plainTextMessageIds.has("__streaming__") ? "Show rendered markdown" : "Show plain text"}
|
|
||||||
onClick={() => toggleMessageRenderMode("__streaming__")}
|
|
||||||
>
|
|
||||||
{plainTextMessageIds.has("__streaming__") ? <EyeOff size={14} /> : <Eye size={14} />}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{streamingText ? (
|
|
||||||
renderAssistantContent(streamingText, plainTextMessageIds.has("__streaming__"))
|
|
||||||
) : (
|
|
||||||
<div className="chat-message-content chat-message-content--waiting">
|
|
||||||
{streamingThinking ? "Thinking…" : "Connecting…"}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{renderToolCalls(streamingToolCalls)}
|
|
||||||
{streamingThinking && (
|
|
||||||
<details className="chat-message-thinking">
|
|
||||||
<summary>Thinking</summary>
|
|
||||||
<pre className="chat-message-thinking-content">{streamingThinking}</pre>
|
|
||||||
</details>
|
|
||||||
)}
|
|
||||||
<div className="chat-typing-indicator">
|
|
||||||
<span />
|
|
||||||
<span />
|
|
||||||
<span />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<div ref={messagesEndRef} />
|
<div ref={messagesEndRef} />
|
||||||
|
|||||||
@@ -1289,7 +1289,7 @@ export function QuickChatFAB({
|
|||||||
const messagesEl = messagesRef.current;
|
const messagesEl = messagesRef.current;
|
||||||
if (!messagesEl) return;
|
if (!messagesEl) return;
|
||||||
messagesEl.scrollTop = messagesEl.scrollHeight;
|
messagesEl.scrollTop = messagesEl.scrollHeight;
|
||||||
}, [messages, streamingText, streamingThinking, isOpen]);
|
}, [messages, streamingText, streamingThinking, isStreaming, isOpen]);
|
||||||
|
|
||||||
const sessionOptions = useMemo(
|
const sessionOptions = useMemo(
|
||||||
() => sessions.map((session, index) => ({
|
() => sessions.map((session, index) => ({
|
||||||
@@ -2043,7 +2043,53 @@ export function QuickChatFAB({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="quick-chat-panel-messages" ref={messagesRef} data-testid="quick-chat-messages">
|
<div className="quick-chat-panel-messages" ref={messagesRef} data-testid="quick-chat-messages">
|
||||||
{sessionsLoading || messagesLoading ? (
|
{sessionsLoading ? (
|
||||||
|
<div className="quick-chat-panel-empty">Loading conversation…</div>
|
||||||
|
) : isStreaming ? (
|
||||||
|
<>
|
||||||
|
{messages.map((message: ChatMessageInfo) => (
|
||||||
|
<QuickChatMessageItem
|
||||||
|
key={message.id}
|
||||||
|
message={message}
|
||||||
|
forcePlain={message.role !== "user" && plainTextMessageIds.has(message.id)}
|
||||||
|
mentionAgentsByName={mentionAgentsByName}
|
||||||
|
onToggleRender={toggleMessageRenderMode}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<div
|
||||||
|
className="quick-chat-panel-message quick-chat-panel-message--received quick-chat-panel-message--streaming"
|
||||||
|
data-testid="quick-chat-streaming-message"
|
||||||
|
>
|
||||||
|
{streamingText ? (
|
||||||
|
<>
|
||||||
|
<div data-testid="quick-chat-streaming-text">
|
||||||
|
{renderAssistantMessageContent(streamingText, plainTextMessageIds.has("__streaming__"))}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`quick-chat-message-render-toggle${plainTextMessageIds.has("__streaming__") ? " quick-chat-message-render-toggle--plain" : ""}`}
|
||||||
|
data-testid="quick-chat-message-render-toggle"
|
||||||
|
aria-label={plainTextMessageIds.has("__streaming__") ? "Show rendered markdown" : "Show plain text"}
|
||||||
|
onClick={() => toggleMessageRenderMode("__streaming__")}
|
||||||
|
>
|
||||||
|
{plainTextMessageIds.has("__streaming__") ? <EyeOff size={14} /> : <Eye size={14} />}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="quick-chat-panel-waiting" data-testid="quick-chat-waiting">
|
||||||
|
{streamingThinking ? "Thinking…" : "Connecting…"}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{renderToolCalls(streamingToolCalls, true)}
|
||||||
|
{streamingThinking && (
|
||||||
|
<details className="chat-message-thinking" data-testid="quick-chat-streaming-thinking">
|
||||||
|
<summary>Thinking</summary>
|
||||||
|
<pre className="chat-message-thinking-content">{streamingThinking}</pre>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : messagesLoading ? (
|
||||||
<div className="quick-chat-panel-empty">Loading conversation…</div>
|
<div className="quick-chat-panel-empty">Loading conversation…</div>
|
||||||
) : messages.length === 0 && !streamingText && !streamingThinking && !isStreaming ? (
|
) : messages.length === 0 && !streamingText && !streamingThinking && !isStreaming ? (
|
||||||
<div className="quick-chat-panel-empty">No messages yet. Start the conversation!</div>
|
<div className="quick-chat-panel-empty">No messages yet. Start the conversation!</div>
|
||||||
@@ -2058,41 +2104,6 @@ export function QuickChatFAB({
|
|||||||
onToggleRender={toggleMessageRenderMode}
|
onToggleRender={toggleMessageRenderMode}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{/* Streaming message bubble */}
|
|
||||||
{isStreaming && (
|
|
||||||
<div
|
|
||||||
className="quick-chat-panel-message quick-chat-panel-message--received quick-chat-panel-message--streaming"
|
|
||||||
data-testid="quick-chat-streaming-message"
|
|
||||||
>
|
|
||||||
{streamingText ? (
|
|
||||||
<>
|
|
||||||
<div data-testid="quick-chat-streaming-text">
|
|
||||||
{renderAssistantMessageContent(streamingText, plainTextMessageIds.has("__streaming__"))}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`quick-chat-message-render-toggle${plainTextMessageIds.has("__streaming__") ? " quick-chat-message-render-toggle--plain" : ""}`}
|
|
||||||
data-testid="quick-chat-message-render-toggle"
|
|
||||||
aria-label={plainTextMessageIds.has("__streaming__") ? "Show rendered markdown" : "Show plain text"}
|
|
||||||
onClick={() => toggleMessageRenderMode("__streaming__")}
|
|
||||||
>
|
|
||||||
{plainTextMessageIds.has("__streaming__") ? <EyeOff size={14} /> : <Eye size={14} />}
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<p className="quick-chat-panel-waiting" data-testid="quick-chat-waiting">
|
|
||||||
Thinking…
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{renderToolCalls(streamingToolCalls, true)}
|
|
||||||
{streamingThinking && (
|
|
||||||
<details className="chat-message-thinking" data-testid="quick-chat-streaming-thinking">
|
|
||||||
<summary>Thinking</summary>
|
|
||||||
<pre className="chat-message-thinking-content">{streamingThinking}</pre>
|
|
||||||
</details>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1512,6 +1512,24 @@ describe("ChatView", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("streaming states", () => {
|
describe("streaming states", () => {
|
||||||
|
it("keeps the streaming indicator visible while message history is still loading", () => {
|
||||||
|
setupMockChat({
|
||||||
|
activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" },
|
||||||
|
messages: [],
|
||||||
|
messagesLoading: true,
|
||||||
|
isStreaming: true,
|
||||||
|
streamingText: "",
|
||||||
|
streamingThinking: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||||
|
|
||||||
|
const streamingMessage = document.querySelector(".chat-message--streaming") as HTMLElement | null;
|
||||||
|
expect(streamingMessage).toBeInTheDocument();
|
||||||
|
expect(streamingMessage?.textContent).toContain("Connecting");
|
||||||
|
expect(screen.queryByText("Loading messages...")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it("shows waiting indicator when streaming starts before text arrives", () => {
|
it("shows waiting indicator when streaming starts before text arrives", () => {
|
||||||
setupMockChat({
|
setupMockChat({
|
||||||
activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" },
|
activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" },
|
||||||
|
|||||||
@@ -57,6 +57,16 @@ const agentSession: ChatSession = {
|
|||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function createDeferredPromise<T>() {
|
||||||
|
let resolve!: (value: T | PromiseLike<T>) => void;
|
||||||
|
let reject!: (reason?: unknown) => void;
|
||||||
|
const promise = new Promise<T>((res, rej) => {
|
||||||
|
resolve = res;
|
||||||
|
reject = rej;
|
||||||
|
});
|
||||||
|
return { promise, resolve, reject };
|
||||||
|
}
|
||||||
|
|
||||||
describe("QuickChatFAB session-first UX", () => {
|
describe("QuickChatFAB session-first UX", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
@@ -183,4 +193,23 @@ describe("QuickChatFAB session-first UX", () => {
|
|||||||
});
|
});
|
||||||
expect(mockCreateChatSession).not.toHaveBeenCalled();
|
expect(mockCreateChatSession).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("shows the streaming indicator instead of the loading placeholder while waiting for a long reply", async () => {
|
||||||
|
const deferredMessages = createDeferredPromise<{ messages: never[] }>();
|
||||||
|
mockFetchChatMessages.mockImplementation(() => deferredMessages.promise);
|
||||||
|
mockStreamChatResponse.mockImplementation(() => ({ close: vi.fn(), isConnected: () => false }));
|
||||||
|
|
||||||
|
render(<QuickChatFAB addToast={vi.fn()} projectId="proj-1" />);
|
||||||
|
fireEvent.click(screen.getByTestId("quick-chat-fab"));
|
||||||
|
|
||||||
|
const input = await screen.findByTestId("quick-chat-input");
|
||||||
|
await waitFor(() => expect(input).not.toBeDisabled());
|
||||||
|
|
||||||
|
fireEvent.change(input, { target: { value: "Explain the current architecture" } });
|
||||||
|
fireEvent.click(screen.getByTestId("quick-chat-send"));
|
||||||
|
|
||||||
|
expect(await screen.findByTestId("quick-chat-streaming-message")).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId("quick-chat-waiting")).toHaveTextContent("Connecting…");
|
||||||
|
expect(screen.queryByText("Loading conversation…")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -178,6 +178,89 @@ describe("createEventBridge", () => {
|
|||||||
expect(textEnd1.contentIndex).toBe(1);
|
expect(textEnd1.contentIndex).toBe(1);
|
||||||
expect(textEnd1.content).toBe("Second");
|
expect(textEnd1.content).toBe("Second");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("repairs a missing sentence boundary between consecutive text blocks", () => {
|
||||||
|
const bridge = createBridgeWithStart();
|
||||||
|
|
||||||
|
bridge.handleEvent({
|
||||||
|
type: "content_block_start",
|
||||||
|
index: 0,
|
||||||
|
content_block: { type: "text", text: "" },
|
||||||
|
});
|
||||||
|
bridge.handleEvent({
|
||||||
|
type: "content_block_delta",
|
||||||
|
index: 0,
|
||||||
|
delta: { type: "text_delta", text: "compare them." },
|
||||||
|
});
|
||||||
|
bridge.handleEvent({
|
||||||
|
type: "content_block_stop",
|
||||||
|
index: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
bridge.handleEvent({
|
||||||
|
type: "content_block_start",
|
||||||
|
index: 1,
|
||||||
|
content_block: { type: "text", text: "" },
|
||||||
|
});
|
||||||
|
bridge.handleEvent({
|
||||||
|
type: "content_block_delta",
|
||||||
|
index: 1,
|
||||||
|
delta: { type: "text_delta", text: "Good overview." },
|
||||||
|
});
|
||||||
|
|
||||||
|
const output = bridge.getOutput();
|
||||||
|
const combinedText = output.content
|
||||||
|
.filter((content): content is any => content.type === "text")
|
||||||
|
.map((content: any) => content.text)
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
expect(combinedText).toBe("compare them. Good overview.");
|
||||||
|
expect(stream.events[4]).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
type: "text_delta",
|
||||||
|
contentIndex: 1,
|
||||||
|
delta: " Good overview.",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not insert spaces into lowercase continuations like property access", () => {
|
||||||
|
const bridge = createBridgeWithStart();
|
||||||
|
|
||||||
|
bridge.handleEvent({
|
||||||
|
type: "content_block_start",
|
||||||
|
index: 0,
|
||||||
|
content_block: { type: "text", text: "" },
|
||||||
|
});
|
||||||
|
bridge.handleEvent({
|
||||||
|
type: "content_block_delta",
|
||||||
|
index: 0,
|
||||||
|
delta: { type: "text_delta", text: "console." },
|
||||||
|
});
|
||||||
|
bridge.handleEvent({
|
||||||
|
type: "content_block_stop",
|
||||||
|
index: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
bridge.handleEvent({
|
||||||
|
type: "content_block_start",
|
||||||
|
index: 1,
|
||||||
|
content_block: { type: "text", text: "" },
|
||||||
|
});
|
||||||
|
bridge.handleEvent({
|
||||||
|
type: "content_block_delta",
|
||||||
|
index: 1,
|
||||||
|
delta: { type: "text_delta", text: "log('hi')" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const output = bridge.getOutput();
|
||||||
|
const combinedText = output.content
|
||||||
|
.filter((content): content is any => content.type === "text")
|
||||||
|
.map((content: any) => content.text)
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
expect(combinedText).toBe("console.log('hi')");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("message_start usage tracking", () => {
|
describe("message_start usage tracking", () => {
|
||||||
@@ -795,6 +878,30 @@ describe("createEventBridge", () => {
|
|||||||
expect(thinkingBlock.thinking).toBe("First thought. Second thought.");
|
expect(thinkingBlock.thinking).toBe("First thought. Second thought.");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("repairs a missing sentence boundary between thinking deltas", () => {
|
||||||
|
const bridge = createBridgeWithStart();
|
||||||
|
|
||||||
|
bridge.handleEvent({
|
||||||
|
type: "content_block_start",
|
||||||
|
index: 0,
|
||||||
|
content_block: { type: "thinking" },
|
||||||
|
});
|
||||||
|
bridge.handleEvent({
|
||||||
|
type: "content_block_delta",
|
||||||
|
index: 0,
|
||||||
|
delta: { type: "thinking_delta", thinking: "I will inspect the hook." },
|
||||||
|
});
|
||||||
|
bridge.handleEvent({
|
||||||
|
type: "content_block_delta",
|
||||||
|
index: 0,
|
||||||
|
delta: { type: "thinking_delta", thinking: "Good, now the component." },
|
||||||
|
});
|
||||||
|
|
||||||
|
const output = bridge.getOutput();
|
||||||
|
const thinkingBlock = output.content[0] as any;
|
||||||
|
expect(thinkingBlock.thinking).toBe("I will inspect the hook. Good, now the component.");
|
||||||
|
});
|
||||||
|
|
||||||
it("emits thinking_end for thinking block stop", () => {
|
it("emits thinking_end for thinking block stop", () => {
|
||||||
const bridge = createBridgeWithStart();
|
const bridge = createBridgeWithStart();
|
||||||
|
|
||||||
|
|||||||
@@ -60,6 +60,29 @@ function mapStopReason(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeStreamingDelta(previousText: string, nextDelta: string): string {
|
||||||
|
if (!previousText || !nextDelta) {
|
||||||
|
return nextDelta;
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousChar = previousText.slice(-1);
|
||||||
|
const nextChar = nextDelta[0] ?? "";
|
||||||
|
|
||||||
|
if (/\s/.test(previousChar) || /\s/.test(nextChar)) {
|
||||||
|
return nextDelta;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Claude sometimes splits adjacent sentences across separate deltas or text
|
||||||
|
// blocks without preserving the separating space. Only repair the specific
|
||||||
|
// "sentence punctuation + uppercase/quoted sentence start" case so code,
|
||||||
|
// domains, and lowercase continuations remain untouched.
|
||||||
|
if (/[.!?]/.test(previousChar) && /[A-Z0-9"'([]/.test(nextChar)) {
|
||||||
|
return ` ${nextDelta}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return nextDelta;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create an event bridge that translates Claude API streaming events
|
* Create an event bridge that translates Claude API streaming events
|
||||||
* into pi's AssistantMessageEventStream events.
|
* into pi's AssistantMessageEventStream events.
|
||||||
@@ -98,6 +121,19 @@ export function createEventBridge(
|
|||||||
|
|
||||||
let started = false;
|
let started = false;
|
||||||
|
|
||||||
|
function getPreviousContentText(contentIndex: number, type: "text" | "thinking"): string {
|
||||||
|
for (let i = contentIndex - 1; i >= 0; i--) {
|
||||||
|
const contentBlock = output.content[i];
|
||||||
|
if (type === "text" && contentBlock?.type === "text") {
|
||||||
|
return contentBlock.text;
|
||||||
|
}
|
||||||
|
if (type === "thinking" && contentBlock?.type === "thinking") {
|
||||||
|
return contentBlock.thinking;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
function handleEvent(event: ClaudeApiEvent): void {
|
function handleEvent(event: ClaudeApiEvent): void {
|
||||||
// Emit start event on first message — tells pi to begin incremental rendering
|
// Emit start event on first message — tells pi to begin incremental rendering
|
||||||
if (!started) {
|
if (!started) {
|
||||||
@@ -226,14 +262,18 @@ export function createEventBridge(
|
|||||||
|
|
||||||
const block = blocks[idx];
|
const block = blocks[idx];
|
||||||
if (block.type === "text") {
|
if (block.type === "text") {
|
||||||
block.text += event.delta!.text;
|
const delta = normalizeStreamingDelta(
|
||||||
|
block.text || getPreviousContentText(idx, "text"),
|
||||||
|
event.delta!.text,
|
||||||
|
);
|
||||||
|
block.text += delta;
|
||||||
const contentBlock = output.content[idx] as TextContent;
|
const contentBlock = output.content[idx] as TextContent;
|
||||||
contentBlock.text = block.text;
|
contentBlock.text = block.text;
|
||||||
|
|
||||||
stream.push({
|
stream.push({
|
||||||
type: "text_delta",
|
type: "text_delta",
|
||||||
contentIndex: idx,
|
contentIndex: idx,
|
||||||
delta: event.delta!.text,
|
delta,
|
||||||
partial: output,
|
partial: output,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -246,14 +286,18 @@ export function createEventBridge(
|
|||||||
|
|
||||||
const block = blocks[idx];
|
const block = blocks[idx];
|
||||||
if (block.type === "thinking") {
|
if (block.type === "thinking") {
|
||||||
block.text += event.delta!.thinking;
|
const delta = normalizeStreamingDelta(
|
||||||
|
block.text || getPreviousContentText(idx, "thinking"),
|
||||||
|
event.delta!.thinking,
|
||||||
|
);
|
||||||
|
block.text += delta;
|
||||||
const contentBlock = output.content[idx] as ThinkingContent;
|
const contentBlock = output.content[idx] as ThinkingContent;
|
||||||
contentBlock.thinking = block.text;
|
contentBlock.thinking = block.text;
|
||||||
|
|
||||||
stream.push({
|
stream.push({
|
||||||
type: "thinking_delta",
|
type: "thinking_delta",
|
||||||
contentIndex: idx,
|
contentIndex: idx,
|
||||||
delta: event.delta!.thinking,
|
delta,
|
||||||
partial: output,
|
partial: output,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user