a bit more progressive update and single turn
This commit is contained in:
@@ -2,12 +2,15 @@
|
|||||||
|
|
||||||
from fastapi import FastAPI, HTTPException
|
from fastapi import FastAPI, HTTPException
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from typing import List, Dict, Any
|
from typing import List, Dict, Any
|
||||||
import uuid
|
import uuid
|
||||||
|
import json
|
||||||
|
import asyncio
|
||||||
|
|
||||||
from . import storage
|
from . import storage
|
||||||
from .council import run_full_council, generate_conversation_title
|
from .council import run_full_council, generate_conversation_title, stage1_collect_responses, stage2_collect_rankings, stage3_synthesize_final, calculate_aggregate_rankings
|
||||||
|
|
||||||
app = FastAPI(title="LLM Council API")
|
app = FastAPI(title="LLM Council API")
|
||||||
|
|
||||||
@@ -120,6 +123,77 @@ async def send_message(conversation_id: str, request: SendMessageRequest):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/conversations/{conversation_id}/message/stream")
|
||||||
|
async def send_message_stream(conversation_id: str, request: SendMessageRequest):
|
||||||
|
"""
|
||||||
|
Send a message and stream the 3-stage council process.
|
||||||
|
Returns Server-Sent Events as each stage completes.
|
||||||
|
"""
|
||||||
|
# Check if conversation exists
|
||||||
|
conversation = storage.get_conversation(conversation_id)
|
||||||
|
if conversation is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Conversation not found")
|
||||||
|
|
||||||
|
# Check if this is the first message
|
||||||
|
is_first_message = len(conversation["messages"]) == 0
|
||||||
|
|
||||||
|
async def event_generator():
|
||||||
|
try:
|
||||||
|
# Add user message
|
||||||
|
storage.add_user_message(conversation_id, request.content)
|
||||||
|
|
||||||
|
# Start title generation in parallel (don't await yet)
|
||||||
|
title_task = None
|
||||||
|
if is_first_message:
|
||||||
|
title_task = asyncio.create_task(generate_conversation_title(request.content))
|
||||||
|
|
||||||
|
# Stage 1: Collect responses
|
||||||
|
yield f"data: {json.dumps({'type': 'stage1_start'})}\n\n"
|
||||||
|
stage1_results = await stage1_collect_responses(request.content)
|
||||||
|
yield f"data: {json.dumps({'type': 'stage1_complete', 'data': stage1_results})}\n\n"
|
||||||
|
|
||||||
|
# Stage 2: Collect rankings
|
||||||
|
yield f"data: {json.dumps({'type': 'stage2_start'})}\n\n"
|
||||||
|
stage2_results, label_to_model = await stage2_collect_rankings(request.content, stage1_results)
|
||||||
|
aggregate_rankings = calculate_aggregate_rankings(stage2_results, label_to_model)
|
||||||
|
yield f"data: {json.dumps({'type': 'stage2_complete', 'data': stage2_results, 'metadata': {'label_to_model': label_to_model, 'aggregate_rankings': aggregate_rankings}})}\n\n"
|
||||||
|
|
||||||
|
# Stage 3: Synthesize final answer
|
||||||
|
yield f"data: {json.dumps({'type': 'stage3_start'})}\n\n"
|
||||||
|
stage3_result = await stage3_synthesize_final(request.content, stage1_results, stage2_results)
|
||||||
|
yield f"data: {json.dumps({'type': 'stage3_complete', 'data': stage3_result})}\n\n"
|
||||||
|
|
||||||
|
# Wait for title generation if it was started
|
||||||
|
if title_task:
|
||||||
|
title = await title_task
|
||||||
|
storage.update_conversation_title(conversation_id, title)
|
||||||
|
yield f"data: {json.dumps({'type': 'title_complete', 'data': {'title': title}})}\n\n"
|
||||||
|
|
||||||
|
# Save complete assistant message
|
||||||
|
storage.add_assistant_message(
|
||||||
|
conversation_id,
|
||||||
|
stage1_results,
|
||||||
|
stage2_results,
|
||||||
|
stage3_result
|
||||||
|
)
|
||||||
|
|
||||||
|
# Send completion event
|
||||||
|
yield f"data: {json.dumps({'type': 'complete'})}\n\n"
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
# Send error event
|
||||||
|
yield f"data: {json.dumps({'type': 'error', 'message': str(e)})}\n\n"
|
||||||
|
|
||||||
|
return StreamingResponse(
|
||||||
|
event_generator(),
|
||||||
|
media_type="text/event-stream",
|
||||||
|
headers={
|
||||||
|
"Cache-Control": "no-cache",
|
||||||
|
"Connection": "keep-alive",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
import uvicorn
|
import uvicorn
|
||||||
uvicorn.run(app, host="0.0.0.0", port=8001)
|
uvicorn.run(app, host="0.0.0.0", port=8001)
|
||||||
|
|||||||
@@ -69,33 +69,114 @@ function App() {
|
|||||||
messages: [...prev.messages, userMessage],
|
messages: [...prev.messages, userMessage],
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Send message and get council response
|
// Create a partial assistant message that will be updated progressively
|
||||||
const response = await api.sendMessage(currentConversationId, content);
|
|
||||||
|
|
||||||
// Add assistant message to UI
|
|
||||||
const assistantMessage = {
|
const assistantMessage = {
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
stage1: response.stage1,
|
stage1: null,
|
||||||
stage2: response.stage2,
|
stage2: null,
|
||||||
stage3: response.stage3,
|
stage3: null,
|
||||||
metadata: response.metadata,
|
metadata: null,
|
||||||
|
loading: {
|
||||||
|
stage1: false,
|
||||||
|
stage2: false,
|
||||||
|
stage3: false,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Add the partial assistant message
|
||||||
setCurrentConversation((prev) => ({
|
setCurrentConversation((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
messages: [...prev.messages, assistantMessage],
|
messages: [...prev.messages, assistantMessage],
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Reload conversations list to update message count
|
// Send message with streaming
|
||||||
await loadConversations();
|
await api.sendMessageStream(currentConversationId, content, (eventType, event) => {
|
||||||
|
switch (eventType) {
|
||||||
|
case 'stage1_start':
|
||||||
|
setCurrentConversation((prev) => {
|
||||||
|
const messages = [...prev.messages];
|
||||||
|
const lastMsg = messages[messages.length - 1];
|
||||||
|
lastMsg.loading.stage1 = true;
|
||||||
|
return { ...prev, messages };
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'stage1_complete':
|
||||||
|
setCurrentConversation((prev) => {
|
||||||
|
const messages = [...prev.messages];
|
||||||
|
const lastMsg = messages[messages.length - 1];
|
||||||
|
lastMsg.stage1 = event.data;
|
||||||
|
lastMsg.loading.stage1 = false;
|
||||||
|
return { ...prev, messages };
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'stage2_start':
|
||||||
|
setCurrentConversation((prev) => {
|
||||||
|
const messages = [...prev.messages];
|
||||||
|
const lastMsg = messages[messages.length - 1];
|
||||||
|
lastMsg.loading.stage2 = true;
|
||||||
|
return { ...prev, messages };
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'stage2_complete':
|
||||||
|
setCurrentConversation((prev) => {
|
||||||
|
const messages = [...prev.messages];
|
||||||
|
const lastMsg = messages[messages.length - 1];
|
||||||
|
lastMsg.stage2 = event.data;
|
||||||
|
lastMsg.metadata = event.metadata;
|
||||||
|
lastMsg.loading.stage2 = false;
|
||||||
|
return { ...prev, messages };
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'stage3_start':
|
||||||
|
setCurrentConversation((prev) => {
|
||||||
|
const messages = [...prev.messages];
|
||||||
|
const lastMsg = messages[messages.length - 1];
|
||||||
|
lastMsg.loading.stage3 = true;
|
||||||
|
return { ...prev, messages };
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'stage3_complete':
|
||||||
|
setCurrentConversation((prev) => {
|
||||||
|
const messages = [...prev.messages];
|
||||||
|
const lastMsg = messages[messages.length - 1];
|
||||||
|
lastMsg.stage3 = event.data;
|
||||||
|
lastMsg.loading.stage3 = false;
|
||||||
|
return { ...prev, messages };
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'title_complete':
|
||||||
|
// Reload conversations to get updated title
|
||||||
|
loadConversations();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'complete':
|
||||||
|
// Stream complete, reload conversations list
|
||||||
|
loadConversations();
|
||||||
|
setIsLoading(false);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'error':
|
||||||
|
console.error('Stream error:', event.message);
|
||||||
|
setIsLoading(false);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
console.log('Unknown event type:', eventType);
|
||||||
|
}
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to send message:', error);
|
console.error('Failed to send message:', error);
|
||||||
// Remove optimistic user message on error
|
// Remove optimistic messages on error
|
||||||
setCurrentConversation((prev) => ({
|
setCurrentConversation((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
messages: prev.messages.slice(0, -1),
|
messages: prev.messages.slice(0, -2),
|
||||||
}));
|
}));
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -65,4 +65,51 @@ export const api = {
|
|||||||
}
|
}
|
||||||
return response.json();
|
return response.json();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a message and receive streaming updates.
|
||||||
|
* @param {string} conversationId - The conversation ID
|
||||||
|
* @param {string} content - The message content
|
||||||
|
* @param {function} onEvent - Callback function for each event: (eventType, data) => void
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async sendMessageStream(conversationId, content, onEvent) {
|
||||||
|
const response = await fetch(
|
||||||
|
`${API_BASE}/api/conversations/${conversationId}/message/stream`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ content }),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to send message');
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
|
||||||
|
const chunk = decoder.decode(value);
|
||||||
|
const lines = chunk.split('\n');
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.startsWith('data: ')) {
|
||||||
|
const data = line.slice(6);
|
||||||
|
try {
|
||||||
|
const event = JSON.parse(data);
|
||||||
|
onEvent(event.type, event);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to parse SSE event:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -71,6 +71,20 @@
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.stage-loading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
margin: 12px 0;
|
||||||
|
background: #f9fafb;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
color: #666;
|
||||||
|
font-size: 14px;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
.spinner {
|
.spinner {
|
||||||
width: 20px;
|
width: 20px;
|
||||||
height: 20px;
|
height: 20px;
|
||||||
|
|||||||
@@ -71,13 +71,39 @@ export default function ChatInterface({
|
|||||||
) : (
|
) : (
|
||||||
<div className="assistant-message">
|
<div className="assistant-message">
|
||||||
<div className="message-label">LLM Council</div>
|
<div className="message-label">LLM Council</div>
|
||||||
<Stage1 responses={msg.stage1} />
|
|
||||||
<Stage2
|
{/* Stage 1 */}
|
||||||
rankings={msg.stage2}
|
{msg.loading?.stage1 && (
|
||||||
labelToModel={msg.metadata?.label_to_model}
|
<div className="stage-loading">
|
||||||
aggregateRankings={msg.metadata?.aggregate_rankings}
|
<div className="spinner"></div>
|
||||||
/>
|
<span>Running Stage 1: Collecting individual responses...</span>
|
||||||
<Stage3 finalResponse={msg.stage3} />
|
</div>
|
||||||
|
)}
|
||||||
|
{msg.stage1 && <Stage1 responses={msg.stage1} />}
|
||||||
|
|
||||||
|
{/* Stage 2 */}
|
||||||
|
{msg.loading?.stage2 && (
|
||||||
|
<div className="stage-loading">
|
||||||
|
<div className="spinner"></div>
|
||||||
|
<span>Running Stage 2: Peer rankings...</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{msg.stage2 && (
|
||||||
|
<Stage2
|
||||||
|
rankings={msg.stage2}
|
||||||
|
labelToModel={msg.metadata?.label_to_model}
|
||||||
|
aggregateRankings={msg.metadata?.aggregate_rankings}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Stage 3 */}
|
||||||
|
{msg.loading?.stage3 && (
|
||||||
|
<div className="stage-loading">
|
||||||
|
<div className="spinner"></div>
|
||||||
|
<span>Running Stage 3: Final synthesis...</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{msg.stage3 && <Stage3 finalResponse={msg.stage3} />}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -94,24 +120,26 @@ export default function ChatInterface({
|
|||||||
<div ref={messagesEndRef} />
|
<div ref={messagesEndRef} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form className="input-form" onSubmit={handleSubmit}>
|
{conversation.messages.length === 0 && (
|
||||||
<textarea
|
<form className="input-form" onSubmit={handleSubmit}>
|
||||||
className="message-input"
|
<textarea
|
||||||
placeholder="Ask your question... (Shift+Enter for new line, Enter to send)"
|
className="message-input"
|
||||||
value={input}
|
placeholder="Ask your question... (Shift+Enter for new line, Enter to send)"
|
||||||
onChange={(e) => setInput(e.target.value)}
|
value={input}
|
||||||
onKeyDown={handleKeyDown}
|
onChange={(e) => setInput(e.target.value)}
|
||||||
disabled={isLoading}
|
onKeyDown={handleKeyDown}
|
||||||
rows={3}
|
disabled={isLoading}
|
||||||
/>
|
rows={3}
|
||||||
<button
|
/>
|
||||||
type="submit"
|
<button
|
||||||
className="send-button"
|
type="submit"
|
||||||
disabled={!input.trim() || isLoading}
|
className="send-button"
|
||||||
>
|
disabled={!input.trim() || isLoading}
|
||||||
Send
|
>
|
||||||
</button>
|
Send
|
||||||
</form>
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user