File size: 6,864 Bytes
3bbe317 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 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 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 | "use client";
import { useState, useCallback } from "react";
import type { Provider, Preset } from "../types";
import { buildPatientContext, buildMedicineInventoryContext } from "../health-store";
export type ChatMessage = {
id: number;
role: "user" | "ai";
content: string;
timestamp: string;
};
export type SendOptions = {
preset?: Preset;
provider?: Provider;
model?: string;
apiKey?: string;
userHfToken?: string;
context?: {
country: string;
language: string;
emergencyNumber: string;
units?: "metric" | "imperial";
};
};
/**
* Providers that require the user to supply credentials client-side.
* Free presets route via the server's HF_TOKEN, so no key is needed.
*/
const BYO_KEY_PROVIDERS: Provider[] = ["openai", "gemini", "claude"];
export function useChat() {
// Start the thread empty — see web/lib/hooks/useChat.ts for the
// rationale (canned-greeting bubble removed for a more real-time
// voice).
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [isTyping, setIsTyping] = useState(false);
const [error, setError] = useState<string | null>(null);
const sendMessage = useCallback(
async (content: string, options: SendOptions) => {
if (!content.trim()) return;
// Only require an API key for BYO providers used directly (no preset).
if (
!options.preset &&
options.provider &&
BYO_KEY_PROVIDERS.includes(options.provider) &&
!options.apiKey?.trim()
) {
setError("Please add an API key in Settings first.");
return;
}
const timestamp = new Date().toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
});
const userMessage: ChatMessage = {
id: Date.now(),
role: "user",
content: content.trim(),
timestamp,
};
setMessages((prev) => [...prev, userMessage]);
setIsTyping(true);
setError(null);
try {
const response = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
preset: options.preset,
provider: options.provider,
model: options.model,
apiKey: options.apiKey,
userHfToken: options.userHfToken,
context: options.context,
messages: [...messages, userMessage].map((m, i) => ({
role: m.role === "ai" ? "assistant" : "user",
// Inject patient context only on the FIRST user message of
// the conversation — keeps it concise and avoids bloating
// every turn with repeated profile data.
content:
i === 0 && m.role === "user"
? m.content + buildPatientContext() + buildMedicineInventoryContext()
: m.content,
})),
}),
});
if (!response.ok) {
throw new Error(`Request failed: ${response.statusText}`);
}
if (!response.body) {
throw new Error("No response body");
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let aiContent = "";
const aiMessageId = Date.now() + 1;
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value);
const lines = chunk.split("\n");
for (const line of lines) {
if (line.startsWith("data: ")) {
const data = line.slice(6);
if (data === "[DONE]") break;
try {
const parsed = JSON.parse(data);
if (parsed.error) throw new Error(parsed.error);
// Extract content from THREE possible chunk shapes (in
// priority order):
// 1. OpenAI-style stream: { choices: [{ delta: { content }}]}
// ← every card emitter (streamCardChunk) and the
// ← post-LLM-filtered single-chunk reply use this.
// 2. OpenAI non-stream: { choices: [{ message: { content }}]}
// 3. Legacy MedOS: { content: "..." }
//
// The HF Space client previously only checked #3, which
// meant every server chunk (all OpenAI-shaped) was
// silently dropped — aiContent stayed empty and the UI
// showed nothing. Logs showed 200/ok at the API level
// because the failure was 100% on the parse side.
const piece =
(parsed.choices?.[0]?.delta?.content) ||
(parsed.choices?.[0]?.message?.content) ||
parsed.content ||
"";
if (piece) {
aiContent += piece;
setMessages((prev) => {
const existing = prev.find((m) => m.id === aiMessageId);
if (existing) {
return prev.map((m) =>
m.id === aiMessageId ? { ...m, content: aiContent } : m,
);
}
return [
...prev,
{
id: aiMessageId,
role: "ai" as const,
content: aiContent,
timestamp: new Date().toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
}),
},
];
});
}
} catch {
// ignore parse errors on keep-alive / partial frames
}
}
}
}
} catch (err: any) {
const errorMessage =
err?.message || "I'm having trouble reaching the medical AI right now.";
setError(errorMessage);
// Gentle, professional inline message — no "⚠️ Error:" prefix,
// no "check your settings" trailer (the user almost never can
// fix backend availability from settings).
setMessages((prev) => [
...prev,
{
id: Date.now() + 2,
role: "ai",
content: errorMessage,
timestamp: new Date().toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
}),
},
]);
} finally {
setIsTyping(false);
}
},
[messages],
);
const clearMessages = useCallback(() => {
setMessages([]);
setError(null);
}, []);
return {
messages,
isTyping,
error,
sendMessage,
clearMessages,
};
}
|