Spaces:
Running
Running
feat: Enhance onboarding flow with progressive medication setup and new LangGraph UI component, alongside dependency updates.
Browse files- frontend/src/components/ComponentOverlay.tsx +1 -1
- frontend/src/components/DashboardGrid.tsx +39 -20
- frontend/src/components/ReportsPanel.tsx +376 -108
- frontend/src/hooks/useDashboardData.ts +9 -1
- frontend/src/hooks/useLangGraph.ts +15 -13
- frontend/src/registry/MedLog.tsx +267 -88
- frontend/src/registry/MyMedsList.tsx +217 -78
- frontend/src/registry/OnboardingProgress.tsx +3 -3
- frontend/src/registry/index.tsx +3 -1
- langgraph.json +3 -0
- src/reachy_mini_conversation_app/database.py +171 -0
- src/reachy_mini_conversation_app/langgraph_agent/nodes/report_builder.py +39 -12
- src/reachy_mini_conversation_app/langgraph_agent/nodes/session_summary_node.py +12 -10
- src/reachy_mini_conversation_app/langgraph_agent/nodes/trend_analyzer.py +11 -6
- src/reachy_mini_conversation_app/langgraph_agent/ui.tsx +19 -0
- src/reachy_mini_conversation_app/openai_realtime.py +62 -21
- src/reachy_mini_conversation_app/profiles/_reachy_mini_minder_locked_profile/complete_onboarding.py +19 -5
- src/reachy_mini_conversation_app/profiles/_reachy_mini_minder_locked_profile/get_onboarding_status.py +23 -3
- src/reachy_mini_conversation_app/profiles/_reachy_mini_minder_locked_profile/setup_medication.py +7 -4
- src/reachy_mini_conversation_app/profiles/_reachy_mini_minder_locked_profile/show_onboarding_step.py +23 -5
- src/reachy_mini_conversation_app/profiles/_reachy_mini_minder_locked_profile/update_settings.py +3 -1
- src/reachy_mini_conversation_app/prompts/sections/onboarding.txt +1 -1
- src/reachy_mini_conversation_app/stream_api.py +30 -0
- tests/test_database_onboarding.py +59 -0
- tests/test_onboarding_tools.py +39 -0
- uv.lock +75 -4
frontend/src/components/ComponentOverlay.tsx
CHANGED
|
@@ -11,7 +11,7 @@ interface ComponentOverlayProps {
|
|
| 11 |
}
|
| 12 |
|
| 13 |
// Components that render full-screen (TV-distance readable)
|
| 14 |
-
const FULLSCREEN_COMPONENTS = new Set(["HeadacheLog", "MedLog", "SessionSummary", "OnboardingProgress", "OnboardingSummary"]);
|
| 15 |
|
| 16 |
export function ComponentOverlay({ component, onDismiss }: ComponentOverlayProps) {
|
| 17 |
const isFullscreen = FULLSCREEN_COMPONENTS.has(component.name);
|
|
|
|
| 11 |
}
|
| 12 |
|
| 13 |
// Components that render full-screen (TV-distance readable)
|
| 14 |
+
const FULLSCREEN_COMPONENTS = new Set(["HeadacheLog", "MedLog", "SessionSummary", "OnboardingProgress", "OnboardingSummary", "MyMedsList"]);
|
| 15 |
|
| 16 |
export function ComponentOverlay({ component, onDismiss }: ComponentOverlayProps) {
|
| 17 |
const isFullscreen = FULLSCREEN_COMPONENTS.has(component.name);
|
frontend/src/components/DashboardGrid.tsx
CHANGED
|
@@ -81,8 +81,7 @@ export function DashboardGrid({ isSessionActive, isConnected, userName, showCame
|
|
| 81 |
{/* Next Step CTA - 1x1 */}
|
| 82 |
<NextStepCard
|
| 83 |
onboardingCompleted={stats.profile.onboarding_completed}
|
| 84 |
-
|
| 85 |
-
medicationTotal={stats.medication.total}
|
| 86 |
headachesToday={stats.headaches.length > 0 ? (stats.headaches[stats.headaches.length - 1].count ?? (stats.headaches[stats.headaches.length - 1].intensity > 0 ? 1 : 0)) : 0}
|
| 87 |
/>
|
| 88 |
</div>
|
|
@@ -316,7 +315,7 @@ function MedicationRingCard({ taken, total, adHocCount, isLoading, hasData }: {
|
|
| 316 |
letterSpacing: "0.1em",
|
| 317 |
color: "var(--color-text-secondary)"
|
| 318 |
}}>
|
| 319 |
-
Meds Logged
|
| 320 |
</span>
|
| 321 |
{adHocCount > 0 && (
|
| 322 |
<span style={{
|
|
@@ -574,42 +573,62 @@ function HeadacheCalendarCard({
|
|
| 574 |
);
|
| 575 |
}
|
| 576 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 577 |
function NextStepCard({
|
| 578 |
onboardingCompleted,
|
| 579 |
-
|
| 580 |
-
medicationTotal,
|
| 581 |
headachesToday,
|
| 582 |
}: {
|
| 583 |
onboardingCompleted: boolean;
|
| 584 |
-
|
| 585 |
-
medicationTotal: number;
|
| 586 |
headachesToday: number;
|
| 587 |
}) {
|
| 588 |
-
// Determine the most relevant next step based on
|
| 589 |
const getCtaContent = () => {
|
| 590 |
// If onboarding not done, that's the priority
|
| 591 |
if (!onboardingCompleted) {
|
| 592 |
return { action: "Start your setup", hint: "Tell Reachy Mini Minder about yourself to get started" };
|
| 593 |
}
|
| 594 |
|
| 595 |
-
const
|
| 596 |
-
const
|
| 597 |
|
| 598 |
-
//
|
| 599 |
-
if (
|
| 600 |
-
|
| 601 |
-
return { action: "Morning medication", hint: "Ready when you are" };
|
| 602 |
-
} else if (hour >= 17 && hour < 23) {
|
| 603 |
-
return { action: "Evening medication", hint: "Just say the word" };
|
| 604 |
-
}
|
| 605 |
}
|
| 606 |
|
| 607 |
-
//
|
| 608 |
-
if (
|
| 609 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 610 |
}
|
| 611 |
|
| 612 |
// Afternoon — companion check-in
|
|
|
|
| 613 |
if (hour >= 12 && hour < 17 && headachesToday === 0) {
|
| 614 |
return { action: "Chat with Reachy", hint: "I'm here if you'd like to talk" };
|
| 615 |
}
|
|
|
|
| 81 |
{/* Next Step CTA - 1x1 */}
|
| 82 |
<NextStepCard
|
| 83 |
onboardingCompleted={stats.profile.onboarding_completed}
|
| 84 |
+
scheduledMeds={stats.medication.scheduled ?? []}
|
|
|
|
| 85 |
headachesToday={stats.headaches.length > 0 ? (stats.headaches[stats.headaches.length - 1].count ?? (stats.headaches[stats.headaches.length - 1].intensity > 0 ? 1 : 0)) : 0}
|
| 86 |
/>
|
| 87 |
</div>
|
|
|
|
| 315 |
letterSpacing: "0.1em",
|
| 316 |
color: "var(--color-text-secondary)"
|
| 317 |
}}>
|
| 318 |
+
Meds Logged Today
|
| 319 |
</span>
|
| 320 |
{adHocCount > 0 && (
|
| 321 |
<span style={{
|
|
|
|
| 573 |
);
|
| 574 |
}
|
| 575 |
|
| 576 |
+
/** Convert a schedule window value to a friendly display string. */
|
| 577 |
+
function formatScheduleWindow(window: string): string {
|
| 578 |
+
// Handle named windows
|
| 579 |
+
const lower = window.toLowerCase();
|
| 580 |
+
if (lower === "morning") return "this morning";
|
| 581 |
+
if (lower === "evening" || lower === "night") return "this evening";
|
| 582 |
+
if (lower === "afternoon") return "this afternoon";
|
| 583 |
+
// Handle HH:MM times
|
| 584 |
+
const match = window.match(/^(\d{1,2}):(\d{2})$/);
|
| 585 |
+
if (match) {
|
| 586 |
+
let h = parseInt(match[1], 10);
|
| 587 |
+
const m = match[2];
|
| 588 |
+
const ampm = h >= 12 ? "PM" : "AM";
|
| 589 |
+
if (h === 0) h = 12;
|
| 590 |
+
else if (h > 12) h -= 12;
|
| 591 |
+
return m === "00" ? `${h} ${ampm}` : `${h}:${m} ${ampm}`;
|
| 592 |
+
}
|
| 593 |
+
return window;
|
| 594 |
+
}
|
| 595 |
+
|
| 596 |
function NextStepCard({
|
| 597 |
onboardingCompleted,
|
| 598 |
+
scheduledMeds,
|
|
|
|
| 599 |
headachesToday,
|
| 600 |
}: {
|
| 601 |
onboardingCompleted: boolean;
|
| 602 |
+
scheduledMeds: { name: string; status: string; scheduled_window: string }[];
|
|
|
|
| 603 |
headachesToday: number;
|
| 604 |
}) {
|
| 605 |
+
// Determine the most relevant next step based on actual schedule
|
| 606 |
const getCtaContent = () => {
|
| 607 |
// If onboarding not done, that's the priority
|
| 608 |
if (!onboardingCompleted) {
|
| 609 |
return { action: "Start your setup", hint: "Tell Reachy Mini Minder about yourself to get started" };
|
| 610 |
}
|
| 611 |
|
| 612 |
+
const pending = scheduledMeds.filter(m => m.status === "pending");
|
| 613 |
+
const total = scheduledMeds.length;
|
| 614 |
|
| 615 |
+
// All meds logged — neutral acknowledgment (Dignity-First)
|
| 616 |
+
if (total > 0 && pending.length === 0) {
|
| 617 |
+
return { action: "All logged", hint: "Anything else I can help with?" };
|
|
|
|
|
|
|
|
|
|
|
|
|
| 618 |
}
|
| 619 |
|
| 620 |
+
// Has pending meds — show the next one with its actual schedule
|
| 621 |
+
if (pending.length > 0) {
|
| 622 |
+
const next = pending[0];
|
| 623 |
+
const timeLabel = formatScheduleWindow(next.scheduled_window);
|
| 624 |
+
return {
|
| 625 |
+
action: next.name,
|
| 626 |
+
hint: `Scheduled for ${timeLabel} · Ready when you are`,
|
| 627 |
+
};
|
| 628 |
}
|
| 629 |
|
| 630 |
// Afternoon — companion check-in
|
| 631 |
+
const hour = new Date().getHours();
|
| 632 |
if (hour >= 12 && hour < 17 && headachesToday === 0) {
|
| 633 |
return { action: "Chat with Reachy", hint: "I'm here if you'd like to talk" };
|
| 634 |
}
|
frontend/src/components/ReportsPanel.tsx
CHANGED
|
@@ -1,13 +1,16 @@
|
|
| 1 |
"use client";
|
| 2 |
|
| 3 |
-
import React, { useState } from "react";
|
| 4 |
import { createPortal } from "react-dom";
|
| 5 |
-
import {
|
| 6 |
-
FileText,
|
| 7 |
-
BarChart3,
|
| 8 |
-
X,
|
| 9 |
-
ChevronRight,
|
| 10 |
-
Sparkles
|
|
|
|
|
|
|
|
|
|
| 11 |
} from "lucide-react";
|
| 12 |
import { useLangGraph } from "@/hooks/useLangGraph";
|
| 13 |
import { renderComponent } from "@/registry";
|
|
@@ -19,26 +22,100 @@ interface UIEvent {
|
|
| 19 |
status: "loading" | "streaming" | "complete" | "error";
|
| 20 |
}
|
| 21 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
interface ReportsPanelProps {
|
| 23 |
isOpen?: boolean;
|
| 24 |
onToggle?: () => void;
|
| 25 |
}
|
| 26 |
|
| 27 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
const [internalIsOpen, setInternalIsOpen] = useState(false);
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
const isOpen = controlledIsOpen !== undefined ? controlledIsOpen : internalIsOpen;
|
| 32 |
const toggleOpen = onToggle || (() => setInternalIsOpen((prev) => !prev));
|
| 33 |
-
|
| 34 |
const { submit, uiEvents, isProcessing, clear } = useLangGraph<UIEvent>();
|
| 35 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
if (!isOpen) {
|
| 37 |
return (
|
| 38 |
<button
|
| 39 |
onClick={() => toggleOpen()}
|
| 40 |
className="flex flex-col items-center gap-1 p-2 text-gray-400 hover:text-white transition-all"
|
| 41 |
-
title="
|
| 42 |
>
|
| 43 |
<BarChart3 className="w-5 h-5" />
|
| 44 |
<span className="text-[10px]">Reports</span>
|
|
@@ -46,125 +123,316 @@ export function ReportsPanel({ isOpen: controlledIsOpen, onToggle }: ReportsPane
|
|
| 46 |
);
|
| 47 |
}
|
| 48 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
return (
|
| 50 |
<>
|
| 51 |
-
<button
|
| 52 |
-
onClick={() => toggleOpen()}
|
| 53 |
-
className="btn btn-secondary p-2"
|
| 54 |
-
>
|
| 55 |
<BarChart3 className="w-5 h-5" />
|
| 56 |
</button>
|
| 57 |
|
| 58 |
{createPortal(
|
| 59 |
-
<div
|
| 60 |
className="modal-overlay"
|
| 61 |
-
onClick={(e) => {
|
|
|
|
|
|
|
| 62 |
>
|
| 63 |
-
<div className="modal
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
<div className="w-8 h-8 rounded-lg bg-accent-cyan flex items-center justify-center shadow-md transition-transform hover:scale-105">
|
| 68 |
-
<BarChart3 className="w-[18px] h-[18px] text-black" />
|
| 69 |
-
</div>
|
| 70 |
-
<div>
|
| 71 |
-
<h3 className="text-base font-bold text-primary tracking-tight">Health Insights</h3>
|
| 72 |
-
<p className="text-[10px] text-muted uppercase tracking-[0.1em] font-medium">Data-Driven Wellbeing</p>
|
| 73 |
-
</div>
|
| 74 |
-
</div>
|
| 75 |
-
<button
|
| 76 |
-
onClick={() => toggleOpen()}
|
| 77 |
-
className="btn btn-ghost p-1.5 hover:bg-surface-elevated rounded-lg transition-colors"
|
| 78 |
>
|
| 79 |
-
<
|
| 80 |
-
|
| 81 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
<button
|
| 88 |
-
onClick={() =>
|
| 89 |
-
className=
|
| 90 |
>
|
| 91 |
-
<
|
| 92 |
-
<div className="w-14 h-14 bg-cta/10 text-cta rounded-xl flex items-center justify-center shrink-0 border border-cta/20 group-hover:scale-110 group-hover:bg-cta group-hover:text-black transition-all shadow-lg shadow-cta/10">
|
| 93 |
-
<FileText className="w-7 h-7" />
|
| 94 |
-
</div>
|
| 95 |
-
<div className="flex flex-col gap-1 pr-4">
|
| 96 |
-
<h3 className="text-base font-bold text-primary">Generate Report</h3>
|
| 97 |
-
<p className="text-sm text-secondary leading-relaxed">Expert clinical summary prepared for your next neurology appointment.</p>
|
| 98 |
-
<div className="mt-2 flex items-center text-[10px] font-black uppercase tracking-widest text-cta group-hover:translate-x-1 transition-transform">
|
| 99 |
-
Start Generation <ChevronRight className="w-3 h-3 ml-1" />
|
| 100 |
-
</div>
|
| 101 |
-
</div>
|
| 102 |
</button>
|
|
|
|
|
|
|
| 103 |
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 111 |
</div>
|
| 112 |
-
<div className="
|
| 113 |
-
<h3 className="text-
|
| 114 |
-
|
| 115 |
-
<
|
| 116 |
-
|
| 117 |
-
|
|
|
|
| 118 |
</div>
|
| 119 |
-
</button>
|
| 120 |
-
</div>
|
| 121 |
-
)}
|
| 122 |
-
|
| 123 |
-
{/* Empty State / Processing */}
|
| 124 |
-
{isProcessing && uiEvents.length === 0 && (
|
| 125 |
-
<div className="flex flex-col items-center justify-center h-full text-center gap-6">
|
| 126 |
-
<div className="relative">
|
| 127 |
-
<div className="w-20 h-20 border-4 border-accent-cyan/10 border-t-accent-cyan rounded-full animate-spin shadow-lg shadow-accent-cyan/10" />
|
| 128 |
-
<Sparkles className="absolute inset-0 m-auto w-8 h-8 text-accent-cyan animate-pulse" />
|
| 129 |
</div>
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 133 |
</div>
|
| 134 |
-
|
| 135 |
-
)}
|
| 136 |
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 142 |
</div>
|
| 143 |
-
)
|
| 144 |
-
</div>
|
| 145 |
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 155 |
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 161 |
</div>
|
| 162 |
-
<
|
| 163 |
-
Analysis based on last 30 days of local records in <strong>mini_minder.db</strong>.
|
| 164 |
-
</p>
|
| 165 |
</div>
|
| 166 |
-
<Sparkles className="w-4 h-4 text-accent-cyan/30" />
|
| 167 |
-
</div>
|
| 168 |
</div>
|
| 169 |
</div>,
|
| 170 |
document.body
|
|
|
|
| 1 |
"use client";
|
| 2 |
|
| 3 |
+
import React, { useState, useEffect, useRef } from "react";
|
| 4 |
import { createPortal } from "react-dom";
|
| 5 |
+
import {
|
| 6 |
+
FileText,
|
| 7 |
+
BarChart3,
|
| 8 |
+
X,
|
| 9 |
+
ChevronRight,
|
| 10 |
+
Sparkles,
|
| 11 |
+
Clock,
|
| 12 |
+
Calendar,
|
| 13 |
+
Plus,
|
| 14 |
} from "lucide-react";
|
| 15 |
import { useLangGraph } from "@/hooks/useLangGraph";
|
| 16 |
import { renderComponent } from "@/registry";
|
|
|
|
| 22 |
status: "loading" | "streaming" | "complete" | "error";
|
| 23 |
}
|
| 24 |
|
| 25 |
+
interface SavedReport {
|
| 26 |
+
id: number;
|
| 27 |
+
report_type: string;
|
| 28 |
+
title: string;
|
| 29 |
+
content: string;
|
| 30 |
+
metadata?: Record<string, unknown>;
|
| 31 |
+
created_at: string;
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
interface ReportsPanelProps {
|
| 35 |
isOpen?: boolean;
|
| 36 |
onToggle?: () => void;
|
| 37 |
}
|
| 38 |
|
| 39 |
+
const API_BASE =
|
| 40 |
+
process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000/api/stream";
|
| 41 |
+
|
| 42 |
+
function formatReportDate(dateStr: string): string {
|
| 43 |
+
try {
|
| 44 |
+
const d = new Date(dateStr + "Z");
|
| 45 |
+
return d.toLocaleDateString("en-GB", {
|
| 46 |
+
day: "numeric",
|
| 47 |
+
month: "short",
|
| 48 |
+
year: "numeric",
|
| 49 |
+
hour: "2-digit",
|
| 50 |
+
minute: "2-digit",
|
| 51 |
+
});
|
| 52 |
+
} catch {
|
| 53 |
+
return dateStr;
|
| 54 |
+
}
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
export function ReportsPanel({
|
| 58 |
+
isOpen: controlledIsOpen,
|
| 59 |
+
onToggle,
|
| 60 |
+
}: ReportsPanelProps = {}) {
|
| 61 |
const [internalIsOpen, setInternalIsOpen] = useState(false);
|
| 62 |
+
const isOpen =
|
| 63 |
+
controlledIsOpen !== undefined ? controlledIsOpen : internalIsOpen;
|
|
|
|
| 64 |
const toggleOpen = onToggle || (() => setInternalIsOpen((prev) => !prev));
|
| 65 |
+
|
| 66 |
const { submit, uiEvents, isProcessing, clear } = useLangGraph<UIEvent>();
|
| 67 |
|
| 68 |
+
// Saved reports from API
|
| 69 |
+
const [savedReports, setSavedReports] = useState<SavedReport[]>([]);
|
| 70 |
+
const [viewingReport, setViewingReport] = useState<SavedReport | null>(null);
|
| 71 |
+
const [loadingReports, setLoadingReports] = useState(false);
|
| 72 |
+
|
| 73 |
+
const reportRef = useRef<HTMLDivElement>(null);
|
| 74 |
+
|
| 75 |
+
// Fetch saved reports when modal opens
|
| 76 |
+
useEffect(() => {
|
| 77 |
+
if (!isOpen) return;
|
| 78 |
+
setLoadingReports(true);
|
| 79 |
+
fetch(`${API_BASE}/reports`)
|
| 80 |
+
.then((r) => r.json())
|
| 81 |
+
.then((data: SavedReport[]) => {
|
| 82 |
+
if (Array.isArray(data)) setSavedReports(data);
|
| 83 |
+
})
|
| 84 |
+
.catch(() => {})
|
| 85 |
+
.finally(() => setLoadingReports(false));
|
| 86 |
+
}, [isOpen]);
|
| 87 |
+
|
| 88 |
+
// Auto-scroll to generated report when it appears
|
| 89 |
+
useEffect(() => {
|
| 90 |
+
if (uiEvents.length > 0 && reportRef.current) {
|
| 91 |
+
reportRef.current.scrollIntoView({ behavior: "smooth", block: "start" });
|
| 92 |
+
}
|
| 93 |
+
}, [uiEvents.length]);
|
| 94 |
+
|
| 95 |
+
// When report generation completes, refresh the saved list
|
| 96 |
+
const lastEvent = uiEvents[uiEvents.length - 1];
|
| 97 |
+
const isComplete =
|
| 98 |
+
lastEvent &&
|
| 99 |
+
!isProcessing &&
|
| 100 |
+
(lastEvent.props as Record<string, unknown>)?.status === "complete";
|
| 101 |
+
|
| 102 |
+
useEffect(() => {
|
| 103 |
+
if (isComplete) {
|
| 104 |
+
fetch(`${API_BASE}/reports`)
|
| 105 |
+
.then((r) => r.json())
|
| 106 |
+
.then((data: SavedReport[]) => {
|
| 107 |
+
if (Array.isArray(data)) setSavedReports(data);
|
| 108 |
+
})
|
| 109 |
+
.catch(() => {});
|
| 110 |
+
}
|
| 111 |
+
}, [isComplete]);
|
| 112 |
+
|
| 113 |
if (!isOpen) {
|
| 114 |
return (
|
| 115 |
<button
|
| 116 |
onClick={() => toggleOpen()}
|
| 117 |
className="flex flex-col items-center gap-1 p-2 text-gray-400 hover:text-white transition-all"
|
| 118 |
+
title="My Reports"
|
| 119 |
>
|
| 120 |
<BarChart3 className="w-5 h-5" />
|
| 121 |
<span className="text-[10px]">Reports</span>
|
|
|
|
| 123 |
);
|
| 124 |
}
|
| 125 |
|
| 126 |
+
// Are we viewing a past report (not a live-generated one)?
|
| 127 |
+
const showingSavedReport = viewingReport !== null && uiEvents.length === 0;
|
| 128 |
+
const showingGenerated = uiEvents.length > 0;
|
| 129 |
+
|
| 130 |
return (
|
| 131 |
<>
|
| 132 |
+
<button onClick={() => toggleOpen()} className="btn btn-secondary p-2">
|
|
|
|
|
|
|
|
|
|
| 133 |
<BarChart3 className="w-5 h-5" />
|
| 134 |
</button>
|
| 135 |
|
| 136 |
{createPortal(
|
| 137 |
+
<div
|
| 138 |
className="modal-overlay"
|
| 139 |
+
onClick={(e) => {
|
| 140 |
+
if (e.target === e.currentTarget) toggleOpen();
|
| 141 |
+
}}
|
| 142 |
>
|
| 143 |
+
<div className="modal-fullscreen p-0">
|
| 144 |
+
{/* Header */}
|
| 145 |
+
<div className="px-6 py-4 border-b border-surface-overlay flex items-center justify-between bg-surface-subtle"
|
| 146 |
+
style={{ flexShrink: 0 }}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 147 |
>
|
| 148 |
+
<div className="flex items-center gap-3">
|
| 149 |
+
<div
|
| 150 |
+
className="w-9 h-9 rounded-lg flex items-center justify-center shadow-md"
|
| 151 |
+
style={{
|
| 152 |
+
background: "var(--color-cta)",
|
| 153 |
+
boxShadow: "0 2px 8px rgba(179, 156, 208, 0.3)",
|
| 154 |
+
}}
|
| 155 |
+
>
|
| 156 |
+
<FileText className="w-5 h-5" style={{ color: "#1a1a1a" }} />
|
| 157 |
+
</div>
|
| 158 |
+
<div>
|
| 159 |
+
<h3
|
| 160 |
+
style={{
|
| 161 |
+
fontSize: 20,
|
| 162 |
+
fontWeight: 700,
|
| 163 |
+
color: "var(--color-text-primary)",
|
| 164 |
+
margin: 0,
|
| 165 |
+
letterSpacing: "-0.01em",
|
| 166 |
+
}}
|
| 167 |
+
>
|
| 168 |
+
My Reports
|
| 169 |
+
</h3>
|
| 170 |
+
<p
|
| 171 |
+
style={{
|
| 172 |
+
fontSize: 11,
|
| 173 |
+
color: "var(--color-text-muted)",
|
| 174 |
+
textTransform: "uppercase",
|
| 175 |
+
letterSpacing: "0.1em",
|
| 176 |
+
fontWeight: 500,
|
| 177 |
+
margin: 0,
|
| 178 |
+
}}
|
| 179 |
+
>
|
| 180 |
+
Clinical Summaries & Trends
|
| 181 |
+
</p>
|
| 182 |
+
</div>
|
| 183 |
+
</div>
|
| 184 |
|
| 185 |
+
<div className="flex items-center gap-3">
|
| 186 |
+
{/* Back button when viewing a single report */}
|
| 187 |
+
{(showingSavedReport || showingGenerated) && (
|
| 188 |
+
<button
|
| 189 |
+
onClick={() => {
|
| 190 |
+
setViewingReport(null);
|
| 191 |
+
clear();
|
| 192 |
+
}}
|
| 193 |
+
className="px-3 py-1.5 text-xs font-bold text-cta border border-cta/30 rounded-lg hover:bg-cta/10 transition-colors"
|
| 194 |
+
>
|
| 195 |
+
← All Reports
|
| 196 |
+
</button>
|
| 197 |
+
)}
|
| 198 |
<button
|
| 199 |
+
onClick={() => toggleOpen()}
|
| 200 |
+
className="btn btn-ghost p-1.5 hover:bg-surface-elevated rounded-lg transition-colors"
|
| 201 |
>
|
| 202 |
+
<X className="w-5 h-5 text-secondary" />
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 203 |
</button>
|
| 204 |
+
</div>
|
| 205 |
+
</div>
|
| 206 |
|
| 207 |
+
{/* Content */}
|
| 208 |
+
<div
|
| 209 |
+
className="flex-1 overflow-y-auto custom-scrollbar"
|
| 210 |
+
style={{
|
| 211 |
+
background: "rgba(18, 18, 18, 0.5)",
|
| 212 |
+
minHeight: 0,
|
| 213 |
+
}}
|
| 214 |
+
>
|
| 215 |
+
{/* === STATE: Processing / Spinner === */}
|
| 216 |
+
{isProcessing && uiEvents.length === 0 && (
|
| 217 |
+
<div className="flex flex-col items-center justify-center h-full text-center gap-6">
|
| 218 |
+
<div className="relative">
|
| 219 |
+
<div className="w-20 h-20 border-4 border-accent-cyan/10 border-t-accent-cyan rounded-full animate-spin shadow-lg shadow-accent-cyan/10" />
|
| 220 |
+
<Sparkles className="absolute inset-0 m-auto w-8 h-8 text-accent-cyan animate-pulse" />
|
| 221 |
</div>
|
| 222 |
+
<div className="space-y-2">
|
| 223 |
+
<h3 className="text-primary font-bold tracking-tight text-lg">
|
| 224 |
+
AI Analyst Working
|
| 225 |
+
</h3>
|
| 226 |
+
<p className="text-[10px] text-muted font-bold uppercase tracking-widest">
|
| 227 |
+
Correlating clinical data...
|
| 228 |
+
</p>
|
| 229 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 230 |
</div>
|
| 231 |
+
)}
|
| 232 |
+
|
| 233 |
+
{/* === STATE: Viewing a generated report (live) === */}
|
| 234 |
+
{showingGenerated && (
|
| 235 |
+
<div className="p-6 max-w-3xl mx-auto" ref={reportRef}>
|
| 236 |
+
<div className="space-y-6">
|
| 237 |
+
{uiEvents.map((ui) => (
|
| 238 |
+
<div
|
| 239 |
+
key={ui.id}
|
| 240 |
+
className="animate-in fade-in slide-in-from-bottom-8 duration-700"
|
| 241 |
+
>
|
| 242 |
+
{renderComponent(ui.name, ui.props)}
|
| 243 |
+
</div>
|
| 244 |
+
))}
|
| 245 |
+
</div>
|
| 246 |
</div>
|
| 247 |
+
)}
|
|
|
|
| 248 |
|
| 249 |
+
{/* === STATE: Viewing a saved report (from history) === */}
|
| 250 |
+
{showingSavedReport && (
|
| 251 |
+
<div className="p-6 max-w-3xl mx-auto">
|
| 252 |
+
{renderComponent("ReportPreview", {
|
| 253 |
+
id: viewingReport!.id,
|
| 254 |
+
title: viewingReport!.title,
|
| 255 |
+
content: viewingReport!.content,
|
| 256 |
+
status: "complete",
|
| 257 |
+
doctorName: (viewingReport!.metadata as Record<string, string>)
|
| 258 |
+
?.doctorName,
|
| 259 |
+
doctorEmail: (viewingReport!.metadata as Record<string, string>)
|
| 260 |
+
?.doctorEmail,
|
| 261 |
+
})}
|
| 262 |
</div>
|
| 263 |
+
)}
|
|
|
|
| 264 |
|
| 265 |
+
{/* === STATE: Reports list (default view) === */}
|
| 266 |
+
{!isProcessing && !showingGenerated && !showingSavedReport && (
|
| 267 |
+
<div className="p-6 space-y-8">
|
| 268 |
+
{/* Action cards */}
|
| 269 |
+
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 max-w-3xl mx-auto animate-in fade-in slide-in-from-bottom-4 duration-500">
|
| 270 |
+
<button
|
| 271 |
+
onClick={() => submit({ type: "report" })}
|
| 272 |
+
className="group relative flex flex-row items-center gap-6 p-6 bg-surface-subtle/50 border border-surface-overlay rounded-2xl transition-all hover:bg-surface-elevated hover:border-cta/40 hover:shadow-2xl hover:shadow-cta/5 overflow-hidden text-left"
|
| 273 |
+
>
|
| 274 |
+
<div className="absolute top-0 right-0 w-32 h-32 bg-cta/5 rounded-full -mr-16 -mt-16 transition-transform group-hover:scale-150 duration-700 blur-2xl" />
|
| 275 |
+
<div className="w-14 h-14 bg-cta/10 text-cta rounded-xl flex items-center justify-center shrink-0 border border-cta/20 group-hover:scale-110 group-hover:bg-cta group-hover:text-black transition-all shadow-lg shadow-cta/10">
|
| 276 |
+
<Plus className="w-7 h-7" />
|
| 277 |
+
</div>
|
| 278 |
+
<div className="flex flex-col gap-1 pr-4">
|
| 279 |
+
<h3 className="text-base font-bold text-primary">
|
| 280 |
+
New Report
|
| 281 |
+
</h3>
|
| 282 |
+
<p className="text-sm text-secondary leading-relaxed">
|
| 283 |
+
Generate a clinical summary for your next appointment.
|
| 284 |
+
</p>
|
| 285 |
+
<div className="mt-2 flex items-center text-[10px] font-black uppercase tracking-widest text-cta group-hover:translate-x-1 transition-transform">
|
| 286 |
+
Start Generation{" "}
|
| 287 |
+
<ChevronRight className="w-3 h-3 ml-1" />
|
| 288 |
+
</div>
|
| 289 |
+
</div>
|
| 290 |
+
</button>
|
| 291 |
|
| 292 |
+
<button
|
| 293 |
+
onClick={() => submit({ type: "trends" })}
|
| 294 |
+
className="group relative flex flex-row items-center gap-6 p-6 bg-surface-subtle/50 border border-surface-overlay rounded-2xl transition-all hover:bg-surface-elevated hover:border-accent-cyan/40 hover:shadow-2xl hover:shadow-accent-cyan/5 overflow-hidden text-left"
|
| 295 |
+
>
|
| 296 |
+
<div className="absolute top-0 right-0 w-32 h-32 bg-accent-cyan/5 rounded-full -mr-16 -mt-16 transition-transform group-hover:scale-150 duration-700 blur-2xl" />
|
| 297 |
+
<div className="w-14 h-14 bg-accent-cyan/10 text-accent-cyan rounded-xl flex items-center justify-center shrink-0 border border-accent-cyan/20 group-hover:scale-110 group-hover:bg-accent-cyan group-hover:text-black transition-all shadow-lg shadow-accent-cyan/10">
|
| 298 |
+
<BarChart3 className="w-7 h-7" />
|
| 299 |
+
</div>
|
| 300 |
+
<div className="flex flex-col gap-1 pr-4">
|
| 301 |
+
<h3 className="text-base font-bold text-primary">
|
| 302 |
+
Analyse Trends
|
| 303 |
+
</h3>
|
| 304 |
+
<p className="text-sm text-secondary leading-relaxed">
|
| 305 |
+
Uncover hidden patterns and triggers in your headache
|
| 306 |
+
diary.
|
| 307 |
+
</p>
|
| 308 |
+
<div className="mt-2 flex items-center text-[10px] font-black uppercase tracking-widest text-accent-cyan group-hover:translate-x-1 transition-transform">
|
| 309 |
+
View Insights{" "}
|
| 310 |
+
<ChevronRight className="w-3 h-3 ml-1" />
|
| 311 |
+
</div>
|
| 312 |
+
</div>
|
| 313 |
+
</button>
|
| 314 |
+
</div>
|
| 315 |
+
|
| 316 |
+
{/* Past reports list */}
|
| 317 |
+
{loadingReports && (
|
| 318 |
+
<div className="flex items-center justify-center py-12">
|
| 319 |
+
<div className="w-8 h-8 border-2 border-cta/20 border-t-cta rounded-full animate-spin" />
|
| 320 |
+
</div>
|
| 321 |
+
)}
|
| 322 |
+
|
| 323 |
+
{!loadingReports && savedReports.length > 0 && (
|
| 324 |
+
<div className="max-w-3xl mx-auto">
|
| 325 |
+
<div className="flex items-center gap-2 mb-4">
|
| 326 |
+
<Clock
|
| 327 |
+
className="w-4 h-4"
|
| 328 |
+
style={{ color: "var(--color-text-muted)" }}
|
| 329 |
+
/>
|
| 330 |
+
<span
|
| 331 |
+
style={{
|
| 332 |
+
fontSize: 12,
|
| 333 |
+
fontWeight: 900,
|
| 334 |
+
textTransform: "uppercase",
|
| 335 |
+
letterSpacing: "0.15em",
|
| 336 |
+
color: "var(--color-text-muted)",
|
| 337 |
+
}}
|
| 338 |
+
>
|
| 339 |
+
Report History
|
| 340 |
+
</span>
|
| 341 |
+
</div>
|
| 342 |
+
<div className="space-y-2">
|
| 343 |
+
{savedReports.map((report) => (
|
| 344 |
+
<button
|
| 345 |
+
key={report.id}
|
| 346 |
+
onClick={() => setViewingReport(report)}
|
| 347 |
+
className="w-full flex items-center gap-4 p-4 bg-surface-subtle/40 border border-surface-overlay rounded-xl hover:bg-surface-elevated hover:border-cta/20 transition-all group text-left"
|
| 348 |
+
>
|
| 349 |
+
<div className="w-10 h-10 rounded-lg bg-cta/10 text-cta flex items-center justify-center shrink-0 border border-cta/15 group-hover:bg-cta/20 transition-colors">
|
| 350 |
+
<FileText className="w-5 h-5" />
|
| 351 |
+
</div>
|
| 352 |
+
<div className="flex-1 min-w-0">
|
| 353 |
+
<h4 className="text-sm font-bold text-primary truncate">
|
| 354 |
+
{report.title}
|
| 355 |
+
</h4>
|
| 356 |
+
<div className="flex items-center gap-2 mt-0.5">
|
| 357 |
+
<Calendar
|
| 358 |
+
className="w-3 h-3"
|
| 359 |
+
style={{
|
| 360 |
+
color: "var(--color-text-muted)",
|
| 361 |
+
}}
|
| 362 |
+
/>
|
| 363 |
+
<span
|
| 364 |
+
style={{
|
| 365 |
+
fontSize: 11,
|
| 366 |
+
color: "var(--color-text-muted)",
|
| 367 |
+
fontWeight: 500,
|
| 368 |
+
}}
|
| 369 |
+
>
|
| 370 |
+
{formatReportDate(report.created_at)}
|
| 371 |
+
</span>
|
| 372 |
+
<span className="pill pill-lavender text-[9px] font-bold">
|
| 373 |
+
{report.report_type}
|
| 374 |
+
</span>
|
| 375 |
+
</div>
|
| 376 |
+
</div>
|
| 377 |
+
<ChevronRight
|
| 378 |
+
className="w-4 h-4 text-muted group-hover:text-cta group-hover:translate-x-1 transition-all"
|
| 379 |
+
/>
|
| 380 |
+
</button>
|
| 381 |
+
))}
|
| 382 |
+
</div>
|
| 383 |
+
</div>
|
| 384 |
+
)}
|
| 385 |
+
|
| 386 |
+
{!loadingReports && savedReports.length === 0 && (
|
| 387 |
+
<div className="flex flex-col items-center justify-center py-16 text-center gap-4">
|
| 388 |
+
<div className="w-16 h-16 rounded-2xl bg-surface-subtle border border-surface-overlay flex items-center justify-center">
|
| 389 |
+
<FileText
|
| 390 |
+
className="w-8 h-8"
|
| 391 |
+
style={{ color: "var(--color-text-muted)", opacity: 0.4 }}
|
| 392 |
+
/>
|
| 393 |
+
</div>
|
| 394 |
+
<div>
|
| 395 |
+
<p
|
| 396 |
+
style={{
|
| 397 |
+
fontSize: 14,
|
| 398 |
+
fontWeight: 600,
|
| 399 |
+
color: "var(--color-text-secondary)",
|
| 400 |
+
}}
|
| 401 |
+
>
|
| 402 |
+
No reports yet
|
| 403 |
+
</p>
|
| 404 |
+
<p
|
| 405 |
+
style={{
|
| 406 |
+
fontSize: 12,
|
| 407 |
+
color: "var(--color-text-muted)",
|
| 408 |
+
marginTop: 4,
|
| 409 |
+
}}
|
| 410 |
+
>
|
| 411 |
+
Generate your first clinical report above.
|
| 412 |
+
</p>
|
| 413 |
+
</div>
|
| 414 |
+
</div>
|
| 415 |
+
)}
|
| 416 |
+
</div>
|
| 417 |
+
)}
|
| 418 |
+
</div>
|
| 419 |
+
|
| 420 |
+
{/* Footer */}
|
| 421 |
+
<div
|
| 422 |
+
className="px-8 py-4 bg-surface-subtle border-t border-surface-overlay flex items-center justify-between"
|
| 423 |
+
style={{ flexShrink: 0 }}
|
| 424 |
+
>
|
| 425 |
+
<div className="flex items-center gap-4">
|
| 426 |
+
<div className="px-2 py-1 rounded bg-accent-cyan/10 border border-accent-cyan/20 text-accent-cyan text-[9px] font-black uppercase tracking-widest">
|
| 427 |
+
Data Policy
|
| 428 |
+
</div>
|
| 429 |
+
<p className="text-[11px] text-muted font-medium">
|
| 430 |
+
Analysis based on last 30 days of local records in{" "}
|
| 431 |
+
<strong>mini_minder.db</strong>.
|
| 432 |
+
</p>
|
| 433 |
</div>
|
| 434 |
+
<Sparkles className="w-4 h-4 text-accent-cyan/30" />
|
|
|
|
|
|
|
| 435 |
</div>
|
|
|
|
|
|
|
| 436 |
</div>
|
| 437 |
</div>,
|
| 438 |
document.body
|
frontend/src/hooks/useDashboardData.ts
CHANGED
|
@@ -13,10 +13,18 @@ interface AdHocMedication {
|
|
| 13 |
logged_at: string | null;
|
| 14 |
}
|
| 15 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
interface MedicationStats {
|
| 17 |
taken: number;
|
| 18 |
total: number;
|
| 19 |
ad_hoc: AdHocMedication[];
|
|
|
|
| 20 |
}
|
| 21 |
|
| 22 |
interface HeadacheDay {
|
|
@@ -34,7 +42,7 @@ export interface DashboardStats {
|
|
| 34 |
|
| 35 |
const DEFAULT_STATS: DashboardStats = {
|
| 36 |
profile: { display_name: null, onboarding_completed: false },
|
| 37 |
-
medication: { taken: 0, total: 0, ad_hoc: [] },
|
| 38 |
days_active_this_week: 0,
|
| 39 |
headaches: [],
|
| 40 |
};
|
|
|
|
| 13 |
logged_at: string | null;
|
| 14 |
}
|
| 15 |
|
| 16 |
+
interface ScheduledMedication {
|
| 17 |
+
name: string;
|
| 18 |
+
status: "logged" | "pending";
|
| 19 |
+
logged_at: string | null;
|
| 20 |
+
scheduled_window: string;
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
interface MedicationStats {
|
| 24 |
taken: number;
|
| 25 |
total: number;
|
| 26 |
ad_hoc: AdHocMedication[];
|
| 27 |
+
scheduled: ScheduledMedication[];
|
| 28 |
}
|
| 29 |
|
| 30 |
interface HeadacheDay {
|
|
|
|
| 42 |
|
| 43 |
const DEFAULT_STATS: DashboardStats = {
|
| 44 |
profile: { display_name: null, onboarding_completed: false },
|
| 45 |
+
medication: { taken: 0, total: 0, ad_hoc: [], scheduled: [] },
|
| 46 |
days_active_this_week: 0,
|
| 47 |
headaches: [],
|
| 48 |
};
|
frontend/src/hooks/useLangGraph.ts
CHANGED
|
@@ -44,19 +44,21 @@ export function useLangGraph<T extends LangGraphUIEvent = LangGraphUIEvent>({
|
|
| 44 |
const data = chunk.data as Record<string, any>;
|
| 45 |
if (chunk.event === "values" && data?.ui) {
|
| 46 |
const uiMessages = data.ui as any[];
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
|
|
|
|
|
|
| 60 |
}
|
| 61 |
}
|
| 62 |
} catch (error) {
|
|
|
|
| 44 |
const data = chunk.data as Record<string, any>;
|
| 45 |
if (chunk.event === "values" && data?.ui) {
|
| 46 |
const uiMessages = data.ui as any[];
|
| 47 |
+
// Reduce ALL ui messages by id so merged updates (merge=True)
|
| 48 |
+
// replace the previous version of the same component, and
|
| 49 |
+
// multiple components can coexist.
|
| 50 |
+
setUiEvents((prev) => {
|
| 51 |
+
const next = { ...prev };
|
| 52 |
+
for (const msg of uiMessages) {
|
| 53 |
+
next[msg.id] = {
|
| 54 |
+
id: msg.id,
|
| 55 |
+
name: msg.name,
|
| 56 |
+
props: msg.props,
|
| 57 |
+
status: msg.props?.status || "complete",
|
| 58 |
+
} as T;
|
| 59 |
+
}
|
| 60 |
+
return next;
|
| 61 |
+
});
|
| 62 |
}
|
| 63 |
}
|
| 64 |
} catch (error) {
|
frontend/src/registry/MedLog.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
"use client";
|
| 2 |
|
| 3 |
-
import { Pill, Check, Edit3 } from "lucide-react";
|
| 4 |
|
| 5 |
interface MedLogProps {
|
| 6 |
entry_id: string;
|
|
@@ -15,21 +15,26 @@ interface MedLogProps {
|
|
| 15 |
robot_message?: string;
|
| 16 |
}
|
| 17 |
|
| 18 |
-
function
|
| 19 |
-
label,
|
| 20 |
-
value,
|
| 21 |
-
}: {
|
| 22 |
-
label: string;
|
| 23 |
-
value: string | null;
|
| 24 |
-
}) {
|
| 25 |
return (
|
| 26 |
-
<div
|
| 27 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
{label}
|
| 29 |
</span>
|
| 30 |
-
|
| 31 |
-
{value || "Asking…"}
|
| 32 |
-
</span>
|
| 33 |
</div>
|
| 34 |
);
|
| 35 |
}
|
|
@@ -60,129 +65,303 @@ export function MedLog(props: MedLogProps) {
|
|
| 60 |
if (props.taken === null) return null;
|
| 61 |
if (props.taken === false) return "Skipped";
|
| 62 |
if (props.taken_late) return "Taken late";
|
| 63 |
-
return "✓
|
| 64 |
};
|
| 65 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
return (
|
| 67 |
<div
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
}`}
|
| 78 |
>
|
| 79 |
{/* Top accent line */}
|
| 80 |
<div
|
| 81 |
-
|
| 82 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 83 |
/>
|
| 84 |
|
| 85 |
-
{/* Header */}
|
| 86 |
-
<div
|
| 87 |
<div
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
>
|
| 97 |
-
{isComplete ?
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
</div>
|
| 99 |
-
<div
|
| 100 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
{isCapturing && (
|
| 102 |
<>
|
| 103 |
-
<span
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
Logging Medication…
|
| 105 |
</>
|
| 106 |
)}
|
| 107 |
-
{needsConfirmation && "Medication Entry
|
| 108 |
-
{isComplete && "Medication Logged"}
|
| 109 |
-
</
|
| 110 |
{isCapturing && (
|
| 111 |
-
<p
|
| 112 |
Filling in details as you speak
|
| 113 |
</p>
|
| 114 |
)}
|
| 115 |
{isComplete && (
|
| 116 |
-
<p
|
| 117 |
Saved to your record
|
| 118 |
</p>
|
| 119 |
)}
|
| 120 |
</div>
|
| 121 |
</div>
|
| 122 |
|
| 123 |
-
{/*
|
| 124 |
-
<div
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 136 |
</div>
|
| 137 |
|
| 138 |
-
{/*
|
| 139 |
-
{
|
| 140 |
-
<div
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 145 |
</div>
|
| 146 |
)}
|
| 147 |
|
| 148 |
-
{/* Confirmation Buttons — Large touch targets */}
|
| 149 |
{needsConfirmation && (
|
| 150 |
-
<div
|
| 151 |
<button
|
| 152 |
onClick={handleConfirm}
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 158 |
>
|
| 159 |
-
<Check
|
| 160 |
Looks Right
|
| 161 |
</button>
|
| 162 |
<button
|
| 163 |
onClick={handleCorrect}
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 170 |
>
|
| 171 |
-
<Edit3
|
| 172 |
Make Changes
|
| 173 |
</button>
|
| 174 |
</div>
|
| 175 |
)}
|
| 176 |
|
| 177 |
-
{/* Robot Message */}
|
| 178 |
-
{props.robot_message && (
|
| 179 |
-
<div className="mt-4 px-4 py-3 rounded-xl bg-cta/10 border border-cta/20">
|
| 180 |
-
<p className="text-[13px] text-cta font-medium">{props.robot_message}</p>
|
| 181 |
-
</div>
|
| 182 |
-
)}
|
| 183 |
-
|
| 184 |
{/* Disclaimer */}
|
| 185 |
-
<p
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 186 |
This records what you tell me, not verified intake.
|
| 187 |
</p>
|
| 188 |
</div>
|
|
|
|
| 1 |
"use client";
|
| 2 |
|
| 3 |
+
import { Pill, Check, Edit3, Clock, StickyNote } from "lucide-react";
|
| 4 |
|
| 5 |
interface MedLogProps {
|
| 6 |
entry_id: string;
|
|
|
|
| 15 |
robot_message?: string;
|
| 16 |
}
|
| 17 |
|
| 18 |
+
function FilledFieldPill({ label, value }: { label: string; value: string }) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
return (
|
| 20 |
+
<div
|
| 21 |
+
style={{
|
| 22 |
+
display: "inline-flex",
|
| 23 |
+
alignItems: "center",
|
| 24 |
+
gap: 8,
|
| 25 |
+
padding: "10px 20px",
|
| 26 |
+
borderRadius: 999,
|
| 27 |
+
background: "rgba(168, 218, 220, 0.1)",
|
| 28 |
+
border: "1px solid rgba(168, 218, 220, 0.2)",
|
| 29 |
+
fontSize: 18,
|
| 30 |
+
color: "var(--color-accent-cyan)",
|
| 31 |
+
fontWeight: 500,
|
| 32 |
+
}}
|
| 33 |
+
>
|
| 34 |
+
<span style={{ opacity: 0.6, textTransform: "uppercase", fontSize: 13, letterSpacing: 1 }}>
|
| 35 |
{label}
|
| 36 |
</span>
|
| 37 |
+
{value}
|
|
|
|
|
|
|
| 38 |
</div>
|
| 39 |
);
|
| 40 |
}
|
|
|
|
| 65 |
if (props.taken === null) return null;
|
| 66 |
if (props.taken === false) return "Skipped";
|
| 67 |
if (props.taken_late) return "Taken late";
|
| 68 |
+
return "✓ On time";
|
| 69 |
};
|
| 70 |
|
| 71 |
+
// Build filled field pills
|
| 72 |
+
const filledPills: { label: string; value: string }[] = [];
|
| 73 |
+
if (props.medication_name) filledPills.push({ label: "Medication", value: props.medication_name });
|
| 74 |
+
if (props.dose) filledPills.push({ label: "Dose", value: props.dose });
|
| 75 |
+
if (props.actual_time) filledPills.push({ label: "Time", value: props.actual_time });
|
| 76 |
+
if (props.taken !== null) filledPills.push({ label: "Status", value: takenDisplay() || "" });
|
| 77 |
+
if (props.notes) filledPills.push({ label: "Notes", value: props.notes });
|
| 78 |
+
|
| 79 |
return (
|
| 80 |
<div
|
| 81 |
+
style={{
|
| 82 |
+
display: "flex",
|
| 83 |
+
flexDirection: "column",
|
| 84 |
+
height: "100%",
|
| 85 |
+
width: "100%",
|
| 86 |
+
padding: "40px 48px",
|
| 87 |
+
position: "relative",
|
| 88 |
+
overflow: "hidden",
|
| 89 |
+
}}
|
|
|
|
| 90 |
>
|
| 91 |
{/* Top accent line */}
|
| 92 |
<div
|
| 93 |
+
style={{
|
| 94 |
+
position: "absolute",
|
| 95 |
+
top: 0,
|
| 96 |
+
left: "10%",
|
| 97 |
+
right: "10%",
|
| 98 |
+
height: 3,
|
| 99 |
+
background: isComplete
|
| 100 |
+
? "linear-gradient(to right, transparent, var(--color-success), transparent)"
|
| 101 |
+
: isCapturing
|
| 102 |
+
? "linear-gradient(to right, transparent, var(--color-accent-pink), transparent)"
|
| 103 |
+
: "linear-gradient(to right, transparent, var(--color-cta), transparent)",
|
| 104 |
+
opacity: 0.8,
|
| 105 |
+
}}
|
| 106 |
/>
|
| 107 |
|
| 108 |
+
{/* Header row */}
|
| 109 |
+
<div style={{ display: "flex", alignItems: "center", gap: 16, marginBottom: 32 }}>
|
| 110 |
<div
|
| 111 |
+
style={{
|
| 112 |
+
width: 56,
|
| 113 |
+
height: 56,
|
| 114 |
+
borderRadius: 16,
|
| 115 |
+
display: "flex",
|
| 116 |
+
alignItems: "center",
|
| 117 |
+
justifyContent: "center",
|
| 118 |
+
background: isComplete
|
| 119 |
+
? "rgba(125, 211, 168, 0.15)"
|
| 120 |
+
: isCapturing
|
| 121 |
+
? "rgba(255, 193, 204, 0.15)"
|
| 122 |
+
: "rgba(179, 156, 208, 0.15)",
|
| 123 |
+
border: `1px solid ${
|
| 124 |
+
isComplete
|
| 125 |
+
? "rgba(125, 211, 168, 0.3)"
|
| 126 |
+
: isCapturing
|
| 127 |
+
? "rgba(255, 193, 204, 0.3)"
|
| 128 |
+
: "rgba(179, 156, 208, 0.3)"
|
| 129 |
+
}`,
|
| 130 |
+
color: isComplete
|
| 131 |
+
? "var(--color-success)"
|
| 132 |
+
: isCapturing
|
| 133 |
+
? "var(--color-accent-pink)"
|
| 134 |
+
: "var(--color-cta)",
|
| 135 |
+
}}
|
| 136 |
>
|
| 137 |
+
{isComplete ? (
|
| 138 |
+
<Check style={{ width: 28, height: 28 }} />
|
| 139 |
+
) : (
|
| 140 |
+
<Pill style={{ width: 28, height: 28 }} />
|
| 141 |
+
)}
|
| 142 |
</div>
|
| 143 |
+
<div>
|
| 144 |
+
<h2
|
| 145 |
+
style={{
|
| 146 |
+
fontSize: 28,
|
| 147 |
+
fontWeight: 700,
|
| 148 |
+
color: "var(--color-text-primary)",
|
| 149 |
+
margin: 0,
|
| 150 |
+
lineHeight: 1.2,
|
| 151 |
+
}}
|
| 152 |
+
>
|
| 153 |
{isCapturing && (
|
| 154 |
<>
|
| 155 |
+
<span
|
| 156 |
+
style={{
|
| 157 |
+
display: "inline-block",
|
| 158 |
+
width: 10,
|
| 159 |
+
height: 10,
|
| 160 |
+
borderRadius: "50%",
|
| 161 |
+
background: "var(--color-accent-pink)",
|
| 162 |
+
marginRight: 12,
|
| 163 |
+
verticalAlign: "middle",
|
| 164 |
+
animation: "pulse 2s infinite",
|
| 165 |
+
}}
|
| 166 |
+
/>
|
| 167 |
Logging Medication…
|
| 168 |
</>
|
| 169 |
)}
|
| 170 |
+
{needsConfirmation && "Confirm Medication Entry"}
|
| 171 |
+
{isComplete && "Medication Logged ✓"}
|
| 172 |
+
</h2>
|
| 173 |
{isCapturing && (
|
| 174 |
+
<p style={{ fontSize: 18, color: "var(--color-text-muted)", marginTop: 4, margin: 0 }}>
|
| 175 |
Filling in details as you speak
|
| 176 |
</p>
|
| 177 |
)}
|
| 178 |
{isComplete && (
|
| 179 |
+
<p style={{ fontSize: 18, color: "var(--color-text-muted)", marginTop: 4, margin: 0 }}>
|
| 180 |
Saved to your record
|
| 181 |
</p>
|
| 182 |
)}
|
| 183 |
</div>
|
| 184 |
</div>
|
| 185 |
|
| 186 |
+
{/* Main content — centered focus area */}
|
| 187 |
+
<div
|
| 188 |
+
style={{
|
| 189 |
+
flex: 1,
|
| 190 |
+
display: "flex",
|
| 191 |
+
flexDirection: "column",
|
| 192 |
+
alignItems: "center",
|
| 193 |
+
justifyContent: "center",
|
| 194 |
+
gap: 24,
|
| 195 |
+
textAlign: "center",
|
| 196 |
+
minHeight: 0,
|
| 197 |
+
}}
|
| 198 |
+
>
|
| 199 |
+
{/* Capturing state: show large medication name or asking prompt */}
|
| 200 |
+
{isCapturing && (
|
| 201 |
+
<>
|
| 202 |
+
<div style={{ color: "var(--color-accent-pink)", marginBottom: 8, opacity: 0.7 }}>
|
| 203 |
+
<Pill style={{ width: 40, height: 40 }} />
|
| 204 |
+
</div>
|
| 205 |
+
<h3
|
| 206 |
+
style={{
|
| 207 |
+
fontSize: 40,
|
| 208 |
+
fontWeight: 600,
|
| 209 |
+
color: "var(--color-text-primary)",
|
| 210 |
+
margin: 0,
|
| 211 |
+
lineHeight: 1.3,
|
| 212 |
+
maxWidth: 600,
|
| 213 |
+
}}
|
| 214 |
+
>
|
| 215 |
+
{props.medication_name || "Which medication?"}
|
| 216 |
+
</h3>
|
| 217 |
+
</>
|
| 218 |
+
)}
|
| 219 |
+
|
| 220 |
+
{/* Complete state */}
|
| 221 |
+
{isComplete && (
|
| 222 |
+
<>
|
| 223 |
+
<div
|
| 224 |
+
style={{
|
| 225 |
+
width: 80,
|
| 226 |
+
height: 80,
|
| 227 |
+
borderRadius: "50%",
|
| 228 |
+
background: "rgba(125, 211, 168, 0.15)",
|
| 229 |
+
border: "2px solid rgba(125, 211, 168, 0.3)",
|
| 230 |
+
display: "flex",
|
| 231 |
+
alignItems: "center",
|
| 232 |
+
justifyContent: "center",
|
| 233 |
+
color: "var(--color-success)",
|
| 234 |
+
}}
|
| 235 |
+
>
|
| 236 |
+
<Check style={{ width: 40, height: 40 }} />
|
| 237 |
+
</div>
|
| 238 |
+
<h3
|
| 239 |
+
style={{
|
| 240 |
+
fontSize: 36,
|
| 241 |
+
fontWeight: 600,
|
| 242 |
+
color: "var(--color-text-primary)",
|
| 243 |
+
margin: 0,
|
| 244 |
+
}}
|
| 245 |
+
>
|
| 246 |
+
Entry saved
|
| 247 |
+
</h3>
|
| 248 |
+
</>
|
| 249 |
+
)}
|
| 250 |
+
|
| 251 |
+
{/* Confirmation state */}
|
| 252 |
+
{needsConfirmation && (
|
| 253 |
+
<h3
|
| 254 |
+
style={{
|
| 255 |
+
fontSize: 36,
|
| 256 |
+
fontWeight: 600,
|
| 257 |
+
color: "var(--color-text-primary)",
|
| 258 |
+
margin: 0,
|
| 259 |
+
}}
|
| 260 |
+
>
|
| 261 |
+
Does this look right?
|
| 262 |
+
</h3>
|
| 263 |
+
)}
|
| 264 |
</div>
|
| 265 |
|
| 266 |
+
{/* Filled fields as pills at the bottom */}
|
| 267 |
+
{filledPills.length > 0 && (
|
| 268 |
+
<div
|
| 269 |
+
style={{
|
| 270 |
+
display: "flex",
|
| 271 |
+
flexWrap: "wrap",
|
| 272 |
+
gap: 10,
|
| 273 |
+
justifyContent: "center",
|
| 274 |
+
marginTop: 24,
|
| 275 |
+
paddingTop: 24,
|
| 276 |
+
borderTop: "1px solid rgba(255,255,255,0.05)",
|
| 277 |
+
}}
|
| 278 |
+
>
|
| 279 |
+
{filledPills.map((pill) => (
|
| 280 |
+
<FilledFieldPill key={pill.label} label={pill.label} value={pill.value} />
|
| 281 |
+
))}
|
| 282 |
+
</div>
|
| 283 |
+
)}
|
| 284 |
+
|
| 285 |
+
{/* Robot message */}
|
| 286 |
+
{props.robot_message && (
|
| 287 |
+
<div
|
| 288 |
+
style={{
|
| 289 |
+
marginTop: 20,
|
| 290 |
+
padding: "16px 24px",
|
| 291 |
+
borderRadius: 16,
|
| 292 |
+
background: "rgba(179, 156, 208, 0.1)",
|
| 293 |
+
border: "1px solid rgba(179, 156, 208, 0.2)",
|
| 294 |
+
textAlign: "center",
|
| 295 |
+
}}
|
| 296 |
+
>
|
| 297 |
+
<p style={{ fontSize: 20, color: "var(--color-cta)", fontWeight: 500, margin: 0 }}>
|
| 298 |
+
{props.robot_message}
|
| 299 |
+
</p>
|
| 300 |
</div>
|
| 301 |
)}
|
| 302 |
|
| 303 |
+
{/* Confirmation Buttons — Large TV-distance touch targets */}
|
| 304 |
{needsConfirmation && (
|
| 305 |
+
<div style={{ display: "flex", gap: 16, marginTop: 24, width: "100%", maxWidth: 500, margin: "24px auto 0" }}>
|
| 306 |
<button
|
| 307 |
onClick={handleConfirm}
|
| 308 |
+
style={{
|
| 309 |
+
flex: 1,
|
| 310 |
+
display: "flex",
|
| 311 |
+
alignItems: "center",
|
| 312 |
+
justifyContent: "center",
|
| 313 |
+
gap: 12,
|
| 314 |
+
padding: "24px 32px",
|
| 315 |
+
borderRadius: 20,
|
| 316 |
+
background: "rgba(125, 211, 168, 0.15)",
|
| 317 |
+
color: "var(--color-success)",
|
| 318 |
+
border: "1px solid rgba(125, 211, 168, 0.3)",
|
| 319 |
+
cursor: "pointer",
|
| 320 |
+
fontSize: 22,
|
| 321 |
+
fontWeight: 700,
|
| 322 |
+
minHeight: 80,
|
| 323 |
+
transition: "all 0.2s ease",
|
| 324 |
+
}}
|
| 325 |
>
|
| 326 |
+
<Check style={{ width: 28, height: 28 }} />
|
| 327 |
Looks Right
|
| 328 |
</button>
|
| 329 |
<button
|
| 330 |
onClick={handleCorrect}
|
| 331 |
+
style={{
|
| 332 |
+
flex: 1,
|
| 333 |
+
display: "flex",
|
| 334 |
+
alignItems: "center",
|
| 335 |
+
justifyContent: "center",
|
| 336 |
+
gap: 12,
|
| 337 |
+
padding: "24px 32px",
|
| 338 |
+
borderRadius: 20,
|
| 339 |
+
background: "rgba(245, 194, 107, 0.15)",
|
| 340 |
+
color: "var(--color-warning)",
|
| 341 |
+
border: "1px solid rgba(245, 194, 107, 0.3)",
|
| 342 |
+
cursor: "pointer",
|
| 343 |
+
fontSize: 22,
|
| 344 |
+
fontWeight: 700,
|
| 345 |
+
minHeight: 80,
|
| 346 |
+
transition: "all 0.2s ease",
|
| 347 |
+
}}
|
| 348 |
>
|
| 349 |
+
<Edit3 style={{ width: 28, height: 28 }} />
|
| 350 |
Make Changes
|
| 351 |
</button>
|
| 352 |
</div>
|
| 353 |
)}
|
| 354 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 355 |
{/* Disclaimer */}
|
| 356 |
+
<p
|
| 357 |
+
style={{
|
| 358 |
+
marginTop: 20,
|
| 359 |
+
fontSize: 14,
|
| 360 |
+
color: "var(--color-text-muted)",
|
| 361 |
+
textAlign: "center",
|
| 362 |
+
fontStyle: "italic",
|
| 363 |
+
}}
|
| 364 |
+
>
|
| 365 |
This records what you tell me, not verified intake.
|
| 366 |
</p>
|
| 367 |
</div>
|
frontend/src/registry/MyMedsList.tsx
CHANGED
|
@@ -28,103 +28,242 @@ export function MyMedsList(props: MyMedsListProps) {
|
|
| 28 |
|
| 29 |
return (
|
| 30 |
<div
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
>
|
| 36 |
{/* Top accent line */}
|
| 37 |
-
<div
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
|
| 39 |
{/* Header */}
|
| 40 |
-
<div
|
| 41 |
-
<div
|
| 42 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
</div>
|
| 44 |
-
<div
|
| 45 |
-
<
|
| 46 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
{props.count} medication{props.count !== 1 ? "s" : ""}
|
| 48 |
</p>
|
| 49 |
</div>
|
| 50 |
</div>
|
| 51 |
|
| 52 |
{/* Medication List */}
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
<
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
</span>
|
| 82 |
-
{med.dose && (
|
| 83 |
-
<span className="text-[11px] text-muted">{med.dose}</span>
|
| 84 |
-
)}
|
| 85 |
</div>
|
| 86 |
-
<div className="flex items-center gap-2 mt-0.5">
|
| 87 |
-
{med.frequency && (
|
| 88 |
-
<span className="text-[10px] text-muted">{med.frequency}</span>
|
| 89 |
-
)}
|
| 90 |
-
{med.times_of_day && med.times_of_day.length > 0 && (
|
| 91 |
-
<span className="flex items-center gap-0.5 text-[10px] text-muted">
|
| 92 |
-
<Clock className="w-3 h-3" />
|
| 93 |
-
{med.times_of_day.join(", ")}
|
| 94 |
-
</span>
|
| 95 |
-
)}
|
| 96 |
-
</div>
|
| 97 |
-
</div>
|
| 98 |
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 107 |
</span>
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
<span
|
| 113 |
-
|
| 114 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 115 |
</div>
|
| 116 |
-
)}
|
| 117 |
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 124 |
|
| 125 |
{/* Voice hint footer */}
|
| 126 |
-
<div
|
| 127 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 128 |
Say “I took number two” to log a dose
|
| 129 |
</span>
|
| 130 |
</div>
|
|
|
|
| 28 |
|
| 29 |
return (
|
| 30 |
<div
|
| 31 |
+
style={{
|
| 32 |
+
display: "flex",
|
| 33 |
+
flexDirection: "column",
|
| 34 |
+
height: "100%",
|
| 35 |
+
width: "100%",
|
| 36 |
+
padding: "40px 48px",
|
| 37 |
+
position: "relative",
|
| 38 |
+
overflow: "hidden",
|
| 39 |
+
}}
|
| 40 |
>
|
| 41 |
{/* Top accent line */}
|
| 42 |
+
<div
|
| 43 |
+
style={{
|
| 44 |
+
position: "absolute",
|
| 45 |
+
top: 0,
|
| 46 |
+
left: "10%",
|
| 47 |
+
right: "10%",
|
| 48 |
+
height: 3,
|
| 49 |
+
background: "linear-gradient(to right, transparent, var(--color-cta), transparent)",
|
| 50 |
+
opacity: 0.8,
|
| 51 |
+
}}
|
| 52 |
+
/>
|
| 53 |
|
| 54 |
{/* Header */}
|
| 55 |
+
<div style={{ display: "flex", alignItems: "center", gap: 16, marginBottom: 32, paddingBottom: 24, borderBottom: "1px solid rgba(255,255,255,0.05)" }}>
|
| 56 |
+
<div
|
| 57 |
+
style={{
|
| 58 |
+
width: 56,
|
| 59 |
+
height: 56,
|
| 60 |
+
borderRadius: 16,
|
| 61 |
+
display: "flex",
|
| 62 |
+
alignItems: "center",
|
| 63 |
+
justifyContent: "center",
|
| 64 |
+
background: "rgba(179, 156, 208, 0.15)",
|
| 65 |
+
border: "1px solid rgba(179, 156, 208, 0.3)",
|
| 66 |
+
color: "var(--color-cta)",
|
| 67 |
+
}}
|
| 68 |
+
>
|
| 69 |
+
<Pill style={{ width: 28, height: 28 }} />
|
| 70 |
</div>
|
| 71 |
+
<div>
|
| 72 |
+
<h2
|
| 73 |
+
style={{
|
| 74 |
+
fontSize: 28,
|
| 75 |
+
fontWeight: 700,
|
| 76 |
+
color: "var(--color-text-primary)",
|
| 77 |
+
margin: 0,
|
| 78 |
+
lineHeight: 1.2,
|
| 79 |
+
}}
|
| 80 |
+
>
|
| 81 |
+
My Medications
|
| 82 |
+
</h2>
|
| 83 |
+
<p style={{ fontSize: 18, color: "var(--color-text-muted)", marginTop: 4, margin: 0 }}>
|
| 84 |
{props.count} medication{props.count !== 1 ? "s" : ""}
|
| 85 |
</p>
|
| 86 |
</div>
|
| 87 |
</div>
|
| 88 |
|
| 89 |
{/* Medication List */}
|
| 90 |
+
<div style={{ flex: 1, overflow: "auto" }}>
|
| 91 |
+
{props.medications.length === 0 ? (
|
| 92 |
+
<div
|
| 93 |
+
style={{
|
| 94 |
+
display: "flex",
|
| 95 |
+
flexDirection: "column",
|
| 96 |
+
alignItems: "center",
|
| 97 |
+
justifyContent: "center",
|
| 98 |
+
height: "100%",
|
| 99 |
+
gap: 16,
|
| 100 |
+
}}
|
| 101 |
+
>
|
| 102 |
+
<Pill style={{ width: 48, height: 48, color: "var(--color-text-muted)", opacity: 0.5 }} />
|
| 103 |
+
<p style={{ fontSize: 22, color: "var(--color-text-muted)" }}>No medications set up yet</p>
|
| 104 |
+
</div>
|
| 105 |
+
) : (
|
| 106 |
+
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
| 107 |
+
{props.medications.map((med, i) => {
|
| 108 |
+
const isTaken = med.status === "taken";
|
| 109 |
+
return (
|
| 110 |
+
<button
|
| 111 |
+
key={med.id || i}
|
| 112 |
+
onClick={() => handleTap(i, med.medication_name)}
|
| 113 |
+
style={{
|
| 114 |
+
width: "100%",
|
| 115 |
+
display: "flex",
|
| 116 |
+
alignItems: "center",
|
| 117 |
+
gap: 20,
|
| 118 |
+
padding: "20px 24px",
|
| 119 |
+
borderRadius: 20,
|
| 120 |
+
background: "rgba(36, 36, 36, 0.6)",
|
| 121 |
+
border: "1px solid rgba(255,255,255,0.08)",
|
| 122 |
+
cursor: "pointer",
|
| 123 |
+
textAlign: "left",
|
| 124 |
+
transition: "all 0.2s ease",
|
| 125 |
+
color: "inherit",
|
| 126 |
+
fontFamily: "inherit",
|
| 127 |
+
}}
|
| 128 |
+
>
|
| 129 |
+
{/* Number badge */}
|
| 130 |
+
<div
|
| 131 |
+
style={{
|
| 132 |
+
width: 48,
|
| 133 |
+
height: 48,
|
| 134 |
+
borderRadius: 14,
|
| 135 |
+
background: "rgba(179, 156, 208, 0.15)",
|
| 136 |
+
border: "1px solid rgba(179, 156, 208, 0.25)",
|
| 137 |
+
display: "flex",
|
| 138 |
+
alignItems: "center",
|
| 139 |
+
justifyContent: "center",
|
| 140 |
+
flexShrink: 0,
|
| 141 |
+
}}
|
| 142 |
+
>
|
| 143 |
+
<span style={{ fontSize: 22, fontWeight: 700, color: "var(--color-cta)" }}>
|
| 144 |
+
{i + 1}
|
| 145 |
</span>
|
|
|
|
|
|
|
|
|
|
| 146 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 147 |
|
| 148 |
+
{/* Details */}
|
| 149 |
+
<div style={{ flex: 1, minWidth: 0 }}>
|
| 150 |
+
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
| 151 |
+
<span
|
| 152 |
+
style={{
|
| 153 |
+
fontSize: 22,
|
| 154 |
+
fontWeight: 600,
|
| 155 |
+
color: "var(--color-text-primary)",
|
| 156 |
+
overflow: "hidden",
|
| 157 |
+
textOverflow: "ellipsis",
|
| 158 |
+
whiteSpace: "nowrap",
|
| 159 |
+
}}
|
| 160 |
+
>
|
| 161 |
+
{med.medication_name}
|
| 162 |
+
</span>
|
| 163 |
+
{med.dose && (
|
| 164 |
+
<span style={{ fontSize: 18, color: "var(--color-text-muted)" }}>
|
| 165 |
+
{med.dose}
|
| 166 |
</span>
|
| 167 |
+
)}
|
| 168 |
+
</div>
|
| 169 |
+
<div style={{ display: "flex", alignItems: "center", gap: 12, marginTop: 4 }}>
|
| 170 |
+
{med.frequency && (
|
| 171 |
+
<span style={{ fontSize: 16, color: "var(--color-text-muted)" }}>
|
| 172 |
+
{med.frequency}
|
| 173 |
+
</span>
|
| 174 |
+
)}
|
| 175 |
+
{med.times_of_day && med.times_of_day.length > 0 && (
|
| 176 |
+
<span
|
| 177 |
+
style={{
|
| 178 |
+
display: "flex",
|
| 179 |
+
alignItems: "center",
|
| 180 |
+
gap: 6,
|
| 181 |
+
fontSize: 16,
|
| 182 |
+
color: "var(--color-text-muted)",
|
| 183 |
+
}}
|
| 184 |
+
>
|
| 185 |
+
<Clock style={{ width: 16, height: 16 }} />
|
| 186 |
+
{med.times_of_day.join(", ")}
|
| 187 |
+
</span>
|
| 188 |
+
)}
|
| 189 |
+
</div>
|
| 190 |
</div>
|
|
|
|
| 191 |
|
| 192 |
+
{/* Status indicator */}
|
| 193 |
+
{med.status && (
|
| 194 |
+
<div style={{ flexShrink: 0 }}>
|
| 195 |
+
{isTaken ? (
|
| 196 |
+
<div
|
| 197 |
+
style={{
|
| 198 |
+
display: "flex",
|
| 199 |
+
alignItems: "center",
|
| 200 |
+
gap: 8,
|
| 201 |
+
padding: "8px 16px",
|
| 202 |
+
borderRadius: 999,
|
| 203 |
+
background: "rgba(125, 211, 168, 0.15)",
|
| 204 |
+
color: "var(--color-success)",
|
| 205 |
+
}}
|
| 206 |
+
>
|
| 207 |
+
<Check style={{ width: 20, height: 20 }} />
|
| 208 |
+
<span style={{ fontSize: 16, fontWeight: 600 }}>
|
| 209 |
+
{med.logged_at || "Taken"}
|
| 210 |
+
</span>
|
| 211 |
+
</div>
|
| 212 |
+
) : (
|
| 213 |
+
<div
|
| 214 |
+
style={{
|
| 215 |
+
display: "flex",
|
| 216 |
+
alignItems: "center",
|
| 217 |
+
gap: 8,
|
| 218 |
+
padding: "8px 16px",
|
| 219 |
+
borderRadius: 999,
|
| 220 |
+
background: "rgba(245, 194, 107, 0.15)",
|
| 221 |
+
color: "var(--color-warning)",
|
| 222 |
+
}}
|
| 223 |
+
>
|
| 224 |
+
<Clock style={{ width: 20, height: 20 }} />
|
| 225 |
+
<span style={{ fontSize: 16, fontWeight: 600 }}>Pending</span>
|
| 226 |
+
</div>
|
| 227 |
+
)}
|
| 228 |
+
</div>
|
| 229 |
+
)}
|
| 230 |
+
|
| 231 |
+
<ChevronRight
|
| 232 |
+
style={{
|
| 233 |
+
width: 24,
|
| 234 |
+
height: 24,
|
| 235 |
+
color: "var(--color-text-muted)",
|
| 236 |
+
flexShrink: 0,
|
| 237 |
+
}}
|
| 238 |
+
/>
|
| 239 |
+
</button>
|
| 240 |
+
);
|
| 241 |
+
})}
|
| 242 |
+
</div>
|
| 243 |
+
)}
|
| 244 |
+
</div>
|
| 245 |
|
| 246 |
{/* Voice hint footer */}
|
| 247 |
+
<div
|
| 248 |
+
style={{
|
| 249 |
+
display: "flex",
|
| 250 |
+
alignItems: "center",
|
| 251 |
+
justifyContent: "center",
|
| 252 |
+
gap: 8,
|
| 253 |
+
marginTop: 24,
|
| 254 |
+
paddingTop: 20,
|
| 255 |
+
borderTop: "1px solid rgba(255,255,255,0.05)",
|
| 256 |
+
}}
|
| 257 |
+
>
|
| 258 |
+
<span
|
| 259 |
+
style={{
|
| 260 |
+
fontSize: 18,
|
| 261 |
+
color: "var(--color-text-muted)",
|
| 262 |
+
textTransform: "uppercase",
|
| 263 |
+
letterSpacing: "0.12em",
|
| 264 |
+
fontWeight: 600,
|
| 265 |
+
}}
|
| 266 |
+
>
|
| 267 |
Say “I took number two” to log a dose
|
| 268 |
</span>
|
| 269 |
</div>
|
frontend/src/registry/OnboardingProgress.tsx
CHANGED
|
@@ -58,7 +58,7 @@ export function OnboardingProgress({ currentStep }: OnboardingProgressProps) {
|
|
| 58 |
relative w-20 h-20 rounded-full flex items-center justify-center
|
| 59 |
transition-all duration-500 ease-out
|
| 60 |
${status === "completed"
|
| 61 |
-
? "bg-
|
| 62 |
: status === "current"
|
| 63 |
? "bg-cta text-gray-900 scale-110 shadow-[0_0_30px_rgba(179,156,208,0.5)]"
|
| 64 |
: "bg-surface-overlay text-muted scale-90 opacity-50"
|
|
@@ -82,7 +82,7 @@ export function OnboardingProgress({ currentStep }: OnboardingProgressProps) {
|
|
| 82 |
className={`
|
| 83 |
text-lg font-semibold uppercase tracking-wide
|
| 84 |
transition-all duration-300
|
| 85 |
-
${status === "current" ? "text-cta" : status === "completed" ? "text-
|
| 86 |
`}
|
| 87 |
>
|
| 88 |
{step.label}
|
|
@@ -95,7 +95,7 @@ export function OnboardingProgress({ currentStep }: OnboardingProgressProps) {
|
|
| 95 |
className={`
|
| 96 |
w-16 h-1 mx-4 rounded-full transition-all duration-500
|
| 97 |
${getStepStatus(steps[index + 1].id, currentStep) !== "upcoming"
|
| 98 |
-
? "bg-
|
| 99 |
: "bg-surface-overlay opacity-30"
|
| 100 |
}
|
| 101 |
`}
|
|
|
|
| 58 |
relative w-20 h-20 rounded-full flex items-center justify-center
|
| 59 |
transition-all duration-500 ease-out
|
| 60 |
${status === "completed"
|
| 61 |
+
? "bg-accent-cyan text-gray-900 scale-100"
|
| 62 |
: status === "current"
|
| 63 |
? "bg-cta text-gray-900 scale-110 shadow-[0_0_30px_rgba(179,156,208,0.5)]"
|
| 64 |
: "bg-surface-overlay text-muted scale-90 opacity-50"
|
|
|
|
| 82 |
className={`
|
| 83 |
text-lg font-semibold uppercase tracking-wide
|
| 84 |
transition-all duration-300
|
| 85 |
+
${status === "current" ? "text-cta" : status === "completed" ? "text-accent-cyan" : "text-muted opacity-50"}
|
| 86 |
`}
|
| 87 |
>
|
| 88 |
{step.label}
|
|
|
|
| 95 |
className={`
|
| 96 |
w-16 h-1 mx-4 rounded-full transition-all duration-500
|
| 97 |
${getStepStatus(steps[index + 1].id, currentStep) !== "upcoming"
|
| 98 |
+
? "bg-accent-cyan"
|
| 99 |
: "bg-surface-overlay opacity-30"
|
| 100 |
}
|
| 101 |
`}
|
frontend/src/registry/index.tsx
CHANGED
|
@@ -143,7 +143,9 @@ export function renderComponent(
|
|
| 143 |
}
|
| 144 |
|
| 145 |
// ---- LoadExternalComponent Wrapper ----
|
| 146 |
-
//
|
|
|
|
|
|
|
| 147 |
|
| 148 |
interface LoadComponentProps {
|
| 149 |
message: {
|
|
|
|
| 143 |
}
|
| 144 |
|
| 145 |
// ---- LoadExternalComponent Wrapper ----
|
| 146 |
+
// Reserved for future LangGraph SDK integration (LoadExternalComponent migration).
|
| 147 |
+
// Currently unused — all rendering goes through renderComponent() above.
|
| 148 |
+
// To adopt: pass this as the `components` prop to LoadExternalComponent.
|
| 149 |
|
| 150 |
interface LoadComponentProps {
|
| 151 |
message: {
|
langgraph.json
CHANGED
|
@@ -3,5 +3,8 @@
|
|
| 3 |
"dependencies": ["."],
|
| 4 |
"graphs": {
|
| 5 |
"report_agent": "./src/reachy_mini_conversation_app/langgraph_agent/graph.py:graph"
|
|
|
|
|
|
|
|
|
|
| 6 |
}
|
| 7 |
}
|
|
|
|
| 3 |
"dependencies": ["."],
|
| 4 |
"graphs": {
|
| 5 |
"report_agent": "./src/reachy_mini_conversation_app/langgraph_agent/graph.py:graph"
|
| 6 |
+
},
|
| 7 |
+
"ui": {
|
| 8 |
+
"report_agent": "./src/reachy_mini_conversation_app/langgraph_agent/ui.tsx"
|
| 9 |
}
|
| 10 |
}
|
src/reachy_mini_conversation_app/database.py
CHANGED
|
@@ -143,6 +143,17 @@ _TOKEN_USAGE_INDEXES = [
|
|
| 143 |
"CREATE INDEX IF NOT EXISTS idx_token_timestamp ON token_usage(timestamp)",
|
| 144 |
]
|
| 145 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 146 |
|
| 147 |
class MiniMinderDB:
|
| 148 |
"""SQLite-backed storage for headache diary and medication logs."""
|
|
@@ -172,6 +183,7 @@ class MiniMinderDB:
|
|
| 172 |
conn.execute(_TOKEN_USAGE_SCHEMA)
|
| 173 |
for idx_sql in _TOKEN_USAGE_INDEXES:
|
| 174 |
conn.execute(idx_sql)
|
|
|
|
| 175 |
conn.commit()
|
| 176 |
# Run migrations for existing databases
|
| 177 |
self._migrate_add_preferred_provider(conn)
|
|
@@ -490,6 +502,43 @@ h1 {{ color: #333; }}
|
|
| 490 |
)
|
| 491 |
return cur.lastrowid # type: ignore[return-value]
|
| 492 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 493 |
def get_scheduled_medications(
|
| 494 |
self, active_only: bool = True
|
| 495 |
) -> List[Dict[str, Any]]:
|
|
@@ -561,6 +610,68 @@ h1 {{ color: #333; }}
|
|
| 561 |
"""Deactivate a scheduled medication (soft delete)."""
|
| 562 |
return self.update_scheduled_medication(med_id, {"active": 0})
|
| 563 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 564 |
def get_todays_medication_status(self) -> Dict[str, Any]:
|
| 565 |
"""Get today's medication status for dashboard and MedStatus GenUI.
|
| 566 |
|
|
@@ -744,6 +855,66 @@ h1 {{ color: #333; }}
|
|
| 744 |
).fetchall()
|
| 745 |
return {r["medication_name"]: r["redacted_as"] for r in rows}
|
| 746 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 747 |
def close(self) -> None:
|
| 748 |
"""Close the database connection."""
|
| 749 |
if self._conn:
|
|
|
|
| 143 |
"CREATE INDEX IF NOT EXISTS idx_token_timestamp ON token_usage(timestamp)",
|
| 144 |
]
|
| 145 |
|
| 146 |
+
_GENERATED_REPORTS_SCHEMA = """
|
| 147 |
+
CREATE TABLE IF NOT EXISTS generated_reports (
|
| 148 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 149 |
+
report_type TEXT NOT NULL DEFAULT 'clinical',
|
| 150 |
+
title TEXT NOT NULL,
|
| 151 |
+
content TEXT NOT NULL,
|
| 152 |
+
metadata TEXT,
|
| 153 |
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
| 154 |
+
)
|
| 155 |
+
"""
|
| 156 |
+
|
| 157 |
|
| 158 |
class MiniMinderDB:
|
| 159 |
"""SQLite-backed storage for headache diary and medication logs."""
|
|
|
|
| 183 |
conn.execute(_TOKEN_USAGE_SCHEMA)
|
| 184 |
for idx_sql in _TOKEN_USAGE_INDEXES:
|
| 185 |
conn.execute(idx_sql)
|
| 186 |
+
conn.execute(_GENERATED_REPORTS_SCHEMA)
|
| 187 |
conn.commit()
|
| 188 |
# Run migrations for existing databases
|
| 189 |
self._migrate_add_preferred_provider(conn)
|
|
|
|
| 502 |
)
|
| 503 |
return cur.lastrowid # type: ignore[return-value]
|
| 504 |
|
| 505 |
+
def upsert_scheduled_medication(self, data: Dict[str, Any]) -> tuple[int, bool]:
|
| 506 |
+
"""Insert or update a scheduled medication by name.
|
| 507 |
+
|
| 508 |
+
If an active medication with the same name (case-insensitive) already
|
| 509 |
+
exists, merge the new fields into it. Otherwise insert a new row.
|
| 510 |
+
|
| 511 |
+
Returns:
|
| 512 |
+
(med_id, is_new) — the row id and whether a new row was created.
|
| 513 |
+
"""
|
| 514 |
+
medication_name = data.get("medication_name", "")
|
| 515 |
+
conn = self._get_conn()
|
| 516 |
+
row = conn.execute(
|
| 517 |
+
"SELECT id FROM scheduled_medications "
|
| 518 |
+
"WHERE LOWER(medication_name) = LOWER(?) AND active = 1",
|
| 519 |
+
(medication_name,),
|
| 520 |
+
).fetchone()
|
| 521 |
+
|
| 522 |
+
if row:
|
| 523 |
+
existing_id: int = row["id"]
|
| 524 |
+
# Only merge fields that are non-None in the incoming data
|
| 525 |
+
updates = {
|
| 526 |
+
k: v
|
| 527 |
+
for k, v in data.items()
|
| 528 |
+
if v is not None and k != "medication_name"
|
| 529 |
+
}
|
| 530 |
+
if updates:
|
| 531 |
+
self.update_scheduled_medication(existing_id, updates)
|
| 532 |
+
logger.info(
|
| 533 |
+
"Upserted (updated) scheduled medication id=%d: %s",
|
| 534 |
+
existing_id,
|
| 535 |
+
medication_name,
|
| 536 |
+
)
|
| 537 |
+
return existing_id, False
|
| 538 |
+
|
| 539 |
+
new_id = self.add_scheduled_medication(data)
|
| 540 |
+
return new_id, True
|
| 541 |
+
|
| 542 |
def get_scheduled_medications(
|
| 543 |
self, active_only: bool = True
|
| 544 |
) -> List[Dict[str, Any]]:
|
|
|
|
| 610 |
"""Deactivate a scheduled medication (soft delete)."""
|
| 611 |
return self.update_scheduled_medication(med_id, {"active": 0})
|
| 612 |
|
| 613 |
+
def deduplicate_scheduled_medications(self) -> int:
|
| 614 |
+
"""Merge duplicate active scheduled medications by name.
|
| 615 |
+
|
| 616 |
+
For each medication name, keeps the most complete record (the one
|
| 617 |
+
with the most non-NULL fields) and deactivates the rest. Fields
|
| 618 |
+
from lesser duplicates are merged into the winner when the winner
|
| 619 |
+
has NULL for those fields.
|
| 620 |
+
|
| 621 |
+
Returns:
|
| 622 |
+
Number of duplicate rows deactivated.
|
| 623 |
+
"""
|
| 624 |
+
meds = self.get_scheduled_medications(active_only=True)
|
| 625 |
+
# Group by lower-case name
|
| 626 |
+
groups: Dict[str, list] = {}
|
| 627 |
+
for m in meds:
|
| 628 |
+
key = (m.get("medication_name") or "").lower()
|
| 629 |
+
groups.setdefault(key, []).append(m)
|
| 630 |
+
|
| 631 |
+
deactivated = 0
|
| 632 |
+
merge_fields = [
|
| 633 |
+
"dose",
|
| 634 |
+
"frequency",
|
| 635 |
+
"times_of_day",
|
| 636 |
+
"reminder_enabled",
|
| 637 |
+
"reminder_times",
|
| 638 |
+
"notes",
|
| 639 |
+
]
|
| 640 |
+
|
| 641 |
+
for _name, dupes in groups.items():
|
| 642 |
+
if len(dupes) <= 1:
|
| 643 |
+
continue
|
| 644 |
+
|
| 645 |
+
# Score each by number of non-None fields
|
| 646 |
+
def completeness(m: Dict[str, Any]) -> int:
|
| 647 |
+
return sum(1 for f in merge_fields if m.get(f) is not None)
|
| 648 |
+
|
| 649 |
+
dupes.sort(key=completeness, reverse=True)
|
| 650 |
+
winner = dupes[0]
|
| 651 |
+
|
| 652 |
+
# Merge missing fields from duplicates into winner
|
| 653 |
+
merged_updates: Dict[str, Any] = {}
|
| 654 |
+
for dup in dupes[1:]:
|
| 655 |
+
for f in merge_fields:
|
| 656 |
+
if winner.get(f) is None and dup.get(f) is not None:
|
| 657 |
+
merged_updates[f] = dup[f]
|
| 658 |
+
winner[f] = dup[f] # update in-memory too
|
| 659 |
+
|
| 660 |
+
if merged_updates:
|
| 661 |
+
self.update_scheduled_medication(winner["id"], merged_updates)
|
| 662 |
+
|
| 663 |
+
# Deactivate losers
|
| 664 |
+
for dup in dupes[1:]:
|
| 665 |
+
self.deactivate_scheduled_medication(dup["id"])
|
| 666 |
+
deactivated += 1
|
| 667 |
+
|
| 668 |
+
if deactivated:
|
| 669 |
+
logger.info(
|
| 670 |
+
"Deduplicated scheduled medications: deactivated %d duplicates",
|
| 671 |
+
deactivated,
|
| 672 |
+
)
|
| 673 |
+
return deactivated
|
| 674 |
+
|
| 675 |
def get_todays_medication_status(self) -> Dict[str, Any]:
|
| 676 |
"""Get today's medication status for dashboard and MedStatus GenUI.
|
| 677 |
|
|
|
|
| 855 |
).fetchall()
|
| 856 |
return {r["medication_name"]: r["redacted_as"] for r in rows}
|
| 857 |
|
| 858 |
+
# -------------------------------------------------------------------------
|
| 859 |
+
# Generated Reports Persistence
|
| 860 |
+
# -------------------------------------------------------------------------
|
| 861 |
+
|
| 862 |
+
def save_report(
|
| 863 |
+
self,
|
| 864 |
+
report_type: str,
|
| 865 |
+
title: str,
|
| 866 |
+
content: str,
|
| 867 |
+
metadata: Optional[Dict[str, Any]] = None,
|
| 868 |
+
) -> int:
|
| 869 |
+
"""Save a generated report and return its id."""
|
| 870 |
+
conn = self._get_conn()
|
| 871 |
+
cursor = conn.execute(
|
| 872 |
+
"""INSERT INTO generated_reports (report_type, title, content, metadata)
|
| 873 |
+
VALUES (?, ?, ?, ?)""",
|
| 874 |
+
(
|
| 875 |
+
report_type,
|
| 876 |
+
title,
|
| 877 |
+
content,
|
| 878 |
+
json.dumps(metadata) if metadata else None,
|
| 879 |
+
),
|
| 880 |
+
)
|
| 881 |
+
conn.commit()
|
| 882 |
+
return cursor.lastrowid # type: ignore[return-value]
|
| 883 |
+
|
| 884 |
+
def get_reports(self, limit: int = 20) -> List[Dict[str, Any]]:
|
| 885 |
+
"""Return the most recent saved reports (newest first)."""
|
| 886 |
+
conn = self._get_conn()
|
| 887 |
+
rows = conn.execute(
|
| 888 |
+
"SELECT * FROM generated_reports ORDER BY created_at DESC LIMIT ?",
|
| 889 |
+
(limit,),
|
| 890 |
+
).fetchall()
|
| 891 |
+
results = []
|
| 892 |
+
for r in rows:
|
| 893 |
+
d = dict(r)
|
| 894 |
+
if d.get("metadata"):
|
| 895 |
+
try:
|
| 896 |
+
d["metadata"] = json.loads(d["metadata"])
|
| 897 |
+
except (json.JSONDecodeError, TypeError):
|
| 898 |
+
pass
|
| 899 |
+
results.append(d)
|
| 900 |
+
return results
|
| 901 |
+
|
| 902 |
+
def get_report(self, report_id: int) -> Optional[Dict[str, Any]]:
|
| 903 |
+
"""Return a single report by id."""
|
| 904 |
+
conn = self._get_conn()
|
| 905 |
+
row = conn.execute(
|
| 906 |
+
"SELECT * FROM generated_reports WHERE id = ?", (report_id,)
|
| 907 |
+
).fetchone()
|
| 908 |
+
if row is None:
|
| 909 |
+
return None
|
| 910 |
+
d = dict(row)
|
| 911 |
+
if d.get("metadata"):
|
| 912 |
+
try:
|
| 913 |
+
d["metadata"] = json.loads(d["metadata"])
|
| 914 |
+
except (json.JSONDecodeError, TypeError):
|
| 915 |
+
pass
|
| 916 |
+
return d
|
| 917 |
+
|
| 918 |
def close(self) -> None:
|
| 919 |
"""Close the database connection."""
|
| 920 |
if self._conn:
|
src/reachy_mini_conversation_app/langgraph_agent/nodes/report_builder.py
CHANGED
|
@@ -10,24 +10,42 @@ from reachy_mini_conversation_app.database import MiniMinderDB
|
|
| 10 |
from reachy_mini_conversation_app.config import DB_PATH
|
| 11 |
|
| 12 |
|
| 13 |
-
|
|
|
|
| 14 |
db = MiniMinderDB(DB_PATH)
|
| 15 |
-
|
| 16 |
-
# 1. Fetch data
|
| 17 |
headaches = db.get_recent_headaches(days=30)
|
| 18 |
medications = db.get_recent_medications(days=30)
|
| 19 |
profile = db.get_or_create_profile()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
|
| 21 |
# 2. Initialize UI Message
|
| 22 |
ai_message = AIMessage(content="I'm preparing your clinical report now...")
|
| 23 |
|
| 24 |
# Initial push of the component
|
| 25 |
-
|
| 26 |
ui_message = push_ui_message(
|
| 27 |
"ReportPreview",
|
| 28 |
{
|
| 29 |
-
"id":
|
| 30 |
-
"title":
|
| 31 |
"status": "loading",
|
| 32 |
"content": "",
|
| 33 |
"doctorName": profile.get("neurologist_name"),
|
|
@@ -38,9 +56,6 @@ async def report_builder_node(state: Dict[str, Any]):
|
|
| 38 |
ui_id = ui_message["id"]
|
| 39 |
|
| 40 |
# 3. Stream content
|
| 41 |
-
# In a real app, you might use an LLM here to summarize.
|
| 42 |
-
# For now, we'll build it procedurally to demonstrate the streaming pattern.
|
| 43 |
-
|
| 44 |
contentLines = [
|
| 45 |
"## Clinical Summary",
|
| 46 |
f"Period: {datetime.now().strftime('%B %Y')}",
|
|
@@ -64,14 +79,26 @@ async def report_builder_node(state: Dict[str, Any]):
|
|
| 64 |
)
|
| 65 |
await asyncio.sleep(0.5) # Simulate processing/streaming
|
| 66 |
|
| 67 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
push_ui_message(
|
| 69 |
"ReportPreview",
|
| 70 |
-
{"status": "complete"},
|
| 71 |
id=ui_id,
|
| 72 |
message=ai_message,
|
| 73 |
merge=True,
|
| 74 |
)
|
| 75 |
|
| 76 |
-
db.close()
|
| 77 |
return {"messages": [ai_message]}
|
|
|
|
| 10 |
from reachy_mini_conversation_app.config import DB_PATH
|
| 11 |
|
| 12 |
|
| 13 |
+
def _fetch_report_data():
|
| 14 |
+
"""Synchronous DB access — runs in a thread to avoid blocking the event loop."""
|
| 15 |
db = MiniMinderDB(DB_PATH)
|
|
|
|
|
|
|
| 16 |
headaches = db.get_recent_headaches(days=30)
|
| 17 |
medications = db.get_recent_medications(days=30)
|
| 18 |
profile = db.get_or_create_profile()
|
| 19 |
+
db.close()
|
| 20 |
+
return headaches, medications, profile
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def _save_report(
|
| 24 |
+
report_type: str, title: str, content: str, metadata: dict | None = None
|
| 25 |
+
) -> int:
|
| 26 |
+
"""Save a generated report to SQLite — runs in a thread."""
|
| 27 |
+
db = MiniMinderDB(DB_PATH)
|
| 28 |
+
report_id = db.save_report(report_type, title, content, metadata)
|
| 29 |
+
db.close()
|
| 30 |
+
return report_id
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
async def report_builder_node(state: Dict[str, Any]):
|
| 34 |
+
headaches, medications, profile = await asyncio.to_thread(_fetch_report_data)
|
| 35 |
+
|
| 36 |
+
display_name = profile.get("display_name", "User")
|
| 37 |
+
title = f"Health Report for {display_name}"
|
| 38 |
|
| 39 |
# 2. Initialize UI Message
|
| 40 |
ai_message = AIMessage(content="I'm preparing your clinical report now...")
|
| 41 |
|
| 42 |
# Initial push of the component
|
| 43 |
+
ui_report_id = str(uuid.uuid4())
|
| 44 |
ui_message = push_ui_message(
|
| 45 |
"ReportPreview",
|
| 46 |
{
|
| 47 |
+
"id": ui_report_id,
|
| 48 |
+
"title": title,
|
| 49 |
"status": "loading",
|
| 50 |
"content": "",
|
| 51 |
"doctorName": profile.get("neurologist_name"),
|
|
|
|
| 56 |
ui_id = ui_message["id"]
|
| 57 |
|
| 58 |
# 3. Stream content
|
|
|
|
|
|
|
|
|
|
| 59 |
contentLines = [
|
| 60 |
"## Clinical Summary",
|
| 61 |
f"Period: {datetime.now().strftime('%B %Y')}",
|
|
|
|
| 79 |
)
|
| 80 |
await asyncio.sleep(0.5) # Simulate processing/streaming
|
| 81 |
|
| 82 |
+
# 4. Auto-save report to database
|
| 83 |
+
saved_id = await asyncio.to_thread(
|
| 84 |
+
_save_report,
|
| 85 |
+
"clinical",
|
| 86 |
+
title,
|
| 87 |
+
accumulated_content,
|
| 88 |
+
{
|
| 89 |
+
"doctorName": profile.get("neurologist_name"),
|
| 90 |
+
"doctorEmail": profile.get("neurologist_email"),
|
| 91 |
+
"period": datetime.now().strftime("%B %Y"),
|
| 92 |
+
},
|
| 93 |
+
)
|
| 94 |
+
|
| 95 |
+
# Final update with saved id
|
| 96 |
push_ui_message(
|
| 97 |
"ReportPreview",
|
| 98 |
+
{"status": "complete", "savedId": saved_id},
|
| 99 |
id=ui_id,
|
| 100 |
message=ai_message,
|
| 101 |
merge=True,
|
| 102 |
)
|
| 103 |
|
|
|
|
| 104 |
return {"messages": [ai_message]}
|
src/reachy_mini_conversation_app/langgraph_agent/nodes/session_summary_node.py
CHANGED
|
@@ -4,6 +4,7 @@ Generates a summary of the current session's logged medications,
|
|
| 4 |
headaches, and questions for provider.
|
| 5 |
"""
|
| 6 |
|
|
|
|
| 7 |
import uuid
|
| 8 |
from datetime import datetime
|
| 9 |
from typing import Dict, Any
|
|
@@ -16,18 +17,24 @@ from reachy_mini_conversation_app.config import DB_PATH
|
|
| 16 |
from reachy_mini_conversation_app.stream_api import get_current_session_id
|
| 17 |
|
| 18 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
async def session_summary_node(state: Dict[str, Any]):
|
| 20 |
"""Build and emit a SessionSummary GenUI component.
|
| 21 |
|
| 22 |
Gathers data from today's entries and emits the SessionSummary component.
|
| 23 |
"""
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
# Get today's data
|
| 27 |
-
today_start = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
|
| 28 |
|
| 29 |
# Get today's medications
|
| 30 |
-
all_meds = db.get_recent_medications(days=1)
|
| 31 |
medications_logged = [
|
| 32 |
{
|
| 33 |
"name": m.get("medication_name", "Unknown"),
|
|
@@ -37,15 +44,11 @@ async def session_summary_node(state: Dict[str, Any]):
|
|
| 37 |
]
|
| 38 |
|
| 39 |
# Get today's headaches
|
| 40 |
-
all_headaches = db.get_recent_headaches(days=1)
|
| 41 |
headaches_logged = [
|
| 42 |
{"onset": h.get("onset_time", "Unknown"), "intensity": h.get("intensity")}
|
| 43 |
for h in all_headaches
|
| 44 |
]
|
| 45 |
|
| 46 |
-
# Get profile for context
|
| 47 |
-
profile = db.get_or_create_profile()
|
| 48 |
-
|
| 49 |
# Build AI message
|
| 50 |
ai_message = AIMessage(content="Here's a summary of our session...")
|
| 51 |
|
|
@@ -71,7 +74,6 @@ async def session_summary_node(state: Dict[str, Any]):
|
|
| 71 |
message=ai_message,
|
| 72 |
)
|
| 73 |
|
| 74 |
-
db.close()
|
| 75 |
return {"messages": [ai_message]}
|
| 76 |
|
| 77 |
|
|
|
|
| 4 |
headaches, and questions for provider.
|
| 5 |
"""
|
| 6 |
|
| 7 |
+
import asyncio
|
| 8 |
import uuid
|
| 9 |
from datetime import datetime
|
| 10 |
from typing import Dict, Any
|
|
|
|
| 17 |
from reachy_mini_conversation_app.stream_api import get_current_session_id
|
| 18 |
|
| 19 |
|
| 20 |
+
def _fetch_session_data():
|
| 21 |
+
"""Synchronous DB access — runs in a thread to avoid blocking the event loop."""
|
| 22 |
+
db = MiniMinderDB(DB_PATH)
|
| 23 |
+
all_meds = db.get_recent_medications(days=1)
|
| 24 |
+
all_headaches = db.get_recent_headaches(days=1)
|
| 25 |
+
profile = db.get_or_create_profile()
|
| 26 |
+
db.close()
|
| 27 |
+
return all_meds, all_headaches, profile
|
| 28 |
+
|
| 29 |
+
|
| 30 |
async def session_summary_node(state: Dict[str, Any]):
|
| 31 |
"""Build and emit a SessionSummary GenUI component.
|
| 32 |
|
| 33 |
Gathers data from today's entries and emits the SessionSummary component.
|
| 34 |
"""
|
| 35 |
+
all_meds, all_headaches, profile = await asyncio.to_thread(_fetch_session_data)
|
|
|
|
|
|
|
|
|
|
| 36 |
|
| 37 |
# Get today's medications
|
|
|
|
| 38 |
medications_logged = [
|
| 39 |
{
|
| 40 |
"name": m.get("medication_name", "Unknown"),
|
|
|
|
| 44 |
]
|
| 45 |
|
| 46 |
# Get today's headaches
|
|
|
|
| 47 |
headaches_logged = [
|
| 48 |
{"onset": h.get("onset_time", "Unknown"), "intensity": h.get("intensity")}
|
| 49 |
for h in all_headaches
|
| 50 |
]
|
| 51 |
|
|
|
|
|
|
|
|
|
|
| 52 |
# Build AI message
|
| 53 |
ai_message = AIMessage(content="Here's a summary of our session...")
|
| 54 |
|
|
|
|
| 74 |
message=ai_message,
|
| 75 |
)
|
| 76 |
|
|
|
|
| 77 |
return {"messages": [ai_message]}
|
| 78 |
|
| 79 |
|
src/reachy_mini_conversation_app/langgraph_agent/nodes/trend_analyzer.py
CHANGED
|
@@ -1,18 +1,24 @@
|
|
|
|
|
| 1 |
from typing import Dict, Any
|
| 2 |
from langchain_core.messages import AIMessage
|
| 3 |
from langgraph.graph.ui import push_ui_message
|
| 4 |
-
from datetime import datetime
|
| 5 |
from collections import Counter
|
| 6 |
|
| 7 |
from reachy_mini_conversation_app.database import MiniMinderDB
|
| 8 |
from reachy_mini_conversation_app.config import DB_PATH
|
| 9 |
|
| 10 |
|
| 11 |
-
|
|
|
|
| 12 |
db = MiniMinderDB(DB_PATH)
|
| 13 |
-
|
| 14 |
-
# 1. Fetch data for last 30 days
|
| 15 |
headaches = db.get_recent_headaches(days=30)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
|
| 17 |
# 2. Analyze trends
|
| 18 |
# Count headaches per day of week
|
|
@@ -21,7 +27,7 @@ async def trend_analyzer_node(state: Dict[str, Any]):
|
|
| 21 |
try:
|
| 22 |
dt = datetime.fromisoformat(h["created_at"])
|
| 23 |
days_of_week.append(dt.strftime("%A"))
|
| 24 |
-
except:
|
| 25 |
continue
|
| 26 |
|
| 27 |
counts = Counter(days_of_week)
|
|
@@ -57,5 +63,4 @@ async def trend_analyzer_node(state: Dict[str, Any]):
|
|
| 57 |
message=ai_message,
|
| 58 |
)
|
| 59 |
|
| 60 |
-
db.close()
|
| 61 |
return {"messages": [ai_message]}
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
from typing import Dict, Any
|
| 3 |
from langchain_core.messages import AIMessage
|
| 4 |
from langgraph.graph.ui import push_ui_message
|
| 5 |
+
from datetime import datetime
|
| 6 |
from collections import Counter
|
| 7 |
|
| 8 |
from reachy_mini_conversation_app.database import MiniMinderDB
|
| 9 |
from reachy_mini_conversation_app.config import DB_PATH
|
| 10 |
|
| 11 |
|
| 12 |
+
def _fetch_trend_data():
|
| 13 |
+
"""Synchronous DB access — runs in a thread to avoid blocking the event loop."""
|
| 14 |
db = MiniMinderDB(DB_PATH)
|
|
|
|
|
|
|
| 15 |
headaches = db.get_recent_headaches(days=30)
|
| 16 |
+
db.close()
|
| 17 |
+
return headaches
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
async def trend_analyzer_node(state: Dict[str, Any]):
|
| 21 |
+
headaches = await asyncio.to_thread(_fetch_trend_data)
|
| 22 |
|
| 23 |
# 2. Analyze trends
|
| 24 |
# Count headaches per day of week
|
|
|
|
| 27 |
try:
|
| 28 |
dt = datetime.fromisoformat(h["created_at"])
|
| 29 |
days_of_week.append(dt.strftime("%A"))
|
| 30 |
+
except Exception:
|
| 31 |
continue
|
| 32 |
|
| 33 |
counts = Counter(days_of_week)
|
|
|
|
| 63 |
message=ai_message,
|
| 64 |
)
|
| 65 |
|
|
|
|
| 66 |
return {"messages": [ai_message]}
|
src/reachy_mini_conversation_app/langgraph_agent/ui.tsx
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* LangGraph GenUI Component Map (Stub)
|
| 3 |
+
*
|
| 4 |
+
* This file declares the UI components emitted by the report_agent graph.
|
| 5 |
+
* Components are rendered client-side via the frontend registry
|
| 6 |
+
* (frontend/src/registry/index.tsx), NOT via LangSmith bundling.
|
| 7 |
+
*
|
| 8 |
+
* This stub exists so that:
|
| 9 |
+
* 1. langgraph.json has a valid `ui` entry (good hygiene / docs contract)
|
| 10 |
+
* 2. Future migration to LoadExternalComponent has a starting point
|
| 11 |
+
*
|
| 12 |
+
* The component names here MUST match the first argument to push_ui_message()
|
| 13 |
+
* in the graph nodes (report_builder.py, trend_analyzer.py, session_summary_node.py).
|
| 14 |
+
*/
|
| 15 |
+
|
| 16 |
+
export default function () {
|
| 17 |
+
// Placeholder — real components live in frontend/src/registry/
|
| 18 |
+
return null;
|
| 19 |
+
}
|
src/reachy_mini_conversation_app/openai_realtime.py
CHANGED
|
@@ -554,8 +554,11 @@ class OpenaiRealtimeHandler(RealtimeHandler):
|
|
| 554 |
],
|
| 555 |
},
|
| 556 |
)
|
| 557 |
-
# Phase 1: force speech only —
|
| 558 |
-
|
|
|
|
|
|
|
|
|
|
| 559 |
logger.info(
|
| 560 |
"Proactive greeting triggered (total startup: %.0fms)",
|
| 561 |
(time.monotonic() - t_connect) * 1000,
|
|
@@ -634,23 +637,44 @@ class OpenaiRealtimeHandler(RealtimeHandler):
|
|
| 634 |
logger.info(
|
| 635 |
"Phase 2: greeting done, triggering onboarding flow"
|
| 636 |
)
|
| 637 |
-
|
| 638 |
-
item
|
| 639 |
-
|
| 640 |
-
|
| 641 |
-
|
| 642 |
-
|
| 643 |
-
|
| 644 |
-
|
| 645 |
-
"
|
| 646 |
-
|
| 647 |
-
|
| 648 |
-
|
| 649 |
-
|
| 650 |
-
|
| 651 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 652 |
)
|
| 653 |
-
await conn.response.create()
|
| 654 |
else:
|
| 655 |
logger.debug("Response done (no response object)")
|
| 656 |
|
|
@@ -839,7 +863,6 @@ class OpenaiRealtimeHandler(RealtimeHandler):
|
|
| 839 |
)
|
| 840 |
# Tools that gather info — LLM should chain to next tool
|
| 841 |
_ONBOARDING_CHAIN_TOOLS = {
|
| 842 |
-
"get_onboarding_status",
|
| 843 |
"get_current_datetime",
|
| 844 |
}
|
| 845 |
# Determine if we need to force speech (no more tools)
|
|
@@ -852,8 +875,23 @@ class OpenaiRealtimeHandler(RealtimeHandler):
|
|
| 852 |
"Follow your system prompt instructions and speak directly to the user. "
|
| 853 |
"You MUST respond in British English only."
|
| 854 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 855 |
elif tool_name in _ONBOARDING_CHAIN_TOOLS:
|
| 856 |
-
# After getting
|
| 857 |
resp_instructions = (
|
| 858 |
"Continue following the Onboarding Flow from your system prompt. "
|
| 859 |
"Use the tool result to decide what to do next. "
|
|
@@ -880,7 +918,10 @@ class OpenaiRealtimeHandler(RealtimeHandler):
|
|
| 880 |
"instructions": resp_instructions,
|
| 881 |
}
|
| 882 |
if force_speech:
|
| 883 |
-
# API-level constraint: prevent tool calls entirely
|
|
|
|
|
|
|
|
|
|
| 884 |
resp_config["tool_choice"] = "none"
|
| 885 |
await self.connection.response.create(
|
| 886 |
response=resp_config,
|
|
|
|
| 554 |
],
|
| 555 |
},
|
| 556 |
)
|
| 557 |
+
# Phase 1: force speech only — remove ALL tools so the
|
| 558 |
+
# model has no choice but to produce audio output.
|
| 559 |
+
await conn.response.create(
|
| 560 |
+
response={"tools": [], "tool_choice": "none"}
|
| 561 |
+
)
|
| 562 |
logger.info(
|
| 563 |
"Proactive greeting triggered (total startup: %.0fms)",
|
| 564 |
(time.monotonic() - t_connect) * 1000,
|
|
|
|
| 637 |
logger.info(
|
| 638 |
"Phase 2: greeting done, triggering onboarding flow"
|
| 639 |
)
|
| 640 |
+
try:
|
| 641 |
+
await conn.conversation.item.create(
|
| 642 |
+
item={
|
| 643 |
+
"type": "message",
|
| 644 |
+
"role": "user",
|
| 645 |
+
"content": [
|
| 646 |
+
{
|
| 647 |
+
"type": "input_text",
|
| 648 |
+
"text": (
|
| 649 |
+
"[SYSTEM] Now begin the onboarding flow. "
|
| 650 |
+
"Call get_onboarding_status to check progress, "
|
| 651 |
+
"then follow your Onboarding Flow instructions."
|
| 652 |
+
),
|
| 653 |
+
}
|
| 654 |
+
],
|
| 655 |
+
},
|
| 656 |
+
)
|
| 657 |
+
phase2_tools = tools or get_tool_specs()
|
| 658 |
+
logger.info(
|
| 659 |
+
"Phase 2: sending response.create with %d tools, forcing get_onboarding_status",
|
| 660 |
+
len(phase2_tools),
|
| 661 |
+
)
|
| 662 |
+
await conn.response.create(
|
| 663 |
+
response={
|
| 664 |
+
"tools": phase2_tools,
|
| 665 |
+
"tool_choice": "required",
|
| 666 |
+
}
|
| 667 |
+
)
|
| 668 |
+
logger.info(
|
| 669 |
+
"Phase 2: response.create sent successfully"
|
| 670 |
+
)
|
| 671 |
+
except Exception as e:
|
| 672 |
+
logger.error("Phase 2 FAILED: %s", e, exc_info=True)
|
| 673 |
+
else:
|
| 674 |
+
logger.info(
|
| 675 |
+
"response.done: no onboarding followup needed (flag=%s)",
|
| 676 |
+
self._needs_onboarding_followup,
|
| 677 |
)
|
|
|
|
| 678 |
else:
|
| 679 |
logger.debug("Response done (no response object)")
|
| 680 |
|
|
|
|
| 863 |
)
|
| 864 |
# Tools that gather info — LLM should chain to next tool
|
| 865 |
_ONBOARDING_CHAIN_TOOLS = {
|
|
|
|
| 866 |
"get_current_datetime",
|
| 867 |
}
|
| 868 |
# Determine if we need to force speech (no more tools)
|
|
|
|
| 875 |
"Follow your system prompt instructions and speak directly to the user. "
|
| 876 |
"You MUST respond in British English only."
|
| 877 |
)
|
| 878 |
+
elif tool_name == "get_onboarding_status":
|
| 879 |
+
# The UI has already been shown by the tool.
|
| 880 |
+
# Let the model continue the onboarding flow —
|
| 881 |
+
# it needs tools (show_onboarding_step, etc.)
|
| 882 |
+
# so do NOT strip them with force_speech.
|
| 883 |
+
resp_instructions = (
|
| 884 |
+
"The onboarding status has been checked and the progress UI "
|
| 885 |
+
"is already shown on screen. Now continue the onboarding flow. "
|
| 886 |
+
"Follow the Onboarding Steps from your system prompt. "
|
| 887 |
+
"If on the 'welcome' step, warmly introduce yourself as Mini-Minder, "
|
| 888 |
+
"the user's health companion, then immediately proceed to the 'name' step: "
|
| 889 |
+
"call show_onboarding_step(current_step='name') and ask 'What should I call you?'. "
|
| 890 |
+
"You MUST speak out loud AND call the appropriate tools. "
|
| 891 |
+
"You MUST respond in British English only."
|
| 892 |
+
)
|
| 893 |
elif tool_name in _ONBOARDING_CHAIN_TOOLS:
|
| 894 |
+
# After getting datetime, call the next tool
|
| 895 |
resp_instructions = (
|
| 896 |
"Continue following the Onboarding Flow from your system prompt. "
|
| 897 |
"Use the tool result to decide what to do next. "
|
|
|
|
| 918 |
"instructions": resp_instructions,
|
| 919 |
}
|
| 920 |
if force_speech:
|
| 921 |
+
# API-level constraint: prevent tool calls entirely.
|
| 922 |
+
# tools=[] removes all tools; tool_choice="none"
|
| 923 |
+
# is a redundant safety net.
|
| 924 |
+
resp_config["tools"] = []
|
| 925 |
resp_config["tool_choice"] = "none"
|
| 926 |
await self.connection.response.create(
|
| 927 |
response=resp_config,
|
src/reachy_mini_conversation_app/profiles/_reachy_mini_minder_locked_profile/complete_onboarding.py
CHANGED
|
@@ -116,13 +116,23 @@ class CompleteOnboarding(Tool):
|
|
| 116 |
},
|
| 117 |
)
|
| 118 |
|
| 119 |
-
# Auto-dismiss the summary overlay after
|
| 120 |
# returns to the dashboard once the robot finishes speaking.
|
| 121 |
async def _delayed_dismiss() -> None:
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 126 |
|
| 127 |
return {
|
| 128 |
"status": "completed",
|
|
@@ -131,3 +141,7 @@ class CompleteOnboarding(Tool):
|
|
| 131 |
"neurologist_configured": neurologist_email is not None,
|
| 132 |
"caregiver_configured": caregiver_email is not None,
|
| 133 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 116 |
},
|
| 117 |
)
|
| 118 |
|
| 119 |
+
# Auto-dismiss the summary overlay after a few seconds so the user
|
| 120 |
# returns to the dashboard once the robot finishes speaking.
|
| 121 |
async def _delayed_dismiss() -> None:
|
| 122 |
+
try:
|
| 123 |
+
await asyncio.sleep(5)
|
| 124 |
+
logger.info("Auto-dismissing onboarding overlay")
|
| 125 |
+
await emit_ui_dismiss()
|
| 126 |
+
# Kick the dashboard to refresh now that the overlay is gone
|
| 127 |
+
await emit_dashboard_updated("onboarding_overlay_dismissed")
|
| 128 |
+
except Exception:
|
| 129 |
+
logger.exception("Failed to auto-dismiss onboarding overlay")
|
| 130 |
+
finally:
|
| 131 |
+
_background_tasks.discard(task)
|
| 132 |
+
|
| 133 |
+
task = asyncio.create_task(_delayed_dismiss())
|
| 134 |
+
# prevent garbage collection of the fire-and-forget task
|
| 135 |
+
_background_tasks.add(task)
|
| 136 |
|
| 137 |
return {
|
| 138 |
"status": "completed",
|
|
|
|
| 141 |
"neurologist_configured": neurologist_email is not None,
|
| 142 |
"caregiver_configured": caregiver_email is not None,
|
| 143 |
}
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
# prevent GC on fire-and-forget tasks
|
| 147 |
+
_background_tasks: set[asyncio.Task[None]] = set()
|
src/reachy_mini_conversation_app/profiles/_reachy_mini_minder_locked_profile/get_onboarding_status.py
CHANGED
|
@@ -4,6 +4,10 @@ import logging
|
|
| 4 |
from typing import Any, Dict
|
| 5 |
|
| 6 |
from reachy_mini_conversation_app.tools.core_tools import Tool, ToolDependencies
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
|
| 8 |
|
| 9 |
logger = logging.getLogger(__name__)
|
|
@@ -24,7 +28,7 @@ class GetOnboardingStatus(Tool):
|
|
| 24 |
}
|
| 25 |
|
| 26 |
async def __call__(self, deps: ToolDependencies, **kwargs: Any) -> Dict[str, Any]:
|
| 27 |
-
"""Get onboarding status."""
|
| 28 |
logger.info("Tool call: get_onboarding_status")
|
| 29 |
|
| 30 |
if deps.database is None:
|
|
@@ -33,9 +37,25 @@ class GetOnboardingStatus(Tool):
|
|
| 33 |
profile = deps.database.get_or_create_profile()
|
| 34 |
medications = deps.database.get_scheduled_medications()
|
| 35 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
return {
|
| 37 |
-
"onboarding_completed":
|
| 38 |
-
"onboarding_step":
|
| 39 |
"display_name": profile.get("display_name"),
|
| 40 |
"medications_count": len(medications),
|
| 41 |
}
|
|
|
|
| 4 |
from typing import Any, Dict
|
| 5 |
|
| 6 |
from reachy_mini_conversation_app.tools.core_tools import Tool, ToolDependencies
|
| 7 |
+
from reachy_mini_conversation_app.stream_api import (
|
| 8 |
+
emit_ui_component,
|
| 9 |
+
emit_dashboard_updated,
|
| 10 |
+
)
|
| 11 |
|
| 12 |
|
| 13 |
logger = logging.getLogger(__name__)
|
|
|
|
| 28 |
}
|
| 29 |
|
| 30 |
async def __call__(self, deps: ToolDependencies, **kwargs: Any) -> Dict[str, Any]:
|
| 31 |
+
"""Get onboarding status and auto-show the onboarding UI if incomplete."""
|
| 32 |
logger.info("Tool call: get_onboarding_status")
|
| 33 |
|
| 34 |
if deps.database is None:
|
|
|
|
| 37 |
profile = deps.database.get_or_create_profile()
|
| 38 |
medications = deps.database.get_scheduled_medications()
|
| 39 |
|
| 40 |
+
completed = bool(profile.get("onboarding_completed", 0))
|
| 41 |
+
current_step = profile.get("onboarding_step", "welcome")
|
| 42 |
+
|
| 43 |
+
# Following the headache-logging pattern: emit the UI directly
|
| 44 |
+
# so the model doesn't need to call show_onboarding_step separately.
|
| 45 |
+
if not completed:
|
| 46 |
+
await emit_ui_component(
|
| 47 |
+
"OnboardingProgress",
|
| 48 |
+
{"currentStep": current_step},
|
| 49 |
+
)
|
| 50 |
+
await emit_dashboard_updated("onboarding_status_checked")
|
| 51 |
+
logger.info(
|
| 52 |
+
"Onboarding incomplete — auto-emitted OnboardingProgress (step=%s)",
|
| 53 |
+
current_step,
|
| 54 |
+
)
|
| 55 |
+
|
| 56 |
return {
|
| 57 |
+
"onboarding_completed": completed,
|
| 58 |
+
"onboarding_step": current_step,
|
| 59 |
"display_name": profile.get("display_name"),
|
| 60 |
"medications_count": len(medications),
|
| 61 |
}
|
src/reachy_mini_conversation_app/profiles/_reachy_mini_minder_locked_profile/setup_medication.py
CHANGED
|
@@ -87,11 +87,14 @@ class SetupMedication(Tool):
|
|
| 87 |
"notes": notes,
|
| 88 |
}
|
| 89 |
|
| 90 |
-
med_id = deps.database.
|
| 91 |
|
| 92 |
# Get current medication count for the index
|
| 93 |
medications = deps.database.get_scheduled_medications()
|
| 94 |
-
index =
|
|
|
|
|
|
|
|
|
|
| 95 |
|
| 96 |
# Emit GenUI component to show the medication card
|
| 97 |
await emit_ui_component(
|
|
@@ -101,7 +104,7 @@ class SetupMedication(Tool):
|
|
| 101 |
"dose": dose,
|
| 102 |
"frequency": frequency,
|
| 103 |
"timesOfDay": times_of_day,
|
| 104 |
-
"isNew":
|
| 105 |
"index": index,
|
| 106 |
},
|
| 107 |
)
|
|
@@ -110,7 +113,7 @@ class SetupMedication(Tool):
|
|
| 110 |
await emit_dashboard_updated("medication_setup")
|
| 111 |
|
| 112 |
return {
|
| 113 |
-
"status": "added",
|
| 114 |
"id": med_id,
|
| 115 |
"medication_name": medication_name,
|
| 116 |
"dose": dose,
|
|
|
|
| 87 |
"notes": notes,
|
| 88 |
}
|
| 89 |
|
| 90 |
+
med_id, is_new = deps.database.upsert_scheduled_medication(data)
|
| 91 |
|
| 92 |
# Get current medication count for the index
|
| 93 |
medications = deps.database.get_scheduled_medications()
|
| 94 |
+
index = next(
|
| 95 |
+
(i for i, m in enumerate(medications) if m["id"] == med_id),
|
| 96 |
+
len(medications) - 1,
|
| 97 |
+
)
|
| 98 |
|
| 99 |
# Emit GenUI component to show the medication card
|
| 100 |
await emit_ui_component(
|
|
|
|
| 104 |
"dose": dose,
|
| 105 |
"frequency": frequency,
|
| 106 |
"timesOfDay": times_of_day,
|
| 107 |
+
"isNew": is_new,
|
| 108 |
"index": index,
|
| 109 |
},
|
| 110 |
)
|
|
|
|
| 113 |
await emit_dashboard_updated("medication_setup")
|
| 114 |
|
| 115 |
return {
|
| 116 |
+
"status": "added" if is_new else "updated",
|
| 117 |
"id": med_id,
|
| 118 |
"medication_name": medication_name,
|
| 119 |
"dose": dose,
|
src/reachy_mini_conversation_app/profiles/_reachy_mini_minder_locked_profile/show_onboarding_step.py
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 1 |
"""Tool to display the onboarding progress UI component."""
|
| 2 |
|
| 3 |
import logging
|
| 4 |
-
from typing import Any, Dict, Literal
|
| 5 |
|
| 6 |
from reachy_mini_conversation_app.tools.core_tools import Tool, ToolDependencies
|
| 7 |
from reachy_mini_conversation_app.stream_api import (
|
|
@@ -22,7 +22,8 @@ class ShowOnboardingStep(Tool):
|
|
| 22 |
description = (
|
| 23 |
"Display or update the onboarding progress indicator to show the user which "
|
| 24 |
"step of the setup process they are on. Call this when transitioning between "
|
| 25 |
-
"onboarding steps to give visual feedback."
|
|
|
|
| 26 |
)
|
| 27 |
parameters_schema = {
|
| 28 |
"type": "object",
|
|
@@ -35,6 +36,13 @@ class ShowOnboardingStep(Tool):
|
|
| 35 |
"'contacts', or 'complete'."
|
| 36 |
),
|
| 37 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
},
|
| 39 |
"required": ["current_step"],
|
| 40 |
}
|
|
@@ -43,13 +51,20 @@ class ShowOnboardingStep(Tool):
|
|
| 43 |
self,
|
| 44 |
deps: ToolDependencies,
|
| 45 |
current_step: OnboardingStep | None = None,
|
|
|
|
| 46 |
**kwargs: Any,
|
| 47 |
) -> Dict[str, Any]:
|
| 48 |
"""Emit the OnboardingProgress component."""
|
| 49 |
# LLM sometimes sends "step" instead of "current_step"
|
| 50 |
if current_step is None:
|
| 51 |
current_step = kwargs.get("step", "welcome")
|
| 52 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
|
| 54 |
# Emit the GenUI component
|
| 55 |
await emit_ui_component(
|
|
@@ -57,9 +72,12 @@ class ShowOnboardingStep(Tool):
|
|
| 57 |
{"currentStep": current_step},
|
| 58 |
)
|
| 59 |
|
| 60 |
-
# Update the onboarding step in the database
|
| 61 |
if deps.database is not None:
|
| 62 |
-
|
|
|
|
|
|
|
|
|
|
| 63 |
await emit_dashboard_updated("onboarding_step_changed")
|
| 64 |
|
| 65 |
return {
|
|
|
|
| 1 |
"""Tool to display the onboarding progress UI component."""
|
| 2 |
|
| 3 |
import logging
|
| 4 |
+
from typing import Any, Dict, Literal, Optional
|
| 5 |
|
| 6 |
from reachy_mini_conversation_app.tools.core_tools import Tool, ToolDependencies
|
| 7 |
from reachy_mini_conversation_app.stream_api import (
|
|
|
|
| 22 |
description = (
|
| 23 |
"Display or update the onboarding progress indicator to show the user which "
|
| 24 |
"step of the setup process they are on. Call this when transitioning between "
|
| 25 |
+
"onboarding steps to give visual feedback. If you have just learned the user's "
|
| 26 |
+
"name, pass it as display_name so it is saved immediately."
|
| 27 |
)
|
| 28 |
parameters_schema = {
|
| 29 |
"type": "object",
|
|
|
|
| 36 |
"'contacts', or 'complete'."
|
| 37 |
),
|
| 38 |
},
|
| 39 |
+
"display_name": {
|
| 40 |
+
"type": "string",
|
| 41 |
+
"description": (
|
| 42 |
+
"The user's preferred name, if just collected. "
|
| 43 |
+
"Pass this when advancing past the 'name' step."
|
| 44 |
+
),
|
| 45 |
+
},
|
| 46 |
},
|
| 47 |
"required": ["current_step"],
|
| 48 |
}
|
|
|
|
| 51 |
self,
|
| 52 |
deps: ToolDependencies,
|
| 53 |
current_step: OnboardingStep | None = None,
|
| 54 |
+
display_name: Optional[str] = None,
|
| 55 |
**kwargs: Any,
|
| 56 |
) -> Dict[str, Any]:
|
| 57 |
"""Emit the OnboardingProgress component."""
|
| 58 |
# LLM sometimes sends "step" instead of "current_step"
|
| 59 |
if current_step is None:
|
| 60 |
current_step = kwargs.get("step", "welcome")
|
| 61 |
+
if display_name is None:
|
| 62 |
+
display_name = kwargs.get("name")
|
| 63 |
+
logger.info(
|
| 64 |
+
"Tool call: show_onboarding_step (step=%s, name=%s)",
|
| 65 |
+
current_step,
|
| 66 |
+
display_name,
|
| 67 |
+
)
|
| 68 |
|
| 69 |
# Emit the GenUI component
|
| 70 |
await emit_ui_component(
|
|
|
|
| 72 |
{"currentStep": current_step},
|
| 73 |
)
|
| 74 |
|
| 75 |
+
# Update the onboarding step (and name if provided) in the database
|
| 76 |
if deps.database is not None:
|
| 77 |
+
updates: Dict[str, Any] = {"onboarding_step": current_step}
|
| 78 |
+
if display_name:
|
| 79 |
+
updates["display_name"] = display_name
|
| 80 |
+
deps.database.update_profile(updates)
|
| 81 |
await emit_dashboard_updated("onboarding_step_changed")
|
| 82 |
|
| 83 |
return {
|
src/reachy_mini_conversation_app/profiles/_reachy_mini_minder_locked_profile/update_settings.py
CHANGED
|
@@ -6,6 +6,7 @@ from typing import Any, Dict, Optional
|
|
| 6 |
from reachy_mini_conversation_app.tools.core_tools import Tool, ToolDependencies
|
| 7 |
from reachy_mini_conversation_app.stream_api import (
|
| 8 |
emit_settings_updated,
|
|
|
|
| 9 |
emit_ui_component,
|
| 10 |
)
|
| 11 |
|
|
@@ -111,8 +112,9 @@ class UpdateSettings(Tool):
|
|
| 111 |
# Persist to database (copy to avoid update_profile mutating with updated_at)
|
| 112 |
deps.database.update_profile(dict(updates))
|
| 113 |
|
| 114 |
-
# Broadcast refresh
|
| 115 |
await emit_settings_updated(list(updates.keys()))
|
|
|
|
| 116 |
|
| 117 |
# Build human-readable changes list for GenUI confirmation
|
| 118 |
field_labels = {
|
|
|
|
| 6 |
from reachy_mini_conversation_app.tools.core_tools import Tool, ToolDependencies
|
| 7 |
from reachy_mini_conversation_app.stream_api import (
|
| 8 |
emit_settings_updated,
|
| 9 |
+
emit_dashboard_updated,
|
| 10 |
emit_ui_component,
|
| 11 |
)
|
| 12 |
|
|
|
|
| 112 |
# Persist to database (copy to avoid update_profile mutating with updated_at)
|
| 113 |
deps.database.update_profile(dict(updates))
|
| 114 |
|
| 115 |
+
# Broadcast refresh events so SettingsPanel AND Dashboard refetch
|
| 116 |
await emit_settings_updated(list(updates.keys()))
|
| 117 |
+
await emit_dashboard_updated("settings_updated")
|
| 118 |
|
| 119 |
# Build human-readable changes list for GenUI confirmation
|
| 120 |
field_labels = {
|
src/reachy_mini_conversation_app/prompts/sections/onboarding.txt
CHANGED
|
@@ -33,7 +33,7 @@ At the very start of each session:
|
|
| 33 |
### Step 3: Medications
|
| 34 |
- CALL `show_onboarding_step(current_step="medications")`
|
| 35 |
- Say: "Would you like to set up your regular medications now? You can always add more later."
|
| 36 |
-
- If YES:
|
| 37 |
- If NO/SKIP: Proceed to Step 4.
|
| 38 |
|
| 39 |
### Step 4: Contacts
|
|
|
|
| 33 |
### Step 3: Medications
|
| 34 |
- CALL `show_onboarding_step(current_step="medications")`
|
| 35 |
- Say: "Would you like to set up your regular medications now? You can always add more later."
|
| 36 |
+
- If YES: For each medication, gather ALL details (name, dose, frequency, times of day) through conversation BEFORE calling `setup_medication` once with all the information. Do NOT call `setup_medication` until you have collected all available details for that medication. After confirming, ask "Any others?" and repeat until done.
|
| 37 |
- If NO/SKIP: Proceed to Step 4.
|
| 38 |
|
| 39 |
### Step 4: Contacts
|
src/reachy_mini_conversation_app/stream_api.py
CHANGED
|
@@ -894,6 +894,7 @@ async def dashboard_stats() -> dict[str, Any]:
|
|
| 894 |
"taken": taken_count,
|
| 895 |
"total": total_count,
|
| 896 |
"ad_hoc": ad_hoc,
|
|
|
|
| 897 |
},
|
| 898 |
"days_active_this_week": days_active,
|
| 899 |
"headaches": headache_by_day,
|
|
@@ -952,3 +953,32 @@ async def reset_database() -> dict[str, Any]:
|
|
| 952 |
"status": "not_found",
|
| 953 |
"message": "No database file found to delete.",
|
| 954 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 894 |
"taken": taken_count,
|
| 895 |
"total": total_count,
|
| 896 |
"ad_hoc": ad_hoc,
|
| 897 |
+
"scheduled": scheduled,
|
| 898 |
},
|
| 899 |
"days_active_this_week": days_active,
|
| 900 |
"headaches": headache_by_day,
|
|
|
|
| 953 |
"status": "not_found",
|
| 954 |
"message": "No database file found to delete.",
|
| 955 |
}
|
| 956 |
+
|
| 957 |
+
|
| 958 |
+
@router.get("/reports")
|
| 959 |
+
async def list_reports(limit: int = 20) -> list[dict[str, Any]]:
|
| 960 |
+
"""Return saved reports, newest first."""
|
| 961 |
+
from pathlib import Path
|
| 962 |
+
from reachy_mini_conversation_app.config import DB_PATH
|
| 963 |
+
|
| 964 |
+
db = MiniMinderDB(Path.cwd() / DB_PATH.name)
|
| 965 |
+
try:
|
| 966 |
+
return db.get_reports(limit=limit)
|
| 967 |
+
finally:
|
| 968 |
+
db.close()
|
| 969 |
+
|
| 970 |
+
|
| 971 |
+
@router.get("/reports/{report_id}")
|
| 972 |
+
async def get_report(report_id: int) -> dict[str, Any]:
|
| 973 |
+
"""Return a single saved report by id."""
|
| 974 |
+
from pathlib import Path
|
| 975 |
+
from reachy_mini_conversation_app.config import DB_PATH
|
| 976 |
+
|
| 977 |
+
db = MiniMinderDB(Path.cwd() / DB_PATH.name)
|
| 978 |
+
try:
|
| 979 |
+
result = db.get_report(report_id)
|
| 980 |
+
if result is None:
|
| 981 |
+
return {"error": "Report not found"}
|
| 982 |
+
return result
|
| 983 |
+
finally:
|
| 984 |
+
db.close()
|
tests/test_database_onboarding.py
CHANGED
|
@@ -160,6 +160,65 @@ def test_update_nonexistent_medication(db: MiniMinderDB) -> None:
|
|
| 160 |
assert success is False
|
| 161 |
|
| 162 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 163 |
def test_schema_includes_new_tables(db: MiniMinderDB) -> None:
|
| 164 |
"""The new tables should exist."""
|
| 165 |
conn = db._get_conn()
|
|
|
|
| 160 |
assert success is False
|
| 161 |
|
| 162 |
|
| 163 |
+
# -----------------------------------------------------------------------------
|
| 164 |
+
# Upsert Scheduled Medication Tests
|
| 165 |
+
# -----------------------------------------------------------------------------
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
def test_upsert_scheduled_medication_new(db: MiniMinderDB) -> None:
|
| 169 |
+
"""Upsert should create a new row when no match exists."""
|
| 170 |
+
med_id, is_new = db.upsert_scheduled_medication(
|
| 171 |
+
{"medication_name": "Aspirin", "dose": "100mg"}
|
| 172 |
+
)
|
| 173 |
+
assert is_new is True
|
| 174 |
+
assert med_id >= 1
|
| 175 |
+
|
| 176 |
+
meds = db.get_scheduled_medications()
|
| 177 |
+
assert len(meds) == 1
|
| 178 |
+
assert meds[0]["medication_name"] == "Aspirin"
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
def test_upsert_scheduled_medication_existing(db: MiniMinderDB) -> None:
|
| 182 |
+
"""Upsert should update existing row and not create a duplicate."""
|
| 183 |
+
# First call: creates the row
|
| 184 |
+
med_id_1, is_new_1 = db.upsert_scheduled_medication(
|
| 185 |
+
{"medication_name": "Amitriptyline"}
|
| 186 |
+
)
|
| 187 |
+
assert is_new_1 is True
|
| 188 |
+
|
| 189 |
+
# Second call: same name, adds dose — should update
|
| 190 |
+
med_id_2, is_new_2 = db.upsert_scheduled_medication(
|
| 191 |
+
{"medication_name": "Amitriptyline", "dose": "10mg"}
|
| 192 |
+
)
|
| 193 |
+
assert is_new_2 is False
|
| 194 |
+
assert med_id_2 == med_id_1
|
| 195 |
+
|
| 196 |
+
# Third call: same name, adds frequency — should update again
|
| 197 |
+
med_id_3, is_new_3 = db.upsert_scheduled_medication(
|
| 198 |
+
{"medication_name": "Amitriptyline", "dose": "10mg", "frequency": "once daily"}
|
| 199 |
+
)
|
| 200 |
+
assert is_new_3 is False
|
| 201 |
+
assert med_id_3 == med_id_1
|
| 202 |
+
|
| 203 |
+
# Only one row should exist with all the merged data
|
| 204 |
+
meds = db.get_scheduled_medications()
|
| 205 |
+
assert len(meds) == 1
|
| 206 |
+
assert meds[0]["dose"] == "10mg"
|
| 207 |
+
assert meds[0]["frequency"] == "once daily"
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
def test_upsert_case_insensitive(db: MiniMinderDB) -> None:
|
| 211 |
+
"""Upsert should match medication names case-insensitively."""
|
| 212 |
+
db.upsert_scheduled_medication({"medication_name": "Aspirin"})
|
| 213 |
+
med_id, is_new = db.upsert_scheduled_medication(
|
| 214 |
+
{"medication_name": "aspirin", "dose": "200mg"}
|
| 215 |
+
)
|
| 216 |
+
assert is_new is False
|
| 217 |
+
|
| 218 |
+
meds = db.get_scheduled_medications()
|
| 219 |
+
assert len(meds) == 1
|
| 220 |
+
|
| 221 |
+
|
| 222 |
def test_schema_includes_new_tables(db: MiniMinderDB) -> None:
|
| 223 |
"""The new tables should exist."""
|
| 224 |
conn = db._get_conn()
|
tests/test_onboarding_tools.py
CHANGED
|
@@ -274,6 +274,45 @@ async def test_full_onboarding_workflow(deps: ToolDependencies) -> None:
|
|
| 274 |
assert status["medications_count"] == 2
|
| 275 |
|
| 276 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 277 |
@pytest.mark.asyncio
|
| 278 |
async def test_all_tool_specs() -> None:
|
| 279 |
"""All onboarding tools should have valid specs."""
|
|
|
|
| 274 |
assert status["medications_count"] == 2
|
| 275 |
|
| 276 |
|
| 277 |
+
@pytest.mark.asyncio
|
| 278 |
+
async def test_setup_medication_progressive_update(deps: ToolDependencies) -> None:
|
| 279 |
+
"""Simulates the three-stage onboarding pattern: name → dose → schedule.
|
| 280 |
+
|
| 281 |
+
The LLM may call setup_medication progressively as it gathers info.
|
| 282 |
+
The upsert ensures only one row exists with all merged data.
|
| 283 |
+
"""
|
| 284 |
+
tool = SetupMedication()
|
| 285 |
+
|
| 286 |
+
# Stage 1: LLM only knows the name
|
| 287 |
+
r1 = await tool(deps, medication_name="Amitriptyline")
|
| 288 |
+
assert r1["status"] == "added"
|
| 289 |
+
first_id = r1["id"]
|
| 290 |
+
|
| 291 |
+
# Stage 2: LLM now has dose
|
| 292 |
+
r2 = await tool(deps, medication_name="Amitriptyline", dose="10 micrograms")
|
| 293 |
+
assert r2["status"] == "updated"
|
| 294 |
+
assert r2["id"] == first_id
|
| 295 |
+
|
| 296 |
+
# Stage 3: LLM now has frequency and time
|
| 297 |
+
r3 = await tool(
|
| 298 |
+
deps,
|
| 299 |
+
medication_name="Amitriptyline",
|
| 300 |
+
dose="10 micrograms",
|
| 301 |
+
frequency="once daily",
|
| 302 |
+
times_of_day=["16:00"],
|
| 303 |
+
)
|
| 304 |
+
assert r3["status"] == "updated"
|
| 305 |
+
assert r3["id"] == first_id
|
| 306 |
+
|
| 307 |
+
# Only 1 medication row should exist
|
| 308 |
+
meds = deps.database.get_scheduled_medications()
|
| 309 |
+
assert len(meds) == 1
|
| 310 |
+
assert meds[0]["medication_name"] == "Amitriptyline"
|
| 311 |
+
assert meds[0]["dose"] == "10 micrograms"
|
| 312 |
+
assert meds[0]["frequency"] == "once daily"
|
| 313 |
+
assert meds[0]["times_of_day"] == ["16:00"]
|
| 314 |
+
|
| 315 |
+
|
| 316 |
@pytest.mark.asyncio
|
| 317 |
async def test_all_tool_specs() -> None:
|
| 318 |
"""All onboarding tools should have valid specs."""
|
uv.lock
CHANGED
|
@@ -3294,6 +3294,40 @@ wheels = [
|
|
| 3294 |
{ url = "https://files.pythonhosted.org/packages/a2/eb/86626c1bbc2edb86323022371c39aa48df6fd8b0a1647bc274577f72e90b/nvidia_nvtx_cu12-12.8.90-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5b17e2001cc0d751a5bc2c6ec6d26ad95913324a4adb86788c944f8ce9ba441f", size = 89954, upload-time = "2025-03-07T01:42:44.131Z" },
|
| 3295 |
]
|
| 3296 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3297 |
[[package]]
|
| 3298 |
name = "openai"
|
| 3299 |
version = "2.16.0"
|
|
@@ -3349,6 +3383,25 @@ wheels = [
|
|
| 3349 |
{ url = "https://files.pythonhosted.org/packages/e9/a5/1be1516390333ff9be3a9cb648c9f33df79d5096e5884b5df71a588af463/opencv_python-4.13.0.92-cp37-abi3-win_amd64.whl", hash = "sha256:423d934c9fafb91aad38edf26efb46da91ffbc05f3f59c4b0c72e699720706f5", size = 40212062, upload-time = "2026-02-05T07:02:12.724Z" },
|
| 3350 |
]
|
| 3351 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3352 |
[[package]]
|
| 3353 |
name = "opt-einsum"
|
| 3354 |
version = "3.4.0"
|
|
@@ -4702,8 +4755,6 @@ dependencies = [
|
|
| 4702 |
{ name = "eclipse-zenoh" },
|
| 4703 |
{ name = "fastrtc" },
|
| 4704 |
{ name = "google-genai" },
|
| 4705 |
-
{ name = "gradio" },
|
| 4706 |
-
{ name = "gradio-client" },
|
| 4707 |
{ name = "huggingface-hub" },
|
| 4708 |
{ name = "langchain-google-genai" },
|
| 4709 |
{ name = "langchain-openai" },
|
|
@@ -4711,6 +4762,7 @@ dependencies = [
|
|
| 4711 |
{ name = "neo4j" },
|
| 4712 |
{ name = "openai" },
|
| 4713 |
{ name = "opencv-python" },
|
|
|
|
| 4714 |
{ name = "pygame" },
|
| 4715 |
{ name = "python-dotenv" },
|
| 4716 |
{ name = "reachy-mini" },
|
|
@@ -4766,8 +4818,6 @@ requires-dist = [
|
|
| 4766 |
{ name = "eclipse-zenoh", specifier = "~=1.7.0" },
|
| 4767 |
{ name = "fastrtc", specifier = ">=0.0.34" },
|
| 4768 |
{ name = "google-genai", specifier = ">=1.14" },
|
| 4769 |
-
{ name = "gradio", specifier = "==5.50.1.dev1" },
|
| 4770 |
-
{ name = "gradio-client", specifier = ">=1.13.3" },
|
| 4771 |
{ name = "gst-signalling", marker = "extra == 'reachy-mini-wireless'", specifier = ">=1.1.2" },
|
| 4772 |
{ name = "huggingface-hub", specifier = "==1.3.0" },
|
| 4773 |
{ name = "langchain-google-genai" },
|
|
@@ -4781,6 +4831,7 @@ requires-dist = [
|
|
| 4781 |
{ name = "num2words", marker = "extra == 'local-vision'" },
|
| 4782 |
{ name = "openai", specifier = ">=2.1" },
|
| 4783 |
{ name = "opencv-python", specifier = ">=4.12.0.88" },
|
|
|
|
| 4784 |
{ name = "presidio-analyzer", marker = "extra == 'memory'" },
|
| 4785 |
{ name = "presidio-anonymizer", marker = "extra == 'memory'" },
|
| 4786 |
{ name = "pygame", specifier = ">=2.6.1" },
|
|
@@ -5883,6 +5934,22 @@ wheels = [
|
|
| 5883 |
{ url = "https://files.pythonhosted.org/packages/64/6b/cdc85edb15e384d8e934aad89638cc8646e118c80de94c60125d0fc0a185/tenacity-9.1.3-py3-none-any.whl", hash = "sha256:51171cfc6b8a7826551e2f029426b10a6af189c5ac6986adcd7eb36d42f17954", size = 28858, upload-time = "2026-02-05T06:33:11.219Z" },
|
| 5884 |
]
|
| 5885 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5886 |
[[package]]
|
| 5887 |
name = "thinc"
|
| 5888 |
version = "8.3.10"
|
|
@@ -6163,6 +6230,10 @@ dependencies = [
|
|
| 6163 |
{ name = "typing-extensions" },
|
| 6164 |
]
|
| 6165 |
wheels = [
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6166 |
{ url = "https://files.pythonhosted.org/packages/0c/1a/c61f36cfd446170ec27b3a4984f072fd06dab6b5d7ce27e11adb35d6c838/torch-2.10.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:5276fa790a666ee8becaffff8acb711922252521b28fbce5db7db5cf9cb2026d", size = 145992962, upload-time = "2026-01-21T16:24:14.04Z" },
|
| 6167 |
{ url = "https://files.pythonhosted.org/packages/b5/60/6662535354191e2d1555296045b63e4279e5a9dbad49acf55a5d38655a39/torch-2.10.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:aaf663927bcd490ae971469a624c322202a2a1e68936eb952535ca4cd3b90444", size = 915599237, upload-time = "2026-01-21T16:23:25.497Z" },
|
| 6168 |
{ url = "https://files.pythonhosted.org/packages/40/b8/66bbe96f0d79be2b5c697b2e0b187ed792a15c6c4b8904613454651db848/torch-2.10.0-cp310-cp310-win_amd64.whl", hash = "sha256:a4be6a2a190b32ff5c8002a0977a25ea60e64f7ba46b1be37093c141d9c49aeb", size = 113720931, upload-time = "2026-01-21T16:24:23.743Z" },
|
|
|
|
| 3294 |
{ url = "https://files.pythonhosted.org/packages/a2/eb/86626c1bbc2edb86323022371c39aa48df6fd8b0a1647bc274577f72e90b/nvidia_nvtx_cu12-12.8.90-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5b17e2001cc0d751a5bc2c6ec6d26ad95913324a4adb86788c944f8ce9ba441f", size = 89954, upload-time = "2025-03-07T01:42:44.131Z" },
|
| 3295 |
]
|
| 3296 |
|
| 3297 |
+
[[package]]
|
| 3298 |
+
name = "onnxruntime"
|
| 3299 |
+
version = "1.24.1"
|
| 3300 |
+
source = { registry = "https://pypi.org/simple" }
|
| 3301 |
+
dependencies = [
|
| 3302 |
+
{ name = "flatbuffers" },
|
| 3303 |
+
{ name = "numpy" },
|
| 3304 |
+
{ name = "packaging" },
|
| 3305 |
+
{ name = "protobuf" },
|
| 3306 |
+
{ name = "sympy" },
|
| 3307 |
+
]
|
| 3308 |
+
wheels = [
|
| 3309 |
+
{ url = "https://files.pythonhosted.org/packages/d2/88/d9757c62a0f96b5193f8d447a141eefd14498c404cc5caf1a6f3233cf102/onnxruntime-1.24.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:79b3119ab9f4f3817062e6dbe7f4a44937de93905e3a31ba34313d18cb49e7be", size = 17212018, upload-time = "2026-02-05T17:32:13.986Z" },
|
| 3310 |
+
{ url = "https://files.pythonhosted.org/packages/7b/61/b3305c39144e19dbe8791802076b29b4b592b09de03d0e340c1314bfd408/onnxruntime-1.24.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:86bc43e922b1f581b3de26a3dc402149c70e5542fceb5bec6b3a85542dbeb164", size = 15018703, upload-time = "2026-02-05T17:30:53.846Z" },
|
| 3311 |
+
{ url = "https://files.pythonhosted.org/packages/94/d6/d273b75fe7825ea3feed321dd540aef33d8a1380ddd8ac3bb70a8ed000fe/onnxruntime-1.24.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1cabe71ca14dcfbf812d312aab0a704507ac909c137ee6e89e4908755d0fc60e", size = 17096352, upload-time = "2026-02-05T17:31:29.057Z" },
|
| 3312 |
+
{ url = "https://files.pythonhosted.org/packages/21/3f/0616101a3938bfe2918ea60b581a9bbba61ffc255c63388abb0885f7ce18/onnxruntime-1.24.1-cp311-cp311-win_amd64.whl", hash = "sha256:3273c330f5802b64b4103e87b5bbc334c0355fff1b8935d8910b0004ce2f20c8", size = 12493235, upload-time = "2026-02-05T17:32:04.451Z" },
|
| 3313 |
+
{ url = "https://files.pythonhosted.org/packages/c8/30/437de870e4e1c6d237a2ca5e11f54153531270cb5c745c475d6e3d5c5dcf/onnxruntime-1.24.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:7307aab9e2e879c0171f37e0eb2808a5b4aec7ba899bb17c5f0cedfc301a8ac2", size = 17211043, upload-time = "2026-02-05T17:32:16.909Z" },
|
| 3314 |
+
{ url = "https://files.pythonhosted.org/packages/21/60/004401cd86525101ad8aa9eec301327426555d7a77fac89fd991c3c7aae6/onnxruntime-1.24.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:780add442ce2d4175fafb6f3102cdc94243acffa3ab16eacc03dd627cc7b1b54", size = 15016224, upload-time = "2026-02-05T17:30:56.791Z" },
|
| 3315 |
+
{ url = "https://files.pythonhosted.org/packages/7d/a1/43ad01b806a1821d1d6f98725edffcdbad54856775643718e9124a09bfbe/onnxruntime-1.24.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34b6119526eda12613f0d0498e2ae59563c247c370c9cef74c2fc93133dde157", size = 17098191, upload-time = "2026-02-05T17:31:31.87Z" },
|
| 3316 |
+
{ url = "https://files.pythonhosted.org/packages/ff/37/5beb65270864037d5c8fb25cfe6b23c48b618d1f4d06022d425cbf29bd9c/onnxruntime-1.24.1-cp312-cp312-win_amd64.whl", hash = "sha256:df0af2f1cfcfff9094971c7eb1d1dfae7ccf81af197493c4dc4643e4342c0946", size = 12493108, upload-time = "2026-02-05T17:32:07.076Z" },
|
| 3317 |
+
{ url = "https://files.pythonhosted.org/packages/95/77/7172ecfcbdabd92f338e694f38c325f6fab29a38fa0a8c3d1c85b9f4617c/onnxruntime-1.24.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:82e367770e8fba8a87ba9f4c04bb527e6d4d7204540f1390f202c27a3b759fb4", size = 17211381, upload-time = "2026-02-05T17:31:09.601Z" },
|
| 3318 |
+
{ url = "https://files.pythonhosted.org/packages/79/5b/532a0d75b93bbd0da0e108b986097ebe164b84fbecfdf2ddbf7c8a3a2e83/onnxruntime-1.24.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1099f3629832580fedf415cfce2462a56cc9ca2b560d6300c24558e2ac049134", size = 15016000, upload-time = "2026-02-05T17:31:00.116Z" },
|
| 3319 |
+
{ url = "https://files.pythonhosted.org/packages/f6/b5/40606c7bce0702975a077bc6668cd072cd77695fc5c0b3fcf59bdb1fe65e/onnxruntime-1.24.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6361dda4270f3939a625670bd67ae0982a49b7f923207450e28433abc9c3a83b", size = 17097637, upload-time = "2026-02-05T17:31:34.787Z" },
|
| 3320 |
+
{ url = "https://files.pythonhosted.org/packages/d5/a0/9e8f7933796b466241b934585723c700d8fb6bde2de856e65335193d7c93/onnxruntime-1.24.1-cp313-cp313-win_amd64.whl", hash = "sha256:bd1e4aefe73b6b99aa303cd72562ab6de3cccb09088100f8ad1c974be13079c7", size = 12492467, upload-time = "2026-02-05T17:32:09.834Z" },
|
| 3321 |
+
{ url = "https://files.pythonhosted.org/packages/fb/8a/ee07d86e35035f9fed42497af76435f5a613d4e8b6c537ea0f8ef9fa85da/onnxruntime-1.24.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:88a2b54dca00c90fca6303eedf13d49b5b4191d031372c2e85f5cffe4d86b79e", size = 15025407, upload-time = "2026-02-05T17:31:02.251Z" },
|
| 3322 |
+
{ url = "https://files.pythonhosted.org/packages/fd/9e/ab3e1dda4b126313d240e1aaa87792ddb1f5ba6d03ca2f093a7c4af8c323/onnxruntime-1.24.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2dfbba602da840615ed5b431facda4b3a43b5d8276cf9e0dbf13d842df105838", size = 17099810, upload-time = "2026-02-05T17:31:37.537Z" },
|
| 3323 |
+
{ url = "https://files.pythonhosted.org/packages/87/23/167d964414cee2af9c72af323b28d2c4cb35beed855c830a23f198265c79/onnxruntime-1.24.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:890c503ca187bc883c3aa72c53f2a604ec8e8444bdd1bf6ac243ec6d5e085202", size = 17214004, upload-time = "2026-02-05T17:31:11.917Z" },
|
| 3324 |
+
{ url = "https://files.pythonhosted.org/packages/b4/24/6e5558fdd51027d6830cf411bc003ae12c64054826382e2fab89e99486a0/onnxruntime-1.24.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4da1b84b3bdeec543120df169e5e62a1445bf732fc2c7fb036c2f8a4090455e8", size = 15017034, upload-time = "2026-02-05T17:31:04.331Z" },
|
| 3325 |
+
{ url = "https://files.pythonhosted.org/packages/91/d4/3cb1c9eaae1103265ed7eb00a3eaeb0d9ba51dc88edc398b7071c9553bed/onnxruntime-1.24.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:557753ec345efa227c6a65139f3d29c76330fcbd54cc10dd1b64232ebb939c13", size = 17097531, upload-time = "2026-02-05T17:31:40.303Z" },
|
| 3326 |
+
{ url = "https://files.pythonhosted.org/packages/0f/da/4522b199c12db7c5b46aaf265ee0d741abe65ea912f6c0aaa2cc18a4654d/onnxruntime-1.24.1-cp314-cp314-win_amd64.whl", hash = "sha256:ea4942104805e868f3ddddfa1fbb58b04503a534d489ab2d1452bbfa345c78c2", size = 12795556, upload-time = "2026-02-05T17:32:11.886Z" },
|
| 3327 |
+
{ url = "https://files.pythonhosted.org/packages/a1/53/3b8969417276b061ff04502ccdca9db4652d397abbeb06c9f6ae05cec9ca/onnxruntime-1.24.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ea8963a99e0f10489acdf00ef3383c3232b7e44aa497b063c63be140530d9f85", size = 15025434, upload-time = "2026-02-05T17:31:06.942Z" },
|
| 3328 |
+
{ url = "https://files.pythonhosted.org/packages/ab/a2/cfcf009eb38d90cc628c087b6506b3dfe1263387f3cbbf8d272af4fef957/onnxruntime-1.24.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34488aa760fb5c2e6d06a7ca9241124eb914a6a06f70936a14c669d1b3df9598", size = 17099815, upload-time = "2026-02-05T17:31:43.092Z" },
|
| 3329 |
+
]
|
| 3330 |
+
|
| 3331 |
[[package]]
|
| 3332 |
name = "openai"
|
| 3333 |
version = "2.16.0"
|
|
|
|
| 3383 |
{ url = "https://files.pythonhosted.org/packages/e9/a5/1be1516390333ff9be3a9cb648c9f33df79d5096e5884b5df71a588af463/opencv_python-4.13.0.92-cp37-abi3-win_amd64.whl", hash = "sha256:423d934c9fafb91aad38edf26efb46da91ffbc05f3f59c4b0c72e699720706f5", size = 40212062, upload-time = "2026-02-05T07:02:12.724Z" },
|
| 3384 |
]
|
| 3385 |
|
| 3386 |
+
[[package]]
|
| 3387 |
+
name = "openwakeword"
|
| 3388 |
+
version = "0.6.0"
|
| 3389 |
+
source = { registry = "https://pypi.org/simple" }
|
| 3390 |
+
dependencies = [
|
| 3391 |
+
{ name = "onnxruntime" },
|
| 3392 |
+
{ name = "requests" },
|
| 3393 |
+
{ name = "scikit-learn", version = "1.7.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
|
| 3394 |
+
{ name = "scikit-learn", version = "1.8.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
|
| 3395 |
+
{ name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
|
| 3396 |
+
{ name = "scipy", version = "1.17.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
|
| 3397 |
+
{ name = "tflite-runtime", marker = "sys_platform == 'linux'" },
|
| 3398 |
+
{ name = "tqdm" },
|
| 3399 |
+
]
|
| 3400 |
+
sdist = { url = "https://files.pythonhosted.org/packages/b5/9b/73b7d98b07f4e1f525ad39703e0c5f30ff61c3fa16c8bfe4d99eadc0567a/openwakeword-0.6.0.tar.gz", hash = "sha256:36858d90f1183e307485597a912a4e3c3384b14ea9923f83feaffae7c1565565", size = 70830, upload-time = "2024-02-11T20:56:17.854Z" }
|
| 3401 |
+
wheels = [
|
| 3402 |
+
{ url = "https://files.pythonhosted.org/packages/8a/33/dafd6822bebe463a9098951d06a0d88fb4f8c946ce087025bc4fa132e533/openwakeword-0.6.0-py3-none-any.whl", hash = "sha256:6f423a4e3ae9dd0e3cd12b50ff8abf69679f687b4ab349d7c82c021c0e2abc9d", size = 60690, upload-time = "2024-02-11T20:56:16.179Z" },
|
| 3403 |
+
]
|
| 3404 |
+
|
| 3405 |
[[package]]
|
| 3406 |
name = "opt-einsum"
|
| 3407 |
version = "3.4.0"
|
|
|
|
| 4755 |
{ name = "eclipse-zenoh" },
|
| 4756 |
{ name = "fastrtc" },
|
| 4757 |
{ name = "google-genai" },
|
|
|
|
|
|
|
| 4758 |
{ name = "huggingface-hub" },
|
| 4759 |
{ name = "langchain-google-genai" },
|
| 4760 |
{ name = "langchain-openai" },
|
|
|
|
| 4762 |
{ name = "neo4j" },
|
| 4763 |
{ name = "openai" },
|
| 4764 |
{ name = "opencv-python" },
|
| 4765 |
+
{ name = "openwakeword" },
|
| 4766 |
{ name = "pygame" },
|
| 4767 |
{ name = "python-dotenv" },
|
| 4768 |
{ name = "reachy-mini" },
|
|
|
|
| 4818 |
{ name = "eclipse-zenoh", specifier = "~=1.7.0" },
|
| 4819 |
{ name = "fastrtc", specifier = ">=0.0.34" },
|
| 4820 |
{ name = "google-genai", specifier = ">=1.14" },
|
|
|
|
|
|
|
| 4821 |
{ name = "gst-signalling", marker = "extra == 'reachy-mini-wireless'", specifier = ">=1.1.2" },
|
| 4822 |
{ name = "huggingface-hub", specifier = "==1.3.0" },
|
| 4823 |
{ name = "langchain-google-genai" },
|
|
|
|
| 4831 |
{ name = "num2words", marker = "extra == 'local-vision'" },
|
| 4832 |
{ name = "openai", specifier = ">=2.1" },
|
| 4833 |
{ name = "opencv-python", specifier = ">=4.12.0.88" },
|
| 4834 |
+
{ name = "openwakeword", specifier = ">=0.6" },
|
| 4835 |
{ name = "presidio-analyzer", marker = "extra == 'memory'" },
|
| 4836 |
{ name = "presidio-anonymizer", marker = "extra == 'memory'" },
|
| 4837 |
{ name = "pygame", specifier = ">=2.6.1" },
|
|
|
|
| 5934 |
{ url = "https://files.pythonhosted.org/packages/64/6b/cdc85edb15e384d8e934aad89638cc8646e118c80de94c60125d0fc0a185/tenacity-9.1.3-py3-none-any.whl", hash = "sha256:51171cfc6b8a7826551e2f029426b10a6af189c5ac6986adcd7eb36d42f17954", size = 28858, upload-time = "2026-02-05T06:33:11.219Z" },
|
| 5935 |
]
|
| 5936 |
|
| 5937 |
+
[[package]]
|
| 5938 |
+
name = "tflite-runtime"
|
| 5939 |
+
version = "2.14.0"
|
| 5940 |
+
source = { registry = "https://pypi.org/simple" }
|
| 5941 |
+
dependencies = [
|
| 5942 |
+
{ name = "numpy", marker = "sys_platform != 'win32'" },
|
| 5943 |
+
]
|
| 5944 |
+
wheels = [
|
| 5945 |
+
{ url = "https://files.pythonhosted.org/packages/9e/1f/aade0d066bacbe697946ae21f0467a702d81adb939bb64515e9abebae9ed/tflite_runtime-2.14.0-cp310-cp310-manylinux2014_x86_64.whl", hash = "sha256:bb11df4283e281cd609c621ac9470ad0cb5674408593272d7593a2c6bde8a808", size = 2413436, upload-time = "2023-10-03T21:15:38.025Z" },
|
| 5946 |
+
{ url = "https://files.pythonhosted.org/packages/04/dd/a701667be92bb884644eeec91ebbde57e197d27d9a79241169b6ce15e1ad/tflite_runtime-2.14.0-cp310-cp310-manylinux_2_34_aarch64.whl", hash = "sha256:d38c6885f5e9673c11a61ccec5cad7c032ab97340718d26b17794137f398b780", size = 2324943, upload-time = "2023-10-03T21:15:40.253Z" },
|
| 5947 |
+
{ url = "https://files.pythonhosted.org/packages/d0/33/ad39ee3706a67139dde4624e66a2dd65604cc800dbb861f719f7cb1f1edc/tflite_runtime-2.14.0-cp310-cp310-manylinux_2_34_armv7l.whl", hash = "sha256:7fe33f763263d1ff2733a09945a7547ab063d8bc311fd2a1be8144d850016ad3", size = 1818760, upload-time = "2023-10-03T21:15:42.321Z" },
|
| 5948 |
+
{ url = "https://files.pythonhosted.org/packages/8f/a6/02d68cb62cd221589a0ff055073251d883936237c9c990e34a1d7cecd06f/tflite_runtime-2.14.0-cp311-cp311-manylinux2014_x86_64.whl", hash = "sha256:195ab752e7e57329a68e54dd3dd5439fad888b9bff1be0f0dc042a3237a90e4d", size = 2414486, upload-time = "2023-10-03T21:15:44.331Z" },
|
| 5949 |
+
{ url = "https://files.pythonhosted.org/packages/f2/e9/5fc0435129c23c17551fcfadc82bd0d5482276213dfbc641f07b4420cb6d/tflite_runtime-2.14.0-cp311-cp311-manylinux_2_34_aarch64.whl", hash = "sha256:ce9fa5d770a9725c746dcbf6f59f3178233b3759f09982e8b2db8d2234c333b0", size = 2325913, upload-time = "2023-10-03T21:15:46.348Z" },
|
| 5950 |
+
{ url = "https://files.pythonhosted.org/packages/fb/76/e246c39d92929655bac8878d76406d6fb0293c678237e55621e7ece4a269/tflite_runtime-2.14.0-cp311-cp311-manylinux_2_34_armv7l.whl", hash = "sha256:c4e66a74165b18089c86788400af19fa551768ac782d231a9beae2f6434f7949", size = 1820588, upload-time = "2023-10-03T21:15:48.399Z" },
|
| 5951 |
+
]
|
| 5952 |
+
|
| 5953 |
[[package]]
|
| 5954 |
name = "thinc"
|
| 5955 |
version = "8.3.10"
|
|
|
|
| 6230 |
{ name = "typing-extensions" },
|
| 6231 |
]
|
| 6232 |
wheels = [
|
| 6233 |
+
{ url = "https://files.pythonhosted.org/packages/e3/ea/304cf7afb744aa626fa9855245526484ee55aba610d9973a0521c552a843/torch-2.10.0-1-cp310-none-macosx_11_0_arm64.whl", hash = "sha256:c37fc46eedd9175f9c81814cc47308f1b42cfe4987e532d4b423d23852f2bf63", size = 79411450, upload-time = "2026-02-06T17:37:35.75Z" },
|
| 6234 |
+
{ url = "https://files.pythonhosted.org/packages/25/d8/9e6b8e7df981a1e3ea3907fd5a74673e791da483e8c307f0b6ff012626d0/torch-2.10.0-1-cp311-none-macosx_11_0_arm64.whl", hash = "sha256:f699f31a236a677b3118bc0a3ef3d89c0c29b5ec0b20f4c4bf0b110378487464", size = 79423460, upload-time = "2026-02-06T17:37:39.657Z" },
|
| 6235 |
+
{ url = "https://files.pythonhosted.org/packages/c9/2f/0b295dd8d199ef71e6f176f576473d645d41357b7b8aa978cc6b042575df/torch-2.10.0-1-cp312-none-macosx_11_0_arm64.whl", hash = "sha256:6abb224c2b6e9e27b592a1c0015c33a504b00a0e0938f1499f7f514e9b7bfb5c", size = 79498197, upload-time = "2026-02-06T17:37:27.627Z" },
|
| 6236 |
+
{ url = "https://files.pythonhosted.org/packages/a4/1b/af5fccb50c341bd69dc016769503cb0857c1423fbe9343410dfeb65240f2/torch-2.10.0-1-cp313-none-macosx_11_0_arm64.whl", hash = "sha256:7350f6652dfd761f11f9ecb590bfe95b573e2961f7a242eccb3c8e78348d26fe", size = 79498248, upload-time = "2026-02-06T17:37:31.982Z" },
|
| 6237 |
{ url = "https://files.pythonhosted.org/packages/0c/1a/c61f36cfd446170ec27b3a4984f072fd06dab6b5d7ce27e11adb35d6c838/torch-2.10.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:5276fa790a666ee8becaffff8acb711922252521b28fbce5db7db5cf9cb2026d", size = 145992962, upload-time = "2026-01-21T16:24:14.04Z" },
|
| 6238 |
{ url = "https://files.pythonhosted.org/packages/b5/60/6662535354191e2d1555296045b63e4279e5a9dbad49acf55a5d38655a39/torch-2.10.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:aaf663927bcd490ae971469a624c322202a2a1e68936eb952535ca4cd3b90444", size = 915599237, upload-time = "2026-01-21T16:23:25.497Z" },
|
| 6239 |
{ url = "https://files.pythonhosted.org/packages/40/b8/66bbe96f0d79be2b5c697b2e0b187ed792a15c6c4b8904613454651db848/torch-2.10.0-cp310-cp310-win_amd64.whl", hash = "sha256:a4be6a2a190b32ff5c8002a0977a25ea60e64f7ba46b1be37093c141d9c49aeb", size = 113720931, upload-time = "2026-01-21T16:24:23.743Z" },
|