Trip-level tags: admins configure available tags in the trip header (inline add/remove pills). The AI can also create new tags via the add_tag tool.
Event tags: selectable in the Add Event modal, shown as colored pills on event cards in the timeline, and on AI suggestion cards.
AI integration: sees available tags in its system prompt, applies them when proposing events, can create new ones with add_tag.
Discord: tags shown as inline code blocks under each event in /trip view.
Colors: auto-assigned from an 8-color palette by tag index, consistent everywhere.
This commit is contained in:
Logan
2026-06-21 15:00:37 -04:00
parent a0fdf2486e
commit 3fb3bca034
6 changed files with 184 additions and 3 deletions
+2
View File
@@ -146,6 +146,7 @@ class TripCreate(BaseModel):
maps_link: Optional[str] = None
start_date: str # YYYY-MM-DD
end_date: str # YYYY-MM-DD
available_tags: List[str] = [] # tag labels configured for this trip
class TripEventCreate(BaseModel):
@@ -157,6 +158,7 @@ class TripEventCreate(BaseModel):
maps_link: Optional[str] = None
place_id: Optional[str] = None # Google Place ID
notes: Optional[str] = None
tags: List[str] = [] # tag labels applied to this event
class AttendeeAction(BaseModel):
+54 -3
View File
@@ -47,6 +47,26 @@ _TOOLS = [
},
},
},
{
"type": "function",
"function": {
"name": "add_tag",
"description": (
"Add a new tag to the trip's available tag list so it can be used on events. "
"Use this when you want to apply a tag that doesn't exist yet."
),
"parameters": {
"type": "object",
"properties": {
"tag": {
"type": "string",
"description": "Short tag label, e.g. 'must-do', 'nightlife', 'food'",
},
},
"required": ["tag"],
},
},
},
{
"type": "function",
"function": {
@@ -66,6 +86,7 @@ _TOOLS = [
"location": {"type": "string", "description": "Full address or place name"},
"maps_link": {"type": "string", "description": "Google Maps URL"},
"notes": {"type": "string", "description": "Brief tips or reasoning"},
"tags": {"type": "array", "items": {"type": "string"}, "description": "Tags to apply — must be from the trip's available tags list"},
},
"required": ["title"],
},
@@ -133,13 +154,15 @@ def _build_system_prompt(trip: dict, events: list[dict]) -> str:
itinerary = "".join(lines) if lines else "\n (no events yet)"
attendees = ", ".join(trip.get("attendees", {}).values()) or "not specified"
available_tags = trip.get("available_tags") or []
tags_section = f"\nAvailable tags: {', '.join(available_tags)}" if available_tags else ""
return f"""You are a trip planning assistant for the following trip.
Trip: {trip["name"]}
Destination: {trip["location"]}
Dates: {trip["start_date"]} to {trip["end_date"]}
Attendees: {attendees}
Attendees: {attendees}{tags_section}
Current itinerary:{itinerary}
@@ -148,6 +171,7 @@ Guidelines:
- Format all responses using Markdown: use **bold** for place names and key details, bullet lists for options, and [links](url) for Maps links.
- When the user mentions places, activities, or asks for suggestions, search for them with search_places before proposing.
- Use propose_event for each concrete suggestion — one call per event. The user will approve or skip each one.
- When proposing events, apply relevant tags from the available tags list if any are defined.
- Be mindful of the existing schedule when assigning times. Avoid obvious conflicts.
- All proposed dates must fall between {trip["start_date"]} and {trip["end_date"]}.
- If the user says something like "everyone should be there by 6", factor that into your time proposals.
@@ -183,6 +207,7 @@ async def create_trip(body: TripCreate):
"start_date": body.start_date,
"end_date": body.end_date,
"attendees": {}, # {discord_user_id: discord_username}
"available_tags": body.available_tags,
"created_at": now,
}
await fstore.doc_set("trips", trip_id, doc, merge=False)
@@ -199,6 +224,17 @@ async def get_trip(trip_id: str):
return {**trip, "events": events}
@router.put("/{trip_id}/tags")
async def update_trip_tags(trip_id: str, body: dict):
"""Replace the trip's available tag list."""
trip = await fstore.doc_get("trips", trip_id)
if not trip:
raise HTTPException(404, f"Trip '{trip_id}' not found.")
tags = [str(t) for t in body.get("available_tags", []) if t]
await fstore.doc_update("trips", trip_id, {"available_tags": tags})
return {"available_tags": tags}
@router.delete("/{trip_id}")
async def delete_trip(trip_id: str, _: dict = Depends(require_service_key_or_admin)):
trip = await fstore.doc_get("trips", trip_id)
@@ -275,6 +311,7 @@ async def create_event(trip_id: str, body: TripEventCreate):
"maps_link": body.maps_link,
"place_id": body.place_id,
"notes": body.notes,
"tags": body.tags,
"attendees": {},
"created_at": now,
}
@@ -403,7 +440,19 @@ async def trip_chat(
for tc in msg.tool_calls:
args = json.loads(tc.function.arguments)
if tc.function.name == "search_places":
if tc.function.name == "add_tag":
new_tag = str(args.get("tag", "")).strip()[:50]
if new_tag and new_tag not in trip.get("available_tags", []):
updated_tags = list(trip.get("available_tags") or []) + [new_tag]
trip["available_tags"] = updated_tags
await fstore.doc_update("trips", trip_id, {"available_tags": updated_tags})
messages.append({
"role": "tool",
"tool_call_id": tc.id,
"content": json.dumps({"available_tags": trip.get("available_tags", [])}),
})
elif tc.function.name == "search_places":
# Limit query string lengths before hitting the Maps API
query = str(args.get("query", ""))[:200]
near = str(args.get("near", ""))[:200]
@@ -416,8 +465,10 @@ async def trip_chat(
elif tc.function.name == "propose_event":
suggestion = {k: args.get(k) for k in (
"title", "date", "start_time", "end_time", "location", "maps_link", "notes"
"title", "date", "start_time", "end_time", "location", "maps_link", "notes", "tags"
)}
if not isinstance(suggestion.get("tags"), list):
suggestion["tags"] = []
suggestions.append(suggestion)
messages.append({
"role": "tool",
+120
View File
@@ -58,6 +58,34 @@ function dateRange(start: string, end: string): string[] {
return dates;
}
// ---------------------------------------------------------------------------
// Tag helpers
// ---------------------------------------------------------------------------
const TAG_PALETTE = [
"bg-violet-900/60 text-violet-300 border-violet-700/50",
"bg-sky-900/60 text-sky-300 border-sky-700/50",
"bg-emerald-900/60 text-emerald-300 border-emerald-700/50",
"bg-amber-900/60 text-amber-300 border-amber-700/50",
"bg-rose-900/60 text-rose-300 border-rose-700/50",
"bg-fuchsia-900/60 text-fuchsia-300 border-fuchsia-700/50",
"bg-cyan-900/60 text-cyan-300 border-cyan-700/50",
"bg-orange-900/60 text-orange-300 border-orange-700/50",
];
function tagColor(tag: string, availableTags: string[]): string {
const idx = availableTags.indexOf(tag);
return TAG_PALETTE[(idx >= 0 ? idx : 0) % TAG_PALETTE.length];
}
function TagPill({ tag, availableTags }: { tag: string; availableTags: string[] }) {
return (
<span className={`inline-block text-[10px] font-medium rounded-full px-2 py-0.5 border ${tagColor(tag, availableTags)}`}>
{tag}
</span>
);
}
function detectConflicts(events: TripEvent[]): Set<string> {
const timed = events.filter((e) => e.start_time);
const conflicts = new Set<string>();
@@ -158,6 +186,7 @@ function AddEventModal({
onAdd: (body: object) => Promise<void>;
prefill?: Partial<TripEvent>;
}) {
const availableTags = trip.available_tags ?? [];
const [title, setTitle] = useState(prefill?.title ?? "");
const [date, setDate] = useState(prefill?.date ?? trip.start_date);
const [startTime, setStartTime] = useState(prefill?.start_time ?? "");
@@ -166,6 +195,7 @@ function AddEventModal({
const [mapsLink, setMapsLink] = useState(prefill?.maps_link ?? "");
const [placeId, setPlaceId] = useState(prefill?.place_id ?? "");
const [notes, setNotes] = useState(prefill?.notes ?? "");
const [tags, setTags] = useState<string[]>(prefill?.tags ?? []);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -190,6 +220,7 @@ function AddEventModal({
maps_link: mapsLink || null,
place_id: placeId || null,
notes: notes || null,
tags,
});
onClose();
} catch {
@@ -277,6 +308,29 @@ function AddEventModal({
/>
</div>
{availableTags.length > 0 && (
<div>
<label className="text-xs text-gray-400 block mb-1">Tags</label>
<div className="flex flex-wrap gap-1.5">
{availableTags.map((tag) => {
const active = tags.includes(tag);
return (
<button
key={tag}
type="button"
onClick={() => setTags((prev) => active ? prev.filter((t) => t !== tag) : [...prev, tag])}
className={`text-xs rounded-full px-2.5 py-1 border transition-colors ${
active ? tagColor(tag, availableTags) : "bg-gray-800 text-gray-500 border-gray-700 hover:border-gray-600 hover:text-gray-400"
}`}
>
{tag}
</button>
);
})}
</div>
</div>
)}
{error && <p className="text-red-400 text-xs">{error}</p>}
<div className="flex gap-3 justify-end pt-1">
@@ -306,11 +360,13 @@ function DayTimeline({
isAdmin,
onDelete,
driveSegments,
availableTags,
}: {
events: TripEvent[];
isAdmin: boolean;
onDelete: (id: string) => void;
driveSegments: { fromId: string; toId: string; text: string }[];
availableTags: string[];
}) {
const timed = [...events.filter((e) => e.start_time)].sort(
(a, b) => toMin(a.start_time!) - toMin(b.start_time!)
@@ -397,6 +453,11 @@ function DayTimeline({
{height >= 60 && e.notes && (
<p className="text-gray-600 text-xs mt-0.5 italic truncate">{e.notes}</p>
)}
{e.tags?.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1">
{e.tags.map((t) => <TagPill key={t} tag={t} availableTags={availableTags} />)}
</div>
)}
</div>
<div className="flex items-center gap-1 shrink-0">
{e.maps_link && (
@@ -451,6 +512,11 @@ function DayTimeline({
<p className="text-gray-500 text-xs mt-0.5">{e.location}</p>
)}
{e.notes && <p className="text-gray-600 text-xs italic mt-0.5">{e.notes}</p>}
{e.tags?.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1">
{e.tags.map((t) => <TagPill key={t} tag={t} availableTags={availableTags} />)}
</div>
)}
</div>
<div className="flex items-center gap-2 shrink-0">
{e.maps_link && (
@@ -493,6 +559,7 @@ interface SuggestionCard {
location?: string;
maps_link?: string;
notes?: string;
tags?: string[];
dismissed?: boolean;
added?: boolean;
}
@@ -567,6 +634,7 @@ function AssistantPanel({
location: s.location ?? null,
maps_link: s.maps_link ?? null,
notes: s.notes ?? null,
tags: s.tags ?? [],
});
onAddEvent(event);
setMessages((prev) =>
@@ -672,6 +740,11 @@ function AssistantPanel({
)}
{s.location && <p className="truncate">{s.location}</p>}
{s.notes && <p className="text-gray-500 italic">{s.notes}</p>}
{s.tags && s.tags.length > 0 && (
<div className="flex flex-wrap gap-1 pt-0.5">
{s.tags.map((t) => <TagPill key={t} tag={t} availableTags={trip.available_tags ?? []} />)}
</div>
)}
</div>
{s.maps_link && (
<a href={s.maps_link} target="_blank" rel="noopener noreferrer"
@@ -755,6 +828,7 @@ export default function TripDetailPage() {
const [selectedDay, setSelectedDay] = useState<string>("");
const [showAdd, setShowAdd] = useState(false);
const [driveTimes, setDriveTimes] = useState<Record<string, { fromId: string; toId: string; text: string }[]>>({});
const [tagInput, setTagInput] = useState("");
const load = useCallback(async () => {
try {
@@ -836,6 +910,25 @@ export default function TripDetailPage() {
);
return { ...prev, events };
});
// Refresh trip to pick up any new tags the AI may have created
c2api.getTrip(id).then((data) => setTrip((prev) => prev ? { ...prev, available_tags: (data as FullTrip).available_tags } : prev)).catch(() => {});
}
async function handleAddTag() {
if (!trip || !tagInput.trim()) return;
const tag = tagInput.trim();
if (trip.available_tags?.includes(tag)) { setTagInput(""); return; }
const updated = [...(trip.available_tags ?? []), tag];
await c2api.updateTripTags(trip.trip_id, updated);
setTrip((prev) => prev ? { ...prev, available_tags: updated } : prev);
setTagInput("");
}
async function handleRemoveTag(tag: string) {
if (!trip) return;
const updated = (trip.available_tags ?? []).filter((t) => t !== tag);
await c2api.updateTripTags(trip.trip_id, updated);
setTrip((prev) => prev ? { ...prev, available_tags: updated } : prev);
}
if (loading) return <p className="text-gray-500 text-sm font-mono">Loading</p>;
@@ -899,6 +992,32 @@ export default function TripDetailPage() {
)}
</div>
</div>
{/* Tag manager */}
{(isAdmin || (trip.available_tags ?? []).length > 0) && (
<div className="flex flex-wrap items-center gap-2">
{(trip.available_tags ?? []).map((tag) => (
<span key={tag} className={`inline-flex items-center gap-1 text-xs rounded-full px-2.5 py-0.5 border ${tagColor(tag, trip.available_tags ?? [])}`}>
{tag}
{isAdmin && (
<button onClick={() => handleRemoveTag(tag)} className="hover:text-white transition-colors leading-none">×</button>
)}
</span>
))}
{isAdmin && (
<div className="flex items-center gap-1">
<input
value={tagInput}
onChange={(e) => setTagInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && (e.preventDefault(), handleAddTag())}
placeholder="Add tag…"
className="bg-transparent border border-gray-700 rounded-full px-2.5 py-0.5 text-xs text-gray-400 placeholder-gray-600 focus:outline-none focus:border-gray-500 w-24"
/>
<button onClick={handleAddTag} className="text-xs text-gray-500 hover:text-gray-300 transition-colors">+</button>
</div>
)}
</div>
)}
</div>
{/* Two-column layout */}
@@ -946,6 +1065,7 @@ export default function TripDetailPage() {
isAdmin={isAdmin}
onDelete={handleDeleteEvent}
driveSegments={driveTimes[selectedDay] ?? []}
availableTags={trip.available_tags ?? []}
/>
</div>
</div>
+2
View File
@@ -142,6 +142,8 @@ export const c2api = {
request<import("@/lib/types").TripRecord>("/trips", { method: "POST", body: JSON.stringify(body) }),
deleteTrip: (id: string) =>
request(`/trips/${id}`, { method: "DELETE" }),
updateTripTags: (id: string, available_tags: string[]) =>
request<{ available_tags: string[] }>(`/trips/${id}/tags`, { method: "PUT", body: JSON.stringify({ available_tags }) }),
createTripEvent: (tripId: string, body: object) =>
request<import("@/lib/types").TripEvent>(`/trips/${tripId}/events`, { method: "POST", body: JSON.stringify(body) }),
deleteTripEvent: (tripId: string, eventId: string) =>
+2
View File
@@ -110,6 +110,7 @@ export interface TripEvent {
maps_link: string | null;
place_id: string | null;
notes: string | null;
tags: string[];
attendees: Record<string, string>;
created_at: string;
}
@@ -132,6 +133,7 @@ export interface TripRecord {
start_date: string;
end_date: string;
attendees: Record<string, string>;
available_tags: string[];
created_at: string;
events?: TripEvent[];
}
@@ -248,6 +248,10 @@ class TripCommands(commands.Cog):
if e.get("notes"):
line += f"\n\u3000\u3000_{e['notes']}_"
event_tags = e.get("tags") or []
if event_tags:
line += f"\n\u3000\u3000`{'` `'.join(event_tags)}`"
event_att = list(e.get("attendees", {}).values())
if event_att:
line += f"\n\u3000\u3000{', '.join(event_att)}"