feat(rtrips): AI trip planner — canvas tools + "Plan with AI" button
Register 5 trip-specific tools (destination, itinerary, booking, budget, packing list) in canvas-tools.ts so Gemini can create trip shapes. Add public toolsEnabled/systemPrompt properties to folk-prompt with persistence. Add #trip-planner hash handler to canvas.html that auto- creates a pre-configured AI prompt. Add "Plan with AI" gradient button to rTrips list and detail views. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
cdfba02b03
commit
cb92a7f6d8
|
|
@ -141,6 +141,144 @@ const registry: CanvasToolDefinition[] = [
|
|||
}),
|
||||
actionLabel: (args) => `Generating image: ${args.prompt.slice(0, 50)}${args.prompt.length > 50 ? "..." : ""}`,
|
||||
},
|
||||
// ── Trip Planning Tools ──
|
||||
{
|
||||
declaration: {
|
||||
name: "create_destination",
|
||||
description: "Create a destination card for a trip location. Use when the user mentions a city, place, or stop on their trip.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
destName: { type: "string", description: "Name of the destination (city or place)" },
|
||||
country: { type: "string", description: "Country name" },
|
||||
lat: { type: "number", description: "Latitude coordinate" },
|
||||
lng: { type: "number", description: "Longitude coordinate" },
|
||||
arrivalDate: { type: "string", description: "Arrival date in YYYY-MM-DD format" },
|
||||
departureDate: { type: "string", description: "Departure date in YYYY-MM-DD format" },
|
||||
notes: { type: "string", description: "Additional notes about this destination" },
|
||||
},
|
||||
required: ["destName"],
|
||||
},
|
||||
},
|
||||
tagName: "folk-destination",
|
||||
buildProps: (args) => ({
|
||||
destName: args.destName,
|
||||
...(args.country ? { country: args.country } : {}),
|
||||
...(args.lat != null ? { lat: args.lat } : {}),
|
||||
...(args.lng != null ? { lng: args.lng } : {}),
|
||||
...(args.arrivalDate ? { arrivalDate: args.arrivalDate } : {}),
|
||||
...(args.departureDate ? { departureDate: args.departureDate } : {}),
|
||||
...(args.notes ? { notes: args.notes } : {}),
|
||||
}),
|
||||
actionLabel: (args) => `Created destination: ${args.destName}${args.country ? `, ${args.country}` : ""}`,
|
||||
},
|
||||
{
|
||||
declaration: {
|
||||
name: "create_itinerary",
|
||||
description: "Create an itinerary card with a list of activities/events organized by date. Use when planning a schedule or day-by-day plan.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
tripTitle: { type: "string", description: "Title for the itinerary" },
|
||||
itemsJson: { type: "string", description: 'JSON array of items. Each: {"id":"<uuid>","title":"...","date":"YYYY-MM-DD","startTime":"HH:MM","category":"ACTIVITY|TRANSPORT|MEAL|FREE_TIME|FLIGHT"}' },
|
||||
},
|
||||
required: ["tripTitle", "itemsJson"],
|
||||
},
|
||||
},
|
||||
tagName: "folk-itinerary",
|
||||
buildProps: (args) => {
|
||||
let items: any[] = [];
|
||||
try { items = JSON.parse(args.itemsJson); } catch { items = []; }
|
||||
return { tripTitle: args.tripTitle, items };
|
||||
},
|
||||
actionLabel: (args) => `Created itinerary: ${args.tripTitle}`,
|
||||
},
|
||||
{
|
||||
declaration: {
|
||||
name: "create_booking",
|
||||
description: "Create a booking card for a flight, hotel, transport, activity, or restaurant reservation.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
bookingType: {
|
||||
type: "string",
|
||||
description: "Type of booking",
|
||||
enum: ["FLIGHT", "HOTEL", "CAR_RENTAL", "TRAIN", "BUS", "FERRY", "ACTIVITY", "RESTAURANT", "OTHER"],
|
||||
},
|
||||
provider: { type: "string", description: "Provider/company name (e.g. airline, hotel name)" },
|
||||
cost: { type: "number", description: "Cost amount" },
|
||||
currency: { type: "string", description: "ISO currency code (e.g. USD, EUR)" },
|
||||
startDate: { type: "string", description: "Start/check-in date in YYYY-MM-DD format" },
|
||||
endDate: { type: "string", description: "End/check-out date in YYYY-MM-DD format" },
|
||||
bookingStatus: { type: "string", description: "Booking status", enum: ["PENDING", "CONFIRMED", "CANCELLED"] },
|
||||
details: { type: "string", description: "Additional booking details or notes" },
|
||||
},
|
||||
required: ["bookingType", "provider"],
|
||||
},
|
||||
},
|
||||
tagName: "folk-booking",
|
||||
buildProps: (args) => ({
|
||||
bookingType: args.bookingType,
|
||||
provider: args.provider,
|
||||
...(args.cost != null ? { cost: args.cost } : {}),
|
||||
...(args.currency ? { currency: args.currency } : {}),
|
||||
...(args.startDate ? { startDate: args.startDate } : {}),
|
||||
...(args.endDate ? { endDate: args.endDate } : {}),
|
||||
...(args.bookingStatus ? { bookingStatus: args.bookingStatus } : {}),
|
||||
...(args.details ? { details: args.details } : {}),
|
||||
}),
|
||||
actionLabel: (args) => `Created booking: ${args.bookingType} — ${args.provider}`,
|
||||
},
|
||||
{
|
||||
declaration: {
|
||||
name: "create_budget",
|
||||
description: "Create a budget tracker card with total budget and expense line items. Use when the user wants to track trip costs.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
budgetTotal: { type: "number", description: "Total budget amount" },
|
||||
currency: { type: "string", description: "ISO currency code (e.g. USD, EUR)" },
|
||||
expensesJson: { type: "string", description: 'JSON array of expenses. Each: {"id":"<uuid>","category":"TRANSPORT|ACCOMMODATION|FOOD|ACTIVITY|SHOPPING|OTHER","description":"...","amount":123,"date":"YYYY-MM-DD"}' },
|
||||
},
|
||||
required: ["budgetTotal"],
|
||||
},
|
||||
},
|
||||
tagName: "folk-budget",
|
||||
buildProps: (args) => {
|
||||
let expenses: any[] = [];
|
||||
try { expenses = JSON.parse(args.expensesJson); } catch { expenses = []; }
|
||||
return {
|
||||
budgetTotal: args.budgetTotal,
|
||||
...(args.currency ? { currency: args.currency } : {}),
|
||||
expenses,
|
||||
};
|
||||
},
|
||||
actionLabel: (args) => `Created budget: ${args.currency || "USD"} ${args.budgetTotal}`,
|
||||
},
|
||||
{
|
||||
declaration: {
|
||||
name: "create_packing_list",
|
||||
description: "Create a packing list card with checkable items organized by category. Use when the user needs help with what to pack.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
itemsJson: { type: "string", description: 'JSON array of packing items. Each: {"id":"<uuid>","name":"...","category":"CLOTHING|FOOTWEAR|ELECTRONICS|GEAR|PERSONAL|DOCUMENTS|SAFETY|SUPPLIES","quantity":1,"packed":false}' },
|
||||
},
|
||||
required: ["itemsJson"],
|
||||
},
|
||||
},
|
||||
tagName: "folk-packing-list",
|
||||
buildProps: (args) => {
|
||||
let items: any[] = [];
|
||||
try { items = JSON.parse(args.itemsJson); } catch { items = []; }
|
||||
return { items };
|
||||
},
|
||||
actionLabel: (args) => {
|
||||
let count = 0;
|
||||
try { count = JSON.parse(args.itemsJson).length; } catch {}
|
||||
return `Created packing list (${count} items)`;
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export const CANVAS_TOOLS: CanvasToolDefinition[] = [...registry];
|
||||
|
|
|
|||
|
|
@ -386,6 +386,7 @@ export class FolkPrompt extends FolkShape {
|
|||
#model = "gemini-flash";
|
||||
#pendingImages: string[] = [];
|
||||
#toolsEnabled = false;
|
||||
#systemPrompt = "";
|
||||
|
||||
#messagesEl: HTMLElement | null = null;
|
||||
#promptInput: HTMLTextAreaElement | null = null;
|
||||
|
|
@ -399,6 +400,20 @@ export class FolkPrompt extends FolkShape {
|
|||
return this.#messages;
|
||||
}
|
||||
|
||||
get toolsEnabled() { return this.#toolsEnabled; }
|
||||
set toolsEnabled(v: boolean) {
|
||||
this.#toolsEnabled = v;
|
||||
this.#toolsBtn?.classList.toggle("active", v);
|
||||
if (this.#promptInput) {
|
||||
this.#promptInput.placeholder = v
|
||||
? "Ask me to create maps, notes, images, or embeds on the canvas..."
|
||||
: "Type your message...";
|
||||
}
|
||||
}
|
||||
|
||||
get systemPrompt() { return this.#systemPrompt; }
|
||||
set systemPrompt(v: string) { this.#systemPrompt = v; }
|
||||
|
||||
override createRenderRoot() {
|
||||
const root = super.createRenderRoot();
|
||||
|
||||
|
|
@ -471,15 +486,15 @@ export class FolkPrompt extends FolkShape {
|
|||
// Tools toggle
|
||||
this.#toolsBtn?.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
this.#toolsEnabled = !this.#toolsEnabled;
|
||||
this.#toolsBtn!.classList.toggle("active", this.#toolsEnabled);
|
||||
if (this.#promptInput) {
|
||||
this.#promptInput.placeholder = this.#toolsEnabled
|
||||
? "Ask me to create maps, notes, images, or embeds on the canvas..."
|
||||
: "Type your message...";
|
||||
}
|
||||
this.toolsEnabled = !this.#toolsEnabled;
|
||||
});
|
||||
|
||||
// Sync initial state after DOM ready
|
||||
this.#toolsBtn?.classList.toggle("active", this.#toolsEnabled);
|
||||
if (this.#toolsEnabled && this.#promptInput) {
|
||||
this.#promptInput.placeholder = "Ask me to create maps, notes, images, or embeds on the canvas...";
|
||||
}
|
||||
|
||||
// Attach button
|
||||
attachBtn?.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
|
|
@ -635,7 +650,7 @@ export class FolkPrompt extends FolkShape {
|
|||
...(m.images?.length ? { images: m.images } : {}),
|
||||
})),
|
||||
model: this.#model,
|
||||
...(useTools ? { useTools: true } : {}),
|
||||
...(useTools ? { useTools: true, systemPrompt: this.#systemPrompt || undefined } : {}),
|
||||
}),
|
||||
});
|
||||
|
||||
|
|
@ -780,6 +795,8 @@ export class FolkPrompt extends FolkShape {
|
|||
|
||||
static override fromData(data: Record<string, any>): FolkPrompt {
|
||||
const shape = FolkShape.fromData(data) as FolkPrompt;
|
||||
if (data.toolsEnabled) shape.toolsEnabled = true;
|
||||
if (data.systemPrompt) shape.systemPrompt = data.systemPrompt;
|
||||
return shape;
|
||||
}
|
||||
|
||||
|
|
@ -788,6 +805,8 @@ export class FolkPrompt extends FolkShape {
|
|||
...super.toJSON(),
|
||||
type: "folk-prompt",
|
||||
model: this.#model,
|
||||
toolsEnabled: this.#toolsEnabled || undefined,
|
||||
systemPrompt: this.#systemPrompt || undefined,
|
||||
messages: this.messages.filter((m) => m.role !== "tool-action").map((msg) => ({
|
||||
role: msg.role,
|
||||
content: msg.content,
|
||||
|
|
@ -799,5 +818,7 @@ export class FolkPrompt extends FolkShape {
|
|||
|
||||
override applyData(data: Record<string, any>): void {
|
||||
super.applyData(data);
|
||||
if (data.toolsEnabled !== undefined) this.toolsEnabled = !!data.toolsEnabled;
|
||||
if (data.systemPrompt !== undefined) this.systemPrompt = data.systemPrompt;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -421,6 +421,8 @@ class FolkTripsPlanner extends HTMLElement {
|
|||
.rapp-nav__title { font-size: 15px; font-weight: 600; flex: 1; color: var(--rs-text-primary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.rapp-nav__btn { padding: 6px 14px; border-radius: 6px; border: none; background: #14b8a6; color: #fff; font-weight: 600; cursor: pointer; font-size: 13px; }
|
||||
.rapp-nav__btn:hover { background: #0d9488; }
|
||||
.rapp-nav__btn--ai { background: linear-gradient(135deg, #0ea5e9, #6366f1); }
|
||||
.rapp-nav__btn--ai:hover { opacity: 0.9; background: linear-gradient(135deg, #0ea5e9, #6366f1); }
|
||||
|
||||
.trip-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 12px; }
|
||||
.trip-card {
|
||||
|
|
@ -504,6 +506,7 @@ class FolkTripsPlanner extends HTMLElement {
|
|||
<div class="rapp-nav">
|
||||
<span class="rapp-nav__title">My Trips</span>
|
||||
<button class="rapp-nav__btn" id="create-trip">+ Plan a Trip</button>
|
||||
<button class="rapp-nav__btn rapp-nav__btn--ai" id="btn-plan-ai">✨ Plan with AI</button>
|
||||
<button class="rapp-nav__btn" id="btn-tour" style="font-size:0.78rem;padding:4px 10px;opacity:0.7">Tour</button>
|
||||
</div>
|
||||
${this.trips.length > 0 ? `<div class="trip-grid">
|
||||
|
|
@ -546,6 +549,7 @@ class FolkTripsPlanner extends HTMLElement {
|
|||
<div class="rapp-nav">
|
||||
${this._history.canGoBack ? `<button class="rapp-nav__back" data-back="list">\u2190 Trips</button>` : ""}
|
||||
<span class="rapp-nav__title">${this.esc(t.title)}</span>
|
||||
<button class="rapp-nav__btn rapp-nav__btn--ai" id="btn-plan-ai">✨ Plan with AI</button>
|
||||
<span class="trip-status" style="background:${st.bg};color:${st.color}">${st.label}</span>
|
||||
</div>
|
||||
<div class="tabs">
|
||||
|
|
@ -678,6 +682,9 @@ class FolkTripsPlanner extends HTMLElement {
|
|||
private attachListeners() {
|
||||
this.shadow.querySelector("#btn-tour")?.addEventListener("click", () => this.startTour());
|
||||
this.shadow.getElementById("create-trip")?.addEventListener("click", () => this.createTrip());
|
||||
this.shadow.getElementById("btn-plan-ai")?.addEventListener("click", () => {
|
||||
window.location.href = `/${this.space}/rspace#trip-planner`;
|
||||
});
|
||||
|
||||
this.shadow.querySelectorAll("[data-trip]").forEach(el => {
|
||||
el.addEventListener("click", () => {
|
||||
|
|
|
|||
|
|
@ -3642,6 +3642,33 @@
|
|||
if (hash.startsWith("#pin-")) {
|
||||
const pinId = hash.slice(5);
|
||||
setTimeout(() => pinManager.openPinById(pinId), 500);
|
||||
} else if (hash === "#trip-planner") {
|
||||
history.replaceState(null, "", location.pathname + location.search);
|
||||
setTimeout(() => {
|
||||
const TRIP_SYSTEM_PROMPT = `You are a travel planning AI in rSpace. Help plan trips by creating visual shapes on the canvas.
|
||||
|
||||
When the user describes a trip, proactively create:
|
||||
- Destination cards for each city/place (with coordinates and dates)
|
||||
- A map showing the destinations
|
||||
- Flight/accommodation search embeds (Google Flights, Airbnb, Booking.com)
|
||||
- An itinerary with activities by date
|
||||
- A budget tracker with estimated costs
|
||||
- A packing list tailored to the destination
|
||||
|
||||
Use real coordinates, YYYY-MM-DD dates, ISO currency codes. Ask clarifying questions if needed before creating shapes.`;
|
||||
|
||||
const prompt = newShape("folk-prompt", {
|
||||
toolsEnabled: true,
|
||||
systemPrompt: TRIP_SYSTEM_PROMPT,
|
||||
});
|
||||
if (prompt) {
|
||||
setTimeout(() => {
|
||||
const root = prompt.shadowRoot || prompt;
|
||||
const ta = root.querySelector(".prompt-input");
|
||||
if (ta) ta.focus();
|
||||
}, 200);
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue