Make reply use the API (#5389)
Co-authored-by: Douwe Osinga <douwe@squareup.com>
This commit is contained in:
+145
-1
@@ -1247,7 +1247,14 @@
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Streaming response initiated"
|
||||
"description": "Streaming response initiated",
|
||||
"content": {
|
||||
"text/event-stream": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/MessageEvent"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"424": {
|
||||
"description": "Agent not initialized"
|
||||
@@ -3172,6 +3179,143 @@
|
||||
"propertyName": "type"
|
||||
}
|
||||
},
|
||||
"MessageEvent": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"message",
|
||||
"type"
|
||||
],
|
||||
"properties": {
|
||||
"message": {
|
||||
"$ref": "#/components/schemas/Message"
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"Message"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"error",
|
||||
"type"
|
||||
],
|
||||
"properties": {
|
||||
"error": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"Error"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"reason",
|
||||
"type"
|
||||
],
|
||||
"properties": {
|
||||
"reason": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"Finish"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"model",
|
||||
"mode",
|
||||
"type"
|
||||
],
|
||||
"properties": {
|
||||
"mode": {
|
||||
"type": "string"
|
||||
},
|
||||
"model": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"ModelChange"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"request_id",
|
||||
"message",
|
||||
"type"
|
||||
],
|
||||
"properties": {
|
||||
"message": {
|
||||
"type": "object"
|
||||
},
|
||||
"request_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"Notification"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"conversation",
|
||||
"type"
|
||||
],
|
||||
"properties": {
|
||||
"conversation": {
|
||||
"$ref": "#/components/schemas/Conversation"
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"UpdateConversation"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"type"
|
||||
],
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"Ping"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"discriminator": {
|
||||
"propertyName": "type"
|
||||
}
|
||||
},
|
||||
"MessageMetadata": {
|
||||
"type": "object",
|
||||
"description": "Metadata for message visibility",
|
||||
|
||||
@@ -351,7 +351,7 @@ export const scanRecipe = <ThrowOnError extends boolean = false>(options: Option
|
||||
};
|
||||
|
||||
export const reply = <ThrowOnError extends boolean = false>(options: Options<ReplyData, ThrowOnError>) => {
|
||||
return (options.client ?? client).post<ReplyResponses, ReplyErrors, ThrowOnError>({
|
||||
return (options.client ?? client).sse.post<ReplyResponses, ReplyErrors, ThrowOnError>({
|
||||
url: '/reply',
|
||||
...options,
|
||||
headers: {
|
||||
|
||||
@@ -360,6 +360,32 @@ export type MessageContent = (TextContent & {
|
||||
type: 'systemNotification';
|
||||
});
|
||||
|
||||
export type MessageEvent = {
|
||||
message: Message;
|
||||
type: 'Message';
|
||||
} | {
|
||||
error: string;
|
||||
type: 'Error';
|
||||
} | {
|
||||
reason: string;
|
||||
type: 'Finish';
|
||||
} | {
|
||||
mode: string;
|
||||
model: string;
|
||||
type: 'ModelChange';
|
||||
} | {
|
||||
message: {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
request_id: string;
|
||||
type: 'Notification';
|
||||
} | {
|
||||
conversation: Conversation;
|
||||
type: 'UpdateConversation';
|
||||
} | {
|
||||
type: 'Ping';
|
||||
};
|
||||
|
||||
/**
|
||||
* Metadata for message visibility
|
||||
*/
|
||||
@@ -1825,9 +1851,11 @@ export type ReplyResponses = {
|
||||
/**
|
||||
* Streaming response initiated
|
||||
*/
|
||||
200: unknown;
|
||||
200: MessageEvent;
|
||||
};
|
||||
|
||||
export type ReplyResponse = ReplyResponses[keyof ReplyResponses];
|
||||
|
||||
export type CreateScheduleData = {
|
||||
body: CreateScheduleRequest;
|
||||
path?: never;
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { ChatState } from '../types/chatState';
|
||||
|
||||
import {
|
||||
Conversation,
|
||||
Message,
|
||||
MessageEvent,
|
||||
reply,
|
||||
resumeAgent,
|
||||
Session,
|
||||
updateFromSession,
|
||||
updateSessionUserRecipeValues,
|
||||
} from '../api';
|
||||
import { getApiUrl } from '../config';
|
||||
|
||||
import { createUserMessage, getCompactingMessage, getThinkingMessage } from '../types/message';
|
||||
|
||||
const TextDecoder = globalThis.TextDecoder;
|
||||
const resultsCache = new Map<string, { messages: Message[]; session: Session }>();
|
||||
|
||||
// Debug logging - set to false in production
|
||||
@@ -45,28 +46,6 @@ const log = {
|
||||
},
|
||||
};
|
||||
|
||||
type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };
|
||||
|
||||
interface NotificationEvent {
|
||||
type: 'Notification';
|
||||
request_id: string;
|
||||
message: {
|
||||
method: string;
|
||||
params: {
|
||||
[key: string]: JsonValue;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
type MessageEvent =
|
||||
| { type: 'Message'; message: Message }
|
||||
| { type: 'Error'; error: string }
|
||||
| { type: 'Ping' }
|
||||
| { type: 'Finish'; reason: string }
|
||||
| { type: 'ModelChange'; model: string; mode: string }
|
||||
| { type: 'UpdateConversation'; conversation: Conversation }
|
||||
| NotificationEvent;
|
||||
|
||||
interface UseChatStreamProps {
|
||||
sessionId: string;
|
||||
onStreamFinish: () => void;
|
||||
@@ -106,121 +85,78 @@ function pushMessage(currentMessages: Message[], incomingMsg: Message): Message[
|
||||
}
|
||||
|
||||
async function streamFromResponse(
|
||||
response: Response,
|
||||
stream: AsyncIterable<MessageEvent>,
|
||||
initialMessages: Message[],
|
||||
updateMessages: (messages: Message[]) => void,
|
||||
updateChatState: (state: ChatState) => void,
|
||||
onFinish: (error?: string) => void
|
||||
): Promise<void> {
|
||||
let chunkCount = 0;
|
||||
let messageEventCount = 0;
|
||||
let currentMessages = initialMessages;
|
||||
|
||||
try {
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
if (!response.body) throw new Error('No response body');
|
||||
log.stream('reading-events');
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let currentMessages = initialMessages;
|
||||
for await (const event of stream) {
|
||||
switch (event.type) {
|
||||
case 'Message': {
|
||||
messageEventCount++;
|
||||
const msg = event.message;
|
||||
currentMessages = pushMessage(currentMessages, msg);
|
||||
|
||||
log.stream('reading-chunks');
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
log.stream('chunks-complete', {
|
||||
totalChunks: chunkCount,
|
||||
messageEvents: messageEventCount,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
chunkCount++;
|
||||
const chunk = decoder.decode(value);
|
||||
const lines = chunk.split('\n');
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith('data: ')) continue;
|
||||
|
||||
const data = line.slice(6);
|
||||
if (data === '[DONE]') continue;
|
||||
|
||||
try {
|
||||
const event = JSON.parse(data) as MessageEvent;
|
||||
|
||||
switch (event.type) {
|
||||
case 'Message': {
|
||||
messageEventCount++;
|
||||
const msg = event.message;
|
||||
currentMessages = pushMessage(currentMessages, msg);
|
||||
|
||||
if (getCompactingMessage(msg)) {
|
||||
log.state(ChatState.Compacting, { reason: 'compacting notification' });
|
||||
updateChatState(ChatState.Compacting);
|
||||
} else if (getThinkingMessage(msg)) {
|
||||
log.state(ChatState.Thinking, { reason: 'thinking notification' });
|
||||
updateChatState(ChatState.Thinking);
|
||||
}
|
||||
|
||||
// Only log every 10th message event to avoid spam
|
||||
if (messageEventCount % 10 === 0) {
|
||||
log.stream('message-chunk', {
|
||||
eventCount: messageEventCount,
|
||||
messageCount: currentMessages.length,
|
||||
});
|
||||
}
|
||||
|
||||
// This calls the wrapped setMessagesAndLog with 'streaming' context
|
||||
updateMessages(currentMessages);
|
||||
break;
|
||||
}
|
||||
case 'Error': {
|
||||
log.error('stream event error', event.error);
|
||||
onFinish('Stream error: ' + event.error);
|
||||
return;
|
||||
}
|
||||
case 'Finish': {
|
||||
log.stream('finish-event', { reason: event.reason });
|
||||
onFinish();
|
||||
return;
|
||||
}
|
||||
case 'ModelChange': {
|
||||
log.stream('model-change', {
|
||||
model: event.model,
|
||||
mode: event.mode,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'UpdateConversation': {
|
||||
log.messages('conversation-update', event.conversation.length);
|
||||
currentMessages = event.conversation;
|
||||
// This calls the wrapped setMessagesAndLog with 'streaming' context
|
||||
updateMessages(event.conversation);
|
||||
break;
|
||||
}
|
||||
case 'Notification': {
|
||||
// Don't log notifications, too noisy
|
||||
break;
|
||||
}
|
||||
case 'Ping': {
|
||||
// Don't log pings
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
console.warn('Unhandled event type:', event['type']);
|
||||
break;
|
||||
}
|
||||
if (getCompactingMessage(msg)) {
|
||||
log.state(ChatState.Compacting, { reason: 'compacting notification' });
|
||||
updateChatState(ChatState.Compacting);
|
||||
} else if (getThinkingMessage(msg)) {
|
||||
log.state(ChatState.Thinking, { reason: 'thinking notification' });
|
||||
updateChatState(ChatState.Thinking);
|
||||
}
|
||||
} catch (e) {
|
||||
log.error('SSE parse failed', e);
|
||||
onFinish('Failed to parse SSE:' + e);
|
||||
|
||||
if (messageEventCount % 10 === 0) {
|
||||
log.stream('message-chunk', {
|
||||
eventCount: messageEventCount,
|
||||
messageCount: currentMessages.length,
|
||||
});
|
||||
}
|
||||
|
||||
updateMessages(currentMessages);
|
||||
break;
|
||||
}
|
||||
case 'Error': {
|
||||
log.error('stream event error', event.error);
|
||||
onFinish('Stream error: ' + event.error);
|
||||
return;
|
||||
}
|
||||
case 'Finish': {
|
||||
log.stream('finish-event', { reason: event.reason });
|
||||
onFinish();
|
||||
return;
|
||||
}
|
||||
case 'ModelChange': {
|
||||
log.stream('model-change', {
|
||||
model: event.model,
|
||||
mode: event.mode,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'UpdateConversation': {
|
||||
log.messages('conversation-update', event.conversation.length);
|
||||
currentMessages = event.conversation;
|
||||
updateMessages(event.conversation);
|
||||
break;
|
||||
}
|
||||
case 'Notification':
|
||||
case 'Ping':
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
log.stream('events-complete', { messageEvents: messageEventCount });
|
||||
onFinish();
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name !== 'AbortError') {
|
||||
log.error('stream read error', error);
|
||||
onFinish('Stream error:' + error);
|
||||
onFinish('Stream error: ' + error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -337,26 +273,19 @@ export function useChatStream({
|
||||
try {
|
||||
log.stream('request-start', { sessionId: sessionId.slice(0, 8) });
|
||||
|
||||
const response = await fetch(getApiUrl('/reply'), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Secret-Key': await window.electron.getSecretKey(),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
const { stream } = await reply({
|
||||
body: {
|
||||
session_id: sessionId,
|
||||
messages: currentMessages,
|
||||
}),
|
||||
},
|
||||
throwOnError: true,
|
||||
signal: abortControllerRef.current.signal,
|
||||
});
|
||||
|
||||
log.stream('response-received', {
|
||||
status: response.status,
|
||||
ok: response.ok,
|
||||
});
|
||||
log.stream('stream-started');
|
||||
|
||||
await streamFromResponse(
|
||||
response,
|
||||
stream,
|
||||
currentMessages,
|
||||
(messages: Message[]) => setMessagesAndLog(messages, 'streaming'),
|
||||
setChatState,
|
||||
|
||||
Reference in New Issue
Block a user