Skip to content

Commit 40ea7a9

Browse files
feat: Add an OOTB Chat uI to the Feature Server to support RAG demo (feast-dev#5106)
1 parent 414d062 commit 40ea7a9

File tree

4 files changed

+238
-1
lines changed

4 files changed

+238
-1
lines changed

MANIFEST.in

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,4 @@ prune examples
77

88
graft sdk/python/feast/ui/build
99
graft sdk/python/feast/embedded_go/lib
10+
recursive-include sdk/python/feast/static *

sdk/python/feast/feature_server.py

Lines changed: 106 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,29 @@
1+
import asyncio
2+
import os
13
import sys
24
import threading
35
import time
46
import traceback
57
from contextlib import asynccontextmanager
8+
from importlib import resources as importlib_resources
69
from typing import Any, Dict, List, Optional
710

811
import pandas as pd
912
import psutil
1013
from dateutil import parser
11-
from fastapi import Depends, FastAPI, Request, Response, status
14+
from fastapi import (
15+
Depends,
16+
FastAPI,
17+
Request,
18+
Response,
19+
WebSocket,
20+
WebSocketDisconnect,
21+
status,
22+
)
1223
from fastapi.concurrency import run_in_threadpool
1324
from fastapi.logger import logger
1425
from fastapi.responses import JSONResponse
26+
from fastapi.staticfiles import StaticFiles
1527
from google.protobuf.json_format import MessageToDict
1628
from prometheus_client import Gauge, start_http_server
1729
from pydantic import BaseModel
@@ -78,6 +90,15 @@ class GetOnlineFeaturesRequest(BaseModel):
7890
query_string: Optional[str] = None
7991

8092

93+
class ChatMessage(BaseModel):
94+
role: str
95+
content: str
96+
97+
98+
class ChatRequest(BaseModel):
99+
messages: List[ChatMessage]
100+
101+
81102
def _get_features(request: GetOnlineFeaturesRequest, store: "feast.FeatureStore"):
82103
if request.feature_service:
83104
feature_service = store.get_feature_service(
@@ -113,6 +134,35 @@ def get_app(
113134
store: "feast.FeatureStore",
114135
registry_ttl_sec: int = DEFAULT_FEATURE_SERVER_REGISTRY_TTL,
115136
):
137+
"""
138+
Creates a FastAPI app that can be used to start a feature server.
139+
140+
Args:
141+
store: The FeatureStore to use for serving features
142+
registry_ttl_sec: The TTL in seconds for the registry cache
143+
144+
Returns:
145+
A FastAPI app
146+
147+
Example:
148+
```python
149+
from feast import FeatureStore
150+
151+
store = FeatureStore(repo_path="feature_repo")
152+
app = get_app(store)
153+
```
154+
155+
The app provides the following endpoints:
156+
- `/get-online-features`: Get online features
157+
- `/retrieve-online-documents`: Retrieve online documents
158+
- `/push`: Push features to the feature store
159+
- `/write-to-online-store`: Write to the online store
160+
- `/health`: Health check
161+
- `/materialize`: Materialize features
162+
- `/materialize-incremental`: Materialize features incrementally
163+
- `/chat`: Chat UI
164+
- `/ws/chat`: WebSocket endpoint for chat
165+
"""
116166
proto_json.patch()
117167
# Asynchronously refresh registry, notifying shutdown and canceling the active timer if the app is shutting down
118168
registry_proto = None
@@ -297,6 +347,21 @@ async def health():
297347
else Response(status_code=status.HTTP_503_SERVICE_UNAVAILABLE)
298348
)
299349

350+
@app.post("/chat")
351+
async def chat(request: ChatRequest):
352+
# Process the chat request
353+
# For now, just return dummy text
354+
return {"response": "This is a dummy response from the Feast feature server."}
355+
356+
@app.get("/chat")
357+
async def chat_ui():
358+
# Serve the chat UI
359+
static_dir_ref = importlib_resources.files(__spec__.parent) / "static/chat" # type: ignore[name-defined, arg-type]
360+
with importlib_resources.as_file(static_dir_ref) as static_dir:
361+
with open(os.path.join(static_dir, "index.html")) as f:
362+
content = f.read()
363+
return Response(content=content, media_type="text/html")
364+
300365
@app.post("/materialize", dependencies=[Depends(inject_user_details)])
301366
def materialize(request: MaterializeRequest) -> None:
302367
for feature_view in request.feature_views or []:
@@ -337,6 +402,46 @@ async def rest_exception_handler(request: Request, exc: Exception):
337402
content=str(exc),
338403
)
339404

405+
# Chat WebSocket connection manager
406+
class ConnectionManager:
407+
def __init__(self):
408+
self.active_connections: List[WebSocket] = []
409+
410+
async def connect(self, websocket: WebSocket):
411+
await websocket.accept()
412+
self.active_connections.append(websocket)
413+
414+
def disconnect(self, websocket: WebSocket):
415+
self.active_connections.remove(websocket)
416+
417+
async def send_message(self, message: str, websocket: WebSocket):
418+
await websocket.send_text(message)
419+
420+
manager = ConnectionManager()
421+
422+
@app.websocket("/ws/chat")
423+
async def websocket_endpoint(websocket: WebSocket):
424+
await manager.connect(websocket)
425+
try:
426+
while True:
427+
message = await websocket.receive_text()
428+
# Process the received message (currently unused but kept for future implementation)
429+
# For now, just return dummy text
430+
response = f"You sent: '{message}'. This is a dummy response from the Feast feature server."
431+
432+
# Stream the response word by word
433+
words = response.split()
434+
for word in words:
435+
await manager.send_message(word + " ", websocket)
436+
await asyncio.sleep(0.1) # Add a small delay between words
437+
except WebSocketDisconnect:
438+
manager.disconnect(websocket)
439+
440+
# Mount static files
441+
static_dir_ref = importlib_resources.files(__spec__.parent) / "static" # type: ignore[name-defined, arg-type]
442+
with importlib_resources.as_file(static_dir_ref) as static_dir:
443+
app.mount("/static", StaticFiles(directory=static_dir), name="static")
444+
340445
return app
341446

342447

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8">
5+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
6+
<title>Feast Chat</title>
7+
<style>
8+
body {
9+
font-family: Arial, sans-serif;
10+
margin: 0;
11+
padding: 0;
12+
display: flex;
13+
flex-direction: column;
14+
height: 100vh;
15+
}
16+
.chat-container {
17+
flex: 1;
18+
display: flex;
19+
flex-direction: column;
20+
padding: 20px;
21+
overflow-y: auto;
22+
}
23+
.message {
24+
margin-bottom: 10px;
25+
padding: 10px;
26+
border-radius: 5px;
27+
max-width: 70%;
28+
}
29+
.user-message {
30+
background-color: #e6f7ff;
31+
align-self: flex-end;
32+
}
33+
.bot-message {
34+
background-color: #f0f0f0;
35+
align-self: flex-start;
36+
}
37+
.input-container {
38+
display: flex;
39+
padding: 10px;
40+
border-top: 1px solid #ccc;
41+
}
42+
#message-input {
43+
flex: 1;
44+
padding: 10px;
45+
border: 1px solid #ccc;
46+
border-radius: 5px;
47+
margin-right: 10px;
48+
}
49+
#send-button {
50+
padding: 10px 20px;
51+
background-color: #1890ff;
52+
color: white;
53+
border: none;
54+
border-radius: 5px;
55+
cursor: pointer;
56+
}
57+
</style>
58+
</head>
59+
<body>
60+
<div class="chat-container" id="chat-container">
61+
<div class="message bot-message">Hello! How can I help you today?</div>
62+
</div>
63+
<div class="input-container">
64+
<input type="text" id="message-input" placeholder="Type your message...">
65+
<button id="send-button">Send</button>
66+
</div>
67+
<script>
68+
const chatContainer = document.getElementById('chat-container');
69+
const messageInput = document.getElementById('message-input');
70+
const sendButton = document.getElementById('send-button');
71+
72+
// WebSocket connection
73+
const ws = new WebSocket(`ws://${window.location.host}/ws/chat`);
74+
75+
ws.onmessage = function(event) {
76+
// Check if there's an existing bot message being built
77+
const lastMessage = chatContainer.lastElementChild;
78+
if (lastMessage && lastMessage.classList.contains('bot-message') && lastMessage.dataset.streaming === 'true') {
79+
// Append to the existing message
80+
lastMessage.textContent += event.data;
81+
} else {
82+
// Create a new bot message
83+
const botMessage = document.createElement('div');
84+
botMessage.classList.add('message', 'bot-message');
85+
botMessage.dataset.streaming = 'true';
86+
botMessage.textContent = event.data;
87+
chatContainer.appendChild(botMessage);
88+
chatContainer.scrollTop = chatContainer.scrollHeight;
89+
}
90+
};
91+
92+
ws.onclose = function(event) {
93+
console.log('Connection closed');
94+
// Mark the last message as complete if it was streaming
95+
const lastMessage = chatContainer.lastElementChild;
96+
if (lastMessage && lastMessage.classList.contains('bot-message') && lastMessage.dataset.streaming === 'true') {
97+
lastMessage.dataset.streaming = 'false';
98+
}
99+
};
100+
101+
function sendMessage() {
102+
const message = messageInput.value.trim();
103+
if (message) {
104+
// Add user message to chat
105+
const userMessage = document.createElement('div');
106+
userMessage.classList.add('message', 'user-message');
107+
userMessage.textContent = message;
108+
chatContainer.appendChild(userMessage);
109+
110+
// Send message to server
111+
ws.send(message);
112+
113+
// Clear input
114+
messageInput.value = '';
115+
116+
// Scroll to bottom
117+
chatContainer.scrollTop = chatContainer.scrollHeight;
118+
}
119+
}
120+
121+
sendButton.addEventListener('click', sendMessage);
122+
messageInput.addEventListener('keypress', function(event) {
123+
if (event.key === 'Enter') {
124+
sendMessage();
125+
}
126+
});
127+
</script>
128+
</body>
129+
</html>

sdk/python/feast/ui_server.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,8 @@ def shutdown_event():
6969

7070
@app.get("/registry")
7171
def read_registry():
72+
if registry_proto is None:
73+
return Response(status_code=503) # Service Unavailable
7274
return Response(
7375
content=registry_proto.SerializeToString(),
7476
media_type="application/octet-stream",

0 commit comments

Comments
 (0)