Compare commits
2 Commits
e7622c7e6d
...
3fb3bca034
| Author | SHA1 | Date | |
|---|---|---|---|
| 3fb3bca034 | |||
| a0fdf2486e |
@@ -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):
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,10 +559,13 @@ interface SuggestionCard {
|
||||
location?: string;
|
||||
maps_link?: string;
|
||||
notes?: string;
|
||||
tags?: string[];
|
||||
dismissed?: boolean;
|
||||
added?: boolean;
|
||||
}
|
||||
|
||||
const CHAT_STORAGE_KEY = (tripId: string) => `drb-trip-chat-${tripId}`;
|
||||
|
||||
function AssistantPanel({
|
||||
trip,
|
||||
onAddEvent,
|
||||
@@ -504,15 +573,26 @@ function AssistantPanel({
|
||||
trip: TripRecord & { events: TripEvent[] };
|
||||
onAddEvent: (event: TripEvent) => void;
|
||||
}) {
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const storageKey = CHAT_STORAGE_KEY(trip.trip_id);
|
||||
const [messages, setMessages] = useState<ChatMessage[]>(() => {
|
||||
try {
|
||||
const saved = localStorage.getItem(storageKey);
|
||||
return saved ? JSON.parse(saved) : [];
|
||||
} catch { return []; }
|
||||
});
|
||||
const [input, setInput] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [messages]);
|
||||
|
||||
useEffect(() => {
|
||||
try { localStorage.setItem(storageKey, JSON.stringify(messages)); } catch { /* quota */ }
|
||||
}, [messages, storageKey]);
|
||||
|
||||
async function send() {
|
||||
const text = input.trim();
|
||||
if (!text || loading) return;
|
||||
@@ -540,6 +620,7 @@ function AssistantPanel({
|
||||
]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -553,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) =>
|
||||
@@ -658,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"
|
||||
@@ -703,6 +790,7 @@ function AssistantPanel({
|
||||
<div className="px-3 py-3 border-t border-gray-800 shrink-0">
|
||||
<div className="flex gap-2">
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
value={input}
|
||||
onChange={(e) => { setInput(e.target.value); e.target.style.height = "auto"; e.target.style.height = `${Math.min(e.target.scrollHeight, 120)}px`; }}
|
||||
onKeyDown={(e) => e.key === "Enter" && !e.shiftKey && (e.preventDefault(), send())}
|
||||
@@ -740,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 {
|
||||
@@ -821,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>;
|
||||
@@ -884,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 */}
|
||||
@@ -931,6 +1065,7 @@ export default function TripDetailPage() {
|
||||
isAdmin={isAdmin}
|
||||
onDelete={handleDeleteEvent}
|
||||
driveSegments={driveTimes[selectedDay] ?? []}
|
||||
availableTags={trip.available_tags ?? []}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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)}"
|
||||
|
||||
Reference in New Issue
Block a user