feat: Add dynamic trip configuration UI to dashboard and backend

This commit is contained in:
Antigravity 2026-05-29 16:41:41 -04:00
parent 6eb126257f
commit aab9e60100
5 changed files with 246 additions and 4 deletions

View File

@ -0,0 +1,30 @@
import { NextResponse } from 'next/server';
const TRIP_SERVICE_URL = 'http://trip-service.ai-agents.svc.cluster.local:6002';
export async function PUT(req, { params }) {
try {
const body = await req.json();
const res = await fetch(`${TRIP_SERVICE_URL}/trips/${params.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
const data = await res.json();
return NextResponse.json(data);
} catch (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
}
export async function DELETE(req, { params }) {
try {
const res = await fetch(`${TRIP_SERVICE_URL}/trips/${params.id}`, {
method: 'DELETE'
});
const data = await res.json();
return NextResponse.json(data);
} catch (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
}

View File

@ -0,0 +1,15 @@
import { NextResponse } from 'next/server';
const TRIP_SERVICE_URL = 'http://trip-service.ai-agents.svc.cluster.local:6002';
export async function POST(req, { params }) {
try {
const res = await fetch(`${TRIP_SERVICE_URL}/trips/${params.id}/test`, {
method: 'POST'
});
const data = await res.json();
return NextResponse.json(data);
} catch (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
}

View File

@ -0,0 +1,28 @@
import { NextResponse } from 'next/server';
const TRIP_SERVICE_URL = 'http://trip-service.ai-agents.svc.cluster.local:6002';
export async function GET() {
try {
const res = await fetch(`${TRIP_SERVICE_URL}/trips`, { cache: 'no-store' });
const data = await res.json();
return NextResponse.json(data);
} catch (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
}
export async function POST(req) {
try {
const body = await req.json();
const res = await fetch(`${TRIP_SERVICE_URL}/trips`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
const data = await res.json();
return NextResponse.json(data);
} catch (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
}

View File

@ -26,7 +26,8 @@ import {
CloudSun, CloudSun,
Wind, Wind,
Droplets, Droplets,
Thermometer Thermometer,
Compass
} from "lucide-react"; } from "lucide-react";
export default function Page() { export default function Page() {
@ -90,6 +91,13 @@ export default function Page() {
{ id: "wf-ops-003", name: "OpsRemediateWorkflow", agent: "OpsAgent", target: "VMID 102", status: "running", result: "Awaiting approval signal" } { id: "wf-ops-003", name: "OpsRemediateWorkflow", agent: "OpsAgent", target: "VMID 102", status: "running", result: "Awaiting approval signal" }
]); ]);
// Trips State
const [tripsList, setTripsList] = useState([]);
const [selectedTripId, setSelectedTripId] = useState(null);
const [tripEditorJson, setTripEditorJson] = useState("");
const [isSavingTrip, setIsSavingTrip] = useState(false);
const [isTestingTrip, setIsTestingTrip] = useState(false);
const messagesEndRef = useRef(null); const messagesEndRef = useRef(null);
const terminalEndRef = useRef(null); const terminalEndRef = useRef(null);
@ -101,6 +109,14 @@ export default function Page() {
terminalEndRef.current?.scrollIntoView({ behavior: "smooth" }); terminalEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [clawTerminalLogs]); }, [clawTerminalLogs]);
useEffect(() => {
if (activeTab === "trips") {
fetch('/api/trips').then(res => res.json()).then(data => {
if(Array.isArray(data)) setTripsList(data);
}).catch(() => {});
}
}, [activeTab]);
// Simulated Claw response logic // Simulated Claw response logic
const handleSendMessage = async (e) => { const handleSendMessage = async (e) => {
e.preventDefault(); e.preventDefault();
@ -523,6 +539,51 @@ export default function Page() {
setIsProcessingClinical(false); setIsProcessingClinical(false);
}; };
const handleSaveTrip = async () => {
try {
setIsSavingTrip(true);
let parsed = JSON.parse(tripEditorJson);
if (selectedTripId) {
await fetch(`/api/trips/${selectedTripId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(parsed)
});
} else {
await fetch('/api/trips', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(parsed)
});
}
const data = await fetch('/api/trips').then(r => r.json());
setTripsList(Array.isArray(data) ? data : []);
setSelectedTripId(null);
setTripEditorJson("");
} catch (e) {
alert("Invalid JSON or save failed: " + e.message);
} finally {
setIsSavingTrip(false);
}
};
const handleTestTrip = async (id) => {
setIsTestingTrip(true);
try {
const res = await fetch(`/api/trips/${id}/test`, { method: 'POST' });
const data = await res.json();
if (data.success) {
alert("Test briefing fired to WhatsApp successfully!");
} else {
alert("Test failed: " + JSON.stringify(data));
}
} catch (e) {
alert("Error triggering test: " + e.message);
} finally {
setIsTestingTrip(false);
}
};
return ( return (
<div className="app-container"> <div className="app-container">
{/* Sidebar Navigation */} {/* Sidebar Navigation */}
@ -602,6 +663,20 @@ export default function Page() {
Clinical Workspace Clinical Workspace
</button> </button>
<button
onClick={() => setActiveTab("trips")}
style={{
display: "flex", alignItems: "center", gap: "12px", width: "100%", padding: "12px 16px", border: "none", borderRadius: "8px", cursor: "pointer",
background: activeTab === "trips" ? "rgba(236, 72, 153, 0.15)" : "transparent",
color: activeTab === "trips" ? "var(--accent-pink, #ec4899)" : "var(--text-secondary)",
fontWeight: activeTab === "trips" ? "600" : "500",
transition: "all 0.2s"
}}
>
<Compass size={18} />
Trips & Briefings
</button>
<button <button
onClick={() => setActiveTab("logs")} onClick={() => setActiveTab("logs")}
style={{ style={{
@ -1280,6 +1355,82 @@ export default function Page() {
</div> </div>
</div> </div>
)} )}
{/* TAB 6: TRIPS & CONFIG */}
{activeTab === "trips" && (
<div className="fade-in" style={{ display: "grid", gridTemplateColumns: "300px 1fr", gap: "24px", height: "calc(100vh - 140px)" }}>
{/* Trip List Panel */}
<div className="glass" style={{ display: "flex", flexDirection: "column", height: "100%", overflow: "hidden" }}>
<div style={{ padding: "16px 20px", borderBottom: "1px solid var(--border-color)", background: "rgba(255,255,255,0.01)" }}>
<h3 style={{ fontSize: "14px", display: "flex", alignItems: "center", gap: "8px" }}>
<Compass size={16} style={{ color: "var(--accent-pink)" }} />
Managed Trips
</h3>
</div>
<div style={{ flex: 1, overflowY: "auto", padding: "16px", display: "flex", flexDirection: "column", gap: "12px" }}>
<button
onClick={() => {
setSelectedTripId(null);
setTripEditorJson("{\n \"name\": \"New Trip\",\n \"briefingTime\": \"07:00\",\n \"sendToGroup\": true,\n \"groupUrls\": [],\n \"llmModel\": \"qwen2.5:0.5b\",\n \"days\": []\n}");
}}
style={{ padding: "12px", border: "1px dashed var(--border-color)", borderRadius: "8px", background: "rgba(255,255,255,0.02)", color: "var(--text-primary)", cursor: "pointer", fontSize: "13px" }}
>
+ Add New Trip
</button>
{tripsList.map(t => (
<div
key={t.id}
onClick={() => {
setSelectedTripId(t.id);
setTripEditorJson(JSON.stringify(t, null, 2));
}}
style={{ padding: "12px", border: `1px solid ${selectedTripId === t.id ? 'var(--accent-pink)' : 'var(--border-color)'}`, borderRadius: "8px", background: selectedTripId === t.id ? "rgba(236,72,153,0.05)" : "var(--bg-secondary)", cursor: "pointer" }}
>
<h4 style={{ fontSize: "14px", fontWeight: "600", marginBottom: "4px" }}>{t.name}</h4>
<div style={{ fontSize: "11px", color: "var(--text-muted)", display: "flex", justifyContent: "space-between" }}>
<span> {t.briefingTime} ET</span>
<span>{t.days?.length} Days</span>
</div>
</div>
))}
</div>
</div>
{/* Editor Panel */}
<div className="glass" style={{ display: "flex", flexDirection: "column", height: "100%", overflow: "hidden" }}>
<div style={{ padding: "16px 20px", borderBottom: "1px solid var(--border-color)", background: "rgba(255,255,255,0.01)", display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<h3 style={{ fontSize: "14px" }}>{selectedTripId ? "Edit Trip Configuration" : "New Trip Configuration"}</h3>
<div style={{ display: "flex", gap: "8px" }}>
{selectedTripId && (
<button
onClick={() => handleTestTrip(selectedTripId)}
disabled={isTestingTrip}
style={{ padding: "8px 14px", border: "1px solid var(--border-color)", borderRadius: "6px", background: "rgba(255,255,255,0.05)", color: "var(--text-primary)", fontSize: "12px", cursor: "pointer" }}
>
{isTestingTrip ? "Firing..." : "Test Briefing"}
</button>
)}
<button
onClick={handleSaveTrip}
disabled={isSavingTrip}
style={{ padding: "8px 14px", border: "none", borderRadius: "6px", background: "linear-gradient(135deg, #ec4899, #be185d)", color: "#fff", fontWeight: "600", fontSize: "12px", cursor: "pointer" }}
>
{isSavingTrip ? "Saving..." : "Save Trip Config"}
</button>
</div>
</div>
<div style={{ flex: 1, display: "flex", flexDirection: "column", padding: "16px", background: "#05070a" }}>
<textarea
value={tripEditorJson}
onChange={(e) => setTripEditorJson(e.target.value)}
placeholder="Paste JSON config here..."
style={{ flex: 1, width: "100%", padding: "16px", border: "none", background: "transparent", color: "#a5f3fc", fontFamily: "var(--font-mono)", fontSize: "12px", resize: "none", outline: "none" }}
spellCheck={false}
/>
</div>
</div>
</div>
)}
</div> </div>
</main> </main>
</div> </div>

View File

@ -88,10 +88,10 @@ async function getTdotTraffic() {
} }
} }
async function getAiInsight(activityDetails, location) { async function getAiInsight(activityDetails, location, modelName = 'qwen2.5:0.5b') {
return new Promise((resolve) => { return new Promise((resolve) => {
const prompt = `Provide one short, clever tip or insight for someone visiting ${location} today, doing: ${activityDetails}. Keep it under 2 sentences.`; const prompt = `Provide one short, clever tip or insight for someone visiting ${location} today, doing: ${activityDetails}. Keep it under 2 sentences.`;
const body = JSON.stringify({ model: 'qwen2.5:0.5b', prompt, stream: false }); const body = JSON.stringify({ model: modelName, prompt, stream: false });
const url = new URL(OLLAMA_URL); const url = new URL(OLLAMA_URL);
const req = http.request({ const req = http.request({
hostname: url.hostname, port: url.port, path: url.pathname, method: 'POST', hostname: url.hostname, port: url.port, path: url.pathname, method: 'POST',
@ -131,7 +131,7 @@ async function generateAndSendBriefing(trip, dayPlan) {
const weather = await getWeather(dayPlan.location || 'Pigeon Forge, TN'); const weather = await getWeather(dayPlan.location || 'Pigeon Forge, TN');
const nps = await getNpsAlerts(trip.npsParkCode); const nps = await getNpsAlerts(trip.npsParkCode);
const traffic = await getTdotTraffic(); const traffic = await getTdotTraffic();
const insight = await getAiInsight(dayPlan.activities, dayPlan.location || 'Smoky Mountains'); const insight = await getAiInsight(dayPlan.activities, dayPlan.location || 'Smoky Mountains', trip.llmModel);
const message = `*🏕️ ${trip.name} - ${dayPlan.date} Briefing*\n\n` + const message = `*🏕️ ${trip.name} - ${dayPlan.date} Briefing*\n\n` +
`*🌤️ Weather (${dayPlan.location || 'Pigeon Forge'}):* ${weather}\n\n` + `*🌤️ Weather (${dayPlan.location || 'Pigeon Forge'}):* ${weather}\n\n` +
@ -174,6 +174,24 @@ app.get('/trips', (req, res) => {
res.json(loadTrips()); res.json(loadTrips());
}); });
app.put('/trips/:id', (req, res) => {
const trips = loadTrips();
const index = trips.findIndex(t => t.id === req.params.id);
if (index === -1) return res.status(404).json({ error: 'Trip not found' });
const updatedTrip = { ...req.body, id: req.params.id }; // preserve ID
trips[index] = updatedTrip;
saveTrips(trips);
res.json({ success: true });
});
app.delete('/trips/:id', (req, res) => {
let trips = loadTrips();
trips = trips.filter(t => t.id !== req.params.id);
saveTrips(trips);
res.json({ success: true });
});
app.post('/trips/:id/test', async (req, res) => { app.post('/trips/:id/test', async (req, res) => {
const trips = loadTrips(); const trips = loadTrips();
const trip = trips.find(t => t.id === req.params.id); const trip = trips.find(t => t.id === req.params.id);