feat(rtrips): add in-module AI planner with split-view chat + canvas export

"Plan with AI" now opens a split-view within rTrips instead of redirecting
to the canvas. Left panel: chat with model selector. Right panel: generated
trip cards (destinations, itineraries, budgets, packing lists) with
accept/discard flow. Demo mode provides realistic mock responses for Japan,
Europe, and beach queries. Accepted items export to canvas via sessionStorage
+ #trip-import hash handler.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-24 18:21:40 -07:00
parent efa5a5c315
commit 496dff3c7f
2 changed files with 434 additions and 5 deletions

View File

@ -15,14 +15,19 @@ import { startPresenceHeartbeat } from '../../../shared/collab-presence';
class FolkTripsPlanner extends HTMLElement {
private shadow: ShadowRoot;
private space = "";
private view: "list" | "detail" = "list";
private view: "list" | "detail" | "ai-planner" = "list";
private trips: any[] = [];
private trip: any = null;
private tab: "overview" | "destinations" | "itinerary" | "bookings" | "expenses" | "packing" = "overview";
private error = "";
private _offlineUnsubs: (() => void)[] = [];
private _stopPresence: (() => void) | null = null;
private _history = new ViewHistory<"list" | "detail">("list");
private _history = new ViewHistory<"list" | "detail" | "ai-planner">("list");
private _aiMessages: { role: string; content: string; toolCalls?: any[] }[] = [];
private _aiGeneratedItems: { type: string; props: Record<string, any>; accepted: boolean; id: string }[] = [];
private _aiLoading = false;
private _aiModel = 'gemini-flash';
private _aiTripContext: any = null;
private _tour!: TourEngine;
private static readonly TOUR_STEPS = [
{ target: '#create-trip', title: "Plan a Trip", message: "Start planning a new trip — add destinations, itinerary, and budget.", advanceOnClick: false },
@ -492,13 +497,50 @@ class FolkTripsPlanner extends HTMLElement {
.packing-check { width: 16px; height: 16px; cursor: pointer; }
.empty { text-align: center; color: var(--rs-text-muted); padding: 40px; }
/* AI Planner */
.ai-planner { display: flex; height: calc(100vh - 60px); gap: 0; }
.ai-chat { width: 40%; min-width: 280px; display: flex; flex-direction: column; border-right: 1px solid var(--rs-border, #e5e7eb); }
.ai-chat-messages { flex: 1; overflow-y: auto; padding: 12px; display: flex; flex-direction: column; }
.ai-msg { padding: 8px 10px; border-radius: 8px; margin-bottom: 6px; font-size: 13px; line-height: 1.4; max-width: 85%; white-space: pre-wrap; word-break: break-word; }
.ai-msg.user { background: #14b8a6; color: white; align-self: flex-end; margin-left: auto; }
.ai-msg.assistant { background: var(--rs-bg-surface-raised, #f1f5f9); align-self: flex-start; }
.ai-msg.tool-action { background: #dbeafe; color: #1e40af; font-size: 11px; text-align: center; max-width: 100%; align-self: center; padding: 4px 12px; border-radius: 12px; }
.ai-chat-input-row { display: flex; gap: 6px; padding: 12px; border-top: 1px solid var(--rs-border, #e5e7eb); align-items: flex-end; }
.ai-chat-input { flex: 1; padding: 8px 10px; border: 1px solid var(--rs-border, #e5e7eb); border-radius: 8px; font-size: 13px; resize: none; min-height: 36px; max-height: 100px; background: var(--rs-bg-surface, #fff); color: var(--rs-text-primary); font-family: inherit; }
.ai-chat-input:focus { outline: none; border-color: #14b8a6; }
.ai-model-select { padding: 6px 8px; border-radius: 6px; border: 1px solid var(--rs-border, #e5e7eb); font-size: 11px; background: var(--rs-bg-surface, #fff); color: var(--rs-text-primary); }
.ai-cards { flex: 1; overflow-y: auto; padding: 16px; }
.ai-cards-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
.ai-cards-header h3 { margin: 0; font-size: 14px; font-weight: 600; }
.ai-cards-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 12px; }
.ai-card { border-radius: 10px; border: 1px solid var(--rs-border-subtle, #e5e7eb); padding: 12px; background: var(--rs-bg-surface, #fff); transition: border-color 0.2s; }
.ai-card.accepted { border-color: #14b8a6; background: color-mix(in srgb, #14b8a6 5%, transparent); }
.ai-card-type { font-size: 10px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--rs-text-muted, #999); margin-bottom: 4px; }
.ai-card-title { font-weight: 600; font-size: 14px; margin-bottom: 6px; }
.ai-card-detail { font-size: 12px; color: var(--rs-text-secondary, #666); line-height: 1.4; }
.ai-card-actions { display: flex; gap: 6px; margin-top: 8px; }
.ai-card-btn { padding: 4px 12px; border-radius: 6px; border: none; font-size: 11px; cursor: pointer; font-weight: 500; }
.ai-card-btn.accept { background: #14b8a6; color: white; }
.ai-card-btn.accept:hover { background: #0d9488; }
.ai-card-btn.discard { background: var(--rs-bg-surface-raised, #f1f5f9); color: var(--rs-text-secondary, #666); }
.ai-card-btn.discard:hover { background: var(--rs-bg-surface-sunken, #e2e8f0); }
.ai-card-btn.accepted-badge { background: #14b8a6; color: white; cursor: default; opacity: 0.7; }
.ai-send-btn { padding: 6px 14px; border-radius: 6px; border: none; background: #14b8a6; color: white; font-size: 13px; font-weight: 600; cursor: pointer; white-space: nowrap; }
.ai-send-btn:hover { background: #0d9488; }
.ai-send-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.ai-clear-btn { padding: 6px 10px; border-radius: 6px; border: 1px solid var(--rs-border, #e5e7eb); background: transparent; color: var(--rs-text-muted); font-size: 11px; cursor: pointer; }
.ai-loading { display: inline-block; width: 16px; height: 16px; border: 2px solid #14b8a640; border-top: 2px solid #14b8a6; border-radius: 50%; animation: ai-spin 0.8s linear infinite; }
@keyframes ai-spin { to { transform: rotate(360deg); } }
.ai-empty { text-align: center; color: var(--rs-text-muted); padding: 40px 20px; }
.ai-empty p { margin: 4px 0; }
</style>
${this.error ? `<div style="color:var(--rs-error);text-align:center;padding:8px">${this.esc(this.error)}</div>` : ""}
${this.view === "list" ? this.renderList() : this.renderDetail()}
${this.view === "ai-planner" ? this.renderAiPlanner() : this.view === "list" ? this.renderList() : this.renderDetail()}
`;
this.attachListeners();
this._tour.renderOverlay();
if (this.view !== 'ai-planner') this._tour.renderOverlay();
}
startTour() {
@ -687,7 +729,13 @@ class FolkTripsPlanner extends HTMLElement {
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._aiTripContext = this.trip || null;
this._aiMessages = [];
this._aiGeneratedItems = [];
this._aiLoading = false;
this._history.push(this.view as any);
this.view = 'ai-planner';
this.render();
});
this.shadow.querySelectorAll("[data-trip]").forEach(el => {
@ -721,6 +769,345 @@ class FolkTripsPlanner extends HTMLElement {
} catch {}
});
});
// AI Planner listeners
const aiInput = this.shadow.getElementById('ai-input') as HTMLTextAreaElement | null;
const aiSend = this.shadow.getElementById('ai-send');
if (aiInput && aiSend) {
const doSend = () => {
const text = aiInput.value;
if (text.trim()) this.sendAiMessage(text);
};
aiSend.addEventListener('click', doSend);
aiInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); doSend(); }
});
// Auto-resize textarea
aiInput.addEventListener('input', () => {
aiInput.style.height = 'auto';
aiInput.style.height = Math.min(aiInput.scrollHeight, 100) + 'px';
});
// Auto-focus
setTimeout(() => aiInput.focus(), 50);
}
this.shadow.getElementById('ai-model')?.addEventListener('change', (e) => {
this._aiModel = (e.target as HTMLSelectElement).value;
});
this.shadow.getElementById('ai-clear')?.addEventListener('click', () => {
this._aiMessages = [];
this._aiGeneratedItems = [];
this.render();
});
this.shadow.getElementById('btn-export-canvas')?.addEventListener('click', () => this.exportToCanvas());
this.shadow.querySelectorAll('[data-accept]').forEach(el => {
el.addEventListener('click', () => this.acceptItem((el as HTMLElement).dataset.accept!));
});
this.shadow.querySelectorAll('[data-discard]').forEach(el => {
el.addEventListener('click', () => this.discardItem((el as HTMLElement).dataset.discard!));
});
}
/* ── AI Planner View ── */
private renderAiPlanner(): string {
const acceptedCount = this._aiGeneratedItems.filter(i => i.accepted).length;
const totalCount = this._aiGeneratedItems.length;
const tripName = this._aiTripContext?.title || '';
return `
<div class="rapp-nav">
<button class="rapp-nav__back" data-back="prev">\u2190 Back</button>
<span class="rapp-nav__title">AI Trip Planner${tripName ? ` \u2014 ${this.esc(tripName)}` : ''}</span>
${totalCount > 0 && acceptedCount > 0 ? `<button class="rapp-nav__btn" id="btn-export-canvas">Export to Canvas (${acceptedCount})</button>` : ''}
</div>
<div class="ai-planner">
<div class="ai-chat">
<div class="ai-chat-messages" id="ai-messages">
${this._aiMessages.length === 0 ? `
<div class="ai-empty">
<p style="font-size:20px"></p>
<p style="font-size:14px;font-weight:500">Plan your trip with AI</p>
<p style="font-size:12px">Describe your trip and I'll generate destinations, itineraries, budgets, and more.</p>
${tripName ? `<p style="font-size:11px;margin-top:8px;color:var(--rs-text-secondary)">Context: ${this.esc(tripName)}</p>` : ''}
</div>
` : this._aiMessages.map(m => {
if (m.toolCalls?.length) {
return `<div class="ai-msg tool-action">\u{1F6E0}\uFE0F Created ${m.toolCalls.length} item${m.toolCalls.length > 1 ? 's' : ''}</div>`;
}
return `<div class="ai-msg ${m.role}">${this.esc(m.content)}</div>`;
}).join('')}
${this._aiLoading ? '<div class="ai-msg assistant"><span class="ai-loading"></span></div>' : ''}
</div>
<div class="ai-chat-input-row">
<textarea class="ai-chat-input" id="ai-input" placeholder="Plan 5 days in Japan..." rows="1"></textarea>
<select class="ai-model-select" id="ai-model">
<option value="gemini-flash"${this._aiModel === 'gemini-flash' ? ' selected' : ''}>Gemini Flash</option>
<option value="gemini-pro"${this._aiModel === 'gemini-pro' ? ' selected' : ''}>Gemini Pro</option>
</select>
<button class="ai-send-btn" id="ai-send"${this._aiLoading ? ' disabled' : ''}>Send</button>
${this._aiMessages.length > 0 ? '<button class="ai-clear-btn" id="ai-clear">Clear</button>' : ''}
</div>
</div>
<div class="ai-cards">
<div class="ai-cards-header">
<h3>Generated Items${totalCount > 0 ? ` (${acceptedCount}/${totalCount} accepted)` : ''}</h3>
</div>
${totalCount > 0 ? `<div class="ai-cards-grid">
${this._aiGeneratedItems.map(item => this.renderAiCard(item)).join('')}
</div>` : `<div class="ai-empty">
<p style="font-size:28px">\u{1F5FA}\uFE0F</p>
<p style="font-size:13px">AI-generated trip items will appear here</p>
</div>`}
</div>
</div>
`;
}
private renderAiCard(item: { type: string; props: Record<string, any>; accepted: boolean; id: string }): string {
const { type, props, accepted, id } = item;
let emoji = '\u{1F4CC}';
let title = type;
let detail = '';
switch (type) {
case 'create_destination':
emoji = '\u{1F4CD}';
title = `${props.destName || 'Destination'}${props.country ? ', ' + props.country : ''}`;
detail = [props.arrivalDate, props.departureDate].filter(Boolean).join(' \u2192 ') || (props.notes || '');
break;
case 'create_itinerary': {
emoji = '\u{1F4C5}';
title = props.tripTitle || 'Itinerary';
let items: any[] = [];
try { items = typeof props.itemsJson === 'string' ? JSON.parse(props.itemsJson) : (props.items || []); } catch {}
detail = `${items.length} activities`;
break;
}
case 'create_booking': {
const typeEmojis: Record<string, string> = { FLIGHT: '\u2708\uFE0F', HOTEL: '\u{1F3E8}', CAR_RENTAL: '\u{1F697}', TRAIN: '\u{1F682}', BUS: '\u{1F68C}', FERRY: '\u26F4\uFE0F', ACTIVITY: '\u{1F3AF}', RESTAURANT: '\u{1F37D}\uFE0F' };
emoji = typeEmojis[props.bookingType] || '\u{1F4CB}';
title = `${props.bookingType || 'Booking'} \u2014 ${props.provider || ''}`;
detail = [props.cost != null ? `${props.currency || 'USD'} ${props.cost}` : '', props.startDate || ''].filter(Boolean).join(' \u00B7 ');
break;
}
case 'create_budget': {
emoji = '\u{1F4B0}';
title = `Budget: ${props.currency || 'USD'} ${props.budgetTotal}`;
let expenses: any[] = [];
try { expenses = typeof props.expensesJson === 'string' ? JSON.parse(props.expensesJson) : (props.expenses || []); } catch {}
detail = `${expenses.length} expense${expenses.length !== 1 ? 's' : ''}`;
break;
}
case 'create_packing_list': {
emoji = '\u{1F392}';
title = 'Packing List';
let pItems: any[] = [];
try { pItems = typeof props.itemsJson === 'string' ? JSON.parse(props.itemsJson) : (props.items || []); } catch {}
detail = `${pItems.length} items`;
break;
}
case 'create_map':
emoji = '\u{1F5FA}\uFE0F';
title = props.location_name || 'Map';
detail = props.latitude != null ? `(${props.latitude.toFixed(2)}, ${props.longitude.toFixed(2)})` : '';
break;
case 'create_note':
emoji = '\u{1F4DD}';
title = props.title || 'Note';
detail = (props.content || '').slice(0, 80) + ((props.content || '').length > 80 ? '...' : '');
break;
default:
title = type.replace('create_', '').replace(/_/g, ' ');
detail = JSON.stringify(props).slice(0, 80);
}
const typePretty = type.replace('create_', '').replace(/_/g, ' ');
return `
<div class="ai-card${accepted ? ' accepted' : ''}" data-card-id="${id}">
<div class="ai-card-type">${emoji} ${this.esc(typePretty)}</div>
<div class="ai-card-title">${this.esc(title)}</div>
${detail ? `<div class="ai-card-detail">${this.esc(detail)}</div>` : ''}
<div class="ai-card-actions">
${accepted
? '<button class="ai-card-btn accepted-badge">\u2713 Accepted</button>'
: `<button class="ai-card-btn accept" data-accept="${id}">Accept</button>
<button class="ai-card-btn discard" data-discard="${id}">Discard</button>`
}
</div>
</div>
`;
}
private async sendAiMessage(text: string) {
if (!text.trim() || this._aiLoading) return;
this._aiMessages.push({ role: 'user', content: text.trim() });
this._aiLoading = true;
this.render();
// Scroll chat to bottom
const msgBox = this.shadow.getElementById('ai-messages');
if (msgBox) msgBox.scrollTop = msgBox.scrollHeight;
try {
let result: { content: string; toolCalls?: any[] };
if (this.space === 'demo') {
result = this.mockAiResponse(text);
} else {
const systemPrompt = this.buildAiSystemPrompt();
const messages = this._aiMessages
.filter(m => m.role === 'user' || (m.role === 'assistant' && m.content))
.map(m => ({ role: m.role, content: m.content }));
const res = await fetch('/api/prompt', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messages, model: this._aiModel, useTools: true, systemPrompt }),
});
if (!res.ok) throw new Error(`API error: ${res.status}`);
result = await res.json();
}
// Process tool calls into generated items
if (result.toolCalls?.length) {
for (const tc of result.toolCalls) {
this._aiGeneratedItems.push({
type: tc.name,
props: tc.args || {},
accepted: false,
id: `ai-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
});
}
this._aiMessages.push({ role: 'assistant', content: '', toolCalls: result.toolCalls });
}
if (result.content) {
this._aiMessages.push({ role: 'assistant', content: result.content });
}
} catch (err: any) {
this._aiMessages.push({ role: 'assistant', content: `Error: ${err.message || 'Failed to get response'}` });
}
this._aiLoading = false;
this.render();
const msgBox2 = this.shadow.getElementById('ai-messages');
if (msgBox2) msgBox2.scrollTop = msgBox2.scrollHeight;
}
private buildAiSystemPrompt(): string {
let prompt = `You are a travel planning AI in rTrips. Help plan trips by creating structured items using tools.
When the user describes a trip, proactively create:
- Destination cards for each city/place (with coordinates and dates)
- An itinerary with activities by date
- Booking suggestions for flights, hotels, transport
- A budget tracker with estimated costs
- A packing list tailored to the destination
Use real coordinates, YYYY-MM-DD dates, ISO currency codes. Be specific and practical.`;
if (this._aiTripContext) {
const t = this._aiTripContext;
prompt += `\n\nCurrent trip context:
- Title: ${t.title || 'Untitled'}
- Status: ${t.status || 'PLANNING'}
- Dates: ${t.start_date || '?'} to ${t.end_date || '?'}
- Budget: $${t.budget_total || '0'}`;
if (t.destinations?.length) {
prompt += `\n- Existing destinations: ${t.destinations.map((d: any) => d.name).join(', ')}`;
}
if (t.description) {
prompt += `\n- Description: ${t.description}`;
}
}
return prompt;
}
private mockAiResponse(text: string): { content: string; toolCalls: any[] } {
const lower = text.toLowerCase();
const toolCalls: any[] = [];
if (lower.includes('japan') || lower.includes('tokyo') || lower.includes('kyoto')) {
toolCalls.push(
{ name: 'create_destination', args: { destName: 'Tokyo', country: 'Japan', lat: 35.6762, lng: 139.6503, arrivalDate: '2026-06-01', departureDate: '2026-06-04', notes: 'Explore Shibuya, Akihabara, and Tsukiji Market' } },
{ name: 'create_destination', args: { destName: 'Kyoto', country: 'Japan', lat: 35.0116, lng: 135.7681, arrivalDate: '2026-06-04', departureDate: '2026-06-06', notes: 'Visit Fushimi Inari, Arashiyama bamboo grove' } },
{ name: 'create_itinerary', args: { tripTitle: 'Japan Adventure', itemsJson: JSON.stringify([
{ id: 'it1', title: 'Arrive Tokyo \u2014 Narita Express', date: '2026-06-01', startTime: '15:00', category: 'TRANSPORT' },
{ id: 'it2', title: 'Shibuya & Harajuku exploration', date: '2026-06-02', startTime: '10:00', category: 'ACTIVITY' },
{ id: 'it3', title: 'Tsukiji Market & teamLab', date: '2026-06-03', startTime: '08:00', category: 'ACTIVITY' },
{ id: 'it4', title: 'Shinkansen to Kyoto', date: '2026-06-04', startTime: '10:00', category: 'TRANSPORT' },
{ id: 'it5', title: 'Fushimi Inari & Kiyomizu-dera', date: '2026-06-05', startTime: '07:00', category: 'ACTIVITY' },
]) } },
{ name: 'create_budget', args: { budgetTotal: 3500, currency: 'USD', expensesJson: JSON.stringify([
{ id: 'e1', category: 'TRANSPORT', description: 'Round-trip flights', amount: 1200, date: '2026-06-01' },
{ id: 'e2', category: 'ACCOMMODATION', description: 'Hotels (5 nights)', amount: 800, date: '2026-06-01' },
{ id: 'e3', category: 'TRANSPORT', description: 'JR Pass (7 day)', amount: 280, date: '2026-06-01' },
{ id: 'e4', category: 'FOOD', description: 'Food & dining', amount: 500, date: '2026-06-01' },
{ id: 'e5', category: 'ACTIVITY', description: 'Activities & entrance fees', amount: 300, date: '2026-06-01' },
]) } },
{ name: 'create_packing_list', args: { itemsJson: JSON.stringify([
{ id: 'p1', name: 'Comfortable walking shoes', category: 'FOOTWEAR', quantity: 1, packed: false },
{ id: 'p2', name: 'Rain jacket (light)', category: 'CLOTHING', quantity: 1, packed: false },
{ id: 'p3', name: 'Portable WiFi hotspot', category: 'ELECTRONICS', quantity: 1, packed: false },
{ id: 'p4', name: 'Power adapter (Type A)', category: 'ELECTRONICS', quantity: 1, packed: false },
{ id: 'p5', name: 'Passport + visa', category: 'DOCUMENTS', quantity: 1, packed: false },
]) } },
);
return { content: "Here's a 5-day Japan plan covering Tokyo and Kyoto! I've created destination cards, a day-by-day itinerary, an estimated budget of $3,500, and a packing list. You can accept items you like and discard the rest.", toolCalls };
}
if (lower.includes('europe') || lower.includes('paris') || lower.includes('rome') || lower.includes('barcelona')) {
toolCalls.push(
{ name: 'create_destination', args: { destName: 'Paris', country: 'France', lat: 48.8566, lng: 2.3522, arrivalDate: '2026-07-01', departureDate: '2026-07-04' } },
{ name: 'create_destination', args: { destName: 'Barcelona', country: 'Spain', lat: 41.3874, lng: 2.1686, arrivalDate: '2026-07-04', departureDate: '2026-07-07' } },
{ name: 'create_booking', args: { bookingType: 'FLIGHT', provider: 'Air France CDG\u2192BCN', cost: 180, currency: 'EUR', startDate: '2026-07-04' } },
{ name: 'create_budget', args: { budgetTotal: 4000, currency: 'EUR', expensesJson: JSON.stringify([
{ id: 'e1', category: 'TRANSPORT', description: 'Flights', amount: 900, date: '2026-07-01' },
{ id: 'e2', category: 'ACCOMMODATION', description: 'Hotels (6 nights)', amount: 1200, date: '2026-07-01' },
{ id: 'e3', category: 'FOOD', description: 'Dining', amount: 700, date: '2026-07-01' },
]) } },
);
return { content: "Here's a week in Europe \u2014 Paris and Barcelona! I've created destination cards, a connecting flight, and a budget estimate.", toolCalls };
}
if (lower.includes('beach') || lower.includes('tropical') || lower.includes('bali') || lower.includes('thailand')) {
toolCalls.push(
{ name: 'create_destination', args: { destName: 'Bali', country: 'Indonesia', lat: -8.3405, lng: 115.0920, arrivalDate: '2026-08-10', departureDate: '2026-08-17', notes: 'Ubud rice terraces, Seminyak beach, temple tours' } },
{ name: 'create_itinerary', args: { tripTitle: 'Bali Beach Retreat', itemsJson: JSON.stringify([
{ id: 'it1', title: 'Arrive Ngurah Rai Airport', date: '2026-08-10', startTime: '14:00', category: 'TRANSPORT' },
{ id: 'it2', title: 'Ubud rice terrace trek', date: '2026-08-11', startTime: '08:00', category: 'ACTIVITY' },
{ id: 'it3', title: 'Temple tour \u2014 Tirta Empul', date: '2026-08-12', startTime: '09:00', category: 'ACTIVITY' },
{ id: 'it4', title: 'Surf lesson at Seminyak', date: '2026-08-13', startTime: '07:00', category: 'ACTIVITY' },
{ id: 'it5', title: 'Beach day & spa', date: '2026-08-14', startTime: '10:00', category: 'FREE_TIME' },
]) } },
{ name: 'create_budget', args: { budgetTotal: 2000, currency: 'USD', expensesJson: JSON.stringify([
{ id: 'e1', category: 'TRANSPORT', description: 'Flights', amount: 800, date: '2026-08-10' },
{ id: 'e2', category: 'ACCOMMODATION', description: 'Villa (7 nights)', amount: 600, date: '2026-08-10' },
{ id: 'e3', category: 'FOOD', description: 'Food & drinks', amount: 300, date: '2026-08-10' },
]) } },
);
return { content: "Here's a relaxing week in Bali! Includes rice terraces, temples, surfing, and plenty of beach time. Budget is very affordable at $2,000.", toolCalls };
}
// Generic fallback
toolCalls.push(
{ name: 'create_note', args: { title: 'Trip Planning Notes', content: `Planning ideas based on: "${text}"\n\n- Research destinations\n- Check flight prices\n- Look for accommodation\n- Plan day-by-day itinerary\n- Set budget` } },
);
return { content: "I've created a planning note to get started. Tell me more about where you'd like to go, your travel dates, budget, and interests \u2014 and I'll generate detailed destination cards, itineraries, and budget estimates!", toolCalls };
}
private acceptItem(id: string) {
const item = this._aiGeneratedItems.find(i => i.id === id);
if (item) { item.accepted = true; this.render(); }
}
private discardItem(id: string) {
this._aiGeneratedItems = this._aiGeneratedItems.filter(i => i.id !== id);
this.render();
}
private exportToCanvas() {
const accepted = this._aiGeneratedItems.filter(i => i.accepted);
if (accepted.length === 0) return;
sessionStorage.setItem('rtrips-canvas-export', JSON.stringify(accepted.map(i => ({ type: i.type, props: i.props }))));
window.location.href = `/${this.space}/rspace#trip-import`;
}
private goBack() {

View File

@ -3441,6 +3441,48 @@
if (hash.startsWith("#pin-")) {
const pinId = hash.slice(5);
setTimeout(() => pinManager.openPinById(pinId), 500);
} else if (hash === "#trip-import") {
history.replaceState(null, "", location.pathname + location.search);
setTimeout(() => {
const raw = sessionStorage.getItem('rtrips-canvas-export');
sessionStorage.removeItem('rtrips-canvas-export');
if (!raw) return;
try {
const items = JSON.parse(raw);
// Map tool names to tagNames + build props (mirrors canvas-tools.ts)
const TOOL_MAP = {
create_map: { tag: 'folk-map', build: (a) => ({ center: [a.longitude, a.latitude], zoom: a.zoom || 12 }) },
create_note: { tag: 'folk-markdown', build: (a) => ({ value: a.title ? `# ${a.title}\n\n${a.content}` : a.content }) },
create_destination: { tag: 'folk-destination', build: (a) => a },
create_itinerary: { tag: 'folk-itinerary', build: (a) => {
let items = []; try { items = JSON.parse(a.itemsJson); } catch {}
return { tripTitle: a.tripTitle, items };
}},
create_booking: { tag: 'folk-booking', build: (a) => a },
create_budget: { tag: 'folk-budget', build: (a) => {
let expenses = []; try { expenses = JSON.parse(a.expensesJson); } catch {}
return { budgetTotal: a.budgetTotal, currency: a.currency, expenses };
}},
create_packing_list: { tag: 'folk-packing-list', build: (a) => {
let items = []; try { items = JSON.parse(a.itemsJson); } catch {}
return { items };
}},
};
const startPos = getViewportCenter();
let colIdx = 0;
for (const item of items) {
const mapping = TOOL_MAP[item.type];
if (!mapping) continue;
const props = mapping.build(item.props);
const pos = {
x: startPos.x + (colIdx % 3) * 340 - 340,
y: startPos.y + Math.floor(colIdx / 3) * 280 - 100,
};
newShape(mapping.tag, props, pos);
colIdx++;
}
} catch (e) { console.error('[Canvas] Failed to import trip items:', e); }
}, 400);
} else if (hash === "#trip-planner") {
history.replaceState(null, "", location.pathname + location.search);
setTimeout(() => {