Skip to content

Commit 58e2d13

Browse files
committed
feat: fix the ai chat
1 parent 8a56a62 commit 58e2d13

25 files changed

Lines changed: 479 additions & 251 deletions

File tree

.vscode/settings.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
{
2-
"python.languageServer": "Default"
2+
"python.languageServer": "None"
33
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"python.languageServer": "None"
3+
}

example/agents/app/agents/chat.py

Lines changed: 4 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,15 @@
1-
from typing import Callable
2-
3-
from fastapi_startkit.ai import Agent, GraphAgent, Middleware
1+
from fastapi_startkit.ai import Agent, Middleware
2+
from langchain_core.tools import BaseTool
43

54
from app.middleware.agent_logger import AgentLogger
65
from app.tools.job_search_tool import job_search_tool
76

87

9-
class RouterAgent(GraphAgent):
10-
def graph(self):
11-
graph = StateGraph(MessagesState)
12-
13-
# Add nodes
14-
graph.add_node("llm_call", llm_call)
15-
graph.add_node("tool_node", tool_node)
16-
17-
# Add edges to connect nodes
18-
graph.add_edge(START, "llm_call")
19-
graph.add_conditional_edges("llm_call", should_continue, ["tool_node", END])
20-
graph.add_edge("tool_node", "llm_call")
21-
22-
# Compile the agent
23-
agent = graph.compile()
24-
8+
class ChatAgent(Agent):
259
def middleware(self) -> list[Middleware]:
2610
return [AgentLogger()]
2711

28-
def tools(self) -> list[Callable]:
12+
def tools(self) -> list[BaseTool]:
2913
return [job_search_tool]
3014

3115
def instructions(self) -> str:
Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,30 @@
1-
from fastapi_startkit.ai import GraphAgent, GraphRunner
2-
from langgraph.graph import StateGraph
1+
from fastapi_startkit.ai import GraphAgent, GraphRunner, Middleware
2+
from fastapi_startkit.ai.graph import AgentState
3+
from langchain_core.tools import BaseTool
4+
from langgraph.graph import END, START, StateGraph
5+
from langgraph.types import Checkpointer
6+
7+
from app.middleware.agent_logger import AgentLogger
8+
from app.tools.job_search_tool import job_search_tool
39

410

511
class SalesAgent(GraphAgent):
6-
def checkpointer(self):
7-
pass
8-
def graph(self, runner: GraphRunner) -> StateGraph:
9-
return StateGraph()
12+
def middleware(self) -> list[Middleware]:
13+
return [AgentLogger()]
14+
15+
def tools(self) -> list[BaseTool]:
16+
return [job_search_tool]
17+
18+
async def graph(self, runner: GraphRunner) -> StateGraph:
19+
from fastapi_startkit.application import app
20+
21+
checkpointer: Checkpointer = await app().make("checkpointer")
22+
23+
return (
24+
StateGraph(AgentState)
25+
.add_node("llm", runner.llm)
26+
.add_node("tools", runner.call_tools)
27+
.add_edge(START, "llm")
28+
.add_conditional_edges("llm", runner.route, ["tools", END])
29+
.add_edge("tools", "llm")
30+
).compile(checkpointer=checkpointer)

example/agents/app/providers/langchain_provider.py

Lines changed: 9 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,26 @@
11
from fastapi_startkit.support import Provider
22
from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver
3-
from psycopg.rows import dict_row
3+
from psycopg import AsyncConnection
4+
from psycopg.rows import DictRow, dict_row
45
from psycopg_pool import AsyncConnectionPool
56

67

78
class LazyCheckpointer:
8-
"""An awaitable, lazily-initialised async Postgres checkpointer.
9-
10-
``Provider.boot()`` runs synchronously while the application is being
11-
constructed — before any serving event loop exists. Async Postgres
12-
connections are bound to the event loop that opens them, so the pool
13-
cannot be opened at boot time. Instead we build the pool closed and open
14-
it (and run ``setup()``) on first ``await`` inside the request loop,
15-
caching the ready saver for subsequent calls.
16-
17-
Usage: ``checkpointer = await app().make("checkpointer")``
18-
"""
19-
209
def __init__(self, uri: str):
21-
self._pool = AsyncConnectionPool(
10+
self.pool = AsyncConnectionPool[AsyncConnection[DictRow]](
2211
conninfo=uri,
2312
open=False,
2413
kwargs={"autocommit": True, "row_factory": dict_row},
2514
)
26-
self._saver: AsyncPostgresSaver | None = None
15+
self.saver: AsyncPostgresSaver | None = None
2716

2817
async def resolve(self) -> AsyncPostgresSaver:
29-
if self._saver is None:
30-
await self._pool.open()
31-
saver = AsyncPostgresSaver(self._pool)
18+
if self.saver is None:
19+
await self.pool.open()
20+
saver = AsyncPostgresSaver(self.pool)
3221
await saver.setup()
33-
self._saver = saver
34-
return self._saver
22+
self.saver = saver
23+
return self.saver
3524

3625
def __await__(self):
3726
return self.resolve().__await__()

example/agents/app/requests/chat.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@
33

44
class ChatRequest(BaseModel):
55
message: str = Field(...)
6+
thread_id: str = Field("default")
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import { useState, useRef, useEffect } from "react"
2+
3+
type Message = {
4+
role: "user" | "assistant"
5+
content: string
6+
}
7+
8+
type ChatProps = {
9+
title?: string
10+
endpoint?: string
11+
placeholder?: string
12+
emptyState?: string
13+
}
14+
15+
export default function Chat({
16+
title = "Chat",
17+
endpoint = "/chat/stream",
18+
placeholder = "Type a message...",
19+
emptyState = "Send a message to start chatting.",
20+
}: ChatProps) {
21+
const [messages, setMessages] = useState<Message[]>([])
22+
const [input, setInput] = useState("")
23+
const [loading, setLoading] = useState(false)
24+
const bottomRef = useRef<HTMLDivElement>(null)
25+
26+
useEffect(() => {
27+
bottomRef.current?.scrollIntoView({ behavior: "smooth" })
28+
}, [messages])
29+
30+
const handleSubmit = async (e: { preventDefault(): void }) => {
31+
e.preventDefault()
32+
if (!input.trim() || loading) return
33+
34+
const userMessage = input.trim()
35+
setInput("")
36+
setMessages(prev => [...prev, { role: "user", content: userMessage }])
37+
setLoading(true)
38+
setMessages(prev => [...prev, { role: "assistant", content: "" }])
39+
40+
try {
41+
const response = await fetch(endpoint, {
42+
method: "POST",
43+
headers: { "Content-Type": "application/json" },
44+
body: JSON.stringify({ message: userMessage }),
45+
})
46+
47+
const reader = response.body?.getReader()
48+
const decoder = new TextDecoder()
49+
if (!reader) return
50+
51+
while (true) {
52+
const { done, value } = await reader.read()
53+
if (done) break
54+
const chunk = decoder.decode(value, { stream: true })
55+
setMessages(prev => {
56+
const updated = [...prev]
57+
updated[updated.length - 1] = {
58+
role: "assistant",
59+
content: updated[updated.length - 1].content + chunk,
60+
}
61+
return updated
62+
})
63+
}
64+
} finally {
65+
setLoading(false)
66+
}
67+
}
68+
69+
return (
70+
<div className="flex flex-col h-screen max-w-2xl mx-auto p-4">
71+
<h1 className="text-xl font-bold mb-4">{title}</h1>
72+
73+
<div className="flex-1 overflow-y-auto space-y-3 mb-4">
74+
{messages.length === 0 && (
75+
<p className="text-center text-gray-400 mt-8">{emptyState}</p>
76+
)}
77+
{messages.map((msg, i) => (
78+
<div key={i} className={`flex ${msg.role === "user" ? "justify-end" : "justify-start"}`}>
79+
<div className={`max-w-sm px-4 py-2 rounded-2xl whitespace-pre-wrap ${
80+
msg.role === "user"
81+
? "bg-blue-500 text-white"
82+
: "bg-gray-100 text-gray-800"
83+
}`}>
84+
{msg.content || (loading && i === messages.length - 1 ? "▋" : "")}
85+
</div>
86+
</div>
87+
))}
88+
<div ref={bottomRef} />
89+
</div>
90+
91+
<form onSubmit={handleSubmit} className="flex gap-2">
92+
<input
93+
className="flex-1 border rounded-xl px-4 py-2 outline-none focus:ring-2 focus:ring-blue-400"
94+
type="text"
95+
value={input}
96+
onChange={e => setInput(e.target.value)}
97+
placeholder={placeholder}
98+
disabled={loading}
99+
/>
100+
<button
101+
type="submit"
102+
disabled={loading || !input.trim()}
103+
className="bg-blue-500 text-white px-5 py-2 rounded-xl disabled:opacity-50 hover:bg-blue-600 transition-colors"
104+
>
105+
Send
106+
</button>
107+
</form>
108+
</div>
109+
)
110+
}
Lines changed: 3 additions & 96 deletions
Original file line numberDiff line numberDiff line change
@@ -1,98 +1,5 @@
1-
import { useState, useRef, useEffect } from "react"
1+
import Chat from "../../components/Chat"
22

3-
type Message = {
4-
role: "user" | "assistant"
5-
content: string
6-
}
7-
8-
export default function Chat() {
9-
const [messages, setMessages] = useState<Message[]>([])
10-
const [input, setInput] = useState("")
11-
const [loading, setLoading] = useState(false)
12-
const bottomRef = useRef<HTMLDivElement>(null)
13-
14-
useEffect(() => {
15-
bottomRef.current?.scrollIntoView({ behavior: "smooth" })
16-
}, [messages])
17-
18-
const handleSubmit = async (e: { preventDefault(): void }) => {
19-
e.preventDefault()
20-
if (!input.trim() || loading) return
21-
22-
const userMessage = input.trim()
23-
setInput("")
24-
setMessages(prev => [...prev, { role: "user", content: userMessage }])
25-
setLoading(true)
26-
setMessages(prev => [...prev, { role: "assistant", content: "" }])
27-
28-
try {
29-
const response = await fetch("/chat/stream", {
30-
method: "POST",
31-
headers: { "Content-Type": "application/json" },
32-
body: JSON.stringify({ message: userMessage }),
33-
})
34-
35-
const reader = response.body?.getReader()
36-
const decoder = new TextDecoder()
37-
if (!reader) return
38-
39-
while (true) {
40-
const { done, value } = await reader.read()
41-
if (done) break
42-
const chunk = decoder.decode(value, { stream: true })
43-
setMessages(prev => {
44-
const updated = [...prev]
45-
updated[updated.length - 1] = {
46-
role: "assistant",
47-
content: updated[updated.length - 1].content + chunk,
48-
}
49-
return updated
50-
})
51-
}
52-
} finally {
53-
setLoading(false)
54-
}
55-
}
56-
57-
return (
58-
<div className="flex flex-col h-screen max-w-2xl mx-auto p-4">
59-
<h1 className="text-xl font-bold mb-4">Chat</h1>
60-
61-
<div className="flex-1 overflow-y-auto space-y-3 mb-4">
62-
{messages.length === 0 && (
63-
<p className="text-center text-gray-400 mt-8">Send a message to start chatting.</p>
64-
)}
65-
{messages.map((msg, i) => (
66-
<div key={i} className={`flex ${msg.role === "user" ? "justify-end" : "justify-start"}`}>
67-
<div className={`max-w-sm px-4 py-2 rounded-2xl whitespace-pre-wrap ${
68-
msg.role === "user"
69-
? "bg-blue-500 text-white"
70-
: "bg-gray-100 text-gray-800"
71-
}`}>
72-
{msg.content || (loading && i === messages.length - 1 ? "▋" : "")}
73-
</div>
74-
</div>
75-
))}
76-
<div ref={bottomRef} />
77-
</div>
78-
79-
<form onSubmit={handleSubmit} className="flex gap-2">
80-
<input
81-
className="flex-1 border rounded-xl px-4 py-2 outline-none focus:ring-2 focus:ring-blue-400"
82-
type="text"
83-
value={input}
84-
onChange={e => setInput(e.target.value)}
85-
placeholder="Type a message..."
86-
disabled={loading}
87-
/>
88-
<button
89-
type="submit"
90-
disabled={loading || !input.trim()}
91-
className="bg-blue-500 text-white px-5 py-2 rounded-xl disabled:opacity-50 hover:bg-blue-600 transition-colors"
92-
>
93-
Send
94-
</button>
95-
</form>
96-
</div>
97-
)
3+
export default function Index() {
4+
return <Chat title="Chat" endpoint="/chat/stream" />
985
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import Chat from "../../../components/Chat"
2+
3+
export default function Index() {
4+
return <Chat title="Sales" endpoint="/sales/stream" />
5+
}

0 commit comments

Comments
 (0)