Skip to content

Commit 78de160

Browse files
authored
Metro map scaffolding (#32)
* initial pass * add station ui * send chatkit message after adding a station * add station works * add station flow * station tagging * location select mode * cleanup * misc. cat lounge cleanup * types * remove unnecessary max-h and max-w
1 parent a5769da commit 78de160

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

44 files changed

+10781
-12
lines changed

README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ You can run the following examples:
77
- [**Cat Lounge**](examples/cat-lounge) - caretaker for a virtual cat that helps improve energy, happiness, and cleanliness stats.
88
- [**Customer Support**](examples/customer-support) – airline concierge with live itinerary data, timeline syncing, and domain-specific tools.
99
- [**News Guide**](examples/news-guide) – Foxhollow Dispatch newsroom assistant with article search, @-mentions, and page-aware responses.
10+
- [**Metro Map**](examples/metro-map) – chat-driven metro planner with a React Flow network of lines and stations.
1011

1112
## Quickstart
1213

@@ -19,6 +20,7 @@ You can run the following examples:
1920
| Cat Lounge | `npm run cat-lounge` | `cd examples/cat-lounge && npm install && npm run start` | http://localhost:5170 |
2021
| Customer Support | `npm run customer-support` | `cd examples/customer-support && npm install && npm start` | http://localhost:5171 |
2122
| News Guide | `npm run news-guide` | `cd examples/news-guide && npm install && npm run start` | http://localhost:5172 |
23+
| Metro Map | `npm run metro-map` | `cd examples/metro-map && npm install && npm run start` | http://localhost:5173 |
2224

2325
## Feature index
2426

@@ -29,6 +31,8 @@ You can run the following examples:
2931
- **News Guide**:
3032
- The agent leans on a suite of retrieval tools—`list_available_tags_and_keywords`, `get_article_by_id`, `search_articles_by_tags/keywords/exact_text`, and `get_current_page`—before responding, and uses `show_article_list_widget` to present results ([news_agent.py](examples/news-guide/backend/app/agents/news_agent.py)).
3133
- Hidden context such as the featured landing page is normalized into agent input so summaries and recommendations stay grounded ([news_agent.py](examples/news-guide/backend/app/agents/news_agent.py)).
34+
- **Metro Map**:
35+
- The metro agent syncs map data with `get_map` and surfaces line and station details via `list_lines`, `list_stations`, `get_line_route`, and `get_station` before giving directions ([metro_map_agent.py](examples/metro-map/backend/app/agents/metro_map_agent.py)).
3236

3337
### Client tool calls that mutate UI state
3438

@@ -49,6 +53,8 @@ You can run the following examples:
4953
- **News Guide**:
5054
- Retrieval tools stream `ProgressUpdateEvent` messages while searching tags, authors, keywords, exact text, or loading the current page so the UI surfaces “Searching…”/“Loading…” states ([news_agent.py](examples/news-guide/backend/app/agents/news_agent.py)).
5155
- The event finder emits progress as it scans dates, days of week, or keywords to keep users informed during longer lookups ([event_finder_agent.py](examples/news-guide/backend/app/agents/event_finder_agent.py)).
56+
- **Metro Map**:
57+
- The metro agent emits a quick sync update when it loads the line data via `get_map` ([metro_map_agent.py](examples/metro-map/backend/app/agents/metro_map_agent.py)).
5258

5359
### Widgets without actions
5460

@@ -72,6 +78,11 @@ You can run the following examples:
7278
- **News Guide**:
7379
- The `view_event_details` action is processed server-side to update the timeline widget with expanded descriptions without a round trip to the model ([server.py](examples/news-guide/backend/app/server.py)).
7480

81+
### Canvas layout
82+
83+
- **Metro Map**:
84+
- The React Flow canvas draws only metro nodes and colored edges—no custom canvas overlay—highlighting stations and line interchanges ([MetroMapCanvas.tsx](examples/metro-map/frontend/src/components/MetroMapCanvas.tsx)).
85+
7586
### Entity tags (@-mentions)
7687

7788
- **News Guide**:

examples/cat-lounge/backend/app/server.py

Lines changed: 3 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,11 @@
1818
Attachment,
1919
HiddenContextItem,
2020
ThreadItemDoneEvent,
21-
ThreadItemUpdated,
21+
ThreadItemReplacedEvent,
2222
ThreadMetadata,
2323
ThreadStreamEvent,
2424
UserMessageItem,
2525
WidgetItem,
26-
WidgetRootUpdated,
2726
)
2827
from openai.types.responses import ResponseInputContentParam
2928
from pydantic import ValidationError
@@ -148,15 +147,8 @@ async def _handle_select_name_action(
148147
selection = current_state.name if is_already_named else name
149148
widget = build_name_suggestions_widget(options, selected=selection)
150149

151-
# Save the updated widget so that if the user views the thread again, they will
152-
# see the updated version of the widget.
153-
updated_widget_item = sender.model_copy(update={"widget": widget})
154-
await self.store.save_item(thread.id, updated_widget_item, context=context)
155-
156-
# Stream back the update so that chatkit can render the updated widget,
157-
yield ThreadItemUpdated(
158-
item_id=sender.id,
159-
update=WidgetRootUpdated(widget=widget),
150+
yield ThreadItemReplacedEvent(
151+
item=sender.model_copy(update={"widget": widget}),
160152
)
161153

162154
if is_already_named:
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
__pycache__/
2+
*.py[cod]
3+
*.egg-info/
4+
.venv/
5+
.env
6+
.ruff_cache/
7+
.pytest_cache/
8+
.coverage/
9+
*.log

examples/metro-map/backend/app/__init__.py

Whitespace-only changes.
Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
from __future__ import annotations
2+
3+
from datetime import datetime
4+
from typing import Annotated
5+
6+
from agents import Agent, RunContextWrapper, StopAtTools, function_tool
7+
from chatkit.agents import AgentContext, ClientToolCall
8+
from chatkit.types import (
9+
AssistantMessageContent,
10+
AssistantMessageItem,
11+
ProgressUpdateEvent,
12+
ThreadItemDoneEvent,
13+
)
14+
from pydantic import BaseModel, ConfigDict, Field
15+
16+
from ..data.metro_map_store import Line, MetroMap, MetroMapStore, Station
17+
from ..memory_store import MemoryStore
18+
from ..request_context import RequestContext
19+
from ..widgets.line_select_widget import build_line_select_widget
20+
21+
INSTRUCTIONS = """
22+
You are a concise metro planner helping city planners update the Orbital Transit map.
23+
Give short answers, list 2–3 options, and highlight the lines or interchanges involved.
24+
25+
Before recommending a route, sync the latest map with the provided tools. Cite line
26+
colors when helpful (e.g., "take Red then Blue at Central Exchange").
27+
28+
When the user asks what to do next, reply with 2 concise follow-up ideas and pick one to lead with.
29+
Default to actionable options like adding another station on the same line or explaining how to travel
30+
from the newly added station to a nearby destination.
31+
32+
When the user mentions a station, always call the `get_map` tool to sync the latest map before responding.
33+
34+
When a user wants to add a station (e.g. "I would like to add a new metro station." or "Add another station"):
35+
- If the user did not specify a line, you MUST call `show_line_selector` with a message prompting them to choose one
36+
from the list of lines. You must NEVER ask the user to choose a line without calling `show_line_selector` first.
37+
This applies even if you just added a station—treat each new "add a station" turn as needing a fresh line selection
38+
unless the user explicitly included the line in that same turn or in the latest message via <LINE_SELECTED>.
39+
- If the user replies with a number to pick one of your follow-up options AND that option involves adding a station,
40+
treat this as a fresh station-add request and immediately call `show_line_selector` before asking anything else.
41+
- If the user did not specify a station name, ask them to enter a name.
42+
- If the user did not specify whether to add the station to the end of the line or the beginning, ask them to choose one.
43+
- When you have all the information you need, call the `add_station` tool with the station name, line id, and append flag.
44+
45+
Describing:
46+
- After a new station has been added, describe it to the user in a whimsical and poetic sentence.
47+
- When describing a station to the user, omit the station id and coordinates.
48+
- When describing a line to the user, omit the line id and color.
49+
50+
When a user wants to plan a route:
51+
- If the user did not specify a starting or detination station, ask them to choose them from the list of stations.
52+
- Provide a one-sentence route, the estimated travel time, and points of interest along the way.
53+
- Avoid over-explaining and stay within the given station list.
54+
55+
Custom tags:
56+
- <LINE_SELECTED>{line_id}</LINE_SELECTED> - when the user has selected a line, you can use this tag to reference the line id.
57+
When this is the latest message, acknowledge the selection.
58+
- <STATION_TAG>...</STATION_TAG> - contains full station details (id, name, description, coordinates, and served lines with ids/colors/orientations).
59+
Use the data inside the tag directly; do not call `get_station` just to resolve a tagged station.
60+
"""
61+
62+
63+
class MetroAgentContext(AgentContext):
64+
model_config = ConfigDict(arbitrary_types_allowed=True)
65+
store: Annotated[MemoryStore, Field(exclude=True)]
66+
metro: Annotated[MetroMapStore, Field(exclude=True)]
67+
request_context: Annotated[RequestContext, Field(exclude=True)]
68+
69+
70+
class MapResult(BaseModel):
71+
map: MetroMap
72+
73+
74+
class LineListResult(BaseModel):
75+
lines: list[Line]
76+
77+
78+
class StationListResult(BaseModel):
79+
stations: list[Station]
80+
81+
82+
class LineDetailResult(BaseModel):
83+
line: Line
84+
stations: list[Station]
85+
86+
87+
class StationDetailResult(BaseModel):
88+
station: Station
89+
lines: list[Line]
90+
91+
92+
@function_tool(description_override="Show a clickable widget listing metro lines.")
93+
async def show_line_selector(ctx: RunContextWrapper[MetroAgentContext], message: str):
94+
widget = build_line_select_widget(ctx.context.metro.list_lines())
95+
await ctx.context.stream(
96+
ThreadItemDoneEvent(
97+
item=AssistantMessageItem(
98+
thread_id=ctx.context.thread.id,
99+
id=ctx.context.generate_id("message"),
100+
created_at=datetime.now(),
101+
content=[AssistantMessageContent(text=message)],
102+
),
103+
)
104+
)
105+
await ctx.context.stream_widget(widget)
106+
107+
108+
@function_tool(description_override="Load the latest metro map with lines and stations.")
109+
async def get_map(ctx: RunContextWrapper[MetroAgentContext]) -> MapResult:
110+
print("[TOOL CALL] get_map")
111+
metro_map = ctx.context.metro.get_map()
112+
await ctx.context.stream(ProgressUpdateEvent(text="Retrieving the latest metro map..."))
113+
return MapResult(map=metro_map)
114+
115+
116+
@function_tool(description_override="List all metro lines with their colors and endpoints.")
117+
async def list_lines(ctx: RunContextWrapper[MetroAgentContext]) -> LineListResult:
118+
print("[TOOL CALL] list_lines")
119+
return LineListResult(lines=ctx.context.metro.list_lines())
120+
121+
122+
@function_tool(description_override="List all stations and which lines serve them.")
123+
async def list_stations(ctx: RunContextWrapper[MetroAgentContext]) -> StationListResult:
124+
print("[TOOL CALL] list_stations")
125+
return StationListResult(stations=ctx.context.metro.list_stations())
126+
127+
128+
@function_tool(description_override="Get the ordered stations for a specific line.")
129+
async def get_line_route(
130+
ctx: RunContextWrapper[MetroAgentContext],
131+
line_id: str,
132+
) -> LineDetailResult:
133+
print("[TOOL CALL] get_line_route", line_id)
134+
line = ctx.context.metro.find_line(line_id)
135+
if not line:
136+
raise ValueError(f"Line '{line_id}' was not found.")
137+
stations = ctx.context.metro.stations_for_line(line_id)
138+
return LineDetailResult(line=line, stations=stations)
139+
140+
141+
@function_tool(description_override="Look up a single station and the lines serving it.")
142+
async def get_station(
143+
ctx: RunContextWrapper[MetroAgentContext],
144+
station_id: str,
145+
) -> StationDetailResult:
146+
print("[TOOL CALL] get_station", station_id)
147+
station = ctx.context.metro.find_station(station_id)
148+
if not station:
149+
raise ValueError(f"Station '{station_id}' was not found.")
150+
lines = [ctx.context.metro.find_line(line_id) for line_id in station.lines]
151+
return StationDetailResult(
152+
station=station,
153+
lines=[line for line in lines if line],
154+
)
155+
156+
157+
@function_tool(
158+
description_override=(
159+
"""Add a new station to the metro map.
160+
- `station_name`: The name of the station to add.
161+
- `line_id`: The id of the line to add the station to. Should be one of the ids returned by list_lines.
162+
- `append`: Whether to add the station to the end of the line or the beginning. Defaults to True.
163+
"""
164+
)
165+
)
166+
async def add_station(
167+
ctx: RunContextWrapper[MetroAgentContext],
168+
station_name: str,
169+
line_id: str,
170+
append: bool = True,
171+
) -> MapResult:
172+
station_name = station_name.strip().title()
173+
print(f"[TOOL CALL] add_station: {station_name} to {line_id}")
174+
await ctx.context.stream(ProgressUpdateEvent(text="Adding station..."))
175+
try:
176+
updated_map, new_station = ctx.context.metro.add_station(station_name, line_id, append)
177+
ctx.context.client_tool_call = ClientToolCall(
178+
name="add_station",
179+
arguments={
180+
"stationId": new_station.id,
181+
"map": updated_map.model_dump(mode="json"),
182+
},
183+
)
184+
return MapResult(map=updated_map)
185+
except Exception as e:
186+
print(f"[ERROR] add_station: {e}")
187+
await ctx.context.stream(
188+
ThreadItemDoneEvent(
189+
item=AssistantMessageItem(
190+
thread_id=ctx.context.thread.id,
191+
id=ctx.context.generate_id("message"),
192+
created_at=datetime.now(),
193+
content=[
194+
AssistantMessageContent(
195+
text=f"There was an error adding **{station_name}**"
196+
)
197+
],
198+
),
199+
)
200+
)
201+
raise
202+
203+
204+
metro_map_agent = Agent[MetroAgentContext](
205+
name="metro_map",
206+
instructions=INSTRUCTIONS,
207+
model="gpt-4o-mini",
208+
tools=[
209+
# Retrieve map data
210+
get_map,
211+
list_lines,
212+
list_stations,
213+
get_line_route,
214+
get_station,
215+
# Respond with a widget
216+
show_line_selector,
217+
# Update the metro map
218+
add_station,
219+
],
220+
# Stop inference after client tool call or widget output
221+
tool_use_behavior=StopAtTools(
222+
stop_at_tool_names=[
223+
add_station.name,
224+
show_line_selector.name,
225+
]
226+
),
227+
)
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
from agents import Agent
2+
from chatkit.agents import AgentContext
3+
4+
title_agent = Agent[AgentContext](
5+
model="gpt-5-nano",
6+
name="Title generator",
7+
instructions="""
8+
Generate a short conversation title for a metro planning assistant chatting with a user.
9+
The first user message in the thread is included below to provide context. Use your own
10+
words, respond with 2-5 words, and avoid punctuation.
11+
""",
12+
)
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
{
2+
"id": "orbital-transit",
3+
"name": "Orbital Transit",
4+
"summary": "Three stellar corridors linking planets and systems with crossovers at Vega Reach and Orionis Gate for easy transfers.",
5+
"stations": [
6+
{ "id": "helios-prime", "name": "Helios Prime", "x": 0, "y": -1, "lines": ["blue"], "description": "A sun-warmed hub where travelers gather beneath golden light that never quite fades." },
7+
{ "id": "cinderia", "name": "Cinderia", "x": 1, "y": -1, "lines": ["blue"], "description": "A drifting ember-world whose platforms glow like the last sparks of a dying star." },
8+
{ "id": "vega-reach", "name": "Vega Reach", "x": 2, "y": 0, "lines": ["blue", "purple"], "description": "A crystalline crossing where starlight refracts into prismatic paths for wandering souls." },
9+
{ "id": "orionis-gate", "name": "Orionis Gate", "x": 3, "y": 0, "lines": ["blue", "purple", "orange"], "description": "A grand celestial threshold where every corridor feels like the beginning of an old legend." },
10+
{ "id": "titan-border", "name": "Titan Border", "x": 4, "y": 0, "lines": ["blue"], "description": "A frontier station perched on the hush between vast ice fields and silent cosmic tides." },
11+
{ "id": "cygnus-way", "name": "Cygnus Way", "x": 5, "y": 0, "lines": ["blue"], "description": "A gentle outpost where gull-wing nebulae drift lazily above the platform rails." },
12+
{ "id": "kepler-forge", "name": "Kepler Forge", "x": -1, "y": 1, "lines": ["purple"], "description": "An industrious orbit-workshop where constellations seem hammered into shape each dusk." },
13+
{ "id": "arcturus-dorange", "name": "Arcturus Dorange", "x": 0, "y": 1, "lines": ["purple"], "description": "A warm, fragrant stop known for amber skies that smell faintly of citrus and stardust." },
14+
{ "id": "sagan-halo", "name": "Sagan Halo", "x": 4, "y": 1, "lines": ["purple"], "description": "A luminous ring-station that hums softly with the quiet wonder of distant worlds." },
15+
{ "id": "lyra-verge", "name": "Lyra Verge", "x": 5, "y": 1, "lines": ["purple"], "description": "A melodic crossing where cosmic winds carry the echo of unseen harps." },
16+
{ "id": "lumen-cradle", "name": "Lumen Cradle", "x": 3, "y": -3, "lines": ["orange"], "description": "A glowing sanctuary nestled in deep space where light itself seems to rest and dream." },
17+
{ "id": "proxima-step", "name": "Proxima Step", "x": 3, "y": -2, "lines": ["orange"], "description": "A humble midpoint marked by wayfarers’ footprints glittering like distant promises." },
18+
{ "id": "zephyr-system", "name": "Zephyr System", "x": 3, "y": -1, "lines": ["orange"], "description": "A breezy orbital junction where gentle solar winds sigh through its floating arches." },
19+
{ "id": "altair-rim", "name": "Altair Rim", "x": 3, "y": 1, "lines": ["orange"], "description": "A bright ridge-station where shimmering pathways skim the edge of luminous voids." },
20+
{ "id": "farpoint-prime", "name": "Farpoint Prime", "x": 3, "y": 2, "lines": ["orange"], "description": "A serene apex of the line where travelers pause to watch galaxies bloom in silence." }
21+
],
22+
"lines": [
23+
{ "id": "blue", "name": "Blue Line", "color": "#06b6d4", "orientation": "horizontal", "stations": ["helios-prime", "cinderia", "vega-reach", "orionis-gate", "titan-border", "cygnus-way"] },
24+
{ "id": "purple", "name": "Purple Line", "color": "#a855f7", "orientation": "horizontal", "stations": ["kepler-forge", "arcturus-dorange", "vega-reach", "orionis-gate", "sagan-halo", "lyra-verge"] },
25+
{ "id": "orange", "name": "Orange Line", "color": "#f59e0b", "orientation": "vertical", "stations": ["lumen-cradle", "proxima-step", "zephyr-system", "orionis-gate", "altair-rim", "farpoint-prime"] }
26+
]
27+
}

0 commit comments

Comments
 (0)